feat(versions): grouped + paginated changelog browser (no endless scroll)

Replace the flat 48-item changelog list with a series browser: releases are
grouped by major.minor (e.g. "0.9"), a rail (vertical from xl, horizontal pills
below) selects the series, and the active series is paginated (8/page) with the
newest release open by default. The installed series is marked; each row shows
its change categories as toned glance-dots.

Also fixes, surfaced by an adversarial review of the change:
- icon: add the missing 'chevron-right' Lucide path — the accordion disclosure
  caret and the pagination "Next" button were rendering an empty <svg>.
- blade: wire:ignore.self on each <details> so a user's open/closed toggle is not
  reset by an unrelated re-render (autoCheck / check-updates / in-update poll); a
  series/page change still re-defaults to first-open via a fresh wire:key.
- blade: 44px touch targets (series pills + pagination) below lg (R7).
- i18n: localised 'Other' series label; pluralised series_count (no "1 Releases").
- Index: render() is the single clamp authority for changelogPage.

Index.php: groupBySeries() + pageWindow() + selectSeries/gotoChangelogPage; render
builds the series list, resolves the active series, paginates. New DE+EN keys.
VersionsChangelogTest covers grouping order, default series, paging, clamp, the
installed-series cue, and the chevron-right icon regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation v0.9.31
boban 2026-06-20 21:47:45 +02:00
parent 05150bf89f
commit 552417fd4b
8 changed files with 461 additions and 70 deletions

View File

@ -13,6 +13,25 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.31] - 2026-06-20
### Geändert
- **Versions-Verlauf als Serien-Browser statt langer Liste.** Releases sind jetzt nach
Hauptversion (major.minor, z. B. „0.9") gruppiert: eine Serien-Navigation (ab großen Bildschirmen
eine seitliche Leiste, darunter horizontale Pills) wählt die Reihe, rechts erscheinen deren
Releases als Accordion (das neueste offen) mit **Seitenblätterung** — kein endloses Scrollen über
alle Versionen mehr. Die installierte Reihe ist markiert, jede Zeile zeigt die Änderungs-Kategorien
als farbige Punkte auf einen Blick.
### Behoben
- **Aufklapp-Pfeil & „Weiter"-Knopf sichtbar.** Das `chevron-right`-Icon fehlte im Icon-Satz, sodass
der Accordion-Pfeil jeder Zeile und der „Weiter"-Knopf der Seitenblätterung als leeres SVG
gerendert wurden (unsichtbare Bedienelemente). Icon ergänzt.
- **Aufgeklappter Eintrag bleibt offen.** Ein selbst aufgeklapptes Release wird nicht mehr durch
einen Hintergrund-Re-Render (Update-Prüfung) wieder zugeklappt (`wire:ignore.self`).
- **Touch-Zielgrößen.** Serien-Pills und Seiten-Knöpfe erfüllen auf kleinen Bildschirmen jetzt die
44-px-Touch-Mindestgröße.
## [0.9.30] - 2026-06-20 ## [0.9.30] - 2026-06-20
### Behoben ### Behoben

View File

