Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6582df3bcb | ||
|
|
aa88cf6665 | ||
|
|
b2ea8ec537 | ||
|
|
1051a72b52 | ||
|
|
afbc236cc2 | ||
|
|
c20aeaada1 |
+178
@@ -1,5 +1,183 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.45.5 (2026-10-03) — Poubelle : actions groupées, tri/filtres réels, purge, et 4 bugs
|
||||
|
||||
### Added
|
||||
|
||||
- **Sélection multiple** sur /trash : case « Select all » (portée au filtre
|
||||
courant), barre d'actions groupées **Restore / Delete / Clear** avec compteur,
|
||||
et compteur de résultats (`N pages` / `N of M pages`).
|
||||
- **Empty Trash** : purge totale en un clic (confirm) via le nouveau
|
||||
`POST /board/api/trash/empty` — traitement en masse côté serveur (une seule
|
||||
requête, pas N appels).
|
||||
- **Tri** : Recently deleted (défaut) / Oldest first / Page name A → Z — remplace
|
||||
le bouton décoratif « Last edited by ▾ » qui ne faisait rien.
|
||||
- **Filtre par emplacement** réel (`In ▾` décoratif remplacé) : liste déroulante
|
||||
construite depuis les workspaces présents dans la poubelle, avec état actif.
|
||||
- **Dates par élément** : « Deleted 3 days ago · 27 days left » (rétention
|
||||
30 j alignée sur `app/services/trash.py`), horodatages UTC ou ISO gérés.
|
||||
- **États vides distincts** : poubelle vide vs aucun résultat pour les filtres
|
||||
(avec bouton « Clear filters »), et **toasts** sur chaque action (succès et
|
||||
échec) au lieu d'échecs silencieux.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Les boutons Restore / Delete ne marchaient JAMAIS** : `getCsrf()` renvoie la
|
||||
CHAÎNE du jeton mais le code faisait `csrf?.[1]` → 2ᵉ caractère → 403 CSRF
|
||||
systématique, avalé par `if (r.ok)`. Corrigé + toasts d'erreur.
|
||||
- **« Move to Trash » depuis l'éditeur envoyait une 404** : la route appelée
|
||||
(`/board/api/pages/{id}/trash`) n'existe pas — route réelle
|
||||
`/api/pages/{id}/trash` — et le `.then()` naviguait quand même, donc la page
|
||||
partait à l'accueil SANS être mise à la poubelle. URL corrigée + on ne
|
||||
navigue plus si la réponse n'est pas ok.
|
||||
- **Une page restaurée disparaissait de Library / Recents / Private** : la
|
||||
suppression depuis l'éditeur réécrivait `parent_section='Trash'` et la
|
||||
restauration ne le remettait pas, alors que tous les listings filtrent
|
||||
`parent_section != 'Trash'`. `deleted_at` est maintenant la seule source de
|
||||
vérité (écriture retirée) + réparation au boot des lignes déjà cassées
|
||||
(`db.init_db`, idempotente).
|
||||
- **Routes trash sans aucune authentification** : `GET /board/api/trash`,
|
||||
`restore`, `delete` et `POST /api/pages/{id}/trash` ne vérifiaient rien (le
|
||||
CSRF ne protège pas : cookie lisible + en-tête forgé par l'attaquant) → 401
|
||||
obligatoire, comme les routes sœurs du même fichier. Vérifié anonyme : 401.
|
||||
- Le sidebar de /trash listait `parent_section='Trash'` → aligné sur
|
||||
`deleted_at IS NOT NULL`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_trash_api.py` (5 tests) : auth 401, soft-delete sans marqueur
|
||||
`Trash`, restauration, suppression définitive, purge en masse (ne touche pas
|
||||
aux pages vivantes), réparation au boot.
|
||||
- `e2e/trash_ui.spec.js` : rendu, dates, sélection groupée, restauration
|
||||
unitaire (le vrai test du fix CSRF), purge — noms uniques par exécution et
|
||||
garde « on ne vide jamais la poubelle d'autrui ».
|
||||
- `test_smoke_uncovered.py` : l'assertion qui codait le bug
|
||||
(`parent_section == 'Trash'`) corrigée.
|
||||
|
||||
## v7.45.4 (2026-10-03) — Anti-FOUC au chargement complet + morts nettoyés
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flashs au chargement COMPLET** (F5, première visite, navigation servie par
|
||||
le service worker) : le masquage `x-cloak`/`opacity` de v7.45.3 ne couvrait
|
||||
que les swaps partiels fdLoad — un chargement de document peignait la page
|
||||
tant qu'Alpine n'avait pas initialisé `.app-layout`, donc sidebar brute (toutes
|
||||
sections ouvertes, menu utilisateur visible) **et** contenu de la zone dont la
|
||||
bande rouge « You are offline. Changes will sync when connection is
|
||||
restored. » (aucun `x-cloak` sur elle). Fix : `x-cloak` sur le nœud racine
|
||||
Alpine `.app-layout` (base.html) — tout l'app (sidebar + zone + bandeau) reste
|
||||
masqué tant que `appState()` n'est pas monté, puis Alpine retire l'attribut.
|
||||
Vérifié par le probe (3 pages × plein chargement) : 0 frame de contenu brut,
|
||||
`x-cloak` retiré et `display:flex` à la fin (donc pas de page blanche).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`fd-navigating` supprimée** : classe posée/retirée à chaque `fdLoad` mais
|
||||
sans aucune règle CSS dans le dépôt (grep css/js/html → seulement app.js) —
|
||||
code mort.
|
||||
- **`e2e/probe_nav_perf.spec.js` étendu à 4 scénarios avec assertions** :
|
||||
navigations partielles (5 pages), **clic réel sur le bouton Home**, Home avec
|
||||
réseau coupé (SW actif), et plein chargement. Asserte : 0 frame de contenu
|
||||
brut peint, zone révélée (`opacity:1`) et montée (`0 [x-show]` non montés,
|
||||
`x-ignore` absent), `.app-layout` sans `x-cloak` et visible. Capture
|
||||
screenshot automatique si du contenu brut est peint + collecte console.
|
||||
|
||||
### Not reproduced
|
||||
|
||||
- Le symptôme rapporté (Home → rafale de menus/fenêtres + bande rouge) n'est
|
||||
**pas rejouable** dans un navigateur frais : clic Home en ligne comme hors
|
||||
ligne, nav partielle et plein chargement donnent 0 frame de contenu brut et 0
|
||||
erreur console. Reste à confirmer côté navigateur signalé (recharge dure
|
||||
après deploy, URL/instance testée).
|
||||
|
||||
## v7.45.3 (2026-10-03) — Navigation partielle : le contenu brut n'est plus peint
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flashs de fenêtres / frames à chaque changement de page ou de section** :
|
||||
pendant la fenêtre `x-ignore` (swap → `Alpine.initTree`), la zone
|
||||
`.main-wrapper` était peinte **non montée** — tous ses `[x-show]` visibles à
|
||||
leur valeur brute (menus, panneaux, états loading/empty/table superposés), puis
|
||||
masqués d'un coup au montage Alpine. Mesure (`e2e/probe_nav_perf.spec.js`,
|
||||
instance locale) : **19 éléments bruts visibles sur 87 ms** en arrivant sur
|
||||
/library (CLS 0.149, sources `lib-loading`/`lib-empty`/`lib-table`), **80
|
||||
éléments sur 732 ms** sur /settings, 7 sur /workspaces, 0 sur /trash. Fix :
|
||||
`opacity: 0` + `pointer-events: none` posés **dans** le handler
|
||||
`htmx:afterSwap` (même task que le swap → aucun paint intermédiaire possible)
|
||||
et retirés **après** `Alpine.initTree` sur les 3 chemins de démontage (scripts
|
||||
chargés, zéro script, filet 4 s). Seul le contenu monté est peint.
|
||||
|
||||
### Added
|
||||
|
||||
- **`e2e/probe_nav_perf.spec.js`** — probe de fluidité de navigation : marques
|
||||
htmx (`beforeRequest`/`beforeSwap`/`afterSwap`/`afterSettle`), fenêtre
|
||||
`x-ignore`, échantillon par frame du nombre d'`[x-show]` bruts encore
|
||||
visibles et d'`[x-cloak]` masqués, `layout-shift` (CLS) avec sources.
|
||||
`node node_modules/@playwright/test/cli.js test probe_nav_perf` (npx est cassé
|
||||
sur ce poste).
|
||||
|
||||
## v7.45.2 (2026-10-03) — Navigation partielle : montage Alpine sans course + scripts idempotents
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cascades d'erreurs Alpine en navigation partielle** (« Undefined
|
||||
variable: … » puis « reading 'has' » sur `$store.fdCtx`) : sur un swap
|
||||
fdLoad, les `<script src>` de page s'exécutent APRÈS le microtask
|
||||
MutationObserver d'Alpine → toute la zone `.main-wrapper` s'initialisait
|
||||
avant que composants ET stores (menu contextuel) existent, et le
|
||||
démontage anticipé dès le premier script chargé perdait la course avec
|
||||
les autres. Fix : `x-ignore` sur **toute** la zone swapée au
|
||||
`htmx:afterSwap` (uniquement quand le nœud `.main-wrapper` a été
|
||||
remplacé — swaps sidebar/vues inchangés), démontage **unique** quand
|
||||
**tous** les `<script src>` ont exécuté (load/error + filet 4 s) via
|
||||
`Alpine.initTree` (idempotent grâce au `_x_marker`). Vérifié : 7 pages ×
|
||||
(complet / 1ʳᵉ / 2ᵉ visite partielle) + éditeur (3 phases) = 0 erreur.
|
||||
- **`SyntaxError: Identifier 'LW' has already been declared`** : tout
|
||||
script de page ré-exécuté à la 2ᵉ visite partielle plantait entièrement
|
||||
(`const` de haut niveau re-déclaré) → panneaux Alpine non montés.
|
||||
Garde de fichier posé sur `local_workspace.js`, `board.js`,
|
||||
`settings.js`, `database_table.js`, `page_editor_realtime.js` (pattern
|
||||
déjà utilisé par `page_editor_scripts.js`).
|
||||
- **Enregistrement Alpine différé** : `if (window.Alpine) Alpine.data(…
|
||||
sinon listener 'alpine:init'` (l'événement ne sera plus jamais émis sur
|
||||
swap) dans 8 scripts de page et 7 templates inline (`accounts`,
|
||||
`card_detail`, `table_view`, `team_load`, `trash`, `welcome`,
|
||||
`workspace`).
|
||||
- **Polices Inter orphelines** : `app.css` référençait 7 fichiers
|
||||
inexistants (`inter-400-latin.woff2`…) → 302 → HTML → « Failed to
|
||||
decode downloaded font » sur chaque page. Blocs legacy supprimés (les
|
||||
faces variable Inter 100-900 restent).
|
||||
|
||||
### Added
|
||||
|
||||
- **Gates E2E** : `e2e/regression_editor_mount.spec.js` (éditeur en 3
|
||||
phases : partielle / complet / 2ᵉ partielle) et
|
||||
`e2e/regression_partial_nav.spec.js` (7 pages × 3 passages + état
|
||||
fdCtx/appState) —0 erreur Alpine attendue.
|
||||
|
||||
## v7.45.1 (2026-10-02) — Fix logout « hors ligne » (SW) + drag & drop upload 403
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Logout → page « Vous êtes hors ligne »** : le fetch event de navigation
|
||||
arrive en `redirect:'manual'` → une 302 du serveur se lisait
|
||||
`opaqueredirect` (status 0) → `bad status` → filet offline. Tout logout
|
||||
(et toute navigation redirigée) affichait la page hors ligne alors que le
|
||||
backend répondait en ~10 ms. `networkFirst` rejoue désormais la requête en
|
||||
`redirect:'follow'` et sert une **302 synthétique vers l'URL finale**
|
||||
(Chromium refuse une response `redirected` servie à une navigation →
|
||||
`net::ERR_FAILED`). Bonus : `timeoutFetch` annule réellement la requête
|
||||
(l'`AbortController` créée dans le `setTimeout` n'était pas branchée sur
|
||||
`fetch`).
|
||||
- **Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF)** :
|
||||
`_doUpload()` (POST `/api/local-workspace/upload[-folder]`) et
|
||||
`toggleFavorite()` du local workspace n'envoyaient pas `X-CSRF-Token` —
|
||||
les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF
|
||||
retirée, balayage complet des `fetch` mutants refait).
|
||||
- **Gate E2E** : `e2e/regression_logout_dnd.spec.js` enregistre les 2 bugs
|
||||
(logout sous SW → page login, drop de fichier → 200 + création, avec
|
||||
nettoyage).
|
||||
|
||||
## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
|
||||
|
||||
### Added
|
||||
|
||||
+7
-1
@@ -1001,7 +1001,13 @@ Détails livrés :
|
||||
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
|
||||
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/réordonnancement (↑↓) via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
|
||||
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
|
||||
- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts).
|
||||
- [x] **Navigation partielle Alpine** (v7.45.2) — les swaps fdLoad initialisaient `.main-wrapper` avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, `reading 'has'` sur `$store.fdCtx`, `SyntaxError: 'LW' already declared` à la 2ᵉ visite). Fix maison dans `app.js` : `x-ignore` sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous `<script src>` chargés via `Alpine.initTree` idempotent ; garde de fichier sur 5 scripts à `const` de haut niveau ; `if (window.Alpine)` sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'`app.css`. Gates : `regression_editor_mount` + `regression_partial_nav` (7 pages × complet/partiel/2ᵉ = 0 erreur).
|
||||
- [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
|
||||
- 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.
|
||||
|
||||
### 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
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.45.0 (Éditeur visuel d'automations = pipeline steps CRUD + conversion JSON + **fix CSP : 51 expressions multi-instructions → méthodes** (30 fichiers-tpl)) | **Statut**: EN COURS 🔄
|
||||
> **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 🔄
|
||||
> **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
|
||||
|
||||
@@ -348,6 +348,16 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
|
||||
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
|
||||
# restauration ne le remettait pas → page invisible en Library/Recents/
|
||||
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
|
||||
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
|
||||
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Private' "
|
||||
"WHERE parent_section='Trash' AND deleted_at IS NULL"
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.45.0",
|
||||
version="7.45.5",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -125,21 +125,41 @@ def unpublish_page(request: Request, page_id: int):
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
|
||||
def _trash_session(request: Request):
|
||||
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
|
||||
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
|
||||
lisible par JS et un attaquant fixe cookie ET en-tête)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
def list_trash(request: Request):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
|
||||
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||
"path": r["workspace"] or "Private",
|
||||
"deleted_at": r["deleted_at"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
def restore_page(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -154,6 +174,7 @@ def restore_page(request: Request, page_id: int):
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
@@ -163,6 +184,26 @@ def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
|
||||
|
||||
@router.post("/api/trash/empty")
|
||||
def empty_trash(request: Request):
|
||||
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
|
||||
en racine, comme permanent_delete — comportement historique conservé)."""
|
||||
_trash_session(request)
|
||||
with get_conn() as conn:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
||||
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": n}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
def api_trash_page(page_id: int, request: Request):
|
||||
"""Soft-delete a page (move to trash).
|
||||
|
||||
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
||||
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
||||
la page : à la restauration elle restait 'Trash' et disparaissait des
|
||||
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
# Pages are soft-deleted via parent_section='Trash'
|
||||
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
||||
# touché par le trash → source de vérité unique, v7.45.5)
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
|
||||
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: accountsData »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: cardDetail »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: tableView »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
if (window.Alpine) { Alpine.data('tableView', tableView); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: teamLoad »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
+203
-27
@@ -12,45 +12,93 @@
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<!-- Search -->
|
||||
<div style="position:relative; margin-bottom:12px;">
|
||||
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
||||
<div style="position:relative; flex:1;">
|
||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
||||
<button class="trash-filter active">
|
||||
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
||||
<!-- Tri -->
|
||||
<div class="lib-toolbar-wrap">
|
||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
||||
</button>
|
||||
<button class="trash-filter">
|
||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
|
||||
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
|
||||
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emplacement -->
|
||||
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
|
||||
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
|
||||
{{ fd_icon("folder",16) }}
|
||||
</button>
|
||||
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
|
||||
<div class="dd-label">Location</div>
|
||||
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
|
||||
<template x-for="l in locations" :key="l">
|
||||
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purge totale -->
|
||||
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
|
||||
</div>
|
||||
|
||||
<!-- Sélection + compteur + actions groupées -->
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
|
||||
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
|
||||
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
|
||||
</label>
|
||||
<span x-text="countLabel"></span>
|
||||
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
|
||||
<button class="btn-sm" @click="restoreSelected()">Restore</button>
|
||||
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
|
||||
<button class="btn-sm" @click="clearSel()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div style="min-height:200px;">
|
||||
<template x-for="item in filteredItems" :key="item.id">
|
||||
<div class="trash-item">
|
||||
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
|
||||
:checked="selected.includes(item.id)" @change="toggle(item.id)">
|
||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||
<div class="trash-item-info">
|
||||
<span class="trash-item-name" x-text="item.name"></span>
|
||||
<span class="trash-item-path" x-text="item.path"></span>
|
||||
<span class="trash-item-path">
|
||||
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
||||
↩️
|
||||
</button>
|
||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
|
||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||
{{ fd_icon("trash",14) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
|
||||
|
||||
<!-- État vide : aucun contenu -->
|
||||
<div x-show="items.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
|
||||
<h3>Trash is empty</h3>
|
||||
<p>Deleted pages will appear here for 30 days.</p>
|
||||
</div>
|
||||
|
||||
<!-- État vide : filtres sans résultat -->
|
||||
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
|
||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
|
||||
<h3>No pages match</h3>
|
||||
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
|
||||
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
@@ -64,36 +112,164 @@
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: trashData »).
|
||||
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||
function trashData() {
|
||||
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
|
||||
var RETENTION_DAYS = 30;
|
||||
return {
|
||||
search: '',
|
||||
sort: 'recent',
|
||||
locFilter: 'all',
|
||||
sortOpen: false,
|
||||
locOpen: false,
|
||||
selected: [],
|
||||
items: [],
|
||||
|
||||
async init() {
|
||||
const csrf = getCsrf();;
|
||||
const token = csrf;
|
||||
try {
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.items = await r.json();
|
||||
} catch(e) { this.items = []; }
|
||||
} catch (e) {
|
||||
this.items = [];
|
||||
window.showToast('Could not load the Trash: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
get locations() {
|
||||
const set = {};
|
||||
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
|
||||
return Object.keys(set).sort();
|
||||
},
|
||||
|
||||
get filteredItems() {
|
||||
const q = this.search.toLowerCase();
|
||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
||||
const loc = this.locFilter;
|
||||
const out = this.items.filter(function (i) {
|
||||
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
|
||||
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
|
||||
});
|
||||
const key = function (i) { return i.deleted_at || ''; };
|
||||
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
|
||||
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
|
||||
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
||||
return out;
|
||||
},
|
||||
|
||||
get countLabel() {
|
||||
const n = this.filteredItems.length, t = this.items.length;
|
||||
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
|
||||
},
|
||||
|
||||
get allSelected() {
|
||||
const list = this.filteredItems;
|
||||
const self = this;
|
||||
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
|
||||
},
|
||||
|
||||
toggle(id) {
|
||||
const i = this.selected.indexOf(id);
|
||||
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
|
||||
},
|
||||
toggleAll() {
|
||||
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
|
||||
},
|
||||
clearSel() { this.selected = []; },
|
||||
clearFilters() { this.search = ''; this.locFilter = 'all'; },
|
||||
toggleSort() { this.sortOpen = !this.sortOpen; },
|
||||
toggleLoc() { this.locOpen = !this.locOpen; },
|
||||
setSort(v) { this.sort = v; this.sortOpen = false; },
|
||||
setLoc(v) { this.locFilter = v; this.locOpen = false; },
|
||||
|
||||
// ── 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.
|
||||
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
|
||||
_drop(ids) {
|
||||
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
|
||||
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
|
||||
},
|
||||
|
||||
async _restore(id) {
|
||||
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
|
||||
if (r.ok) this._drop([id]);
|
||||
return r.ok;
|
||||
},
|
||||
async _delete(id) {
|
||||
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
|
||||
if (r.ok) this._drop([id]);
|
||||
return r.ok;
|
||||
},
|
||||
|
||||
async restore(id) {
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
try {
|
||||
if (await this._restore(id)) window.showToast('Page restored', 'success');
|
||||
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;
|
||||
const csrf = getCsrf();;
|
||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
||||
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;
|
||||
for (const id of ids) {
|
||||
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||
}
|
||||
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;
|
||||
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');
|
||||
},
|
||||
async emptyTrash() {
|
||||
const n = this.items.length;
|
||||
if (!confirm('Permanently delete ALL ' + n + ' page(s) in the Trash? This cannot be undone.')) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.items = [];
|
||||
this.selected = [];
|
||||
window.showToast(n + ' page(s) purged', 'success');
|
||||
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
|
||||
_ts(s) {
|
||||
if (!s) return null;
|
||||
let t = String(s).replace(' ', 'T');
|
||||
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
|
||||
const d = new Date(t);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
},
|
||||
ago(s) {
|
||||
const d = this._ts(s);
|
||||
if (!d) return 'Deleted';
|
||||
const sec = (Date.now() - d.getTime()) / 1000;
|
||||
if (sec < 60) return 'Deleted just now';
|
||||
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
|
||||
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
|
||||
const days = Math.floor(sec / 86400);
|
||||
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
|
||||
},
|
||||
leftLabel(s) {
|
||||
const d = this._ts(s);
|
||||
if (!d) return '';
|
||||
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (left <= 0) return ' · auto-delete soon';
|
||||
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,7 +138,8 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
|
||||
// globales window — probe « Undefined variable: onboarding »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -145,7 +145,8 @@
|
||||
|
||||
// globales window — probe « Undefined variable: workspacePage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
|
||||
+22
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.45.0"
|
||||
"version": "7.45.5"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -3218,7 +3218,7 @@
|
||||
"dashboard"
|
||||
],
|
||||
"summary": "Api Trash Page",
|
||||
"description": "Soft-delete a page (move to trash).",
|
||||
"description": "Soft-delete a page (move to trash).\n\nv7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``\nest la seule source de vérité. L'ancienne écriture marquait définitivement\nla page : à la restauration elle restait 'Trash' et disparaissait des\nlistings Library / Recents / Private (``parent_section != 'Trash'``).",
|
||||
"operationId": "api_trash_page_api_pages__page_id__trash_post",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4082,6 +4082,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/api/trash/empty": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"board"
|
||||
],
|
||||
"summary": "Empty Trash",
|
||||
"description": "Empty Trash : purge en masse (les enfants des pages supprimées passent\nen racine, comme permanent_delete — comportement historique conservé).",
|
||||
"operationId": "empty_trash_board_api_trash_empty_post",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/board/trash": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
|
||||
*
|
||||
* Pour chaque navigation il enregistre :
|
||||
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
|
||||
* - la fenêtre x-ignore (zone non montée Alpine)
|
||||
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
|
||||
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
|
||||
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
|
||||
* - les layout-shift (CLS) et leur source
|
||||
*
|
||||
* Lancement : npx playwright test probe_nav_perf --reporter=list
|
||||
*/
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation posée UNE fois dans la page (avant le clic).
|
||||
const INSTALL = () => {
|
||||
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
|
||||
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
|
||||
|
||||
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
|
||||
document.addEventListener(ev, () => mark(ev))
|
||||
);
|
||||
|
||||
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
|
||||
const t = m.target;
|
||||
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
|
||||
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
|
||||
}
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
|
||||
|
||||
new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
P.shifts.push({
|
||||
v: +e.value.toFixed(4),
|
||||
t: Math.round(e.startTime - P.t0),
|
||||
sources: (e.sources || []).map((s) => {
|
||||
const n = s.node;
|
||||
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
|
||||
}),
|
||||
});
|
||||
}
|
||||
}).observe({ type: 'layout-shift', buffered: false });
|
||||
|
||||
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
|
||||
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
|
||||
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
|
||||
const tick = () => {
|
||||
let raw = 0, rawVisible = 0, cloak = 0;
|
||||
const zone = document.querySelector('.main-wrapper');
|
||||
const painted = zonePainted(zone);
|
||||
if (zone) {
|
||||
for (const el of zone.querySelectorAll('[x-show]')) {
|
||||
if (el._x_marker) continue; // monté par Alpine
|
||||
raw++;
|
||||
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
|
||||
}
|
||||
cloak = zone.querySelectorAll('[x-cloak]').length;
|
||||
}
|
||||
P.rawMax = Math.max(P.rawMax, rawVisible);
|
||||
if (rawVisible) P.rawFrames++;
|
||||
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
|
||||
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
return true;
|
||||
};
|
||||
|
||||
test('probe fluidité nav', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
await login(page);
|
||||
|
||||
const NAVS = ['/library', '/settings', '/workspaces', '/trash', '/local-workspace'];
|
||||
const results = [];
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
for (const dest of NAVS) {
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'probe';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, dest);
|
||||
// Filet : si du contenu brut est peint, on capture pour voir CE qui flash
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 3200;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe.frames;
|
||||
return f.length ? f[f.length - 1] : null;
|
||||
});
|
||||
if (last && last[2] > 0) {
|
||||
shot = `navflash_${dest.replace(/\W+/g, '')}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
// État final : la zone doit être révélée (opacity>0) ET montée Alpine,
|
||||
// et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche
|
||||
const final = await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const a = document.querySelector('.app-layout');
|
||||
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
|
||||
return {
|
||||
opacity: getComputedStyle(z).opacity,
|
||||
pointerEvents: getComputedStyle(z).pointerEvents,
|
||||
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
xIgnore: z.hasAttribute('x-ignore'),
|
||||
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||
};
|
||||
});
|
||||
|
||||
// Résumé : première et dernière frame où du contenu brut visible subsiste
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const results_summary = {
|
||||
dest,
|
||||
marks: Object.fromEntries(P.marks),
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames.length ? rawFrames[0] : null,
|
||||
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
|
||||
rawMaxElements: P.rawMax,
|
||||
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
|
||||
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
|
||||
shifts: P.shifts,
|
||||
final,
|
||||
frameCount: P.frames.length,
|
||||
};
|
||||
results.push(results_summary);
|
||||
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
|
||||
}
|
||||
|
||||
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
|
||||
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
|
||||
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore
|
||||
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
|
||||
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
|
||||
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
|
||||
});
|
||||
|
||||
// Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un
|
||||
// ancre synthétique) — on mesure ce qui peint et on garde une capture si flash.
|
||||
test('probe bouton Home', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(INSTALL);
|
||||
|
||||
const console_ = [];
|
||||
page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); });
|
||||
page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180)));
|
||||
|
||||
const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href }));
|
||||
console.log('HOME avant clic ' + JSON.stringify(before));
|
||||
|
||||
await page.click('.home-btn');
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 4000;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe.frames;
|
||||
return f.length ? f[f.length - 1] : null;
|
||||
});
|
||||
if (last && last[2] > 0) {
|
||||
shot = 'home_flash.png';
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const after = await page.evaluate(() => {
|
||||
const b = document.querySelector('.offline-banner');
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
return {
|
||||
url: location.pathname + location.search,
|
||||
onLine: navigator.onLine,
|
||||
banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||
mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a',
|
||||
};
|
||||
});
|
||||
const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after };
|
||||
console.log('HOME résultat => ' + JSON.stringify(res));
|
||||
if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`);
|
||||
console.log('GATE HOME OK — 0 frame de contenu brut');
|
||||
});
|
||||
|
||||
// Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic
|
||||
// Home AVEC le réseau coupé (SW actif) pour voir ce qui peint.
|
||||
test('probe bouton Home hors ligne', async ({ page, context }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.addInitScript(INSTALL);
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages
|
||||
await page.evaluate(() => { window.__probe = null; window.__probe = undefined; });
|
||||
await page.evaluate(INSTALL);
|
||||
|
||||
await context.setOffline(true);
|
||||
await page.waitForTimeout(200);
|
||||
const onLine = await page.evaluate(() => navigator.onLine);
|
||||
|
||||
await page.click('.home-btn');
|
||||
const shots = [];
|
||||
for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) {
|
||||
await page.waitForTimeout(delay === 700 ? 700 : 1800);
|
||||
const p = `home_offline_${name}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {});
|
||||
shots.push(p);
|
||||
}
|
||||
const P = await page.evaluate(() => window.__probe || null).catch(() => null);
|
||||
const after = await page.evaluate(() => {
|
||||
const b = document.querySelector('.offline-banner');
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12);
|
||||
return {
|
||||
url: location.pathname + location.search,
|
||||
onLine: navigator.onLine,
|
||||
banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||
overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'),
|
||||
};
|
||||
}).catch((e) => ({ err: String(e) }));
|
||||
const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : [];
|
||||
const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after };
|
||||
console.log('HOME OFFLINE => ' + JSON.stringify(res));
|
||||
});
|
||||
|
||||
// Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique
|
||||
// pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start().
|
||||
test('probe fluidité plein chargement', async ({ page }) => {
|
||||
test.setTimeout(120000);
|
||||
const SHOTS = require('path').join(__dirname, 'shots');
|
||||
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||
await login(page);
|
||||
await page.addInitScript(INSTALL);
|
||||
|
||||
const OUT = [];
|
||||
for (const dest of ['/local-workspace', '/library', '/workspaces']) {
|
||||
await page.goto(FD_BASE + dest, { waitUntil: 'commit' });
|
||||
let shot = null;
|
||||
const tEnd = Date.now() + 4000;
|
||||
while (Date.now() < tEnd && !shot) {
|
||||
const last = await page.evaluate(() => {
|
||||
const f = window.__probe && window.__probe.frames;
|
||||
return f && f.length ? f[f.length - 1] : null;
|
||||
}).catch(() => null);
|
||||
if (last && last[2] > 0) {
|
||||
shot = `fullflash_${dest.replace(/\W+/g, '')}.png`;
|
||||
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
const P = await page.evaluate(() => window.__probe);
|
||||
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||
const res = {
|
||||
dest,
|
||||
rawVisibleFrames: rawFrames.length,
|
||||
rawFirst: rawFrames[0] || null,
|
||||
rawLast: rawFrames[rawFrames.length - 1] || null,
|
||||
rawMax: P.rawMax,
|
||||
shot,
|
||||
shifts: P.shifts,
|
||||
final: await page.evaluate(() => {
|
||||
const z = document.querySelector('.main-wrapper');
|
||||
const a = document.querySelector('.app-layout');
|
||||
return z ? {
|
||||
op: getComputedStyle(z).opacity,
|
||||
un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||
} : null;
|
||||
}),
|
||||
};
|
||||
OUT.push(res);
|
||||
console.log('FULL ' + dest + ' => ' + JSON.stringify(res));
|
||||
}
|
||||
console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot])));
|
||||
const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0
|
||||
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||
if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||
if (OUT.some((r) => r.rawVisibleFrames > 0)) {
|
||||
throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; '));
|
||||
}
|
||||
console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/** Diag temporaire : navigation partielle vers l'éditeur, erreurs par phase */
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
function alpineErrs(msgs) {
|
||||
return msgs.filter((m) => m.includes('Alpine Expression Error') || m.includes('PAGEERROR'));
|
||||
}
|
||||
|
||||
async function partialClick(page, pid) {
|
||||
await page.evaluate((id) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/pages/' + id;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, pid);
|
||||
}
|
||||
|
||||
test('diag phases', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
const msgs = [];
|
||||
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 160).replace(/\s+/g, ' ')));
|
||||
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 200)));
|
||||
|
||||
await login(page);
|
||||
const pid = await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'Diag2', type: 'page' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
return d.id || (d.item && d.item.id);
|
||||
});
|
||||
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
msgs.length = 0;
|
||||
|
||||
// PHASE A : navigation partielle (clic intercepte par fdLoad)
|
||||
await partialClick(page, pid);
|
||||
await page.waitForTimeout(4000);
|
||||
const stackA = await page.evaluate(() => {
|
||||
const el = document.querySelector('[x-data="editorState()"]');
|
||||
const st = el && el._x_dataStack;
|
||||
return { stack: st ? st.map((o) => (o ? Object.keys(o).length : -1)) : 'none', refreshed: !!(el && el.__fdRefreshed) };
|
||||
});
|
||||
const trace = await page.evaluate(() => window.__fdTrace || []);
|
||||
stackA.trace = trace;
|
||||
console.log('PHASE A (partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length, JSON.stringify(stackA));
|
||||
console.log('TRACE:', JSON.stringify(trace));
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' A>', m));
|
||||
msgs.filter((m) => !m.includes('Alpine Expression')).slice(0, 5).forEach((m) => console.log(' A-other>', m));
|
||||
|
||||
// A2 : les erreurs s'arretent-elles apres le montage (fenetre transitoire) ?
|
||||
const a1 = alpineErrs(msgs).length;
|
||||
await page.waitForTimeout(3000);
|
||||
const a2 = alpineErrs(msgs).length;
|
||||
const ui = await page.evaluate(() => ({
|
||||
title: !!document.getElementById('_titleEl'),
|
||||
blocks: document.querySelectorAll('[data-bid]').length,
|
||||
editorVisible: !!document.querySelector('.page-editor-wrapper, .editor-wrap, [x-data^="editorState"]'),
|
||||
}));
|
||||
console.log('PHASE A2: erreurs a t0=', a1, '-> apres +3s=', a2, 'UI=', JSON.stringify(ui));
|
||||
|
||||
// PHASE B : rechargement complet de la meme page
|
||||
msgs.length = 0;
|
||||
await page.goto(`${FD_BASE}/pages/${pid}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('PHASE B (complet): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' B>', m));
|
||||
|
||||
// PHASE C : deuxieme navigation partielle vers la meme page
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
msgs.length = 0;
|
||||
await partialClick(page, pid);
|
||||
await page.waitForTimeout(3500);
|
||||
console.log('PHASE C (2e partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' C>', m));
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Régression — 2 bugs rapportés (post v7.45.0) :
|
||||
* 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation
|
||||
* redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet).
|
||||
* 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans
|
||||
* X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel).
|
||||
*
|
||||
* Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page
|
||||
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||
data: { email: USER, password: PASS, name: 'E2E' },
|
||||
});
|
||||
if (!resp.ok() && resp.status() !== 409) {
|
||||
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||
}
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
// workspace actif requis par /api/local-workspace/upload (A22)
|
||||
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||
await page.evaluate(async () => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||
});
|
||||
const w = await r.json();
|
||||
await fetch(`/api/workspaces/${w.id}/select`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.evaluate(async () => {
|
||||
if ('serviceWorker' in navigator) await navigator.serviceWorker.ready;
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||
|
||||
await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
|
||||
const body = await page.evaluate(() => document.body.innerText);
|
||||
expect(body).not.toContain('hors ligne');
|
||||
expect(page.url()).toContain('/auth/login');
|
||||
});
|
||||
|
||||
test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const uploadResp = [];
|
||||
page.on('response', (r) => {
|
||||
if (r.url().includes('/api/local-workspace/upload')) {
|
||||
uploadResp.push(
|
||||
r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' }))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
const zone = document.querySelector('[x-data*="wsInitData"]') || document.body;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' }));
|
||||
zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const resp = await uploadResp[0];
|
||||
expect(resp.status, resp.body).toBe(200);
|
||||
|
||||
// Nettoyage : l'upload crée une page fichier → on la supprime.
|
||||
const parsed = JSON.parse(resp.body || '{}');
|
||||
for (const item of parsed.items || []) {
|
||||
if (!item.id) continue;
|
||||
await page.evaluate(async (pid) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/local-workspace/items/' + pid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
}, item.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Sweep diag : nav partielle vs complet sur N pages — erreurs + état fdCtx/appState */
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
const PAGES = ['/workspaces', '/library', '/local-workspace', '/settings', '/trash', '/accounts', '/import'];
|
||||
|
||||
test('sweep', async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
const msgs = [];
|
||||
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 200).replace(/\s+/g, ' ')));
|
||||
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 160)));
|
||||
|
||||
await login(page);
|
||||
|
||||
for (const path of PAGES) {
|
||||
// baseline : chargement complet
|
||||
msgs.length = 0;
|
||||
await page.goto(FD_BASE + path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(2200);
|
||||
const fullErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||
const stFull = await page.evaluate(() => ({
|
||||
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||
appState: typeof window.appState,
|
||||
}));
|
||||
|
||||
// nav partielle : depuis /workspaces
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(700);
|
||||
msgs.length = 0;
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, path);
|
||||
await page.waitForTimeout(2500);
|
||||
const partErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||
// 2e visite partielle dans LE MEME document (declencheur du SyntaxError
|
||||
// « Identifier ... has already been declared » sur script sans garde)
|
||||
await page.evaluate(() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = '/workspaces';
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
msgs.length = 0;
|
||||
await page.evaluate((p) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = p;
|
||||
a.textContent = 'x';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
}, path);
|
||||
await page.waitForTimeout(2500);
|
||||
const part2Errs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught') || m.includes('already been declared'));
|
||||
const stPart = await page.evaluate(() => ({
|
||||
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||
appState: typeof window.appState,
|
||||
lwGuard: !!window.__fdLocalWorkspaceScriptsLoaded,
|
||||
}));
|
||||
|
||||
console.log(`PAGE ${path}`);
|
||||
console.log(` complet : errs=${fullErrs.length} ${JSON.stringify(stFull)}`);
|
||||
fullErrs.slice(0, 2).forEach((m) => console.log(' F>', m));
|
||||
console.log(` 1ere partiel : errs=${partErrs.length} ${JSON.stringify(stPart)}`);
|
||||
partErrs.slice(0, 3).forEach((m) => console.log(' P>', m));
|
||||
console.log(` 2e partiel : errs=${part2Errs.length}`);
|
||||
part2Errs.slice(0, 3).forEach((m) => console.log(' P2>', m));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge.
|
||||
*
|
||||
* Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors
|
||||
* que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient
|
||||
* pas. Ici on clique pour de vrai et on vérifie l'état via l'API.
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Appel API depuis la page (cookies + jeton CSRF du document).
|
||||
async function api(page, method, url) {
|
||||
return page.evaluate(async ([m, u]) => {
|
||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } });
|
||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) };
|
||||
}, [method, url]);
|
||||
}
|
||||
|
||||
async function mkTrashed(page, name) {
|
||||
const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name));
|
||||
expect(c.ok, 'create page').toBeTruthy();
|
||||
const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash');
|
||||
expect(t.ok, 'soft delete').toBeTruthy();
|
||||
return c.body.id;
|
||||
}
|
||||
|
||||
const trashIds = async (page) => {
|
||||
const r = await api(page, 'GET', '/board/api/trash');
|
||||
expect(r.ok).toBeTruthy();
|
||||
return (r.body || []).map((i) => i.id);
|
||||
};
|
||||
|
||||
test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
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
|
||||
|
||||
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 idA = await mkTrashed(page, nameA);
|
||||
const idB = await mkTrashed(page, nameB);
|
||||
|
||||
await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance
|
||||
// de test peut contenir d'autres éléments (jamais on ne touche aux leurs).
|
||||
await page.fill('input[placeholder="Search pages in Trash"]', TAG);
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const rowA = page.locator('.trash-item', { hasText: nameA });
|
||||
const rowB = page.locator('.trash-item', { hasText: nameB });
|
||||
await expect(rowA).toHaveCount(1);
|
||||
await expect(rowB).toHaveCount(1);
|
||||
|
||||
// Date de suppression + jours restants affichés (standard « trash »)
|
||||
await expect(rowA.first()).toContainText('Deleted');
|
||||
await expect(rowA.first()).toContainText('left');
|
||||
// Compteur + purge totale présents
|
||||
await expect(page.locator('label input[type=checkbox]')).toHaveCount(1);
|
||||
await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible();
|
||||
|
||||
// Sélection groupée (portée au filtre courant) → restauration
|
||||
const rowCount = await page.locator('.trash-item').count();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(2);
|
||||
await page.locator('label input[type=checkbox]').check();
|
||||
await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1);
|
||||
await page.locator('button.btn-sm', { hasText: 'Restore' }).click();
|
||||
await page.waitForTimeout(900);
|
||||
|
||||
await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0);
|
||||
await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0);
|
||||
let ids = await trashIds(page);
|
||||
expect(ids).not.toContain(idA);
|
||||
expect(ids).not.toContain(idB);
|
||||
|
||||
// Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux)
|
||||
const idC = await mkTrashed(page, nameC);
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForTimeout(900);
|
||||
const rowC = page.locator('.trash-item', { hasText: nameC });
|
||||
await expect(rowC).toHaveCount(1);
|
||||
await rowC.first().locator('button[title="Restore"]').click();
|
||||
await page.waitForTimeout(900);
|
||||
await expect(rowC).toHaveCount(0);
|
||||
ids = await trashIds(page);
|
||||
expect(ids).not.toContain(idC);
|
||||
|
||||
// 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);
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForTimeout(900);
|
||||
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
|
||||
const all = (await api(page, 'GET', '/board/api/trash')).body || [];
|
||||
if (all.every((i) => i.name.startsWith('E2E Trash'))) {
|
||||
await page.getByRole('button', { name: 'Empty Trash' }).click();
|
||||
await page.waitForTimeout(900);
|
||||
expect(await trashIds(page)).toHaveLength(0);
|
||||
await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible();
|
||||
} else {
|
||||
console.log('SKIP purge : la poubelle contient des pages non-E2E ' +
|
||||
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'));
|
||||
expect(fatal, fatal.join('\n')).toHaveLength(0);
|
||||
});
|
||||
@@ -23,68 +23,7 @@
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* ===== LIGHT THEME (default) ===== */
|
||||
|
||||
+75
-2
@@ -283,10 +283,8 @@
|
||||
if (!window.htmx) { window.location.href = url; return; }
|
||||
if (loading) return;
|
||||
loading = true;
|
||||
document.documentElement.classList.add('fd-navigating');
|
||||
function done() {
|
||||
loading = false;
|
||||
document.documentElement.classList.remove('fd-navigating');
|
||||
}
|
||||
var p;
|
||||
try {
|
||||
@@ -326,6 +324,81 @@
|
||||
fdLoad(window.location.href, false);
|
||||
});
|
||||
|
||||
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
|
||||
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
|
||||
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
|
||||
// .main-wrapper serait initialisée avant que composants ET stores (menu
|
||||
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
|
||||
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
|
||||
// pendant le chargement des scripts, puis on démarle quand tous les
|
||||
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
|
||||
// (initTree est idempotent : le _x_marker posé au premier passage évite
|
||||
// tout double montage.)
|
||||
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
|
||||
window.fdRefreshXData = function (name) {
|
||||
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
|
||||
if (el.__fdRefreshed) return;
|
||||
var st = el._x_dataStack;
|
||||
if (!st) return; // sous x-ignore → done() montera
|
||||
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
|
||||
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
|
||||
var fresh = el.cloneNode(true);
|
||||
el.parentNode.replaceChild(fresh, el);
|
||||
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('htmx:afterSwap', function () {
|
||||
var mw = document.querySelector('.main-wrapper');
|
||||
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
|
||||
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
|
||||
fdLastMw = mw;
|
||||
mw.__fdSwapWatch = true;
|
||||
// Avant tout paint (même task que le swap) : la zone est x-ignore, donc non
|
||||
// montée Alpine — tous ses [x-show] seraient peints à leur valeur brute
|
||||
// (rafale de menus/panneaux/états visibles une fraction de seconde). On ne
|
||||
// la peint qu'une fois initTree passé.
|
||||
mw.style.opacity = '0';
|
||||
mw.style.pointerEvents = 'none';
|
||||
mw.setAttribute('x-ignore', '');
|
||||
var pending = mw.querySelectorAll('script[src]').length;
|
||||
var reveal = function () {
|
||||
if (!mw.isConnected) return;
|
||||
mw.style.opacity = '';
|
||||
mw.style.pointerEvents = '';
|
||||
};
|
||||
var done = function () {
|
||||
reveal(); // idempotent : appelé même si la branche suivante retourne
|
||||
if (!mw.__fdSwapWatch) return;
|
||||
mw.__fdSwapWatch = false;
|
||||
document.removeEventListener('load', onScript, true);
|
||||
document.removeEventListener('error', onScript, true);
|
||||
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
|
||||
mw.removeAttribute('x-ignore');
|
||||
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
|
||||
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
|
||||
reveal();
|
||||
};
|
||||
var onScript = function (e) {
|
||||
if (!e.target || e.target.tagName !== 'SCRIPT') return;
|
||||
if (--pending <= 0) done();
|
||||
};
|
||||
if (!pending) {
|
||||
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
|
||||
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
|
||||
mw.removeAttribute('x-ignore');
|
||||
mw.__fdSwapWatch = false;
|
||||
setTimeout(function () {
|
||||
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
|
||||
reveal(); // après initTree : un paint max de blanc, jamais de contenu brut
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
document.addEventListener('load', onScript, true);
|
||||
document.addEventListener('error', onScript, true);
|
||||
setTimeout(done, 4000);
|
||||
});
|
||||
|
||||
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
|
||||
|
||||
+7
-4
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdBoardScriptsLoaded) {
|
||||
window.__fdBoardScriptsLoaded = true;
|
||||
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
@@ -22,7 +24,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: kanbanBoard »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
if (window.Alpine) { Alpine.data('kanbanBoard', kanbanBoard); fdRefreshXData('kanbanBoard'); } else document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
@@ -42,7 +44,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: filterSystem »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
if (window.Alpine) { Alpine.data('filterSystem', filterSystem); fdRefreshXData('filterSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
@@ -84,7 +86,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: sortSystem »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
if (window.Alpine) { Alpine.data('sortSystem', sortSystem); fdRefreshXData('sortSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
@@ -111,7 +113,7 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: newIssueForm »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
|
||||
if (window.Alpine) { Alpine.data('newIssueForm', newIssueForm); fdRefreshXData('newIssueForm'); } else document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
@@ -169,3 +171,4 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdDbTableScriptsLoaded) {
|
||||
window.__fdDbTableScriptsLoaded = true;
|
||||
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
|
||||
|
||||
(function() {
|
||||
@@ -1309,3 +1311,4 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
|
||||
.catch(function(){ return rowId; });
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
var _regGiteaWs = () => {
|
||||
Alpine.data('giteaWorkspace', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const owner = params.get('owner') || '';
|
||||
@@ -637,4 +637,10 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
if (window.Alpine) {
|
||||
_regGiteaWs();
|
||||
fdRefreshXData('giteaWorkspace');
|
||||
} else {
|
||||
document.addEventListener('alpine:init', _regGiteaWs);
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
// globales window — probe « Undefined variable: importWizard »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
if (window.Alpine) { Alpine.data('importWizard', importWizard); fdRefreshXData('importWizard'); } else document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
|
||||
// registre (probe : globale window → « Undefined variable: libraryPage »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('libraryPage', libraryPage);
|
||||
});
|
||||
if (window.Alpine) { Alpine.data('libraryPage', libraryPage); fdRefreshXData('libraryPage'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('libraryPage', libraryPage); });
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdLocalWorkspaceScriptsLoaded) {
|
||||
window.__fdLocalWorkspaceScriptsLoaded = true;
|
||||
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
|
||||
@@ -628,7 +630,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
if (!node) return;
|
||||
try {
|
||||
var method = node.favorited ? 'DELETE' : 'POST';
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
|
||||
var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} });
|
||||
if (r.ok) {
|
||||
node.favorited = !node.favorited;
|
||||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||||
@@ -1763,7 +1765,7 @@ const _wsInitData = (function() { // lexical : NON propriété globalThis →
|
||||
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
|
||||
|
||||
try {
|
||||
var r = await fetch(url, { method: 'POST', body: formData });
|
||||
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData });
|
||||
this.uploadProgress = 80;
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
@@ -2065,7 +2067,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsI
|
||||
|
||||
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
|
||||
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
var _regWs = function () {
|
||||
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
|
||||
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
|
||||
Alpine.data('wsInitData', function () { return _wsInitData; });
|
||||
@@ -2089,4 +2091,12 @@ document.addEventListener('alpine:init', function () {
|
||||
};
|
||||
return t;
|
||||
});
|
||||
});
|
||||
};
|
||||
if (window.Alpine) {
|
||||
_regWs();
|
||||
fdRefreshXData('wsInitData');
|
||||
fdRefreshXData('wsPreview');
|
||||
} else {
|
||||
document.addEventListener('alpine:init', _regWs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdRtScriptsLoaded) {
|
||||
window.__fdRtScriptsLoaded = true;
|
||||
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
/* eslint-disable */
|
||||
@@ -530,3 +532,4 @@ window.__fdRT = (function () {
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1865,7 +1865,11 @@ applyAIBlocks(text){
|
||||
})
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();;fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
// Le route était /board/api/pages/{id}/trash → 404 permanent (le .then
|
||||
// naviguait quand même : la page n'allait JAMAIS à la poubelle depuis
|
||||
// l'éditeur). Route réelle : /api/pages/{id}/trash, et on ne navigue
|
||||
// plus si la réponse n'est pas ok.
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();fetch(`/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(r=>{if(!r.ok)throw new Error('HTTP '+r.status);window.location.href='/';}).catch(()=>{window.showToast('Move to Trash failed','error');});},
|
||||
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
|
||||
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
|
||||
|
||||
@@ -2535,6 +2539,10 @@ applyAIBlocks(text){
|
||||
|
||||
// globales window — probe « Undefined variable: editorState »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
// Navigation partielle (fdLoad) : Alpine démarre déjà → 'alpine:init'
|
||||
// ne sera plus émis, on enregistre tout de suite (pattern _ctx_menu.js)
|
||||
// et on remonte la racine montée avant l'enregistrement.
|
||||
if (window.Alpine) { Alpine.data('editorState', editorState); fdRefreshXData('editorState'); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
window.editorState = editorState;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
if (!window.__fdSettingsScriptsLoaded) {
|
||||
window.__fdSettingsScriptsLoaded = true;
|
||||
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
|
||||
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
@@ -5,7 +7,7 @@ const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.
|
||||
|
||||
// globales window — probe « Undefined variable: settingsInit »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
if (window.Alpine) { Alpine.data('settingsInit', settingsInit); fdRefreshXData('settingsInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
function settingsInit() {
|
||||
return {
|
||||
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
||||
@@ -1233,3 +1235,4 @@ function settingsInit() {
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
// globales window — probe « Undefined variable: workspacesPage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
|
||||
if (window.Alpine) { Alpine.data('workspacesPage', workspacesPage); fdRefreshXData('workspacesPage'); } else document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
|
||||
+18
-5
@@ -156,9 +156,22 @@ async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) {
|
||||
async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) {
|
||||
const cache = await caches.open(cacheName);
|
||||
try {
|
||||
const res = await timeoutFetch(request, timeoutMs);
|
||||
let res = await timeoutFetch(request, timeoutMs);
|
||||
// Les navigations interceptées arrivent en redirect:'manual' : une 302 du
|
||||
// serveur se lit opaqueredirect (status 0) → on rejoue la requête avec suivi
|
||||
// explicite. Sans ça, tout logout / redirection rendait la page hors ligne.
|
||||
if (res.type === 'opaqueredirect') {
|
||||
// Navigation en redirect:'manual' : on suit la redirection à la main.
|
||||
res = await timeoutFetch(new Request(request, { redirect: 'follow' }), timeoutMs);
|
||||
if (res && res.ok && res.redirected) {
|
||||
// Chromium refuse une response 'redirected' servie à une navigation
|
||||
// (ERR_FAILED) → on émet une vraie redirection vers l'URL finale.
|
||||
return new Response(null, { status: 302, headers: { Location: res.url } });
|
||||
}
|
||||
}
|
||||
if (res && res.ok) {
|
||||
cache.put(request, res.clone());
|
||||
// Ne pas mettre en cache une réponse de redirection sous l'URL d'origine.
|
||||
if (!res.redirected) cache.put(request, res.clone());
|
||||
return res;
|
||||
}
|
||||
throw new Error('bad status');
|
||||
@@ -176,12 +189,12 @@ async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell =
|
||||
|
||||
function timeoutFetch(request, ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
ctrl.abort();
|
||||
reject(new Error('timeout'));
|
||||
}, ms);
|
||||
fetch(request).then(
|
||||
fetch(request, { signal: ctrl.signal }).then(
|
||||
(res) => { clearTimeout(timer); resolve(res); },
|
||||
(err) => { clearTimeout(timer); reject(err); }
|
||||
);
|
||||
|
||||
@@ -453,7 +453,10 @@ def test_dashboard_page_content_rename_trash(client):
|
||||
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
|
||||
).fetchone()
|
||||
assert row["title"] == "Renommée A32"
|
||||
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
|
||||
# v7.45.5 : parent_section n'est PLUS réécrit en 'Trash' (deleted_at est
|
||||
# la source de vérité) — l'ancienne assertion codait le bug : la page
|
||||
# restaurée restait 'Trash' et disparaissait de Library/Recents/Private.
|
||||
assert row["parent_section"] != "Trash" and row["deleted_at"] is not None
|
||||
finally:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Trash — v7.45.5.
|
||||
|
||||
Trois régressions couvertes :
|
||||
1. les routes trash n'avaient AUCUNE vérification de session (le CSRF seul ne
|
||||
protège pas : cookie lisible + en-tête forgé) ;
|
||||
2. la suppression depuis l'éditeur réécrivait ``parent_section='Trash'`` et la
|
||||
restauration ne le remettait pas → page invisible en Library/Recents/Private ;
|
||||
3. ``POST /board/api/trash/empty`` (purge totale) n'existait pas.
|
||||
"""
|
||||
from tests.conftest import anon_csrf, login_test_client
|
||||
|
||||
|
||||
def _mk_page(title="To trash", workspace="bruno/flowdeck", section="Private", parent_id=None):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, parent_id) "
|
||||
"VALUES (?, ?, '', 'blocks', ?, ?)",
|
||||
(workspace, title, section, parent_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _row(page_id):
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
return conn.execute(
|
||||
"SELECT parent_section, deleted_at FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def test_trash_requires_session(client):
|
||||
pid = _mk_page()
|
||||
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403
|
||||
|
||||
assert client.get("/board/api/trash").status_code == 401
|
||||
assert client.post(f"/api/pages/{pid}/trash").status_code == 401
|
||||
assert client.post(f"/board/api/trash/{pid}/restore").status_code == 401
|
||||
assert client.delete(f"/board/api/trash/{pid}").status_code == 401
|
||||
assert client.post("/board/api/trash/empty").status_code == 401
|
||||
# rien n'a été touché
|
||||
row = _row(pid)
|
||||
assert row["deleted_at"] is None
|
||||
|
||||
|
||||
def test_soft_delete_then_restore_keeps_section(client):
|
||||
login_test_client(client)
|
||||
pid = _mk_page(title="Restore me")
|
||||
|
||||
r = client.post(f"/api/pages/{pid}/trash")
|
||||
assert r.status_code == 200
|
||||
row = _row(pid)
|
||||
assert row["deleted_at"], "la page doit être marquée supprimée"
|
||||
assert row["parent_section"] != "Trash", "parent_section ne doit plus être réécrit"
|
||||
|
||||
items = client.get("/board/api/trash").json()
|
||||
hit = next((i for i in items if i["id"] == pid), None)
|
||||
assert hit, "la page doit apparaître dans la poubelle"
|
||||
assert hit["deleted_at"] and hit["path"] and hit["icon"]
|
||||
|
||||
r = client.post(f"/board/api/trash/{pid}/restore")
|
||||
assert r.status_code == 200
|
||||
row = _row(pid)
|
||||
assert row["deleted_at"] is None
|
||||
assert row["parent_section"] != "Trash"
|
||||
assert not any(i["id"] == pid for i in client.get("/board/api/trash").json())
|
||||
|
||||
|
||||
def test_permanent_delete(client):
|
||||
login_test_client(client)
|
||||
pid = _mk_page(title="Gone for good")
|
||||
client.post(f"/api/pages/{pid}/trash")
|
||||
|
||||
assert client.delete(f"/board/api/trash/{pid}").status_code == 200
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (pid,)).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_empty_trash_purges_only_trashed(client):
|
||||
login_test_client(client)
|
||||
ids = [_mk_page(title=f"purge-{i}") for i in range(3)]
|
||||
keep = _mk_page(title="keep-me")
|
||||
for pid in ids:
|
||||
client.post(f"/api/pages/{pid}/trash")
|
||||
|
||||
r = client.post("/board/api/trash/empty")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["deleted"] == 3
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
assert conn.execute(
|
||||
f"SELECT COUNT(*) FROM pages WHERE id IN ({placeholders})", ids
|
||||
).fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (keep,)).fetchone()[0] == 1
|
||||
|
||||
|
||||
def test_boot_repairs_restored_rows_marked_trash(client):
|
||||
"""Le correctif de db.init_db() remet en 'Private' les pages restaurées que
|
||||
l'ancien soft-delete laissait marquées 'Trash' (donc invisibles partout)."""
|
||||
login_test_client(client)
|
||||
from app.db import get_conn, init_db
|
||||
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('', 'ghost', '', 'blocks', 'Trash')"
|
||||
)
|
||||
conn.commit()
|
||||
pid = cur.lastrowid
|
||||
|
||||
init_db()
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT parent_section FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert row["parent_section"] == "Private"
|
||||
Reference in New Issue
Block a user