From ede5b4dbe8405c759db14e366a3227f3d3374cf5 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Wed, 28 May 2025 11:11:28 +0530 Subject: [PATCH] feat: enhance toast error messages with structured parsing - Add parseErrorMessage() utility to parse JSON error responses - Add showErrorToast() helper for consistent error display - Update all toast.error calls to use structured error parsing - Support multiple error formats: error+troubleshooting, title+description, message+details - Enhance apiRequest() to support both 'body' and 'data' properties - Add comprehensive unit tests for error parsing functionality - Improve user experience with clear, actionable error messages Fixes structured error messages from Gitea API responses that were showing as raw JSON --- package.json | 2 +- src/components/activity/ActivityLog.tsx | 12 +- src/components/auth/LoginForm.tsx | 5 +- src/components/auth/SignupForm.tsx | 5 +- src/components/config/ConfigTabs.tsx | 33 ++--- src/components/dashboard/Dashboard.tsx | 10 +- src/components/organizations/Organization.tsx | 14 +- src/components/repositories/Repository.tsx | 38 ++--- src/lib/utils.test.ts | 92 +++++++++--- src/lib/utils.ts | 131 +++++++++++++++++- 10 files changed, 240 insertions(+), 102 deletions(-) diff --git a/package.json b/package.json index 602ec6d..09031b6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gitea-mirror", "type": "module", - "version": "2.9.2", + "version": "2.9.3", "engines": { "bun": ">=1.2.9" }, diff --git a/src/components/activity/ActivityLog.tsx b/src/components/activity/ActivityLog.tsx index 134de0a..001d05a 100644 --- a/src/components/activity/ActivityLog.tsx +++ b/src/components/activity/ActivityLog.tsx @@ -16,7 +16,7 @@ import { DialogTitle, DialogTrigger, } from '../ui/dialog'; -import { apiRequest, formatDate } from '@/lib/utils'; +import { apiRequest, formatDate, showErrorToast } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; import type { MirrorJob } from '@/lib/db/schema'; import type { ActivityApiResponse } from '@/types/activities'; @@ -155,7 +155,7 @@ export function ActivityLog() { if (!res.success) { // Only show error toast for manual refreshes to avoid spam during live updates if (!isLiveRefresh) { - toast.error(res.message ?? 'Failed to fetch activities.'); + showErrorToast(res.message ?? 'Failed to fetch activities.', toast); } return false; } @@ -184,9 +184,7 @@ export function ActivityLog() { if (isMountedRef.current) { // Only show error toast for manual refreshes to avoid spam during live updates if (!isLiveRefresh) { - toast.error( - err instanceof Error ? err.message : 'Failed to fetch activities.', - ); + showErrorToast(err, toast); } } return false; @@ -331,11 +329,11 @@ export function ActivityLog() { setActivities([]); toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`); } else { - toast.error(res.error || 'Failed to cleanup activities.'); + showErrorToast(res.error || 'Failed to cleanup activities.', toast); } } catch (error) { console.error('Error cleaning up activities:', error); - toast.error(error instanceof Error ? error.message : 'Failed to cleanup activities.'); + showErrorToast(error, toast); } finally { setIsInitialLoading(false); } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 20c2b36..2842e9c 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { SiGitea } from 'react-icons/si'; import { toast, Toaster } from 'sonner'; +import { showErrorToast } from '@/lib/utils'; import { FlipHorizontal } from 'lucide-react'; export function LoginForm() { @@ -45,10 +46,10 @@ export function LoginForm() { window.location.href = '/'; }, 1000); } else { - toast.error(data.error || 'Login failed. Please try again.'); + showErrorToast(data.error || 'Login failed. Please try again.', toast); } } catch (error) { - toast.error('An error occurred while logging in. Please try again.'); + showErrorToast(error, toast); } finally { setIsLoading(false); } diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx index fc82a90..29b3f87 100644 --- a/src/components/auth/SignupForm.tsx +++ b/src/components/auth/SignupForm.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { GitMerge } from 'lucide-react'; import { toast, Toaster } from 'sonner'; +import { showErrorToast } from '@/lib/utils'; export function SignupForm() { const [isLoading, setIsLoading] = useState(false); @@ -51,10 +52,10 @@ export function SignupForm() { window.location.href = '/'; }, 1500); } else { - toast.error(data.error || 'Failed to create account. Please try again.'); + showErrorToast(data.error || 'Failed to create account. Please try again.', toast); } } catch (error) { - toast.error('An error occurred while creating your account. Please try again.'); + showErrorToast(error, toast); } finally { setIsLoading(false); } diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index e6a0899..a29d92f 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -14,7 +14,7 @@ import type { } from '@/types/config'; import { Button } from '../ui/button'; import { useAuth } from '@/hooks/useAuth'; -import { apiRequest } from '@/lib/utils'; +import { apiRequest, showErrorToast } from '@/lib/utils'; import { RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import { Skeleton } from '@/components/ui/skeleton'; @@ -134,16 +134,13 @@ export function ConfigTabs() { 'Configuration saved successfully! Now import your GitHub data to begin.', ); } else { - toast.error( + showErrorToast( `Failed to save configuration: ${result.message || 'Unknown error'}`, + toast ); } } catch (error) { - toast.error( - `An error occurred while saving the configuration: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + showErrorToast(error, toast); } }; @@ -198,18 +195,13 @@ export function ConfigTabs() { console.warn('Failed to fetch updated config after auto-save:', fetchError); } } else { - toast.error( + showErrorToast( `Auto-save failed: ${result.message || 'Unknown error'}`, - { duration: 3000 } + toast ); } } catch (error) { - toast.error( - `Auto-save error: ${ - error instanceof Error ? error.message : String(error) - }`, - { duration: 3000 } - ); + showErrorToast(error, toast); } finally { setIsAutoSavingSchedule(false); } @@ -266,18 +258,13 @@ export function ConfigTabs() { console.warn('Failed to fetch updated config after auto-save:', fetchError); } } else { - toast.error( + showErrorToast( `Auto-save failed: ${result.message || 'Unknown error'}`, - { duration: 3000 } + toast ); } } catch (error) { - toast.error( - `Auto-save error: ${ - error instanceof Error ? error.message : String(error) - }`, - { duration: 3000 } - ); + showErrorToast(error, toast); } finally { setIsAutoSavingCleanup(false); } diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index a3dfe91..05514fb 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -5,7 +5,7 @@ import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MirrorJob, Organization, Repository } from "@/lib/db/schema"; import { useAuth } from "@/hooks/useAuth"; -import { apiRequest } from "@/lib/utils"; +import { apiRequest, showErrorToast } from "@/lib/utils"; import type { DashboardApiResponse } from "@/types/dashboard"; import { useSSE } from "@/hooks/useSEE"; import { toast } from "sonner"; @@ -103,15 +103,11 @@ export function Dashboard() { } return true; } else { - toast.error(response.error || "Error fetching dashboard data"); + showErrorToast(response.error || "Error fetching dashboard data", toast); return false; } } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Error fetching dashboard data" - ); + showErrorToast(error, toast); return false; } finally { setIsLoading(false); diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index dcbc800..5803829 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -5,7 +5,7 @@ import type { MirrorJob, Organization } from "@/lib/db/schema"; import { OrganizationList } from "./OrganizationsList"; import AddOrganizationDialog from "./AddOrganizationDialog"; import { useAuth } from "@/hooks/useAuth"; -import { apiRequest } from "@/lib/utils"; +import { apiRequest, showErrorToast } from "@/lib/utils"; import { membershipRoleEnum, type AddOrganizationApiRequest, @@ -193,12 +193,10 @@ export function Organization() { searchTerm: org, })); } else { - toast.error(response.error || "Error adding organization"); + showErrorToast(response.error || "Error adding organization", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error adding organization" - ); + showErrorToast(error, toast); } finally { setIsLoading(false); } @@ -250,12 +248,10 @@ export function Organization() { }) ); } else { - toast.error(response.error || "Error starting mirror jobs"); + showErrorToast(response.error || "Error starting mirror jobs", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error starting mirror jobs" - ); + showErrorToast(error, toast); } finally { // Reset loading states - we'll let the SSE updates handle status changes setLoadingOrgIds(new Set()); diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index c6bcf6f..81d1ddd 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -9,7 +9,7 @@ import { type RepositoryApiResponse, type RepoStatus, } from "@/types/Repository"; -import { apiRequest } from "@/lib/utils"; +import { apiRequest, showErrorToast } from "@/lib/utils"; import { Select, SelectContent, @@ -108,16 +108,14 @@ export default function Repository() { } else { // Only show error toast for manual refreshes to avoid spam during live updates if (!isLiveRefresh) { - toast.error(response.error || "Error fetching repositories"); + showErrorToast(response.error || "Error fetching repositories", toast); } return false; } } catch (error) { // Only show error toast for manual refreshes to avoid spam during live updates if (!isLiveRefresh) { - toast.error( - error instanceof Error ? error.message : "Error fetching repositories" - ); + showErrorToast(error, toast); } return false; } finally { @@ -184,12 +182,10 @@ export default function Repository() { }) ); } else { - toast.error(response.error || "Error starting mirror job"); + showErrorToast(response.error || "Error starting mirror job", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error starting mirror job" - ); + showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); @@ -248,12 +244,10 @@ export default function Repository() { }) ); } else { - toast.error(response.error || "Error starting mirror jobs"); + showErrorToast(response.error || "Error starting mirror jobs", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error starting mirror jobs" - ); + showErrorToast(error, toast); } finally { // Reset loading states - we'll let the SSE updates handle status changes setLoadingRepoIds(new Set()); @@ -287,12 +281,10 @@ export default function Repository() { }) ); } else { - toast.error(response.error || "Error starting sync job"); + showErrorToast(response.error || "Error starting sync job", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error starting sync job" - ); + showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); @@ -329,12 +321,10 @@ export default function Repository() { }) ); } else { - toast.error(response.error || "Error retrying job"); + showErrorToast(response.error || "Error retrying job", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error retrying job" - ); + showErrorToast(error, toast); } finally { setLoadingRepoIds((prev) => { const newSet = new Set(prev); @@ -381,12 +371,10 @@ export default function Repository() { searchTerm: repo, })); } else { - toast.error(response.error || "Error adding repository"); + showErrorToast(response.error || "Error adding repository", toast); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Error adding repository" - ); + showErrorToast(error, toast); } }; diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 7a985f5..a71ed85 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,35 +1,35 @@ import { describe, test, expect } from "bun:test"; -import { jsonResponse, formatDate, truncate, safeParse } from "./utils"; +import { jsonResponse, formatDate, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils"; describe("jsonResponse", () => { test("creates a Response with JSON content", () => { const data = { message: "Hello, world!" }; const response = jsonResponse({ data }); - + expect(response).toBeInstanceOf(Response); expect(response.status).toBe(200); expect(response.headers.get("Content-Type")).toBe("application/json"); }); - + test("uses the provided status code", () => { const data = { error: "Not found" }; const response = jsonResponse({ data, status: 404 }); - + expect(response.status).toBe(404); }); - + test("correctly serializes complex objects", async () => { const now = new Date(); - const data = { + const data = { message: "Complex object", date: now, nested: { foo: "bar" }, array: [1, 2, 3] }; - + const response = jsonResponse({ data }); const responseBody = await response.json(); - + expect(responseBody).toEqual({ message: "Complex object", date: now.toISOString(), @@ -43,22 +43,22 @@ describe("formatDate", () => { test("formats a date object", () => { const date = new Date("2023-01-15T12:30:45Z"); const formatted = formatDate(date); - + // The exact format might depend on the locale, so we'll check for parts expect(formatted).toContain("2023"); expect(formatted).toContain("January"); expect(formatted).toContain("15"); }); - + test("formats a date string", () => { const dateStr = "2023-01-15T12:30:45Z"; const formatted = formatDate(dateStr); - + expect(formatted).toContain("2023"); expect(formatted).toContain("January"); expect(formatted).toContain("15"); }); - + test("returns 'Never' for null or undefined", () => { expect(formatDate(null)).toBe("Never"); expect(formatDate(undefined)).toBe("Never"); @@ -69,18 +69,18 @@ describe("truncate", () => { test("truncates a string that exceeds the length", () => { const str = "This is a long string that needs truncation"; const truncated = truncate(str, 10); - + expect(truncated).toBe("This is a ..."); expect(truncated.length).toBe(13); // 10 chars + "..." }); - + test("does not truncate a string that is shorter than the length", () => { const str = "Short"; const truncated = truncate(str, 10); - + expect(truncated).toBe("Short"); }); - + test("handles empty strings", () => { expect(truncate("", 10)).toBe(""); }); @@ -90,21 +90,71 @@ describe("safeParse", () => { test("parses valid JSON strings", () => { const jsonStr = '{"name":"John","age":30}'; const parsed = safeParse(jsonStr); - + expect(parsed).toEqual({ name: "John", age: 30 }); }); - + test("returns undefined for invalid JSON strings", () => { const invalidJson = '{"name":"John",age:30}'; // Missing quotes around age const parsed = safeParse(invalidJson); - + expect(parsed).toBeUndefined(); }); - + test("returns the original value for non-string inputs", () => { const obj = { name: "John", age: 30 }; const parsed = safeParse(obj); - + expect(parsed).toBe(obj); }); }); + +describe("parseErrorMessage", () => { + test("parses JSON error with error and troubleshooting fields", () => { + const errorMessage = JSON.stringify({ + error: "Unexpected end of JSON input", + errorType: "SyntaxError", + timestamp: "2025-05-28T09:08:02.37Z", + troubleshooting: "JSON parsing error detected. Check Gitea server status and logs. Ensure Gitea is returning valid JSON responses." + }); + + const result = parseErrorMessage(errorMessage); + + expect(result.title).toBe("Unexpected end of JSON input"); + expect(result.description).toBe("JSON parsing error detected. Check Gitea server status and logs. Ensure Gitea is returning valid JSON responses."); + expect(result.isStructured).toBe(true); + }); + + test("parses JSON error with title and description fields", () => { + const errorMessage = JSON.stringify({ + title: "Connection Failed", + description: "Unable to connect to the server. Please check your network connection." + }); + + const result = parseErrorMessage(errorMessage); + + expect(result.title).toBe("Connection Failed"); + expect(result.description).toBe("Unable to connect to the server. Please check your network connection."); + expect(result.isStructured).toBe(true); + }); + + test("handles plain string error messages", () => { + const errorMessage = "Simple error message"; + + const result = parseErrorMessage(errorMessage); + + expect(result.title).toBe("Simple error message"); + expect(result.description).toBeUndefined(); + expect(result.isStructured).toBe(false); + }); + + test("handles Error objects", () => { + const error = new Error("Something went wrong"); + + const result = parseErrorMessage(error); + + expect(result.title).toBe("Something went wrong"); + expect(result.description).toBeUndefined(); + expect(result.isStructured).toBe(false); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 517bc4d..671f451 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -36,20 +36,141 @@ export function safeParse(value: unknown): T | undefined { return value as T; } +// Enhanced error message parsing for toast notifications +export interface ParsedErrorMessage { + title: string; + description?: string; + isStructured: boolean; +} + +export function parseErrorMessage(error: unknown): ParsedErrorMessage { + // Handle Error objects + if (error instanceof Error) { + return parseErrorMessage(error.message); + } + + // Handle string messages + if (typeof error === "string") { + // Try to parse as JSON first + try { + const parsed = JSON.parse(error); + + // Check for common structured error formats + if (typeof parsed === "object" && parsed !== null) { + // Format 1: { error: "message", errorType: "type", troubleshooting: "info" } + if (parsed.error) { + return { + title: parsed.error, + description: parsed.troubleshooting || parsed.errorType || undefined, + isStructured: true, + }; + } + + // Format 2: { title: "title", description: "desc" } + if (parsed.title) { + return { + title: parsed.title, + description: parsed.description || undefined, + isStructured: true, + }; + } + + // Format 3: { message: "msg", details: "details" } + if (parsed.message) { + return { + title: parsed.message, + description: parsed.details || undefined, + isStructured: true, + }; + } + } + } catch { + // Not valid JSON, treat as plain string + } + + // Plain string message + return { + title: error, + description: undefined, + isStructured: false, + }; + } + + // Handle objects directly + if (typeof error === "object" && error !== null) { + const errorObj = error as any; + + if (errorObj.error) { + return { + title: errorObj.error, + description: errorObj.troubleshooting || errorObj.errorType || undefined, + isStructured: true, + }; + } + + if (errorObj.title) { + return { + title: errorObj.title, + description: errorObj.description || undefined, + isStructured: true, + }; + } + + if (errorObj.message) { + return { + title: errorObj.message, + description: errorObj.details || undefined, + isStructured: true, + }; + } + } + + // Fallback for unknown types + return { + title: String(error), + description: undefined, + isStructured: false, + }; +} + +// Enhanced toast helper that parses structured error messages +export function showErrorToast(error: unknown, toast: any) { + const parsed = parseErrorMessage(error); + + if (parsed.description) { + // Use sonner's rich toast format with title and description + toast.error(parsed.title, { + description: parsed.description, + }); + } else { + // Simple error toast + toast.error(parsed.title); + } +} + // Helper function for API requests export async function apiRequest( endpoint: string, - options: RequestInit = {} + options: (RequestInit & { data?: any }) = {} ): Promise { try { - const response = await httpRequest(`${API_BASE}${endpoint}`, { + // Handle the custom 'data' property by converting it to 'body' + const { data, ...requestOptions } = options; + const finalOptions: RequestInit = { headers: { "Content-Type": "application/json", - ...(options.headers || {}), + ...(requestOptions.headers || {}), }, - ...options, - }); + ...requestOptions, + }; + + // If data is provided, stringify it and set as body + if (data !== undefined) { + finalOptions.body = JSON.stringify(data); + } + + const response = await httpRequest(`${API_BASE}${endpoint}`, finalOptions); return response.data; } catch (err) {