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
This commit is contained in:
Arunavo Ray
2025-05-28 11:11:28 +05:30
parent 99336e2607
commit ede5b4dbe8
10 changed files with 240 additions and 102 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "2.9.2", "version": "2.9.3",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },

View File

@@ -16,7 +16,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '../ui/dialog'; } from '../ui/dialog';
import { apiRequest, formatDate } from '@/lib/utils'; import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import type { MirrorJob } from '@/lib/db/schema'; import type { MirrorJob } from '@/lib/db/schema';
import type { ActivityApiResponse } from '@/types/activities'; import type { ActivityApiResponse } from '@/types/activities';
@@ -155,7 +155,7 @@ export function ActivityLog() {
if (!res.success) { if (!res.success) {
// Only show error toast for manual refreshes to avoid spam during live updates // Only show error toast for manual refreshes to avoid spam during live updates
if (!isLiveRefresh) { if (!isLiveRefresh) {
toast.error(res.message ?? 'Failed to fetch activities.'); showErrorToast(res.message ?? 'Failed to fetch activities.', toast);
} }
return false; return false;
} }
@@ -184,9 +184,7 @@ export function ActivityLog() {
if (isMountedRef.current) { if (isMountedRef.current) {
// Only show error toast for manual refreshes to avoid spam during live updates // Only show error toast for manual refreshes to avoid spam during live updates
if (!isLiveRefresh) { if (!isLiveRefresh) {
toast.error( showErrorToast(err, toast);
err instanceof Error ? err.message : 'Failed to fetch activities.',
);
} }
} }
return false; return false;
@@ -331,11 +329,11 @@ export function ActivityLog() {
setActivities([]); setActivities([]);
toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`); toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`);
} else { } else {
toast.error(res.error || 'Failed to cleanup activities.'); showErrorToast(res.error || 'Failed to cleanup activities.', toast);
} }
} catch (error) { } catch (error) {
console.error('Error cleaning up activities:', error); console.error('Error cleaning up activities:', error);
toast.error(error instanceof Error ? error.message : 'Failed to cleanup activities.'); showErrorToast(error, toast);
} finally { } finally {
setIsInitialLoading(false); setIsInitialLoading(false);
} }

View File

