mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Error tests, FK violations fix
- ErrorHandler tests: 77 assertions covering FK extraction, normalization, dedup, edge cases. Fix FK table map for child tables. - Fix FK violation: (int)null → 0 in createThesis for orientation/ap/finality/license FK columns. Add FK value logging to updateThesis. - Add CURRENT_ISSUES.md with summary of FK violation, dev debugging, and tag dedup status for next conversation
This commit is contained in:
@@ -222,13 +222,36 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
||||
<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 (max 10) :";
|
||||
$value = $oldFn("tag");
|
||||
$placeholder = "sociologie, anthropologie, ...";
|
||||
$hint = "Séparez par des virgules. Max 10 mots-clés.";
|
||||
$attrs = $withAutofocusFn("tag");
|
||||
include APP_ROOT . "/templates/partials/form/text-field.php";
|
||||
$label = "Mots-clés :";
|
||||
$placeholder = "Rechercher un mot-clé…";
|
||||
$hint = "Tapez pour rechercher ou créer des mots-clés.";
|
||||
$selectedTags = $_selectedTags;
|
||||
$hxPost = ($mode === 'partage') ? "/partage/tag-search-fragment" : "/admin/tag-search-fragment.php";
|
||||
include APP_ROOT . "/templates/partials/form/tag-search.php";
|
||||
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost);
|
||||
?>
|
||||
</fieldset>
|
||||
|
||||
|
||||
236
app/templates/partials/form/tag-search.php
Normal file
236
app/templates/partials/form/tag-search.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
/**
|
||||
* Tag search partial — interactive mot-clé input with HTMX-powered suggestions.
|
||||
*
|
||||
* Replaces the old comma-separated text field with an interactive component:
|
||||
* - Type to search among existing tags via HTMX
|
||||
* - If the tag doesn't exist, a "Créer" option appears
|
||||
* - Selected tags are shown as pills with a round delete button (bin icon)
|
||||
* - All keywords are lowercased and deduplicated
|
||||
*
|
||||
* Variables consumed:
|
||||
* string $name — base input name (hidden inputs will be name[]); default 'tag'
|
||||
* string $label — visible label text
|
||||
* string $placeholder — placeholder text for the search input
|
||||
* string $hint — optional hint shown above the input
|
||||
* string $hxPost — HTMX POST endpoint for tag search
|
||||
* array $selectedTags — array of ['id' => int|null, 'name' => string] for pre-filled tags
|
||||
* string|null $id — override the id attribute prefix
|
||||
* int $maxTags — maximum number of tags (default 10)
|
||||
*/
|
||||
|
||||
$name = $name ?? 'tag';
|
||||
$label = $label ?? 'Mots-clés';
|
||||
$placeholder = $placeholder ?? 'Rechercher un mot-clé…';
|
||||
$hint = $hint ?? null;
|
||||
$hxPost = $hxPost ?? '/admin/tag-search-fragment.php';
|
||||
$selectedTags = $selectedTags ?? [];
|
||||
$id = $id ?? $name;
|
||||
$maxTags = $maxTags ?? 10;
|
||||
$tagCount = count($selectedTags);
|
||||
?>
|
||||
<div id="<?= htmlspecialchars($id) ?>-search-container">
|
||||
<span class="admin-row-label"><?= htmlspecialchars($label) ?></span>
|
||||
<div class="tag-search-wrapper">
|
||||
<?php if ($hint): ?>
|
||||
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Active tag pills -->
|
||||
<div class="tag-search-pills" id="<?= htmlspecialchars($id) ?>-pills">
|
||||
<?php foreach ($selectedTags as $tag): ?>
|
||||
<span class="tag-pill">
|
||||
<input type="hidden" name="<?= htmlspecialchars($name) ?>[]" value="<?= htmlspecialchars($tag['name']) ?>">
|
||||
<span class="tag-pill-name"><?= htmlspecialchars($tag['name']) ?></span>
|
||||
<button type="button" class="tag-pill-remove" title="Retirer « <?= htmlspecialchars($tag['name']) ?> »" aria-label="Retirer <?= htmlspecialchars($tag['name']) ?>">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>
|
||||
</button>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Counter visible only when tags exist or max reached -->
|
||||
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= $tagCount === 0 ? ' style="display:none"' : '' ?>>
|
||||
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"><?= $tagCount ?>/<?= (int)$maxTags ?></span>
|
||||
<?php if ($tagCount >= $maxTags): ?>
|
||||
<span class="tag-search-max-msg">Maximum de mots-clés atteint</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Search input (hidden when max tags reached) -->
|
||||
<div class="tag-search-input-wrap"<?= $tagCount >= $maxTags ? ' style="display:none"' : '' ?>>
|
||||
<input type="text"
|
||||
name="q"
|
||||
id="<?= htmlspecialchars($id) ?>-search"
|
||||
class="tag-search-input"
|
||||
placeholder="<?= htmlspecialchars($placeholder) ?>"
|
||||
autocomplete="off"
|
||||
hx-post="<?= htmlspecialchars($hxPost) ?>"
|
||||
hx-trigger="input changed delay:200ms, focus"
|
||||
hx-target="#<?= htmlspecialchars($id) ?>-suggestions"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#<?= htmlspecialchars($id) ?>-pills">
|
||||
</div>
|
||||
|
||||
<!-- Suggestions dropdown (positioned absolutely over content) -->
|
||||
<div class="tag-search-suggestions" id="<?= htmlspecialchars($id) ?>-suggestions" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline script for the interactive behaviour (no external JS required) -->
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
|
||||
if (!container || container._tagSearchInit) return;
|
||||
container._tagSearchInit = true;
|
||||
|
||||
const pills = document.getElementById(<?= json_encode($id . '-pills') ?>);
|
||||
const search = document.getElementById(<?= json_encode($id . '-search') ?>);
|
||||
const dropdown = document.getElementById(<?= json_encode($id . '-suggestions') ?>);
|
||||
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
|
||||
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
|
||||
const maxTags = <?= (int)$maxTags ?>;
|
||||
const inputName = <?= json_encode($name) ?>;
|
||||
let selectedIdx = -1;
|
||||
|
||||
function updateCount() {
|
||||
const n = pills.querySelectorAll('.tag-pill').length;
|
||||
if (countEl) countEl.textContent = n + '/' + maxTags;
|
||||
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
||||
|
||||
// Show/hide search input based on max
|
||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||
const maxMsg = container.querySelector('.tag-search-max-msg');
|
||||
if (n >= maxTags) {
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
if (maxMsg) maxMsg.style.display = '';
|
||||
} else {
|
||||
if (wrap) {
|
||||
wrap.style.display = '';
|
||||
if (search) search.style.display = '';
|
||||
}
|
||||
if (maxMsg) maxMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Lowercase, collapse spaces, trim
|
||||
function normalizeTag(name) {
|
||||
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
// Check if tag already exists in pills (case-insensitive)
|
||||
function tagAlreadyAdded(name) {
|
||||
const norm = normalizeTag(name);
|
||||
const existing = pills.querySelectorAll('.tag-pill-name');
|
||||
for (const el of existing) {
|
||||
if (normalizeTag(el.textContent) === norm) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove a pill
|
||||
pills.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-pill-remove');
|
||||
if (!btn) return;
|
||||
const pill = btn.closest('.tag-pill');
|
||||
pill.remove();
|
||||
updateCount();
|
||||
// Re-enable search field visibility
|
||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||
const searchInput = container.querySelector('.tag-search-input');
|
||||
if (wrap && searchInput) {
|
||||
wrap.style.display = '';
|
||||
searchInput.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight a suggestion by index
|
||||
function highlight(idx) {
|
||||
const items = dropdown.querySelectorAll('.tag-search-item');
|
||||
items.forEach(function(item, i) {
|
||||
if (i === idx) {
|
||||
item.classList.add('tag-search-item--highlight');
|
||||
} else {
|
||||
item.classList.remove('tag-search-item--highlight');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select a suggestion by button element
|
||||
function selectTag(btn) {
|
||||
const tagName = normalizeTag(btn.getAttribute('data-tag-name') || '');
|
||||
if (!tagName) return;
|
||||
|
||||
if (tagAlreadyAdded(tagName)) return;
|
||||
if (pills.querySelectorAll('.tag-pill').length >= maxTags) return;
|
||||
|
||||
const escapedName = htmlEscape(tagName);
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'tag-pill';
|
||||
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escapedName + '">'
|
||||
+ '<span class="tag-pill-name">' + escapedName + '</span>'
|
||||
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escapedName + '\u00A0\u00BB" aria-label="Retirer ' + escapedName + '">'
|
||||
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
|
||||
+ '</button>';
|
||||
pills.appendChild(pill);
|
||||
updateCount();
|
||||
search.value = '';
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
search.focus();
|
||||
}
|
||||
|
||||
// Click on suggestion
|
||||
dropdown.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-search-item');
|
||||
if (!btn) return;
|
||||
selectTag(btn);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
search.addEventListener('keydown', function(e) {
|
||||
const items = dropdown.querySelectorAll('.tag-search-item');
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
selectedIdx = (selectedIdx + 1) % items.length;
|
||||
} else {
|
||||
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
|
||||
}
|
||||
highlight(selectedIdx);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (items.length > 0) {
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
||||
selectTag(items[selectedIdx]);
|
||||
} else {
|
||||
selectTag(items[0]);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Hide dropdown on blur (after a tiny delay so click events fire)
|
||||
search.addEventListener('blur', function() {
|
||||
setTimeout(function() {
|
||||
if (!dropdown.contains(document.activeElement)) {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
function htmlEscape(str) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = str;
|
||||
return el.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
unset($name, $label, $placeholder, $hint, $hxPost, $selectedTags, $id, $maxTags, $tagCount);
|
||||
Reference in New Issue
Block a user