Merge pull request #575 from the-luap/feat/clickable-version-links-566

feat(admin): clickable version links + update-available modal with changelog & upgrade command
This commit is contained in:
Paul Nothaft
2026-05-29 11:35:06 +02:00
committed by GitHub
9 changed files with 769 additions and 33 deletions
@@ -0,0 +1,30 @@
/**
* Regression coverage for the version → release-page URL template (#566).
*
* release-please tags every release as `vX.Y.Z[-beta.N]`. If anyone
* later refactors the helper (e.g. switches the prefix, the repo path,
* or strips the channel suffix), these assertions will fail loud
* instead of silently producing dead links in the admin sidebar.
*/
import { describe, expect, it } from 'vitest';
import { githubReleaseUrl } from '../githubReleaseUrl';
describe('githubReleaseUrl', () => {
it('maps a stable version to its tag page', () => {
expect(githubReleaseUrl('3.55.0')).toBe(
'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
);
});
it('maps a beta version (with channel suffix) to its tag page', () => {
expect(githubReleaseUrl('3.55.0-beta.0')).toBe(
'https://github.com/the-luap/picpeak/releases/tag/v3.55.0-beta.0',
);
});
it('does not double-prefix the v when given a bare version', () => {
// Defensive — the helper is the single source of truth for the
// leading "v". Callers must pass the bare version string.
expect(githubReleaseUrl('3.55.0')).not.toContain('vv');
});
});
@@ -0,0 +1,76 @@
/**
* Coverage for the per-version "Update available" chip dismissal (#567).
*
* The chip should reappear only when a newer version than the
* dismissed one is published. These tests pin the comparison rules
* (mirroring the backend's compareVersions in updateCheckService.js
* — stable > beta, higher beta > lower beta, semantic numeric compare
* on major.minor.patch) so a future refactor can't silently break the
* "I dismissed v3.55.0 but should still see v3.55.1" flow.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
isNewerVersion,
setDismissedVersion,
shouldShowUpdateChip,
} from '../updateDismissal';
describe('isNewerVersion', () => {
it('compares major.minor.patch semantically (not lexically)', () => {
expect(isNewerVersion('3.10.0', '3.9.0')).toBe(true);
expect(isNewerVersion('3.9.0', '3.10.0')).toBe(false);
expect(isNewerVersion('10.0.0', '9.99.99')).toBe(true);
});
it('treats stable as newer than the same-numbered beta', () => {
expect(isNewerVersion('3.55.0', '3.55.0-beta.0')).toBe(true);
expect(isNewerVersion('3.55.0-beta.0', '3.55.0')).toBe(false);
});
it('compares beta numbers when both are betas of the same base version', () => {
expect(isNewerVersion('3.55.0-beta.2', '3.55.0-beta.1')).toBe(true);
expect(isNewerVersion('3.55.0-beta.1', '3.55.0-beta.2')).toBe(false);
});
it('returns false when versions are equal', () => {
expect(isNewerVersion('3.55.0', '3.55.0')).toBe(false);
expect(isNewerVersion('3.55.0-beta.0', '3.55.0-beta.0')).toBe(false);
});
it('returns false for unparseable input rather than throwing', () => {
expect(isNewerVersion('nonsense', '3.55.0')).toBe(false);
expect(isNewerVersion('3.55.0', '')).toBe(false);
});
});
describe('shouldShowUpdateChip', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('shows the chip when nothing has been dismissed', () => {
expect(shouldShowUpdateChip('3.55.0')).toBe(true);
});
it('hides the chip when the same version has been dismissed', () => {
setDismissedVersion('3.55.0');
expect(shouldShowUpdateChip('3.55.0')).toBe(false);
});
it('shows the chip again when a newer version appears after dismissal', () => {
setDismissedVersion('3.55.0');
expect(shouldShowUpdateChip('3.55.1')).toBe(true);
expect(shouldShowUpdateChip('3.56.0')).toBe(true);
});
it('keeps the chip hidden when an OLDER version somehow becomes the latest', () => {
// Defensive — shouldn't happen in practice (release-please never
// republishes older tags) but a regression here would re-pester
// an admin who's deliberately on a newer version.
setDismissedVersion('3.55.0');
expect(shouldShowUpdateChip('3.54.0')).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Build the GitHub release page URL for a given version string.
*
* Version strings already carry their channel suffix (e.g. `3.55.0`
* for stable, `3.55.0-beta.0` for beta). release-please tags every
* release as `vX.Y.Z[-beta.N]`, so a pure string template covers
* both channels without branching on the suffix.
*
* Used by the admin sidebar version display to deep-link to release
* notes for the running version (#566) and by the update-available
* indicator to link to the upgrade target's notes.
*/
export const githubReleaseUrl = (version: string): string =>
`https://github.com/the-luap/picpeak/releases/tag/v${version}`;
+78
View File
@@ -0,0 +1,78 @@
/**
* Per-version dismissal of the "Update available" chip (#567).
*
* Admins who deliberately stay on the current version can dismiss the
* chip; it reappears only when an even newer version is published. The
* dismissal lives in localStorage keyed by storage version of this
* helper, so a future schema change can invalidate old dismissals
* without touching individual admin's storage manually.
*
* Single key holds the last-dismissed version string. If the latest
* version > dismissed version → show the chip. Otherwise hide.
*
* Uses a string-based comparison helper so we don't pull in semver
* just for one greater-than check — see `isNewerVersion` below for
* the same algorithm the backend's updateCheckService uses.
*/
const STORAGE_KEY = 'picpeak.updateDismissedVersion.v1';
interface ParsedVersion {
major: number;
minor: number;
patch: number;
beta: number | null;
}
const parseVersion = (version: string): ParsedVersion | null => {
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
beta: match[4] ? parseInt(match[4], 10) : null,
};
};
/** Returns true when `a` is strictly newer than `b`. Mirrors backend semantics. */
export const isNewerVersion = (a: string, b: string): boolean => {
const va = parseVersion(a);
const vb = parseVersion(b);
if (!va || !vb) return false;
if (va.major !== vb.major) return va.major > vb.major;
if (va.minor !== vb.minor) return va.minor > vb.minor;
if (va.patch !== vb.patch) return va.patch > vb.patch;
// Same major.minor.patch — stable > beta, higher beta > lower beta
if (va.beta === null && vb.beta !== null) return true;
if (va.beta !== null && vb.beta === null) return false;
if (va.beta !== null && vb.beta !== null) return va.beta > vb.beta;
return false;
};
export const getDismissedVersion = (): string | null => {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
};
export const setDismissedVersion = (version: string): void => {
try {
localStorage.setItem(STORAGE_KEY, version);
} catch {
// localStorage unavailable (private mode, quota exceeded) — silently
// skip. The chip will keep reappearing, which is harmless.
}
};
/**
* Should the chip be shown for this latest version? True when the user
* has never dismissed (or dismissed an older version than latest).
*/
export const shouldShowUpdateChip = (latestVersion: string): boolean => {
const dismissed = getDismissedVersion();
if (!dismissed) return true;
return isNewerVersion(latestVersion, dismissed);
};