/** * Translate service-reported errors back to the form field that produced them. * * The host validates and submits the *derived payload*, so errors come back * addressed by payload path (`trigger.schedule`) rather than by form field * (`schedule`). The reverse lookup is derived from the same builder that made * the payload; the mapping is lossy by nature, since one payload value can be * built from several fields. */ import type { SetupRequestBody } from "./types"; /** An error the service reported, addressed by payload path where it gave one. */ export interface ManifestServiceError { path: string; message: string; } export interface MappedManifestErrors { /** Keyed by form field name. */ fieldErrors: Record; /** Errors with no field to attach to; shown against the form as a whole. */ formErrors: string[]; } const EMPTY_MAPPED_ERRORS: MappedManifestErrors = { fieldErrors: {}, formErrors: [], }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Turn a service `loc` array into a path into the payload actually sent. * * A validation framework may address a value through segments that are not keys * of the request body — a discriminated union reports * `["body","trigger","cron","schedule"]` for a body whose trigger has no `cron` * key. Walking the payload and skipping segments that do not resolve reduces * that to `trigger.schedule` without knowing anything about the payload's * meaning. */ export function locToPayloadPath( loc: readonly (string | number)[], payload: unknown, ): string { const segments = loc[0] === "body" ? loc.slice(1) : loc; const parts: string[] = []; let current: unknown = payload; segments.forEach((segment) => { if (typeof segment === "number") { if (Array.isArray(current) && parts.length > 0) { parts[parts.length - 1] += `[${segment}]`; current = current[segment]; } return; } if (isRecord(current) && segment in current) { parts.push(segment); current = current[segment]; } }); return parts.join("."); } /** * Read errors out of a service response body, whichever of the two shapes it * uses: a preflight result keyed by field, or a validation failure keyed by * `loc`. */ export function normalizeServiceErrors( body: unknown, payload: SetupRequestBody | null, ): ManifestServiceError[] { if (!isRecord(body)) return []; if (Array.isArray(body.errors)) { return body.errors.flatMap((entry) => { if (!isRecord(entry) || typeof entry.message !== "string") return []; return [ { path: typeof entry.field === "string" ? entry.field : "", message: entry.message, }, ]; }); } const { detail } = body; if (typeof detail === "string") { return [{ path: "", message: detail }]; } if (Array.isArray(detail)) { return detail.flatMap((entry) => { if (!isRecord(entry) || typeof entry.msg !== "string") return []; const loc = Array.isArray(entry.loc) ? (entry.loc as (string | number)[]) : []; return [{ path: locToPayloadPath(loc, payload), message: entry.msg }]; }); } return []; } /** * The fields behind a payload path. * * The map is derived from a payload where every list holds a single entry, so * a path addressing a later index names the same field the first one does. * Reading every index as the first is what lets an error about the second * repository of a multi-value field reach that field at all, rather than * falling through to a form-level message that points at nothing. */ function fieldsAt( path: string, errorMap: Record, ): string[] | undefined { return errorMap[path] ?? errorMap[path.replace(/\[\d+\]/g, "[0]")]; } /** Apply the derived payload-path map, falling back to a form-level error. */ export function mapServiceErrors( errors: readonly ManifestServiceError[], errorMap: Record, ): MappedManifestErrors { if (errors.length === 0) return EMPTY_MAPPED_ERRORS; const fieldErrors: Record = {}; const formErrors: string[] = []; errors.forEach(({ path, message }) => { const fields = path ? fieldsAt(path, errorMap) : undefined; if (!fields?.length) { formErrors.push(message); return; } fields.forEach((field) => { // Keep the first error reported against a field; later ones would // silently replace a message the user has not read yet. if (!(field in fieldErrors)) fieldErrors[field] = message; }); }); return { fieldErrors, formErrors }; }