Add autosave draft system for partage form with HTMX-based session persistence

- New fragment endpoint POST/GET /partage/fragments/draft.php:
  saves all form fields to PHP session, excludes file/csrf/slug fields
  GET returns JSON for JS hydration on page load
  rotates both global CSRF and share CSRF tokens in sync

- form.php accepts optional $formExtraAttrs and $showAutosaveStatus:
  allows injecting HTMX attributes and 'Brouillon enregistré' indicator

- renderShareLinkForm adds hx-post with change/input debounce trigger,
  loads autosave-handler.js, hydrate fields from draft on page load

- Draft cleared on successful form submission in handleShareLinkSubmission

- autosave-handler.js now also updates share_link_token hidden input
  when rotating CSRF token (partage form uses both csrf_token and share_link_token)

- Added .autosave-status CSS to form.css (was admin.css-only)

- Updated fragment routing to accept GET requests (needed for draft hydration)
This commit is contained in:
Pontoporeia
2026-06-11 10:32:53 +02:00
parent 4b37a05be3
commit 99125cc8e3
33 changed files with 1388 additions and 806 deletions

View File

@@ -99,7 +99,7 @@ class TestDatabase
}
// Re-seed tags (some tests rely on tags existing)
try {
$pdo->exec("DELETE FROM tags WHERE deleted_at IS NOT NULL");
$pdo->exec('DELETE FROM tags WHERE deleted_at IS NOT NULL');
} catch (Exception $e) {
// tags table already empty
}

View File

@@ -42,7 +42,7 @@ class AutofocusFieldForErrorTest extends TestCase
public function testCreateAutofocusFinality(): void
{
$this->assertSame('finality', ThesisCreateController::autofocusFieldForError("La finalité est manquante."));
$this->assertSame('finality', ThesisCreateController::autofocusFieldForError('La finalité est manquante.'));
}
public function testCreateAutofocusLanguages(): void

View File

@@ -252,20 +252,20 @@ class DatabaseExtendedTest extends TestCase
$pdo = TestDatabase::getPDO();
// Count seed languages first (français, anglais, néerlandais, italian)
$seedCount = (int)$pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn();
$seedCount = (int)$pdo->query('SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL')->fetchColumn();
// Insert two languages that differ only by case
$pdo->prepare("INSERT INTO languages (name) VALUES ('TestLang')")->execute();
$pdo->prepare("INSERT INTO languages (name) VALUES ('testlang')")->execute();
// Both seed + 2 new should exist before dedup
$before = $pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn();
$before = $pdo->query('SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL')->fetchColumn();
$this->assertSame($seedCount + 2, (int)$before);
$this->db->deduplicateLanguages();
// One of the dupes should be soft-deleted
$after = $pdo->query("SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL")->fetchColumn();
$after = $pdo->query('SELECT COUNT(*) FROM languages WHERE deleted_at IS NULL')->fetchColumn();
$this->assertSame($seedCount + 1, (int)$after);
}
@@ -293,7 +293,7 @@ class DatabaseExtendedTest extends TestCase
// Create a thesis linked to 'Français'
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Merge Test', 'Author', 2024);
$pdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)")
$pdo->prepare('INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?, ?)')
->execute([$thesisId, $francaisId]);
// Merge Français → French
@@ -333,7 +333,7 @@ class DatabaseExtendedTest extends TestCase
$tagB = (int)$pdo->lastInsertId();
[$authorId, $thesisId] = TestDatabase::seedBasicThesis('Tag Merge', 'Author', 2024);
$pdo->prepare("INSERT INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)")
$pdo->prepare('INSERT INTO thesis_tags (tag_id, thesis_id) VALUES (?, ?)')
->execute([$tagB, $thesisId]);
$this->db->mergeTag($tagB, $tagA);

View File

@@ -15,17 +15,26 @@ class PureLogicTest extends TestCase
// We need a TfeController instance to test protected methods.
// Use the anonymous subclass pattern.
$db = TestDatabase::getInstance();
return new class($db) extends TfeController {
public function exposedSplitJuryByRole(array $jury): array { return $this->splitJuryByRole($jury); }
public function exposedCollectCaptionPaths(array $files): array { return $this->collectCaptionPaths($files); }
return new class ($db) extends TfeController {
public function exposedSplitJuryByRole(array $jury): array
{
return $this->splitJuryByRole($jury);
}
public function exposedCollectCaptionPaths(array $files): array
{
return $this->collectCaptionPaths($files);
}
};
}
private function getThesisCreateController(): ThesisCreateController
{
$db = TestDatabase::getInstance();
return new class($db) extends ThesisCreateController {
public function exposedDetectFileType(string $mimeType, string $ext): string { return $this->detectFileType($mimeType, $ext); }
return new class ($db) extends ThesisCreateController {
public function exposedDetectFileType(string $mimeType, string $ext): string
{
return $this->detectFileType($mimeType, $ext);
}
};
}

View File

@@ -15,7 +15,7 @@ class TfeControllerOgTest extends TestCase
*/
private static function makeController(): object
{
return new class extends TfeController {
return new class () extends TfeController {
public function __construct()
{
// Skip parent constructor — we don't need DB for these pure methods

View File

@@ -106,8 +106,8 @@ class ThesisEditValidationTest extends TestCase
$this->assertCount(3, $members);
$internes = array_filter($members, fn($m) => $m['is_external'] === 0 && $m['role'] === 'lecteur');
$externes = array_filter($members, fn($m) => $m['is_external'] === 1 && $m['role'] === 'lecteur');
$internes = array_filter($members, fn ($m) => $m['is_external'] === 0 && $m['role'] === 'lecteur');
$externes = array_filter($members, fn ($m) => $m['is_external'] === 1 && $m['role'] === 'lecteur');
$this->assertCount(2, $internes);
$this->assertCount(1, $externes);