- {messages.map((msg) => (
+ {formatValidationIssues(messages).map((msg) => (
// eslint-disable-next-line react/jsx-key
{msg}
))}
@@ -353,6 +365,7 @@ function Review({
{APIError && (
0 && (
0 && (
;
+ field?: string;
+ sheet?: string;
+ column?: string;
+ row?: number;
+}
+
+export interface ValidationValidator {
+ pyxform: string;
+ compatibility: string;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ artifact_hash: string;
+ errors: ValidationIssue[];
+ warnings: ValidationIssue[];
+ validator: ValidationValidator;
}
export interface SuffixDict {
diff --git a/react-ui/src/app/types/index.d.ts b/react-ui/src/app/types/index.d.ts
index 24a9fa6..44480ea 100644
--- a/react-ui/src/app/types/index.d.ts
+++ b/react-ui/src/app/types/index.d.ts
@@ -1,12 +1,12 @@
import { AxiosError } from "axios";
import { Dispatch, SetStateAction } from "react";
-import { Indicator, IndicatorArea } from "./api";
+import { Indicator, IndicatorArea, ValidationIssue } from "./api";
interface StepCallback {
(
proceed?: () => void,
step?: number,
- setStep?: Dispatch>
+ setStep?: Dispatch>,
): void;
}
@@ -30,5 +30,8 @@ export type ApiError = AxiosError<{
code?: number;
service?: string;
non_field_errors?: string[];
+ errors?: ValidationIssue[];
+ warnings?: ValidationIssue[];
+ valid?: boolean;
[key: string]: unknown;
}>;
diff --git a/react-ui/src/app/utils/apiError.test.tsx b/react-ui/src/app/utils/apiError.test.tsx
new file mode 100644
index 0000000..be0f1a2
--- /dev/null
+++ b/react-ui/src/app/utils/apiError.test.tsx
@@ -0,0 +1,139 @@
+import {
+ formatValidationIssues,
+ getApiErrorStatus,
+ getApiErrorSummary,
+ getApiErrorTitle,
+ parseApiError,
+ parseValidationWarningsHeader,
+} from "./apiError";
+
+describe("API error handling", () => {
+ it("preserves generic nested error formatting", () => {
+ const error = {
+ response: {
+ status: 400,
+ data: {
+ message: "Request failed",
+ detail: "The survey could not be published.",
+ fields: { name: ["This field is required."] },
+ },
+ },
+ };
+
+ const summary = getApiErrorSummary(error);
+ expect(summary).toContain("Request failed");
+ expect(summary).toContain("fields.name: This field is required.");
+ expect(getApiErrorTitle(error, "Publish failed")).toBe("Publish failed");
+ });
+
+ it("parses structured validation JSON returned as an Axios Blob", async () => {
+ const parsed = await parseApiError({
+ response: {
+ status: 400,
+ data: new Blob([
+ JSON.stringify({
+ valid: false,
+ artifact_hash: "sha256:test",
+ errors: [
+ {
+ code: "PYXFORM_CONVERSION_ERROR",
+ layer: "pyxform",
+ severity: "error",
+ message: "Unknown question type",
+ sheet: "survey",
+ column: "type",
+ row: 4,
+ },
+ ],
+ warnings: [],
+ validator: { pyxform: "4.5.0", compatibility: "1.0" },
+ }),
+ ]),
+ },
+ });
+
+ expect(getApiErrorStatus(parsed)).toBe(400);
+ expect(getApiErrorTitle(parsed)).toBe("Survey validation failed");
+ expect(getApiErrorSummary(parsed)).toContain(
+ "Unknown question type (sheet survey, column type, row 4)",
+ );
+ });
+
+ it("distinguishes validator unavailability with HTTP 503", async () => {
+ const parsed = await parseApiError({
+ response: {
+ status: 503,
+ data: new Blob([
+ JSON.stringify({
+ valid: false,
+ artifact_hash: "sha256:test",
+ errors: [
+ {
+ code: "VALIDATOR_UNAVAILABLE",
+ layer: "validator",
+ severity: "error",
+ message: "Validator unavailable",
+ },
+ ],
+ warnings: [],
+ validator: { pyxform: "4.5.0", compatibility: "1.0" },
+ }),
+ ]),
+ },
+ });
+
+ expect(getApiErrorStatus(parsed)).toBe(503);
+ expect(getApiErrorTitle(parsed)).toBe("Survey validation unavailable");
+ expect(getApiErrorSummary(parsed)).toBe("Validator unavailable");
+ });
+
+ it("formats legacy strings and retains fields absent from issue messages", () => {
+ expect(
+ formatValidationIssues([
+ "legacy warning",
+ {
+ code: "XML_NAME_INVALID",
+ layer: "compatibility",
+ severity: "error",
+ message: "Invalid name",
+ field: "household_name",
+ },
+ ]),
+ ).toEqual([
+ "legacy warning",
+ "Invalid name (field household_name)",
+ ]);
+ });
+
+ it("omits a field location duplicated in the issue message", () => {
+ expect(
+ formatValidationIssues([
+ {
+ code: "EXTERNAL_FILE_MISSING",
+ layer: "compatibility",
+ severity: "error",
+ message:
+ "External file 'test_fail_missing_choices.csv' could not be found.",
+ field: "test_fail_missing_choices.csv",
+ },
+ ]),
+ ).toEqual([
+ "External file 'test_fail_missing_choices.csv' could not be found.",
+ ]);
+ });
+
+ it("parses structured warning headers from binary responses", () => {
+ expect(
+ parseValidationWarningsHeader(
+ JSON.stringify([
+ {
+ code: "PYXFORM_WARNING",
+ layer: "pyxform",
+ severity: "warning",
+ message: "A non-blocking warning",
+ },
+ ]),
+ ),
+ ).toHaveLength(1);
+ });
+});
diff --git a/react-ui/src/app/utils/apiError.tsx b/react-ui/src/app/utils/apiError.tsx
index 6fbd6e1..7617c04 100644
--- a/react-ui/src/app/utils/apiError.tsx
+++ b/react-ui/src/app/utils/apiError.tsx
@@ -1,4 +1,6 @@
import React, { ReactNode } from "react";
+import { ApiError } from "../types";
+import { ValidationIssue, ValidationResult } from "../types/api";
type UnknownRecord = Record;
@@ -21,7 +23,67 @@ function pushMessage(messages: string[], message: unknown, prefix?: string) {
messages.push(prefix ? `${humanizeKey(prefix)}: ${text}` : text);
}
+function isValidationIssue(value: unknown): value is ValidationIssue {
+ if (!isRecord(value)) return false;
+
+ return (
+ typeof value.code === "string" &&
+ typeof value.layer === "string" &&
+ typeof value.severity === "string" &&
+ typeof value.message === "string"
+ );
+}
+
+function isValidationResult(value: unknown): value is ValidationResult {
+ if (!isRecord(value)) return false;
+
+ return (
+ typeof value.valid === "boolean" &&
+ typeof value.artifact_hash === "string" &&
+ Array.isArray(value.errors) &&
+ Array.isArray(value.warnings) &&
+ isRecord(value.validator)
+ );
+}
+
+function getStructuredValidationIssues(value: unknown): ValidationIssue[] {
+ if (isValidationResult(value)) return value.errors.filter(isValidationIssue);
+ if (isRecord(value) && Array.isArray(value.errors)) {
+ return value.errors.filter(isValidationIssue);
+ }
+ return [];
+}
+
+function issueLocation(issue: ValidationIssue) {
+ const location = [
+ issue.sheet && `sheet ${issue.sheet}`,
+ issue.column && `column ${issue.column}`,
+ issue.row !== undefined && `row ${issue.row}`,
+ issue.field &&
+ !issue.message.includes(issue.field) &&
+ `field ${issue.field}`,
+ ].filter(Boolean);
+
+ return location.length ? ` (${location.join(", ")})` : "";
+}
+
+export function formatValidationIssues(
+ issues: Array,
+): string[] {
+ return issues.map((issue) => {
+ if (typeof issue === "string") return issue;
+ return `${issue.message}${issueLocation(issue)}`;
+ });
+}
+
function collectMessages(value: unknown, messages: string[], prefix?: string) {
+ const validationIssues = getStructuredValidationIssues(value);
+ if (validationIssues.length) {
+ if (isRecord(value)) pushMessage(messages, value.message);
+ messages.push(...formatValidationIssues(validationIssues));
+ return;
+ }
+
if (Array.isArray(value)) {
value.forEach((item) => collectMessages(item, messages, prefix));
return;
@@ -55,7 +117,7 @@ export function getApiErrorMessages(
const fallbackMessage = (error as any)?.message || fallback;
const messages: string[] = [];
- if (responseData instanceof Blob) {
+ if (typeof Blob !== "undefined" && responseData instanceof Blob) {
return [fallbackMessage];
}
@@ -66,6 +128,73 @@ export function getApiErrorMessages(
return uniqueMessages.length > 0 ? uniqueMessages : [fallbackMessage];
}
+function readBlob(data: Blob): Promise {
+ if (typeof data.text === "function") return data.text();
+
+ if (typeof FileReader !== "undefined") {
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ""));
+ reader.onerror = () => resolve("");
+ reader.readAsText(data);
+ });
+ }
+
+ return Promise.resolve("");
+}
+
+async function readErrorPayload(data: unknown): Promise {
+ let payload = data;
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
+ payload = await readBlob(data);
+ }
+
+ if (typeof payload === "string") {
+ try {
+ return JSON.parse(payload);
+ } catch {
+ return payload;
+ }
+ }
+
+ return payload;
+}
+
+/** Decode a JSON error body returned as a Blob while preserving AxiosError shape. */
+export async function parseApiError(error: unknown): Promise {
+ if (!isRecord(error) || !isRecord(error.response)) {
+ return error as unknown as ApiError;
+ }
+
+ const { response } = error;
+ if (!("data" in response)) return error as unknown as ApiError;
+
+ const payload = await readErrorPayload(response.data);
+ if (payload === response.data) return error as unknown as ApiError;
+
+ return {
+ ...error,
+ response: {
+ ...response,
+ data: payload,
+ },
+ } as ApiError;
+}
+
+export function getApiErrorStatus(error: unknown): number | undefined {
+ const status = (error as any)?.response?.status;
+ return typeof status === "number" ? status : undefined;
+}
+
+export function getApiErrorTitle(error: unknown, fallback = "Error"): string {
+ const status = getApiErrorStatus(error);
+ if (isValidationResult((error as any)?.response?.data)) {
+ if (status === 400) return "Survey validation failed";
+ if (status === 503) return "Survey validation unavailable";
+ }
+ return fallback;
+}
+
export function renderApiErrorMessage(
error: unknown,
fallback = "An unknown error occurred.",
@@ -95,3 +224,16 @@ export function getApiErrorSummary(
) {
return getApiErrorMessages(error, fallback).join(" ");
}
+
+export function parseValidationWarningsHeader(
+ value: unknown,
+): ValidationIssue[] {
+ if (typeof value !== "string") return [];
+
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? parsed.filter(isValidationIssue) : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/react-ui/src/app/utils/generate.test.tsx b/react-ui/src/app/utils/generate.test.tsx
new file mode 100644
index 0000000..08ab0d8
--- /dev/null
+++ b/react-ui/src/app/utils/generate.test.tsx
@@ -0,0 +1,80 @@
+import type { AxiosResponse } from "axios";
+import { vi } from "vitest";
+import { API } from ".";
+import { AppDispatch } from "../redux/store";
+import { generateDoc, getXLS } from "./generate";
+
+vi.mock("./download", () => ({
+ downloadFile: vi.fn(),
+}));
+
+describe("survey generation organization headers", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const dispatch = vi.fn() as unknown as AppDispatch;
+ const response = {
+ data: {},
+ headers: {},
+ } as unknown as AxiosResponse;
+
+ it("sends shared survey organizations for XLSX and DOCX generation", async () => {
+ const post = vi
+ .spyOn(API, "post")
+ .mockReturnValue(Promise.resolve(response));
+ const surveyForm = {
+ submodules: [1],
+ submodules_order: [],
+ subquestion_submodule_mapping: {},
+ organizations: [{ id: 1 }],
+ };
+
+ await getXLS(dispatch, surveyForm, undefined, true);
+ generateDoc(dispatch, surveyForm, undefined, true);
+
+ expect(post).toHaveBeenNthCalledWith(
+ 1,
+ "/generate/",
+ expect.anything(),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ "Survey-Designer-Organizations": "1",
+ }),
+ }),
+ );
+ expect(post).toHaveBeenNthCalledWith(
+ 2,
+ "/generate-doc/",
+ expect.anything(),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ "Survey-Designer-Organizations": "1",
+ }),
+ }),
+ );
+ });
+
+ it("omits the organization header when the survey has no organizations", async () => {
+ const post = vi
+ .spyOn(API, "post")
+ .mockReturnValue(Promise.resolve(response));
+ const surveyForm = {
+ submodules: [1],
+ submodules_order: [],
+ subquestion_submodule_mapping: {},
+ };
+
+ await getXLS(dispatch, surveyForm, undefined, true);
+
+ expect(post).toHaveBeenCalledWith(
+ "/generate/",
+ expect.anything(),
+ expect.objectContaining({
+ headers: expect.not.objectContaining({
+ "Survey-Designer-Organizations": expect.anything(),
+ }),
+ }),
+ );
+ });
+});
diff --git a/react-ui/src/app/utils/generate.tsx b/react-ui/src/app/utils/generate.tsx
index d3da5ac..1dfe184 100644
--- a/react-ui/src/app/utils/generate.tsx
+++ b/react-ui/src/app/utils/generate.tsx
@@ -6,7 +6,13 @@ import { downloadFile } from "./download";
import { notificationsActions } from "../redux/reducers/notificationReducer";
import { docFetcherActions } from "../redux/reducers/docFetcherReducer";
import { AppDispatch } from "../redux/store";
-import { renderApiErrorMessage } from "./apiError";
+import {
+ formatValidationIssues,
+ getApiErrorTitle,
+ parseApiError,
+ parseValidationWarningsHeader,
+ renderApiErrorMessage,
+} from "./apiError";
export function getOrderedSubmodules(
surveyForm: any,
@@ -78,6 +84,24 @@ export function getDataForGeneration(
};
}
+type SurveyFormOrganizations = {
+ organizations?: Array<{ id: number | string }>;
+};
+
+const getOrganizationHeaders = (
+ surveyForm: SurveyFormOrganizations | null | undefined,
+): Record => {
+ if (!surveyForm?.organizations?.length) {
+ return {};
+ }
+
+ return {
+ "Survey-Designer-Organizations": surveyForm.organizations
+ .map(({ id }) => id)
+ .join(","),
+ };
+};
+
export const getXLS = (
dispatch: AppDispatch,
surveyForm: any,
@@ -92,6 +116,7 @@ export const getXLS = (
responseType: "blob",
headers: {
"X-CSRFToken": csrfToken,
+ ...getOrganizationHeaders(surveyForm),
},
})
.then((res) => {
@@ -106,12 +131,25 @@ export const getXLS = (
}
downloadFile(res, timestamp, mimeType, extension);
+ const warnings = parseValidationWarningsHeader(
+ res.headers["x-survey-validation-warnings"] ||
+ res.headers["x-validation-warnings"],
+ );
+ if (warnings.length) {
+ dispatch(
+ notificationsActions.setWarnNotification({
+ title: "Download completed with warnings",
+ msg: formatValidationIssues(warnings).join("\n"),
+ }),
+ );
+ }
})
- .catch((err) => {
+ .catch(async (err) => {
+ const parsedError = await parseApiError(err);
dispatch(
notificationsActions.setErrorNotification({
- msg: renderApiErrorMessage(err, "Error getting XLS file."),
- title: "Error getting XLS file.",
+ msg: renderApiErrorMessage(parsedError, "Error getting XLS file."),
+ title: getApiErrorTitle(parsedError, "Error getting XLS file."),
}),
);
});
@@ -129,6 +167,7 @@ export const generateDoc = (
API.post("/generate-doc/", data, {
headers: {
"X-CSRFToken": csrfToken,
+ ...getOrganizationHeaders(surveyForm),
},
})
.then((res) => {