mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
fix: render node-update changelog notes as formatted markdown (#1474)
The Changelog tab in the Node Updates sheet rendered GitHub release notes as raw markdown inside a preformatted block, so headings, bullet lists, and issue/commit links showed as literal markup and were hard to read. Render the notes with a small react-markdown wrapper styled to the design tokens (raw HTML is intentionally not rendered, keeping it safe). Also: - Replace the bare loading spinner with a content-shaped skeleton. - Add a graceful empty state when no notes are available, with a link to the online changelog. - Add a "View on Sencho" link next to "View on GitHub". - Stop the release-notes fetch from re-firing on every failure by tracking whether a fetch has settled, so a null result lands on the empty state instead of looping; Recheck resets it to force a fresh fetch.
This commit is contained in:
Generated
+1460
-34
File diff suppressed because it is too large
Load Diff
@@ -54,8 +54,10 @@
|
|||||||
"react-day-picker": "^10.0.1",
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-is": "^19.2.7",
|
"react-is": "^19.2.7",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
"recharts": "^3.9.0",
|
"recharts": "^3.9.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
|
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
|
||||||
Download, RefreshCw, Monitor, Globe, ExternalLink, Ban,
|
Download, RefreshCw, Monitor, Globe, ExternalLink, Ban, ScrollText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MarkdownContent } from '@/components/ui/MarkdownContent';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { formatVersion, isValidVersion } from '@/lib/version';
|
import { formatVersion, isValidVersion } from '@/lib/version';
|
||||||
@@ -43,6 +45,9 @@ export function NodeUpdatesSheet({
|
|||||||
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
||||||
const [releaseHtmlUrl, setReleaseHtmlUrl] = useState<string | null>(null);
|
const [releaseHtmlUrl, setReleaseHtmlUrl] = useState<string | null>(null);
|
||||||
const [loadingRelease, setLoadingRelease] = useState(false);
|
const [loadingRelease, setLoadingRelease] = useState(false);
|
||||||
|
// Tracks whether a release-notes fetch has settled (success or failure), so a
|
||||||
|
// null result lands on the empty state instead of re-triggering the effect.
|
||||||
|
const [releaseLoaded, setReleaseLoaded] = useState(false);
|
||||||
const [hasSeenChangelog, setHasSeenChangelog] = useState(false);
|
const [hasSeenChangelog, setHasSeenChangelog] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,7 +58,7 @@ export function NodeUpdatesSheet({
|
|||||||
// release regardless of update availability). Pass recheck when the user
|
// release regardless of update availability). Pass recheck when the user
|
||||||
// forced a version recheck so the changelog stays in sync.
|
// forced a version recheck so the changelog stays in sync.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && releaseNotes === null && !loadingRelease) {
|
if (open && !releaseLoaded && !loadingRelease) {
|
||||||
setLoadingRelease(true);
|
setLoadingRelease(true);
|
||||||
const recheck = recheckingUpdates ? '?recheck=true' : '';
|
const recheck = recheckingUpdates ? '?recheck=true' : '';
|
||||||
apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true })
|
apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true })
|
||||||
@@ -64,10 +69,18 @@ export function NodeUpdatesSheet({
|
|||||||
setReleaseHtmlUrl(data.htmlUrl);
|
setReleaseHtmlUrl(data.htmlUrl);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* silent */ })
|
.catch((err) => {
|
||||||
.finally(() => setLoadingRelease(false));
|
// Informational panel: a failure falls through to the empty
|
||||||
|
// state (with an online changelog link) rather than a toast,
|
||||||
|
// but leave a breadcrumb so the failure is diagnosable.
|
||||||
|
console.warn('[Fleet] Release-notes fetch failed:', err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingRelease(false);
|
||||||
|
setReleaseLoaded(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [open, releaseNotes, loadingRelease, recheckingUpdates]);
|
}, [open, releaseLoaded, loadingRelease, recheckingUpdates]);
|
||||||
|
|
||||||
// Clear the changelog dot when user opens that tab.
|
// Clear the changelog dot when user opens that tab.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,7 +100,10 @@ export function NodeUpdatesSheet({
|
|||||||
|
|
||||||
const handleRecheck = async () => {
|
const handleRecheck = async () => {
|
||||||
setRecheckingUpdates(true);
|
setRecheckingUpdates(true);
|
||||||
setReleaseNotes(null); // force re-fetch with fresh release notes
|
// Force a fresh release-notes fetch with the rechecked version.
|
||||||
|
setReleaseNotes(null);
|
||||||
|
setReleaseHtmlUrl(null);
|
||||||
|
setReleaseLoaded(false);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -195,6 +211,17 @@ export function NodeUpdatesSheet({
|
|||||||
{ id: 'changelog', label: 'Changelog', dot: showChangelogDot },
|
{ id: 'changelog', label: 'Changelog', dot: showChangelogDot },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const senchoChangelogLink = (
|
||||||
|
<a
|
||||||
|
href="https://sencho.io/changelog"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
|
||||||
|
>
|
||||||
|
View on Sencho <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SystemSheet
|
<SystemSheet
|
||||||
open={open}
|
open={open}
|
||||||
@@ -227,28 +254,45 @@ export function NodeUpdatesSheet({
|
|||||||
) : activeTab === 'changelog' ? (
|
) : activeTab === 'changelog' ? (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
|
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||||
{loadingRelease ? (
|
{loadingRelease ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="space-y-3">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
<Skeleton className="h-5 w-2/5" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
<Skeleton className="h-3 w-11/12" />
|
||||||
|
<Skeleton className="h-3 w-4/5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 pt-3">
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : releaseNotes ? (
|
) : releaseNotes ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<pre className="whitespace-pre-wrap text-sm font-sans text-stat-value leading-relaxed">
|
<MarkdownContent>{releaseNotes}</MarkdownContent>
|
||||||
{releaseNotes}
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-card-border/40 pt-3">
|
||||||
</pre>
|
{releaseHtmlUrl && (
|
||||||
{releaseHtmlUrl && (
|
<a
|
||||||
<a
|
href={releaseHtmlUrl}
|
||||||
href={releaseHtmlUrl}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
|
||||||
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
|
>
|
||||||
>
|
View on GitHub <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||||
View on GitHub <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
</a>
|
||||||
</a>
|
)}
|
||||||
)}
|
{senchoChangelogLink}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
|
||||||
Release notes could not be loaded.
|
<ScrollText className="w-8 h-8 text-muted-foreground/40" strokeWidth={1.25} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-stat-value">No release notes to show</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Read the full changelog online.</p>
|
||||||
|
</div>
|
||||||
|
{senchoChangelogLink}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,64 @@ describe('NodeUpdatesSheet', () => {
|
|||||||
expect(screen.getByText('Db')).toBeInTheDocument();
|
expect(screen.getByText('Db')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the changelog release notes as formatted markdown, not raw text', async () => {
|
||||||
|
apiFetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
releaseNotes: '## [0.92.0](https://example.com/compare) (2026-06-19)\n\n### Added\n\n* add a toggle ([#1363](https://example.com/issues/1363))\n',
|
||||||
|
htmlUrl: 'https://example.com/releases/v0.92.0',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
render(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
|
||||||
|
// The "### Added" section becomes a real heading element, not literal text.
|
||||||
|
const heading = await screen.findByRole('heading', { name: 'Added' });
|
||||||
|
expect(heading.tagName).toBe('H3');
|
||||||
|
// The issue reference becomes a link to GitHub, not raw "[#1363](...)".
|
||||||
|
const link = screen.getByRole('link', { name: '#1363' });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://example.com/issues/1363');
|
||||||
|
expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
// No markdown markers leak through as visible text.
|
||||||
|
expect(screen.queryByText(/### Added/)).not.toBeInTheDocument();
|
||||||
|
// Both external changelog links render.
|
||||||
|
expect(screen.getByRole('link', { name: /View on GitHub/ })).toHaveAttribute('href', 'https://example.com/releases/v0.92.0');
|
||||||
|
expect(screen.getByRole('link', { name: /View on Sencho/ })).toHaveAttribute('href', 'https://sencho.io/changelog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles on a graceful empty state with a Sencho link when no notes are returned', async () => {
|
||||||
|
apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ releaseNotes: null, htmlUrl: null }) });
|
||||||
|
render(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
|
||||||
|
// The fetch settles (no perpetual spinner) and shows the empty message.
|
||||||
|
expect(await screen.findByText('No release notes to show')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /View on Sencho/ })).toHaveAttribute('href', 'https://sencho.io/changelog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches release notes once and does not refetch on re-render when notes are null', async () => {
|
||||||
|
apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ releaseNotes: null, htmlUrl: null }) });
|
||||||
|
const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length;
|
||||||
|
const { rerender } = render(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
|
||||||
|
await screen.findByText('No release notes to show');
|
||||||
|
expect(releaseCalls()).toBe(1);
|
||||||
|
rerender(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
|
||||||
|
// The releaseLoaded latch keeps a null result from re-triggering the fetch.
|
||||||
|
await waitFor(() => expect(releaseCalls()).toBe(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Recheck resets and refetches release notes with the recheck flag', async () => {
|
||||||
|
apiFetchMock.mockImplementation((url: string) =>
|
||||||
|
String(url).includes('release-notes')
|
||||||
|
? Promise.resolve({ ok: true, json: async () => ({ releaseNotes: '## v1', htmlUrl: null }) })
|
||||||
|
: Promise.resolve({ ok: true, json: async () => ({ rechecked: true }) }),
|
||||||
|
);
|
||||||
|
render(<NodeUpdatesSheet {...baseProps({ isAdmin: true, initialTab: 'changelog' })} />);
|
||||||
|
await screen.findByRole('heading', { name: 'v1' });
|
||||||
|
apiFetchMock.mockClear();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Recheck' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const call = apiFetchMock.mock.calls.find(c => String(c[0]).includes('release-notes'));
|
||||||
|
expect(call?.[0]).toBe('/fleet/update-status/release-notes?recheck=true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a checking spinner state', () => {
|
it('shows a checking spinner state', () => {
|
||||||
render(<NodeUpdatesSheet {...baseProps({ checkingUpdates: true })} />);
|
render(<NodeUpdatesSheet {...baseProps({ checkingUpdates: true })} />);
|
||||||
expect(screen.getByText('Checking for updates...')).toBeInTheDocument();
|
expect(screen.getByText('Checking for updates...')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Markdown, { type Components } from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a markdown string (e.g. GitHub release notes) as styled React
|
||||||
|
* elements. Raw HTML in the source is intentionally not rendered, so the
|
||||||
|
* output is safe from injected markup.
|
||||||
|
*/
|
||||||
|
const components: Components = {
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-base font-semibold text-stat-value mt-5 first:mt-0 mb-2">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-sm font-semibold text-stat-value mt-5 first:mt-0 mb-2">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-stat-subtitle mt-4 first:mt-0 mb-1.5">{children}</h3>
|
||||||
|
),
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="text-sm text-stat-value leading-relaxed my-2 first:mt-0 last:mb-0">{children}</p>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc pl-5 space-y-1 my-2 text-sm text-stat-value">{children}</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal pl-5 space-y-1 my-2 text-sm text-stat-value">{children}</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-brand hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
code: ({ children }) => (
|
||||||
|
<code className="font-mono text-[0.8125rem] rounded bg-card-border/40 px-1 py-0.5 text-stat-value">{children}</code>
|
||||||
|
),
|
||||||
|
pre: ({ children }) => (
|
||||||
|
// The fenced block owns its frame; neutralize the inline-code pill on the
|
||||||
|
// inner <code> so it does not paint a background inside the block.
|
||||||
|
<pre className="font-mono text-xs rounded-md border border-card-border bg-card-border/20 p-3 overflow-x-auto my-2 [&>code]:bg-transparent [&>code]:p-0 [&>code]:rounded-none">{children}</pre>
|
||||||
|
),
|
||||||
|
strong: ({ children }) => <strong className="font-semibold text-stat-value">{children}</strong>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="border-l-2 border-card-border pl-3 text-stat-subtitle italic my-2">{children}</blockquote>
|
||||||
|
),
|
||||||
|
hr: () => <hr className="border-card-border/60 my-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MarkdownContent({ children, className }: { children: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('break-words', className)}>
|
||||||
|
<Markdown remarkPlugins={[remarkGfm]} components={components}>
|
||||||
|
{children}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { MarkdownContent } from '../MarkdownContent';
|
||||||
|
|
||||||
|
describe('MarkdownContent', () => {
|
||||||
|
it('does not render raw HTML from the source as live markup', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownContent>{'Hello <img src=x onerror="alert(1)"> <script>alert(2)</script> world'}</MarkdownContent>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('img')).toBeNull();
|
||||||
|
expect(container.querySelector('script')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders standard markdown structures', () => {
|
||||||
|
const { container, getByRole } = render(
|
||||||
|
<MarkdownContent>{'# Title\n\n* one\n* two\n\n[link](https://example.com)'}</MarkdownContent>,
|
||||||
|
);
|
||||||
|
expect(getByRole('heading', { name: 'Title' })).toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll('li')).toHaveLength(2);
|
||||||
|
expect(getByRole('link', { name: 'link' })).toHaveAttribute('href', 'https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user