@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { SiGitea } from 'react-icons/si'; import { SiGitea } from 'react-icons/si';
import { toast, Toaster } from 'sonner'; import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { FlipHorizontal } from 'lucide-react'; import { FlipHorizontal } from 'lucide-react';
export function LoginForm() { export function LoginForm() {
@@ -45,10 +46,10 @@ export function LoginForm() {
window.location.href = '/'; window.location.href = '/';
}, 1000); }, 1000);
} else { } else {
toast.error(data.error || 'Login failed. Please try again.'); showErrorToast(data.error || 'Login failed. Please try again.', toast);
} }
} catch (error) { } catch (error) {
toast.error('An error occurred while logging in. Please try again.'); showErrorToast(error, toast);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { GitMerge } from 'lucide-react'; import { GitMerge } from 'lucide-react';
import { toast, Toaster } from 'sonner'; import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
export function SignupForm() { export function SignupForm() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -51,10 +52,10 @@ export function SignupForm() {
window.location.href = '/'; window.location.href = '/';
}, 1500); }, 1500);
} else { } 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) { } catch (error) {
toast.error('An error occurred while creating your account. Please try again.'); showErrorToast(error, toast);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -14,7 +14,7 @@ import type {
} from '@/types/config'; } from '@/types/config';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { apiRequest } from '@/lib/utils'; import { apiRequest, showErrorToast } from '@/lib/utils';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -134,16 +134,13 @@ export function ConfigTabs() {
'Configuration saved successfully! Now import your GitHub data to begin.', 'Configuration saved successfully! Now import your GitHub data to begin.',
); );
} else { } else {
toast.error( showErrorToast(
`Failed to save configuration: ${result.message || 'Unknown error'}`, `Failed to save configuration: ${result.message || 'Unknown error'}`,
toast
); );
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
`An error occurred while saving the configuration: ${
error instanceof Error ? error.message : String(error)
}`,
);
} }
}; };
@@ -198,18 +195,13 @@ export function ConfigTabs() {
console.warn('Failed to fetch updated config after auto-save:', fetchError); console.warn('Failed to fetch updated config after auto-save:', fetchError);
} }
} else { } else {
toast.error( showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`, `Auto-save failed: ${result.message || 'Unknown error'}`,
{ duration: 3000 } toast
); );
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
`Auto-save error: ${
error instanceof Error ? error.message : String(error)
}`,
{ duration: 3000 }
);
} finally { } finally {
setIsAutoSavingSchedule(false); setIsAutoSavingSchedule(false);
} }
@@ -266,18 +258,13 @@ export function ConfigTabs() {
console.warn('Failed to fetch updated config after auto-save:', fetchError); console.warn('Failed to fetch updated config after auto-save:', fetchError);
} }
} else { } else {
toast.error( showErrorToast(
`Auto-save failed: ${result.message || 'Unknown error'}`, `Auto-save failed: ${result.message || 'Unknown error'}`,
{ duration: 3000 } toast
); );
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
`Auto-save error: ${
error instanceof Error ? error.message : String(error)
}`,
{ duration: 3000 }
);
} finally { } finally {
setIsAutoSavingCleanup(false); setIsAutoSavingCleanup(false);
} }

View File

@@ -5,7 +5,7 @@ import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema"; import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils"; import { apiRequest, showErrorToast } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard"; import type { DashboardApiResponse } from "@/types/dashboard";
import { useSSE } from "@/hooks/useSEE"; import { useSSE } from "@/hooks/useSEE";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -103,15 +103,11 @@ export function Dashboard() {
} }
return true; return true;
} else { } else {
toast.error(response.error || "Error fetching dashboard data"); showErrorToast(response.error || "Error fetching dashboard data", toast);
return false; return false;
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error
? error.message
: "Error fetching dashboard data"
);
return false; return false;
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@@ -5,7 +5,7 @@ import type { MirrorJob, Organization } from "@/lib/db/schema";
import { OrganizationList } from "./OrganizationsList"; import { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog"; import AddOrganizationDialog from "./AddOrganizationDialog";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiRequest } from "@/lib/utils"; import { apiRequest, showErrorToast } from "@/lib/utils";
import { import {
membershipRoleEnum, membershipRoleEnum,
type AddOrganizationApiRequest, type AddOrganizationApiRequest,
@@ -193,12 +193,10 @@ export function Organization() {
searchTerm: org, searchTerm: org,
})); }));
} else { } else {
toast.error(response.error || "Error adding organization"); showErrorToast(response.error || "Error adding organization", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error adding organization"
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -250,12 +248,10 @@ export function Organization() {
}) })
); );
} else { } else {
toast.error(response.error || "Error starting mirror jobs"); showErrorToast(response.error || "Error starting mirror jobs", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error starting mirror jobs"
);
} finally { } finally {
// Reset loading states - we'll let the SSE updates handle status changes // Reset loading states - we'll let the SSE updates handle status changes
setLoadingOrgIds(new Set()); setLoadingOrgIds(new Set());

View File

@@ -9,7 +9,7 @@ import {
type RepositoryApiResponse, type RepositoryApiResponse,
type RepoStatus, type RepoStatus,
} from "@/types/Repository"; } from "@/types/Repository";
import { apiRequest } from "@/lib/utils"; import { apiRequest, showErrorToast } from "@/lib/utils";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -108,16 +108,14 @@ export default function Repository() {
} else { } else {
// Only show error toast for manual refreshes to avoid spam during live updates // Only show error toast for manual refreshes to avoid spam during live updates
if (!isLiveRefresh) { if (!isLiveRefresh) {
toast.error(response.error || "Error fetching repositories"); showErrorToast(response.error || "Error fetching repositories", toast);
} }
return false; return false;
} }
} catch (error) { } catch (error) {
// Only show error toast for manual refreshes to avoid spam during live updates // Only show error toast for manual refreshes to avoid spam during live updates
if (!isLiveRefresh) { if (!isLiveRefresh) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error fetching repositories"
);
} }
return false; return false;
} finally { } finally {
@@ -184,12 +182,10 @@ export default function Repository() {
}) })
); );
} else { } else {
toast.error(response.error || "Error starting mirror job"); showErrorToast(response.error || "Error starting mirror job", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error starting mirror job"
);
} finally { } finally {
setLoadingRepoIds((prev) => { setLoadingRepoIds((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -248,12 +244,10 @@ export default function Repository() {
}) })
); );
} else { } else {
toast.error(response.error || "Error starting mirror jobs"); showErrorToast(response.error || "Error starting mirror jobs", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error starting mirror jobs"
);
} finally { } finally {
// Reset loading states - we'll let the SSE updates handle status changes // Reset loading states - we'll let the SSE updates handle status changes
setLoadingRepoIds(new Set()); setLoadingRepoIds(new Set());
@@ -287,12 +281,10 @@ export default function Repository() {
}) })
); );
} else { } else {
toast.error(response.error || "Error starting sync job"); showErrorToast(response.error || "Error starting sync job", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error starting sync job"
);
} finally { } finally {
setLoadingRepoIds((prev) => { setLoadingRepoIds((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -329,12 +321,10 @@ export default function Repository() {
}) })
); );
} else { } else {
toast.error(response.error || "Error retrying job"); showErrorToast(response.error || "Error retrying job", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error retrying job"
);
} finally { } finally {
setLoadingRepoIds((prev) => { setLoadingRepoIds((prev) => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -381,12 +371,10 @@ export default function Repository() {
searchTerm: repo, searchTerm: repo,
})); }));
} else { } else {
toast.error(response.error || "Error adding repository"); showErrorToast(response.error || "Error adding repository", toast);
} }
} catch (error) { } catch (error) {
toast.error( showErrorToast(error, toast);
error instanceof Error ? error.message : "Error adding repository"
);
} }
}; };

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"; 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", () => { describe("jsonResponse", () => {
test("creates a Response with JSON content", () => { test("creates a Response with JSON content", () => {
@@ -108,3 +108,53 @@ describe("safeParse", () => {
expect(parsed).toBe(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);
});
});

View File

@@ -36,20 +36,141 @@ export function safeParse<T>(value: unknown): T | undefined {
return value as T; 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 // Helper function for API requests
export async function apiRequest<T>( export async function apiRequest<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: (RequestInit & { data?: any }) = {}
): Promise<T> { ): Promise<T> {
try { try {
const response = await httpRequest<T>(`${API_BASE}${endpoint}`, { // Handle the custom 'data' property by converting it to 'body'
const { data, ...requestOptions } = options;
const finalOptions: RequestInit = {
headers: { headers: {
"Content-Type": "application/json", "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<T>(`${API_BASE}${endpoint}`, finalOptions);
return response.data; return response.data;
} catch (err) { } catch (err) {