add option to invite users to new calendar entry
This commit is contained in:
1 parent
8910a46d1d
commit
2070e9c390
7 files changed
+396
-13
No files matched your search
+8
-4
@@ -72,7 +72,8 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.atocal-row select {
|
||||
.atocal-row select,
|
||||
.atocal-row input[type="text"] {
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
@@ -123,14 +124,16 @@
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.atocal-row select:hover {
|
||||
.atocal-row select:hover,
|
||||
.atocal-row input[type="text"]:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
|
||||
/* Keyboard navigation must stay visible */
|
||||
.atocal-actions button:focus-visible,
|
||||
.atocal-actions a:focus-visible,
|
||||
.atocal-row select:focus-visible {
|
||||
.atocal-row select:focus-visible,
|
||||
.atocal-row input[type="text"]:focus-visible {
|
||||
outline: 2px solid var(--color-primary-element);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -152,7 +155,8 @@
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.atocal-row select {
|
||||
.atocal-row select,
|
||||
.atocal-row input[type="text"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -30,7 +30,11 @@
|
||||
"Invalid organizer email address.": "Ungültige E-Mail-Adresse des Organisators.",
|
||||
"No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "In deinem Konto wurde kein beschreibbarer Kalender gefunden. Erstelle einen in der Nextcloud-Kalender-App und versuche es erneut.",
|
||||
"The selected calendar is no longer writable. Pick another one and try again.": "Der ausgewählte Kalender ist nicht mehr beschreibbar. Wähle einen anderen aus und versuche es erneut.",
|
||||
"The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuche es erneut."
|
||||
"The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuche es erneut.",
|
||||
"Invitees": "Teilnehmer",
|
||||
"Email addresses, comma-separated": "E-Mail-Adressen, durch Kommas getrennt",
|
||||
"%s is not a valid email address.": "%s ist keine gültige E-Mail-Adresse.",
|
||||
"A meeting can invite at most %d people.": "Ein Meeting kann höchstens %d Personen einladen."
|
||||
},
|
||||
"pluralForms": "nplurals=2; plural=(n != 1);"
|
||||
}
|
||||
+5
-1
@@ -30,7 +30,11 @@
|
||||
"Invalid organizer email address.": "Ungültige E-Mail-Adresse des Organisators.",
|
||||
"No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "In Ihrem Konto wurde kein beschreibbarer Kalender gefunden. Erstellen Sie einen in der Nextcloud-Kalender-App und versuchen Sie es erneut.",
|
||||
"The selected calendar is no longer writable. Pick another one and try again.": "Der ausgewählte Kalender ist nicht mehr beschreibbar. Wählen Sie einen anderen aus und versuchen Sie es erneut.",
|
||||
"The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
|
||||
"The event could not be created. Please try again.": "Das Meeting konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||
"Invitees": "Teilnehmer",
|
||||
"Email addresses, comma-separated": "E-Mail-Adressen, durch Kommas getrennt",
|
||||
"%s is not a valid email address.": "%s ist keine gültige E-Mail-Adresse.",
|
||||
"A meeting can invite at most %d people.": "Ein Meeting kann höchstens %d Personen einladen."
|
||||
},
|
||||
"pluralForms": "nplurals=2; plural=(n != 1);"
|
||||
}
|
||||
+5
-1
@@ -30,7 +30,11 @@
|
||||
"Invalid organizer email address.": "Invalid organizer email address.",
|
||||
"No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.": "No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.",
|
||||
"The selected calendar is no longer writable. Pick another one and try again.": "The selected calendar is no longer writable. Pick another one and try again.",
|
||||
"The event could not be created. Please try again.": "The event could not be created. Please try again."
|
||||
"The event could not be created. Please try again.": "The event could not be created. Please try again.",
|
||||
"Invitees": "Invitees",
|
||||
"Email addresses, comma-separated": "Email addresses, comma-separated",
|
||||
"%s is not a valid email address.": "%s is not a valid email address.",
|
||||
"A meeting can invite at most %d people.": "A meeting can invite at most %d people."
|
||||
},
|
||||
"pluralForms": "nplurals=2; plural=(n != 1);"
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use OCP\AppFramework\Http\RedirectResponse;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\Calendar\ICreateFromString;
|
||||
use OCP\Calendar\IManager;
|
||||
use OCP\Contacts\IManager as IContactsManager;
|
||||
use OCP\Constants;
|
||||
use OCP\IRequest;
|
||||
use OCP\IURLGenerator;
|
||||
@@ -63,7 +64,19 @@ class CalendarController extends Controller
|
||||
*/
|
||||
private const MAX_EMAIL_LENGTH = 254;
|
||||
|
||||
/*
|
||||
* Maximum number of people a meeting may invite.
|
||||
*/
|
||||
private const MAX_ATTENDEES = 20;
|
||||
|
||||
/*
|
||||
* Address book entries offered as autocomplete options,
|
||||
* keeps the confirm page small on large address books.
|
||||
*/
|
||||
private const MAX_CONTACT_SUGGESTIONS = 50;
|
||||
|
||||
private IManager $calendarManager;
|
||||
private IContactsManager $contactsManager;
|
||||
private IAppManager $appManager;
|
||||
private IUserSession $userSession;
|
||||
private IURLGenerator $urlGenerator;
|
||||
@@ -75,6 +88,7 @@ class CalendarController extends Controller
|
||||
string $appName,
|
||||
IRequest $request,
|
||||
IManager $calendarManager,
|
||||
IContactsManager $contactsManager,
|
||||
IAppManager $appManager,
|
||||
IUserSession $userSession,
|
||||
IURLGenerator $urlGenerator,
|
||||
@@ -85,6 +99,7 @@ class CalendarController extends Controller
|
||||
parent::__construct($appName, $request);
|
||||
|
||||
$this->calendarManager = $calendarManager;
|
||||
$this->contactsManager = $contactsManager;
|
||||
$this->appManager = $appManager;
|
||||
$this->userSession = $userSession;
|
||||
$this->urlGenerator = $urlGenerator;
|
||||
@@ -137,7 +152,10 @@ class CalendarController extends Controller
|
||||
return $this->renderConfirm(
|
||||
$data,
|
||||
$calendars,
|
||||
$this->defaultCalendarId($calendars)
|
||||
$this->defaultCalendarId($calendars),
|
||||
'',
|
||||
'',
|
||||
$this->contactSuggestions()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +174,8 @@ class CalendarController extends Controller
|
||||
string $organizer = '',
|
||||
string $location = '',
|
||||
string $url = '',
|
||||
string $calendarId = ''
|
||||
string $calendarId = '',
|
||||
string $attendees = ''
|
||||
): RedirectResponse|TemplateResponse {
|
||||
try {
|
||||
$data = $this->validateEventData(
|
||||
@@ -180,6 +199,34 @@ class CalendarController extends Controller
|
||||
|
||||
$calendars = $this->findWritableCalendars($user->getUID());
|
||||
|
||||
/*
|
||||
* Invitees: the input must be rejected before the event
|
||||
* is written, but the user input survives a re-render.
|
||||
*/
|
||||
try {
|
||||
$data['attendees'] = $this->parseAttendees($attendees);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->renderConfirm(
|
||||
$data,
|
||||
$calendars,
|
||||
$calendarId,
|
||||
$e->getMessage(),
|
||||
$attendees,
|
||||
$this->contactSuggestions()
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Display names from the address book end up as ATTENDEE
|
||||
* parameter CN in the event.
|
||||
*/
|
||||
if ($data['attendees'] !== []) {
|
||||
$data['attendees'] = $this->withContactNames(
|
||||
$data['attendees'],
|
||||
$this->contactSuggestions()
|
||||
);
|
||||
}
|
||||
|
||||
if ($calendarId === '') {
|
||||
$calendarId = $this->defaultCalendarId($calendars);
|
||||
}
|
||||
@@ -203,7 +250,9 @@ class CalendarController extends Controller
|
||||
: $this->l10n->t(
|
||||
'The selected calendar is no longer writable. '
|
||||
. 'Pick another one and try again.'
|
||||
)
|
||||
),
|
||||
$attendees,
|
||||
$this->contactSuggestions()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,7 +274,9 @@ class CalendarController extends Controller
|
||||
$calendarId,
|
||||
$this->l10n->t(
|
||||
'The event could not be created. Please try again.'
|
||||
)
|
||||
),
|
||||
$attendees,
|
||||
$this->contactSuggestions()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -346,7 +397,9 @@ class CalendarController extends Controller
|
||||
array $data,
|
||||
array $calendars,
|
||||
string $calendarId = '',
|
||||
string $error = ''
|
||||
string $error = '',
|
||||
string $attendees = '',
|
||||
array $contacts = []
|
||||
): TemplateResponse {
|
||||
/*
|
||||
* Stylesheets are not auto-loaded, css/style.css
|
||||
@@ -377,6 +430,8 @@ class CalendarController extends Controller
|
||||
$calendars
|
||||
),
|
||||
'calendarId' => $calendarId,
|
||||
'attendees' => $attendees,
|
||||
'contacts' => $contacts,
|
||||
'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'),
|
||||
'createUrl' => $this->urlGenerator->linkToRoute(
|
||||
Application::APP_ID . '.calendar.create'
|
||||
@@ -385,6 +440,104 @@ class CalendarController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attendee emails from the invite input, comma-, semicolon-
|
||||
* or whitespace-separated. Lowercased and deduplicated.
|
||||
* An invalid entry aborts the request.
|
||||
*
|
||||
* @return list<array{email: string, name: string}>
|
||||
*/
|
||||
private function parseAttendees(string $input): array
|
||||
{
|
||||
$attendees = [];
|
||||
$seen = [];
|
||||
|
||||
foreach (preg_split('/[\s,;]+/', trim($input)) ?: [] as $part) {
|
||||
$email = strtolower(trim($part));
|
||||
|
||||
if ($email === '' || isset($seen[$email])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (count($seen) >= self::MAX_ATTENDEES) {
|
||||
throw new \InvalidArgumentException(
|
||||
$this->l10n->t(
|
||||
'A meeting can invite at most %d people.',
|
||||
[self::MAX_ATTENDEES]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
throw new \InvalidArgumentException(
|
||||
$this->l10n->t(
|
||||
'%s is not a valid email address.',
|
||||
[$part]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$seen[$email] = true;
|
||||
$attendees[] = ['email' => $email, 'name' => ''];
|
||||
}
|
||||
|
||||
return $attendees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address book entries with an email address, offered as
|
||||
* autocomplete options by the invite input.
|
||||
*
|
||||
* @return list<array{email: string, name: string}>
|
||||
*/
|
||||
private function contactSuggestions(): array
|
||||
{
|
||||
$contacts = [];
|
||||
|
||||
$results = $this->contactsManager->search('', ['FN', 'EMAIL']);
|
||||
|
||||
foreach ($results ?? [] as $result) {
|
||||
$name = (string)($result['FN'] ?? '');
|
||||
|
||||
$emails = $result['EMAIL'] ?? [];
|
||||
$emails = is_array($emails) ? $emails : [$emails];
|
||||
|
||||
foreach ($emails as $email) {
|
||||
$email = trim((string)$email);
|
||||
|
||||
if ($email !== ''
|
||||
&& count($contacts) < self::MAX_CONTACT_SUGGESTIONS) {
|
||||
$contacts[] = ['email' => $email, 'name' => $name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill attendee display names from the address book.
|
||||
* First match wins, emails are case-insensitive.
|
||||
*
|
||||
* @param list<array{email: string, name: string}> $attendees
|
||||
* @param list<array{email: string, name: string}> $contacts
|
||||
* @return list<array{email: string, name: string}>
|
||||
*/
|
||||
private function withContactNames(array $attendees, array $contacts): array
|
||||
{
|
||||
$namesByEmail = [];
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$namesByEmail[strtolower($contact['email'])] ??= $contact['name'];
|
||||
}
|
||||
|
||||
foreach ($attendees as $i => $attendee) {
|
||||
$attendees[$i]['name'] = $namesByEmail[$attendee['email']] ?? '';
|
||||
}
|
||||
|
||||
return $attendees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the event through the platform event builder,
|
||||
* which handles escaping and ICS serialization.
|
||||
@@ -423,6 +576,22 @@ class CalendarController extends Controller
|
||||
'TRIGGER' => self::REMINDER_TRIGGER,
|
||||
]);
|
||||
|
||||
/*
|
||||
* Attendee invitation: the CalDAV backend schedules
|
||||
* (IMIP/iTIP emails) based on these properties.
|
||||
*/
|
||||
foreach ($data['attendees'] as $attendee) {
|
||||
$parameters = $attendee['name'] !== ''
|
||||
? ['CN' => $attendee['name']]
|
||||
: [];
|
||||
|
||||
$vobject->VEVENT->add(
|
||||
'ATTENDEE',
|
||||
'mailto:' . $attendee['email'],
|
||||
$parameters
|
||||
);
|
||||
}
|
||||
|
||||
$fileName = (string) $vobject->VEVENT->UID . '.ics';
|
||||
|
||||
$calendar->createFromString($fileName, $vobject->serialize());
|
||||
|
||||
@@ -129,6 +129,33 @@ $l = $_['l10n'];
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="atocal-row">
|
||||
<label for="atocal-attendees">
|
||||
<strong><?= $h($l->t('Invitees')) ?></strong>
|
||||
</label>
|
||||
<span>
|
||||
<input
|
||||
id="atocal-attendees"
|
||||
name="attendees"
|
||||
type="text"
|
||||
list="atocal-contact-emails"
|
||||
value="<?= $h($_['attendees']) ?>"
|
||||
placeholder="<?= $h(
|
||||
$l->t('Email addresses, comma-separated')
|
||||
) ?>"
|
||||
>
|
||||
<?php if ($_['contacts'] !== []): ?>
|
||||
<datalist id="atocal-contact-emails">
|
||||
<?php foreach ($_['contacts'] as $contact): ?>
|
||||
<option
|
||||
value="<?= $h($contact['email']) ?>"
|
||||
><?= $h($contact['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</datalist>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="requesttoken"
|
||||
|
||||
@@ -13,6 +13,7 @@ use OCP\Calendar\ICalendarEventBuilder;
|
||||
use OCP\Calendar\ICreateFromString;
|
||||
use OCP\Calendar\IManager;
|
||||
use OCP\Constants;
|
||||
use OCP\Contacts\IManager as IContactsManager;
|
||||
use OCP\IDateTimeZone;
|
||||
use OCP\IL10N;
|
||||
use OCP\IRequest;
|
||||
@@ -29,6 +30,7 @@ require_once __DIR__ . '/OCUtilStub.php';
|
||||
class CalendarControllerTest extends TestCase
|
||||
{
|
||||
private IManager&MockObject $calendarManager;
|
||||
private IContactsManager&MockObject $contactsManager;
|
||||
private IAppManager&MockObject $appManager;
|
||||
private IUserSession&MockObject $userSession;
|
||||
private IURLGenerator&MockObject $urlGenerator;
|
||||
@@ -37,6 +39,7 @@ class CalendarControllerTest extends TestCase
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->calendarManager = $this->createMock(IManager::class);
|
||||
$this->contactsManager = $this->createMock(IContactsManager::class);
|
||||
$this->appManager = $this->createMock(IAppManager::class);
|
||||
$this->userSession = $this->createMock(IUserSession::class);
|
||||
$this->urlGenerator = $this->createMock(IURLGenerator::class);
|
||||
@@ -50,13 +53,16 @@ class CalendarControllerTest extends TestCase
|
||||
|
||||
$l10n = $this->createMock(IL10N::class);
|
||||
$l10n->method('t')->willReturnCallback(
|
||||
static fn (string $text) => $text
|
||||
static fn (string $text, array $params = []): string => $params === []
|
||||
? $text
|
||||
: vsprintf($text, $params)
|
||||
);
|
||||
|
||||
return new CalendarController(
|
||||
'atocal',
|
||||
$this->createMock(IRequest::class),
|
||||
$this->calendarManager,
|
||||
$this->contactsManager,
|
||||
$this->appManager,
|
||||
$this->userSession,
|
||||
$this->urlGenerator,
|
||||
@@ -484,6 +490,60 @@ class CalendarControllerTest extends TestCase
|
||||
'organizer' => '',
|
||||
'location' => '',
|
||||
'url' => '',
|
||||
'attendees' => [],
|
||||
];
|
||||
|
||||
$this->invoke($c, 'createEvent', [$calendar, $data]);
|
||||
}
|
||||
|
||||
/*
|
||||
* createEvent: ATTENDEE injection
|
||||
*/
|
||||
|
||||
public function testCreateEventInvitesAttendees(): void
|
||||
{
|
||||
$builder = $this->createMock(ICalendarEventBuilder::class);
|
||||
$builder->method('toIcs')->willReturn(
|
||||
"BEGIN:VCALENDAR\r\n" .
|
||||
"VERSION:2.0\r\n" .
|
||||
"PRODID:-//Nextcloud//AToCal//EN\r\n" .
|
||||
"BEGIN:VEVENT\r\n" .
|
||||
"UID:test-uid-123\r\n" .
|
||||
"SUMMARY:Weekly sync\r\n" .
|
||||
"END:VEVENT\r\n" .
|
||||
"END:VCALENDAR\r\n"
|
||||
);
|
||||
$this->calendarManager->method('createEventBuilder')->willReturn($builder);
|
||||
|
||||
$calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE);
|
||||
$calendar->expects($this->once())->method('createFromString')
|
||||
->willReturnCallback(function (string $name, string $data): bool {
|
||||
$this->assertStringContainsString(
|
||||
'ATTENDEE;CN=Alice Example:mailto:alice@example.com',
|
||||
$data
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'ATTENDEE:mailto:bob@example.com',
|
||||
$data
|
||||
);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
$c = $this->newController();
|
||||
|
||||
$data = [
|
||||
'summary' => 'Weekly sync',
|
||||
'description' => '',
|
||||
'start' => new \DateTimeImmutable('2026-10-07T14:15:00 UTC'),
|
||||
'end' => new \DateTimeImmutable('2026-10-07T15:15:00 UTC'),
|
||||
'organizer' => '',
|
||||
'location' => '',
|
||||
'url' => '',
|
||||
'attendees' => [
|
||||
['email' => 'alice@example.com', 'name' => 'Alice Example'],
|
||||
['email' => 'bob@example.com', 'name' => ''],
|
||||
],
|
||||
];
|
||||
|
||||
$this->invoke($c, 'createEvent', [$calendar, $data]);
|
||||
@@ -727,4 +787,115 @@ class CalendarControllerTest extends TestCase
|
||||
\OC_Util::$styles
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* parseAttendees()
|
||||
*/
|
||||
|
||||
public function testParseAttendeesNormalizesInput(): void
|
||||
{
|
||||
$c = $this->newController();
|
||||
|
||||
$attendees = $this->invoke($c, 'parseAttendees', [
|
||||
'alice@example.com, BOB@Example.com ; bob@example.com carl@example.com',
|
||||
]);
|
||||
|
||||
$this->assertSame([
|
||||
['email' => 'alice@example.com', 'name' => ''],
|
||||
['email' => 'bob@example.com', 'name' => ''],
|
||||
['email' => 'carl@example.com', 'name' => ''],
|
||||
], $attendees);
|
||||
}
|
||||
|
||||
public function testParseAttendeesEmptyInput(): void
|
||||
{
|
||||
$c = $this->newController();
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$this->invoke($c, 'parseAttendees', [' '])
|
||||
);
|
||||
}
|
||||
|
||||
public function testParseAttendeesRejectsInvalidEmail(): void
|
||||
{
|
||||
$c = $this->newController();
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('is not a valid email address.');
|
||||
|
||||
$this->invoke($c, 'parseAttendees', ['alice@example.com,not-an-email']);
|
||||
}
|
||||
|
||||
public function testParseAttendeesEnforcesLimit(): void
|
||||
{
|
||||
$c = $this->newController();
|
||||
|
||||
$emails = implode(',', array_map(
|
||||
static fn (int $i): string => "person$i@example.com",
|
||||
range(1, 21)
|
||||
));
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('A meeting can invite at most 20 people.');
|
||||
|
||||
$this->invoke($c, 'parseAttendees', [$emails]);
|
||||
}
|
||||
|
||||
/*
|
||||
* confirm(): contact suggestions for the datalist
|
||||
*/
|
||||
|
||||
public function testConfirmOffersContactSuggestions(): void
|
||||
{
|
||||
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||
$this->setUpWritableCalendars();
|
||||
$this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/');
|
||||
$this->contactsManager->method('search')->willReturn([
|
||||
['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'],
|
||||
['FN' => 'Bob', 'EMAIL' => ['bob@example.com', 'bob@work.example.com']],
|
||||
]);
|
||||
|
||||
$c = $this->newController();
|
||||
|
||||
$response = $c->confirm(
|
||||
'Weekly sync',
|
||||
'',
|
||||
'2026-10-07T14:15',
|
||||
'2026-10-07T15:15'
|
||||
);
|
||||
|
||||
$this->assertSame('confirm', $response->getTemplateName());
|
||||
$this->assertSame([
|
||||
['email' => 'alice@example.com', 'name' => 'Alice Example'],
|
||||
['email' => 'bob@example.com', 'name' => 'Bob'],
|
||||
['email' => 'bob@work.example.com', 'name' => 'Bob'],
|
||||
], $response->getParams()['contacts']);
|
||||
}
|
||||
|
||||
/*
|
||||
* create(): invalid invitee input keeps the form usable
|
||||
*/
|
||||
|
||||
public function testCreateInvalidAttendeeRendersConfirm(): void
|
||||
{
|
||||
$this->userSession->method('getUser')->willReturn($this->mockUser('alice'));
|
||||
$this->setUpWritableCalendars();
|
||||
|
||||
$args = $this->validCreateArgs();
|
||||
$args[] = 'alice@example.com, not-an-email';
|
||||
|
||||
$c = $this->newController();
|
||||
|
||||
$response = $c->create(...$args);
|
||||
|
||||
$this->assertSame('confirm', $response->getTemplateName());
|
||||
$params = $response->getParams();
|
||||
$this->assertStringContainsString(
|
||||
'is not a valid email address.',
|
||||
$params['error']
|
||||
);
|
||||
$this->assertSame('alice@example.com, not-an-email', $params['attendees']);
|
||||
$this->assertSame('5::personal', $params['calendarId']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user