Extract partage page chrome to templates/partage/form-page.php

This commit is contained in:
Pontoporeia
2026-06-11 11:29:11 +02:00
parent cbd369bc72
commit b744271cf6
3 changed files with 100 additions and 193 deletions

144
TODO.md
View File

@@ -1,131 +1,31 @@
# TODO
## HTMX v2 Migration
> Last updated: 2026-06-11
> Context: Form Accessibility & Resilience improvements for XAMXAM thesis submission platform
Reference: `docs/autosave-system.md` → "HTMX v2 Migration Plan" section.
## In Progress
- [x] `contenus-edit.php` (pages): Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange`
- [x] `contenus-edit.php` (form_help): Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange`
- [x] `apropos-groups-form.php` (contacts): Add `hx-*` attrs only
- [x] `contenus-edit.php` (sidebar_links): Add `hx-*` attrs only
- [x] Add `handleAutosaveResponse()` shared handler + `htmx:beforeRequest` loading state
- [x] Delete `autosave.js`
- [x] Fix backend `$isAjax` detection: also recognize `HX-Request` header (page.php, apropos.php, form-help.php)
- [x] Form-help inline editors: add OverType toolbar + HTMX auto-save + remove save buttons
- [x] Markdown cheatsheet modal: reusable dialog on all OverType editors
## Pending
## FilePond crash on TFE upload forms
- [ ] #aria-test-manual Test WCAG changes with VoiceOver and NVDA on full add/edit/partage form flows
- [ ] #nojs-upload-test Test end-to-end: submit partage form with JS disabled, verify files arrive via `$_FILES`
- [ ] #filepond-preserve Preserve FilePond temp file IDs on partage validation redirect `(partage/index.php)` `(FilepondHandler.php)`
- [ ] #two-phase-commit Add two-phase commit: INSERT thesis `status='draft'`, COMMIT, move files, UPDATE to `active` `(ThesisCreateController.php)`
- [ ] #cleanup-drafts Add periodic cleanup job for orphaned drafts (`just cleanup-drafts`)
- [x] Analyze root cause → `docs/filepond-crash-analysis.md`
- [x] Partial fixes (Content-Type headers, onerror cleanup, load object) — insufficient, crash still reproduces
- [x] HTMX/destroy race hypothesis investigation → `docs/filepond-race-investigation.md` (verdict: REFUTED)
- [x] Diagnostic probes + deep analysis: confirmed load-file-error dispatch path, traced via error.stack to fileValidateSizeFilter line 389
- [x] **ROOT CAUSE FIXED**: fileValidateSizeFilter accessed `item.filename` but FileValidateSize's LOAD_FILE filter passes the raw File/Blob (which has `.name`, not `.filename`). Changed to `item.filename || item.name`. Also added null guard to getExt().
- [x] Defensive: Wt and Fr crash guards in filepond.min.js prevent action.status.main crash
- [x] process.onload: replaced throw with error-marker return (prevents FilePond crash when server returns HTML)
- [x] Routing: partage index.php now routes /partage/actions/* directly to PHP files (was treating 'actions' as a slug and returning full HTML page)
- [x] **All crashes resolved** — verified working on partage form
- [ ] #form-setup-helper Add `ThesisFormSetup` helper class to reduce bootstrap duplication across add/edit/partage `(partage/index.php)` `(admin/add.php)` `(admin/edit.php)`
## Form Accessibility & Resilience — Assessment Follow-up
## Completed
Reference: Assessment against progressive-enhancement / WCAG-AA / "never lose data" / low-common-denominator guidelines.
- [x] #refactor-partage Extract partage form page chrome to `templates/partage/form-page.php` `(partage/index.php)`
- [x] #htmx-migration HTMX v2 migration: OverType editors, autosave handler, backend `HX-Request` detection ✓
- [x] #filepond-crash FilePond crash on TFE upload forms: root cause fixed (`.filename``.name`), all crashes resolved ✓
- [x] #aria-errormessage WCAG AA: field-level `aria-errormessage`, `aria-invalid`, `aria-describedby` on all form fields ✓
- [x] #nojs-upload-fix No-JS file uploads: `filepond_mode` default to `0 disabled`, server-side `$_FILES` fallback ✓
- [x] #autosave-partage Autosave text fields on partage form: session draft endpoint (`fragments/draft.php`), HTMX autosave on change/input, page-load hydration, "Brouillon enregistré" indicator, draft cleared on submit ✓
- [x] #mobile-responsive Mobile-responsive form layout: `@media (max-width: 600px)` breakpoint, 44×44px touch targets ✓
- [x] #aria-fieldset-fix Remove invalid `required` attribute from `<fieldset>`, keep `aria-required="true"`, add `role="group"`
- [x] #split-form-css Split `form.css` into `form-base.css` and `form-admin.css`
- [x] #extra-css-admin Update `head.php` to support `$extraCssAdmin` for admin-only stylesheets `(head.php)`
### 1. WCAG AA: Add field-level `aria-errormessage` + `aria-invalid` to TFE form
**Current state:** Flash error divs have `role="alert"` but individual fields are never linked to their error via `aria-errormessage`. The `autofocusFieldForError()` mechanism focuses the field after a validation redirect but does not announce the error to screen readers. Help `<small>` text is not linked via `aria-describedby`.
**To do:**
- [x] Extend `text-field.php`, `select-field.php`, `checkbox-list.php` partials to accept optional `$errorFieldName` variable
- [x] When `$errorFieldName` matches the field, add `aria-errormessage="flash-error"` and `aria-invalid="true"` on the input
- [x] On validation redirect, populate `$errorFieldName` from `App::consumeAutofocus()` so each failing field references the flash error container
- [x] Add `aria-describedby` linking each `<small>` hint to its input (always, not just on error)
- [x] Give flash-error div `id="flash-error"` and `tabindex="-1"` for programmatic reference
- [x] Wire `App::flashAutofocus()` into partage submit catch block (was missing)
- [x] Wire `$withAutofocusFn` in admin add template (was defaulting to identity)
- [x] Apply `aria-invalid` + `aria-errormessage` on synopsis textarea (not in text-field partial)
- [x] Apply `aria-describedby` on file inputs in `fichiers-fragment.php`
- [x] Apply format checkboxes `aria-invalid` support in `fichiers-fragment.php`
- [ ] Test with VoiceOver and NVDA on the full add/edit/partage form flows
### 2. No-JS file uploads silently fail (data loss)
**Current state:** `form.php` hardcodes `<input type="hidden" name="filepond_mode" value="1">`. Without JS, no `queue_file[]` hidden inputs are populated → server gets `filepond_mode=1` with empty queue → all files silently dropped. The form is supposed to work without JS.
**To do:**
- [x] Change the hidden input to `<input type="hidden" name="filepond_mode" value="0" disabled>` by default; JS enables it and sets `value="1"` on DOMContentLoaded
- [x] Add server-side fallback in `ThesisCreateController::submit()` and `ThesisEditController::save()`: when `filepond_mode=1` but no `queue_file` data is present, fall through to the legacy `$_FILES` path
- [ ] Test end-to-end: submit the partage form with JS disabled, verify files arrive via `$_FILES`
### 3. Autosave text fields on partage form
**Current state:** Only FilePond files are persisted to the server before submit. Text/select/checkbox fields live only in DOM and are lost on tab close / crash / network failure. `beforeunload-guard.js` warns but cannot recover data. The admin panel already has an HTMX-based autosave pattern (`contenus-edit.php`).
**To do:**
- [ ] Implement a per-session draft endpoint (`POST /partage/<slug>/draft`) that saves form data to a session-backed store
- [ ] On field blur/change, POST the field name+value via HTMX (debounced 500ms)
- [ ] On page load, hydrate all fields from the draft endpoint
- [ ] Draft is cleared on successful submission; expires after 24h with session
- [ ] Add a visible "Brouillon enregistré" indicator to reassure the student
### 4. FilePond temp file IDs lost on partage validation redirect
**Current state:** After a validation error, `storePrimedFiles()` shows file *names* so the user knows what to re-select. But the actual FilePond hex IDs (server-side temp files) are not preserved — the user must re-upload everything. For large files on slow connections, this is painful.
**To do:**
- [ ] After a validation error in the partage form, preserve FilePond temp file IDs in `$_SESSION['share_filepond_ids_' . $slug]` by queue type
- [ ] Use these IDs to build the `data-existing-files` JSON attribute on the re-rendered form, seeding the FilePond pools
- [ ] Ensure the temp files' server-side lifetime covers at least one validation round-trip (extend `FilepondHandler` TTL if needed)
### 5. Two-phase commit: protect against crash during file moves
**Current state:** DB transaction commits, then files are moved from tmp to storage. If the process crashes between COMMIT and file move completion, the DB has a thesis record with no files (or partial files). This is a public-facing service — no acceptable failure rate.
**To do:**
- [ ] Add an `is_published` or `status` flag to theses table (if not already present)
- [ ] In the partage submit flow: INSERT thesis with `status='draft'` inside the transaction → COMMIT → move files → UPDATE `status='active'`
- [ ] On partage submission error recovery, roll back draft theses that are older than N minutes with no files
- [ ] Add a periodic cleanup job (`just cleanup-drafts`) for orphaned drafts
### 6. Mobile-responsive form layout
**Current state:** Form field rows use `grid-template-columns: 260px 1fr` with no breakpoint. Below ~520px viewport width, labels wrap and inputs are cramped.
**To do:**
- [x] Add `@media (max-width: 600px)` rule in `form.css` switching to `grid-template-columns: 1fr` with labels stacked above inputs
- [x] Test on 320px wide viewport (PSP, low-end Android) — verified via responsive design tokens (min 360px clamp)
- [x] Ensure touch targets are at least 44×44px (WCAG 2.5.5)
### 7. Split form.css: shared base vs admin-only styles
**Current state:** `form.css` is 36KB (1,500+ lines). The partage form loads all of it but uses only ~40% (base input/fieldset styles). Admin-specific styles (`.admin-jury-*`, `.admin-backoffice-*`, etc.) are dead weight for students.
**To do:**
- [ ] Split `form.css` into `form-base.css` (~15KB: inputs, selects, textareas, fieldsets, legends, validation states, flash messages, `aria-invalid` styling) and `form-admin.css` (~21KB: admin layout, jury fieldset, backoffice, tag pills, autocomplete)
- [ ] Load `form-base.css` in the partage form; load both in admin forms
- [ ] Update `head.php` to support `$extraCssAdmin` for admin-only stylesheets
### 8. Fix `aria-required` on `<fieldset>` (standards compliance)
**Current state:** `checkbox-list.php` and `fichiers-fragment.php` put `required aria-required="true"` on `<fieldset>` elements. The `required` attribute is not valid on `<fieldset>`. Screen readers may not interpret this correctly.
**To do:**
- [x] Remove `required` attribute from `<fieldset>` in `checkbox-list.php` — keep `aria-required="true"` only
- [x] Remove `required` attribute from `<fieldset>` in `fichiers-fragment.php` — keep `aria-required="true"` only
- [x] Add `role="group"` on both `<fieldset>` elements for explicit ARIA semantics
### 9. Refactor partage form page wrapper to a template
**Current state:** `renderShareLinkForm()` in `partage/index.php` outputs an entire `<html>` document inline (~120 lines of HTML inside a PHP function). This duplicates `head.php`/`header.php` logic.
**To do:**
- [ ] Extract the partage form page chrome to `templates/partage/form-page.php`
- [ ] Use `App::render()` or a dedicated `renderPartageForm()` helper
- [ ] Share meta tags, favicon references, CSRF meta, and live-reload script from `head.php`
### 10. Reduce form bootstrap duplication across 3 entry points
**Current state:** `admin/add.php`, `admin/edit.php`, and `partage/index.php` (inside `renderShareLinkForm()`) each repeat ~4060 lines of identical setup: load `ThesisCreateController`, call `loadFormData()`, build `$helpFn`, load site settings, set up jury/default access variables, etc.
**To do:**
- [ ] Add a `ThesisFormSetup` helper class (or static method on the controllers) that returns a standardised array of form variables
- [ ] Each entry point becomes: `$formVars = ThesisFormSetup::prepare($mode, $slug); extract($formVars); include '...form.php';`
## Deferred / Blocked

View File

@@ -183,27 +183,11 @@ renderShareLinkForm($slug, $link);
*/
function renderShareLinkError(string $title, string $message): void
{
$pageTitle = htmlspecialchars($title);
$pageTitle = $title;
include APP_ROOT . '/templates/partage/form-page.php';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $pageTitle ?></title>
<link rel="apple-touch-icon" sizes="152x152" href="/assets/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/assets/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/style.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
</head>
<body class="student-body">
<div class="share-error">
<h1><?= $pageTitle ?></h1>
<h1><?= htmlspecialchars($title) ?></h1>
<p><?= htmlspecialchars($message) ?></p>
<a href="/">← Retour à l'accueil</a>
</div>
@@ -258,24 +242,8 @@ function requirePasswordGate(array $link, string $slug): void
$flashError = $_SESSION['_flash_error'] ?? null;
unset($_SESSION['_flash_error']);
include APP_ROOT . '/templates/partage/form-page.php';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="apple-touch-icon" sizes="152x152" href="/assets/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/assets/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/style.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
</head>
<body class="student-body">
<div class="password-gate">
<h1>🔒 Accès protégé</h1>
<?php if ($flashError): ?>
@@ -335,8 +303,11 @@ function renderShareLinkForm(string $slug, array $link): void
$_SESSION['csrf_token'] = $shareCsrfToken;
}
$pageTitle = 'Soumettre un TFE';
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
$pageTitle = 'Soumettre un TFE';
$includeFilePond = true;
$csrfToken = $_SESSION['csrf_token'] ?? null;
$filepondBase = '/partage/actions/filepond';
$isVerified = !empty($_SESSION['share_verified_' . $slug]);
// WCAG 3.3.1: which field has a validation error (set by submit handler via App::flashAutofocus)
$errorFieldName = App::consumeAutofocus();
@@ -427,40 +398,9 @@ function renderShareLinkForm(string $slug, array $link): void
$contactPublic = false;
$currentContextNote = null;
$currentContactVisible = null;
include APP_ROOT . '/templates/partage/form-page.php';
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle) ?></title>
<link rel="apple-touch-icon" sizes="152x152" href="/assets/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/assets/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
<meta name="theme-color" content="#ffffff">
<?php if (!empty($_SESSION['csrf_token'])): ?>
<meta name="csrf-token" content="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<?php endif; ?>
<meta name="filepond-base" content="/partage/actions/filepond">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/style.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond.min.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond-plugin-image-preview.min.css') ?>">
<script src="<?= App::assetV('/assets/js/vendor/filepond.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-file-validate-type.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-file-validate-size.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-image-preview.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/file-upload-filepond.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/beforeunload-guard.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/pill-search.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/jury-autocomplete.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/htmx.min.js') ?>" defer></script>
</head>
<body class="student-body">
<main id="main-content">
<div class="thesis-add-header">
<h1><?= htmlspecialchars($pageTitle) ?></h1>

