feat: dialogues themes et conflits non bloquants pour la visionneuse XLSX #154
CI / lint (push) Successful in 2m31s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m40s
CI / e2e (push) Canceled after 42s

This commit is contained in:
2026-09-29 14:50:47 -04:00
parent 605060c51d
commit 8da65611cb
18 changed files with 368 additions and 77 deletions
+19 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.40.0**. > [Unreleased](#unreleased). La dernière version livrée est **2.41.0**.
--- ---
@@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.41.0] — 2026-09-29
### Ajouté
- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non
bloquants.**
Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème**
(`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant
`.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer /
dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la
confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`)
n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en
conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée
signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs`
(42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend.
---
## [2.40.0] — 2026-09-29 ## [2.40.0] — 2026-09-29
### Ajouté ### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.41.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.40.0). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.41.0).
--- ---
*Projet : ObsiGate | Version : 2.40.0 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.41.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.40.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.41.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.40.0). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.41.0).
--- ---
*Project: ObsiGate | Version: 2.40.0 | Last updated: September 2026* *Project: ObsiGate | Version: 2.41.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.40.0 2.41.0
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.40.0" version = "2.41.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.40.0" version = "2.41.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.40.0", "version": "2.41.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+6 -1
View File
@@ -159,10 +159,15 @@ réécrire : **valeurs calculées** mises en cache par Excel, segments
(slicers), chronologies, contrôles de formulaire, connexions/requêtes, (slicers), chronologies, contrôles de formulaire, connexions/requêtes,
XML personnalisé, signature numérique, commentaires enrichis, macros. XML personnalisé, signature numérique, commentaires enrichis, macros.
L'ouverture affiche alors un bandeau qui les liste, et la première L'ouverture affiche alors un bandeau qui les liste, et la première
sauvegarde demande confirmation. Si vous refusez, rien n'est écrit. sauvegarde demande confirmation dans une fenêtre intégrée au thème de
l'application. Si vous refusez, rien n'est écrit.
> Les **graphiques, images et tableaux croisés** sont, eux, bien conservés. > Les **graphiques, images et tableaux croisés** sont, eux, bien conservés.
Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate
n'interrompt pas votre travail : un bandeau vous propose de **réessayer**
l'enregistrement, vos modifications restant en place.
### Formules ### Formules
Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme
+7 -6
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.40.0 | **Dernière mise à jour :** 2026-09-29 > **Version :** 2.41.0 | **Dernière mise à jour :** 2026-09-29
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -97,10 +97,11 @@
### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur) ### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur)
- **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡 - **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡
- **Statut :** 🔵 **en cours** — **Lot 1 livré le 2026-09-29** (ruban de commandes groupé, onglets - **Statut :** 🔵 **en cours** — **Lots 1 & 2 livrés le 2026-09-29** (ruban de commandes groupé,
de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non
recalculées, tokens de grille et affordances). Lots suivants : dialogues thémés, inspecteur recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`,
droit (dashboard + IA), découpage modulaire + undo/redo. bandeau de conflit 409 non bloquant, indicateur *dirty*). Lots suivants : inspecteur droit
(dashboard + IA), découpage modulaire + undo/redo.
- **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) - **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md)
- **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale : - **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale :
commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés
@@ -110,7 +111,7 @@
- **Sous-tâches :** - **Sous-tâches :**
- [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + », - [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + »,
badges d'état, tokens de grille et affordances visuelles (Lot 1) badges d'état, tokens de grille et affordances visuelles (Lot 1)
- [ ] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2) - [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2)
- [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3) - [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3)
- [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4) - [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4)
+12 -7
View File
@@ -2,8 +2,8 @@
> **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md) > **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md)
> **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale) > **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale)
> **Statut :** 🔵 **en cours** — Lot 1 livré le 2026-09-29 > **Statut :** 🔵 **en cours** — Lots 1 & 2 livrés le 2026-09-29
> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) > **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 · Lot 4)
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte > **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
> l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.** > l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.**
@@ -68,12 +68,16 @@ la grille ni le backend.
`--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs, `--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs,
zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol). zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol).
### Lot 2 — Dialogues thémés & feedback (à venir) ### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)*
- Remplacer `confirm()`/`prompt()` par des **modales thémées** (`.obsigate-modal-*`) et des - **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based,
**toasts** : structure (feuille/ligne/colonne), perte (409 `xlsx_lossy_content`), conflit réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`).
(409 `conflict`) en bandeau non bloquant. - **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter /
- Indicateur *dirty* sur l'onglet de feuille et la barre. renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la
confirmation de perte (409 `xlsx_lossy_content`).
- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict`
avec bouton **Réessayer** — les modifications sont conservées.
- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée.
### Lot 3 — Inspecteur droit (à venir) ### Lot 3 — Inspecteur droit (à venir)
@@ -116,3 +120,4 @@ la grille ni le backend.
| Date | Événement | | Date | Événement |
|---|---| |---|---|
| 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) | | 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) |
| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) |
+82
View File
@@ -1143,6 +1143,88 @@ const FileOperations = {
}; };
// ---------------------------------------------------------------------------
// Generic themed dialogs (#154-A2)
// ---------------------------------------------------------------------------
// Promise-based replacements for window.confirm() / window.prompt() so the
// Excel viewer's structure actions and lossy-write confirmations stay inside
// the app theme (and are keyboard accessible) instead of native dialogs.
function _closeDialog(overlay, resolve, value) {
overlay.classList.remove("active");
if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey);
setTimeout(() => overlay.remove(), 200);
resolve(value);
}
function _openDialog(innerHtml) {
const overlay = document.createElement("div");
overlay.className = "obsigate-modal-overlay";
const modal = document.createElement("div");
modal.className = "obsigate-modal";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.innerHTML = innerHtml;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => overlay.classList.add("active"), 10);
return { overlay, modal };
}
/** Themed replacement for window.confirm(). Resolves to a boolean. */
export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body"><p class="modal-confirm-text">${escapeHtml(message)}</p></div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn ${danger ? "danger" : "primary"}" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false));
overlay._onKey = (e) => {
if (e.key === "Escape") done(false);
else if (e.key === "Enter") done(true);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20);
});
}
/** Themed replacement for window.prompt(). Resolves to the string (or null). */
export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) {
return new Promise((resolve) => {
const { overlay, modal } = _openDialog(`
<div class="obsigate-modal-header"><h3 class="obsigate-modal-title">${escapeHtml(title)}</h3></div>
<div class="obsigate-modal-body">
<div class="modal-form-group">
${message ? `<label class="modal-label">${escapeHtml(message)}</label>` : ""}
<input type="text" class="modal-input" data-dialog="input" spellcheck="false"
value="${escapeHtml(value)}" placeholder="${escapeHtml(placeholder)}" />
</div>
</div>
<div class="obsigate-modal-footer">
<button class="modal-btn" data-dialog="cancel">${escapeHtml(cancelLabel || t("common.cancel"))}</button>
<button class="modal-btn primary" data-dialog="confirm">${escapeHtml(confirmLabel || t("common.confirm"))}</button>
</div>`);
const input = modal.querySelector('[data-dialog="input"]');
const done = (v) => _closeDialog(overlay, resolve, v);
overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value));
modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null));
overlay._onKey = (e) => {
if (e.key === "Escape") done(null);
else if (e.key === "Enter") done(input.value);
};
document.addEventListener("keydown", overlay._onKey);
setTimeout(() => { input.focus(); input.select(); }, 20);
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Find in Page Manager // Find in Page Manager
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+94 -15
View File
@@ -3,7 +3,7 @@ import { api, AuthManager } from './auth.js';
import { state } from './state.js'; import { state } from './state.js';
import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js'; import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js';
import { isInlineEditorActive, queryEditor } from './editor-inline.js'; import { isInlineEditorActive, queryEditor } from './editor-inline.js';
import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js'; import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js';
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js'; import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js'; import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js'; import { initDashboardTabs } from './sync.js';
@@ -1266,7 +1266,41 @@ export function renderXlsxViewer(area, data) {
const saveBtn = area.querySelector("#xlsx-save-btn"); const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")]; const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; }; const refreshSaveState = () => {
const dirty = dirtyCount() > 0;
saveBtn.disabled = !dirty;
saveBtn.classList.toggle("is-dirty", dirty);
// #154-A2 — star a sheet tab that holds unsaved edits.
panelEls.forEach((panel) => {
const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`);
if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0);
});
};
// #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept
// and the user can retry the save without losing their work.
const bannerHost = document.createElement("div");
bannerHost.className = "xlsx-banner-host";
area.querySelector(".xlsx-panels").before(bannerHost);
const showConflict = (msg) => {
bannerHost.innerHTML = "";
const banner = document.createElement("div");
banner.className = "xlsx-banner xlsx-banner-conflict";
banner.setAttribute("role", "alert");
banner.innerHTML = `<i data-lucide="git-merge" class="xlsx-banner-icon"></i>
<span class="xlsx-banner-text">${escapeHtml(msg)}</span>
<button class="btn-action xlsx-banner-retry" type="button">${escapeHtml(t("xlsx.conflict_retry"))}</button>
<button class="btn-action xlsx-banner-dismiss" type="button" aria-label="${escapeHtml(t("common.cancel"))}">✕</button>`;
banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => {
bannerHost.innerHTML = "";
saveBtn.click();
});
banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => {
bannerHost.innerHTML = "";
});
bannerHost.appendChild(banner);
safeCreateIcons();
};
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is // #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
// fetched window by window from GET …/xlsx/sheet when the user reaches the // fetched window by window from GET …/xlsx/sheet when the user reaches the
@@ -1570,7 +1604,13 @@ export function renderXlsxViewer(area, data) {
if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) { if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) {
const features = (err.details && err.details.features) || lossy; const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", "); const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (!confirm(t("xlsx.lossy_confirm", { features: labels }))) throw err; const proceed = await showConfirm({
title: t("xlsx.lossy_title"),
message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (!proceed) throw err;
lossyConfirmed = true; lossyConfirmed = true;
force = true; force = true;
continue; continue;
@@ -1591,6 +1631,9 @@ export function renderXlsxViewer(area, data) {
// A refused confirmation is a decision, not a failure: neutral toast. // A refused confirmation is a decision, not a failure: neutral toast.
if (err && err.code === "xlsx_lossy_content") { if (err && err.code === "xlsx_lossy_content") {
showToast(t("xlsx.lossy_cancelled"), "info"); showToast(t("xlsx.lossy_cancelled"), "info");
} else if (err && err.code === "conflict") {
// #154-A2 — non-blocking: the edits are kept, the user may retry.
showConflict(t("xlsx.conflict_msg"));
} else { } else {
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
} }
@@ -1774,13 +1817,17 @@ export function renderXlsxViewer(area, data) {
renderXlsxViewer(area, fresh); renderXlsxViewer(area, fresh);
}; };
const structureError = (err, retryActions) => { const structureError = async (err, retryActions) => {
if (err && err.code === "xlsx_lossy_content") { if (err && err.code === "xlsx_lossy_content") {
const features = (err.details && err.details.features) || lossy; const features = (err.details && err.details.features) || lossy;
const labels = features.map((f) => t("xlsx.feature_" + f)).join(", "); const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) { const proceed = await showConfirm({
return putStructure(retryActions, true); // re-emitted with force title: t("xlsx.lossy_title"),
} message: t("xlsx.lossy_confirm", { features: labels }),
confirmLabel: t("xlsx.lossy_confirm_btn"),
danger: true,
});
if (proceed) return putStructure(retryActions, true); // re-emitted with force
showToast(t("xlsx.lossy_cancelled"), "info"); showToast(t("xlsx.lossy_cancelled"), "info");
return null; return null;
} }
@@ -1788,11 +1835,14 @@ export function renderXlsxViewer(area, data) {
return null; return null;
}; };
// #154-A1 — the “+” button mirrors the structure menu's « Ajouter une // #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une
// feuille » item, right where the tabs are (Excel-like). // feuille » item, right where the tabs are (Excel-like).
const tabAddBtn = area.querySelector("#xlsx-tab-add"); const tabAddBtn = area.querySelector("#xlsx-tab-add");
if (tabAddBtn) tabAddBtn.addEventListener("click", async () => { if (tabAddBtn) tabAddBtn.addEventListener("click", async () => {
const name = prompt(t("xlsx.structure_prompt_add")); const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return; if (!name) return;
const actions = [{ op: "sheet_add", name }]; const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
@@ -1820,26 +1870,43 @@ export function renderXlsxViewer(area, data) {
menu.appendChild(b); menu.appendChild(b);
}; };
item(t("xlsx.sheet_add"), async () => { item(t("xlsx.sheet_add"), async () => {
const name = prompt(t("xlsx.structure_prompt_add")); const name = await showPrompt({
title: t("xlsx.sheet_add"),
message: t("xlsx.structure_prompt_add"),
});
if (!name) return; if (!name) return;
const actions = [{ op: "sheet_add", name }]; const actions = [{ op: "sheet_add", name }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
item(t("xlsx.sheet_rename"), async () => { item(t("xlsx.sheet_rename"), async () => {
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName); const to = await showPrompt({
title: t("xlsx.sheet_rename"),
message: t("xlsx.structure_prompt_rename"),
value: sheetName,
});
if (!to || to === sheetName) return; if (!to || to === sheetName) return;
const actions = [{ op: "sheet_rename", from: sheetName, to }]; const actions = [{ op: "sheet_rename", from: sheetName, to }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
item(t("xlsx.sheet_duplicate"), async () => { item(t("xlsx.sheet_duplicate"), async () => {
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`); const as = await showPrompt({
title: t("xlsx.sheet_duplicate"),
message: t("xlsx.structure_prompt_add"),
value: `${sheetName} (copie)`,
});
if (!as) return; if (!as) return;
const actions = [{ op: "sheet_duplicate", name: sheetName, as }]; const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
item(t("xlsx.sheet_delete"), async () => { item(t("xlsx.sheet_delete"), async () => {
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; } if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return; const okDelete = await showConfirm({
title: t("xlsx.sheet_delete"),
message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }),
confirmLabel: t("xlsx.sheet_delete"),
danger: true,
});
if (!okDelete) return;
const actions = [{ op: "sheet_delete", name: sheetName }]; const actions = [{ op: "sheet_delete", name: sheetName }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
@@ -1850,7 +1917,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
item(t("xlsx.row_delete"), async () => { item(t("xlsx.row_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return; const okRow = await showConfirm({
title: t("xlsx.row_delete"),
message: t("xlsx.structure_confirm_row", { n: parsed.row }),
confirmLabel: t("xlsx.row_delete"),
danger: true,
});
if (!okRow) return;
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }]; const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
@@ -1859,7 +1932,13 @@ export function renderXlsxViewer(area, data) {
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
item(t("xlsx.col_delete"), async () => { item(t("xlsx.col_delete"), async () => {
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return; const okCol = await showConfirm({
title: t("xlsx.col_delete"),
message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }),
confirmLabel: t("xlsx.col_delete"),
danger: true,
});
if (!okCol) return;
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }]; const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
try { await putStructure(actions); } catch (err) { structureError(err, actions); } try { await putStructure(actions); } catch (err) { structureError(err, actions); }
}); });
+3
View File
@@ -1827,6 +1827,9 @@
"xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.", "xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.",
"xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}", "xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}",
"xlsx.lossy_cancelled": "Save cancelled", "xlsx.lossy_cancelled": "Save cancelled",
"xlsx.lossy_confirm_btn": "Save anyway",
"xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.",
"xlsx.conflict_retry": "Retry",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)", "xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.cached_value_title": "Last value calculated by Excel", "xlsx.cached_value_title": "Last value calculated by Excel",
"xlsx.tabs_add_sheet": "Add a sheet", "xlsx.tabs_add_sheet": "Add a sheet",
+3
View File
@@ -1827,6 +1827,9 @@
"xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.", "xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.",
"xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}", "xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}",
"xlsx.lossy_cancelled": "Sauvegarde annulée", "xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.lossy_confirm_btn": "Enregistrer quand même",
"xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.",
"xlsx.conflict_retry": "Réessayer",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)", "xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel", "xlsx.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.tabs_add_sheet": "Ajouter une feuille", "xlsx.tabs_add_sheet": "Ajouter une feuille",
+49
View File
@@ -11051,6 +11051,55 @@ body.desktop-mode .editor-container {
.xlsx-status-formula { .xlsx-status-formula {
color: var(--accent); color: var(--accent);
} }
/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */
.xlsx-save-primary.is-dirty {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.xlsx-tab-dirty::after {
content: "•";
margin-left: 5px;
color: var(--warning, #e0a800);
}
/* #154-A2 — non-blocking banners (conflict, …). */
.xlsx-banner-host {
display: flex;
flex-direction: column;
gap: 8px;
}
.xlsx-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
font-size: 0.82rem;
}
.xlsx-banner-conflict {
border-left-color: var(--warning, #e0a800);
}
.xlsx-banner-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: var(--warning, #e0a800);
}
.xlsx-banner-text {
flex: 1;
min-width: 0;
}
.xlsx-banner-dismiss {
padding: 2px 8px;
}
.modal-confirm-text {
color: var(--text-primary);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-line;
}
.xlsx-table { .xlsx-table {
background: var(--grid-bg); background: var(--grid-bg);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.40.0", "version": "2.41.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js", "main": "patch.js",
"directories": { "directories": {
+13 -9
View File
@@ -116,12 +116,6 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await login(page); await login(page);
await openFixture(page); await openFixture(page);
let dialogMessage = null;
page.on('dialog', async (dialog) => {
dialogMessage = dialog.message();
await dialog.accept();
});
const cell = page.locator('#content-area td[data-cell="A2"]'); const cell = page.locator('#content-area td[data-cell="A2"]');
await cell.click(); await cell.click();
await cell.fill('Total confirmé'); await cell.fill('Total confirmé');
@@ -131,7 +125,11 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await expect(save).toBeEnabled(); await expect(save).toBeEnabled();
await save.click(); await save.click();
await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments'); // #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one.
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog).toContainText('segments');
await dialog.locator('[data-dialog="confirm"]').click();
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
// La cellule reste modifiée côté UI (plus de marque « sale »). // La cellule reste modifiée côté UI (plus de marque « sale »).
@@ -145,13 +143,19 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
const toggle = page.locator('#xlsx-formula-btn'); const toggle = page.locator('#xlsx-formula-btn');
await expect(toggle).toHaveAttribute('aria-pressed', 'false'); await expect(toggle).toHaveAttribute('aria-pressed', 'false');
// 409 → confirmation, puis reprise avec force (le toggle reste désactivé). // 409 → confirmation thémée, puis reprise avec force (le toggle reste désactivé).
page.on('dialog', (dialog) => dialog.accept());
const cell = page.locator('#content-area td[data-cell="B2"]'); const cell = page.locator('#content-area td[data-cell="B2"]');
await cell.click(); await cell.click();
await cell.fill('=B1*3'); await cell.fill('=B1*3');
await cell.press('Enter'); await cell.press('Enter');
await page.locator('#xlsx-save-btn').click(); await page.locator('#xlsx-save-btn').click();
const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal');
// A lossy workbook asks for confirmation. A previous test's save may already
// have dropped the slicers part, in which case the PUT succeeds directly.
await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
if (await dialog.isVisible()) {
await dialog.locator('[data-dialog="confirm"]').click();
}
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
}); });
}); });
+69 -27
View File
@@ -105,6 +105,9 @@ async function test(name, fn) {
if (apiQueue.length) return apiQueue.shift(); if (apiQueue.length) return apiQueue.shift();
return { ok: true, status: 200, body: { status: "ok" } }; return { ok: true, status: 200, body: { status: "ok" } };
}; };
// #154-A2 — themed dialogs live in document.body; clear any left behind by a
// previous test so the helpers always reach the current one.
document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove());
try { try {
await fn(); await fn();
console.log(` ✓ ${name}`); console.log(` ✓ ${name}`);
@@ -165,6 +168,17 @@ function editCell(area, ref, text) {
return td; return td;
} }
/** #154-A2 — the themed dialog is the last overlay in document.body. */
const lastDialog = () => {
const overlays = document.querySelectorAll(".obsigate-modal-overlay");
return overlays[overlays.length - 1] || null;
};
const clickDialog = (which) => {
const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`);
assert.ok(btn, `dialog button "${which}" is present`);
btn.click();
};
const lossyError = { const lossyError = {
ok: false, ok: false,
status: 409, status: 409,
@@ -248,12 +262,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async ()
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click(); area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1); // #154-A2 — a themed dialog replaces window.confirm(), and no retry happens
// before the user answers.
const dialog = lastDialog();
assert.ok(dialog, "a themed confirmation is shown");
assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent);
assert.equal(calls.length, 1, "nothing is retried before the answer");
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
assert.equal(calls[0].body.force, false); assert.equal(calls[0].body.force, false);
assert.equal(calls[1].body.force, true); assert.equal(calls[1].body.force, true);
// The prompt names the features the backend reported.
assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]);
// Save succeeded → cells are no longer dirty. // Save succeeded → cells are no longer dirty.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
}); });
@@ -264,27 +283,52 @@ await test("confirming once is enough for the following saves", async () => {
nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} });
area.querySelector("#xlsx-save-btn").click(); area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
editCell(area, "A1", "2"); editCell(area, "A1", "2");
area.querySelector("#xlsx-save-btn").click(); area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1, "the user is not asked twice");
assert.equal(calls.length, 3); assert.equal(calls.length, 3);
assert.equal(calls[2].body.force, true); assert.equal(calls[2].body.force, true);
// Wait out the dialog close animation, then confirm none is left.
await new Promise((r) => setTimeout(r, 250));
assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice");
}); });
await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => { await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => {
const area = mount({ lossy: ["slicers"] }); const area = mount({ lossy: ["slicers"] });
editCell(area, "A1", "250"); editCell(area, "A1", "250");
nextResponse = () => lossyError; nextResponse = () => lossyError;
confirmAnswer = false;
area.querySelector("#xlsx-save-btn").click(); area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
assert.equal(confirmCalls, 1); clickDialog("cancel");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 1, "no retry after a refusal"); assert.equal(calls.length, 1, "no retry after a refusal");
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
}); });
await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => {
const area = mount();
editCell(area, "A1", "250");
nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } });
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 10));
const banner = area.querySelector(".xlsx-banner-conflict");
assert.ok(banner, "a conflict banner is shown");
assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent);
// The edit is preserved and the save button is usable again.
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
// The retry re-runs the save.
nextResponse = () => ({ ok: true, status: 200, body: {} });
banner.querySelector(".xlsx-banner-retry").click();
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2);
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry");
});
await test("a non-409 failure is not retried", async () => { await test("a non-409 failure is not retried", async () => {
const area = mount(); const area = mount();
editCell(area, "A1", "250"); editCell(area, "A1", "250");
@@ -582,29 +626,25 @@ await test("CSV export downloads the visible sheet without the cached shadows",
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => { await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
const area = mount(); const area = mount();
// The prompt is resolved through the module scope: stub it globally.
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({ apiQueue.push({
ok: true, status: 200, ok: true, status: 200,
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] }, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
}); // re-read }); // re-read
area.querySelector("#xlsx-structure-btn").click(); area.querySelector("#xlsx-structure-btn").click();
const items = [...area.querySelectorAll(".xlsx-structure-item")]; const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]);
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
addBtn.click(); addBtn.click();
await new Promise((r) => setTimeout(r, 5)); await new Promise((r) => setTimeout(r, 5));
globalThis.prompt = realPrompt; // #154-A2 — a themed prompt replaces window.prompt().
const input = lastDialog()?.querySelector('[data-dialog="input"]');
assert.ok(input, "a themed prompt asks for the sheet name");
input.value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/); assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]); assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
assert.equal(calls[0].body.force, false); assert.equal(calls[0].body.force, false);
// The viewer re-rendered from the server payload (a single sheet → no tabs).
assert.ok(
area.querySelector("#content-area, .xlsx-viewer") || area,
"the viewer was rebuilt",
);
assert.ok( assert.ok(
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf", area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
"the re-render shows the fresh payload", "the re-render shows the fresh payload",
@@ -626,9 +666,7 @@ await test("sheet_delete confirms and is refused on the last sheet", async () =>
await test("the 409 lossy flow re-emits with force after confirmation", async () => { await test("the 409 lossy flow re-emits with force after confirmation", async () => {
const area = mount(); const area = mount();
// The prompt is resolved through the module scope: stub it globally. // The prompt is answered through the themed dialog.
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ apiQueue.push({
ok: false, status: 409, ok: false, status: 409,
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } }, body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
@@ -639,11 +677,15 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] }, body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
}); });
area.querySelector("#xlsx-structure-btn").click(); area.querySelector("#xlsx-structure-btn").click();
const items = [...area.querySelectorAll(".xlsx-structure-item")]; [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click(); await new Promise((r) => setTimeout(r, 5));
lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
const lossyDialog = lastDialog();
assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown");
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
globalThis.prompt = realPrompt;
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
assert.equal(calls.length, 3); assert.equal(calls.length, 3);
assert.equal(calls[1].body.force, true); assert.equal(calls[1].body.force, true);
}); });
@@ -743,8 +785,6 @@ await test("a csv shows no tab strip and no + button", () => {
await test("the + button adds a sheet through the structure endpoint", async () => { await test("the + button adds a sheet through the structure endpoint", async () => {
const area = mount(); const area = mount();
const realPrompt = globalThis.prompt;
globalThis.prompt = () => "Feuille 2";
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
apiQueue.push({ apiQueue.push({
ok: true, status: 200, ok: true, status: 200,
@@ -752,7 +792,9 @@ await test("the + button adds a sheet through the structure endpoint", async ()
}); // re-read }); // re-read
area.querySelector("#xlsx-tab-add").click(); area.querySelector("#xlsx-tab-add").click();
await new Promise((r) => setTimeout(r, 5)); await new Promise((r) => setTimeout(r, 5));
globalThis.prompt = realPrompt; lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2";
clickDialog("confirm");
await new Promise((r) => setTimeout(r, 10));
assert.equal(calls.length, 2); assert.equal(calls.length, 2);
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/); assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]); assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);