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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user