Fix SetupGuard error handling and add retry UI
This commit is contained in:
@@ -89,7 +89,9 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TASK-005 — `SetupGuard` redirects to `/setup` when the backend is temporarily unreachable
|
### TASK-005 — `SetupGuard` redirects to `/setup` when the backend is temporarily unreachable (done)
|
||||||
|
|
||||||
|
**Where fixed:** `frontend/src/components/SetupGuard.tsx`
|
||||||
|
|
||||||
**Where found:** `frontend/src/components/SetupGuard.tsx`. When `useSetup` returns `{ loading: false, status: null }` due to a network error, the guard treats this the same as "setup not completed" and redirects to `/setup`.
|
**Where found:** `frontend/src/components/SetupGuard.tsx`. When `useSetup` returns `{ loading: false, status: null }` due to a network error, the guard treats this the same as "setup not completed" and redirects to `/setup`.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { Spinner, makeStyles } from "@fluentui/react-components";
|
import {
|
||||||
|
Button,
|
||||||
|
MessageBar,
|
||||||
|
MessageBarBody,
|
||||||
|
Spinner,
|
||||||
|
makeStyles,
|
||||||
|
tokens,
|
||||||
|
} from "@fluentui/react-components";
|
||||||
import { useSetup } from "../hooks/useSetup";
|
import { useSetup } from "../hooks/useSetup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +29,7 @@ interface SetupGuardProps {
|
|||||||
/**
|
/**
|
||||||
* Render `children` only when setup has been completed.
|
* Render `children` only when setup has been completed.
|
||||||
*
|
*
|
||||||
* Redirects to `/setup` if setup is still pending.
|
* Redirects to `/setup` only when setup status is known and incomplete.
|
||||||
*/
|
*/
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
loadingWrapper: {
|
loadingWrapper: {
|
||||||
@@ -31,11 +38,30 @@ const useStyles = makeStyles({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
},
|
},
|
||||||
|
errorContainer: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
padding: tokens.spacingHorizontalM,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground2,
|
||||||
|
},
|
||||||
|
errorCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "520px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: tokens.spacingVerticalM,
|
||||||
|
padding: tokens.spacingVerticalL,
|
||||||
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
|
borderRadius: tokens.borderRadiusXLarge,
|
||||||
|
boxShadow: tokens.shadow8,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { status, loading } = useSetup();
|
const { status, loading, error, refresh } = useSetup();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +71,21 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error && status === null) {
|
||||||
|
return (
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<div className={styles.errorCard}>
|
||||||
|
<MessageBar intent="error">
|
||||||
|
<MessageBarBody>{error}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
<Button appearance="primary" onClick={() => void refresh()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!status?.completed) {
|
if (!status?.completed) {
|
||||||
return <Navigate to="/setup" replace />;
|
return <Navigate to="/setup" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
import { SetupGuard } from "../SetupGuard";
|
import { SetupGuard } from "../SetupGuard";
|
||||||
@@ -65,13 +65,32 @@ describe("SetupGuard", () => {
|
|||||||
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects to /setup when the API call fails", async () => {
|
it("renders an error card when the setup status check fails", async () => {
|
||||||
// Task 0.3: a failed check must redirect to /setup, not allow through.
|
|
||||||
mockedGetSetupStatus.mockRejectedValue(new Error("Network error"));
|
mockedGetSetupStatus.mockRejectedValue(new Error("Network error"));
|
||||||
renderGuard();
|
renderGuard();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("setup-page")).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("protected-content")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("setup-page")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries setup status fetch when Retry is clicked", async () => {
|
||||||
|
mockedGetSetupStatus.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
mockedGetSetupStatus.mockResolvedValueOnce({ completed: true });
|
||||||
|
|
||||||
|
renderGuard();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("protected-content")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user