Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at> Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>tags/v28.0.0beta1
@@ -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> |
@@ -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 |
@@ -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 …') | |||
}) | |||
}) |
@@ -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> |
@@ -58,21 +58,11 @@ | |||
* @license MIT | |||
*/ | |||
/*! | |||
* focus-trap 7.2.0 | |||
* @license MIT, https://github.com/focus-trap/focus-trap/blob/master/LICENSE | |||
*/ | |||
/*! | |||
* focus-trap 7.5.2 | |||
* @license MIT, https://github.com/focus-trap/focus-trap/blob/master/LICENSE | |||
*/ | |||
/*! | |||
* tabbable 6.0.1 | |||
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE | |||
*/ | |||
/*! | |||
* tabbable 6.2.0 | |||
* @license MIT, https://github.com/focus-trap/tabbable/blob/master/LICENSE | |||
@@ -80,14 +70,6 @@ | |||
/*! @license DOMPurify 3.0.5 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.5/LICENSE */ | |||
/*! For license information please see NcButton.js.LICENSE.txt */ | |||
/*! For license information please see NcModal.js.LICENSE.txt */ | |||
/*! For license information please see NcNoteCard.js.LICENSE.txt */ | |||
/*! For license information please see NcPasswordField.js.LICENSE.txt */ | |||
/*! Hammer.JS - v2.0.7 - 2016-04-22 | |||
* http://hammerjs.github.io/ | |||
* |
@@ -39,7 +39,7 @@ class ContactsMenuContext implements Context, ActorAwareInterface { | |||
* @return Locator | |||
*/ | |||
public static function contactsMenu() { | |||
return Locator::forThe()->xpath("//*[@id = 'header']//*[@id = 'contactsmenu']//*[@id = 'contactsmenu-menu']")-> | |||
return Locator::forThe()->xpath("//*[@id = 'header']//*[@id = 'contactsmenu']//*[@class = 'contactsmenu__menu']")-> | |||
describedAs("Contacts menu"); | |||
} | |||
@@ -47,7 +47,7 @@ class ContactsMenuContext implements Context, ActorAwareInterface { | |||
* @return Locator | |||
*/ | |||
public static function contactsMenuSearchInput() { | |||
return Locator::forThe()->id("contactsmenu-search")-> | |||
return Locator::forThe()->id("contactsmenu__menu__search")-> | |||
descendantOf(self::contactsMenu())-> | |||
describedAs("Contacts menu search input"); | |||
} | |||
@@ -56,7 +56,7 @@ class ContactsMenuContext implements Context, ActorAwareInterface { | |||
* @return Locator | |||
*/ | |||
public static function noResultsMessage() { | |||
return Locator::forThe()->xpath("//*[@class = 'emptycontent' and normalize-space() = 'No contacts found']")-> | |||
return Locator::forThe()->xpath("//*[@class = 'empty-content' and normalize-space() = 'No contacts found']")-> | |||
descendantOf(self::contactsMenu())-> | |||
describedAs("No results message in Contacts menu"); | |||
} | |||
@@ -65,7 +65,7 @@ class ContactsMenuContext implements Context, ActorAwareInterface { | |||
* @return Locator | |||
*/ | |||
private static function menuItemFor($contactName) { | |||
return Locator::forThe()->xpath("//*[@class = 'full-name' and normalize-space() = '$contactName']")-> | |||
return Locator::forThe()->xpath("//*[@class = 'contact__body__full-name' and normalize-space() = '$contactName']")-> | |||
descendantOf(self::contactsMenu())-> | |||
describedAs($contactName . " contact in Contacts menu"); | |||
} |