Files
xamxam/app/templates/partials/form/form.php
Pontoporeia 99125cc8e3 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)
2026-06-11 11:04:49 +02:00

561 lines
26 KiB
PHP

<?php
/**
* Shared TFE form partial — single source of truth for add, edit, and partage forms.
*
* Required variables (set by the including page):
* string $mode — 'add' | 'edit' | 'partage'
* string $formAction — form action URL
* string $hiddenFields — raw HTML for hidden inputs (csrf, thesis_id, etc.)
*
* old/value callables:
* callable $oldFn — fn(string $key, string $default=''): string (with escaping)
* callable $withAutofocusFn — fn(string $field, array $attrs=[]): array
*
* Data shared across fieldsets:
* array $formData — raw form repopulation data (not pre-escaped)
* array $orientations, $apPrograms, $finalityTypes, $languages, $formatTypes, $licenseTypes
*
* Jury data:
* ?string $juryPromoteur, $juryPromoteurUlb
* array $juryPromoteurs, $juryPromoteursUlb
* array $lecteursInternes, $lecteursExternes
* bool $showPromoteurUlb, $promoteurUlbConditional
*
* Licence / access:
* bool $libreEnabled, $interneEnabled, $interditEnabled
* string $generalitiesHtml
* int $defaultAccessTypeId
*
* Optional flags (all default to false):
* bool $showIntroHelp — render partage intro help block
* bool $showFlash — render flash banners (error/warning/success)
* bool $showContact — Contact checkbox fieldset
* bool $showCoverPreview — cover image preview + remove checkbox
* bool $showExistingFiles — existing thesis files list (deletable)
* bool $showBackoffice — Backoffice fieldset (context_note, jury_points, remarks, baiu_link, exemplaires, contact_visible, contact_interne, is_published)
* bool $showEmailConfirmation — E-mail de confirmation fieldset
* string $helpFn — fn(string $key): string (for help blocks)
* string $helpContent — current help block content (for help-block renders)
* array $helpBlocks — all help blocks (for intro)
*
* Files mode variables:
* string $filesMode — 'add' | 'edit' (determines which file inputs to show)
* ?string $currentCover — existing cover file info for edit mode
* array $currentFiles — existing thesis files for edit mode
* ?string $currentContextNote — existing context note for edit mode
* array $currentRaw — raw thesis row for edit mode
* ?string $contactPublic — contact visibility flag for edit mode
* ?string $contactInterne — contact email for edit mode
*
* Autosave:
* string $formExtraAttrs — extra HTML attributes to inject into the <form> tag
* bool $showAutosaveStatus — render the "Brouillon enregistré" status indicator
*
* Website:
* string $existingWebsiteUrl
* string $existingWebsiteLabel
* array $checkedFormatsForSiteWeb
*/
// ── Defaults ──────────────────────────────────────────────────────────────────
$mode = $mode ?? 'add';
$formExtraAttrs = $formExtraAttrs ?? '';
$showAutosaveStatus = $showAutosaveStatus ?? false;
// In admin add/edit, no field is required (admins can save partial records)
$adminMode = ($mode === 'add' || $mode === 'edit');
$formAction = $formAction ?? '';
$hiddenFields = $hiddenFields ?? '';
$formData = $formData ?? [];
$synopsisExtra = $synopsisExtra ?? "";
$juryPromoteur = $juryPromoteur ?? null;
$juryPromoteurs = $juryPromoteurs ?? [];
$juryPromoteurUlb = $juryPromoteurUlb ?? null;
$juryPromoteursUlb = $juryPromoteursUlb ?? [];
$lecteursInternes = $lecteursInternes ?? [];
$lecteursExternes = $lecteursExternes ?? [];
$juryPresident = $juryPresident ?? null;
$showPromoteurUlb = $showPromoteurUlb ?? true;
$promoteurUlbConditional = $promoteurUlbConditional ?? false;
$libreEnabled = $libreEnabled ?? true;
$interneEnabled = $interneEnabled ?? true;
$interditEnabled = $interditEnabled ?? true;
$generalitiesHtml = $generalitiesHtml ?? "";
$defaultAccessTypeId = $defaultAccessTypeId ?? 2;
// Optional flags
$showIntroHelp = $showIntroHelp ?? false;
$showFlash = $showFlash ?? false;
$showContact = $showContact ?? false;
$showCoverPreview = $showCoverPreview ?? false;
$showExistingFiles = $showExistingFiles ?? false;
$showBannerPreview = false; // Banners merged into covers — field removed
$showBackoffice = $showBackoffice ?? false;
$showEmailConfirmation = $showEmailConfirmation ?? false;
$oldFn = $oldFn ?? (function_exists('old') ? 'old' : fn($k, $d = '') => $d);
$withAutofocusFn = $withAutofocusFn ?? fn($field, $attrs = []) => $attrs;
$filesMode = $filesMode ?? 'add';
$existingWebsiteUrl = $existingWebsiteUrl ?? '';
$existingWebsiteLabel = $existingWebsiteLabel ?? '';
$checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
// WCAG 3.3.1: which field has a validation error (set by caller from App::consumeAutofocus())
$errorFieldName = $errorFieldName ?? null;
?>
<?php if ($showIntroHelp && isset($helpFn)): ?>
<?php
$helpContent = $helpFn("partage_intro");
$helpKey = 'partage_intro';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
?>
<?php endif; ?>
<?php if ($showFlash): ?>
<?php
$flashError = $_SESSION["_flash_error"] ?? null;
$flashWarning = $_SESSION["_flash_warning"] ?? null;
$flashSuccess = $_SESSION["_flash_success"] ?? null;
$flashContact = $_SESSION["_flash_contact"] ?? false;
unset(
$_SESSION["_flash_error"],
$_SESSION["_flash_warning"],
$_SESSION["_flash_success"],
$_SESSION["_flash_contact"],
);
?>
<?php if ($flashError): ?>
<div class="flash-error" id="flash-error" role="alert" tabindex="-1"><?= htmlspecialchars(
$flashError,
) ?></div>
<?php endif; ?>
<?php if ($flashWarning): ?>
<div class="flash-warning" id="flash-warning" role="alert" tabindex="-1"><?= htmlspecialchars(
$flashWarning,
) ?></div>
<script>document.addEventListener('DOMContentLoaded',function(){var el=document.getElementById('flash-warning');if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.focus();}});</script>
<?php endif; ?>
<?php if ($flashContact && $mode === 'partage'): ?>
<?php require_once APP_ROOT . '/src/EmailObfuscator.php'; ?>
<div class="flash-info" role="alert">
Si le problème persiste, envoyez un e-mail à
<a href="<?= EmailObfuscator::mailto('xamxam@erg.be') ?>"><?= EmailObfuscator::email('xamxam@erg.be') ?></a>.
</div>
<?php endif; ?>
<?php if ($flashSuccess): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars(
$flashSuccess,
) ?></div>
<?php endif; ?>
<?php endif; ?>
<form action="<?= $formAction ?>" method="post" enctype="multipart/form-data" class="admin-form" data-beforeunload-guard <?= $formExtraAttrs ?>>
<!-- Default: JS-disabled mode (disabled → not submitted → server uses $_FILES path).
On DOMContentLoaded, JS enables this input and sets value="1" → server uses FilePond path. -->
<input type="hidden" name="filepond_mode" value="0" disabled>
<?= $hiddenFields ?>
<?php if (!$adminMode): ?>
<p class="required-note"><span class="asterisk">*</span> Champs obligatoires</p>
<?php endif; ?>
<!-- ═══════════════════ Informations du TFE ═══════════════════ -->
<?php
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_tfe_info");
$helpKey = 'fieldset_tfe_info';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
include APP_ROOT . "/templates/partials/form/fieldset-tfe-info.php";
?>
<?php if ($showContact): ?>
<!-- ═══════════════════ Contact ═══════════════════ -->
<fieldset>
<legend>Contact</legend>
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="contact_public" value="1"
<?= !empty($formData["contact_public"]) ||
($contactPublic ?? false)
? "checked"
: "" ?>>
Rendre le contact visible publiquement sur la fiche du TFE
</label>
<small>L'adresse est toujours conservée en interne comme contact de référence.</small>
</div>
</fieldset>
<?php endif; ?>
<!-- ═══════════════════ Langue(s) ═══════════════════ -->
<?php
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_languages");
$helpKey = 'fieldset_languages';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
?>
<fieldset id="languages-fieldset">
<legend>Langue(s)</legend>
<?php
$name = "languages";
$label = "Langue(s) du TFE :";
$options = $languages;
$checked = $formData["languages"] ?? [];
$_hasLangAutre = !empty($formData['language_autre']) && is_array($formData['language_autre']) && count(array_filter($formData['language_autre'], fn($l) => is_string($l) && trim($l) !== '')) > 0;
$required = !$adminMode && !$_hasLangAutre;
$hxPost = $mode === 'partage' ? "/partage/fragments/language-autre.php" : "/admin/fragments/language-autre.php";
$hxTarget = "#languages-required-asterisk";
$hxSwap = "outerHTML";
$labelHtml = htmlspecialchars($label) . '<span id="languages-required-asterisk">' . ($required ? ' <span class="asterisk">*</span>' : '') . '</span>';
include APP_ROOT . "/templates/partials/form/checkbox-list.php";
unset($hxSwap, $_hasLangAutre, $labelHtml);
?>
<?php
$_langAutreRequired = empty($formData["languages"]);
// Build selectedLanguages array from form data or current other languages
$_selectedOtherLangs = [];
if (!empty($formData['language_autre']) && is_array($formData['language_autre'])) {
foreach ($formData['language_autre'] as $_l) {
if (is_string($_l) && trim($_l) !== '') {
$_selectedOtherLangs[] = ['name' => trim($_l)];
}
}
} elseif (!empty($selectedOtherLanguages) && is_array($selectedOtherLanguages)) {
$_selectedOtherLangs = array_map(fn($n) => ['name' => $n], $selectedOtherLanguages);
} else {
$_langRaw = $formData["language_autre"] ?? '';
if (is_string($_langRaw) && $_langRaw !== '') {
foreach (array_map('trim', explode(',', $_langRaw)) as $_l) {
if ($_l !== '') {
$_selectedOtherLangs[] = ['name' => $_l];
}
}
}
}
?>
<?php
$name = "language_autre";
$label = "Autre(s) langue(s) :";
$placeholder = "Rechercher une langue…";
$hint = "Si votre TFE contient une langue absente de la liste, précisez-la ici.";
$selectedLanguages = $_selectedOtherLangs;
$required = $_langAutreRequired && !$adminMode;
$hxPost = ($mode === 'partage') ? "/partage/fragments/pill-search.php" : "/admin/fragments/pill-search.php";
include APP_ROOT . "/templates/partials/form/language-search.php";
unset($_langAutreRequired, $_selectedOtherLangs, $_langRaw, $_l, $name, $label, $placeholder, $hint, $selectedLanguages, $required, $hxPost);
?>
</fieldset>
<!-- ═══════════════════ Mots-clés ═══════════════════ -->
<?php
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_keywords");
$helpKey = 'fieldset_keywords';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
?>
<fieldset>
<legend>Mots-clés</legend>
<?php
// Build selectedTags array from form data or thesis keywords
$_selectedTags = [];
// If formData has tag as an array (pill-based repopulation), prefer that
if (!empty($formData['tag']) && is_array($formData['tag'])) {
foreach ($formData['tag'] as $_t) {
if (is_string($_t) && trim($_t) !== '') {
$_selectedTags[] = ['name' => trim($_t)];
}
}
} elseif (!empty($currentTags) && is_array($currentTags)) {
$_selectedTags = array_map(fn($n) => ['name' => $n], $currentTags);
} else {
$_tagsRaw = $formData["tag"] ?? '';
if (is_string($_tagsRaw) && $_tagsRaw !== '') {
foreach (array_map('trim', explode(',', $_tagsRaw)) as $_t) {
if ($_t !== '') {
$_selectedTags[] = ['name' => $_t];
}
}
}
}
$name = "tag";
$label = "Mots-clés :";
$placeholder = "Rechercher un mot-clé…";
$hint = "Tapez pour rechercher ou créer des mots-clés.";
$selectedTags = $_selectedTags;
$required = !$adminMode;
$minTags = ($mode === 'partage') ? 3 : 0;
$hxPost = ($mode === 'partage') ? "/partage/fragments/pill-search.php" : "/admin/fragments/pill-search.php";
include APP_ROOT . "/templates/partials/form/tag-search.php";
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost, $minTags, $required);
?>
</fieldset>
<!-- ═══════════════════ Cadre académique ═══════════════════ -->
<?php
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_academic");
$helpKey = 'fieldset_academic';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
include APP_ROOT .
"/templates/partials/form/fieldset-academic.php"; ?>
<!-- ═══════════════════ Composition du jury ═══════════════════ -->
<?php
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_jury");
$helpKey = 'fieldset_jury';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
require APP_ROOT . "/templates/partials/form/jury-fieldset.php";
?>
<!-- ═══════════════════ Format(s) + Fichiers ═══════════════════ -->
<?php
// Helper: build existing-files JSON for a specific FilePond queue type.
$_buildQueueFilesJson = function (array $files, string $queueType): array {
$result = [];
foreach ($files as $f) {
$ft = $f['file_type'] ?? '';
$fp = $f['file_path'] ?? '';
// Skip website URLs (no actual file)
if (str_starts_with($fp, 'http://') || str_starts_with($fp, 'https://')) {
continue;
}
// Only include files matching the requested queue type
if ($queueType === 'cover' && $ft !== 'cover') continue;
if ($queueType === 'note_intention' && $ft !== 'note_intention') continue;
if ($queueType === 'tfe' && ($ft === 'cover' || $ft === 'note_intention' || $ft === 'annex')) continue;
if ($queueType === 'annexe' && $ft !== 'annex') continue;
// Include PeerTube files too — load.php now handles them
$result[] = [
'source' => (string)((int)$f['id']),
'options' => [
'type' => 'local',
'file' => [
'name' => $f['file_name'] ?? basename($f['file_path'] ?? ''),
'size' => (int)($f['file_size'] ?? 0),
'type' => $f['mime_type'] ?? 'application/octet-stream',
],
],
];
}
// Include session temp files so uploads survive page reload
require_once APP_ROOT . '/src/FilepondHandler.php';
$tempFiles = FilepondHandler::getSessionTempFiles($queueType);
foreach ($tempFiles as $tf) {
$result[] = $tf;
}
return $result;
};
if ($filesMode === 'add'): ?>
<?php
// Add / partage mode: Format + Fichiers rendered as one HTMX-swappable block.
// Synthesise POST-like data so fichiers-fragment.php can render the initial state.
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_files");
$helpKey = 'fieldset_files';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
// Temporarily populate $_POST so the fragment can read formats/website/annexes values.
$_savedPost = $_POST;
$_POST['formats'] = $checkedFormatsForSiteWeb;
$_POST['website_url'] = $existingWebsiteUrl;
$_POST['website_label'] = $existingWebsiteLabel;
$_POST['admin_mode'] = $adminMode ? '1' : '0';
$_POST['has_annexes'] = $formData['has_annexes'] ?? null;
$existingFilesJsonForCover = [];
$existingFilesJsonForNoteIntention = [];
$existingFilesJsonForTfe = [];
$existingFilesJsonForAnnexe = [];
include APP_ROOT . '/templates/partials/form/fichiers-fragment.php';
$_POST = $_savedPost;
unset($_savedPost);
?>
<?php else: ?>
<!-- Edit mode: reuse the same Format + Fichiers HTMX fragment as add/partage -->
<?php
// Synthesise POST-like data so fichiers-fragment.php renders the initial state.
$_savedPost = $_POST;
$_POST['formats'] = $checkedFormatsForSiteWeb;
$_POST['website_url'] = $existingWebsiteUrl;
$_POST['website_label'] = $existingWebsiteLabel;
$_POST['admin_mode'] = $adminMode ? '1' : '0';
$_POST['edit_mode'] = '1';
$_POST['has_annexes'] = $formData['has_annexes'] ?? null;
// Build per-queue-type existing-files JSON for FilePond edit mode
$existingFilesJsonForCover = $_buildQueueFilesJson($currentFiles ?? [], 'cover');
$existingFilesJsonForNoteIntention = $_buildQueueFilesJson($currentFiles ?? [], 'note_intention');
$existingFilesJsonForTfe = $_buildQueueFilesJson($currentFiles ?? [], 'tfe');
$existingFilesJsonForAnnexe = $_buildQueueFilesJson($currentFiles ?? [], 'annexe');
include APP_ROOT . '/templates/partials/form/fichiers-fragment.php';
$_POST = $_savedPost;
unset($_savedPost);
?>
<?php endif; ?>
<!-- ═══════════════════ Métadonnées complémentaires ═══════════════════
(Durée/Nombre de pages supprimés — redondants avec les fichiers attachés) -->
<!-- ═══════════════════ Degrés d'ouverture et licences ═══════════════════ -->
<?php
if ($mode === "partage" && isset($helpFn)) {
$helpContent = $helpFn("fieldset_access");
$helpKey = 'fieldset_access';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
}
include APP_ROOT .
"/templates/partials/form/fieldset-licence-explanation.php";
?>
<?php if ($showBackoffice): ?>
<!-- ═══════════════════ Backoffice ═══════════════════ -->
<fieldset>
<legend>Backoffice</legend>
<!-- 1. Note contextuelle relative à soutenance -->
<div class="admin-form-group">
<label for="context_note">Note contextuelle relative à soutenance :</label>
<div>
<textarea id="context_note" name="context_note"
rows="4" maxlength="1500"><?= htmlspecialchars(
$currentContextNote ??
($formData["context_note"] ?? ""),
) ?></textarea>
<small>Visible publiquement pour les TFE Interne ou Interdit. Max 1 500 caractères.</small>
</div>
</div>
<!-- 2. Points du jury -->
<div class="admin-form-group">
<label for="jury_points">Points du jury :</label>
<input type="number" id="jury_points" name="jury_points"
value="<?= htmlspecialchars(
$currentRaw["jury_points"] ??
($formData["jury_points"] ?? ""),
) ?>"
step="0.01" min="0" max="20" placeholder="sur 20">
<small>Note du jury (interne, non visible publiquement).</small>
</div>
<!-- 3. Remarques -->
<div class="admin-form-group">
<label for="remarks">Remarques :</label>
<textarea id="remarks" name="remarks" rows="4"><?= htmlspecialchars(
$currentRaw["remarks"] ?? ($formData["remarks"] ?? ""),
) ?></textarea>
<small>Notes internes (non visibles publiquement).</small>
</div>
<!-- 4. Lien BAIU -->
<?php
$name = 'lien'; $label = 'Lien BAIU :'; $value = $oldFn('lien');
$type = 'url'; $placeholder = 'https://...'; $hint = '';
include APP_ROOT . '/templates/partials/form/text-field.php';
?>
<!-- 5. Exemplaire BAIU -->
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="exemplaire_baiu" value="1"
<?= !empty(
$currentRaw["exemplaire_baiu"] ??
($formData["exemplaire_baiu"] ?? false)
)
? "checked"
: "" ?>>
Exemplaire physique BAIU
</label>
<small>Case logistique : cocher si un exemplaire physique est disponible à la BAIU.</small>
</div>
<!-- 6. Exemplaire ERG -->
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="exemplaire_erg" value="1"
<?= !empty(
$currentRaw["exemplaire_erg"] ??
($formData["exemplaire_erg"] ?? false)
)
? "checked"
: "" ?>>
Exemplaire physique ERG
</label>
<small>Case logistique : cocher si un exemplaire physique est disponible à l'ERG.</small>
</div>
<!-- 7. Contact interne (privé) -->
<div class="admin-form-group">
<label for="contact_interne">Contact interne (privé) :</label>
<input type="email" id="contact_interne" name="contact_interne"
value="<?= htmlspecialchars($contactInterne ?? $formData['contact_interne'] ?? '') ?>"
placeholder="ton.email@exemple.be">
<small>Email privé de l'étudiant·e, utilisé pour l'envoi de la confirmation du formulaire. Non visible publiquement.</small>
</div>
<!-- 9. Publication -->
<div class="admin-form-group">
<label class="admin-checkbox-label">
<input type="checkbox" name="is_published" value="1"
<?= !empty($currentRaw["is_published"] ?? false)
? "checked"
: "" ?>>
Publier ce TFE sur le site public
</label>
<small>Si coché, la fiche apparaîtra dans les résultats de recherche du site public.</small>
</div>
</fieldset>
<?php endif; ?>
<?php if ($showEmailConfirmation): ?>
<!-- ═══════════════════ E-mail de confirmation ═══════════ -->
<fieldset>
<legend>E-mail de confirmation</legend>
<?php if ($mode === "partage" && isset($helpFn)): ?>
<?php
$helpContent = $helpFn("fieldset_email");
$helpKey = 'fieldset_email';
include APP_ROOT . "/templates/partials/form/form-help-block.php";
?>
<?php endif; ?>
<?php
$name = "confirmation_email";
$label = "Adresse e-mail :";
$value = $oldFn("confirmation_email");
$type = "email";
$required = !$adminMode;
$placeholder = "ton.email@exemple.be";
$hint =
"Nécessaire pour recevoir le récapitulatif de ta soumission.";
include APP_ROOT . "/templates/partials/form/text-field.php";
?>
</fieldset>
<?php endif; ?>
<?php if ($showAutosaveStatus): ?>
<div class="autosave-status" data-autosave-status></div>
<?php endif; ?>
<div class="form-footer admin-form-footer">
<button type="submit" name="go" class="btn btn--primary"><?= $mode === 'edit' ? 'Enregistrer' : 'Soumettre' ?></button>
<?php if ($mode === 'add' || $mode === 'edit'): ?>
<a href="/admin/" class="btn btn--secondary">Annuler</a>
<?php endif; ?>
</div>
</form>