feat: menu contextuel et selection type Excel dans la grille XLSX #155
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m41s
CI / e2e (push) Successful in 15m30s

This commit is contained in:
2026-09-29 16:43:39 -04:00
parent 69927176df
commit 383ffa6a65
18 changed files with 681 additions and 44 deletions
+99 -5
View File
@@ -179,6 +179,16 @@ const clickDialog = (which) => {
btn.click();
};
/** #155 — open the grid context menu on a cell (right-click). */
const openMenuOn = (area, ref) => {
const td = area.querySelector(`td[data-cell="${ref}"]`);
assert.ok(td, `cell ${ref} exists`);
td.dispatchEvent(new w.MouseEvent("contextmenu", { bubbles: true, clientX: 12, clientY: 12 }));
return document.querySelector(".xlsx-context-menu");
};
const menuItem = (label) =>
[...document.querySelectorAll(".xlsx-context-item")].find((b) => b.textContent === label);
const lossyError = {
ok: false,
status: 409,
@@ -559,14 +569,16 @@ const mountGrid = () => {
return area;
};
await test("clicking a header sorts the rows numerically or lexically", () => {
await test("the context menu sorts the rows numerically or lexically", () => {
const area = mountGrid();
// Sort by column B (numbers) ascending: 2, 3, 10.
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_asc"]).click();
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
assert.deepEqual(cells, ["2", "3", "10"]);
// Second click: descending.
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
// Re-open and sort descending.
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_desc"]).click();
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
assert.deepEqual(cells, ["10", "3", "2"]);
});
@@ -574,7 +586,8 @@ await test("clicking a header sorts the rows numerically or lexically", () => {
await test("a dirty cell travels with its row during a sort", () => {
const area = mountGrid();
editCell(area, "A3", "Cerise modifiée");
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
openMenuOn(area, "B1");
menuItem(FR["xlsx.sort_asc"]).click();
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
@@ -963,6 +976,87 @@ await test("the inspector exposes a resize handle", () => {
assert.ok(area.querySelector("#xlsx-inspector-resize"), "resize handle present");
});
// ── #155 — selection & context menu ─────────────────────────────────────────
await test("a right-click on a cell opens a themed context menu", () => {
const area = mount();
const menu = openMenuOn(area, "A1");
assert.ok(menu, "menu rendered");
assert.ok(menuItem(FR["xlsx.insert_row_above"]), "insert row item");
assert.ok(menuItem(FR["xlsx.sort_asc"]), "sort item");
assert.ok(menuItem(FR["xlsx.clear_contents"]), "clear item");
});
await test("the context menu inserts a row through the structure endpoint", async () => {
const area = mount();
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({
ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], xlsx_lossy_features: [] },
}); // re-read
openMenuOn(area, "A2");
menuItem(FR["xlsx.insert_row_above"]).click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "row_insert", sheet: "Feuille1", at: 2, count: 1 }]);
});
await test("clear contents empties the selected cells (local edit)", () => {
const area = mount();
openMenuOn(area, "A1");
menuItem(FR["xlsx.clear_contents"]).click();
assert.equal(area.querySelector('td[data-cell="A1"]').textContent, "");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
});
await test("clicking a column header selects the whole column", () => {
const area = mount();
area.querySelector(".xlsx-table thead th:nth-child(2)").click(); // column A
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:A2");
const selected = [...area.querySelectorAll("td.xlsx-selected")].map((td) => td.dataset.cell).sort();
assert.deepEqual(selected, ["A1", "A2"]);
});
await test("clicking a row header selects the whole row", () => {
const area = mount();
area.querySelector("th.xlsx-rownum").click(); // row 1
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
});
await test("dragging from a cell to another selects the range", () => {
const area = mount();
const viewer = area.querySelector(".xlsx-viewer");
const a1 = area.querySelector('td[data-cell="A1"]');
const b2 = area.querySelector('td[data-cell="B2"]');
a1.dispatchEvent(new w.MouseEvent("mousedown", { bubbles: true, button: 0 }));
b2.dispatchEvent(new w.MouseEvent("mouseover", { bubbles: true, button: 0 }));
viewer.dispatchEvent(new w.MouseEvent("mouseup", { bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B2");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 4);
});
await test("Shift+Arrow extends the selection from the anchor", () => {
const area = mount();
const a1 = area.querySelector('td[data-cell="A1"]');
a1.focus();
a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true, bubbles: true }));
assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1:B1");
assert.equal(area.querySelectorAll("td.xlsx-selected").length, 2);
});
await test("the context menu module exposes open/close helpers", async () => {
const mod = await import(
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "xlsx", "context-menu.js")).href
);
const menu = mod.openContextMenu(5, 5, [{ label: "Only" }]);
assert.equal(menu.querySelectorAll(".xlsx-context-item").length, 1);
assert.equal(mod.isContextMenuOpen(), true);
mod.closeContextMenu();
assert.equal(mod.isContextMenuOpen(), false);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);