Add live contact search to invitee input
The static datalist only carried the first 50 address book contacts, so typed fragments could not match anything beyond that. A new read-only GET /contacts/search endpoint queries the full address book per keystroke, and a small vanilla JS combobox shows the results and replaces only the fragment after the last separator, so comma-separated lists keep working. The server-rendered datalist remains as the no-JS fallback; the JS removes the list attribute at runtime so the native popup does not double the custom one.
This commit is contained in:
1 parent
0e5b190971
commit
cc1fd4e508
7 files changed
+342
-9
No files matched your search
@@ -14,5 +14,10 @@ return [
|
|||||||
'url' => '/create',
|
'url' => '/create',
|
||||||
'verb' => 'POST',
|
'verb' => 'POST',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => 'calendar#searchContacts',
|
||||||
|
'url' => '/contacts/search',
|
||||||
|
'verb' => 'GET',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -112,6 +112,49 @@
|
|||||||
appearance: none;
|
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 select,
|
||||||
.atocal-row input[type="text"] {
|
.atocal-row input[type="text"] {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|||||||
+187
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -11,6 +11,7 @@ use OCP\AppFramework\Http;
|
|||||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||||
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
||||||
use OCP\AppFramework\Http\RedirectResponse;
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
use OCP\Calendar\ICreateFromString;
|
use OCP\Calendar\ICreateFromString;
|
||||||
use OCP\Calendar\IManager;
|
use OCP\Calendar\IManager;
|
||||||
@@ -292,6 +293,19 @@ class CalendarController extends Controller
|
|||||||
return new RedirectResponse($calendarUrl);
|
return new RedirectResponse($calendarUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live contact search for the invitee input.
|
||||||
|
*
|
||||||
|
* Read-only GET endpoint, needs no CSRF protection.
|
||||||
|
*
|
||||||
|
* @return DataResponse<Http::STATUS_OK, list<array{email: string, name: string}>>
|
||||||
|
*/
|
||||||
|
#[NoAdminRequired]
|
||||||
|
public function searchContacts(string $query = ''): DataResponse
|
||||||
|
{
|
||||||
|
return new DataResponse($this->contactSuggestions($query));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All writable calendars of the given principal.
|
* All writable calendars of the given principal.
|
||||||
*
|
*
|
||||||
@@ -402,10 +416,11 @@ class CalendarController extends Controller
|
|||||||
array $contacts = []
|
array $contacts = []
|
||||||
): TemplateResponse {
|
): TemplateResponse {
|
||||||
/*
|
/*
|
||||||
* Stylesheets are not auto-loaded, css/style.css
|
* Stylesheets and scripts are not auto-loaded, they must
|
||||||
* must be registered for the page layout.
|
* be registered for the page layout.
|
||||||
*/
|
*/
|
||||||
Util::addStyle(Application::APP_ID, 'style');
|
Util::addStyle(Application::APP_ID, 'style');
|
||||||
|
Util::addScript(Application::APP_ID, 'invitees');
|
||||||
|
|
||||||
return new TemplateResponse(
|
return new TemplateResponse(
|
||||||
Application::APP_ID,
|
Application::APP_ID,
|
||||||
@@ -432,6 +447,9 @@ class CalendarController extends Controller
|
|||||||
'calendarId' => $calendarId,
|
'calendarId' => $calendarId,
|
||||||
'attendees' => $attendees,
|
'attendees' => $attendees,
|
||||||
'contacts' => $contacts,
|
'contacts' => $contacts,
|
||||||
|
'contactsSearchUrl' => $this->urlGenerator->linkToRoute(
|
||||||
|
Application::APP_ID . '.calendar.searchContacts'
|
||||||
|
),
|
||||||
'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'),
|
'cancelUrl' => $this->urlGenerator->getAbsoluteURL('/'),
|
||||||
'createUrl' => $this->urlGenerator->linkToRoute(
|
'createUrl' => $this->urlGenerator->linkToRoute(
|
||||||
Application::APP_ID . '.calendar.create'
|
Application::APP_ID . '.calendar.create'
|
||||||
@@ -485,16 +503,17 @@ class CalendarController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Address book entries with an email address, offered as
|
* Address book entries with an email address, offered by the
|
||||||
* autocomplete options by the invite input.
|
* invitee input: as autocomplete options on the initial page
|
||||||
|
* and as live search results while typing.
|
||||||
*
|
*
|
||||||
* @return list<array{email: string, name: string}>
|
* @return list<array{email: string, name: string}>
|
||||||
*/
|
*/
|
||||||
private function contactSuggestions(): array
|
private function contactSuggestions(string $query = ''): array
|
||||||
{
|
{
|
||||||
$contacts = [];
|
$contacts = [];
|
||||||
|
|
||||||
$results = $this->contactsManager->search('', ['FN', 'EMAIL']);
|
$results = $this->contactsManager->search(trim($query), ['FN', 'EMAIL']);
|
||||||
|
|
||||||
foreach ($results ?? [] as $result) {
|
foreach ($results ?? [] as $result) {
|
||||||
$name = (string)($result['FN'] ?? '');
|
$name = (string)($result['FN'] ?? '');
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ $l = $_['l10n'];
|
|||||||
name="attendees"
|
name="attendees"
|
||||||
type="text"
|
type="text"
|
||||||
list="atocal-contact-emails"
|
list="atocal-contact-emails"
|
||||||
|
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
|
||||||
value="<?= $h($_['attendees']) ?>"
|
value="<?= $h($_['attendees']) ?>"
|
||||||
placeholder="<?= $h(
|
placeholder="<?= $h(
|
||||||
$l->t('Email addresses, comma-separated')
|
$l->t('Email addresses, comma-separated')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace OCA\AToCal\Tests\Unit\Controller;
|
|||||||
|
|
||||||
use OCA\AToCal\Controller\CalendarController;
|
use OCA\AToCal\Controller\CalendarController;
|
||||||
use OCP\App\IAppManager;
|
use OCP\App\IAppManager;
|
||||||
|
use OCP\AppFramework\Http\DataResponse;
|
||||||
use OCP\AppFramework\Http\RedirectResponse;
|
use OCP\AppFramework\Http\RedirectResponse;
|
||||||
use OCP\AppFramework\Http\TemplateResponse;
|
use OCP\AppFramework\Http\TemplateResponse;
|
||||||
use OCP\Calendar\ICalendar;
|
use OCP\Calendar\ICalendar;
|
||||||
@@ -898,4 +899,45 @@ class CalendarControllerTest extends TestCase
|
|||||||
$this->assertSame('alice@example.com, not-an-email', $params['attendees']);
|
$this->assertSame('alice@example.com, not-an-email', $params['attendees']);
|
||||||
$this->assertSame('5::personal', $params['calendarId']);
|
$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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,43 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Minimal stand-in for the server class OC_Util, which is not part
|
* Minimal stand-ins for server classes that are not part of
|
||||||
* of the OCP vendor package. Records addStyle() calls for assertions.
|
* 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)) {
|
namespace OC {
|
||||||
|
class AppScriptDependency
|
||||||
|
{
|
||||||
|
public function __construct(string $app, array $deps = []) {}
|
||||||
|
|
||||||
|
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
|
class OC_Util
|
||||||
{
|
{
|
||||||
/** @var list<array{string, ?string, bool}> */
|
/** @var list<array{string, ?string, bool}> */
|
||||||
@@ -20,4 +53,7 @@ if (!class_exists(\OC_Util::class, false)) {
|
|||||||
self::$styles[] = [$application, $file, $prepend];
|
self::$styles[] = [$application, $file, $prepend];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OC::$server ??= new AToCalFakeServer();
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user