From b55cc496e61eda9f11d64b561d85070c81a1543d Mon Sep 17 00:00:00 2001 From: Harbduls Date: Tue, 2 Jun 2026 13:48:21 +0100 Subject: [PATCH] feat: show detailed GraphQL error messages to users --- .../__tests__/graphql-error-display.test.tsx | 559 ++++++++++++++++++ .../components/ui/GraphQLErrorDisplay.tsx | 235 ++++++++ soroscan-frontend/lib/graphql-error-parser.ts | 261 ++++++++ soroscan-frontend/lib/hooks/useGraphQL.ts | 30 +- 4 files changed, 1081 insertions(+), 4 deletions(-) create mode 100644 soroscan-frontend/__tests__/graphql-error-display.test.tsx create mode 100644 soroscan-frontend/components/ui/GraphQLErrorDisplay.tsx create mode 100644 soroscan-frontend/lib/graphql-error-parser.ts diff --git a/soroscan-frontend/__tests__/graphql-error-display.test.tsx b/soroscan-frontend/__tests__/graphql-error-display.test.tsx new file mode 100644 index 000000000..220dd8d1c --- /dev/null +++ b/soroscan-frontend/__tests__/graphql-error-display.test.tsx @@ -0,0 +1,559 @@ +/** + * Tests for the GraphQL error parsing utility and the GraphQLErrorDisplay component. + * + * Coverage matrix: + * Parser — validation error, BAD_USER_INPUT, parse/syntax error, + * network error, unauthenticated, rate-limited, multiple errors, + * unknown/generic errors, field_errors extension, null safety. + * Display — inline / banner / toast variants, suggestion text, details expand, + * dismiss behaviour, multi-error list, accessible roles & attributes. + */ + +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { + parseGraphQLError, + parseSingleGraphQLError, + parseGraphQLErrors, +} from "../lib/graphql-error-parser"; +import { GraphQLErrorDisplay, GraphQLErrorList } from "../components/ui/GraphQLErrorDisplay"; +import type { ApolloError } from "@apollo/client"; +import type { GraphQLError } from "graphql"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Build a minimal GraphQLError-like object. */ +function makeGQLError( + message: string, + extensions?: Record, + path?: string[] +): GraphQLError { + return { + message, + extensions: extensions ?? {}, + path, + locations: undefined, + nodes: undefined, + source: undefined, + positions: undefined, + originalError: null, + name: "GraphQLError", + [Symbol.toStringTag]: "GraphQLError", + toJSON: () => ({ message }), + } as unknown as GraphQLError; +} + +/** Build a minimal ApolloError-like object. */ +function makeApolloError( + graphQLErrors: GraphQLError[] = [], + networkError: Error | null = null +): ApolloError { + return { + message: graphQLErrors[0]?.message ?? networkError?.message ?? "Unknown", + graphQLErrors, + networkError, + clientErrors: [], + protocolErrors: [], + extraInfo: undefined, + name: "ApolloError", + } as unknown as ApolloError; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION 1 — parseGraphQLError (utility) +// ───────────────────────────────────────────────────────────────────────────── + +describe("parseGraphQLError — utility", () => { + describe("authentication errors", () => { + it("maps UNAUTHENTICATED extension code", () => { + const err = makeApolloError([ + makeGQLError("Not authenticated", { code: "UNAUTHENTICATED" }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("UNAUTHENTICATED"); + expect(parsed.message).toMatch(/session has expired/i); + expect(parsed.suggestion).toMatch(/sign in/i); + }); + + it("maps 401 HTTP network error to UNAUTHENTICATED", () => { + const netErr = Object.assign(new Error("Unauthorized"), { statusCode: 401 }); + const err = makeApolloError([], netErr); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("UNAUTHENTICATED"); + }); + }); + + describe("authorisation errors", () => { + it("maps FORBIDDEN extension code", () => { + const err = makeApolloError([ + makeGQLError("Access denied", { code: "FORBIDDEN" }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("FORBIDDEN"); + expect(parsed.message).toMatch(/permission/i); + expect(parsed.suggestion).toMatch(/administrator/i); + }); + + it("maps 403 HTTP network error to FORBIDDEN", () => { + const netErr = Object.assign(new Error("Forbidden"), { statusCode: 403 }); + const err = makeApolloError([], netErr); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("FORBIDDEN"); + }); + + it("uses message heuristic for permission denied", () => { + const err = makeApolloError([ + makeGQLError("You do not have permission denied to perform this action"), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("FORBIDDEN"); + }); + }); + + describe("validation / bad input errors", () => { + it("maps BAD_USER_INPUT extension code", () => { + const err = makeApolloError([ + makeGQLError("Bad input", { code: "BAD_USER_INPUT" }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("BAD_USER_INPUT"); + expect(parsed.message).toMatch(/invalid data/i); + expect(parsed.suggestion).toMatch(/highlighted fields/i); + }); + + it("maps VALIDATION_FAILED extension code", () => { + const err = makeApolloError([ + makeGQLError("Validation failed", { code: "VALIDATION_FAILED" }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("VALIDATION_FAILED"); + expect(parsed.message).toMatch(/validation/i); + }); + + it("extracts field_errors from extensions into details", () => { + const err = makeApolloError([ + makeGQLError("Validation failed", { + code: "BAD_USER_INPUT", + field_errors: { + email: ["Enter a valid email address."], + password: ["This field may not be blank."], + }, + }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.details).toContain("email"); + expect(parsed.details).toContain("password"); + }); + + it("uses message heuristic for 'invalid' keyword", () => { + const err = makeApolloError([ + makeGQLError("Field 'email' is an invalid type"), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("VALIDATION_FAILED"); + }); + + it("uses message heuristic for 'required' keyword", () => { + const err = makeApolloError([ + makeGQLError("contractId is required"), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("VALIDATION_FAILED"); + }); + }); + + describe("parse / syntax errors", () => { + it("maps GRAPHQL_PARSE_FAILED extension code", () => { + const err = makeApolloError([ + makeGQLError("Syntax error", { code: "GRAPHQL_PARSE_FAILED" }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("PARSE_FAILED"); + expect(parsed.message).toMatch(/could not understand/i); + expect(parsed.suggestion).toMatch(/report/i); + }); + + it("uses message heuristic for 'syntax' keyword", () => { + const err = makeApolloError([ + makeGQLError("Unexpected syntax near token"), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("PARSE_FAILED"); + }); + }); + + describe("network errors", () => { + it("maps a generic network error", () => { + const netErr = new Error("Failed to fetch"); + const err = makeApolloError([], netErr); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("NETWORK_ERROR"); + expect(parsed.message).toMatch(/could not reach/i); + expect(parsed.suggestion).toMatch(/internet connection/i); + }); + + it("maps 500 HTTP error to INTERNAL_SERVER_ERROR", () => { + const netErr = Object.assign(new Error("Server Error"), { statusCode: 500 }); + const err = makeApolloError([], netErr); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("INTERNAL_SERVER_ERROR"); + expect(parsed.message).toMatch(/unexpected server error/i); + }); + + it("maps 429 HTTP error to RATE_LIMITED", () => { + const netErr = Object.assign(new Error("Too Many Requests"), { statusCode: 429 }); + const err = makeApolloError([], netErr); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("RATE_LIMITED"); + expect(parsed.suggestion).toMatch(/wait/i); + }); + + it("maps plain Error with 'fetch' in message to NETWORK_ERROR", () => { + const parsed = parseGraphQLError(new Error("fetch error: network unreachable")); + expect(parsed.code).toBe("NETWORK_ERROR"); + }); + }); + + describe("multiple GraphQL errors", () => { + it("returns the first error's code and combines all details", () => { + const err = makeApolloError([ + makeGQLError("Email invalid", { code: "BAD_USER_INPUT", field_errors: { email: ["Bad email"] } }), + makeGQLError("Password too short", { code: "BAD_USER_INPUT", field_errors: { password: ["Min 8 chars"] } }), + ]); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("BAD_USER_INPUT"); + // details should contain info from both errors + expect(parsed.details).toContain("email"); + expect(parsed.details).toContain("password"); + }); + }); + + describe("parseGraphQLErrors — array variant", () => { + it("returns an empty array for an empty input", () => { + expect(parseGraphQLErrors([])).toEqual([]); + }); + + it("parses each error individually", () => { + const errors = [ + makeGQLError("Invalid email", { code: "VALIDATION_FAILED" }), + makeGQLError("Server blew up", { code: "INTERNAL_SERVER_ERROR" }), + ]; + const results = parseGraphQLErrors(errors); + expect(results).toHaveLength(2); + expect(results[0].code).toBe("VALIDATION_FAILED"); + expect(results[1].code).toBe("INTERNAL_SERVER_ERROR"); + }); + }); + + describe("unknown / generic errors", () => { + it("returns a safe fallback for null", () => { + const parsed = parseGraphQLError(null); + expect(parsed.code).toBe("UNKNOWN"); + expect(parsed.message).toBeTruthy(); + expect(parsed.suggestion).toBeTruthy(); + }); + + it("returns a safe fallback for undefined", () => { + const parsed = parseGraphQLError(undefined); + expect(parsed.code).toBe("UNKNOWN"); + }); + + it("returns a safe fallback for an empty ApolloError", () => { + const err = makeApolloError([], null); + const parsed = parseGraphQLError(err); + expect(parsed.code).toBe("UNKNOWN"); + expect(parsed.message).toMatch(/try again/i); + }); + + it("never throws — handles arbitrary objects gracefully", () => { + expect(() => parseGraphQLError({ weird: true })).not.toThrow(); + expect(() => parseGraphQLError("string error")).not.toThrow(); + expect(() => parseGraphQLError(42)).not.toThrow(); + }); + }); + + describe("path context in details", () => { + it("surfaces field path when no field_errors extension is present", () => { + const err = makeGQLError("Value too long", {}, ["updateProfile", "bio"]); + const parsed = parseSingleGraphQLError(err); + expect(parsed.details).toContain("updateProfile"); + expect(parsed.details).toContain("bio"); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION 2 — GraphQLErrorDisplay (component) +// ───────────────────────────────────────────────────────────────────────────── + +describe("GraphQLErrorDisplay — component", () => { + const validationError = { + code: "VALIDATION_FAILED" as const, + message: "Your request failed validation.", + suggestion: "Check that all required fields are filled in.", + details: "email: Enter a valid email · password: This field is required", + }; + + const networkError = { + code: "NETWORK_ERROR" as const, + message: "Could not reach the server.", + suggestion: "Check your internet connection and try again.", + }; + + const unknownError = { + code: "UNKNOWN" as const, + message: "Something went wrong. Please try again.", + suggestion: "If the issue continues, contact support.", + }; + + describe("rendering", () => { + it("renders the error message", () => { + render(); + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "Your request failed validation." + ); + }); + + it("renders the actionable suggestion", () => { + render(); + expect(screen.getByTestId("graphql-error-display-suggestion")).toHaveTextContent( + "Check that all required fields are filled in." + ); + }); + + it("renders nothing when error is null", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when error is undefined", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the UNKNOWN fallback safely", () => { + render(); + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "Something went wrong. Please try again." + ); + expect(screen.getByTestId("graphql-error-display-suggestion")).toHaveTextContent( + "contact support" + ); + }); + + it("renders a network error with correct message and suggestion", () => { + render(); + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "Could not reach the server." + ); + expect(screen.getByTestId("graphql-error-display-suggestion")).toHaveTextContent( + "internet connection" + ); + }); + }); + + describe("accessibility", () => { + it("has role=alert", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("has aria-live=assertive", () => { + render(); + expect(screen.getByRole("alert")).toHaveAttribute("aria-live", "assertive"); + }); + + it("exposes data-error-code attribute for test targeting", () => { + render(); + expect(screen.getByTestId("graphql-error-display")).toHaveAttribute( + "data-error-code", + "VALIDATION_FAILED" + ); + }); + }); + + describe("variants", () => { + it.each(["inline", "banner", "toast"] as const)( + 'renders the "%s" variant without crashing', + (variant) => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + } + ); + }); + + describe("details expand/collapse", () => { + it("shows a 'Show details' toggle when details are present", () => { + render(); + expect(screen.getByRole("button", { name: /show details/i })).toBeInTheDocument(); + }); + + it("expands details when toggle is clicked", () => { + render(); + const toggle = screen.getByRole("button", { name: /show details/i }); + fireEvent.click(toggle); + expect(screen.getByTestId("graphql-error-display-details")).toHaveTextContent( + "email" + ); + }); + + it("collapses details on second click", () => { + render(); + const toggle = screen.getByRole("button", { name: /show details/i }); + fireEvent.click(toggle); + fireEvent.click(screen.getByRole("button", { name: /hide details/i })); + expect(screen.queryByTestId("graphql-error-display-details")).not.toBeInTheDocument(); + }); + + it("does not show details toggle when details are absent", () => { + render(); + expect(screen.queryByRole("button", { name: /details/i })).not.toBeInTheDocument(); + }); + + it("hides details section when showDetails=false", () => { + render(); + expect(screen.queryByRole("button", { name: /details/i })).not.toBeInTheDocument(); + }); + }); + + describe("dismiss behaviour", () => { + it("shows a dismiss button when dismissible=true", () => { + render(); + expect(screen.getByLabelText("Dismiss error")).toBeInTheDocument(); + }); + + it("removes the error from the DOM after dismissal", () => { + render(); + fireEvent.click(screen.getByLabelText("Dismiss error")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("calls onDismiss callback when dismiss button is clicked", () => { + const onDismiss = jest.fn(); + render(); + fireEvent.click(screen.getByLabelText("Dismiss error")); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("does not show dismiss button by default", () => { + render(); + expect(screen.queryByLabelText("Dismiss error")).not.toBeInTheDocument(); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION 3 — GraphQLErrorList (multi-error component) +// ───────────────────────────────────────────────────────────────────────────── + +describe("GraphQLErrorList — component", () => { + const errors = [ + { + code: "VALIDATION_FAILED" as const, + message: "Email is invalid.", + suggestion: "Enter a valid email address.", + }, + { + code: "VALIDATION_FAILED" as const, + message: "Password is too short.", + suggestion: "Use at least 8 characters.", + }, + ]; + + it("renders all errors", () => { + render(); + expect(screen.getByText("Email is invalid.")).toBeInTheDocument(); + expect(screen.getByText("Password is too short.")).toBeInTheDocument(); + }); + + it("renders nothing for an empty array", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders a dismiss button for each item when dismissible=true", () => { + render(); + const buttons = screen.getAllByLabelText("Dismiss error"); + expect(buttons).toHaveLength(2); + }); + + it("removes an individual error after dismissal", () => { + render(); + const buttons = screen.getAllByLabelText("Dismiss error"); + fireEvent.click(buttons[0]); + expect(screen.queryByText("Email is invalid.")).not.toBeInTheDocument(); + expect(screen.getByText("Password is too short.")).toBeInTheDocument(); + }); + + it("disappears entirely when all errors are dismissed", () => { + render(); + fireEvent.click(screen.getByLabelText("Dismiss error")); + expect(screen.queryByTestId("graphql-error-list")).not.toBeInTheDocument(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION 4 — End-to-end: parse + render +// ───────────────────────────────────────────────────────────────────────────── + +describe("Parse → render integration", () => { + it("validation error renders user-friendly message and suggestion", () => { + const apolloErr = makeApolloError([ + makeGQLError("Validation failed", { + code: "VALIDATION_FAILED", + field_errors: { email: ["Enter a valid email address."] }, + }), + ]); + const parsed = parseGraphQLError(apolloErr); + render(); + + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "failed validation" + ); + expect(screen.getByTestId("graphql-error-display-suggestion")).toBeInTheDocument(); + // Details should be toggleable + fireEvent.click(screen.getByRole("button", { name: /show details/i })); + expect(screen.getByTestId("graphql-error-display-details")).toHaveTextContent("email"); + }); + + it("parse/syntax error renders correct copy", () => { + const apolloErr = makeApolloError([ + makeGQLError("Syntax error: unexpected token", { code: "GRAPHQL_PARSE_FAILED" }), + ]); + const parsed = parseGraphQLError(apolloErr); + render(); + + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "could not understand" + ); + expect(screen.getByTestId("graphql-error-display-suggestion")).toHaveTextContent( + "report" + ); + }); + + it("network error renders correct copy", () => { + const netErr = new Error("Failed to fetch"); + const apolloErr = makeApolloError([], netErr); + const parsed = parseGraphQLError(apolloErr); + render(); + + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "Could not reach" + ); + expect(screen.getByTestId("graphql-error-display-suggestion")).toHaveTextContent( + "internet connection" + ); + }); + + it("unknown error renders safe fallback", () => { + const parsed = parseGraphQLError(null); + render(); + + expect(screen.getByTestId("graphql-error-display-message")).toHaveTextContent( + "Something went wrong" + ); + expect(screen.getByTestId("graphql-error-display-suggestion")).toHaveTextContent( + "contact support" + ); + }); +}); diff --git a/soroscan-frontend/components/ui/GraphQLErrorDisplay.tsx b/soroscan-frontend/components/ui/GraphQLErrorDisplay.tsx new file mode 100644 index 000000000..d4695416c --- /dev/null +++ b/soroscan-frontend/components/ui/GraphQLErrorDisplay.tsx @@ -0,0 +1,235 @@ +"use client"; + +/** + * GraphQLErrorDisplay + * + * A focused, accessible component for rendering parsed GraphQL errors. + * Consumes `ParsedGraphQLError` objects from `lib/graphql-error-parser.ts`. + * + * Integration points: + * - FE-5: typed query/mutation hooks feed ApolloError instances that are + * passed through `parseGraphQLError()` before reaching this component. + * - FE-22: the `variant="banner"` prop makes this suitable for page-level + * error layout slots (e.g. at the top of a form or data panel). + * + * Variants: + * - "inline" — compact single-line block, ideal inside forms. + * - "banner" — full-width dismissible panel, ideal for page-level errors. + * - "toast" — minimal text-only block for inside toast containers. + */ + +import React from "react"; +import { XCircle, WifiOff, ShieldOff, AlertTriangle, Info, X, ChevronDown, ChevronUp } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ParsedGraphQLError, GraphQLErrorCode } from "@/lib/graphql-error-parser"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type GraphQLErrorDisplayVariant = "inline" | "banner" | "toast"; + +export interface GraphQLErrorDisplayProps { + /** The parsed error object from `parseGraphQLError()`. Pass `null` to render nothing. */ + error: ParsedGraphQLError | null | undefined; + /** Visual variant. Defaults to "inline". */ + variant?: GraphQLErrorDisplayVariant; + /** Allow the user to dismiss the error. */ + dismissible?: boolean; + /** Callback fired when the user dismisses the error. */ + onDismiss?: () => void; + /** Show a "details" expandable section. Defaults to true when details exist. */ + showDetails?: boolean; + /** Extra class names on the root element. */ + className?: string; + /** Override the data-testid (defaults to "graphql-error-display"). */ + testId?: string; +} + +// ── Icon mapping ───────────────────────────────────────────────────────────── + +const CODE_ICONS: Partial>> = { + UNAUTHENTICATED: ShieldOff, + FORBIDDEN: ShieldOff, + NETWORK_ERROR: WifiOff, + RATE_LIMITED: AlertTriangle, + INTERNAL_SERVER_ERROR: AlertTriangle, + UNKNOWN: Info, +}; + +function getIcon(code: GraphQLErrorCode): React.ComponentType<{ className?: string }> { + return CODE_ICONS[code] ?? XCircle; +} + +// ── Variant styles ──────────────────────────────────────────────────────────── + +const ROOT_VARIANTS: Record = { + inline: + "flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400", + banner: + "w-full flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400", + toast: + "flex items-start gap-2 text-sm text-red-800 dark:text-red-400", +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function GraphQLErrorDisplay({ + error, + variant = "inline", + dismissible = false, + onDismiss, + showDetails = true, + className, + testId = "graphql-error-display", +}: GraphQLErrorDisplayProps) { + const [dismissed, setDismissed] = React.useState(false); + const [expanded, setExpanded] = React.useState(false); + + // Reset dismissed state when the error changes + React.useEffect(() => { + setDismissed(false); + setExpanded(false); + }, [error?.code, error?.message]); + + if (!error || dismissed) return null; + + const Icon = getIcon(error.code); + const hasDetails = showDetails && Boolean(error.details); + const hasSuggestion = Boolean(error.suggestion); + const canDismiss = dismissible || Boolean(onDismiss); + + const handleDismiss = () => { + setDismissed(true); + onDismiss?.(); + }; + + return ( +
+ {/* Icon */} +
+ ); +} + +// ── Multi-error list variant ────────────────────────────────────────────────── + +export interface GraphQLErrorListProps { + /** Multiple parsed errors — e.g. from `parseGraphQLErrors()`. */ + errors: ParsedGraphQLError[]; + variant?: GraphQLErrorDisplayVariant; + dismissible?: boolean; + className?: string; +} + +/** + * Renders a stacked list of `GraphQLErrorDisplay` items. + * Useful when a single operation returns multiple validation errors. + */ +export function GraphQLErrorList({ + errors, + variant = "inline", + dismissible = false, + className, +}: GraphQLErrorListProps) { + const [dismissed, setDismissed] = React.useState>(new Set()); + + if (!errors.length) return null; + + const visible = errors.filter((_, i) => !dismissed.has(i)); + if (!visible.length) return null; + + return ( +
    + {errors.map((error, i) => + dismissed.has(i) ? null : ( +
  • + setDismissed((s) => new Set([...s, i]))} + testId={`graphql-error-display-${i}`} + /> +
  • + ) + )} +
