feat: confirmations destructives de /trash en fenêtre thématisée (v7.45.6)
FlowDeck CI / docker (push) Successful in 1m52s
FlowDeck CI / lint (push) Successful in 1m59s
FlowDeck CI / test (push) Successful in 15m20s

Les 3 confirmations destructives de la poubelle (suppression définitive
unitaire, suppression groupée, Empty Trash) remplacent le confirm() du
navigateur par une fenêtre FlowDeck :

- réutilise les classes flowdeck-modal-overlay / .flowdeck-modal déjà
  présentes dans base.html (variables --bg-secondary / --border / --accent,
  rayon 12 px, boutons Cancel + confirmation coloré) → aucun nouveau CSS ;
- overlay cliquable pour annuler, Échap pour annuler, × pour annuler ;
- bouton de confirmation porteur du libellé exact (Delete / Delete all /
  Empty Trash) et message reprenant le nom de la page ou le nombre de pages ;
- role="alertdialog" aria-modal + aria-labelledby ;
- composant dans trashData() : askConfirm / cancelConfirm / acceptConfirm,
  mutations de propriétés individuelles (pitfall réactivité Alpine) ;
- id #fd-trash-confirm (la classe flowdeck-modal-overlay sert déjà à
  #fd-global-folder-modal → locator unique côté test) ;
- aucun confirm() natif ne subsiste dans trash.html.

Gates : e2e/trash_ui.spec.js étendu — ouverture + titre + message de la
fenêtre, ANNULATION vérifiée (page toujours présente), confirmation
(suppression réelle : plus dans la poubelle + contenu effacé), fenêtre Empty
Trash ouverte/annulée quel que soit le contenu, purge réelle seulement si la
poubelle ne contient que des pages E2E (instance partagée) ; le handler
dialog du test échoue si un dialog natif revient (NATIVE_DIALOG). Suite e2e
11 passed · pytest 1099 passed · ruff OK · OpenAPI 510 chemins / 7.45.6.
This commit is contained in:
2026-10-03 10:16:45 -04:00
parent 6582df3bcb
commit 894004a1f5
8 changed files with 151 additions and 20 deletions
+25
View File
@@ -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
+1
View File
@@ -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).
+1 -1
View File
@@ -1 +1 @@
7.45.5
7.45.6
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+71 -12
View File
@@ -106,6 +106,25 @@
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
<span style="font-size:16px;">ⓘ</span>
</div>
<!-- Confirmation thématique (remplace confirm() du navigateur) — classes
flowdeck-modal définies dans base.html, thème variables du site -->
<div id="fd-trash-confirm" class="flowdeck-modal-overlay" x-show="confirm.show" x-transition.opacity
@click.self="cancelConfirm()" @keydown.escape.window="cancelConfirm()" style="display:none;">
<div class="flowdeck-modal" x-transition role="alertdialog" aria-modal="true" aria-labelledby="fd-trash-confirm-title">
<div class="flowdeck-modal-header">
<h3 id="fd-trash-confirm-title" x-text="confirm.title"></h3>
<button class="flowdeck-modal-close" @click="cancelConfirm()" aria-label="Close">&times;</button>
</div>
<div class="flowdeck-modal-body">
<p x-text="confirm.message"></p>
</div>
<div class="flowdeck-modal-footer">
<button class="flowdeck-modal-btn cancel" @click="cancelConfirm()">Cancel</button>
<button class="flowdeck-modal-btn confirm" @click="acceptConfirm()" x-text="confirm.okText"></button>
</div>
</div>
</div>
</div>
{% 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);
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.45.5"
"version": "7.45.6"
},
"paths": {
"/auth/register": {
+50 -4
View File
@@ -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);
});