Fixed error handling

This commit is contained in:
2026-09-27 14:35:52 +02:00
parent 9ba48de55b
commit 0d626fd84a
5 changed files with 214 additions and 90 deletions
@@ -155,22 +155,43 @@ const ToastCard: React.FC<{
)} )}
</div> </div>
{/* Display errors from ProblemDetails comma-delimited detail or errors list */} {/* Display comma-separated error items from detail property one under another */}
{toast.errorsList && toast.errorsList.length > 1 ? ( {toast.errorsList && toast.errorsList.length > 0 ? (
<div className="mt-1.5 space-y-1"> <div className="mt-2 space-y-1.5">
<ul className="space-y-1"> <ul className="space-y-1.5">
{toast.errorsList.map((err, idx) => ( {toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5"> <li
<span className="text-rose-400 mt-0.5 font-bold">•</span> key={idx}
<span className="leading-snug break-words">{err}</span> className="text-xs text-rose-200/95 flex items-start gap-2 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
>
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
<span className="leading-snug break-words font-medium">{err}</span>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
) : toast.message ? ( ) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words"> <div className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message} {toast.message.includes('\n') ? (
</p> <ul className="space-y-1.5 mt-1">
{toast.message
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((line, idx) => (
<li
key={idx}
className="flex items-start gap-2 text-rose-200/95 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
>
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
<span className="leading-snug break-words font-medium">{line.replace(/^[•\-*]\s*/, '')}</span>
</li>
))}
</ul>
) : (
<p>{toast.message}</p>
)}
</div>
) : null} ) : null}
{/* Optional collapsible technical reference */} {/* Optional collapsible technical reference */}
@@ -310,14 +331,32 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
// Parse friendly non-technical error with ProblemDetails awareness // Parse friendly non-technical error with ProblemDetails awareness
const parsed = parseFriendlyError(rawError, actionTitle); const parsed = parseFriendlyError(rawError, actionTitle);
addToast('error', parsed.title, parsed.message, { let finalMessage = parsed.message;
if (
typeof messageOrError === 'string' &&
messageOrError.trim() &&
!messageOrError.includes('Request failed with status code') &&
(!parsed.hasReturnObject || !parsed.errorsList?.length)
) {
finalMessage = messageOrError.trim();
}
let finalErrorsList = parsed.errorsList || options?.errorsList;
if (!finalErrorsList && typeof finalMessage === 'string' && finalMessage.includes(',')) {
const parts = finalMessage.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 1) {
finalErrorsList = parts;
}
}
addToast('error', parsed.title, finalMessage, {
duration: options?.duration || DEFAULT_DURATIONS.error, duration: options?.duration || DEFAULT_DURATIONS.error,
statusCode: parsed.statusCode || options?.statusCode, statusCode: parsed.statusCode || options?.statusCode,
category: options?.category || parsed.category, category: options?.category || parsed.category,
categoryLabel: options?.categoryLabel || parsed.categoryLabel, categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails, technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors, fieldErrors: parsed.fieldErrors || options?.fieldErrors,
errorsList: parsed.errorsList || options?.errorsList, errorsList: finalErrorsList,
}); });
}; };
@@ -163,7 +163,7 @@ export const BeneficiariesPage: React.FC = () => {
benForm.reset(); benForm.reset();
success('Beneficiary Registered', 'New project beneficiary profile successfully created.'); success('Beneficiary Registered', 'New project beneficiary profile successfully created.');
}, },
onError: (err: any) => toastError('Registration Failed', err.message || 'Unable to save beneficiary.'), onError: (err: any) => toastError('Registration Failed', err),
}); });
const updateBenMutation = useMutation({ const updateBenMutation = useMutation({
@@ -185,7 +185,7 @@ export const BeneficiariesPage: React.FC = () => {
benForm.reset(); benForm.reset();
success('Beneficiary Updated', 'Beneficiary details and targets updated.'); success('Beneficiary Updated', 'Beneficiary details and targets updated.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update beneficiary.'), onError: (err: any) => toastError('Update Failed', err),
}); });
const createCompMutation = useMutation({ const createCompMutation = useMutation({
@@ -199,7 +199,7 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset(); compForm.reset();
success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.'); success('Compliance Requirement Logged', 'Compliance item registered for project beneficiaries.');
}, },
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to record compliance item.'), onError: (err: any) => toastError('Creation Failed', err),
}); });
const updateCompMutation = useMutation({ const updateCompMutation = useMutation({
@@ -219,7 +219,7 @@ export const BeneficiariesPage: React.FC = () => {
compForm.reset(); compForm.reset();
success('Compliance Item Updated', 'Status and requirement record updated.'); success('Compliance Item Updated', 'Status and requirement record updated.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update compliance item.'), onError: (err: any) => toastError('Update Failed', err),
}); });
const createOutcomeMutation = useMutation({ const createOutcomeMutation = useMutation({
@@ -233,7 +233,7 @@ export const BeneficiariesPage: React.FC = () => {
outcomeForm.reset(); outcomeForm.reset();
success('Outcome Logged', 'Beneficiary outcome and impact metric registered.'); success('Outcome Logged', 'Beneficiary outcome and impact metric registered.');
}, },
onError: (err: any) => toastError('Failed to Record Outcome', err.message || 'Unable to save outcome.'), onError: (err: any) => toastError('Failed to Record Outcome', err),
}); });
// Filtered & Sorted Beneficiaries // Filtered & Sorted Beneficiaries
@@ -235,7 +235,7 @@ export const ChangeManagementPage: React.FC = () => {
issueForm.reset(); issueForm.reset();
success('Issue Logged', 'New project issue created and assigned.'); success('Issue Logged', 'New project issue created and assigned.');
}, },
onError: (err: any) => toastError('Creation Failed', err.message || 'Unable to log issue.'), onError: (err: any) => toastError('Creation Failed', err),
}); });
const updateIssueMutation = useMutation({ const updateIssueMutation = useMutation({
@@ -270,7 +270,7 @@ export const ChangeManagementPage: React.FC = () => {
editIssueForm.reset(); editIssueForm.reset();
success('Issue Updated', 'Issue record and resolution status saved successfully.'); success('Issue Updated', 'Issue record and resolution status saved successfully.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update issue.'), onError: (err: any) => toastError('Update Failed', err),
}); });
const resolveIssueMutation = useMutation({ const resolveIssueMutation = useMutation({
@@ -294,7 +294,7 @@ export const ChangeManagementPage: React.FC = () => {
resolveIssueForm.reset(); resolveIssueForm.reset();
success('Issue Resolved', 'Resolution recorded and issue status updated.'); success('Issue Resolved', 'Resolution recorded and issue status updated.');
}, },
onError: (err: any) => toastError('Resolution Failed', err.message || 'Unable to resolve issue.'), onError: (err: any) => toastError('Resolution Failed', err),
}); });
const createChangeMutation = useMutation({ const createChangeMutation = useMutation({
@@ -314,7 +314,7 @@ export const ChangeManagementPage: React.FC = () => {
changeForm.reset(); changeForm.reset();
success('Change Request Submitted', 'Formal change request submitted for review.'); success('Change Request Submitted', 'Formal change request submitted for review.');
}, },
onError: (err: any) => toastError('Submission Failed', err.message || 'Unable to submit change request.'), onError: (err: any) => toastError('Submission Failed', err),
}); });
const resolveChangeMutation = useMutation({ const resolveChangeMutation = useMutation({
@@ -335,7 +335,7 @@ export const ChangeManagementPage: React.FC = () => {
resolveChangeForm.reset(); resolveChangeForm.reset();
success('Change Decision Applied', 'Change request decision successfully resolved.'); success('Change Decision Applied', 'Change request decision successfully resolved.');
}, },
onError: (err: any) => toastError('Decision Failed', err.message || 'Unable to resolve change request.'), onError: (err: any) => toastError('Decision Failed', err),
}); });
const updateChangeMutation = useMutation({ const updateChangeMutation = useMutation({
@@ -356,7 +356,7 @@ export const ChangeManagementPage: React.FC = () => {
editChangeForm.reset(); editChangeForm.reset();
success('Change Request Updated', 'Change proposal and governance status saved.'); success('Change Request Updated', 'Change proposal and governance status saved.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update change request.'), onError: (err: any) => toastError('Update Failed', err),
}); });
// Filtered & Sorted Issues // Filtered & Sorted Issues
@@ -111,7 +111,7 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset(); visitForm.reset();
success('Site Visit Logged', 'Field inspection recorded with inspector findings.'); success('Site Visit Logged', 'Field inspection recorded with inspector findings.');
}, },
onError: (err: any) => toastError('Logging Failed', err.message || 'Unable to save site visit.'), onError: (err: any) => toastError('Logging Failed', err),
}); });
const updateVisitMutation = useMutation({ const updateVisitMutation = useMutation({
@@ -132,7 +132,7 @@ export const SiteVisitsPage: React.FC = () => {
visitForm.reset(); visitForm.reset();
success('Site Visit Updated', 'Inspection report and verification status updated.'); success('Site Visit Updated', 'Inspection report and verification status updated.');
}, },
onError: (err: any) => toastError('Update Failed', err.message || 'Unable to update site visit.'), onError: (err: any) => toastError('Update Failed', err),
}); });
// Filtered & Sorted Visits // Filtered & Sorted Visits
@@ -29,6 +29,8 @@ export interface ProblemDetailsResponse {
title?: string | null; title?: string | null;
status?: number | null; status?: number | null;
detail?: string | null; detail?: string | null;
details?: string | null;
Details?: string | null;
instance?: string | null; instance?: string | null;
errors?: Record<string, string[] | string> | any; errors?: Record<string, string[] | string> | any;
[key: string]: any; [key: string]: any;
@@ -124,7 +126,17 @@ export function inspectErrorResponseObject(error: unknown): ProblemDetailsRespon
// 2. Direct object inspection (e.g. if response.data was passed directly) // 2. Direct object inspection (e.g. if response.data was passed directly)
if (typeof error === 'object' && error !== null) { if (typeof error === 'object' && error !== null) {
if ('detail' in anyErr || 'status' in anyErr || 'title' in anyErr || 'type' in anyErr) { if (
'details' in anyErr ||
'Details' in anyErr ||
'detail' in anyErr ||
'Detail' in anyErr ||
'status' in anyErr ||
'title' in anyErr ||
'type' in anyErr ||
'errors' in anyErr ||
'Errors' in anyErr
) {
return anyErr as ProblemDetailsResponse; return anyErr as ProblemDetailsResponse;
} }
} }
@@ -138,8 +150,11 @@ export function inspectErrorResponseObject(error: unknown): ProblemDetailsRespon
export function isProblemDetailsObject(data: any): data is ProblemDetails { export function isProblemDetailsObject(data: any): data is ProblemDetails {
if (!data || typeof data !== 'object') return false; if (!data || typeof data !== 'object') return false;
return ( return (
'details' in data ||
'Details' in data ||
'title' in data || 'title' in data ||
'detail' in data || 'detail' in data ||
'Detail' in data ||
'status' in data || 'status' in data ||
'type' in data || 'type' in data ||
'errors' in data || 'errors' in data ||
@@ -163,9 +178,13 @@ export function parseCommaDelimitedErrors(detail?: string | null): string[] {
const errors: string[] = []; const errors: string[] = [];
for (const part of rawParts) { for (const part of rawParts) {
const cleaned = cleanTechnicalJargon(part.trim()); const pTrim = part.trim();
if (!pTrim) continue;
const cleaned = cleanTechnicalJargon(pTrim);
if (cleaned && cleaned.length > 0) { if (cleaned && cleaned.length > 0) {
errors.push(cleaned); errors.push(cleaned);
} else if (pTrim.length > 0 && pTrim.toLowerCase() !== 'error') {
errors.push(pTrim);
} }
} }
@@ -301,7 +320,16 @@ export function parseFriendlyError(
// 3. Special handling for Auth (401) and Forbid (403) // 3. Special handling for Auth (401) and Forbid (403)
if (statusCode === 401) { if (statusCode === 401) {
const rawDetail = returnObject?.detail || (error as any)?.message; const rawDetail =
returnObject?.details ??
returnObject?.Details ??
returnObject?.detail ??
returnObject?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.response?.data?.detail ??
anyErr?.details ??
anyErr?.message;
const cleanDetail = cleanTechnicalJargon(rawDetail || ''); const cleanDetail = cleanTechnicalJargon(rawDetail || '');
return { return {
title: 'Authentication Required', title: 'Authentication Required',
@@ -316,7 +344,16 @@ export function parseFriendlyError(
} }
if (statusCode === 403) { if (statusCode === 403) {
const rawDetail = returnObject?.detail || (error as any)?.message; const rawDetail =
returnObject?.details ??
returnObject?.Details ??
returnObject?.detail ??
returnObject?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.response?.data?.detail ??
anyErr?.details ??
anyErr?.message;
const detailErrors = parseCommaDelimitedErrors(rawDetail); const detailErrors = parseCommaDelimitedErrors(rawDetail);
const friendlyMessage = const friendlyMessage =
detailErrors.length > 0 detailErrors.length > 0
@@ -340,16 +377,38 @@ export function parseFriendlyError(
// 4. Case A: Response has a valid return object (e.g. RFC 9110 ProblemDetails) // 4. Case A: Response has a valid return object (e.g. RFC 9110 ProblemDetails)
if (hasReturnObject && returnObject) { if (hasReturnObject && returnObject) {
// Priority: 'detail' property from response, then 'details', etc.
const rawDetail = const rawDetail =
returnObject.detail ?? returnObject.detail ??
returnObject.Detail ?? returnObject.Detail ??
returnObject.details ??
returnObject.Details ??
anyErr?.response?.data?.detail ??
anyErr?.response?.data?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.detail ??
anyErr?.Detail ??
anyErr?.details ??
anyErr?.Details ??
returnObject.message ?? returnObject.message ??
returnObject.error; returnObject.Message ??
returnObject.error ??
returnObject.Error;
// Parse comma-delimited errors from detail property // Parse errors list from details property
const errorsList = parseCommaDelimitedErrors(rawDetail); const errorsList: string[] = [];
// If detail was empty, check if errors array/dictionary exists if (Array.isArray(rawDetail)) {
rawDetail.forEach((e: any) => {
const cleaned = cleanTechnicalJargon(typeof e === 'string' ? e : e?.message || JSON.stringify(e));
if (cleaned) errorsList.push(cleaned);
});
} else if (typeof rawDetail === 'string' && rawDetail.trim()) {
errorsList.push(...parseCommaDelimitedErrors(rawDetail));
}
// If details was empty, check if errors array/dictionary exists
if (errorsList.length === 0 && returnObject.errors) { if (errorsList.length === 0 && returnObject.errors) {
if (Array.isArray(returnObject.errors)) { if (Array.isArray(returnObject.errors)) {
returnObject.errors.forEach((e: any) => { returnObject.errors.forEach((e: any) => {
@@ -406,7 +465,7 @@ export function parseFriendlyError(
} else if (errorsList.length === 1) { } else if (errorsList.length === 1) {
friendlyMessage = errorsList[0]; friendlyMessage = errorsList[0];
} else { } else {
// Fallback message if detail was empty string // Fallback message ONLY if details property was empty
friendlyMessage = friendlyMessage =
operationIntent === 'creation' operationIntent === 'creation'
? 'Failed to create record. Please verify the submitted parameters.' ? 'Failed to create record. Please verify the submitted parameters.'
@@ -440,77 +499,103 @@ export function parseFriendlyError(
let category: ErrorCategory = 'general'; let category: ErrorCategory = 'general';
let categoryLabel: string = 'Error'; let categoryLabel: string = 'Error';
switch (statusCode) { // Check if error or response data has a detail or details property
case 400: const directDetail =
category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation'; anyErr?.response?.data?.detail ??
categoryLabel = 'Bad Request'; anyErr?.response?.data?.Detail ??
message = anyErr?.response?.data?.details ??
operationIntent === 'creation' anyErr?.response?.data?.Details ??
? 'The new record could not be created. Please review the submitted parameters.' anyErr?.detail ??
: operationIntent === 'update' anyErr?.Detail ??
? 'The record could not be updated. Please verify the submitted parameters.' anyErr?.details ??
: 'The request could not be processed due to invalid parameters.'; anyErr?.Details;
break;
case 404: const fallbackErrorsList: string[] = [];
title = 'Record Not Found';
category = 'notFound';
categoryLabel = 'Not Found';
message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
break;
case 409: if (directDetail) {
title = 'Resource Conflict'; if (typeof directDetail === 'string' && directDetail.trim()) {
category = 'conflict'; fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim()));
categoryLabel = 'Conflict'; message = fallbackErrorsList.join('\n');
message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.'; } else if (Array.isArray(directDetail)) {
break; fallbackErrorsList.push(...directDetail.map(String));
message = fallbackErrorsList.join('\n');
}
}
case 422: if (!message) {
category = 'validation'; switch (statusCode) {
categoryLabel = 'Incomplete Data'; case 400:
message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.'; category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation';
break; categoryLabel = 'Bad Request';
message =
operationIntent === 'creation'
? 'The new record could not be created. Please review the submitted parameters.'
: operationIntent === 'update'
? 'The record could not be updated. Please verify the submitted parameters.'
: 'The request could not be processed due to invalid parameters.';
break;
case 429: case 404:
title = 'Rate Limit Exceeded'; title = 'Record Not Found';
category = 'server'; category = 'notFound';
categoryLabel = 'Rate Limit'; categoryLabel = 'Not Found';
message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.'; message = 'The requested resource could not be found (HTTP 404). It may have been archived, removed, or the link is outdated.';
break; break;
case 500: case 409:
case 502: title = 'Resource Conflict';
case 503: category = 'conflict';
case 504: categoryLabel = 'Conflict';
title = 'Service Temporarily Unavailable'; message = 'This operation conflicts with the current status of the resource (HTTP 409). Please refresh the page and try again.';
category = 'server'; break;
categoryLabel = 'Service Issue';
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
break;
default: case 422:
if (typeof error === 'string' && error.trim()) { category = 'validation';
message = cleanTechnicalJargon(error); categoryLabel = 'Incomplete Data';
} else if (error instanceof Error && error.message) { message = 'The submitted information could not be processed by the server (HTTP 422). Please verify all required fields.';
message = cleanTechnicalJargon(error.message); break;
}
if (!message) { case 429:
message = statusCode title = 'Rate Limit Exceeded';
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.` category = 'server';
: 'An unexpected issue occurred while processing this request. Please try again.'; categoryLabel = 'Rate Limit';
} message = 'The system received too many requests in a short time (HTTP 429). Please pause for a moment before trying again.';
break; break;
case 500:
case 502:
case 503:
case 504:
title = 'Service Temporarily Unavailable';
category = 'server';
categoryLabel = 'Service Issue';
message = `The server encountered an error while processing your request (HTTP ${statusCode}). Please try again in a few moments.`;
break;
default:
if (typeof error === 'string' && error.trim()) {
message = cleanTechnicalJargon(error);
} else if (error instanceof Error && error.message) {
message = cleanTechnicalJargon(error.message);
}
if (!message) {
message = statusCode
? `An unexpected server issue occurred (HTTP ${statusCode}). Please try again or contact support.`
: 'An unexpected issue occurred while processing this request. Please try again.';
}
break;
}
} }
return { return {
title, title,
message, message,
statusCode, statusCode,
hasReturnObject: false, hasReturnObject: fallbackErrorsList.length > 0,
isProblemDetails: false, isProblemDetails: false,
category, category,
categoryLabel, categoryLabel,
errorsList: fallbackErrorsList.length > 0 ? fallbackErrorsList : undefined,
technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined), technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined),
}; };
} }