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,6 +499,30 @@ export function parseFriendlyError(
let category: ErrorCategory = 'general'; let category: ErrorCategory = 'general';
let categoryLabel: string = 'Error'; let categoryLabel: string = 'Error';
// Check if error or response data has a detail or details property
const directDetail =
anyErr?.response?.data?.detail ??
anyErr?.response?.data?.Detail ??
anyErr?.response?.data?.details ??
anyErr?.response?.data?.Details ??
anyErr?.detail ??
anyErr?.Detail ??
anyErr?.details ??
anyErr?.Details;
const fallbackErrorsList: string[] = [];
if (directDetail) {
if (typeof directDetail === 'string' && directDetail.trim()) {
fallbackErrorsList.push(...parseCommaDelimitedErrors(directDetail.trim()));
message = fallbackErrorsList.join('\n');
} else if (Array.isArray(directDetail)) {
fallbackErrorsList.push(...directDetail.map(String));
message = fallbackErrorsList.join('\n');
}
}
if (!message) {
switch (statusCode) { switch (statusCode) {
case 400: case 400:
category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation'; category = operationIntent === 'creation' ? 'creation' : operationIntent === 'update' ? 'update' : 'validation';
@@ -502,15 +585,17 @@ export function parseFriendlyError(
} }
break; 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),
}; };
} }