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,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);
});
});

View File

@@ -36,20 +36,141 @@ export function safeParse<T>(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<T>(
endpoint: string,
options: RequestInit = {}
options: (RequestInit & { data?: any }) = {}
): Promise<T> {
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: {
"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;
} catch (err) {