@ -36,6 +36,15 @@ class Index extends Component
*/ */
private const UPDATE_FLAG = 'clusev:update-in-progress'; private const UPDATE_FLAG = 'clusev:update-in-progress';
/** Patch releases shown per page within a series — keeps the changelog from endless-scrolling. */
private const CHANGELOG_PER_PAGE = 8;
/** Selected major.minor changelog series ('' = auto: the newest). Drives the series rail. */
public string $series = '';
/** 1-based page within the selected series' release list. */
public int $changelogPage = 1;
public ?string $lastChecked = null; public ?string $lastChecked = null;
public ?string $updateStatus = null; public ?string $updateStatus = null;
@ -417,6 +426,103 @@ class Index extends Component
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch]; return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
} }
/** Switch the visible major.minor series and jump back to its first page. */
public function selectSeries(string $series): void
{
$this->series = $series;
$this->changelogPage = 1;
}
/** Jump to a page within the active series (lower bound clamped here; upper clamp in render). */
public function gotoChangelogPage(int $page): void
{
$this->changelogPage = max(1, $page);
}
/**
* Group parsed releases into major.minor series (e.g. "0.9"), newest series first. Each series
* keeps its releases (still newest-first) plus a count and the latest date. An Unreleased node,
* if present, becomes its own pseudo-series pinned to the top. This is what lets the changelog
* show a compact series index + paginated detail instead of one endless list.
*
* @param array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}> $releases
* @return array<int, array{key:string, label:string, unreleased:bool, count:int, latest:?string, releases:array<int, array<string, mixed>>}>
*/
private function groupBySeries(array $releases): array
{
$buckets = [];
foreach ($releases as $rel) {
if ($rel['unreleased']) {
$key = 'unreleased';
} elseif (preg_match('/^v?(\d+)\.(\d+)\./', $rel['version'], $m)) {
$key = $m[1].'.'.$m[2];
} else {
$key = 'other';
}
$buckets[$key][] = $rel;
}
$series = [];
foreach ($buckets as $key => $rels) {
$dates = array_values(array_filter(array_map(static fn (array $r): ?string => $r['date'], $rels)));
$series[] = [
'key' => $key,
'label' => match ($key) {
'unreleased' => __('versions.unreleased'),
'other' => __('versions.series_other'),
default => $key,
},
'unreleased' => $key === 'unreleased',
'count' => count($rels),
'latest' => $dates[0] ?? null, // releases are newest-first → the first date is the latest
'releases' => $rels,
];
}
// Unreleased first, then series by version descending, 'other' last.
usort($series, static function (array $a, array $b): int {
$rank = static fn (array $s): int => $s['key'] === 'unreleased' ? 0 : ($s['key'] === 'other' ? 2 : 1);
$ra = $rank($a);
$rb = $rank($b);
if ($ra !== $rb) {
return $ra <=> $rb;
}
return $ra === 1 ? version_compare($b['key'].'.0', $a['key'].'.0') : 0;
});
return $series;
}
/**
* Compact pagination window the full range up to 7 pages, otherwise first + last plus a
* 3-wide window around the current page with '…' gaps. Returns ints and '…' string markers.
*
* @return array<int, int|string>
*/
private function pageWindow(int $current, int $total): array
{
if ($total <= 7) {
return range(1, $total);
}
$pages = [1];
$from = max(2, $current - 1);
$to = min($total - 1, $current + 1);
if ($from > 2) {
$pages[] = '…';
}
for ($i = $from; $i <= $to; $i++) {
$pages[] = $i;
}
if ($to < $total - 1) {
$pages[] = '…';
}
$pages[] = $total;
return $pages;
}
/** /**
* Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its * Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its
* version, optional date, and the changes grouped by Keep-a-Changelog * version, optional date, and the changes grouped by Keep-a-Changelog
@ -492,12 +598,36 @@ class Index extends Component
public function render() public function render()
{ {
$releases = $this->releases(); $releases = $this->releases();
$seriesList = $this->groupBySeries($releases);
// Active series: keep the operator's choice while it still exists, else the newest.
$keys = array_column($seriesList, 'key');
$activeKey = in_array($this->series, $keys, true) ? $this->series : ($keys[0] ?? '');
$activeSeries = null;
foreach ($seriesList as $s) {
if ($s['key'] === $activeKey) {
$activeSeries = $s;
break;
}
}
// Paginate the active series' releases so a long history never becomes an endless scroll.
$activeReleases = $activeSeries['releases'] ?? [];
$totalPages = max(1, (int) ceil(count($activeReleases) / self::CHANGELOG_PER_PAGE));
$page = min(max(1, $this->changelogPage), $totalPages);
$this->changelogPage = $page; // make render the single clamp authority — no stale out-of-range value lingers
$pagedReleases = array_slice($activeReleases, ($page - 1) * self::CHANGELOG_PER_PAGE, self::CHANGELOG_PER_PAGE);
// Mark the series that holds the running version (a small "installed" cue in the rail).
$installed = ltrim((string) config('clusev.version'), 'vV');
$currentSeries = preg_match('/^(\d+)\.(\d+)\./', $installed, $m) ? $m[1].'.'.$m[2] : null;
// Passive (no network): cached remote result if a check has run, else local .git. // Passive (no network): cached remote result if a check has run, else local .git.
$latestTag = $this->resolveLatestTag($this->channel()); $latestTag = $this->resolveLatestTag($this->channel());
// A stale cache can hold a "latest" older than what's now installed (e.g. right after an // A stale cache can hold a "latest" older than what's now installed (e.g. right after an
// update, before the next check). Never present yourself as behind a release you've // update, before the next check). Never present yourself as behind a release you've
// already passed — hide it until a fresh check resolves the true latest. // already passed — hide it until a fresh check resolves the true latest.
if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), ltrim((string) config('clusev.version'), 'vV'), '<')) { if ($latestTag !== null && version_compare(ltrim($latestTag, 'vV'), $installed, '<')) {
$latestTag = null; $latestTag = null;
} }
@ -507,7 +637,15 @@ class Index extends Component
'repository' => config('clusev.repository'), 'repository' => config('clusev.repository'),
'license' => config('clusev.license'), 'license' => config('clusev.license'),
'build' => $this->build(), 'build' => $this->build(),
'releases' => $releases, 'seriesList' => $seriesList,
'activeKey' => $activeKey,
'activeSeries' => $activeSeries,
'pagedReleases' => $pagedReleases,
'changelogPage' => $page,
'totalPages' => $totalPages,
'pageWindow' => $this->pageWindow($page, $totalPages),
'currentSeries' => $currentSeries,
'totalReleases' => count($releases),
'latestTag' => $latestTag, 'latestTag' => $latestTag,
])->title(__('versions.title')); ])->title(__('versions.title'));
} }

View File

@ -2,7 +2,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). // First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.30', 'version' => '0.9.31',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in // image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -25,6 +25,16 @@ return [
'in_progress' => 'in Arbeit', 'in_progress' => 'in Arbeit',
'changelog_empty' => 'Kein Änderungsprotokoll gefunden.', 'changelog_empty' => 'Kein Änderungsprotokoll gefunden.',
// Series index + pagination (Änderungsprotokoll nach major.minor-Reihen gruppiert)
'series_nav' => 'Versionsreihen',
'series_label' => 'Serie :series',
'series_count' => '{1} :count Release|[2,*] :count Releases',
'series_other' => 'Sonstige',
'series_installed' => 'Installierte Reihe',
'pagination_nav' => 'Seiten',
'pagination_prev' => 'Zurück',
'pagination_next' => 'Weiter',
// Changelog section labels (Keep a Changelog subsections) // Changelog section labels (Keep a Changelog subsections)
'section_added' => 'Hinzugefügt', 'section_added' => 'Hinzugefügt',
'section_changed' => 'Geändert', 'section_changed' => 'Geändert',

View File

@ -25,6 +25,16 @@ return [
'in_progress' => 'in progress', 'in_progress' => 'in progress',
'changelog_empty' => 'No changelog found.', 'changelog_empty' => 'No changelog found.',
// Series index + pagination (changelog grouped by major.minor series)
'series_nav' => 'Version series',
'series_label' => 'Series :series',
'series_count' => '{1} :count release|[2,*] :count releases',
'series_other' => 'Other',
'series_installed' => 'Installed series',
'pagination_nav' => 'Pages',
'pagination_prev' => 'Previous',
'pagination_next' => 'Next',
// Changelog section labels (Keep a Changelog subsections) // Changelog section labels (Keep a Changelog subsections)
'section_added' => 'Added', 'section_added' => 'Added',
'section_changed' => 'Changed', 'section_changed' => 'Changed',

View File

@ -4,6 +4,7 @@
$paths = [ $paths = [
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>', 'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
'chevron-left' => '<path d="m15 18-6-6 6-6"/>', 'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
'chevron-right' => '<path d="m9 18 6-6-6-6"/>',
'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>', 'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'help-circle' => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>', 'help-circle' => '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
'lock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 'lock' => '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',

View File

@ -15,6 +15,8 @@
]; ];
$sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g; $sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g;
$tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral'; $tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral';
// Toned glance-dots on each collapsed release row (static classes so Tailwind's scanner sees them).
$dotClass = ['accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'neutral' => 'bg-ink-4'];
$repoHost = parse_url($repository, PHP_URL_HOST); $repoHost = parse_url($repository, PHP_URL_HOST);
$repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/'); $repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/');
@ -92,80 +94,140 @@
</div> </div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
{{-- Release history BY VERSION --}} {{-- Release history grouped into major.minor series, paginated (no endless scroll) --}}
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => count($releases)])" :padded="false"> <x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => $totalReleases])" :padded="false">
<div class="divide-y divide-line px-4 py-2 sm:px-5"> @if (empty($seriesList))
@forelse ($releases as $rel) <p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('versions.changelog_empty') }}</p>
@if ($loop->first) @else
{{-- Latest version: always open, not collapsible --}} <div class="grid grid-cols-1 xl:grid-cols-[172px_minmax(0,1fr)]">
<div class="py-4"> {{-- Series index: horizontal pills on small/medium screens, a vertical rail from xl --}}
<div class="mb-2.5 flex flex-wrap items-center gap-2"> <nav aria-label="{{ __('versions.series_nav') }}"
@if ($rel['unreleased']) class="flex gap-2 overflow-x-auto border-b border-line p-3 xl:flex-col xl:gap-1 xl:overflow-visible xl:border-b-0 xl:border-r xl:p-2.5">
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span> @foreach ($seriesList as $s)
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge> <button type="button" wire:key="series-{{ $s['key'] }}" wire:click="selectSeries('{{ $s['key'] }}')"
@else @class([
<span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span> 'group flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors xl:min-h-0 xl:shrink',
@if ($rel['date']) 'border-accent/30 bg-accent/10' => $s['key'] === $activeKey,
<span class="font-mono text-[11px] text-ink-4">{{ $rel['date'] }}</span> 'border-transparent hover:border-line hover:bg-raised/60' => $s['key'] !== $activeKey,
])
@if ($s['key'] === $activeKey) aria-current="true" @endif>
@if ($s['key'] === $currentSeries)
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-online" title="{{ __('versions.series_installed') }}"></span>
@endif
<span @class([
'font-mono text-sm font-semibold',
'text-accent-text' => $s['key'] === $activeKey,
'text-ink-2 group-hover:text-ink' => $s['key'] !== $activeKey,
])>{{ $s['label'] }}</span>
<span @class([
'ml-auto rounded-full px-1.5 py-px font-mono text-[10px] tabular-nums',
'bg-accent/15 text-accent-text' => $s['key'] === $activeKey,
'bg-raised text-ink-4' => $s['key'] !== $activeKey,
])>{{ $s['count'] }}</span>
</button>
@endforeach
</nav>
{{-- Releases for the active series --}}
<div class="min-w-0">
@if ($activeSeries)
{{-- Series sub-header: which series, how many releases, latest date --}}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line px-4 py-3 sm:px-5">
<span class="font-display text-sm font-semibold text-ink">
@if ($activeSeries['unreleased'] || $activeSeries['key'] === 'other')
{{ $activeSeries['label'] }}
@else
{{ __('versions.series_label', ['series' => $activeSeries['label']]) }}
@endif @endif
</span>
<span class="font-mono text-[11px] text-ink-4">{{ trans_choice('versions.series_count', $activeSeries['count'], ['count' => $activeSeries['count']]) }}</span>
@if ($activeSeries['latest'])
<span class="ml-auto font-mono text-[11px] text-ink-4">{{ $activeSeries['latest'] }}</span>
@endif @endif
</div> </div>
<div class="space-y-3">
@foreach ($rel['groups'] as $group => $items) {{-- Accordion: the first release of each page is open, the rest collapse --}}
<div> <div class="divide-y divide-line px-4 sm:px-5">
<div class="mb-1.5 flex items-center gap-2"> @foreach ($pagedReleases as $rel)
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge> {{-- wire:ignore.self keeps a user's open/closed toggle from being reset when an
</div> unrelated re-render (autoCheck / check-updates / the in-update poll) morphs the
<ul class="space-y-1.5 pl-0.5"> page; a series/page change uses a fresh wire:key, so it re-defaults to first-open. --}}
@foreach ($items as $text) <details wire:key="rel-{{ $rel['version'] }}" wire:ignore.self class="group" @if ($loop->first) open @endif>
<li class="flex items-start gap-2.5"> <summary class="flex cursor-pointer list-none items-center gap-2.5 py-3.5 [&::-webkit-details-marker]:hidden">
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-ink-4"></span> <x-icon name="chevron-right" class="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-200 [details[open]_&]:rotate-90" />
<span class="text-sm leading-relaxed text-ink-2">{{ $text }}</span> @if ($rel['unreleased'])
</li> <span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span>
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge>
@else
<span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span>
@if ($rel['date'])
<span class="font-mono text-[11px] text-ink-4">{{ $rel['date'] }}</span>
@endif
@endif
{{-- Toned glance-dots: which change categories this release has --}}
<span class="ml-auto flex items-center gap-1" aria-hidden="true">
@foreach (array_unique(array_map($tone, array_keys($rel['groups']))) as $t)
<span class="h-1.5 w-1.5 rounded-full {{ $dotClass[$t] ?? 'bg-ink-4' }}"></span>
@endforeach
</span>
</summary>
<div class="space-y-3 pb-4 pl-[26px]">
@foreach ($rel['groups'] as $group => $items)
<div>
<div class="mb-1.5 flex items-center gap-2">
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge>
</div>
<ul class="space-y-1.5 pl-0.5">
@foreach ($items as $text)
<li class="flex items-start gap-2.5">
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-ink-4"></span>
<span class="text-sm leading-relaxed text-ink-2">{{ $text }}</span>
</li>
@endforeach
</ul>
</div>
@endforeach @endforeach
</ul> </div>
</div> </details>
@endforeach @endforeach
</div> </div>
</div>
@else {{-- Pagination within the active series --}}
{{-- Older versions: collapsible <details> accordion --}} @if ($totalPages > 1)
<details class="group py-1"> <nav aria-label="{{ __('versions.pagination_nav') }}" class="flex items-center justify-between gap-3 border-t border-line px-4 py-3 sm:px-5">
<summary class="flex cursor-pointer list-none items-center gap-2 py-3 [&::-webkit-details-marker]:hidden"> <button type="button" wire:click="gotoChangelogPage({{ $changelogPage - 1 }})" @disabled($changelogPage <= 1)
<x-icon name="chevron-right" class="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-200 [details[open]_&]:rotate-90" /> class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
@if ($rel['unreleased']) <x-icon name="chevron-left" class="h-3.5 w-3.5" />
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span> <span class="hidden sm:inline">{{ __('versions.pagination_prev') }}</span>
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge> </button>
@else
<span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span> <div class="flex items-center gap-1">
@if ($rel['date']) @foreach ($pageWindow as $p)
<span class="font-mono text-[11px] text-ink-4">{{ $rel['date'] }}</span> @if ($p === '…')
@endif <span class="px-1 font-mono text-[11px] text-ink-4" aria-hidden="true"></span>
@endif @else
</summary> <button type="button" wire:key="page-{{ $p }}" wire:click="gotoChangelogPage({{ $p }})"
<div class="pb-4 pt-1 space-y-3"> @class([
@foreach ($rel['groups'] as $group => $items) 'grid h-11 min-w-11 place-items-center rounded-md border px-2 font-mono text-[11px] tabular-nums transition-colors lg:h-7 lg:min-w-[28px]',
<div> 'border-accent/40 bg-accent/15 text-accent-text' => $p === $changelogPage,
<div class="mb-1.5 flex items-center gap-2"> 'border-line bg-raised text-ink-2 hover:border-accent/30 hover:text-ink' => $p !== $changelogPage,
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge> ])
</div> @if ($p === $changelogPage) aria-current="page" @endif>{{ $p }}</button>
<ul class="space-y-1.5 pl-0.5"> @endif
@foreach ($items as $text) @endforeach
<li class="flex items-start gap-2.5">
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-ink-4"></span>
<span class="text-sm leading-relaxed text-ink-2">{{ $text }}</span>
</li>
@endforeach
</ul>
</div> </div>
@endforeach
</div> <button type="button" wire:click="gotoChangelogPage({{ $changelogPage + 1 }})" @disabled($changelogPage >= $totalPages)
</details> class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
@endif <span class="hidden sm:inline">{{ __('versions.pagination_next') }}</span>
@empty <x-icon name="chevron-right" class="h-3.5 w-3.5" />
<p class="py-4 font-mono text-[11px] text-ink-3">{{ __('versions.changelog_empty') }}</p> </button>
@endforelse </nav>
</div> @endif
@endif
</div>
</div>
@endif
</x-panel> </x-panel>
{{-- Aside --}} {{-- Aside --}}

View File

@ -0,0 +1,151 @@
<?php
namespace Tests\Feature;
use App\Livewire\Versions\Index;
use App\Models\User;
use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Tests\TestCase;
/**
* The changelog browser: releases are grouped into major.minor series (a left rail / pill row)
* and the active series is paginated so a long history never becomes an endless scroll. These
* tests drive the real CHANGELOG.md, asserting structural invariants (ordering, paging, defaults)
* rather than specific version numbers, so they survive new releases.
*/
class VersionsChangelogTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
app(DeploymentService::class)->clearUpdateRequest();
$this->actingAs(User::factory()->create(['must_change_password' => false]));
}
protected function tearDown(): void
{
app(DeploymentService::class)->clearUpdateRequest();
parent::tearDown();
}
public function test_releases_are_grouped_into_series_ordered_newest_first(): void
{
Livewire::test(Index::class)->assertViewHas('seriesList', function ($series): bool {
if (! is_array($series) || count($series) < 2) {
return false;
}
// Newest series first: each key sorts >= the next (compare on "X.Y.0").
for ($i = 0; $i < count($series) - 1; $i++) {
if (version_compare($series[$i]['key'].'.0', $series[$i + 1]['key'].'.0', '<')) {
return false;
}
}
return true;
});
}
public function test_default_active_series_is_the_newest_one(): void
{
$firstKey = null;
$component = Livewire::test(Index::class)
->assertViewHas('seriesList', function ($series) use (&$firstKey): bool {
$firstKey = $series[0]['key'] ?? null;
return true;
});
$component->assertViewHas('activeKey', fn ($key): bool => $key === $firstKey);
}
public function test_an_unknown_selected_series_falls_back_to_the_newest(): void
{
Livewire::test(Index::class)
->call('selectSeries', '99.99')
->assertViewHas('activeKey', fn ($key): bool => $key !== '99.99' && $key !== '');
}
public function test_selecting_a_series_switches_the_visible_releases_and_resets_the_page(): void
{
// Default view shows the newest series, so the very first release (v0.1.0) is NOT visible…
Livewire::test(Index::class)->assertDontSee('v0.1.0');
// …selecting the 0.1 series brings it into view and jumps back to page 1.
Livewire::test(Index::class)
->set('changelogPage', 3)
->call('selectSeries', '0.1')
->assertSet('series', '0.1')
->assertSet('changelogPage', 1)
->assertSee('v0.1.0');
}
public function test_a_long_series_is_paginated_to_the_per_page_limit(): void
{
Livewire::test(Index::class)
->call('selectSeries', '0.9')
->assertViewHas('totalPages', fn ($total): bool => $total > 1)
->assertViewHas('pagedReleases', fn ($releases): bool => is_array($releases) && count($releases) <= 8);
}
public function test_paging_shows_a_different_slice_of_the_series(): void
{
$pageOne = null;
Livewire::test(Index::class)
->call('selectSeries', '0.9')
->assertViewHas('pagedReleases', function ($releases) use (&$pageOne): bool {
$pageOne = array_column($releases, 'version');
return true;
});
Livewire::test(Index::class)
->call('selectSeries', '0.9')
->call('gotoChangelogPage', 2)
->assertViewHas('pagedReleases', function ($releases) use (&$pageOne): bool {
$pageTwo = array_column($releases, 'version');
// Page 2 is non-empty and disjoint from page 1.
return $pageTwo !== [] && array_intersect($pageTwo, $pageOne) === [];
});
}
public function test_an_out_of_range_page_is_clamped(): void
{
Livewire::test(Index::class)
->call('selectSeries', '0.9')
->call('gotoChangelogPage', 9999)
->assertViewHas('changelogPage', fn ($page): bool => $page >= 1)
->assertViewHas('pagedReleases', fn ($releases): bool => is_array($releases) && count($releases) >= 1)
->assertSee(__('versions.changelog_title'));
}
public function test_the_installed_series_is_resolved_from_the_running_version(): void
{
config()->set('clusev.version', '0.7.3');
Livewire::test(Index::class)->assertViewHas('currentSeries', '0.7');
}
public function test_total_release_count_is_passed_for_the_panel_subtitle(): void
{
Livewire::test(Index::class)->assertViewHas('totalReleases', fn ($count): bool => is_int($count) && $count >= 1);
}
public function test_chevron_right_icon_renders_a_real_path(): void
{
// The accordion disclosure caret and the pagination "Next" button both use x-icon
// name="chevron-right"; a missing map entry renders an empty <svg> (invisible affordance).
$html = Blade::render('<x-icon name="chevron-right" />');
$this->assertStringContainsString('<path', $html, 'chevron-right must resolve to a real Lucide path, not an empty svg');
}
}