mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
perf(admin): infinite-scroll the contenus langues/mots-clés tables
The contenus page loaded both lookup tables in full via HTMX on page load: 760 tag rows + 216 language rows, ~2.4MB of HTML including ~1960 inline SVG icons and per-row CSRF forms. The DB queries were already fast (~6ms); the cost was pure client-side payload and DOM. - Add paged lookups: getTagsPage/getLanguagesPage + countTagsWithCount/ countLanguagesWithCount, and optional limit/offset on the existing unpaged variants (backward compatible). - Fragments serve 25 rows per request. A sentinel <tr> with hx-trigger="intersect once root:#<wrap>" appends the next page when it scrolls into the table's own scroll container (htmx 'revealed' only checks the window viewport, not nested scrollers). - Search forms still swap the whole wrapper and reset to offset 0. - Style the load-more row; add PagedLanguagesTagsTest coverage. Initial DOM for the page drops from ~2.4MB to ~160KB; scrolling reaches the full totals (736 tags, 216 languages) and stops cleanly.
This commit is contained in:
@@ -51,6 +51,9 @@
|
|||||||
- [x] #fix-desync-deploy-code-exit-23 Fix desync: deploy-code exit-23 'Operation not permitted' — drop -p/-t (destination owned by www-data:xamxam, setgid; SSH user can't chmod), use -rlDz --size-only --context deploy
|
- [x] #fix-desync-deploy-code-exit-23 Fix desync: deploy-code exit-23 'Operation not permitted' — drop -p/-t (destination owned by www-data:xamxam, setgid; SSH user can't chmod), use -rlDz --size-only --context deploy
|
||||||
- [x] #deploy-permissions-sudo-prompt-can-t deploy-permissions sudo prompt can't accept input: ssh -t drops pty when local stdin isn't a TTY. Add scoped NOPASSWD sudo drop-in (deploy/xamxam-fix-permissions.sudoers) + deploy-sudoers recipe and wire into deploy --context deploy
|
- [x] #deploy-permissions-sudo-prompt-can-t deploy-permissions sudo prompt can't accept input: ssh -t drops pty when local stdin isn't a TTY. Add scoped NOPASSWD sudo drop-in (deploy/xamxam-fix-permissions.sudoers) + deploy-sudoers recipe and wire into deploy --context deploy
|
||||||
- [x] #fix-raw-markdown-leaking Fix raw markdown leaking into TOC labels on licence/charte/about pages
|
- [x] #fix-raw-markdown-leaking Fix raw markdown leaking into TOC labels on licence/charte/about pages
|
||||||
|
- [x] #rewrite-cc2r-checkbox-label Rewrite CC2r checkbox label to 'J'adhère au Collective Commitment to Reuse (CC2r)' (italic, both Libre and Interne branches)
|
||||||
|
- [x] #fix-admin-contenus-page [!high] Fix admin contenus page slowdown: langues/mots-clés fragments ship 2.4MB HTML (737 tag rows + 217 lang rows, ~1960 inline SVG icons + per-row CSRF forms). Implement htmx infinite-scroll 'load more' (paged fragments via limit/offset) for both tables.
|
||||||
|
- [x] #reduce-contenus-initial-page [!high] Reduce contenus initial page size from 100 to 25 rows per table (payload ~160KB total vs ~534KB)
|
||||||
|
|
||||||
## Deferred / Blocked
|
## Deferred / Blocked
|
||||||
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
|
- [ ] #just-setup-backs-a [!medium] just setup backs a stale setup-dev.sh (clones php-live-reload, legacy admin/data/ dirs) — needs rewrite or removal
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
*
|
*
|
||||||
* HTMX fragment: returns the langues table for the contenus page,
|
* HTMX fragment: returns the langues table for the contenus page,
|
||||||
* optionally filtered by a search query.
|
* optionally filtered by a search query.
|
||||||
|
*
|
||||||
|
* Infinite scroll: rows are served in pages of PAGE_SIZE. When there are
|
||||||
|
* more rows, a sentinel <tr> is emitted with hx-trigger="revealed": when it
|
||||||
|
* scrolls into view htmx fetches the next offset and swaps itself
|
||||||
|
* (outerHTML) with the next chunk of rows + a new sentinel. This keeps the
|
||||||
|
* initial payload small instead of shipping all languages at once.
|
||||||
|
*
|
||||||
|
* When $offset > 0 the fragment returns only the <tr> rows (append mode),
|
||||||
|
* because the sentinel lives inside the existing <tbody>.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/../../bootstrap.php';
|
require_once __DIR__ . '/../../bootstrap.php';
|
||||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||||
@@ -15,14 +24,83 @@ if (empty($_SESSION['csrf_token'])) {
|
|||||||
|
|
||||||
require_once __DIR__ . '/../../src/Database.php';
|
require_once __DIR__ . '/../../src/Database.php';
|
||||||
|
|
||||||
|
const LANGUES_PAGE_SIZE = 25;
|
||||||
|
|
||||||
$searchQuery = trim($_GET['q'] ?? '');
|
$searchQuery = trim($_GET['q'] ?? '');
|
||||||
|
$offset = max(0, (int) ($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
$languages = ($searchQuery !== '') ? $db->searchLanguages($searchQuery) : $db->getAllLanguagesWithCount();
|
$languages = $db->getLanguagesPage($searchQuery, LANGUES_PAGE_SIZE, $offset);
|
||||||
|
$total = $db->countLanguagesWithCount($searchQuery);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
|
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$hasMore = ($offset + count($languages)) < $total;
|
||||||
|
$nextUrl = '/admin/contenus-langues-fragment.php?offset=' . ($offset + LANGUES_PAGE_SIZE)
|
||||||
|
. ($searchQuery !== '' ? '&q=' . urlencode($searchQuery) : '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one <tr> for a language row.
|
||||||
|
*/
|
||||||
|
function render_langue_row(array $lang): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td style="width:1%"><input type="checkbox" name="selected_langs[]" value="<?= (int)$lang['id'] ?>" onchange="languesUpdateBulk()"></td>
|
||||||
|
<td id="lang-name-<?= (int)$lang['id'] ?>" data-name="<?= htmlspecialchars($lang['name']) ?>">
|
||||||
|
<span class="tag-name-cell"><?= htmlspecialchars($lang['name']) ?></span>
|
||||||
|
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
|
||||||
|
onclick="languesStartRename(<?= (int)$lang['id'] ?>)">
|
||||||
|
<?= icon('pencil-note') ?>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$lang['thesis_count'] ?></td>
|
||||||
|
<td class="admin-actions-col" style="width:1%">
|
||||||
|
<div class="admin-actions">
|
||||||
|
<form method="post" action="actions/language.php" class="admin-inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="return" value="/admin/contenus.php">
|
||||||
|
<input type="hidden" name="language_id" value="<?= (int)$lang['id'] ?>">
|
||||||
|
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
||||||
|
onclick="languesConfirmDelete(this, <?= htmlspecialchars(json_encode($lang['name']), ENT_QUOTES) ?>)">
|
||||||
|
<?= icon('trash') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "load more" sentinel row (or nothing when exhausted).
|
||||||
|
*/
|
||||||
|
function render_langues_sentinel(bool $hasMore, string $nextUrl): void
|
||||||
|
{
|
||||||
|
if (!$hasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr class="admin-load-more" hx-get="<?= htmlspecialchars($nextUrl) ?>"
|
||||||
|
hx-trigger="intersect once root:#langues-table-wrap" hx-swap="outerHTML" hx-target="this">
|
||||||
|
<td colspan="4" class="admin-load-more__cell">Chargement…</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Append mode: only the rows, swapped in place of the sentinel ──────────
|
||||||
|
if ($offset > 0) {
|
||||||
|
foreach ($languages as $lang) {
|
||||||
|
render_langue_row($lang);
|
||||||
|
}
|
||||||
|
render_langues_sentinel($hasMore, $nextUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial load: full wrapper ────────────────────────────────────────────
|
||||||
?>
|
?>
|
||||||
<div id="langues-bulk-actions" class="admin-bulk-actions" style="display:none;position:sticky;top:0;z-index:10">
|
<div id="langues-bulk-actions" class="admin-bulk-actions" style="display:none;position:sticky;top:0;z-index:10">
|
||||||
<strong><span id="langues-selected-count">0</span> langue(s) sélectionnée(s)</strong>
|
<strong><span id="langues-selected-count">0</span> langue(s) sélectionnée(s)</strong>
|
||||||
@@ -68,32 +146,9 @@ try {
|
|||||||
<tr><td colspan="4" class="admin-empty">Aucune langue trouvée.</td></tr>
|
<tr><td colspan="4" class="admin-empty">Aucune langue trouvée.</td></tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($languages as $lang): ?>
|
<?php foreach ($languages as $lang): ?>
|
||||||
<tr>
|
<?php render_langue_row($lang); ?>
|
||||||
<td style="width:1%"><input type="checkbox" name="selected_langs[]" value="<?= (int)$lang['id'] ?>" onchange="languesUpdateBulk()"></td>
|
|
||||||
<td id="lang-name-<?= (int)$lang['id'] ?>" data-name="<?= htmlspecialchars($lang['name']) ?>">
|
|
||||||
<span class="tag-name-cell"><?= htmlspecialchars($lang['name']) ?></span>
|
|
||||||
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
|
|
||||||
onclick="languesStartRename(<?= (int)$lang['id'] ?>)">
|
|
||||||
<?= icon('pencil-note') ?>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$lang['thesis_count'] ?></td>
|
|
||||||
<td class="admin-actions-col" style="width:1%">
|
|
||||||
<div class="admin-actions">
|
|
||||||
<form method="post" action="actions/language.php" class="admin-inline-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<input type="hidden" name="return" value="/admin/contenus.php">
|
|
||||||
<input type="hidden" name="language_id" value="<?= (int)$lang['id'] ?>">
|
|
||||||
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
|
||||||
onclick="languesConfirmDelete(this, <?= htmlspecialchars(json_encode($lang['name']), ENT_QUOTES) ?>)">
|
|
||||||
<?= icon('trash') ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
<?php render_langues_sentinel($hasMore, $nextUrl); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
*
|
*
|
||||||
* HTMX fragment: returns the mots-clés table for the contenus page,
|
* HTMX fragment: returns the mots-clés table for the contenus page,
|
||||||
* optionally filtered by a search query.
|
* optionally filtered by a search query.
|
||||||
|
*
|
||||||
|
* Infinite scroll: rows are served in pages of PAGE_SIZE. When there are
|
||||||
|
* more rows, a sentinel <tr> is emitted with hx-trigger="revealed": when it
|
||||||
|
* scrolls into view htmx fetches the next offset and swaps itself
|
||||||
|
* (outerHTML) with the next chunk of rows + a new sentinel. This keeps the
|
||||||
|
* initial payload small instead of shipping all tags at once.
|
||||||
|
*
|
||||||
|
* When $offset > 0 the fragment returns only the <tr> rows (append mode),
|
||||||
|
* because the sentinel lives inside the existing <tbody>.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/../../bootstrap.php';
|
require_once __DIR__ . '/../../bootstrap.php';
|
||||||
require_once __DIR__ . '/../../src/AdminAuth.php';
|
require_once __DIR__ . '/../../src/AdminAuth.php';
|
||||||
@@ -15,14 +24,83 @@ if (empty($_SESSION['csrf_token'])) {
|
|||||||
|
|
||||||
require_once __DIR__ . '/../../src/Database.php';
|
require_once __DIR__ . '/../../src/Database.php';
|
||||||
|
|
||||||
|
const MOTSCLES_PAGE_SIZE = 25;
|
||||||
|
|
||||||
$searchQuery = trim($_GET['q'] ?? '');
|
$searchQuery = trim($_GET['q'] ?? '');
|
||||||
|
$offset = max(0, (int) ($_GET['offset'] ?? 0));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = new Database();
|
$db = new Database();
|
||||||
$tags = ($searchQuery !== '') ? $db->searchTags($searchQuery) : $db->getAllTagsWithCount();
|
$tags = $db->getTagsPage($searchQuery, MOTSCLES_PAGE_SIZE, $offset);
|
||||||
|
$total = $db->countTagsWithCount($searchQuery);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
|
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$hasMore = ($offset + count($tags)) < $total;
|
||||||
|
$nextUrl = '/admin/contenus-motscles-fragment.php?offset=' . ($offset + MOTSCLES_PAGE_SIZE)
|
||||||
|
. ($searchQuery !== '' ? '&q=' . urlencode($searchQuery) : '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one <tr> for a tag row.
|
||||||
|
*/
|
||||||
|
function render_motscles_row(array $tag): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td style="width:1%"><input type="checkbox" name="selected_tags[]" value="<?= (int)$tag['id'] ?>" onchange="motsclesUpdateBulk()"></td>
|
||||||
|
<td id="motscles-name-<?= (int)$tag['id'] ?>" data-name="<?= htmlspecialchars($tag['name']) ?>">
|
||||||
|
<span class="tag-name-cell"><?= htmlspecialchars($tag['name']) ?></span>
|
||||||
|
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
|
||||||
|
onclick="motsclesStartRename(<?= (int)$tag['id'] ?>)">
|
||||||
|
<?= icon('pencil-note') ?>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$tag['thesis_count'] ?></td>
|
||||||
|
<td class="admin-actions-col" style="width:1%">
|
||||||
|
<div class="admin-actions">
|
||||||
|
<form method="post" action="actions/tag.php" class="admin-inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="return" value="/admin/contenus.php">
|
||||||
|
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
|
||||||
|
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
||||||
|
onclick="motsclesConfirmDelete(this, <?= htmlspecialchars(json_encode($tag['name']), ENT_QUOTES) ?>)">
|
||||||
|
<?= icon('trash') ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "load more" sentinel row (or nothing when exhausted).
|
||||||
|
*/
|
||||||
|
function render_motscles_sentinel(bool $hasMore, string $nextUrl): void
|
||||||
|
{
|
||||||
|
if (!$hasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr class="admin-load-more" hx-get="<?= htmlspecialchars($nextUrl) ?>"
|
||||||
|
hx-trigger="intersect once root:#motscles-table-wrap" hx-swap="outerHTML" hx-target="this">
|
||||||
|
<td colspan="4" class="admin-load-more__cell">Chargement…</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Append mode: only the rows, swapped in place of the sentinel ──────────
|
||||||
|
if ($offset > 0) {
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
render_motscles_row($tag);
|
||||||
|
}
|
||||||
|
render_motscles_sentinel($hasMore, $nextUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial load: full wrapper ────────────────────────────────────────────
|
||||||
?>
|
?>
|
||||||
<div id="motscles-bulk-actions" class="admin-bulk-actions" style="display:none;position:sticky;top:0;z-index:10">
|
<div id="motscles-bulk-actions" class="admin-bulk-actions" style="display:none;position:sticky;top:0;z-index:10">
|
||||||
<strong><span id="motscles-selected-count">0</span> mot(s)-clé(s) sélectionné(s)</strong>
|
<strong><span id="motscles-selected-count">0</span> mot(s)-clé(s) sélectionné(s)</strong>
|
||||||
@@ -68,32 +146,9 @@ try {
|
|||||||
<tr><td colspan="4" class="admin-empty">Aucun mot-clé trouvé.</td></tr>
|
<tr><td colspan="4" class="admin-empty">Aucun mot-clé trouvé.</td></tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($tags as $tag): ?>
|
<?php foreach ($tags as $tag): ?>
|
||||||
<tr>
|
<?php render_motscles_row($tag); ?>
|
||||||
<td style="width:1%"><input type="checkbox" name="selected_tags[]" value="<?= (int)$tag['id'] ?>" onchange="motsclesUpdateBulk()"></td>
|
|
||||||
<td id="motscles-name-<?= (int)$tag['id'] ?>" data-name="<?= htmlspecialchars($tag['name']) ?>">
|
|
||||||
<span class="tag-name-cell"><?= htmlspecialchars($tag['name']) ?></span>
|
|
||||||
<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer"
|
|
||||||
onclick="motsclesStartRename(<?= (int)$tag['id'] ?>)">
|
|
||||||
<?= icon('pencil-note') ?>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="admin-tags-count" style="width:1%;white-space:nowrap"><?= (int)$tag['thesis_count'] ?></td>
|
|
||||||
<td class="admin-actions-col" style="width:1%">
|
|
||||||
<div class="admin-actions">
|
|
||||||
<form method="post" action="actions/tag.php" class="admin-inline-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<input type="hidden" name="return" value="/admin/contenus.php">
|
|
||||||
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
|
|
||||||
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
|
|
||||||
onclick="motsclesConfirmDelete(this, <?= htmlspecialchars(json_encode($tag['name']), ENT_QUOTES) ?>)">
|
|
||||||
<?= icon('trash') ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
<?php render_motscles_sentinel($hasMore, $nextUrl); ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -255,6 +255,22 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Load more" sentinel row for infinite-scroll admin tables (contenus).
|
||||||
|
Sits at the bottom of the <tbody>; htmx replaces it with the next page
|
||||||
|
of rows when it scrolls into the table's viewport. */
|
||||||
|
.admin-load-more__cell {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: var(--space-s) var(--space-2xs);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade the sentinel out once its request is in flight so it does not
|
||||||
|
visually "stick" while the next page is fetched. */
|
||||||
|
.admin-load-more.htmx-request .admin-load-more__cell {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Identifier column in the thesis table */
|
/* Identifier column in the thesis table */
|
||||||
.admin-table-id {
|
.admin-table-id {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
+117
-7
@@ -1390,11 +1390,41 @@ class Database
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all tags with a count of associated (published) theses.
|
* Paged tag listing used by the admin contenus "load more" fragment.
|
||||||
|
* Same ordering as getAllTagsWithCount(); $query filters by name prefix.
|
||||||
*/
|
*/
|
||||||
public function getAllTagsWithCount(): array
|
public function getTagsPage(string $query = '', int $limit = 100, int $offset = 0): array
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->query('
|
$query = trim($query);
|
||||||
|
$sql = '
|
||||||
|
SELECT tg.id, tg.name,
|
||||||
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
|
FROM tags tg
|
||||||
|
LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||||
|
LEFT JOIN theses t ON tt.thesis_id = t.id AND t.deleted_at IS NULL
|
||||||
|
WHERE tg.deleted_at IS NULL';
|
||||||
|
$params = [];
|
||||||
|
if ($query !== '') {
|
||||||
|
$sql .= ' AND tg.name LIKE ?';
|
||||||
|
$params[] = $query . '%';
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY tg.id ORDER BY tg.name COLLATE NOCASE LIMIT ? OFFSET ?';
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all tags with a count of associated (published) theses.
|
||||||
|
*
|
||||||
|
* @param int|null $limit Optional page size (for htmx infinite scroll).
|
||||||
|
* @param int $offset Row offset when $limit is set.
|
||||||
|
*/
|
||||||
|
public function getAllTagsWithCount(?int $limit = null, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$sql = '
|
||||||
SELECT tg.id, tg.name,
|
SELECT tg.id, tg.name,
|
||||||
COUNT(DISTINCT t.id) as thesis_count
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
FROM tags tg
|
FROM tags tg
|
||||||
@@ -1403,9 +1433,33 @@ class Database
|
|||||||
WHERE tg.deleted_at IS NULL
|
WHERE tg.deleted_at IS NULL
|
||||||
GROUP BY tg.id
|
GROUP BY tg.id
|
||||||
ORDER BY tg.name COLLATE NOCASE
|
ORDER BY tg.name COLLATE NOCASE
|
||||||
');
|
';
|
||||||
|
if ($limit !== null) {
|
||||||
|
$stmt = $this->pdo->prepare($sql . ' LIMIT ? OFFSET ?');
|
||||||
|
$stmt->execute([$limit, $offset]);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
return $this->pdo->query($sql)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count tags matching an optional name prefix (used to know when the
|
||||||
|
* infinite-scroll page has reached the end).
|
||||||
|
*/
|
||||||
|
public function countTagsWithCount(string $query = ''): int
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
if ($query === '') {
|
||||||
|
return (int) $this->pdo->query(
|
||||||
|
'SELECT COUNT(*) FROM tags WHERE deleted_at IS NULL'
|
||||||
|
)->fetchColumn();
|
||||||
|
}
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT COUNT(*) FROM tags WHERE name LIKE ? AND deleted_at IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([$query . '%']);
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a tag. Throws if the new name already exists.
|
* Rename a tag. Throws if the new name already exists.
|
||||||
@@ -1514,11 +1568,14 @@ class Database
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all languages with a count of associated theses.
|
* Return all languages with a count of associated theses.
|
||||||
|
*
|
||||||
|
* @param int|null $limit Optional page size (for htmx infinite scroll).
|
||||||
|
* @param int $offset Row offset when $limit is set.
|
||||||
*/
|
*/
|
||||||
public function getAllLanguagesWithCount(): array
|
public function getAllLanguagesWithCount(?int $limit = null, int $offset = 0): array
|
||||||
{
|
{
|
||||||
// Group by lowercased name to deduplicate, keeping the first id
|
// Group by lowercased name to deduplicate, keeping the first id
|
||||||
$stmt = $this->pdo->query('
|
$sql = '
|
||||||
SELECT MIN(l.id) as id,
|
SELECT MIN(l.id) as id,
|
||||||
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
||||||
COUNT(DISTINCT t.id) as thesis_count
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
@@ -1528,7 +1585,60 @@ class Database
|
|||||||
WHERE l.deleted_at IS NULL
|
WHERE l.deleted_at IS NULL
|
||||||
GROUP BY LOWER(l.name)
|
GROUP BY LOWER(l.name)
|
||||||
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE
|
||||||
');
|
';
|
||||||
|
if ($limit !== null) {
|
||||||
|
$stmt = $this->pdo->prepare($sql . ' LIMIT ? OFFSET ?');
|
||||||
|
$stmt->execute([$limit, $offset]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
return $this->pdo->query($sql)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count distinct languages matching an optional name prefix (used to know
|
||||||
|
* when the infinite-scroll page has reached the end).
|
||||||
|
*/
|
||||||
|
public function countLanguagesWithCount(string $query = ''): int
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
if ($query === '') {
|
||||||
|
return (int) $this->pdo->query(
|
||||||
|
'SELECT COUNT(DISTINCT LOWER(name)) FROM languages WHERE deleted_at IS NULL'
|
||||||
|
)->fetchColumn();
|
||||||
|
}
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT COUNT(DISTINCT LOWER(name)) FROM languages WHERE LOWER(name) LIKE LOWER(?) AND deleted_at IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([$query . '%']);
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paged language listing used by the admin contenus "load more" fragment.
|
||||||
|
* Same ordering/dedup as getAllLanguagesWithCount(); $query filters by
|
||||||
|
* name prefix.
|
||||||
|
*/
|
||||||
|
public function getLanguagesPage(string $query = '', int $limit = 100, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
$sql = '
|
||||||
|
SELECT MIN(l.id) as id,
|
||||||
|
UPPER(SUBSTR(MIN(l.name),1,1)) || SUBSTR(MIN(l.name),2) as name,
|
||||||
|
COUNT(DISTINCT t.id) as thesis_count
|
||||||
|
FROM languages l
|
||||||
|
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
|
||||||
|
LEFT JOIN theses t ON tl.thesis_id = t.id AND t.deleted_at IS NULL
|
||||||
|
WHERE l.deleted_at IS NULL';
|
||||||
|
$params = [];
|
||||||
|
if ($query !== '') {
|
||||||
|
$sql .= ' AND LOWER(l.name) LIKE LOWER(?)';
|
||||||
|
$params[] = $query . '%';
|
||||||
|
}
|
||||||
|
$sql .= ' GROUP BY LOWER(l.name) ORDER BY LOWER(MIN(l.name)) COLLATE NOCASE LIMIT ? OFFSET ?';
|
||||||
|
$params[] = $limit;
|
||||||
|
$params[] = $offset;
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PagedLanguagesTagsTest — covers the paged lookups that back the admin
|
||||||
|
* contenus page "load more" infinite scroll (getTagsPage / getLanguagesPage
|
||||||
|
* plus their count helpers and the optional limit/offset on the unpaged
|
||||||
|
* variants).
|
||||||
|
*/
|
||||||
|
class PagedLanguagesTagsTest extends TestCase
|
||||||
|
{
|
||||||
|
private Database $db;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
TestDatabase::resetData();
|
||||||
|
$this->db = TestDatabase::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Languages are seeded by the schema and not cleared by resetData(),
|
||||||
|
// so remove the test-specific rows we inserted.
|
||||||
|
TestDatabase::getPDO()
|
||||||
|
->exec("DELETE FROM languages WHERE name LIKE 'PagLang%'");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTags(array $names): void
|
||||||
|
{
|
||||||
|
$pdo = TestDatabase::getPDO();
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (?)');
|
||||||
|
foreach ($names as $n) {
|
||||||
|
$stmt->execute([$n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedLanguages(array $names): void
|
||||||
|
{
|
||||||
|
$pdo = TestDatabase::getPDO();
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO languages (name) VALUES (?)');
|
||||||
|
foreach ($names as $n) {
|
||||||
|
$stmt->execute([$n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tags ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testGetTagsPageRespectsLimitAndOffset(): void
|
||||||
|
{
|
||||||
|
$this->seedTags(['alpha', 'bravo', 'charlie', 'delta', 'echo']);
|
||||||
|
|
||||||
|
$page1 = $this->db->getTagsPage('', 2, 0);
|
||||||
|
$page2 = $this->db->getTagsPage('', 2, 2);
|
||||||
|
$page3 = $this->db->getTagsPage('', 2, 4);
|
||||||
|
|
||||||
|
$this->assertSame(['alpha', 'bravo'], array_column($page1, 'name'));
|
||||||
|
$this->assertSame(['charlie', 'delta'], array_column($page2, 'name'));
|
||||||
|
$this->assertSame(['echo'], array_column($page3, 'name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetTagsPageFiltersByPrefix(): void
|
||||||
|
{
|
||||||
|
$this->seedTags(['alpha', 'alpine', 'bravo']);
|
||||||
|
|
||||||
|
$rows = $this->db->getTagsPage('al', 100, 0);
|
||||||
|
|
||||||
|
$this->assertSame(['alpha', 'alpine'], array_column($rows, 'name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountTagsWithCount(): void
|
||||||
|
{
|
||||||
|
$this->seedTags(['alpha', 'alpine', 'bravo']);
|
||||||
|
|
||||||
|
$this->assertSame(3, $this->db->countTagsWithCount(''));
|
||||||
|
$this->assertSame(2, $this->db->countTagsWithCount('al'));
|
||||||
|
$this->assertSame(0, $this->db->countTagsWithCount('zzz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountTagsIgnoresSoftDeleted(): void
|
||||||
|
{
|
||||||
|
$this->seedTags(['alpha', 'bravo']);
|
||||||
|
$id = (int) TestDatabase::getPDO()
|
||||||
|
->query("SELECT id FROM tags WHERE name = 'bravo'")->fetchColumn();
|
||||||
|
$this->db->deleteTag($id);
|
||||||
|
|
||||||
|
$this->assertSame(1, $this->db->countTagsWithCount(''));
|
||||||
|
$this->assertSame(['alpha'], array_column($this->db->getTagsPage('', 100, 0), 'name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAllTagsWithCountOptionalPaging(): void
|
||||||
|
{
|
||||||
|
$this->seedTags(['alpha', 'bravo', 'charlie']);
|
||||||
|
|
||||||
|
$this->assertCount(3, $this->db->getAllTagsWithCount());
|
||||||
|
$this->assertSame(
|
||||||
|
['alpha', 'bravo'],
|
||||||
|
array_column($this->db->getAllTagsWithCount(2, 0), 'name')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
['charlie'],
|
||||||
|
array_column($this->db->getAllTagsWithCount(2, 2), 'name')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Languages ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The schema seeds a few languages, so language tests must not assume an
|
||||||
|
* empty table. Query a unique prefix ("PagLang") and account for the
|
||||||
|
* seeded rows in the unpaged assertions.
|
||||||
|
*/
|
||||||
|
public function testGetLanguagesPageRespectsLimitAndOffset(): void
|
||||||
|
{
|
||||||
|
$this->seedLanguages(['PagLang A', 'PagLang B', 'PagLang C', 'PagLang D']);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['PagLang A', 'PagLang B'],
|
||||||
|
array_column($this->db->getLanguagesPage('PagLang', 2, 0), 'name')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
['PagLang C', 'PagLang D'],
|
||||||
|
array_column($this->db->getLanguagesPage('PagLang', 2, 2), 'name')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLanguagesPageDeduplicatesCaseInsensitively(): void
|
||||||
|
{
|
||||||
|
$this->seedLanguages(['PagLangDup', 'paglanguedup']);
|
||||||
|
|
||||||
|
// Grouped by LOWER(name): two distinct lowercase names.
|
||||||
|
$names = array_column($this->db->getLanguagesPage('PagLang', 100, 0), 'name');
|
||||||
|
$lowered = array_map('strtolower', $names);
|
||||||
|
|
||||||
|
$this->assertSame(['paglangdup', 'paglanguedup'], $lowered);
|
||||||
|
$this->assertSame(2, $this->db->countLanguagesWithCount('PagLang'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetLanguagesPageFiltersCaseInsensitively(): void
|
||||||
|
{
|
||||||
|
$this->seedLanguages(['PagLangX', 'PagLangY']);
|
||||||
|
|
||||||
|
// Lowercase query must still match the capitalised names.
|
||||||
|
$rows = $this->db->getLanguagesPage('pagl', 100, 0);
|
||||||
|
|
||||||
|
$this->assertSame(['PagLangX', 'PagLangY'], array_column($rows, 'name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCountLanguagesWithCount(): void
|
||||||
|
{
|
||||||
|
$this->seedLanguages(['PagLangA', 'PagLangB', 'PagLangC']);
|
||||||
|
|
||||||
|
$this->assertSame(3, $this->db->countLanguagesWithCount('PagLang'));
|
||||||
|
$this->assertSame(1, $this->db->countLanguagesWithCount('PagLangA'));
|
||||||
|
$this->assertSame(0, $this->db->countLanguagesWithCount('zzz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetAllLanguagesWithCountOptionalPaging(): void
|
||||||
|
{
|
||||||
|
$this->seedLanguages(['PagLangA', 'PagLangB', 'PagLangC']);
|
||||||
|
|
||||||
|
$unpaged = array_map('strtolower', array_column($this->db->getAllLanguagesWithCount(), 'name'));
|
||||||
|
$this->assertContains('paglanga', $unpaged);
|
||||||
|
$this->assertContains('paglangb', $unpaged);
|
||||||
|
$this->assertContains('paglangc', $unpaged);
|
||||||
|
|
||||||
|
$page1 = array_column($this->db->getAllLanguagesWithCount(2, 0), 'name');
|
||||||
|
$page2 = array_column($this->db->getAllLanguagesWithCount(2, 2), 'name');
|
||||||
|
$this->assertCount(2, $page1);
|
||||||
|
$this->assertCount(2, $page2);
|
||||||
|
// Pages must not overlap.
|
||||||
|
$this->assertSame([], array_intersect($page1, $page2));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user