fix: keep skipped releases with curated notes, and show a failed copy

- Someone who skipped releases now gets them from the changelog after the
  curated notes, up to five releases in all, without the frontend release
  the curated notes already cover.
- The tests remove the update notifications sent to every user in the
  shared test database, not only their own user's.
- Where the clipboard API is missing (plain HTTP) or refuses, the command
  is selected and the tooltip says to copy it by hand.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-21 21:33:28 +02:00
1 parent 8aabcf2fd0
commit 60ee06ba43
7 files changed
+136 -36

No files matched your search

+46 -19
View File
@@ -220,24 +220,38 @@ function sortItems<T extends ReleaseNoteItem>(items: T[]): T[] {
export function notesFromChangelogs(
backend: ChangelogRelease[],
frontend: ChangelogRelease[],
options: { version: string; since?: string | null; frontendVersion?: string | null },
options: {
version: string;
since?: string | null;
frontendVersion?: string | null;
/** Only the releases before `version`, for curated notes that already cover it. */
earlierOnly?: boolean;
},
): ReleaseNoteItem[] {
const { version, since, frontendVersion } = options;
const { version, since, frontendVersion, earlierOnly = false } = options;
if (earlierOnly && !since) return [];
const inRange = (release: ChangelogRelease) =>
compareVersions(release.version, version) <= 0 &&
compareVersions(release.version, version) < (earlierOnly ? 0 : 1) &&
(since ? compareVersions(release.version, since) > 0 : release.version === version);
const limit = earlierOnly ? MAX_VERSIONS - 1 : MAX_VERSIONS;
const backendReleases = backend
.filter(inRange)
.toSorted((a, b) => compareVersions(b.version, a.version))
.slice(0, MAX_VERSIONS);
.slice(0, limit);
// The frontend that went out with `version` is the curated notes' too.
const until = earlierOnly ? (backend.find((r) => r.version === version)?.date ?? null) : null;
const dates = backendReleases.map((r) => r.date).filter((d): d is string => Boolean(d));
const from = dates.length ? dates.reduce((a, b) => (a < b ? a : b)) : null;
const frontendReleases = from
? frontend
.filter(
(r) => r.date && r.date >= from && (!frontendVersion || compareVersions(r.version, frontendVersion) <= 0),
(r) =>
r.date &&
r.date >= from &&
(!until || r.date < until) &&
(!frontendVersion || compareVersions(r.version, frontendVersion) <= 0),
)
.slice(0, MAX_VERSIONS)
.slice(0, limit)
: [];
const seen = new Set<string>();
@@ -291,20 +305,33 @@ export async function getReleaseNotes(
fetchText(`${baseUrl}/frontend/CHANGELOG.md`),
]);
if (curatedText) {
try {
const curated = parseCuratedNotes(JSON.parse(curatedText), curatedUrl);
if (curated?.version === version) return { ...curated, releases_url };
} catch {
// not JSON: fall through to the changelog
}
const since = options.since && SEMVER.test(options.since) ? options.since : null;
const frontendVersion =
options.frontendVersion && SEMVER.test(options.frontendVersion) ? options.frontendVersion : null;
const fromChangelogs = (earlierOnly: boolean) =>
backendText
? notesFromChangelogs(parseChangelog(backendText), parseChangelog(frontendText ?? ""), {
version,
since,
frontendVersion,
earlierOnly,
})
: [];
let curated: ReturnType<typeof parseCuratedNotes> = null;
try {
curated = curatedText ? parseCuratedNotes(JSON.parse(curatedText), curatedUrl) : null;
} catch {
// not JSON: the changelog is shown instead
}
if (curated?.version === version) {
// Someone who skipped releases gets theirs from the changelog after the curated ones; commit links aren't
// "Learn more" links.
const earlier = fromChangelogs(true);
for (const item of earlier) item.url = null;
return { ...curated, items: [...curated.items, ...earlier], releases_url };
}
if (!backendText) return empty;
const items = notesFromChangelogs(parseChangelog(backendText), parseChangelog(frontendText ?? ""), {
version,
since: options.since && SEMVER.test(options.since) ? options.since : null,
frontendVersion: options.frontendVersion && SEMVER.test(options.frontendVersion) ? options.frontendVersion : null,
});
return { ...empty, source: "changelog", items };
return { ...empty, source: "changelog", items: fromChangelogs(false) };
}
+52 -1
View File
@@ -1,9 +1,11 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import {
announceVersionUpdate,
clearReleaseNotesCache,
getReleaseNotes,
notesFromChangelogs,
parseChangelog,
parseCuratedNotes,
@@ -110,6 +112,49 @@ describe("release notes", () => {
expect(items.map((i) => i.title)).not.toContain("Pick an accent colour");
});
it("takes only the releases before the version, and not its frontend, for curated notes that cover it", () => {
const items = notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), parseChangelog(FRONTEND_CHANGELOG), {
version: "1.2.0",
since: "1.1.0",
frontendVersion: "1.3.0",
earlierOnly: true,
});
expect(items.map((i) => i.title)).toEqual(["Load listings faster"]);
expect(notesFromChangelogs(parseChangelog(BACKEND_CHANGELOG), [], { version: "1.2.0", earlierOnly: true })).toEqual(
[],
);
});
it("adds skipped releases from the changelog after curated notes", async () => {
const files: Record<string, string> = {
"/release-notes/latest-release-notes.json": JSON.stringify({
version: "1.2.0",
items: [{ type: "feature", title: "Curated feature" }],
}),
"/backend/CHANGELOG.md": BACKEND_CHANGELOG,
"/frontend/CHANGELOG.md": FRONTEND_CHANGELOG,
};
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const body = files[new URL(String(input)).pathname];
return new Response(body ?? "", { status: body ? 200 : 404 });
});
try {
clearReleaseNotesCache();
const notes = await getReleaseNotes({ version: "1.2.0", since: "1.1.0" }, "https://notes.example");
expect(notes?.source).toBe("curated");
expect(notes?.items.map((i) => [i.title, i.url])).toEqual([
["Curated feature", null],
["Load listings faster", null],
]);
const direct = await getReleaseNotes({ version: "1.2.0" }, "https://notes.example");
expect(direct?.items.map((i) => i.title)).toEqual(["Curated feature"]);
} finally {
fetchMock.mockRestore();
clearReleaseNotesCache();
}
});
it("resolves curated screenshots against the file and drops unsafe ones", () => {
const notes = parseCuratedNotes(
{
@@ -152,10 +197,16 @@ describe("version update notification", () => {
user = { id: res.body.user.id as string, token: login.body.token as string };
});
// An announcement goes to every user in the shared test database, not only this file's.
const clearAnnouncements = () =>
prisma.notification.deleteMany({ where: { type: "system", internalPath: { startsWith: "?whats-new=" } } });
beforeEach(async () => {
await prisma.notification.deleteMany({ where: { userId: user.id } });
});
afterEach(clearAnnouncements);
afterAll(async () => {
await prisma.setting.deleteMany({ where: { key: "last_run_version" } });
await prisma.user.deleteMany({ where: { id: user.id } });
@@ -118,17 +118,30 @@ function Screenshots({
function CodeBlock({ code }: { code: string }) {
const { t } = useTranslation("app");
const [copied, setCopied] = React.useState(false);
const box = React.useRef<HTMLPreElement>(null);
const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle");
React.useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1500);
if (copyState === "idle") return;
const timer = window.setTimeout(() => setCopyState("idle"), copyState === "failed" ? 4000 : 1500);
return () => window.clearTimeout(timer);
}, [copied]);
const copy = () =>
navigator.clipboard
?.writeText(code)
.then(() => setCopied(true))
.catch(() => undefined);
}, [copyState]);
// The clipboard API only exists on HTTPS or localhost, and self-hosted instances are often plain HTTP on a LAN
// address; there the command is selected, to copy by hand.
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopyState("copied");
} catch {
if (box.current) window.getSelection()?.selectAllChildren(box.current);
setCopyState("failed");
}
};
const copyLabel =
copyState === "failed"
? t("whatsNew.copyFailed")
: copyState === "copied"
? t("whatsNew.copied")
: t("whatsNew.copy");
return (
<Box
sx={{
@@ -142,6 +155,7 @@ function CodeBlock({ code }: { code: string }) {
>
<Box
component="pre"
ref={box}
sx={{
m: 0,
p: 1.25,
@@ -155,14 +169,18 @@ function CodeBlock({ code }: { code: string }) {
>
{code}
</Box>
<Tooltip title={copied ? t("whatsNew.copied") : t("whatsNew.copy")}>
<Tooltip title={copyLabel}>
<IconButton
size="small"
aria-label={t("whatsNew.copy")}
onClick={copy}
onClick={() => void copy()}
sx={{ position: "absolute", top: 4, right: 4 }}
>
{copied ? <CheckIcon sx={{ fontSize: 16 }} color="success" /> : <ContentCopyIcon sx={{ fontSize: 16 }} />}
{copyState === "copied" ? (
<CheckIcon sx={{ fontSize: 16 }} color="success" />
) : (
<ContentCopyIcon sx={{ fontSize: 16 }} />
)}
</IconButton>
</Tooltip>
</Box>
+2 -1
View File
@@ -2197,7 +2197,8 @@
"zoom": "Enlarge screenshot",
"highlight": "Highlight",
"copy": "Copy",
"copied": "Copied"
"copied": "Copied",
"copyFailed": "Couldn't copy here. It's selected: press Ctrl+C or ⌘C."
}
},
"common": {
+2 -1
View File
@@ -2197,7 +2197,8 @@
"zoom": "Ampliar captura",
"highlight": "Destacado",
"copy": "Copiar",
"copied": "Copiado"
"copied": "Copiado",
"copyFailed": "No se pudo copiar aquí. Está seleccionado: pulsa Ctrl+C o ⌘C."
}
},
"common": {
+2 -1
View File
@@ -2210,7 +2210,8 @@
"zoom": "Agrandir la capture",
"highlight": "À la une",
"copy": "Copier",
"copied": "Copié"
"copied": "Copié",
"copyFailed": "Copie impossible ici. Le texte est sélectionné : appuyez sur Ctrl+C ou ⌘C."
}
},
"common": {
+2 -1
View File
@@ -2263,7 +2263,8 @@
"zoom": "Padidinti ekrano vaizdą",
"highlight": "Svarbiausia",
"copy": "Kopijuoti",
"copied": "Nukopijuota"
"copied": "Nukopijuota",
"copyFailed": "Čia nepavyko nukopijuoti. Tekstas pažymėtas: spauskite Ctrl+C arba ⌘C."
}
},
"common": {