mirror of
https://github.com/komodorio/helm-dashboard.git
synced 2026-03-26 14:28:04 +00:00
Added Error Boundary (#649)
* Added Error Boundary * Test improvements * Introduced useDevLogger * Updated Cypress to latest and aligned the tests * Added eslint-enable * Set allowCypressEnv: false for security reasons.
This commit is contained in:
@@ -11,6 +11,8 @@ import { ErrorModalContext } from "./context/ErrorModalContext";
|
||||
import GlobalErrorModal from "./components/modal/GlobalErrorModal";
|
||||
import { AppContextProvider } from "./context/AppContext";
|
||||
import apiService from "./API/apiService";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import ErrorFallback from "./components/ErrorFallback";
|
||||
|
||||
const DocsPage = lazy(() => import("./pages/DocsPage"));
|
||||
|
||||
@@ -53,25 +55,27 @@ export default function App() {
|
||||
<AppContextProvider>
|
||||
<ErrorModalContext.Provider value={value}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="docs/*" element={<DocsPage />} />
|
||||
<Route path="*" element={<PageLayout />}>
|
||||
<Route path=":context?/*" element={<SyncContext />}>
|
||||
<Route
|
||||
path="repository/:selectedRepo?/*"
|
||||
element={<RepositoryPage />}
|
||||
/>
|
||||
<Route path="installed/?" element={<Installed />} />
|
||||
<Route
|
||||
path=":namespace/:chart/installed/revision/:revision"
|
||||
element={<Revision />}
|
||||
/>
|
||||
<Route path="*" element={<Installed />} />
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="docs/*" element={<DocsPage />} />
|
||||
<Route path="*" element={<PageLayout />}>
|
||||
<Route path=":context?/*" element={<SyncContext />}>
|
||||
<Route
|
||||
path="repository/:selectedRepo?/*"
|
||||
element={<RepositoryPage />}
|
||||
/>
|
||||
<Route path="installed/?" element={<Installed />} />
|
||||
<Route
|
||||
path=":namespace/:chart/installed/revision/:revision"
|
||||
element={<Revision />}
|
||||
/>
|
||||
<Route path="*" element={<Installed />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</ErrorBoundary>
|
||||
<GlobalErrorModal
|
||||
isOpen={!!shouldShowErrorModal}
|
||||
onClose={() => setShowErrorModal(undefined)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from "cypress/react18";
|
||||
import { mount } from "cypress/react";
|
||||
import { Button } from "./common/Button/Button";
|
||||
|
||||
describe("Button component tests", () => {
|
||||
|
||||
@@ -45,6 +45,14 @@ const renderClustersList = (props: ClustersListProps) => {
|
||||
|
||||
describe("ClustersList", () => {
|
||||
it("Got one cluster information", () => {
|
||||
cy.intercept("GET", "/api/k8s/contexts", [
|
||||
{
|
||||
Name: "minikube",
|
||||
Namespace: "default",
|
||||
IsCurrent: true,
|
||||
},
|
||||
]).as("getClusters");
|
||||
|
||||
renderClustersList({
|
||||
selectedCluster: "minikube",
|
||||
filteredNamespaces: ["default"],
|
||||
@@ -52,12 +60,21 @@ describe("ClustersList", () => {
|
||||
installedReleases: [generateTestReleaseData()],
|
||||
});
|
||||
|
||||
cy.wait("@getClusters");
|
||||
cy.get(".data-cy-clusterName").contains("minikube");
|
||||
cy.get(".data-cy-clusterList-namespace").contains("default");
|
||||
cy.get(".data-cy-clustersInput").should("be.checked");
|
||||
});
|
||||
|
||||
it("Dont have a cluster chekced", () => {
|
||||
cy.intercept("GET", "/api/k8s/contexts", [
|
||||
{
|
||||
Name: "minikube",
|
||||
Namespace: "default",
|
||||
IsCurrent: true,
|
||||
},
|
||||
]).as("getClusters");
|
||||
|
||||
renderClustersList({
|
||||
selectedCluster: "",
|
||||
filteredNamespaces: [""],
|
||||
@@ -65,6 +82,7 @@ describe("ClustersList", () => {
|
||||
installedReleases: [generateTestReleaseData()],
|
||||
});
|
||||
|
||||
cy.wait("@getClusters");
|
||||
cy.get(".data-cy-clustersInput").should("not.be.checked");
|
||||
});
|
||||
});
|
||||
|
||||
113
frontend/src/components/ErrorFallback/ErrorFallback.cy.tsx
Normal file
113
frontend/src/components/ErrorFallback/ErrorFallback.cy.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import ErrorFallback from "./ErrorFallback";
|
||||
import { mount } from "cypress/react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Component tests for ErrorFallback
|
||||
* Tests the error fallback UI and reset functionality
|
||||
*/
|
||||
describe("ErrorFallback", () => {
|
||||
beforeEach(() => {
|
||||
// Ensure portal root exists for createPortal
|
||||
if (!document.getElementById("portal")) {
|
||||
const portalDiv = document.createElement("div");
|
||||
portalDiv.id = "portal";
|
||||
document.body.appendChild(portalDiv);
|
||||
}
|
||||
});
|
||||
|
||||
it("should render error modal with error message and hint", () => {
|
||||
const mockError = new Error("Test error message");
|
||||
const mockReset = cy.stub().as("resetErrorBoundary");
|
||||
|
||||
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||
|
||||
// Verify modal is open (checking document directly because of portal)
|
||||
cy.get("#portal").should("be.visible");
|
||||
cy.get("#portal").should("contain", "Application Error");
|
||||
cy.get("#portal").should("contain", "Test error message");
|
||||
|
||||
// Verify Komodor hint is present (from GlobalErrorModal)
|
||||
cy.get("#portal").should("contain", "Sign up for free.");
|
||||
cy.get("#portal a")
|
||||
.should("have.attr", "href")
|
||||
.and("include", "komodor.com");
|
||||
});
|
||||
|
||||
it("should call resetErrorBoundary when modal is closed", () => {
|
||||
const mockError = new Error("Test error");
|
||||
const mockReset = cy.stub().as("resetErrorBoundary");
|
||||
|
||||
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||
|
||||
// Find and click close button (using the selector from Modal.tsx)
|
||||
cy.get("[data-modal-hide='staticModal']").click();
|
||||
|
||||
// Verify reset was called
|
||||
cy.get("@resetErrorBoundary").should("have.been.calledOnce");
|
||||
});
|
||||
|
||||
it("should handle non-Error objects gracefully", () => {
|
||||
const mockError = "String error" as unknown as Error;
|
||||
const mockReset = cy.stub().as("resetErrorBoundary");
|
||||
|
||||
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||
|
||||
// Should show fallback message
|
||||
cy.get("#portal").should(
|
||||
"contain",
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error in development mode", () => {
|
||||
const mockError = new Error("Test error for logging");
|
||||
const mockReset = cy.stub();
|
||||
|
||||
cy.window().then((win) => {
|
||||
cy.spy(win.console, "error").as("consoleError");
|
||||
});
|
||||
|
||||
mount(<ErrorFallback error={mockError} resetErrorBoundary={mockReset} />);
|
||||
|
||||
// In dev mode, error should be logged
|
||||
cy.get("@consoleError").should("have.been.called");
|
||||
});
|
||||
|
||||
it("should catch errors from a real component and recover after reset (Integration)", () => {
|
||||
const BuggyComponent = ({ shouldCrash }: { shouldCrash: boolean }) => {
|
||||
if (shouldCrash) {
|
||||
throw new Error("Integrated crash");
|
||||
}
|
||||
return <div data-cy="recovered">Recovered successfully!</div>;
|
||||
};
|
||||
|
||||
const TestWrapper = () => {
|
||||
const [shouldCrash, setShouldCrash] = useState(true);
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={ErrorFallback}
|
||||
onReset={() => setShouldCrash(false)}
|
||||
>
|
||||
<BuggyComponent shouldCrash={shouldCrash} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
mount(<TestWrapper />);
|
||||
|
||||
// Verify modal caught the real throw
|
||||
cy.get("#portal").should("be.visible").and("not.be.empty");
|
||||
cy.get("#portal").should("contain", "Integrated crash");
|
||||
|
||||
// Click close to reset
|
||||
cy.get("[data-modal-hide='staticModal']").click();
|
||||
|
||||
// Verify modal is gone (portal should be empty) and component recovered
|
||||
cy.get("#portal").should("be.empty");
|
||||
cy.get("[data-cy='recovered']")
|
||||
.should("be.visible")
|
||||
.and("contain", "Recovered successfully!");
|
||||
});
|
||||
});
|
||||
116
frontend/src/components/ErrorFallback/ErrorFallback.stories.tsx
Normal file
116
frontend/src/components/ErrorFallback/ErrorFallback.stories.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import ErrorFallback from "./ErrorFallback";
|
||||
import { useState } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import Button from "../Button";
|
||||
|
||||
const meta = {
|
||||
title: "Components/ErrorFallback",
|
||||
component: ErrorFallback,
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
|
||||
layout: "centered",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
resetErrorBoundary: { action: "reset" },
|
||||
},
|
||||
} satisfies Meta<typeof ErrorFallback>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* Default error fallback with a standard error message
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
error: new Error("Something went wrong in the application"),
|
||||
resetErrorBoundary: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractiveIntegration: Story = {
|
||||
args: {
|
||||
error: new Error("Interactive Demo Error"),
|
||||
resetErrorBoundary: () => {},
|
||||
},
|
||||
render: (args) => {
|
||||
const BuggyComponent = () => {
|
||||
const [shouldError, setShouldError] = useState(false);
|
||||
|
||||
if (shouldError) {
|
||||
throw new Error(
|
||||
"This is a real runtime error caught by the ErrorBoundary!"
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-96 rounded border bg-white p-8 text-center shadow-md">
|
||||
<h3 className="mb-4 text-lg font-bold">Interactive Demo</h3>
|
||||
<p className="mb-6 text-sm text-gray-600">
|
||||
Clicking the button below will cause this component to throw a
|
||||
runtime error during render.
|
||||
</p>
|
||||
<Button onClick={() => setShouldError(true)}>
|
||||
Trigger Runtime Error
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={(props) => (
|
||||
<ErrorFallback
|
||||
{...props}
|
||||
resetErrorBoundary={() => {
|
||||
props.resetErrorBoundary();
|
||||
args.resetErrorBoundary();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BuggyComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Long error message to test text wrapping
|
||||
*/
|
||||
export const LongErrorMessage: Story = {
|
||||
args: {
|
||||
error: new Error(
|
||||
"This is a very long error message that should demonstrate how the error modal handles text wrapping and displays lengthy error descriptions to the user. The error boundary should gracefully handle this scenario."
|
||||
),
|
||||
resetErrorBoundary: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-Error object to test fallback behavior
|
||||
*/
|
||||
export const NonErrorObject: Story = {
|
||||
args: {
|
||||
error: "String error message" as unknown as Error,
|
||||
resetErrorBoundary: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Error with stack trace (useful for development)
|
||||
*/
|
||||
export const WithStackTrace: Story = {
|
||||
args: {
|
||||
error: (() => {
|
||||
try {
|
||||
throw new Error("Error with detailed stack trace");
|
||||
} catch (e) {
|
||||
return e as Error;
|
||||
}
|
||||
})(),
|
||||
resetErrorBoundary: () => {},
|
||||
},
|
||||
};
|
||||
33
frontend/src/components/ErrorFallback/ErrorFallback.tsx
Normal file
33
frontend/src/components/ErrorFallback/ErrorFallback.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import GlobalErrorModal from "../modal/GlobalErrorModal";
|
||||
import { useDevLogger } from "../../hooks/useDevLogger";
|
||||
|
||||
/**
|
||||
* Error fallback component for React Error Boundary
|
||||
* Uses the existing GlobalErrorModal for consistent error display
|
||||
* @param error - The error that was caught
|
||||
* @param resetErrorBoundary - Function to reset the error boundary state
|
||||
*/
|
||||
const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||
useDevLogger(error);
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset the error boundary to allow the component tree to re-render
|
||||
resetErrorBoundary();
|
||||
};
|
||||
|
||||
return (
|
||||
<GlobalErrorModal
|
||||
isOpen={true}
|
||||
onClose={handleClose}
|
||||
titleText="Application Error"
|
||||
contentText={
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred. Please try again."
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorFallback;
|
||||
1
frontend/src/components/ErrorFallback/index.ts
Normal file
1
frontend/src/components/ErrorFallback/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./ErrorFallback";
|
||||
9
frontend/src/hooks/useDevLogger.ts
Normal file
9
frontend/src/hooks/useDevLogger.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const useDevLogger = (error: unknown) => {
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error("UnexpectedError", error);
|
||||
}
|
||||
}, [error]);
|
||||
};
|
||||
Reference in New Issue
Block a user