+ ); +} diff --git a/soroscan-frontend/lib/graphql-error-parser.ts b/soroscan-frontend/lib/graphql-error-parser.ts new file mode 100644 index 000000000..2dfa8dd33 --- /dev/null +++ b/soroscan-frontend/lib/graphql-error-parser.ts @@ -0,0 +1,261 @@ +/** + * GraphQL Error Parser + * + * Translates raw Apollo/GraphQL errors into structured, user-friendly objects. + * + * Architecture note: + * - FE-5 (GraphQL type generation) feeds typed queries/mutations into our hooks. + * The ApolloError type from @apollo/client already carries `graphQLErrors` and + * `networkError`, so this utility slots in naturally after the Apollo link chain + * (apollo-client.ts) processes auth/retry logic. + * - FE-22 (global error layout / error boundary) can consume `ParsedGraphQLError` + * objects directly to render page-level banners, form-level blocks, or toasts. + */ + +import type { ApolloError, GraphQLErrors } from '@apollo/client'; +import type { GraphQLError } from 'graphql'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** Extension codes we handle explicitly. */ +export type GraphQLErrorCode = + | 'UNAUTHENTICATED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'BAD_USER_INPUT' + | 'VALIDATION_FAILED' + | 'RATE_LIMITED' + | 'INTERNAL_SERVER_ERROR' + | 'NETWORK_ERROR' + | 'PARSE_FAILED' + | 'UNKNOWN'; + +/** Structured output of the parser — ready to render directly. */ +export interface ParsedGraphQLError { + /** Short, human-readable headline (no stack traces, no raw field names). */ + message: string; + /** Optional extra context — e.g. which fields failed validation. */ + details?: string; + /** Actionable next step for the user. */ + suggestion?: string; + /** Normalised code for conditional rendering / testing. */ + code: GraphQLErrorCode; +} + +// ── Error code → UX copy map ───────────────────────────────────────────────── + +const CODE_MAP: Record< + Exclude, + Omit +> = { + UNAUTHENTICATED: { + message: 'Your session has expired.', + suggestion: 'Please sign in again to continue.', + }, + FORBIDDEN: { + message: "You don't have permission to do that.", + suggestion: 'Contact your organisation administrator if you think this is a mistake.', + }, + NOT_FOUND: { + message: 'The requested resource could not be found.', + suggestion: 'Double-check the ID or URL and try again.', + }, + BAD_USER_INPUT: { + message: 'One or more fields contain invalid data.', + suggestion: 'Review the highlighted fields and correct the values before resubmitting.', + }, + VALIDATION_FAILED: { + message: 'Your request failed validation.', + suggestion: 'Check that all required fields are filled in and match the expected format.', + }, + RATE_LIMITED: { + message: 'Too many requests in a short time.', + suggestion: 'Wait a moment, then try again.', + }, + INTERNAL_SERVER_ERROR: { + message: 'An unexpected server error occurred.', + suggestion: 'This is on our end. Please try again later or contact support if the issue persists.', + }, + NETWORK_ERROR: { + message: 'Could not reach the server.', + suggestion: 'Check your internet connection and try again.', + }, + PARSE_FAILED: { + message: 'The server could not understand the request.', + suggestion: 'If this keeps happening, please report it to the development team.', + }, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Derive a normalised code from a raw GraphQL error's extensions. + * Falls back to message-based heuristics for backends that don't set + * extensions.code consistently (e.g. Strawberry default errors). + */ +function resolveCode(error: GraphQLError): GraphQLErrorCode { + const raw = + (error.extensions?.code as string | undefined) ?? + (error.extensions?.errorType as string | undefined) ?? + ''; + + const upper = raw.toUpperCase(); + + if (upper === 'UNAUTHENTICATED' || upper === '401') return 'UNAUTHENTICATED'; + if (upper === 'FORBIDDEN' || upper === '403') return 'FORBIDDEN'; + if (upper === 'NOT_FOUND' || upper === '404') return 'NOT_FOUND'; + if (upper === 'BAD_USER_INPUT') return 'BAD_USER_INPUT'; + if (upper === 'VALIDATION_FAILED' || upper === 'VALIDATION_ERROR') return 'VALIDATION_FAILED'; + if (upper === 'RATE_LIMITED' || upper === 'TOO_MANY_REQUESTS') return 'RATE_LIMITED'; + if (upper === 'INTERNAL_SERVER_ERROR' || upper === '500') return 'INTERNAL_SERVER_ERROR'; + if (upper === 'GRAPHQL_PARSE_FAILED' || upper === 'PARSE_FAILED') return 'PARSE_FAILED'; + + // Heuristic: Strawberry / graphene validation errors often contain keywords + const msg = error.message.toLowerCase(); + if (msg.includes('not authenticated') || msg.includes('not authorized') || msg.includes('permission denied')) { + return 'FORBIDDEN'; + } + if (msg.includes('invalid') || msg.includes('must be') || msg.includes('required')) { + return 'VALIDATION_FAILED'; + } + if (msg.includes('not found')) return 'NOT_FOUND'; + if (msg.includes('syntax') || msg.includes('parse')) return 'PARSE_FAILED'; + + return 'UNKNOWN'; +} + +/** + * Extract a human-readable detail string from a single GraphQL error's + * extensions — surfaces validation field errors when present. + */ +function extractDetails(error: GraphQLError): string | undefined { + const ext = error.extensions; + if (!ext) return undefined; + + // Common shapes: { field_errors: { fieldName: ["msg"] } }, { fields: [...] } + if (ext.field_errors && typeof ext.field_errors === 'object') { + const parts: string[] = []; + for (const [field, msgs] of Object.entries(ext.field_errors as Record)) { + const text = Array.isArray(msgs) ? msgs.join('; ') : String(msgs); + parts.push(`${field}: ${text}`); + } + if (parts.length) return parts.join(' · '); + } + + // Strawberry / graphene detail string + if (typeof ext.detail === 'string' && ext.detail) return ext.detail; + + // Path context e.g. ["login", "email"] + if (Array.isArray(error.path) && error.path.length) { + return `Field path: ${error.path.join(' › ')}`; + } + + return undefined; +} + +// ── Single-error parser ─────────────────────────────────────────────────────── + +/** + * Parse a single raw GraphQLError into a user-friendly object. + */ +export function parseSingleGraphQLError(error: GraphQLError): ParsedGraphQLError { + const code = resolveCode(error); + const base = CODE_MAP[code as Exclude] ?? { + message: 'Something went wrong. Please try again.', + suggestion: 'If the issue continues, contact support.', + }; + + return { + code, + message: base.message, + details: extractDetails(error), + suggestion: base.suggestion, + }; +} + +// ── Main entry point ────────────────────────────────────────────────────────── + +/** + * `parseGraphQLError(error)` + * + * Accepts an `ApolloError` (or any error-like object) and returns a + * `ParsedGraphQLError` ready to display. Always returns a safe fallback — + * never throws. + * + * Precedence: + * 1. First entry in `graphQLErrors` array (most specific) + * 2. Network error + * 3. Generic fallback + * + * Usage in hooks: + * ```ts + * onError: (err) => { + * const parsed = parseGraphQLError(err); + * showToast(parsed.message, 'error', parsed.suggestion); + * } + * ``` + */ +export function parseGraphQLError(error: ApolloError | Error | unknown): ParsedGraphQLError { + try { + // Apollo errors carry graphQLErrors + networkError + const apolloError = error as ApolloError; + + if (apolloError?.graphQLErrors?.length) { + const first = apolloError.graphQLErrors[0]; + const parsed = parseSingleGraphQLError(first); + + // If there are multiple field errors, surface them all in details + if (apolloError.graphQLErrors.length > 1) { + const allDetails = apolloError.graphQLErrors + .map((e: GraphQLError) => extractDetails(e) ?? e.message) + .filter(Boolean) + .join(' | '); + return { ...parsed, details: allDetails || parsed.details }; + } + + return parsed; + } + + if (apolloError?.networkError) { + const net = apolloError.networkError as { statusCode?: number; message?: string }; + const status = net?.statusCode; + + // Map HTTP status to a code + if (status === 401) return { ...CODE_MAP.UNAUTHENTICATED, code: 'UNAUTHENTICATED' }; + if (status === 403) return { ...CODE_MAP.FORBIDDEN, code: 'FORBIDDEN' }; + if (status === 404) return { ...CODE_MAP.NOT_FOUND, code: 'NOT_FOUND' }; + if (status === 429) return { ...CODE_MAP.RATE_LIMITED, code: 'RATE_LIMITED' }; + if (status && status >= 500) return { ...CODE_MAP.INTERNAL_SERVER_ERROR, code: 'INTERNAL_SERVER_ERROR' }; + + return { ...CODE_MAP.NETWORK_ERROR, code: 'NETWORK_ERROR' }; + } + + // Plain Error object — extract what we can + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + if (msg.includes('network') || msg.includes('fetch') || msg.includes('failed to fetch')) { + return { ...CODE_MAP.NETWORK_ERROR, code: 'NETWORK_ERROR' }; + } + if (msg.includes('parse') || msg.includes('syntax')) { + return { ...CODE_MAP.PARSE_FAILED, code: 'PARSE_FAILED' }; + } + } + } catch { + // Defensive — parsing should never crash the app + } + + return { + code: 'UNKNOWN', + message: 'Something went wrong. Please try again.', + suggestion: 'If the issue continues, contact support.', + }; +} + +/** + * Parse an array of raw GraphQL errors into individual user-friendly objects. + * Useful when you want to list every validation error separately. + */ +export function parseGraphQLErrors(errors: GraphQLErrors): ParsedGraphQLError[] { + if (!errors?.length) return []; + return errors.map((e) => parseSingleGraphQLError(e)); +} diff --git a/soroscan-frontend/lib/hooks/useGraphQL.ts b/soroscan-frontend/lib/hooks/useGraphQL.ts index e75f9745b..e34de948a 100644 --- a/soroscan-frontend/lib/hooks/useGraphQL.ts +++ b/soroscan-frontend/lib/hooks/useGraphQL.ts @@ -1,9 +1,15 @@ import { useQuery, useMutation, type QueryHookOptions, type MutationHookOptions } from '@apollo/client'; import type { DocumentNode } from 'graphql'; import type { OperationVariables } from '@apollo/client'; +import { parseGraphQLError, type ParsedGraphQLError } from '@/lib/graphql-error-parser'; /** - * Custom hook wrapper for Apollo useQuery with error handling + * Custom hook wrapper for Apollo useQuery with structured error handling. + * + * Integration (FE-5 / FE-22): + * - FE-5: pass a typed DocumentNode from `src/generated/graphql.ts` as `query`. + * - FE-22: consume `parsedError` in a page-level error layout or pass it to + * ``. */ export function useGraphQLQuery( query: DocumentNode, @@ -12,20 +18,30 @@ export function useGraphQLQuery(query, { ...options, onError: (error) => { - console.error('GraphQL Query Error:', error.message); + console.error('[GraphQL Query Error]', error.message); options?.onError?.(error); }, }); + const parsedError: ParsedGraphQLError | null = result.error + ? parseGraphQLError(result.error) + : null; + return { ...result, isLoading: result.loading, isError: !!result.error, + /** Structured, user-friendly error — feed directly into GraphQLErrorDisplay. */ + parsedError, }; } /** - * Custom hook wrapper for Apollo useMutation with error handling + * Custom hook wrapper for Apollo useMutation with structured error handling. + * + * Integration (FE-5 / FE-22): + * - FE-5: pass a typed DocumentNode from `src/generated/graphql.ts` as `mutation`. + * - FE-22: consume `parsedError` in an inline form error block or toast. */ export function useGraphQLMutation( mutation: DocumentNode, @@ -34,17 +50,23 @@ export function useGraphQLMutation(mutation, { ...options, onError: (error) => { - console.error('GraphQL Mutation Error:', error.message); + console.error('[GraphQL Mutation Error]', error.message); options?.onError?.(error); }, }); + const parsedError: ParsedGraphQLError | null = result.error + ? parseGraphQLError(result.error) + : null; + return [ mutate, { ...result, isLoading: result.loading, isError: !!result.error, + /** Structured, user-friendly error — feed directly into GraphQLErrorDisplay. */ + parsedError, }, ] as const; }