refactor: Contacts menu to Vue

Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
pull/40749/head
Christoph Wurst 8 months ago committed by nextcloud-command
parent c932c94fdd
commit 6a375ca161

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

File diff suppressed because one or more lines are too long

@ -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/
*

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

4
dist/core-main.js vendored

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

@ -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");
}

Loading…
Cancel
Save