diff --git a/appinfo/routes.php b/appinfo/routes.php index a7a6be5..c096553 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -14,5 +14,10 @@ return [ 'url' => '/create', 'verb' => 'POST', ], + [ + 'name' => 'calendar#searchContacts', + 'url' => '/contacts/search', + 'verb' => 'GET', + ], ], ]; \ No newline at end of file diff --git a/css/style.css b/css/style.css index 3227eff..e6ee8d1 100644 --- a/css/style.css +++ b/css/style.css @@ -112,6 +112,49 @@ appearance: none; } +/* + * Live contact search results, anchored to the invitee wrapper. + */ +.atocal-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin: 2px 0 0; + padding: 4px; + list-style: none; + background: var(--color-main-background); + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + z-index: 10; + max-height: 220px; + overflow-y: auto; +} + +.atocal-suggestions button { + display: flex; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-main-text); + text-align: left; + cursor: pointer; +} + +.atocal-suggestions li.atocal-active > button, +.atocal-suggestions button:hover { + background: var(--color-background-hover); +} + +.atocal-suggestions .atocal-suggestion-email { + color: var(--color-text-maxcontrast); +} + .atocal-row select, .atocal-row input[type="text"] { align-self: center; diff --git a/js/invitees.js b/js/invitees.js new file mode 100644 index 0000000..ec6e903 --- /dev/null +++ b/js/invitees.js @@ -0,0 +1,187 @@ +/** + * Live contact search for the invitee input. + * + * A picked suggestion replaces the fragment after the last + * separator, so a comma-separated list keeps working: + * + * "alice@x.com, bo" -> pick "Bob" -> "alice@x.com, bob@x.com, " + * + * Without JavaScript the input falls back to the static + * datalist rendered by the server. + */ +(function () { + 'use strict'; + + var DEBOUNCE_MS = 250; + var SEPARATOR = /[;,]/; + + var input = document.getElementById('atocal-attendees'); + + if (!input) { + return; + } + + var searchUrl = input.dataset.searchUrl; + + if (!searchUrl) { + return; + } + + var list = document.createElement('ul'); + list.className = 'atocal-suggestions'; + list.hidden = true; + list.setAttribute('role', 'listbox'); + + /* The wrapper span provides position: relative */ + input.parentNode.appendChild(list); + + /* The native datalist popup would double the custom one */ + input.removeAttribute('list'); + + var debounceTimer = null; + var requestSeq = 0; + var suggestions = []; + var activeIndex = -1; + + function fragment() { + var parts = input.value.split(SEPARATOR); + return parts[parts.length - 1].trim(); + } + + function hide() { + list.hidden = true; + list.replaceChildren(); + suggestions = []; + activeIndex = -1; + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + } + + function commit(email) { + var parts = input.value.split(/([;,])/); + parts.pop(); + var prefix = parts.join('').replace(/\s+$/, ''); + + input.value = prefix === '' ? email + ', ' : prefix + ' ' + email + ', '; + hide(); + input.focus(); + } + + function moveSelection(offset) { + activeIndex = (activeIndex + offset + suggestions.length) % suggestions.length; + + list.childNodes.forEach(function (option, i) { + option.classList.toggle('atocal-active', i === activeIndex); + }); + input.setAttribute('aria-activedescendant', 'atocal-suggestion-' + activeIndex); + } + + function render(contacts) { + hide(); + + if (contacts.length === 0) { + return; + } + suggestions = contacts; + + contacts.forEach(function (contact, i) { + var option = document.createElement('li'); + option.id = 'atocal-suggestion-' + i; + option.setAttribute('role', 'option'); + + var button = document.createElement('button'); + button.type = 'button'; + button.tabIndex = -1; + button.addEventListener('mousedown', function (event) { + /* keep focus on the input */ + event.preventDefault(); + commit(contact.email); + }); + + var name = document.createElement('span'); + name.className = 'atocal-suggestion-name'; + name.textContent = contact.name === '' ? contact.email : contact.name; + + var email = document.createElement('span'); + email.className = 'atocal-suggestion-email'; + email.textContent = contact.email; + + button.appendChild(name); + button.appendChild(email); + option.appendChild(button); + list.appendChild(option); + }); + + list.hidden = false; + input.setAttribute('aria-expanded', 'true'); + } + + function search(query) { + var seq = ++requestSeq; + + fetch(searchUrl + '?query=' + encodeURIComponent(query), { + headers: { Accept: 'application/json' } + }) + .then(function (response) { + if (!response.ok) { + throw new Error('search failed'); + } + return response.json(); + }) + .then(function (contacts) { + if (seq === requestSeq) { + render(Array.isArray(contacts) ? contacts : []); + } + }) + .catch(function () { + if (seq === requestSeq) { + hide(); + } + }); + } + + input.addEventListener('input', function () { + clearTimeout(debounceTimer); + + var query = fragment(); + + if (query === '') { + hide(); + return; + } + debounceTimer = setTimeout(function () { + search(query); + }, DEBOUNCE_MS); + }); + + input.addEventListener('keydown', function (event) { + if (list.hidden) { + return; + } + switch (event.key) { + case 'ArrowDown': + moveSelection(1); + event.preventDefault(); + break; + case 'ArrowUp': + moveSelection(-1); + event.preventDefault(); + break; + case 'Enter': + if (activeIndex >= 0) { + commit(suggestions[activeIndex].email); + event.preventDefault(); + } + break; + case 'Escape': + hide(); + break; + } + }); + + document.addEventListener('click', function (event) { + if (!list.hidden && event.target !== input && !list.contains(event.target)) { + hide(); + } + }); +})(); diff --git a/lib/Controller/CalendarController.php b/lib/Controller/CalendarController.php index 4790504..ab3bbac 100644 --- a/lib/Controller/CalendarController.php +++ b/lib/Controller/CalendarController.php @@ -11,6 +11,7 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\Calendar\ICreateFromString; use OCP\Calendar\IManager; @@ -292,6 +293,19 @@ class CalendarController extends Controller return new RedirectResponse($calendarUrl); } + /** + * Live contact search for the invitee input. + * + * Read-only GET endpoint, needs no CSRF protection. + * + * @return DataResponse> + */ + #[NoAdminRequired] + public function searchContacts(string $query = ''): DataResponse + { + return new DataResponse($this->contactSuggestions($query)); + } + /** * All writable calendars of the given principal. * @@ -402,10 +416,11 @@ class CalendarController extends Controller array $contacts = [] ): TemplateResponse { /* - * Stylesheets are not auto-loaded, css/style.css - * must be registered for the page layout. + * Stylesheets and scripts are not auto-loaded, they must + * be registered for the page layout. */ Util::addStyle(Application::APP_ID, 'style'); + Util::addScript(Application::APP_ID, 'invitees'); return new TemplateResponse( Application::APP_ID, @@ -432,6 +447,9 @@ class CalendarController extends Controller 'calendarId' => $calendarId, 'attendees' => $attendees, 'contacts' => $contacts, + 'contactsSearchUrl' => $this->urlGenerator->linkToRoute( + Application::APP_ID . '.calendar.searchContacts' + ), 'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'), 'createUrl' => $this->urlGenerator->linkToRoute( Application::APP_ID . '.calendar.create' @@ -485,16 +503,17 @@ class CalendarController extends Controller } /** - * Address book entries with an email address, offered as - * autocomplete options by the invite input. + * Address book entries with an email address, offered by the + * invitee input: as autocomplete options on the initial page + * and as live search results while typing. * * @return list */ - private function contactSuggestions(): array + private function contactSuggestions(string $query = ''): array { $contacts = []; - $results = $this->contactsManager->search('', ['FN', 'EMAIL']); + $results = $this->contactsManager->search(trim($query), ['FN', 'EMAIL']); foreach ($results ?? [] as $result) { $name = (string)($result['FN'] ?? ''); diff --git a/templates/confirm.php b/templates/confirm.php index b2e29a8..da5db80 100644 --- a/templates/confirm.php +++ b/templates/confirm.php @@ -141,6 +141,7 @@ $l = $_['l10n']; name="attendees" type="text" list="atocal-contact-emails" + data-search-url="" value="" placeholder="t('Email addresses, comma-separated') diff --git a/tests/Unit/CalendarControllerTest.php b/tests/Unit/CalendarControllerTest.php index a471de4..11cf848 100644 --- a/tests/Unit/CalendarControllerTest.php +++ b/tests/Unit/CalendarControllerTest.php @@ -6,6 +6,7 @@ namespace OCA\AToCal\Tests\Unit\Controller; use OCA\AToCal\Controller\CalendarController; use OCP\App\IAppManager; +use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\Calendar\ICalendar; @@ -898,4 +899,45 @@ class CalendarControllerTest extends TestCase $this->assertSame('alice@example.com, not-an-email', $params['attendees']); $this->assertSame('5::personal', $params['calendarId']); } + + /* + * searchContacts() + */ + + public function testSearchContactsReturnsMatches(): void + { + $this->contactsManager->expects($this->once())->method('search') + ->with('ali', ['FN', 'EMAIL']) + ->willReturn([ + ['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'], + ]); + + $c = $this->newController(); + + $response = $c->searchContacts('ali'); + + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame( + [['email' => 'alice@example.com', 'name' => 'Alice Example']], + $response->getData() + ); + } + + public function testSearchContactsCapsResults(): void + { + $contacts = array_map( + static fn (int $i): array => [ + 'FN' => "Person $i", + 'EMAIL' => "person$i@example.com", + ], + range(1, 60) + ); + $this->contactsManager->method('search')->willReturn($contacts); + + $c = $this->newController(); + + $response = $c->searchContacts('person'); + + $this->assertCount(50, $response->getData()); + } } diff --git a/tests/Unit/OCUtilStub.php b/tests/Unit/OCUtilStub.php index b519831..5f73c6b 100644 --- a/tests/Unit/OCUtilStub.php +++ b/tests/Unit/OCUtilStub.php @@ -3,21 +3,57 @@ declare(strict_types=1); /* - * Minimal stand-in for the server class OC_Util, which is not part - * of the OCP vendor package. Records addStyle() calls for assertions. + * Minimal stand-ins for server classes that are not part of + * the OCP vendor package. OC_Util records addStyle() calls, + * the OC classes satisfy the lookups that OCP\Util::addScript() + * triggers for app scripts. */ -if (!class_exists(\OC_Util::class, false)) { - class OC_Util +namespace OC { + class AppScriptDependency { - /** @var list */ - public static array $styles = []; + public function __construct(string $app, array $deps = []) {} - public static function addStyle( - string $application, - ?string $file = null, - bool $prepend = false - ): void { - self::$styles[] = [$application, $file, $prepend]; - } + public function addDep(string $dep): void {} } } + +namespace { + class OC + { + public static ?object $server = null; + } + + class AToCalFakeL10NFactory + { + public function findLanguage(string $app): string + { + return 'en'; + } + } + + class AToCalFakeServer + { + public function get(string $serviceName): AToCalFakeL10NFactory + { + return new AToCalFakeL10NFactory(); + } + } + + if (!class_exists('OC_Util', false)) { + class OC_Util + { + /** @var list */ + public static array $styles = []; + + public static function addStyle( + string $application, + ?string $file = null, + bool $prepend = false + ): void { + self::$styles[] = [$application, $file, $prepend]; + } + } + } + + OC::$server ??= new AToCalFakeServer(); +}