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
parent
05150bf89f
commit
552417fd4b
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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._
|
||||
|
||||
## [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
|
||||
|
||||
### Behoben
|
||||
|
|
|
|||
|
|
@ -36,6 +36,15 @@ class Index extends Component
|
|||
*/
|
||||
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 $updateStatus = null;
|
||||
|
|
@ -417,6 +426,103 @@ class Index extends Component
|
|||
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
|
||||
* version, optional date, and the changes grouped by Keep-a-Changelog
|
||||
|
|
@ -492,12 +598,36 @@ class Index extends Component
|
|||
public function render()
|
||||
{
|
||||
$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.
|
||||
$latestTag = $this->resolveLatestTag($this->channel());
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -507,7 +637,15 @@ class Index extends Component
|
|||
'repository' => config('clusev.repository'),
|
||||
'license' => config('clusev.license'),
|
||||
'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,
|
||||
])->title(__('versions.title'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
// 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
|
||||
// image ships no .git); the Versions page prefers them and falls back to a live .git read in
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ return [
|
|||
'in_progress' => 'in Arbeit',
|
||||
'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)
|
||||
'section_added' => 'Hinzugefügt',
|
||||
'section_changed' => 'Geändert',
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ return [
|
|||
'in_progress' => 'in progress',
|
||||
'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)
|
||||
'section_added' => 'Added',
|
||||
'section_changed' => 'Changed',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
$paths = [
|
||||
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
|
||||
'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"/>',
|
||||
'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"/>',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
];
|
||||
$sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g;
|
||||
$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);
|
||||
$repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/');
|
||||
|
|
@ -92,46 +94,66 @@
|
|||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||
{{-- Release history BY VERSION --}}
|
||||
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => count($releases)])" :padded="false">
|
||||
<div class="divide-y divide-line px-4 py-2 sm:px-5">
|
||||
@forelse ($releases as $rel)
|
||||
@if ($loop->first)
|
||||
{{-- Latest version: always open, not collapsible --}}
|
||||
<div class="py-4">
|
||||
<div class="mb-2.5 flex flex-wrap items-center gap-2">
|
||||
@if ($rel['unreleased'])
|
||||
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span>
|
||||
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge>
|
||||
{{-- Release history — grouped into major.minor series, paginated (no endless scroll) --}}
|
||||
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => $totalReleases])" :padded="false">
|
||||
@if (empty($seriesList))
|
||||
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('versions.changelog_empty') }}</p>
|
||||
@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>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-[172px_minmax(0,1fr)]">
|
||||
{{-- Series index: horizontal pills on small/medium screens, a vertical rail from xl --}}
|
||||
<nav aria-label="{{ __('versions.series_nav') }}"
|
||||
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">
|
||||
@foreach ($seriesList as $s)
|
||||
<button type="button" wire:key="series-{{ $s['key'] }}" wire:click="selectSeries('{{ $s['key'] }}')"
|
||||
@class([
|
||||
'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',
|
||||
'border-accent/30 bg-accent/10' => $s['key'] === $activeKey,
|
||||
'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
|
||||
</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
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@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
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
{{-- Older versions: collapsible <details> accordion --}}
|
||||
<details class="group py-1">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2 py-3 [&::-webkit-details-marker]:hidden">
|
||||
|
||||
{{-- Accordion: the first release of each page is open, the rest collapse --}}
|
||||
<div class="divide-y divide-line px-4 sm:px-5">
|
||||
@foreach ($pagedReleases as $rel)
|
||||
{{-- wire:ignore.self keeps a user's open/closed toggle from being reset when an
|
||||
unrelated re-render (autoCheck / check-updates / the in-update poll) morphs the
|
||||
page; a series/page change uses a fresh wire:key, so it re-defaults to first-open. --}}
|
||||
<details wire:key="rel-{{ $rel['version'] }}" wire:ignore.self class="group" @if ($loop->first) open @endif>
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2.5 py-3.5 [&::-webkit-details-marker]:hidden">
|
||||
<x-icon name="chevron-right" class="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-200 [details[open]_&]:rotate-90" />
|
||||
@if ($rel['unreleased'])
|
||||
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span>
|
||||
|
|
@ -142,8 +164,14 @@
|
|||
<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="pb-4 pt-1 space-y-3">
|
||||
<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">
|
||||
|
|
@ -161,11 +189,45 @@
|
|||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@empty
|
||||
<p class="py-4 font-mono text-[11px] text-ink-3">{{ __('versions.changelog_empty') }}</p>
|
||||
@endforelse
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination within the active series --}}
|
||||
@if ($totalPages > 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">
|
||||
<button type="button" wire:click="gotoChangelogPage({{ $changelogPage - 1 }})" @disabled($changelogPage <= 1)
|
||||
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">
|
||||
<x-icon name="chevron-left" class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{{ __('versions.pagination_prev') }}</span>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
@foreach ($pageWindow as $p)
|
||||
@if ($p === '…')
|
||||
<span class="px-1 font-mono text-[11px] text-ink-4" aria-hidden="true">…</span>
|
||||
@else
|
||||
<button type="button" wire:key="page-{{ $p }}" wire:click="gotoChangelogPage({{ $p }})"
|
||||
@class([
|
||||
'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]',
|
||||
'border-accent/40 bg-accent/15 text-accent-text' => $p === $changelogPage,
|
||||
'border-line bg-raised text-ink-2 hover:border-accent/30 hover:text-ink' => $p !== $changelogPage,
|
||||
])
|
||||
@if ($p === $changelogPage) aria-current="page" @endif>{{ $p }}</button>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<button type="button" wire:click="gotoChangelogPage({{ $changelogPage + 1 }})" @disabled($changelogPage >= $totalPages)
|
||||
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">
|
||||
<span class="hidden sm:inline">{{ __('versions.pagination_next') }}</span>
|
||||
<x-icon name="chevron-right" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</nav>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
|
||||
{{-- Aside --}}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue