refactor(inquiries): Update form validation and error handling
- Change required fields in inquiries form to enforce mandatory input - Enhance API error handler to provide user-friendly messages without exposing raw server text - Improve test cases for API error handling to ensure accurate message mapping and validation - Refactor error messages for clarity and consistency across the application
This commit is contained in:
@@ -4,9 +4,9 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ToastContainer, Zoom } from "react-toastify";
|
import { ToastContainer, Zoom } from "react-toastify";
|
||||||
|
|
||||||
import "../../globals.css";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import GoogleAnalytics from "@/app/_components/GoogleAnalytics";
|
import GoogleAnalytics from "@/app/_components/GoogleAnalytics";
|
||||||
|
import "@/app/globals.css";
|
||||||
|
import { Suspense } from "react";
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|||||||
+25
-4
@@ -43,25 +43,25 @@ const InquiresForm = ({
|
|||||||
name: "full_name",
|
name: "full_name",
|
||||||
label: "Full Name",
|
label: "Full Name",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
required: false,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "company_name",
|
name: "company_name",
|
||||||
label: "Company Name",
|
label: "Company Name",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
required: false,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "work_email",
|
name: "work_email",
|
||||||
label: "Work Email",
|
label: "Work Email",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
required: false,
|
required: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "phone_number",
|
name: "phone_number",
|
||||||
label: "Phone Number",
|
label: "Phone Number",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
required: false,
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -123,6 +123,8 @@ const InquiresForm = ({
|
|||||||
field.name.includes("email") || field.name === "work_email";
|
field.name.includes("email") || field.name === "work_email";
|
||||||
const isPhoneField =
|
const isPhoneField =
|
||||||
field.name.includes("phone") || field.name === "phone_number";
|
field.name.includes("phone") || field.name === "phone_number";
|
||||||
|
const isStrictTextField =
|
||||||
|
field.name === "full_name" || field.name === "company_name";
|
||||||
|
|
||||||
if (isPhoneField) {
|
if (isPhoneField) {
|
||||||
return createFieldValidation(field.label, field.required, {
|
return createFieldValidation(field.label, field.required, {
|
||||||
@@ -150,6 +152,25 @@ const InquiresForm = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStrictTextField) {
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
minLength: field.required
|
||||||
|
? {
|
||||||
|
value: 2,
|
||||||
|
message: `${field.label} must be at least 2 characters`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: `${field.label} must not exceed 200 characters`,
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
value: /^[\p{L}\p{M}\p{N}\s'.,()&/_-]+$/u,
|
||||||
|
message: `${field.label} may only include letters, numbers, spaces, and common punctuation (no HTML or code)`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return createFieldValidation(field.label, field.required, {
|
return createFieldValidation(field.label, field.required, {
|
||||||
maxLength: {
|
maxLength: {
|
||||||
value: 200,
|
value: 200,
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ describe("API Error Handler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseApiError(error);
|
const result = parseApiError(error);
|
||||||
expect(result.generalMessage).toContain("Validation error");
|
expect(result.generalMessage).toContain("highlighted fields");
|
||||||
expect(result.fieldErrors.email).toBe("Invalid email format");
|
expect(result.fieldErrors.email).toMatch(/email/i);
|
||||||
expect(result.fieldErrors.full_name).toContain("Name is too short");
|
expect(result.fieldErrors.full_name).toMatch(/name|short|character/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse 400 errors with message only", () => {
|
it("should map message-only 400 responses without exposing raw server text", () => {
|
||||||
const error = new Error("Bad Request") as AxiosError;
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
error.isAxiosError = true;
|
error.isAxiosError = true;
|
||||||
(error as AxiosError).response = {
|
(error as AxiosError).response = {
|
||||||
@@ -54,7 +54,47 @@ describe("API Error Handler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseApiError(error);
|
const result = parseApiError(error);
|
||||||
expect(result.generalMessage).toBe("Custom validation error message");
|
expect(result.generalMessage).not.toContain("Custom validation");
|
||||||
|
expect(result.generalMessage.length).toBeGreaterThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse nested error object with field errors", () => {
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
error: {
|
||||||
|
code: "validation_failed",
|
||||||
|
errors: { work_email: "invalid_email" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.fieldErrors.work_email).toMatch(/email/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse FastAPI-style detail arrays", () => {
|
||||||
|
const error = new Error("Unprocessable") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 422,
|
||||||
|
data: {
|
||||||
|
detail: [
|
||||||
|
{ loc: ["body", "phone_number"], msg: "value is not a valid phone" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
statusText: "Unprocessable Entity",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.fieldErrors.phone_number).toMatch(/phone/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle 401 unauthorized errors", () => {
|
it("should handle 401 unauthorized errors", () => {
|
||||||
@@ -69,7 +109,7 @@ describe("API Error Handler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseApiError(error);
|
const result = parseApiError(error);
|
||||||
expect(result.generalMessage).toContain("Authentication required");
|
expect(result.generalMessage).toMatch(/Authentication|Sign in/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle 422 unprocessable entity errors", () => {
|
it("should handle 422 unprocessable entity errors", () => {
|
||||||
@@ -88,7 +128,7 @@ describe("API Error Handler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseApiError(error);
|
const result = parseApiError(error);
|
||||||
expect(result.generalMessage).toContain("Validation error");
|
expect(result.generalMessage).toContain("highlighted fields");
|
||||||
expect(result.fieldErrors.phone).toContain("phone number");
|
expect(result.fieldErrors.phone).toContain("phone number");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,7 +144,7 @@ describe("API Error Handler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseApiError(error);
|
const result = parseApiError(error);
|
||||||
expect(result.generalMessage).toContain("Too many requests");
|
expect(result.generalMessage).toMatch(/many|wait/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle 500 server errors", () => {
|
it("should handle 500 server errors", () => {
|
||||||
@@ -119,7 +159,23 @@ describe("API Error Handler", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = parseApiError(error);
|
const result = parseApiError(error);
|
||||||
expect(result.generalMessage).toContain("Server error");
|
expect(result.generalMessage).toMatch(/server|try again/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should give a client-safe message for unlisted 4xx statuses with a body", () => {
|
||||||
|
const error = new Error("Nope") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 418,
|
||||||
|
data: { message: "I'm a teapot" },
|
||||||
|
statusText: "I'm a teapot",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).not.toContain("teapot");
|
||||||
|
expect(result.generalMessage.length).toBeGreaterThan(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,17 +202,15 @@ describe("API Error Handler", () => {
|
|||||||
handleApiError(error, mockSetError, mockShowToast, ["email", "phone"]);
|
handleApiError(error, mockSetError, mockShowToast, ["email", "phone"]);
|
||||||
|
|
||||||
expect(mockSetError).toHaveBeenCalledTimes(2);
|
expect(mockSetError).toHaveBeenCalledTimes(2);
|
||||||
expect(mockSetError).toHaveBeenCalledWith("email", {
|
expect(mockSetError).toHaveBeenCalledWith(
|
||||||
type: "server",
|
"email",
|
||||||
message: "Invalid email",
|
expect.objectContaining({ type: "server", message: expect.any(String) })
|
||||||
});
|
|
||||||
expect(mockSetError).toHaveBeenCalledWith("phone", {
|
|
||||||
type: "server",
|
|
||||||
message: "Invalid phone",
|
|
||||||
});
|
|
||||||
expect(mockShowToast).toHaveBeenCalledWith(
|
|
||||||
"Please correct the errors in the highlighted fields."
|
|
||||||
);
|
);
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith(
|
||||||
|
"phone",
|
||||||
|
expect.objectContaining({ type: "server", message: expect.any(String) })
|
||||||
|
);
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("highlighted fields"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should only set errors for fields in formFields list", () => {
|
it("should only set errors for fields in formFields list", () => {
|
||||||
@@ -181,10 +235,10 @@ describe("API Error Handler", () => {
|
|||||||
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
||||||
|
|
||||||
expect(mockSetError).toHaveBeenCalledTimes(1);
|
expect(mockSetError).toHaveBeenCalledTimes(1);
|
||||||
expect(mockSetError).toHaveBeenCalledWith("email", {
|
expect(mockSetError).toHaveBeenCalledWith(
|
||||||
type: "server",
|
"email",
|
||||||
message: "Invalid email",
|
expect.objectContaining({ type: "server" })
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show general message when no field errors", () => {
|
it("should show general message when no field errors", () => {
|
||||||
@@ -204,7 +258,7 @@ describe("API Error Handler", () => {
|
|||||||
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
||||||
|
|
||||||
expect(mockSetError).not.toHaveBeenCalled();
|
expect(mockSetError).not.toHaveBeenCalled();
|
||||||
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("Server error"));
|
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/server|try again/i));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+275
-85
@@ -1,19 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* API Error Handler Utility
|
* API Error Handler Utility
|
||||||
* Parses server error responses and extracts field-level validation errors
|
* Parses server error responses and extracts field-level validation errors.
|
||||||
|
* Server text is mapped to safe, user-facing copy — raw messages are not echoed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
import { FieldValues, Path, UseFormSetError } from "react-hook-form";
|
import { FieldValues, Path, UseFormSetError } from "react-hook-form";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard API error response structure
|
* Standard API error response structure (and common variants we normalize).
|
||||||
*/
|
*/
|
||||||
export interface ApiErrorResponse {
|
export interface ApiErrorResponse {
|
||||||
message?: string;
|
message?: string;
|
||||||
errors?: Record<string, string | string[]>;
|
errors?: Record<string, string | string[] | unknown> | unknown;
|
||||||
error?: string;
|
error?: string | ApiNestedError | Record<string, unknown>;
|
||||||
detail?: string | Record<string, string[]>;
|
detail?: string | string[] | Record<string, string[] | unknown> | FastApiDetailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiNestedError {
|
||||||
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
errors?: Record<string, unknown>;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FastApiDetailItem {
|
||||||
|
loc?: unknown[];
|
||||||
|
msg?: string;
|
||||||
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,9 +38,12 @@ export interface ParsedApiError {
|
|||||||
fieldErrors: Record<string, string>;
|
fieldErrors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const DEFAULT_GENERAL = "Something went wrong. Please try again.";
|
||||||
* Field name mapping for common API field names to form field names
|
|
||||||
*/
|
/** Shown under a field when the server flagged it but we do not expose its raw text. */
|
||||||
|
const GENERIC_FIELD_MESSAGE =
|
||||||
|
"This value could not be accepted. Check for typos and remove any HTML, scripts, or unusual symbols.";
|
||||||
|
|
||||||
const FIELD_NAME_MAP: Record<string, string> = {
|
const FIELD_NAME_MAP: Record<string, string> = {
|
||||||
fullName: "full_name",
|
fullName: "full_name",
|
||||||
full_name: "full_name",
|
full_name: "full_name",
|
||||||
@@ -41,106 +58,291 @@ const FIELD_NAME_MAP: Record<string, string> = {
|
|||||||
work_email: "work_email",
|
work_email: "work_email",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Common error messages for validation types
|
|
||||||
*/
|
|
||||||
const ERROR_MESSAGES: Record<string, string> = {
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
invalid_input: "Invalid input detected. Please remove any special characters or scripts.",
|
invalid_input:
|
||||||
xss_detected: "Invalid content detected. Please remove any HTML tags or scripts.",
|
"That does not look like valid input. Use plain text only — no HTML or scripts.",
|
||||||
validation_failed: "Validation failed. Please check your input.",
|
xss_detected:
|
||||||
|
"Invalid content was detected. Remove any HTML tags, scripts, or code-like text.",
|
||||||
|
validation_failed: "Please check this field and try again.",
|
||||||
required: "This field is required.",
|
required: "This field is required.",
|
||||||
invalid_email: "Please enter a valid email address.",
|
invalid_email: "Please enter a valid email address.",
|
||||||
invalid_phone: "Please enter a valid phone number.",
|
invalid_phone: "Please enter a valid phone number using digits and common phone symbols only.",
|
||||||
too_short: "Input is too short.",
|
too_short: "This value is too short.",
|
||||||
too_long: "Input is too long.",
|
too_long: "This value is too long.",
|
||||||
|
duplicate: "This value is already in use. Try a different one.",
|
||||||
|
duplicate_inquiry:
|
||||||
|
"You already have a pending inquiry with this email. Please wait for it to be processed.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes field name from API to form field name
|
* Maps a single server token or short code to a friendly message.
|
||||||
*/
|
*/
|
||||||
|
function mapErrorCode(code: string): string | null {
|
||||||
|
const key = code.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||||
|
if (ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
|
||||||
|
const aliases: Record<string, keyof typeof ERROR_MESSAGES> = {
|
||||||
|
invalid_email_format: "invalid_email",
|
||||||
|
email_invalid: "invalid_email",
|
||||||
|
invalid_email: "invalid_email",
|
||||||
|
invalid_phone_format: "invalid_phone",
|
||||||
|
invalid_phone: "invalid_phone",
|
||||||
|
phone_invalid: "invalid_phone",
|
||||||
|
xss: "xss_detected",
|
||||||
|
xss_detected: "xss_detected",
|
||||||
|
malicious_content: "xss_detected",
|
||||||
|
validation_error: "validation_failed",
|
||||||
|
validation_failed: "validation_failed",
|
||||||
|
duplicate_inquiry: "duplicate_inquiry",
|
||||||
|
duplicate: "duplicate",
|
||||||
|
};
|
||||||
|
const mappedKey = aliases[key];
|
||||||
|
return mappedKey ? ERROR_MESSAGES[mappedKey] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers a safe message from server hint text without echoing the full string.
|
||||||
|
*/
|
||||||
|
function inferMessageFromHint(hint: string): string {
|
||||||
|
const h = hint.toLowerCase();
|
||||||
|
if (/(xss|script|html|tag|malicious|injection)/i.test(hint)) {
|
||||||
|
return ERROR_MESSAGES.xss_detected;
|
||||||
|
}
|
||||||
|
if (/email/.test(h)) return ERROR_MESSAGES.invalid_email;
|
||||||
|
if (/phone|tel|mobile/.test(h)) return ERROR_MESSAGES.invalid_phone;
|
||||||
|
if (/company|organization|organisation/.test(h)) {
|
||||||
|
return "Please use a valid company name (plain text, no HTML or scripts).";
|
||||||
|
}
|
||||||
|
if (/name|full/.test(h)) {
|
||||||
|
return "Please enter a valid name using letters and common characters only.";
|
||||||
|
}
|
||||||
|
if (/required|missing|empty/.test(h)) return ERROR_MESSAGES.required;
|
||||||
|
if (/long|length|exceed/.test(h)) return ERROR_MESSAGES.too_long;
|
||||||
|
if (/short|minimum/.test(h)) return ERROR_MESSAGES.too_short;
|
||||||
|
if (/duplicate|already|exist/.test(h)) return ERROR_MESSAGES.duplicate_inquiry;
|
||||||
|
return GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFieldName(fieldName: string): string {
|
function normalizeFieldName(fieldName: string): string {
|
||||||
return FIELD_NAME_MAP[fieldName] || fieldName;
|
return FIELD_NAME_MAP[fieldName] || fieldName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts a human-readable error message from various error formats
|
* Turns assorted server error values into a single user-safe string.
|
||||||
*/
|
*/
|
||||||
function extractErrorMessage(error: string | string[] | unknown): string {
|
function normalizeFieldErrorValue(value: unknown): string {
|
||||||
if (typeof error === "string") {
|
if (value === null || value === undefined) return GENERIC_FIELD_MESSAGE;
|
||||||
return ERROR_MESSAGES[error] || error;
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return mapErrorCode(String(value)) ?? GENERIC_FIELD_MESSAGE;
|
||||||
}
|
}
|
||||||
if (Array.isArray(error)) {
|
if (typeof value === "string") {
|
||||||
return error.map((e) => ERROR_MESSAGES[e] || e).join(". ");
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return GENERIC_FIELD_MESSAGE;
|
||||||
|
const byCode = mapErrorCode(trimmed);
|
||||||
|
if (byCode) return byCode;
|
||||||
|
if (/^[a-z0-9_.]+$/i.test(trimmed) && trimmed.length <= 80) {
|
||||||
|
return mapErrorCode(trimmed.replace(/\./g, "_")) ?? GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
return inferMessageFromHint(trimmed);
|
||||||
}
|
}
|
||||||
return "Invalid input";
|
if (Array.isArray(value)) {
|
||||||
|
const parts = value
|
||||||
|
.map((v) => normalizeFieldErrorValue(v))
|
||||||
|
.filter((s, i, arr) => s && arr.indexOf(s) === i);
|
||||||
|
return parts.length ? parts.join(" ") : GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
if (isPlainObject(value)) {
|
||||||
|
const msg =
|
||||||
|
typeof value.message === "string"
|
||||||
|
? value.message
|
||||||
|
: typeof value.msg === "string"
|
||||||
|
? value.msg
|
||||||
|
: typeof value.detail === "string"
|
||||||
|
? value.detail
|
||||||
|
: "";
|
||||||
|
if (msg) return inferMessageFromHint(msg);
|
||||||
|
}
|
||||||
|
return GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFieldErrors(
|
||||||
|
target: Record<string, string>,
|
||||||
|
source: Record<string, string>
|
||||||
|
): void {
|
||||||
|
for (const [k, v] of Object.entries(source)) {
|
||||||
|
const key = normalizeFieldName(k);
|
||||||
|
target[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorsRecord(record: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [field, err] of Object.entries(record)) {
|
||||||
|
out[field] = normalizeFieldErrorValue(err);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFastApiDetailArray(items: FastApiDetailItem[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const loc = Array.isArray(item.loc) ? item.loc : [];
|
||||||
|
const rawKey = loc.filter((x): x is string => typeof x === "string").pop();
|
||||||
|
const field = rawKey ? normalizeFieldName(rawKey) : "_form";
|
||||||
|
const hint = item.msg ?? item.type ?? "";
|
||||||
|
out[field] = hint ? inferMessageFromHint(String(hint)) : GENERIC_FIELD_MESSAGE;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDetail(detail: unknown): Record<string, string> {
|
||||||
|
if (!detail) return {};
|
||||||
|
if (typeof detail === "string") {
|
||||||
|
return { _form: inferMessageFromHint(detail) };
|
||||||
|
}
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
if (detail.length && typeof detail[0] === "object" && detail[0] !== null && "loc" in detail[0]) {
|
||||||
|
return parseFastApiDetailArray(detail as FastApiDetailItem[]);
|
||||||
|
}
|
||||||
|
return { _form: normalizeFieldErrorValue(detail) };
|
||||||
|
}
|
||||||
|
if (isPlainObject(detail)) {
|
||||||
|
return parseErrorsRecord(detail as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks common API shapes and collects field errors + optional form-level key.
|
||||||
|
*/
|
||||||
|
function extractFieldErrorsFromBody(data: unknown): {
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
formLevel?: string;
|
||||||
|
} {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
let formLevel: string | undefined;
|
||||||
|
|
||||||
|
if (!data) return { fieldErrors };
|
||||||
|
|
||||||
|
if (typeof data === "string") {
|
||||||
|
formLevel = inferMessageFromHint(data);
|
||||||
|
return { fieldErrors, formLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(data)) return { fieldErrors };
|
||||||
|
|
||||||
|
const d = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (isPlainObject(d.errors)) {
|
||||||
|
mergeFieldErrors(fieldErrors, parseErrorsRecord(d.errors as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(d.errors)) {
|
||||||
|
mergeFieldErrors(fieldErrors, { _form: normalizeFieldErrorValue(d.errors) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailParsed = parseDetail(d.detail);
|
||||||
|
mergeFieldErrors(fieldErrors, detailParsed);
|
||||||
|
if (detailParsed._form && !fieldErrors._form) {
|
||||||
|
fieldErrors._form = detailParsed._form;
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = d.error;
|
||||||
|
if (typeof err === "string") {
|
||||||
|
formLevel = inferMessageFromHint(err);
|
||||||
|
} else if (isPlainObject(err)) {
|
||||||
|
const ne = err as ApiNestedError;
|
||||||
|
if (typeof ne.message === "string") {
|
||||||
|
formLevel = inferMessageFromHint(ne.message);
|
||||||
|
}
|
||||||
|
if (typeof ne.code === "string") {
|
||||||
|
const byCode = mapErrorCode(ne.code);
|
||||||
|
if (byCode) formLevel = byCode;
|
||||||
|
}
|
||||||
|
if (isPlainObject(ne.errors)) {
|
||||||
|
mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.errors as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
if (isPlainObject(ne.details)) {
|
||||||
|
mergeFieldErrors(fieldErrors, parseErrorsRecord(ne.details as Record<string, unknown>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof d.message === "string" && !formLevel) {
|
||||||
|
formLevel = inferMessageFromHint(d.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fieldErrors, formLevel };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAxiosResponse(error: unknown): AxiosError<ApiErrorResponse>["response"] | undefined {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
return error.response;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && "isAxiosError" in error && (error as AxiosError).isAxiosError) {
|
||||||
|
return (error as AxiosError<ApiErrorResponse>).response;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses API error response and extracts field-level errors
|
* Parses API error response and extracts field-level errors
|
||||||
* @param error - The axios error object
|
|
||||||
* @returns Parsed error with general message and field-specific errors
|
|
||||||
*/
|
*/
|
||||||
export function parseApiError(error: unknown): ParsedApiError {
|
export function parseApiError(error: unknown): ParsedApiError {
|
||||||
const result: ParsedApiError = {
|
const result: ParsedApiError = {
|
||||||
generalMessage: "Something went wrong. Please try again.",
|
generalMessage: DEFAULT_GENERAL,
|
||||||
fieldErrors: {},
|
fieldErrors: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!(error instanceof Error)) {
|
const response = getAxiosResponse(error);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
result.generalMessage = "Unable to connect to the server. Check your connection and try again.";
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const axiosError = error as AxiosError<ApiErrorResponse>;
|
const { status, data } = response;
|
||||||
|
const extracted = extractFieldErrorsFromBody(data);
|
||||||
|
Object.assign(result.fieldErrors, extracted.fieldErrors);
|
||||||
|
|
||||||
if (!axiosError.response) {
|
const validationSummary =
|
||||||
// Network error
|
"Some information could not be accepted. Please review the highlighted fields below.";
|
||||||
result.generalMessage = "Unable to connect to server. Please check your connection.";
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status, data } = axiosError.response;
|
if (status === 400 || status === 422) {
|
||||||
|
result.generalMessage = validationSummary;
|
||||||
// Handle different HTTP status codes
|
if (extracted.formLevel && Object.keys(result.fieldErrors).length === 0) {
|
||||||
if (status === 400) {
|
result.generalMessage = extracted.formLevel;
|
||||||
result.generalMessage = "Validation error. Please check the highlighted fields.";
|
|
||||||
|
|
||||||
// Extract field errors from different response formats
|
|
||||||
if (data?.errors) {
|
|
||||||
// Format: { errors: { field1: "error", field2: ["error1", "error2"] } }
|
|
||||||
Object.entries(data.errors).forEach(([field, error]) => {
|
|
||||||
const normalizedField = normalizeFieldName(field);
|
|
||||||
result.fieldErrors[normalizedField] = extractErrorMessage(error);
|
|
||||||
});
|
|
||||||
} else if (data?.detail && typeof data.detail === "object") {
|
|
||||||
// Django REST framework format: { detail: { field1: ["error"] } }
|
|
||||||
Object.entries(data.detail).forEach(([field, errors]) => {
|
|
||||||
const normalizedField = normalizeFieldName(field);
|
|
||||||
result.fieldErrors[normalizedField] = extractErrorMessage(errors);
|
|
||||||
});
|
|
||||||
} else if (data?.message) {
|
|
||||||
result.generalMessage = data.message;
|
|
||||||
} else if (data?.error) {
|
|
||||||
result.generalMessage = data.error;
|
|
||||||
}
|
}
|
||||||
} else if (status === 401) {
|
} else if (status === 401) {
|
||||||
result.generalMessage = "Authentication required. Please log in and try again.";
|
result.generalMessage = "Authentication is required. Sign in and try again.";
|
||||||
} else if (status === 403) {
|
} else if (status === 403) {
|
||||||
result.generalMessage = "You don't have permission to perform this action.";
|
result.generalMessage = "You do not have permission to perform this action.";
|
||||||
} else if (status === 404) {
|
} else if (status === 404) {
|
||||||
result.generalMessage = "The requested resource was not found.";
|
result.generalMessage = "The requested resource was not found.";
|
||||||
} else if (status === 422) {
|
} else if (status === 409) {
|
||||||
// Unprocessable Entity - validation error
|
result.generalMessage = ERROR_MESSAGES.duplicate_inquiry;
|
||||||
result.generalMessage = "Validation error. Please check the highlighted fields.";
|
|
||||||
if (data?.errors) {
|
|
||||||
Object.entries(data.errors).forEach(([field, error]) => {
|
|
||||||
const normalizedField = normalizeFieldName(field);
|
|
||||||
result.fieldErrors[normalizedField] = extractErrorMessage(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (status === 429) {
|
} else if (status === 429) {
|
||||||
result.generalMessage = "Too many requests. Please wait a moment and try again.";
|
result.generalMessage = "Too many attempts. Please wait a moment and try again.";
|
||||||
} else if (status >= 500) {
|
} else if (status >= 500) {
|
||||||
result.generalMessage = "Server error. Please try again later.";
|
result.generalMessage = "The server had a problem. Please try again in a few minutes.";
|
||||||
|
} else if (status >= 400 && status < 500) {
|
||||||
|
result.generalMessage =
|
||||||
|
Object.keys(result.fieldErrors).length > 0
|
||||||
|
? validationSummary
|
||||||
|
: extracted.formLevel ?? "We could not complete this request. Please check your input and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.fieldErrors._form) {
|
||||||
|
const formOnly = result.fieldErrors._form;
|
||||||
|
delete result.fieldErrors._form;
|
||||||
|
if (Object.keys(result.fieldErrors).length === 0) {
|
||||||
|
result.generalMessage = formOnly;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -148,10 +350,6 @@ export function parseApiError(error: unknown): ParsedApiError {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles API error and sets form field errors using react-hook-form
|
* Handles API error and sets form field errors using react-hook-form
|
||||||
* @param error - The error from API call
|
|
||||||
* @param setError - react-hook-form setError function
|
|
||||||
* @param showToast - Function to show toast notification
|
|
||||||
* @param formFields - Optional list of form field names to validate against
|
|
||||||
*/
|
*/
|
||||||
export function handleApiError<T extends FieldValues>(
|
export function handleApiError<T extends FieldValues>(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
@@ -161,10 +359,8 @@ export function handleApiError<T extends FieldValues>(
|
|||||||
): void {
|
): void {
|
||||||
const parsedError = parseApiError(error);
|
const parsedError = parseApiError(error);
|
||||||
|
|
||||||
// Set field-specific errors
|
|
||||||
let hasFieldErrors = false;
|
let hasFieldErrors = false;
|
||||||
Object.entries(parsedError.fieldErrors).forEach(([field, message]) => {
|
Object.entries(parsedError.fieldErrors).forEach(([field, message]) => {
|
||||||
// Only set error if field exists in form (or if formFields not specified)
|
|
||||||
if (!formFields || formFields.includes(field)) {
|
if (!formFields || formFields.includes(field)) {
|
||||||
setError(field as Path<T>, {
|
setError(field as Path<T>, {
|
||||||
type: "server",
|
type: "server",
|
||||||
@@ -174,11 +370,5 @@ export function handleApiError<T extends FieldValues>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show toast with general message
|
showToast(parsedError.generalMessage);
|
||||||
// If we have field errors, use a more specific message
|
|
||||||
if (hasFieldErrors) {
|
|
||||||
showToast("Please correct the errors in the highlighted fields.");
|
|
||||||
} else {
|
|
||||||
showToast(parsedError.generalMessage);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user