Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to select contact groups as attendees #4742

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 58 additions & 32 deletions lib/Controller/ContactController.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,12 @@ public function searchAttendee(string $search):JSONResponse {
return new JSONResponse();
}

$result = $this->contactsManager->search($search, ['FN', 'EMAIL']);
$contactsResult = $this->contactsManager->search($search, ['FN', 'EMAIL']);

$groupsContactsResult = $this->contactsManager->search($search, ['CATEGORIES']);

$contacts = [];
foreach ($result as $r) {
foreach ($contactsResult as $r) {
// Information about system users is fetched via DAV nowadays
if (isset($r['isLocalSystemBook']) && $r['isLocalSystemBook']) {
continue;
Expand All @@ -151,43 +153,30 @@ public function searchAttendee(string $search):JSONResponse {
continue;
}

$name = $this->getNameFromContact($r);
if (\is_string($r['EMAIL'])) {
$r['EMAIL'] = [$r['EMAIL']];
}
$contacts[] = $this->processContact($r);
}

$photo = isset($r['PHOTO'])
? $this->getPhotoUri($r['PHOTO'])
: null;
$groupsContacts = array_reduce($groupsContactsResult, function (array $acc, array $groupContact) use ($search) {

$lang = null;
if (isset($r['LANG'])) {
if (\is_array($r['LANG'])) {
$lang = $r['LANG'][0];
} else {
$lang = $r['LANG'];
}
// Information about system users is fetched via DAV nowadays
if (isset($groupContact['isLocalSystemBook']) && $groupContact['isLocalSystemBook']) {
return $acc;
}

$timezoneId = null;
if (isset($r['TZ'])) {
if (\is_array($r['TZ'])) {
$timezoneId = $r['TZ'][0];
} else {
$timezoneId = $r['TZ'];
}
if (!isset($groupContact['EMAIL'])) {
return $acc;
}

$contacts[] = [
'name' => $name,
'emails' => $r['EMAIL'],
'lang' => $lang,
'tzid' => $timezoneId,
'photo' => $photo,
];
}
$categories = array_filter(explode(',', $groupContact['CATEGORIES']), function (string $category) use ($search) {
return str_contains(mb_strtolower($category), mb_strtolower($search));
});
foreach ($categories as $category) {
$acc[$category][] = $this->processContact($groupContact);
}
return $acc;
}, []);

return new JSONResponse($contacts);
return new JSONResponse(['contacts' => $contacts, 'groups' => $groupsContacts]);
}

/**
Expand Down Expand Up @@ -328,4 +317,41 @@ private function getPhotoUri(string $raw):?string {

return null;
}

private function processContact(array $contactData): array {
$name = $this->getNameFromContact($contactData);
if (\is_string($contactData['EMAIL'])) {
$contactData['EMAIL'] = [$contactData['EMAIL']];
}

$photo = isset($contactData['PHOTO'])
? $this->getPhotoUri($contactData['PHOTO'])
: null;

$lang = null;
if (isset($contactData['LANG'])) {
if (\is_array($contactData['LANG'])) {
$lang = $contactData['LANG'][0];
} else {
$lang = $contactData['LANG'];
}
}

$timezoneId = null;
if (isset($contactData['TZ'])) {
if (\is_array($contactData['TZ'])) {
$timezoneId = $contactData['TZ'][0];
} else {
$timezoneId = $contactData['TZ'];
}
}

return [
'name' => $name,
'emails' => $contactData['EMAIL'],
'lang' => $lang,
'tzid' => $timezoneId,
'photo' => $photo,
];
}
}
20 changes: 19 additions & 1 deletion src/components/Editor/Invitees/InviteesListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
class="invitees-list-item__member-count">
({{ $n('calendar', '%n member', '%n members', members.length) }})
</span>
<span v-else class="invitees-list-item__member-count">

Check warning on line 43 in src/components/Editor/Invitees/InviteesListItem.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListItem.vue#L43

Added line #L43 was not covered by tests
({{ attendeeEmail }})
</span>
</div>
<div class="invitees-list-item__actions">
<NcButton v-if="members.length"
Expand All @@ -55,11 +58,16 @@
</template>
</NcButton>
<Actions v-if="isViewedByOrganizer">
<ActionText>
{{ attendeeEmail }}

Check warning on line 62 in src/components/Editor/Invitees/InviteesListItem.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListItem.vue#L61-L62

Added lines #L61 - L62 were not covered by tests
<template #icon>
<Email :size="20" decorative />
</template>
</ActionText>
<ActionCheckbox :checked="attendee.rsvp"
@change="toggleRSVP">
{{ $t('calendar', 'Request reply') }}
</ActionCheckbox>

<ActionRadio :name="radioName"
:checked="isChair"
@change="changeRole('CHAIR')">
Expand Down Expand Up @@ -111,12 +119,14 @@
NcActionRadio as ActionRadio,
NcActionCheckbox as ActionCheckbox,
NcButton,
NcActionText as ActionText,

Check warning on line 122 in src/components/Editor/Invitees/InviteesListItem.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListItem.vue#L122

Added line #L122 was not covered by tests
} from '@nextcloud/vue'
import { removeMailtoPrefix } from '../../../utils/attendee.js'
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'

import Delete from 'vue-material-design-icons/Delete.vue'
import Email from 'vue-material-design-icons/Email.vue'

export default {
name: 'InviteesListItem',
Expand All @@ -125,7 +135,9 @@
ActionButton,
ActionCheckbox,
ActionRadio,
ActionText,
Actions,
Email,

Check warning on line 140 in src/components/Editor/Invitees/InviteesListItem.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListItem.vue#L140

Added line #L140 was not covered by tests
Delete,
NcButton,
ChevronDown,
Expand Down Expand Up @@ -189,6 +201,12 @@

return ''
},
attendeeEmail() {
if (this.attendee.uri) {
return removeMailtoPrefix(this.attendee.uri)
}
return ''
},

Check warning on line 209 in src/components/Editor/Invitees/InviteesListItem.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListItem.vue#L208-L209

Added lines #L208 - L209 were not covered by tests
radioName() {
return this._uid + '-role-radio-input-group'
},
Expand Down
49 changes: 42 additions & 7 deletions src/components/Editor/Invitees/InviteesListSearch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@
@option:selected="addAttendee">
<template #option="option">
<div class="invitees-search-list-item">
<!-- We need to specify a unique key here for the avatar to be reactive. -->
<Avatar v-if="option.isUser"
<Avatar v-if="option.type === 'group'">
<template #icon>
<AccountMultiple :size="20" />

Check warning on line 43 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L42-L43

Added lines #L42 - L43 were not covered by tests
</template>
</Avatar><!-- We need to specify a unique key here for the avatar to be reactive. -->
<Avatar v-else-if="option.isUser"
:key="option.uid"
:user="option.avatar"
:display-name="option.dropdownName" />
Expand All @@ -57,12 +61,15 @@
<div>
{{ option.dropdownName }}
</div>
<div v-if="option.email !== option.dropdownName && option.type !== 'circle'">
<div v-if="option.email !== option.dropdownName && option.type !== 'circle' && option.type !== 'group'">

Check warning on line 64 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L64

Added line #L64 was not covered by tests
{{ option.email }}
</div>
<div v-if="option.type === 'circle'">
{{ option.subtitle }}
</div>
<div v-if="option.type === 'group'">
{{ option.subtitle }}
</div>
</div>
</div>
</template>
Expand All @@ -86,13 +93,15 @@
import { randomId } from '../../../utils/randomId.js'
import GoogleCirclesCommunitiesIcon from 'vue-material-design-icons/GoogleCirclesCommunities.vue'
import { showInfo } from '@nextcloud/dialogs'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'

Check warning on line 96 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L96

Added line #L96 was not covered by tests

export default {
name: 'InviteesListSearch',
components: {
Avatar,
NcSelect,
GoogleCirclesCommunitiesIcon,
AccountMultiple,
},
props: {
alreadyInvitedEmails: {
Expand Down Expand Up @@ -180,8 +189,13 @@
if (selectedValue.type === 'circle') {
showInfo(this.$t('calendar', 'Note that members of circles get invited but are not synced yet.'))
this.resolveCircleMembers(selectedValue.id, selectedValue.email)
} else if (selectedValue.type === 'group') {
selectedValue.contacts.forEach((contact) => {
this.$emit('add-attendee', contact)

Check warning on line 194 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L192-L194

Added lines #L192 - L194 were not covered by tests
})
} else {
this.$emit('add-attendee', selectedValue)

Check warning on line 197 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L196-L197

Added lines #L196 - L197 were not covered by tests
}
this.$emit('add-attendee', selectedValue)
},
async resolveCircleMembers(circleId, groupId) {
let results
Expand All @@ -195,7 +209,7 @@
return []
}
results.data.forEach((member) => {
if (!this.organizer || member.email !== this.organizer.uri) {
if (!this.alreadyInvitedEmails.includes(member.email) && (!this.organizer || member.email !== this.organizer.uri)) {
this.$emit('add-attendee', member)
}
})
Expand All @@ -212,11 +226,31 @@
return []
}

const data = response.data
return data.reduce((arr, result) => {
const contacts = []

Check warning on line 229 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L229

Added line #L229 was not covered by tests
/** Groups are shown before contacts */
for (const [groupName, groupContacts] of Object.entries(response.data.groups)) {
const processedGroupContacts = this.buildEmailsFromContactData(groupContacts)
if (processedGroupContacts.length > 0) {
contacts.push({

Check warning on line 234 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L231-L234

Added lines #L231 - L234 were not covered by tests
type: 'group',
dropdownName: groupName,
subtitle: this.$n('calendar', 'Contains %n contact(s) with email addresses', 'Contains %n contact(s) with email addresses', processedGroupContacts.length),
contacts: processedGroupContacts,
})
}
}

return contacts
},
buildEmailsFromContactData(contactsData) {
return contactsData.reduce((arr, result) => {
const hasMultipleEMails = result.emails.length > 1

result.emails.forEach((email) => {
if(email === '') {

Check failure on line 250 in src/components/Editor/Invitees/InviteesListSearch.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected space(s) after "if"
return
}

Check warning on line 252 in src/components/Editor/Invitees/InviteesListSearch.vue

View check run for this annotation

Codecov / codecov/patch

src/components/Editor/Invitees/InviteesListSearch.vue#L250-L252

Added lines #L250 - L252 were not covered by tests

let name
if (result.name && !hasMultipleEMails) {
name = result.name
Expand All @@ -231,6 +265,7 @@
}

arr.push({
type: 'contact',
calendarUserType: 'INDIVIDUAL',
commonName: result.name,
email,
Expand Down
79 changes: 59 additions & 20 deletions tests/php/unit/Controller/ContactControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,10 @@ public function testSearchAttendee():void {
->with()
->willReturn(true);

$this->manager->expects(self::once())
$this->manager->expects(self::exactly(2))
->method('search')
->with('search 123', ['FN', 'EMAIL'])
->willReturn([
->withConsecutive(['search 123', ['FN', 'EMAIL']], ['search 123', ['CATEGORIES']])
->willReturnOnConsecutiveCalls([
[
'FN' => 'Person 1',
'ADR' => [
Expand Down Expand Up @@ -222,29 +222,68 @@ public function testSearchAttendee():void {
'TZ' => 'Australia/Adelaide',
'PHOTO' => 'VALUE:BINARY:4242424242'
],
], [
[
'FN' => 'Person 4',
'EMAIL' => '[email protected]',
'CATEGORIES' => 'search 123,other'
],
[
'FN' => 'Person 5',
'CATEGORIES' => 'search 123'
],
[
'FN' => 'Person 6',
'EMAIL' => '[email protected]',
'CATEGORIES' => 'search 123'
],
]);

$response = $this->controller->searchAttendee('search 123');

$this->assertInstanceOf(JSONResponse::class, $response);
$this->assertEquals([
[
'name' => 'Person 1',
'emails' => [
'[email protected]',
'[email protected]',
],
'lang' => 'de',
'tzid' => 'Europe/Berlin',
'photo' => 'http://foo.bar',
], [
'name' => 'Person 2',
'emails' => [
'[email protected]'
],
'lang' => null,
'tzid' => null,
'photo' => null,
'contacts' => [
[
'name' => 'Person 1',
'emails' => [
'[email protected]',
'[email protected]',
],
'lang' => 'de',
'tzid' => 'Europe/Berlin',
'photo' => 'http://foo.bar',
], [
'name' => 'Person 2',
'emails' => [
'[email protected]'
],
'lang' => null,
'tzid' => null,
'photo' => null,
]
],
'groups' => [
'search 123' => [
[
'name' => 'Person 4',
'emails' => [
'[email protected]'
],
'lang' => null,
'tzid' => null,
'photo' => null,
],
[
'name' => 'Person 6',
'emails' => [
'[email protected]'
],
'lang' => null,
'tzid' => null,
'photo' => null,
]
]
]
], $response->getData());
$this->assertEquals(200, $response->getStatus());
Expand Down
Loading