Compare commits
3
Commits
e1a3fd12f0
...
a9b7fd11af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b7fd11af | ||
|
|
5bba3a93cd | ||
|
|
e5432a0a38 |
No files matched your search
@@ -1,2 +1,3 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
|
node_modules/
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
+6
-2
@@ -13,7 +13,9 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var DEBOUNCE_MS = 250;
|
var DEBOUNCE_MS = 250;
|
||||||
var SEPARATOR = /[;,]/;
|
/* Must match the server's attendee parsing: whitespace, comma and
|
||||||
|
* semicolon all separate entries. */
|
||||||
|
var SEPARATOR = /[\s;,]+/;
|
||||||
|
|
||||||
var input = document.getElementById('atocal-attendees');
|
var input = document.getElementById('atocal-attendees');
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var list = document.createElement('ul');
|
var list = document.createElement('ul');
|
||||||
|
list.id = 'atocal-suggestions';
|
||||||
list.className = 'atocal-suggestions';
|
list.className = 'atocal-suggestions';
|
||||||
list.hidden = true;
|
list.hidden = true;
|
||||||
list.setAttribute('role', 'listbox');
|
list.setAttribute('role', 'listbox');
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hide() {
|
function hide() {
|
||||||
|
requestSeq++;
|
||||||
list.hidden = true;
|
list.hidden = true;
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
suggestions = [];
|
suggestions = [];
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commit(email) {
|
function commit(email) {
|
||||||
var parts = input.value.split(/([;,])/);
|
var parts = input.value.split(/([\s;,]+)/);
|
||||||
parts.pop();
|
parts.pop();
|
||||||
var prefix = parts.join('').replace(/\s+$/, '');
|
var prefix = parts.join('').replace(/\s+$/, '');
|
||||||
|
|
||||||
|
|||||||
Generated
+2148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"description": "JS test harness for the atocal frontend scripts",
|
||||||
|
"scripts": {
|
||||||
|
"test:js": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,10 @@ $l = $_['l10n'];
|
|||||||
id="atocal-attendees"
|
id="atocal-attendees"
|
||||||
name="attendees"
|
name="attendees"
|
||||||
type="text"
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="atocal-suggestions"
|
||||||
|
aria-expanded="false"
|
||||||
list="atocal-contact-emails"
|
list="atocal-contact-emails"
|
||||||
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
|
data-search-url="<?= $h($_['contactsSearchUrl']) ?>"
|
||||||
value="<?= $h($_['attendees']) ?>"
|
value="<?= $h($_['attendees']) ?>"
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
const SEARCH_URL = '/apps/atocal/contact/search';
|
||||||
|
const BOB = { name: 'Bob', email: 'bob@example.com' };
|
||||||
|
const ZOE = { name: 'Zoe', email: 'zoe@example.com' };
|
||||||
|
|
||||||
|
let input;
|
||||||
|
let list;
|
||||||
|
|
||||||
|
function response(contacts) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(contacts),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Promise we can settle manually, to emulate an in-flight request */
|
||||||
|
function pendingResponse() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((res) => {
|
||||||
|
resolve = res;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(fragment) {
|
||||||
|
input.value = fragment;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Let the debounce fire and the promise chain settle */
|
||||||
|
async function settle(ms = DEBOUNCE_MS) {
|
||||||
|
await vi.advanceTimersByTimeAsync(ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
document.body.innerHTML =
|
||||||
|
'<span><input id="atocal-attendees" data-search-url="' + SEARCH_URL + '"'
|
||||||
|
+ ' role="combobox" aria-autocomplete="list"'
|
||||||
|
+ ' aria-controls="atocal-suggestions" aria-expanded="false"></span>';
|
||||||
|
|
||||||
|
await import('../../js/invitees.js');
|
||||||
|
|
||||||
|
input = document.getElementById('atocal-attendees');
|
||||||
|
list = document.querySelector('.atocal-suggestions');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
/* Empty fragment hides the list and cancels pending debounces */
|
||||||
|
type('');
|
||||||
|
await settle(0);
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invitee suggestions', () => {
|
||||||
|
it('marks the input as a combobox controlling the list', () => {
|
||||||
|
expect(input.getAttribute('role')).toBe('combobox');
|
||||||
|
expect(input.getAttribute('aria-autocomplete')).toBe('list');
|
||||||
|
|
||||||
|
/* aria-controls must resolve to the list built by the widget */
|
||||||
|
const controlled = document.getElementById(
|
||||||
|
input.getAttribute('aria-controls')
|
||||||
|
);
|
||||||
|
expect(controlled).toBe(list);
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders matching contacts', async () => {
|
||||||
|
fetch.mockReturnValue(response([BOB]));
|
||||||
|
|
||||||
|
type('bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(list.hidden).toBe(false);
|
||||||
|
expect(list.querySelectorAll('[role="option"]')).toHaveLength(1);
|
||||||
|
expect(list.textContent).toContain('bob@example.com');
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores responses that arrive after the field was cleared', async () => {
|
||||||
|
const inFlight = pendingResponse();
|
||||||
|
fetch.mockReturnValue(inFlight.promise);
|
||||||
|
|
||||||
|
type('bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
/* User clears the field while the request is still in flight */
|
||||||
|
type('');
|
||||||
|
|
||||||
|
inFlight.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([BOB]),
|
||||||
|
});
|
||||||
|
await settle(0);
|
||||||
|
|
||||||
|
expect(list.hidden).toBe(true);
|
||||||
|
expect(list.children).toHaveLength(0);
|
||||||
|
expect(input.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores responses that arrive after a contact was picked', async () => {
|
||||||
|
const second = pendingResponse();
|
||||||
|
fetch
|
||||||
|
.mockReturnValueOnce(response([BOB]))
|
||||||
|
.mockReturnValueOnce(second.promise);
|
||||||
|
|
||||||
|
type('bo');
|
||||||
|
await settle();
|
||||||
|
expect(list.hidden).toBe(false);
|
||||||
|
|
||||||
|
/* Typing more triggers a second request */
|
||||||
|
type('bob');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
/* User picks the visible suggestion while the request is in flight */
|
||||||
|
list.querySelector('button').dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input.value).toBe('bob@example.com, ');
|
||||||
|
expect(list.hidden).toBe(true);
|
||||||
|
|
||||||
|
/* The stale response must not reopen the list */
|
||||||
|
second.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([ZOE]),
|
||||||
|
});
|
||||||
|
await settle(0);
|
||||||
|
|
||||||
|
expect(list.hidden).toBe(true);
|
||||||
|
expect(list.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches only the last whitespace-separated fragment', async () => {
|
||||||
|
fetch.mockReturnValue(response([BOB]));
|
||||||
|
|
||||||
|
type('alice@example.com bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
SEARCH_URL + '?query=bo',
|
||||||
|
expect.objectContaining({ headers: { Accept: 'application/json' } })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps whitespace-separated entries when picking a contact', async () => {
|
||||||
|
fetch.mockReturnValue(response([BOB]));
|
||||||
|
|
||||||
|
type('alice@example.com bo');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
list.querySelector('button').dispatchEvent(
|
||||||
|
new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(input.value).toBe('alice@example.com bob@example.com, ');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['tests/js/**/*.test.mjs'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user