Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
01b8162
fix(change_request): render wizard search results as keyboard-reachab…
gonzalesedwin1123 Oct 2, 2026
1a9611a
fix(change_request): keyboard handling, focus management and live reg…
gonzalesedwin1123 Oct 2, 2026
c4b3054
test(e2e): select the CR wizard registrant by keyboard only in test 1…
gonzalesedwin1123 Oct 2, 2026
ab2c732
chore(change_request): bump to 19.0.3.1.17 with HISTORY fragment (#580)
gonzalesedwin1123 Oct 2, 2026
c454b51
fix(change_request): presentational table, set position and decorativ…
gonzalesedwin1123 Oct 2, 2026
9fb14bd
fix(change_request): re-render-driven focus restore, form-hosted live…
gonzalesedwin1123 Oct 2, 2026
af12f28
test(e2e): find the search box by its label; prove Tab and arrow navi…
gonzalesedwin1123 Oct 2, 2026
0d647c0
docs(change_request): regenerate README from fragments (CI generator …
gonzalesedwin1123 Oct 2, 2026
bf35abf
test(e2e): address result rows by their option name in tests 16 and 1…
gonzalesedwin1123 Oct 2, 2026
3e17040
fix(change_request): leave focus alone when the user moved it during …
gonzalesedwin1123 Oct 2, 2026
b9ba3d2
fix(change_request): confirm the selection, keep the hand-off under b…
gonzalesedwin1123 Oct 2, 2026
1a4a831
docs(change_request): regenerate README from fragments (CI generator …
gonzalesedwin1123 Oct 2, 2026
dc61c92
fix(change_request): drop the pending live-region frame and mount tim…
gonzalesedwin1123 Oct 6, 2026
b9ad69b
refactor(change_request): pass the pager's disabled attribute as trus…
gonzalesedwin1123 Oct 6, 2026
52612d5
test(e2e): restore the real requestAnimationFrame after each test (#580)
gonzalesedwin1123 Oct 6, 2026
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
133 changes: 123 additions & 10 deletions e2e/tests/01-spp-starter-spmis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// 15 - Manually defines 5 more Change Request Document Type vocabulary codes for the Philippines (BIR Form 2316, Academic Calendar, Authorization Letter, Certificate of Enrolment, Valid ID of Parent), matching the e2e fixture PDFs in e2e/fixtures/, by adding rows to the same vocabulary's Codes tab as test 14
// 16 - Creates an "Edit Individual Information" change request for Santos, Jose Miguel (as admin), uploads a supporting document, and submits it for approval
// 17 - Creates an "Edit Group Information" change request for Santos Family (as admin), uploads a supporting document, and submits it for approval
// 18 - Creates an "Update ID Document" change request for Santos, Jose Miguel (as admin), sets a National ID with tomorrow's expiry date, uploads a supporting document, and submits it for approval
// 18 - Creates an "Update ID Document" change request for Santos, Jose Miguel (as admin), selecting the registrant by keyboard only and checking that the live region does not receive a late range announcement after the results are gone, sets a National ID with tomorrow's expiry date, uploads a supporting document, and submits it for approval
// 19 - Creates an HQ validator user (hqval@mail.com) with the "CR HQ Validator" role and sets its password
// 20 - Logs in as the HQ validator and resolves all three pending change requests: approves
// Edit Individual Information, rejects Edit Group Information (with a reason), and
Expand Down Expand Up @@ -69,6 +69,58 @@ async function logout(page: Page) {
await page.getByRole("menuitem", {name: "Log out"}).click();
}

type HeldFrames = {
pending: Map<number, FrameRequestCallback>;
next: number;
request: typeof window.requestAnimationFrame;
cancel: typeof window.cancelAnimationFrame;
};

// Hold every requestAnimationFrame callback instead of running it. Owl keeps
// its own bound copy of the function, so rendering is unaffected; only direct
// callers such as the CR search results widget are intercepted. This pins the
// ordering "the widget unmounts before its pending frame fires", so a test can
// check what that frame would have written once the widget is gone.
async function holdAnimationFrames(page: Page) {
await page.evaluate(() => {
const held: HeldFrames = {
pending: new Map(),
next: 1,
request: window.requestAnimationFrame,
cancel: window.cancelAnimationFrame,
};
(window as unknown as {__heldFrames: HeldFrames}).__heldFrames = held;
window.requestAnimationFrame = (cb) => {
const id = held.next++;
held.pending.set(id, cb);
return id;
};
window.cancelAnimationFrame = (id) => {
held.pending.delete(id);
};
});
}

// Restore the real functions, run whatever was held, and read the text of the
// given element right after, in the same tick, before any continuation that
// was waiting on a frame can write to it.
async function releaseAnimationFrames(page: Page, readSelector: string) {
return page.evaluate((selector) => {
const held = (window as unknown as {__heldFrames: HeldFrames}).__heldFrames;
window.requestAnimationFrame = held.request;
window.cancelAnimationFrame = held.cancel;
const callbacks = [...held.pending.values()];
held.pending.clear();
for (const cb of callbacks) {
cb(performance.now());
}
return {
ran: callbacks.length,
text: document.querySelector(selector)?.textContent ?? null,
};
}, readSelector);
}

function formatDateMDY(date: Date): string {
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
Expand Down Expand Up @@ -135,6 +187,23 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => {
});

test.afterEach(async ({}, testInfo) => {
// A test that fails between holdAnimationFrames and releaseAnimationFrames
// would leave the stub in place for every later test in this serial suite;
// restore the real functions so their failures report their own cause.
// The page may be closed or mid-navigation after a failure, so a throw
// here must not mask the test's own error.
if (page) {
await page
.evaluate(() => {
const w = window as unknown as {__heldFrames?: HeldFrames};
if (w.__heldFrames) {
window.requestAnimationFrame = w.__heldFrames.request;
window.cancelAnimationFrame = w.__heldFrames.cancel;
delete w.__heldFrames;
}
})
.catch(() => {});
}
if (testInfo.status !== testInfo.expectedStatus && !process.env.CI) {
console.log(
`❌ "${testInfo.title}" failed — pausing for investigation (set CI=1 to skip)`
Expand Down Expand Up @@ -998,8 +1067,12 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => {
await page.getByText("Edit Individual Information").click();
console.log("✅ Request Type: Edit Individual Information");

await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("san");
await page.getByRole("cell", {name: "SANTOS, JOSE MIGUEL"}).click();
await page.getByRole("textbox", {name: "Search Registrant"}).fill("san");
// Result rows are listbox options (#580); their cells are presentational to
// the accessibility tree, so the row is addressed by its option name.
await page
.getByRole("option", {name: "SANTOS, JOSE MIGUEL, Individual", exact: true})
.click();
console.log("✅ Registrant selected: SANTOS, JOSE MIGUEL");

await page.getByRole("button", {name: "Create"}).click();
Expand Down Expand Up @@ -1065,8 +1138,8 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => {
await page.getByText("Edit Group Information").click();
console.log("✅ Request Type: Edit Group Information");

await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("san");
await page.getByRole("cell", {name: "Santos Family"}).click();
await page.getByRole("textbox", {name: "Search Registrant"}).fill("san");
await page.getByRole("option", {name: "Santos Family, Group", exact: true}).click();
console.log("✅ Registrant selected: Santos Family");

await page.getByRole("button", {name: "Create"}).click();
Expand Down Expand Up @@ -1126,11 +1199,51 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => {
await page.getByText("Update ID Document").click();
console.log("✅ Request Type: Update ID Document");

await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("sant");
await page
.locator("tr.o_cr_search_result", {hasText: "SANTOS, JOSE MIGUEL"})
.click();
console.log("✅ Registrant selected: SANTOS, JOSE MIGUEL");
const searchBox = page.getByRole("textbox", {name: "Search Registrant"});
// Frames are held from before the results mount until after the selection:
// the widget fills the live region one frame after clearing it, and the
// selection unmounts the widget, so the frame holding the range
// announcement must be cancelled on unmount rather than land on the
// emptied region once the results are gone (#581 review).
await holdAnimationFrames(page);
await searchBox.fill("sant");
// Keyboard path (#580): the result rows are listbox options. The role
// locator proves role + accessible name; Tab from the search box proves the
// list is in the Tab order (fewer than 11 hits, so both pager buttons are
// disabled and skipped); ArrowDown proves the keydown handler moves focus;
// Enter exercises the keydown path rather than click, and the focus
// assertion proves focus is handed to "Change Registrant" once the results
// block disappears. Tests 16 and 17 keep the mouse path.
const options = page.getByRole("option");
await options.first().waitFor();
await searchBox.press("Tab");
await expect(options.first()).toBeFocused();
if ((await options.count()) > 1) {
await page.keyboard.press("ArrowDown");
await expect(options.nth(1)).toBeFocused();
await page.keyboard.press("Home");
await expect(options.first()).toBeFocused();
}
const santosRow = page.getByRole("option", {
name: "SANTOS, JOSE MIGUEL, Individual",
exact: true,
});
await santosRow.focus();
await expect(santosRow).toBeFocused();
await page.keyboard.press("Enter");
const changeRegistrant = page.getByRole("button", {name: /Change Registrant/});
await expect(changeRegistrant).toBeVisible();
// The results are gone, so the widget has unmounted and cleared the live
// region; the frame it had queued for the range announcement must have
// gone with it, or releasing the frames writes the range back.
const liveRegion = page.locator(".o_cr_search_live").first();
await expect(liveRegion).toHaveText("");
const released = await releaseAnimationFrames(page, ".o_cr_search_live");
expect(released.text, "no range announcement after the results are gone").toBe("");
// With frames running again the focus hand-off completes and is confirmed.
await expect(changeRegistrant).toBeFocused();
await expect(liveRegion).toHaveText("Selected: SANTOS, JOSE MIGUEL, Individual");
console.log("✅ Registrant selected by keyboard: SANTOS, JOSE MIGUEL");

await page.getByRole("button", {name: "Create"}).click();
await page.waitForLoadState("domcontentloaded");
Expand Down
34 changes: 34 additions & 0 deletions spp_change_request_v2/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,40 @@ Before declaring a new CR type complete:
Changelog
=========

19.0.3.1.17
~~~~~~~~~~~

- fix(change_request): the registrant search results in the Create
Change Request wizard can be used from the keyboard and are announced
by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The
result rows were plain table rows with a mouse click handler, so a
keyboard user could type a search but never pick a result, and the
Previous/Next pager was made of links without a target that cannot
take focus at all. Rows are now options of a listbox — Tab reaches the
list, the arrow keys move between rows, Enter or Space selects — each
carrying the registrant's name and type as its accessible name and its
position in the whole result set, while the table around them is
presentational for assistive technology so its header row is not
announced as an empty table; the pager is made of real buttons whose
out-of-range side is disabled rather than merely greyed; the result
range and the empty-result message are mirrored into a live region;
focus is kept on the pager button after a page change and handed to
"Change Registrant" after a selection, instead of being dropped when
the results re-render, and is left alone when the user has moved it
elsewhere while a request was in flight; the selection is confirmed
through the same live region ("Selected: NAME, Individual") after the
focus move so a screen reader speaks both, which replaces the alert
role on the selected-registrant card (the request-type hint is a
status rather than an alert for the same reason); the pager buttons
are named "Previous/Next page of results" so they are not mistaken for
wizard steps; the icons on the selected-registrant card are hidden
from assistive technology like those in the rows; and every string the
search and the card render is now translatable. The search box is also
now labelled by its "Search Registrant" label rather than by its
placeholder, and the decorative type icons are hidden from assistive
technology. The inline results table looks and behaves the same for
mouse users (#580)

19.0.3.1.16
~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_change_request_v2/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "OpenSPP Change Request V2",
"version": "19.0.3.1.16",
"version": "19.0.3.1.17",
"sequence": 50,
"category": "OpenSPP",
"summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention",
Expand Down
4 changes: 4 additions & 0 deletions spp_change_request_v2/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.3.1.17

- fix(change_request): the registrant search results in the Create Change Request wizard can be used from the keyboard and are announced by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The result rows were plain table rows with a mouse click handler, so a keyboard user could type a search but never pick a result, and the Previous/Next pager was made of links without a target that cannot take focus at all. Rows are now options of a listbox — Tab reaches the list, the arrow keys move between rows, Enter or Space selects — each carrying the registrant's name and type as its accessible name and its position in the whole result set, while the table around them is presentational for assistive technology so its header row is not announced as an empty table; the pager is made of real buttons whose out-of-range side is disabled rather than merely greyed; the result range and the empty-result message are mirrored into a live region; focus is kept on the pager button after a page change and handed to "Change Registrant" after a selection, instead of being dropped when the results re-render, and is left alone when the user has moved it elsewhere while a request was in flight; the selection is confirmed through the same live region ("Selected: NAME, Individual") after the focus move so a screen reader speaks both, which replaces the alert role on the selected-registrant card (the request-type hint is a status rather than an alert for the same reason); the pager buttons are named "Previous/Next page of results" so they are not mistaken for wizard steps; the icons on the selected-registrant card are hidden from assistive technology like those in the rows; and every string the search and the card render is now translatable. The search box is also now labelled by its "Search Registrant" label rather than by its placeholder, and the decorative type icons are hidden from assistive technology. The inline results table looks and behaves the same for mouse users (#580)

### 19.0.3.1.16

- fix(change_request): an Edit Individual change request can be opened again on a registrant that already holds a future date of birth. `spp.change.request.create` prefills the detail from the registrant and that prefill is a write, so the guard added in 19.0.3.1.15 refused the copied value and the request could not be created at all — closing the very path field staff use to correct the date. A birthdate the guard would refuse is now dropped from the prefill mapping rather than offered, so the field arrives empty and a valid date has to be entered. The rule itself lives in one place (`_is_future_birthdate` on the mixin), so what prefill declines to offer and what the constraint refuses cannot drift apart.
Expand Down
Loading
Loading