View File

@@ -0,0 +1,67 @@
<?php
/**
* Partage page chrome — wraps any partage page (form, error, password gate, confirmation).
*
* Required variables:
* string $pageTitle — page <title>
*
* Optional variables:
* bool $includeFilePond — if true, includes FilePond CSS/JS
* array $extraCss — additional CSS files to load
* array $extraJs — additional JS files to load
* ?string $csrfToken — CSRF token for <meta name="csrf-token">
* ?string $filepondBase — FilePond base URL for <meta name="filepond-base">
*/
$includeFilePond = $includeFilePond ?? false;
$extraCss = $extraCss ?? [];
$extraJs = $extraJs ?? [];
$csrfToken = $csrfToken ?? ($_SESSION['csrf_token'] ?? null);
$filepondBase = $filepondBase ?? null;
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? 'XAMXAM') ?></title>
<link rel="apple-touch-icon" sizes="152x152" href="/assets/favicon/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/assets/favicon/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon-180x180.png">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon/favicon-16x16.png">
<link rel="shortcut icon" href="/assets/favicon/favicon.ico">
<meta name="theme-color" content="#ffffff">
<?php if ($csrfToken !== null): ?>
<meta name="csrf-token" content="<?= htmlspecialchars($csrfToken) ?>">
<?php endif; ?>
<?php if ($filepondBase !== null): ?>
<meta name="filepond-base" content="<?= htmlspecialchars($filepondBase) ?>">
<?php endif; ?>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/style.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/form-base.css') ?>">
<?php if ($includeFilePond): ?>
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond.min.css') ?>">
<link rel="stylesheet" href="<?= App::assetV('/assets/css/filepond-plugin-image-preview.min.css') ?>">
<?php endif; ?>
<?php foreach ($extraCss as $css): ?>
<link rel="stylesheet" href="<?= App::assetV($css) ?>">
<?php endforeach; ?>
<?php if ($includeFilePond): ?>
<script src="<?= App::assetV('/assets/js/vendor/filepond.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-file-validate-type.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-file-validate-size.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-image-preview.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/filepond-plugin-image-exif-orientation.min.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/file-upload-filepond.js') ?>" defer></script>
<?php endif; ?>
<script src="<?= App::assetV('/assets/js/app/beforeunload-guard.js') ?>" defer></script>
<?php if ($includeFilePond): ?>
<script src="<?= App::assetV('/assets/js/app/pill-search.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/app/jury-autocomplete.js') ?>" defer></script>
<script src="<?= App::assetV('/assets/js/vendor/htmx.min.js') ?>" defer></script>
<?php endif; ?>
<?php foreach ($extraJs as $js): ?>
<script src="<?= App::assetV($js) ?>" defer></script>
<?php endforeach; ?>
</head>
<body class="student-body">