mirror of https://github.com/nextcloud/server.git
Merge pull request #40749 from nextcloud/refactor/contacts-menu-to-vue
refactor: Contacts menu to Vuepull/40902/head
commit
b914916bdb
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@ -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
|
@ -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}}
|
@ -1,4 +0,0 @@
|
||||
<div class="emptycontent">
|
||||
<div class="icon-search"></div>
|
||||
<h2>{{couldNotLoadText}}</h2>
|
||||
</div>
|
@ -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}}
|
@ -1,4 +0,0 @@
|
||||
<div class="emptycontent">
|
||||
<div class="icon-loading"></div>
|
||||
<h2>{{loadingText}}</h2>
|
||||
</div>
|
@ -1,4 +0,0 @@
|
||||
<label for="contactsmenu-search">{{searchContactsLabel}}</label>
|
||||
<input id="contactsmenu-search" type="search" value="{{searchTerm}}">
|
||||
<div class="content">
|
||||
</div>
|
@ -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>
|
@ -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')
|
||||
})
|
||||
})
|
@ -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 …')
|
||||
})
|
||||
})
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue