mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
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:
1 parent
8aabcf2fd0
commit
60ee06ba43
7 files changed
+136
-36
No files matched your search
@@ -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) };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user