Browse Source

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>
tags/v28.0.0beta1
Christoph Wurst 8 months ago
parent
commit
6a375ca161
41 changed files with 581 additions and 1009 deletions
  1. 0
    265
      core/js/tests/specs/contactsmenuSpec.js
  2. 0
    473
      core/src/OC/contactsmenu.js
  3. 0
    70
      core/src/OC/contactsmenu/contact.handlebars
  4. 0
    4
      core/src/OC/contactsmenu/error.handlebars
  5. 0
    12
      core/src/OC/contactsmenu/list.handlebars
  6. 0
    4
      core/src/OC/contactsmenu/loading.handlebars
  7. 0
    4
      core/src/OC/contactsmenu/menu.handlebars
  8. 0
    2
      core/src/OC/index.js
  9. 191
    0
      core/src/components/ContactsMenu/Contact.vue
  10. 59
    0
      core/src/tests/components/ContactsMenu/Contact.spec.js
  11. 174
    0
      core/src/tests/views/ContactsMenu.spec.js
  12. 113
    113
      core/src/views/ContactsMenu.vue
  13. 2
    2
      dist/core-common.js
  14. 0
    18
      dist/core-common.js.LICENSE.txt
  15. 1
    1
      dist/core-common.js.map
  16. 2
    2
      dist/core-login.js
  17. 1
    1
      dist/core-login.js.map
  18. 2
    2
      dist/core-main.js
  19. 1
    1
      dist/core-main.js.map
  20. 2
    2
      dist/federatedfilesharing-vue-settings-admin.js
  21. 1
    1
      dist/federatedfilesharing-vue-settings-admin.js.map
  22. 1
    1
      dist/preview-service-worker.js
  23. 2
    2
      dist/settings-vue-settings-admin-basic-settings.js
  24. 1
    1
      dist/settings-vue-settings-admin-basic-settings.js.map
  25. 2
    2
      dist/settings-vue-settings-admin-security.js
  26. 1
    1
      dist/settings-vue-settings-admin-security.js.map
  27. 2
    2
      dist/settings-vue-settings-apps-users-management.js
  28. 1
    1
      dist/settings-vue-settings-apps-users-management.js.map
  29. 2
    2
      dist/settings-vue-settings-personal-info.js
  30. 1
    1
      dist/settings-vue-settings-personal-info.js.map
  31. 2
    2
      dist/settings-vue-settings-personal-security.js
  32. 1
    1
      dist/settings-vue-settings-personal-security.js.map
  33. 2
    2
      dist/settings-vue-settings-personal-webauthn.js
  34. 1
    1
      dist/settings-vue-settings-personal-webauthn.js.map
  35. 2
    2
      dist/sharebymail-vue-settings-admin-sharebymail.js
  36. 1
    1
      dist/sharebymail-vue-settings-admin-sharebymail.js.map
  37. 2
    2
      dist/twofactor_backupcodes-settings.js
  38. 1
    1
      dist/twofactor_backupcodes-settings.js.map
  39. 2
    2
      dist/workflowengine-workflowengine.js
  40. 1
    1
      dist/workflowengine-workflowengine.js.map
  41. 4
    4
      tests/acceptance/features/bootstrap/ContactsMenuContext.php

+ 0
- 265
core/js/tests/specs/contactsmenuSpec.js View File

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

});

+ 0
- 473
core/src/OC/contactsmenu.js View File

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

+ 0
- 70
core/src/OC/contactsmenu/contact.handlebars View File

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

+ 0
- 4
core/src/OC/contactsmenu/error.handlebars View File

@@ -1,4 +0,0 @@
<div class="emptycontent">
<div class="icon-search"></div>
<h2>{{couldNotLoadText}}</h2>
</div>

+ 0
- 12
core/src/OC/contactsmenu/list.handlebars View File

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

+ 0
- 4
core/src/OC/contactsmenu/loading.handlebars View File

@@ -1,4 +0,0 @@
<div class="emptycontent">
<div class="icon-loading"></div>
<h2>{{loadingText}}</h2>
</div>

+ 0
- 4
core/src/OC/contactsmenu/menu.handlebars View File

@@ -1,4 +0,0 @@
<label for="contactsmenu-search">{{searchContactsLabel}}</label>
<input id="contactsmenu-search" type="search" value="{{searchTerm}}">
<div class="content">
</div>

+ 0
- 2
core/src/OC/index.js View File

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

+ 191
- 0
core/src/components/ContactsMenu/Contact.vue View File

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

+ 59
- 0
core/src/tests/components/ContactsMenu/Contact.spec.js View File

@@ -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')
})
})

+ 174
- 0
core/src/tests/views/ContactsMenu.spec.js View File

@@ -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 …')
})
})

+ 113
- 113
core/src/views/ContactsMenu.vue View File

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

+ 2
- 2
dist/core-common.js
File diff suppressed because it is too large
View File


+ 0
- 18
dist/core-common.js.LICENSE.txt View File

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

+ 1
- 1
dist/core-common.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/core-login.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/core-login.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/core-main.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/core-main.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/federatedfilesharing-vue-settings-admin.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/federatedfilesharing-vue-settings-admin.js.map
File diff suppressed because it is too large
View File


+ 1
- 1
dist/preview-service-worker.js
File diff suppressed because it is too large
View File


+ 2
- 2
dist/settings-vue-settings-admin-basic-settings.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-admin-basic-settings.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/settings-vue-settings-admin-security.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-admin-security.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/settings-vue-settings-apps-users-management.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-apps-users-management.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/settings-vue-settings-personal-info.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-personal-info.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/settings-vue-settings-personal-security.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-personal-security.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/settings-vue-settings-personal-webauthn.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/settings-vue-settings-personal-webauthn.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/sharebymail-vue-settings-admin-sharebymail.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/sharebymail-vue-settings-admin-sharebymail.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/twofactor_backupcodes-settings.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/twofactor_backupcodes-settings.js.map
File diff suppressed because it is too large
View File


+ 2
- 2
dist/workflowengine-workflowengine.js
File diff suppressed because it is too large
View File


+ 1
- 1
dist/workflowengine-workflowengine.js.map
File diff suppressed because it is too large
View File


+ 4
- 4
tests/acceptance/features/bootstrap/ContactsMenuContext.php View File

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