diff --git a/CHANGELOG.md b/CHANGELOG.md index 704931e..4a5deb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog - FlowDeck +## v7.45.6 (2026-10-03) — Fenêtre de confirmation thématisée pour les suppressions de la poubelle + +### Changed + +- **Les 3 confirmations destructives de /trash** (suppression définitive + unitaire, suppression groupée, **Empty Trash**) remplacent le `confirm()` du + navigateur par une **fenêtre thématisée FlowDeck** : classes + `flowdeck-modal-overlay` / `.flowdeck-modal` déjà présentes dans `base.html` + (variables `--bg-secondary`, `--border`, `--accent`, rayon 12 px), overlay + cliquable pour annuler, **Échap** pour annuler, bouton Cancel + bouton de + confirmation coloré porteur du libellé exact (`Delete` / `Delete all` / + `Empty Trash`), titre + message reprenant le nom de la page ou le nombre de + pages concernées, `role="alertdialog"` + `aria-modal`. +- Aucun `confirm()` natif ne subsiste dans `trash.html` — le composant est + monté dans `trashData()` (`askConfirm` / `cancelConfirm` / `acceptConfirm`, + mutations de propriétés individuelles pour la réactivité Alpine). + +### Tests + +- `e2e/trash_ui.spec.js` : la fenêtre est ouverte, son titre et son message + vérifiés, **l'annulation est vérifiée** (page toujours présente), puis la + confirmation (suppression réelle : plus dans la poubelle + contenu effacé) et + enfin la purge via Empty Trash. Le handler `dialog` du test échoue + maintenant si un `confirm()`/`alert()` natif revient (`NATIVE_DIALOG`). + ## v7.45.5 (2026-10-03) — Poubelle : actions groupées, tri/filtres réels, purge, et 4 bugs ### Added diff --git a/ROADMAP.md b/ROADMAP.md index fe4038f..3c0a3ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1008,6 +1008,7 @@ Détails livrés : - Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément). - Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé. - [x] **Poubelle** (v7.45.5) — page /trash alignée sur ce que fait ce type de section : **sélection multiple** (select all porté au filtre courant + barre Restore/Delete/Clear), **Empty Trash** (`POST /board/api/trash/empty`, purge en masse en 1 requête), **tri** (récents/anciens/nom) et **filtre emplacement** réels (les deux boutons décoratifs « Last edited by ▾ » / « In ▾ » qui ne faisaient rien sont remplacés), **date de suppression + jours restants** par élément (rétention 30 j), compteur de résultats, états vides distincts, toasts sur chaque action. 4 bugs trouvés en route : (1) **Restore/Delete ne marchaient jamais** (`getCsrf()` = chaîne mais code lu `csrf?.[1]` → 403 CSRF silencieux), (2) **« Move to Trash » de l'éditeur = 404** (route `/board/api/pages/{id}/trash` inexistante, `.then()` naviguait quand même → la page n'allait jamais à la poubelle), (3) **page restaurée invisible** en Library/Recents/Private (`parent_section='Trash'` réécrit au delete, jamais remis → `deleted_at` = source de vérité unique + réparation au boot), (4) **routes trash sans auth** (401 ajouté, vérifié anonyme). Gates : `tests/test_trash_api.py` (5) + `e2e/trash_ui.spec.js` (1) + suite 1099 passed. +- [x] **Fenêtre de confirmation thématisée** (v7.45.6) — les 3 confirmations destructives de /trash (suppression unitaire, groupée, **Empty Trash**) ne passent plus par le `confirm()` du navigateur : fenêtre FlowDeck réutilisant les classes `flowdeck-modal*` déjà présentes dans `base.html` (variables du thème, overlay cliquable + Échap pour annuler, Cancel + bouton de confirmation coloré porteur du libellé exact, titre/message avec le nom de la page ou le nombre de pages, `role="alertdialog"`). Composant dans `trashData()` : `askConfirm` / `cancelConfirm` / `acceptConfirm` (mutations de propriétés individuelles, pitfall réactivité Alpine). Gate e2e : ouverture + titre + message + **annulation (page toujours là)** + confirmation (contenu réellement effacé) + purge, et le test échoue si un dialog natif revient (`NATIVE_DIALOG`). ### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28) > **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents). diff --git a/VERSION b/VERSION index 51db024..041703a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.45.5 +7.45.6 diff --git a/WORKLOAD.md b/WORKLOAD.md index 0f3efa1..e91bfe2 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.45.5 (poubelle : sélection groupée + Empty Trash + tri/filtres réels + dates par élément ; fix CSRF restore/delete, fix 404 « Move to Trash » éditeur, fix pages restaurées invisibles, auth sur les routes trash) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.45.6 (confirmations destructives de /trash en fenêtre thématisée FlowDeck — plus de confirm() natif, annulation/Échap testés en e2e) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index 156590c..e808d92 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.45.5", + version="7.45.6", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/templates/trash.html b/app/templates/trash.html index 7eefe5a..8d360e9 100644 --- a/app/templates/trash.html +++ b/app/templates/trash.html @@ -106,6 +106,25 @@ Once a page has been in Trash for 30 days, it will be automatically deleted ⓘ + + + {% endblock %} @@ -127,6 +146,8 @@ locOpen: false, selected: [], items: [], + // Confirmation thématique (remplace confirm() du navigateur) + confirm: { show: false, title: '', message: '', okText: 'Delete', action: null }, async init() { try { @@ -184,6 +205,27 @@ setSort(v) { this.sort = v; this.sortOpen = false; }, setLoc(v) { this.locFilter = v; this.locOpen = false; }, + // ── Confirmation (modal thème FlowDeck, classes flowdeck-modal de base) ── + // Mutations de propriétés individuelles (remplacer l'objet entier tue la + // réactivité Alpine — pitfall connu). + askConfirm(title, message, okText, action) { + this.confirm.title = title; + this.confirm.message = message; + this.confirm.okText = okText || 'Delete'; + this.confirm.action = action; + this.confirm.show = true; + }, + cancelConfirm() { + this.confirm.show = false; + this.confirm.action = null; + }, + acceptConfirm() { + const action = this.confirm.action; + this.confirm.show = false; + this.confirm.action = null; + if (action) action(); + }, + // ── API ────────────────────────────────────────────────────────── // getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait // csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux. @@ -210,13 +252,6 @@ else window.showToast('Restore failed', 'error'); } catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); } }, - async deleteForever(id) { - if (!confirm('Permanently delete this page? This cannot be undone.')) return; - try { - if (await this._delete(id)) window.showToast('Page permanently deleted', 'success'); - else window.showToast('Delete failed', 'error'); - } catch (e) { window.showToast('Delete failed: ' + e.message, 'error'); } - }, async restoreSelected() { const ids = this.selected.slice(); let ok = 0; @@ -225,18 +260,42 @@ } window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error'); }, - async deleteSelected() { - const ids = this.selected.slice(); - if (!confirm('Permanently delete ' + ids.length + ' page(s)? This cannot be undone.')) return; + async _deleteAndToast(ids) { let ok = 0; for (const id of ids) { try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ } } - window.showToast(ok + ' of ' + ids.length + ' page(s) deleted', ok === ids.length ? 'success' : 'error'); + const msg = ids.length === 1 + ? (ok ? 'Page permanently deleted' : 'Delete failed') + : ok + ' of ' + ids.length + ' page(s) deleted'; + window.showToast(msg, ok === ids.length ? 'success' : 'error'); + }, + deleteForever(id) { + const self = this; + const item = this.items.find(function (i) { return i.id === id; }); + const name = item ? item.name : 'this page'; + this.askConfirm('Delete permanently', + '“' + name + '” will be deleted for good. This cannot be undone.', + 'Delete', + function () { self._deleteAndToast([id]); }); + }, + deleteSelected() { + const self = this; + const ids = this.selected.slice(); + this.askConfirm('Delete ' + ids.length + ' page(s) for good?', + 'These pages will be permanently deleted. This cannot be undone.', + 'Delete all', + function () { self._deleteAndToast(ids); }); }, async emptyTrash() { + const self = this; const n = this.items.length; - if (!confirm('Permanently delete ALL ' + n + ' page(s) in the Trash? This cannot be undone.')) return; + this.askConfirm('Empty Trash?', + 'All ' + n + ' page(s) in the Trash will be permanently deleted. This cannot be undone.', + 'Empty Trash', + function () { self._emptyAndToast(n); }); + }, + async _emptyAndToast(n) { try { const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() }); if (!r.ok) throw new Error('HTTP ' + r.status); diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 7807def..3e51a4d 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.45.5" + "version": "7.45.6" }, "paths": { "/auth/register": { diff --git a/e2e/trash_ui.spec.js b/e2e/trash_ui.spec.js index 3a4c536..723a776 100644 --- a/e2e/trash_ui.spec.js +++ b/e2e/trash_ui.spec.js @@ -51,14 +51,16 @@ test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ const errs = []; page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160))); page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); }); - page.on('dialog', (d) => d.accept()); // confirm() des actions destructives + // Aucun dialog système acceptable : on échoue si le navigateur ouvre un + // confirm()/alert() natif (c'est tout l'objet du changement). + page.on('dialog', (d) => { errs.push('NATIVE_DIALOG ' + d.message()); d.dismiss().catch(() => {}); }); await login(page); // DB persistante : nommage unique par exécution, on ne dépend jamais des // restes des runs précédents. const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase(); - const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D'; + const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D', nameE = TAG + ' E'; const idA = await mkTrashed(page, nameA); const idB = await mkTrashed(page, nameB); @@ -109,6 +111,37 @@ test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ ids = await trashIds(page); expect(ids).not.toContain(idC); + // Suppression définitive unitaire → fenêtre de confirmation THÉMATIQUE + // (plus de confirm() du navigateur) + const idE = await mkTrashed(page, nameE); + await page.reload({ waitUntil: 'load' }); + await page.waitForTimeout(900); + const rowE = page.locator('.trash-item', { hasText: nameE }); + await expect(rowE).toHaveCount(1); + await rowE.first().locator('button[title="Delete permanently"]').click(); + + const modal = page.locator('#fd-trash-confirm'); // id sinon 2e match #fd-global-folder-modal + await expect(modal).toBeVisible(); + await expect(modal.locator('h3')).toHaveText('Delete permanently'); + await expect(modal.locator('.flowdeck-modal-body p')).toContainText(nameE); + + // Annulation → la page est toujours là + await modal.locator('button.cancel').click(); + await expect(modal).toBeHidden(); + await expect(rowE).toHaveCount(1); + + // Confirmation → suppression réelle (plus dans la poubelle, contenu effacé) + await rowE.first().locator('button[title="Delete permanently"]').click(); + await expect(modal).toBeVisible(); + await modal.locator('button.confirm').click(); + await page.waitForTimeout(900); + await expect(rowE).toHaveCount(0); + expect(await trashIds(page)).not.toContain(idE); + // Le SW transforme les réponses API non-ok (404 ici) en erreur réseau → + // `api()` peut rejeter ; dans les deux cas la page n'est plus servable. + const content = await api(page, 'GET', '/api/pages/' + idE + '/content').catch(() => ({ ok: false, status: 0 })); + expect(content.ok).toBeFalsy(); + // Purge totale — seulement si la poubelle ne contient QUE des pages E2E // (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui). await mkTrashed(page, nameD); @@ -116,8 +149,20 @@ test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ await page.waitForTimeout(900); await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1); const all = (await api(page, 'GET', '/board/api/trash')).body || []; + // Fenêtre Empty Trash : ouverture + titre + annulation — testée quel que soit + // le contenu (l'instance est partagée, on n'a pas le droit de tout vider). + await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click(); + await expect(modal).toBeVisible(); + await expect(modal.locator('h3')).toHaveText('Empty Trash?'); + await expect(modal.locator('.flowdeck-modal-body p')).toContainText(String(all.length)); + await modal.locator('button.cancel').click(); + await expect(modal).toBeHidden(); + await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1); + if (all.every((i) => i.name.startsWith('E2E Trash'))) { - await page.getByRole('button', { name: 'Empty Trash' }).click(); + await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click(); + await expect(modal).toBeVisible(); + await modal.locator('button.confirm').click(); await page.waitForTimeout(900); expect(await trashIds(page)).toHaveLength(0); await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible(); @@ -126,6 +171,7 @@ test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name))); } - const fatal = errs.filter((e) => e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error')); + const fatal = errs.filter((e) => + e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error') || e.startsWith('NATIVE_DIALOG')); expect(fatal, fatal.join('\n')).toHaveLength(0); });