feat: Journal before the version history in Settings → System, history folded by version (#372) #470
8 changed files with 940 additions and 79 deletions
338
src/components/settings/ChangelogContent.test.tsx
Normal file
338
src/components/settings/ChangelogContent.test.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
// ChangelogContent — the version history folds by version (#372).
|
||||
//
|
||||
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
|
||||
// component runs as a plain function, its state survives from one call to the
|
||||
// next, `commit()` runs its effect (the fetch of the changelog), and the
|
||||
// handlers of the rendered tree are called as clicks would. The markup is
|
||||
// read through renderToStaticMarkup.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
|
||||
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||
const ctx = vi.hoisted(() => ({ language: "fr" }));
|
||||
vi.mock("react-i18next", async () =>
|
||||
(await import("../../test/i18nMock")).i18nMock({ language: () => ctx.language }),
|
||||
);
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { hookHarness } from "../../test/hookHarness";
|
||||
import ChangelogContent, { CHANGELOG_NOTES_ID_PREFIX } from "./ChangelogContent";
|
||||
|
||||
const harness = hookHarness();
|
||||
|
||||
const FR = [
|
||||
"# Journal des modifications",
|
||||
"",
|
||||
"## [Non publié]",
|
||||
"",
|
||||
"## [0.17.2] - 2026-10-18",
|
||||
"",
|
||||
"### Ajouté",
|
||||
"",
|
||||
"- **Import** : note-0172-a",
|
||||
"",
|
||||
"### Corrigé",
|
||||
"",
|
||||
"- note-0172-b",
|
||||
"",
|
||||
"## [0.17.1] - 2026-10-08",
|
||||
"",
|
||||
"### Modifié",
|
||||
"",
|
||||
"- note-0171",
|
||||
"",
|
||||
"## 0.3.2",
|
||||
"",
|
||||
"### Ajouté",
|
||||
"",
|
||||
"- note-032",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const EN = FR.replace("Journal des modifications", "Changelog")
|
||||
.replace("[Non publié]", "[Unreleased]")
|
||||
.replace("note-0171", "note-0171-en");
|
||||
|
||||
/** FR with content under its unreleased heading, as in a development build. */
|
||||
const FR_DEV = FR.replace("## [Non publié]\n", "## [Non publié]\n\n### Modifié\n\n- note-dev\n");
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as AnyElement;
|
||||
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||
}
|
||||
|
||||
function texts(node: ReactNode): string[] {
|
||||
if (Array.isArray(node)) return node.flatMap(texts);
|
||||
if (typeof node === "string") return [node];
|
||||
if (!isValidElement(node)) return [];
|
||||
return texts((node as AnyElement).props.children);
|
||||
}
|
||||
|
||||
interface FetchedFile {
|
||||
text: () => Promise<string>;
|
||||
}
|
||||
|
||||
let files: Record<string, string>;
|
||||
/** The bundled files, `files` by URL; any other URL fails, as an unreadable file. */
|
||||
const readFiles = async (url: string): Promise<FetchedFile> => {
|
||||
if (!(url in files)) throw new Error(`unexpected fetch ${url}`);
|
||||
return { text: async () => files[url] };
|
||||
};
|
||||
const fetchMock = vi.fn(readFiles);
|
||||
|
||||
/** Lets the fetch, `text()` and the state updates that follow them settle. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** One render of the mounted component. */
|
||||
function render(installedVersion: string | null = "0.17.1"): ReactNode {
|
||||
return harness.render(() => ChangelogContent({ installedVersion }) as ReactNode);
|
||||
}
|
||||
|
||||
/** Mount, run the effect, let the file load, then render with the loaded state. */
|
||||
async function mount(installedVersion: string | null = "0.17.1"): Promise<ReactNode> {
|
||||
render(installedVersion);
|
||||
harness.commit();
|
||||
await settle();
|
||||
return render(installedVersion);
|
||||
}
|
||||
|
||||
/** The title button of `version`. */
|
||||
function titleOf(tree: ReactNode, version: string): AnyElement {
|
||||
const [button] = find(
|
||||
tree,
|
||||
(e) => e.type === "button" && e.props["aria-controls"] !== undefined && texts(e.props.children).includes(version),
|
||||
);
|
||||
expect(button, `title of ${version}`).toBeDefined();
|
||||
return button;
|
||||
}
|
||||
|
||||
/** The version titles, in order, with their state. */
|
||||
function titles(tree: ReactNode): Array<[string, boolean]> {
|
||||
return find(tree, (e) => e.type === "button" && e.props["aria-controls"] !== undefined).map((e) => [
|
||||
texts(e.props.children).join(""),
|
||||
e.props["aria-expanded"] as boolean,
|
||||
]);
|
||||
}
|
||||
|
||||
/** The expand-all button. */
|
||||
function allButton(tree: ReactNode): AnyElement {
|
||||
const [button] = find(tree, (e) => e.type === "button" && e.props["aria-controls"] === undefined);
|
||||
expect(button, "expand-all button").toBeDefined();
|
||||
return button;
|
||||
}
|
||||
|
||||
const click = (button: AnyElement) => (button.props.onClick as () => void)();
|
||||
|
||||
beforeEach(() => {
|
||||
harness.clear();
|
||||
ctx.language = "fr";
|
||||
files = { "/CHANGELOG.fr.md": FR, "/CHANGELOG.md": EN };
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(readFiles);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ChangelogContent — loading", () => {
|
||||
it("reads the changelog of the app language", async () => {
|
||||
await mount();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.fr.md");
|
||||
|
||||
harness.clear();
|
||||
ctx.language = "en";
|
||||
fetchMock.mockClear();
|
||||
await mount();
|
||||
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.md");
|
||||
});
|
||||
|
||||
it("shows the empty message until the file is read, and when it cannot be read", async () => {
|
||||
expect(renderToStaticMarkup(render())).toContain("t(changelog.empty)");
|
||||
|
||||
harness.clear();
|
||||
files = {};
|
||||
const html = renderToStaticMarkup(await mount());
|
||||
expect(html).toContain("t(changelog.empty)");
|
||||
expect(html).not.toContain("aria-expanded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangelogContent — which versions open (#372)", () => {
|
||||
it("opens on the installed version, every other version folded", async () => {
|
||||
const tree = await mount("0.17.1");
|
||||
expect(titles(tree)).toEqual([
|
||||
["0.17.2", false],
|
||||
["0.17.1", true],
|
||||
["0.3.2", false],
|
||||
]);
|
||||
const html = renderToStaticMarkup(tree);
|
||||
expect(html).toContain("note-0171");
|
||||
expect(html).not.toContain("note-0172-a");
|
||||
expect(html).not.toContain("note-032");
|
||||
});
|
||||
|
||||
it("opens on the first published version when the installed version is unknown", async () => {
|
||||
const tree = await mount(null);
|
||||
expect(titles(tree)).toEqual([
|
||||
["0.17.2", true],
|
||||
["0.17.1", false],
|
||||
["0.3.2", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens on the first published version when the installed one is not in the file", async () => {
|
||||
const tree = await mount("0.18.0");
|
||||
expect(titles(tree).filter(([, open]) => open)).toEqual([["0.17.2", true]]);
|
||||
});
|
||||
|
||||
it("hides the empty unreleased heading", async () => {
|
||||
const html = renderToStaticMarkup(await mount());
|
||||
expect(html).not.toContain("Non publié");
|
||||
});
|
||||
|
||||
it("keeps an unreleased heading with content, folded, before the versions", async () => {
|
||||
files["/CHANGELOG.fr.md"] = FR_DEV;
|
||||
const tree = await mount("0.17.2");
|
||||
expect(titles(tree)).toEqual([
|
||||
["Non publié", false],
|
||||
["0.17.2", true],
|
||||
["0.17.1", false],
|
||||
["0.3.2", false],
|
||||
]);
|
||||
expect(renderToStaticMarkup(tree)).not.toContain("note-dev");
|
||||
});
|
||||
|
||||
it("shows the notes of an open version, sections and items, without bold marks", async () => {
|
||||
const html = renderToStaticMarkup(await mount("0.17.2"));
|
||||
expect(html).toContain(">Ajouté</h4>");
|
||||
expect(html).toContain(">Corrigé</h4>");
|
||||
expect(html).toContain("Import : note-0172-a");
|
||||
expect(html).not.toContain("**Import**");
|
||||
expect(html).toContain("note-0172-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangelogContent — folding by hand", () => {
|
||||
it("unfolds a folded version and folds it again on its title", async () => {
|
||||
let tree: ReactNode = await mount("0.17.1");
|
||||
click(titleOf(tree, "0.3.2"));
|
||||
tree = render();
|
||||
expect(titles(tree)).toEqual([
|
||||
["0.17.2", false],
|
||||
["0.17.1", true],
|
||||
["0.3.2", true],
|
||||
]);
|
||||
expect(renderToStaticMarkup(tree)).toContain("note-032");
|
||||
|
||||
click(titleOf(tree, "0.3.2"));
|
||||
tree = render();
|
||||
expect(titleOf(tree, "0.3.2").props["aria-expanded"]).toBe(false);
|
||||
expect(renderToStaticMarkup(tree)).not.toContain("note-032");
|
||||
});
|
||||
|
||||
it("folds the installed version on its title", async () => {
|
||||
let tree: ReactNode = await mount("0.17.1");
|
||||
click(titleOf(tree, "0.17.1"));
|
||||
tree = render();
|
||||
expect(titles(tree).every(([, open]) => !open)).toBe(true);
|
||||
});
|
||||
|
||||
it("unfolds everything with « Expand all », then folds everything with « Collapse all »", async () => {
|
||||
let tree: ReactNode = await mount("0.17.1");
|
||||
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
|
||||
|
||||
click(allButton(tree));
|
||||
tree = render();
|
||||
expect(titles(tree).every(([, open]) => open)).toBe(true);
|
||||
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
|
||||
const html = renderToStaticMarkup(tree);
|
||||
for (const note of ["note-0172-a", "note-0171", "note-032"]) expect(html).toContain(note);
|
||||
|
||||
click(allButton(tree));
|
||||
tree = render();
|
||||
expect(titles(tree).every(([, open]) => !open)).toBe(true);
|
||||
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
|
||||
});
|
||||
|
||||
it("offers « Collapse all » once every version is unfolded by hand", async () => {
|
||||
let tree: ReactNode = await mount("0.17.1");
|
||||
click(titleOf(tree, "0.17.2"));
|
||||
tree = render();
|
||||
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
|
||||
click(titleOf(tree, "0.3.2"));
|
||||
tree = render();
|
||||
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangelogContent — accessibility", () => {
|
||||
it("makes each title a button inside its heading, with its state and the notes it controls", async () => {
|
||||
const html = renderToStaticMarkup(await mount("0.17.1"));
|
||||
// Heading > button with aria-expanded and aria-controls, chevron, version.
|
||||
expect(html).toMatch(
|
||||
/<h3[^>]*><button type="button" aria-expanded="true" aria-controls="changelog-notes-1" title="t\(changelog\.hideNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.1<\/button><\/h3>/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<h3[^>]*><button type="button" aria-expanded="false" aria-controls="changelog-notes-0" title="t\(changelog\.showNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.2<\/button><\/h3>/,
|
||||
);
|
||||
// Every id an aria-controls names is in the page, folded or not.
|
||||
const controlled = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(controlled).toEqual([0, 1, 2].map((i) => `${CHANGELOG_NOTES_ID_PREFIX}${i}`));
|
||||
for (const id of controlled) expect(html).toContain(`id="${id}"`);
|
||||
});
|
||||
|
||||
it("names the expand-all button through t, never in hard text", async () => {
|
||||
const html = renderToStaticMarkup(await mount("0.17.1"));
|
||||
expect(html).toMatch(/<button type="button"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>t\(changelog\.expandAll\)<\/button>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChangelogContent — language switch", () => {
|
||||
it("reloads the file of the new language and opens the installed version again", async () => {
|
||||
let tree: ReactNode = await mount("0.17.1");
|
||||
click(allButton(tree));
|
||||
ctx.language = "en";
|
||||
render();
|
||||
harness.commit();
|
||||
await settle();
|
||||
tree = render();
|
||||
expect(fetchMock).toHaveBeenLastCalledWith("/CHANGELOG.md");
|
||||
expect(titles(tree)).toEqual([
|
||||
["0.17.2", false],
|
||||
["0.17.1", true],
|
||||
["0.3.2", false],
|
||||
]);
|
||||
expect(renderToStaticMarkup(tree)).toContain("note-0171-en");
|
||||
});
|
||||
|
||||
it("never lets a late answer of the previous language replace the current one", async () => {
|
||||
const pending: Array<{ url: string; resolve: (text: string) => void }> = [];
|
||||
fetchMock.mockImplementation(
|
||||
(url: string) =>
|
||||
new Promise<FetchedFile>((resolveFetch) => {
|
||||
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }) });
|
||||
}),
|
||||
);
|
||||
|
||||
render("0.17.1");
|
||||
harness.commit(); // asks for the French file
|
||||
ctx.language = "en";
|
||||
render("0.17.1");
|
||||
harness.commit(); // cancels the French one, asks for the English file
|
||||
expect(pending.map((p) => p.url)).toEqual(["/CHANGELOG.fr.md", "/CHANGELOG.md"]);
|
||||
|
||||
pending[1].resolve(EN);
|
||||
await settle();
|
||||
pending[0].resolve(FR);
|
||||
await settle();
|
||||
const html = renderToStaticMarkup(render("0.17.1"));
|
||||
expect(html).toContain("note-0171-en");
|
||||
expect(html).not.toMatch(/note-0171(?!-en)/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +1,52 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import {
|
||||
allVersionsExpanded,
|
||||
initiallyExpandedVersions,
|
||||
parseChangelog,
|
||||
toggleAllVersions,
|
||||
toggleVersion,
|
||||
visibleChangelogEntries,
|
||||
type ChangelogEntry,
|
||||
} from "../../utils/changelog";
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
sections: { heading: string; items: string[] }[];
|
||||
/** Prefix of the id of each version's notes, which its title names in `aria-controls`. */
|
||||
export const CHANGELOG_NOTES_ID_PREFIX = "changelog-notes-";
|
||||
|
||||
interface ChangelogContentProps {
|
||||
/** The running app's version (`getVersion()`), `null` when it could not be read. */
|
||||
installedVersion: string | null;
|
||||
}
|
||||
|
||||
function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let current: ChangelogEntry | null = null;
|
||||
let currentSection: { heading: string; items: string[] } | null = null;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
|
||||
if (versionMatch) {
|
||||
if (currentSection && current) current.sections.push(currentSection);
|
||||
if (current) entries.push(current);
|
||||
current = { version: versionMatch[1], sections: [] };
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionMatch = trimmed.match(/^### (.+)/);
|
||||
if (sectionMatch && current) {
|
||||
if (currentSection) current.sections.push(currentSection);
|
||||
currentSection = { heading: sectionMatch[1], items: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("- ") && currentSection) {
|
||||
currentSection.items.push(trimmed.slice(2));
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection && current) current.sections.push(currentSection);
|
||||
if (current) entries.push(current);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default function ChangelogContent() {
|
||||
export default function ChangelogContent({ installedVersion }: ChangelogContentProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
|
||||
// Versions whose notes show. Seeded by the expansion rule at each load of
|
||||
// the file, then changed by the titles and the expand-all button. In
|
||||
// memory only: the history opens on the installed version every time (#372).
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// A file of the previous language that answers late must not replace
|
||||
// the one of the current language.
|
||||
let cancelled = false;
|
||||
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
||||
fetch(file)
|
||||
.then((r) => r.text())
|
||||
.then((text) => setEntries(parseChangelog(text)))
|
||||
.catch(() => setEntries([]));
|
||||
}, [i18n.language]);
|
||||
.then((text) => {
|
||||
if (cancelled) return;
|
||||
const visible = visibleChangelogEntries(parseChangelog(text));
|
||||
setEntries(visible);
|
||||
setExpanded(initiallyExpandedVersions(visible, installedVersion));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEntries([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [i18n.language, installedVersion]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
|
|
@ -59,14 +54,53 @@ export default function ChangelogContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const allExpanded = allVersionsExpanded(entries, expanded);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((current) => toggleAllVersions(entries, current))}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{allExpanded ? (
|
||||
<ChevronsDownUp size={13} aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronsUpDown size={13} aria-hidden="true" />
|
||||
)}
|
||||
{allExpanded ? t("changelog.collapseAll") : t("changelog.expandAll")}
|
||||
</button>
|
||||
</div>
|
||||
{entries.map((entry, index) => {
|
||||
const open = expanded.has(entry.version);
|
||||
const notesId = `${CHANGELOG_NOTES_ID_PREFIX}${index}`;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.version}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">{entry.version}</h3>
|
||||
<h3 className="text-lg font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={notesId}
|
||||
title={open ? t("changelog.hideNotes") : t("changelog.showNotes")}
|
||||
onClick={() => setExpanded((current) => toggleVersion(current, entry.version))}
|
||||
className="flex items-center gap-2 w-full px-6 py-3 text-left hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown size={18} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronRight size={18} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
{entry.version}
|
||||
</button>
|
||||
</h3>
|
||||
{/* Always in the page, so `aria-controls` names an element even while folded. */}
|
||||
<div id={notesId}>
|
||||
{open && (
|
||||
<div className="px-6 pb-6 space-y-3">
|
||||
{entry.sections.map((section, si) => (
|
||||
<div key={si} className="space-y-1.5">
|
||||
<h4 className="text-sm font-semibold text-[var(--primary)]">
|
||||
|
|
@ -86,7 +120,11 @@ export default function ChangelogContent() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"changelog": {
|
||||
"title": "Version History",
|
||||
"description": "View what's new and fixed in each version",
|
||||
"empty": "No entries available"
|
||||
"empty": "No entries available",
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all",
|
||||
"showNotes": "Show this version's notes",
|
||||
"hideNotes": "Hide this version's notes"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"changelog": {
|
||||
"title": "Historique des versions",
|
||||
"description": "Consultez les nouveautés et corrections de chaque version",
|
||||
"empty": "Aucune entrée disponible"
|
||||
"empty": "Aucune entrée disponible",
|
||||
"expandAll": "Tout déplier",
|
||||
"collapseAll": "Tout replier",
|
||||
"showNotes": "Afficher les notes de cette version",
|
||||
"hideNotes": "Masquer les notes de cette version"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
|
|
|
|||
118
src/pages/settings/SystemsSettingsPage.test.tsx
Normal file
118
src/pages/settings/SystemsSettingsPage.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// SystemsSettingsPage — the Journal before the version history, and the
|
||||
// history mounted once the installed version is known (#372).
|
||||
//
|
||||
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
|
||||
// page runs as a plain function, `commit()` runs its effect (getVersion), and
|
||||
// the next render reads what the effect stored. The cards are stubs: only
|
||||
// where the page puts them, and what it hands the history, are under test.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
|
||||
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const { createElement } = await vi.importActual<typeof import("react")>("react");
|
||||
return {
|
||||
Link: ({ to, className, children }: { to: string; className?: string; children?: ReactNode }) =>
|
||||
createElement("a", { href: to, className }, children),
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn() }));
|
||||
vi.mock("../../services/logService", () => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn() }));
|
||||
vi.mock("../../components/settings/UpdateCard", () => ({ default: () => "[UpdateCard]" }));
|
||||
vi.mock("../../components/settings/LogViewerCard", () => ({ default: () => "[LogViewerCard]" }));
|
||||
vi.mock("../../components/settings/ChangelogContent", () => ({ default: () => "[ChangelogContent]" }));
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { hookHarness } from "../../test/hookHarness";
|
||||
import { logWarn } from "../../services/logService";
|
||||
import ChangelogContent from "../../components/settings/ChangelogContent";
|
||||
import SystemsSettingsPage from "./SystemsSettingsPage";
|
||||
|
||||
const harness = hookHarness();
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as AnyElement;
|
||||
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||
}
|
||||
|
||||
/** One render of the mounted page. */
|
||||
const render = (): ReactNode => harness.render(() => SystemsSettingsPage() as ReactNode);
|
||||
|
||||
/** Lets getVersion() and the state update that follows it settle. */
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const history = (page: ReactNode) => find(page, (e) => e.type === ChangelogContent);
|
||||
|
||||
beforeEach(() => {
|
||||
harness.clear();
|
||||
vi.mocked(getVersion).mockReset();
|
||||
vi.mocked(logWarn).mockReset();
|
||||
});
|
||||
|
||||
describe("SystemsSettingsPage — order of the sections (#372)", () => {
|
||||
it("renders Version, Update, the Journal, then the version history and the data notice", () => {
|
||||
vi.mocked(getVersion).mockResolvedValue("0.17.2");
|
||||
harness.slots[0] = "0.17.2"; // the version, already read
|
||||
const html = renderToStaticMarkup(render());
|
||||
const at = (marker: string) => {
|
||||
const index = html.indexOf(marker);
|
||||
expect(index, marker).toBeGreaterThanOrEqual(0);
|
||||
return index;
|
||||
};
|
||||
const order = [
|
||||
at("t(settings.systems.sections.version)"),
|
||||
at("t(settings.systems.sections.update)"),
|
||||
at("[UpdateCard]"),
|
||||
at("t(settings.systems.sections.logs)"),
|
||||
at("[LogViewerCard]"),
|
||||
at("t(settings.systems.sections.changelog)"),
|
||||
at("[ChangelogContent]"),
|
||||
at("t(settings.dataSafeNotice)"),
|
||||
];
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SystemsSettingsPage — the version history waits for the version (#372)", () => {
|
||||
it("keeps the history heading but mounts no history while getVersion() runs", () => {
|
||||
vi.mocked(getVersion).mockReturnValue(new Promise(() => {}));
|
||||
let page = render();
|
||||
harness.commit();
|
||||
page = render();
|
||||
expect(history(page)).toHaveLength(0);
|
||||
expect(renderToStaticMarkup(page)).toContain("t(settings.systems.sections.changelog)");
|
||||
});
|
||||
|
||||
it("mounts the history with the installed version once it is read", async () => {
|
||||
vi.mocked(getVersion).mockResolvedValue("0.17.2");
|
||||
expect(history(render())).toHaveLength(0);
|
||||
harness.commit();
|
||||
await settle();
|
||||
const page = render();
|
||||
const [mounted] = history(page);
|
||||
expect(mounted.props.installedVersion).toBe("0.17.2");
|
||||
expect(renderToStaticMarkup(page)).toContain("t(settings.version[version=0.17.2])");
|
||||
expect(logWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts the history with an unknown version when getVersion() fails, and says so in the Journal", async () => {
|
||||
vi.mocked(getVersion).mockRejectedValue(new Error("ipc down"));
|
||||
render();
|
||||
harness.commit();
|
||||
await settle();
|
||||
const page = render();
|
||||
const [mounted] = history(page);
|
||||
expect(mounted.props.installedVersion).toBeNull();
|
||||
expect(renderToStaticMarkup(page)).toContain("t(settings.version[version=])");
|
||||
expect(logWarn).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(logWarn).mock.calls[0][0]).toBe(
|
||||
"App version unavailable; the version history opens on the latest release",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,16 +4,23 @@ import { Link } from "react-router-dom";
|
|||
import { ArrowLeft, ShieldCheck } from "lucide-react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { APP_NAME } from "../../shared/constants";
|
||||
import { logWarn } from "../../services/logService";
|
||||
import UpdateCard from "../../components/settings/UpdateCard";
|
||||
import ChangelogContent from "../../components/settings/ChangelogContent";
|
||||
import LogViewerCard from "../../components/settings/LogViewerCard";
|
||||
|
||||
export default function SystemsSettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [version, setVersion] = useState("");
|
||||
// `undefined` while getVersion() runs, `null` when it failed.
|
||||
const [version, setVersion] = useState<string | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setVersion);
|
||||
getVersion()
|
||||
.then(setVersion)
|
||||
.catch((error) => {
|
||||
logWarn("App version unavailable; the version history opens on the latest release", String(error));
|
||||
setVersion(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -40,7 +47,7 @@ export default function SystemsSettingsPage() {
|
|||
<div>
|
||||
<h3 className="text-lg font-semibold">{APP_NAME}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.version", { version })}
|
||||
{t("settings.version", { version: version ?? "" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -54,13 +61,7 @@ export default function SystemsSettingsPage() {
|
|||
<UpdateCard />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{t("settings.systems.sections.changelog")}
|
||||
</h2>
|
||||
<ChangelogContent />
|
||||
</section>
|
||||
|
||||
{/* The Journal comes before the version history, which grows at each release (#372). */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{t("settings.systems.sections.logs")}
|
||||
|
|
@ -68,6 +69,14 @@ export default function SystemsSettingsPage() {
|
|||
<LogViewerCard />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{t("settings.systems.sections.changelog")}
|
||||
</h2>
|
||||
{/* Mounted once the version is settled: the history opens on it (#372). */}
|
||||
{version !== undefined && <ChangelogContent installedVersion={version} />}
|
||||
</section>
|
||||
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
|
||||
<p>{t("settings.dataSafeNotice")}</p>
|
||||
|
|
|
|||
231
src/utils/changelog.test.ts
Normal file
231
src/utils/changelog.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// Version history of Settings → System (#372): the parser and the rule that
|
||||
// decides which versions show their notes when the history opens.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import {
|
||||
allVersionsExpanded,
|
||||
initiallyExpandedVersions,
|
||||
isPublishedVersion,
|
||||
parseChangelog,
|
||||
toggleAllVersions,
|
||||
toggleVersion,
|
||||
visibleChangelogEntries,
|
||||
type ChangelogEntry,
|
||||
} from "./changelog";
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, "../..");
|
||||
|
||||
/** An entry with one section per heading, each with its items. */
|
||||
function entry(version: string, sections: Record<string, string[]> = {}): ChangelogEntry {
|
||||
return {
|
||||
version,
|
||||
sections: Object.entries(sections).map(([heading, items]) => ({ heading, items })),
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = (set: Set<string>) => [...set].sort();
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("reads bracketed headings with a date, and headings without brackets", () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## [0.17.2] - 2026-10-18",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"- A",
|
||||
"",
|
||||
"## 0.3.2",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- B",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(entries.map((e) => e.version)).toEqual(["0.17.2", "0.3.2"]);
|
||||
});
|
||||
|
||||
it("gives each version its sections in order, with their items, bold marks kept", () => {
|
||||
const [first, second] = parseChangelog(
|
||||
[
|
||||
"## [0.17.2] - 2026-10-18",
|
||||
"### Added",
|
||||
"- **Import**: A",
|
||||
"- B",
|
||||
"### Fixed",
|
||||
"- C",
|
||||
"## [0.17.1] - 2026-10-08",
|
||||
"### Changed",
|
||||
"- D",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(first).toEqual(
|
||||
entry("0.17.2", { Added: ["**Import**: A", "B"], Fixed: ["C"] }),
|
||||
);
|
||||
expect(second).toEqual(entry("0.17.1", { Changed: ["D"] }));
|
||||
});
|
||||
|
||||
it("reads an empty Unreleased heading as an entry without sections, in both languages", () => {
|
||||
expect(parseChangelog("# Changelog\n\n## [Unreleased]\n\n## [0.17.2] - 2026-10-18\n")).toEqual([
|
||||
entry("Unreleased"),
|
||||
entry("0.17.2"),
|
||||
]);
|
||||
expect(parseChangelog("## [Non publié]\n\n## [0.17.2] - 2026-10-18\n")[0]).toEqual(entry("Non publié"));
|
||||
});
|
||||
|
||||
it("ignores text outside of a section, a section before any version, and Windows line ends", () => {
|
||||
const entries = parseChangelog(
|
||||
[
|
||||
"### Orphan",
|
||||
"- before any version",
|
||||
"## [1.0.0]",
|
||||
"Some paragraph.",
|
||||
"- item without a section",
|
||||
"### Added",
|
||||
"- kept",
|
||||
].join("\r\n"),
|
||||
);
|
||||
expect(entries).toEqual([entry("1.0.0", { Added: ["kept"] })]);
|
||||
});
|
||||
|
||||
it("reads nothing from text without a version heading", () => {
|
||||
expect(parseChangelog("")).toEqual([]);
|
||||
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublishedVersion", () => {
|
||||
it("is true for a version number, false for the unreleased heading of either language", () => {
|
||||
expect(isPublishedVersion("0.17.2")).toBe(true);
|
||||
expect(isPublishedVersion("0.2.10")).toBe(true);
|
||||
expect(isPublishedVersion("Unreleased")).toBe(false);
|
||||
expect(isPublishedVersion("Non publié")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleChangelogEntries", () => {
|
||||
it("hides an empty Unreleased heading, in both languages", () => {
|
||||
const published = entry("0.17.2", { Added: ["A"] });
|
||||
expect(visibleChangelogEntries([entry("Unreleased"), published])).toEqual([published]);
|
||||
expect(visibleChangelogEntries([entry("Non publié"), published])).toEqual([published]);
|
||||
});
|
||||
|
||||
it("hides an Unreleased heading whose sections have no item", () => {
|
||||
const published = entry("0.17.2", { Added: ["A"] });
|
||||
expect(visibleChangelogEntries([entry("Unreleased", { Added: [] }), published])).toEqual([published]);
|
||||
});
|
||||
|
||||
it("keeps an Unreleased heading with content (development build)", () => {
|
||||
const unreleased = entry("Unreleased", { Changed: ["Work in progress"] });
|
||||
const published = entry("0.17.2", { Added: ["A"] });
|
||||
expect(visibleChangelogEntries([unreleased, published])).toEqual([unreleased, published]);
|
||||
});
|
||||
|
||||
it("keeps a published version even without any item", () => {
|
||||
const bare = entry("0.2.2");
|
||||
expect(visibleChangelogEntries([entry("0.2.3", { Added: ["A"] }), bare])).toContain(bare);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initiallyExpandedVersions", () => {
|
||||
const entries = [
|
||||
entry("0.17.2", { Added: ["A"] }),
|
||||
entry("0.17.1", { Changed: ["B"] }),
|
||||
entry("0.3.2", { Fixed: ["C"] }),
|
||||
];
|
||||
|
||||
it("unfolds the installed version alone", () => {
|
||||
expect(sorted(initiallyExpandedVersions(entries, "0.17.1"))).toEqual(["0.17.1"]);
|
||||
expect(sorted(initiallyExpandedVersions(entries, "0.3.2"))).toEqual(["0.3.2"]);
|
||||
});
|
||||
|
||||
it("unfolds the first published version when the installed one is absent from the changelog", () => {
|
||||
expect(sorted(initiallyExpandedVersions(entries, "0.18.0"))).toEqual(["0.17.2"]);
|
||||
});
|
||||
|
||||
it("unfolds the first published version when the installed version is unknown", () => {
|
||||
expect(sorted(initiallyExpandedVersions(entries, null))).toEqual(["0.17.2"]);
|
||||
});
|
||||
|
||||
it("never takes an Unreleased heading with content for the first published version", () => {
|
||||
const withUnreleased = [entry("Non publié", { Modifié: ["En cours"] }), ...entries];
|
||||
expect(sorted(initiallyExpandedVersions(withUnreleased, null))).toEqual(["0.17.2"]);
|
||||
expect(sorted(initiallyExpandedVersions(withUnreleased, "0.18.0"))).toEqual(["0.17.2"]);
|
||||
expect(sorted(initiallyExpandedVersions(withUnreleased, "0.17.1"))).toEqual(["0.17.1"]);
|
||||
});
|
||||
|
||||
it("unfolds nothing without any published version", () => {
|
||||
expect(sorted(initiallyExpandedVersions([], "0.17.2"))).toEqual([]);
|
||||
expect(sorted(initiallyExpandedVersions([entry("Unreleased", { Added: ["A"] })], null))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleVersion", () => {
|
||||
it("unfolds a folded version and folds an unfolded one, without touching the others or its input", () => {
|
||||
const expanded = new Set(["0.17.2"]);
|
||||
expect(sorted(toggleVersion(expanded, "0.17.1"))).toEqual(["0.17.1", "0.17.2"]);
|
||||
expect(sorted(toggleVersion(expanded, "0.17.2"))).toEqual([]);
|
||||
expect(sorted(expanded)).toEqual(["0.17.2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allVersionsExpanded and toggleAllVersions (the expand-all button)", () => {
|
||||
const entries = [entry("0.17.2"), entry("0.17.1"), entry("0.3.2")];
|
||||
|
||||
it("unfolds every version while one or more is folded", () => {
|
||||
expect(allVersionsExpanded(entries, new Set(["0.17.2"]))).toBe(false);
|
||||
expect(sorted(toggleAllVersions(entries, new Set(["0.17.2"])))).toEqual(["0.17.1", "0.17.2", "0.3.2"]);
|
||||
expect(sorted(toggleAllVersions(entries, new Set()))).toEqual(["0.17.1", "0.17.2", "0.3.2"]);
|
||||
});
|
||||
|
||||
it("folds every version once all are unfolded", () => {
|
||||
const all = new Set(["0.17.2", "0.17.1", "0.3.2"]);
|
||||
expect(allVersionsExpanded(entries, all)).toBe(true);
|
||||
expect(sorted(toggleAllVersions(entries, all))).toEqual([]);
|
||||
});
|
||||
|
||||
it("is not all unfolded without any entry", () => {
|
||||
expect(allVersionsExpanded([], new Set())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the real changelogs", () => {
|
||||
// The app bundles both files (vite.config.ts copies them to public/), and
|
||||
// getVersion() reads the version of tauri.conf.json. /release bumps that
|
||||
// version and adds its heading to both files in the same commit, so the
|
||||
// installed version is always found, in both languages.
|
||||
const installed = (
|
||||
JSON.parse(readFileSync(resolve(ROOT, "src-tauri/tauri.conf.json"), "utf-8")) as { version: string }
|
||||
).version;
|
||||
const files = {
|
||||
en: parseChangelog(readFileSync(resolve(ROOT, "CHANGELOG.md"), "utf-8")),
|
||||
fr: parseChangelog(readFileSync(resolve(ROOT, "CHANGELOG.fr.md"), "utf-8")),
|
||||
};
|
||||
|
||||
it("read the unreleased heading first, then only version numbers, with and without brackets", () => {
|
||||
for (const [lng, entries] of Object.entries(files)) {
|
||||
const [unreleased, ...published] = entries;
|
||||
expect(isPublishedVersion(unreleased.version), `${lng}: ${unreleased.version}`).toBe(false);
|
||||
const notVersions = published.filter((e) => !isPublishedVersion(e.version)).map((e) => e.version);
|
||||
expect(notVersions, lng).toEqual([]);
|
||||
// Headings without brackets (`## 0.3.2`) are read too.
|
||||
expect(published.map((e) => e.version), lng).toContain("0.3.2");
|
||||
}
|
||||
});
|
||||
|
||||
it("list the same published versions in both languages", () => {
|
||||
const published = (entries: ChangelogEntry[]) =>
|
||||
entries.filter((e) => isPublishedVersion(e.version)).map((e) => e.version);
|
||||
expect(published(files.fr)).toEqual(published(files.en));
|
||||
});
|
||||
|
||||
it("open on the installed version, in both languages", () => {
|
||||
for (const [lng, entries] of Object.entries(files)) {
|
||||
const visible = visibleChangelogEntries(entries);
|
||||
expect(sorted(initiallyExpandedVersions(visible, installed)), lng).toEqual([installed]);
|
||||
}
|
||||
});
|
||||
});
|
||||
119
src/utils/changelog.ts
Normal file
119
src/utils/changelog.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Version history of Settings → System (#372): the parser of the bundled
|
||||
// CHANGELOG files and the rule that decides which versions show their notes
|
||||
// when the history opens. Pure: no fetch, no React. `UpdateCard` reads the
|
||||
// notes of an available update on its own and does not use this module.
|
||||
|
||||
/** One `### ` heading of a version and its `- ` items. */
|
||||
export interface ChangelogSection {
|
||||
heading: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/** One `## ` heading of the changelog and its sections. */
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Keep a Changelog file, newest version first: one entry per `## `
|
||||
* heading, bracketed (`## [0.17.2] - 2026-10-18` gives `0.17.2`) or not
|
||||
* (`## 0.3.2`), each with its `### ` sections and their `- ` items. A line
|
||||
* outside of a section is ignored.
|
||||
*/
|
||||
export function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let current: ChangelogEntry | null = null;
|
||||
let currentSection: ChangelogSection | null = null;
|
||||
|
||||
for (const line of markdown.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
|
||||
if (versionMatch) {
|
||||
if (currentSection && current) current.sections.push(currentSection);
|
||||
if (current) entries.push(current);
|
||||
current = { version: versionMatch[1], sections: [] };
|
||||
currentSection = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionMatch = trimmed.match(/^### (.+)/);
|
||||
if (sectionMatch && current) {
|
||||
if (currentSection) current.sections.push(currentSection);
|
||||
currentSection = { heading: sectionMatch[1], items: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("- ") && currentSection) {
|
||||
currentSection.items.push(trimmed.slice(2));
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSection && current) current.sections.push(currentSection);
|
||||
if (current) entries.push(current);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* A released version (`0.17.2`), as opposed to the heading of what is not
|
||||
* released yet, `[Unreleased]` in English and `[Non publié]` in French. The
|
||||
* shape decides, not the label, so both languages read the same.
|
||||
*/
|
||||
export function isPublishedVersion(version: string): boolean {
|
||||
return /^\d+\.\d+/.test(version);
|
||||
}
|
||||
|
||||
/** No item under any of its sections: nothing to read under the title. */
|
||||
function hasNoItem(entry: ChangelogEntry): boolean {
|
||||
return entry.sections.every((section) => section.items.length === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries the history shows: every one, except an unreleased heading
|
||||
* with nothing under it, as right after a release. An unreleased heading
|
||||
* with content (a development build) stays, and a published version always
|
||||
* stays: the history is complete.
|
||||
*/
|
||||
export function visibleChangelogEntries(entries: ChangelogEntry[]): ChangelogEntry[] {
|
||||
return entries.filter((entry) => isPublishedVersion(entry.version) || !hasNoItem(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* The versions whose notes show when the history opens: the installed
|
||||
* version, or, when it is unknown (`null`) or absent from the changelog, the
|
||||
* first published version, the newest. Every other version starts folded.
|
||||
*/
|
||||
export function initiallyExpandedVersions(
|
||||
entries: ChangelogEntry[],
|
||||
installedVersion: string | null,
|
||||
): Set<string> {
|
||||
const installed =
|
||||
installedVersion === null ? undefined : entries.find((entry) => entry.version === installedVersion);
|
||||
const opened = installed ?? entries.find((entry) => isPublishedVersion(entry.version));
|
||||
return new Set(opened ? [opened.version] : []);
|
||||
}
|
||||
|
||||
/** `expanded` with `version` unfolded when it was folded, folded when it was unfolded. */
|
||||
export function toggleVersion(expanded: ReadonlySet<string>, version: string): Set<string> {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(version)) {
|
||||
next.delete(version);
|
||||
} else {
|
||||
next.add(version);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Every entry shows its notes: the expand-all button then folds them all. */
|
||||
export function allVersionsExpanded(entries: ChangelogEntry[], expanded: ReadonlySet<string>): boolean {
|
||||
return entries.length > 0 && entries.every((entry) => expanded.has(entry.version));
|
||||
}
|
||||
|
||||
/** The expand-all button: folds every entry when all are unfolded, unfolds them all otherwise. */
|
||||
export function toggleAllVersions(entries: ChangelogEntry[], expanded: ReadonlySet<string>): Set<string> {
|
||||
return allVersionsExpanded(entries, expanded)
|
||||
? new Set()
|
||||
: new Set(entries.map((entry) => entry.version));
|
||||
}
|
||||
Loading…
Reference in a new issue