feat(admin): update-available modal with aggregated changelog + upgrade command (#567)
Closes #567. The sidebar already had a "vX.Y.Z available" indicator (#566 made it a link to that release's page) but there was no way to read the actual changelog inline or to grab a copy-paste upgrade command. This adds the modal the issue spec'd, layered on top of the existing updateCheckService / environmentService backend infrastructure that already shipped. ## Backend - `updateCheckService.fetchAvailableVersions` now returns full release objects (tag, name, body, publishedAt, htmlUrl) instead of just version strings — body data is what the changelog modal renders. `checkForUpdates` extracts the version strings for its existing consumers; no API change visible to callers. - New `getReleasesSince(currentVersion, channel)` returns the list of releases strictly newer than current, filtered to the user's channel. Reuses the same 1-hour cache as `checkForUpdates` so the modal opening doesn't trigger an extra GitHub round-trip. - New `GET /admin/system/updates/changelog` route in `adminSystem.js`, same auth + UPDATE_CHECK_ENABLED gating as the existing /updates and /updates/instructions endpoints. - 4 unit tests (axios mocked) pin: strictly-newer filtering, channel-scoped, empty array on GitHub fetch failure, empty array when already on latest. ## Frontend - New `UpdateAvailableModal.tsx` — opens from the sidebar chip. Two sections: 1. **How to upgrade** — fetches /updates/instructions for the environment-detected copy-paste command (Docker compose / git / standalone). Copy-to-clipboard button per step. 2. **Release notes** — fetches /updates/changelog for every version between current and latest in the user's channel. Latest is auto-expanded; older releases are collapsed by default (click to expand). Each release also has a "View on GitHub" link to the canonical release page. - Renders release body markdown through the existing safe MarkdownContent component (marked + DOMPurify allowlist). - New `updateDismissal.ts` helper — single localStorage key holds the last-dismissed version. Chip stays hidden until a STRICTLY newer version appears, using the same compare semantics as the backend (stable > beta, higher beta > lower beta, semantic numeric on major.minor.patch). 9 unit tests pin the rules. - `VersionInfo.tsx` — chip is now a button that opens the modal instead of an external link (the #566 link-to-release behaviour is preserved on the modal's per-release "View on GitHub" affordance). Dismissal triggers an immediate re-render so the chip disappears without waiting for the next route change. No new dependencies — uses `marked` + `DOMPurify` that were already present in the bundle for the contract block renderer.
This commit is contained in:
@@ -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,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