diff --git a/client/src/components/Admin/GitHubPanel.test.tsx b/client/src/components/Admin/GitHubPanel.test.tsx
index c1f20094e..7bc8bb211 100644
--- a/client/src/components/Admin/GitHubPanel.test.tsx
+++ b/client/src/components/Admin/GitHubPanel.test.tsx
@@ -1,4 +1,4 @@
-// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
+// FE-ADMIN-GH-001 to FE-ADMIN-GH-017
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -333,4 +333,31 @@ describe('GitHubPanel', () => {
// Load more should be hidden (PAGE_2 < 10)
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
+
+ it('FE-ADMIN-GH-017: a failed "Load more" keeps the list and the retry loads the page', async () => {
+ let failPage2 = true;
+ server.use(
+ http.get('/api/admin/github-releases', ({ request }) => {
+ const page = new URL(request.url).searchParams.get('page');
+ if (page === '2') {
+ if (failPage2) return HttpResponse.json({ error: 'rate limited' }, { status: 500 });
+ return HttpResponse.json(PAGE_2);
+ }
+ return HttpResponse.json(PAGE_1);
+ }),
+ );
+ const user = userEvent.setup();
+ render();
+ await screen.findByText('v1.0.0');
+
+ await user.click(screen.getByText('Load more'));
+ await screen.findByText(/Failed to load releases/);
+ expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
+
+ failPage2 = false;
+ await user.click(screen.getByText('Load more'));
+ await screen.findByText('v0.0.0');
+ expect(screen.getAllByText(/^v[01]\.\d\.0$/).length).toBe(15);
+ expect(screen.queryByText(/Failed to load releases/)).not.toBeInTheDocument();
+ });
});
diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx
index 9d64967ed..2657f5d34 100644
--- a/client/src/components/Admin/GitHubPanel.tsx
+++ b/client/src/components/Admin/GitHubPanel.tsx
@@ -69,7 +69,7 @@ function SupportCard({ link }: { link: SupportLink }) {
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t } = useTranslation();
const {
- shownReleases: shown, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate,
+ releases, shownReleases: shown, loading, error, expanded, toggleExpand, hasMore, loadingMore, handleLoadMore, formatDate,
} = useGithubReleases({ isPrerelease });
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
@@ -183,7 +183,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
);
- } else if (error) {
+ } else if (error && releases.length === 0) {
releasesCard = ;
} else {
releasesCard = (
@@ -276,6 +276,13 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
+ {/* A failed "Load more" keeps the timeline and stays retryable */}
+ {error && (
+
+ {t('admin.github.error')}: {error}
+
+ )}
+
{/* Load more */}
{hasMore && (
diff --git a/client/src/components/Admin/useGithubReleases.test.ts b/client/src/components/Admin/useGithubReleases.test.ts
index 32b9ce2b8..09abd05c2 100644
--- a/client/src/components/Admin/useGithubReleases.test.ts
+++ b/client/src/components/Admin/useGithubReleases.test.ts
@@ -1,4 +1,4 @@
-// FE-COMP-GHREL-HOOK-001 to -011: the release history behind both admin shells.
+// FE-COMP-GHREL-HOOK-001 to -012: the release history behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import apiClient from '../../api/client';
@@ -76,7 +76,7 @@ describe('useGithubReleases, one page per load', () => {
expect(result.current.loadingMore).toBe(false);
});
- it('FE-COMP-GHREL-HOOK-004: a failed load more sets the error and still moves the page on', async () => {
+ it('FE-COMP-GHREL-HOOK-004: a failed load more sets the error and holds the page', async () => {
const spy = serve({ 1: fullPage(1), 2: new Error('rate limited'), 3: [release(21)] });
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
await waitFor(() => expect(result.current.loading).toBe(false));
@@ -87,10 +87,37 @@ describe('useGithubReleases, one page per load', () => {
await act(async () => {
await result.current.handleLoadMore();
});
- expect(requestedPages(spy)).toEqual([1, 2, 3]);
+ expect(requestedPages(spy)).toEqual([1, 2, 2]);
expect(result.current.error).toBe('rate limited');
});
+ it('FE-COMP-GHREL-HOOK-012: a failed load more keeps the list and the retry clears the error', async () => {
+ let failPage2 = true;
+ const spy = vi
+ .spyOn(apiClient, 'get')
+ .mockImplementation(async (_url: string, config?: { params?: { page?: number } }) => {
+ const page = config?.params?.page;
+ if (page === 1) return { data: fullPage(1) };
+ if (failPage2) throw new Error('offline');
+ return { data: [release(11)] };
+ });
+ const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ await act(async () => {
+ await result.current.handleLoadMore();
+ });
+ expect(result.current.error).toBe('offline');
+ expect(result.current.releases).toHaveLength(10);
+ failPage2 = false;
+ await act(async () => {
+ await result.current.handleLoadMore();
+ });
+ expect(requestedPages(spy)).toEqual([1, 2, 2]);
+ expect(result.current.error).toBeNull();
+ expect(result.current.releases).toHaveLength(11);
+ expect(result.current.hasMore).toBe(false);
+ });
+
it('FE-COMP-GHREL-HOOK-005: a first page of only prereleases is all a stable install gets', async () => {
const spy = serve({ 1: fullPage(1, true), 2: [release(11)] });
const { result } = renderHook(() => useGithubReleases({ isPrerelease: false }));
diff --git a/client/src/components/Admin/useGithubReleases.ts b/client/src/components/Admin/useGithubReleases.ts
index 9f8ff82a8..b83c0f36a 100644
--- a/client/src/components/Admin/useGithubReleases.ts
+++ b/client/src/components/Admin/useGithubReleases.ts
@@ -23,9 +23,9 @@ interface UseGithubReleasesOptions {
isPrerelease: boolean;
/**
* Keep pulling pages until at least one release survives the prerelease filter, up to
- * MAX_PAGES_PER_LOAD per load. A failed page then keeps what is already shown and
- * leaves the page counter where it was, and the next good load clears the error.
- * Without it each load reads exactly one page and the page counter moves on regardless.
+ * MAX_PAGES_PER_LOAD per load. Without it each load reads exactly one page. Either way
+ * a failed page keeps what is already shown and leaves the page counter where it was,
+ * and the next good load clears the error.
*/
fillPages?: boolean;
}
@@ -59,9 +59,11 @@ export function useGithubReleases({ isPrerelease, fillPages = false }: UseGithub
const fetchReleases = async (pageNum = 1, append = false) => {
const data = await fetchPage(pageNum);
- if (!data) return;
+ if (!data) return false;
setReleases((prev) => (append ? [...prev, ...data] : data));
setHasMore(data.length === PER_PAGE);
+ setError(null);
+ return true;
};
// Keep pulling pages until at least one release survives the prerelease filter,
@@ -102,8 +104,7 @@ export function useGithubReleases({ isPrerelease, fillPages = false }: UseGithub
}
const next = page + 1;
setLoadingMore(true);
- await fetchReleases(next, true);
- setPage(next);
+ if (await fetchReleases(next, true)) setPage(next);
setLoadingMore(false);
};