@stackcraftmx/calcita 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -509,7 +509,7 @@ Para hacer lo mismo en un componente propio: `sx={[{p: 2}, ...sxListOf(sx)]}` (u
509
509
  | `CalcitaTopMenu` | `link`, `root` |
510
510
  | `CalcitaTransferListInput` | `root`, `list`, `actions` |
511
511
  | `CalcitaTwoStepVerification` | `qr`, `secret`, `secretCode`, `header`, `headerTitle`, `headerText`, `menuIcon` |
512
- | `CalcitaWizardLayout` | `stepper`, `phoneSteps`, `actions` |
512
+ | `CalcitaWizardLayout` | `stepper`, `phoneSteps`, `actions`, `nextReason` |
513
513
  | `CalcitaWordmark` | `root`, `mode` |
514
514
 
515
515
  </details>
@@ -807,6 +807,8 @@ export const api = createApiClient({
807
807
  const orders = await api.get('/orders', {params: {page: 2}, signal});
808
808
  await api.post('/orders', {customerId: 7});
809
809
  const {blob, fileName} = await api.download('/orders/export', {onDownloadProgress});
810
+ // un archivo que el servidor arma con datos de la pantalla (una plantilla con de quién es): POST con su cuerpo
811
+ const template = await api.download('/documents/template', {method: 'POST', body: {companyId: 7}, onDownloadProgress});
810
812
  ```
811
813
 
812
814
  - Cada petición lleva el idioma vigente (`Accept-Language`): el servidor responde sus mensajes ya traducidos.
@@ -814,7 +816,7 @@ const {blob, fileName} = await api.download('/orders/export', {onDownloadProgres
814
816
  `requestId` para soporte y los errores por campo de un 422 (los formularios los ponen en su campo). Sin red es la misma
815
817
  forma (status 0); cancelar no es un error.
816
818
  - Cada respuesta le avisa al aviso de versión nueva qué compilación está publicada (header `X-Frontend-Build`).
817
- - Un Excel que llegó cortado no se entrega.
819
+ - Un Excel que llegó cortado no se entrega (también en una descarga con POST).
818
820
 
819
821
  Si se publica otra versión con la pestaña abierta, la pantalla que no se había abierto pide un archivo que ya no existe.
820
822
  `withChunkRecovery` (de `/react-router`) recarga una vez y `RouteErrorScreen` dice «Hay una versión nueva» con su botón;
@@ -1003,6 +1005,21 @@ campos, para lo obligatorio). `FormWizard` parte un formulario en pasos, con pas
1003
1005
  react-hook-form); los ejemplos traen los dos. `hideBackOnFirstStep` no pinta «Anterior» en el primer paso (por omisión sale
1004
1006
  apagado, como antes).
1005
1007
 
1008
+ Un paso que todavía no se puede dejar (faltan documentos obligatorios) lo dice con `steps[].nextDisabledReason`, ya traducido:
1009
+ «Siguiente» se ve apagado y no avanza, y el motivo sale en un aviso al pasar el cursor o al llegar con Tab; en el teléfono va
1010
+ escrito arriba de los botones. En el último paso se apaga el de enviar, y con `stepSelection` `checked` tampoco se salta a un
1011
+ paso de adelante. Con `null`, como siempre. `WizardLayout` suelto lo recibe como `nextDisabledReason` (en el último paso el
1012
+ botón de enviar es suyo: apáguelo usted).
1013
+
1014
+ ```jsx
1015
+ const missing = DOCUMENTS.filter(({field, required}) => required && !values[field]).length;
1016
+ const steps = [
1017
+ {key: 'documents', label: 'Documentos', content: <DocumentsSection />,
1018
+ nextDisabledReason: missing > 0 ? `Faltan ${missing} documentos obligatorios` : null},
1019
+ …
1020
+ ];
1021
+ ```
1022
+
1006
1023
  El `onChange` y el `onBlur` que la pantalla le pase a un campo de formik o de react-hook-form también corren, después de los
1007
1024
  del formulario y con el valor nuevo: `<FormSelectField name="kind" … onChange={(kind) => loadRatesFor(kind)} />`. Antes los
1008
1025
  del formulario los pisaban sin avisar.
@@ -1044,6 +1061,18 @@ En el teléfono la etiqueta se lleva el 38 % del renglón y un nombre largo se p
1044
1061
  anchas sigue a la izquierda. Por omisión `side`, como antes. Para todos los formularios, una vez en el tema:
1045
1062
  `components: {CalcitaFormSection: {defaultProps: {labelPlacement: 'topOnPhone'}}}`.
1046
1063
 
1064
+ La columna de las etiquetas mide el 38 % del renglón hasta 200 px. Una lista de documentos lleva nombres largos y a la derecha
1065
+ solo los botones del cargador: con `labelWidth={FORM_LABEL_WIDTH.auto}` el nombre se lleva lo que sobre y el campo solo lo que
1066
+ ocupa. También acepta un ancho (`'320px'`, `'50%'` o un número en px). Va por sección: las demás de la pantalla no cambian.
1067
+ En el teléfono lo que sobra es poco: con `auto`, mejor junto con `labelPlacement={FORM_LABEL_PLACEMENT.topOnPhone}`.
1068
+ Como las demás piezas, `FormSection` acepta `sx`.
1069
+
1070
+ ```jsx
1071
+ <FormSection title="Expediente" labelWidth={FORM_LABEL_WIDTH.auto} labelPlacement={FORM_LABEL_PLACEMENT.topOnPhone}>
1072
+ {DOCUMENTS.map(({field, label}) => <FormFileUploadField key={field} name={field} label={label} upload={uploadDocument} />)}
1073
+ </FormSection>
1074
+ ```
1075
+
1047
1076
  ### Lo obligatorio, a la vista
1048
1077
 
1049
1078
  Un campo con `required` lleva un asterisco en su etiqueta, en los 17 campos de la casa (texto, número, listas, catálogo,
@@ -229,14 +229,22 @@ function createApiClient({
229
229
  }
230
230
  return mapResponse(json, { method, url });
231
231
  };
232
- const download = async (path, { params, signal, onDownloadProgress, fallbackFileName = "descarga", timeoutMilliseconds = NO_TIMEOUT } = {}) => {
233
- const response = await request("GET", path, { params, signal, parse: API_RESPONSE_PARSE.response, timeoutMilliseconds });
232
+ const download = async (path, {
233
+ method = "GET",
234
+ body,
235
+ params,
236
+ signal,
237
+ onDownloadProgress,
238
+ fallbackFileName = "descarga",
239
+ timeoutMilliseconds = NO_TIMEOUT
240
+ } = {}) => {
241
+ const response = await request(method, path, { body, params, signal, parse: API_RESPONSE_PARSE.response, timeoutMilliseconds });
234
242
  const blob = await readBlobWithProgress(response, onDownloadProgress);
235
243
  const fileName = fileNameFromContentDisposition(response.headers.get("content-disposition"), fallbackFileName);
236
244
  const contentType = String(response.headers.get("content-type") ?? "").split(";")[0].trim();
237
245
  if (contentType === XLSX_CONTENT_TYPE && !await isCompleteZip(blob)) {
238
246
  const requestId = response.headers.get("x-request-id");
239
- log.error(`GET ${path} → el Excel llegó incompleto: el servidor falló a media escritura`, { requestId, fileName });
247
+ log.error(`${method} ${path} → el Excel llegó incompleto: el servidor falló a media escritura`, { requestId, fileName });
240
248
  throw new ApiError({ status: SERVER_ERROR_STATUS, problem: { request_id: requestId } });
241
249
  }
242
250
  return { blob, fileName };
@@ -1 +1 @@
1
- {"version":3,"file":"createApiClient.js","sources":["../../src/api/createApiClient.js"],"sourcesContent":["// La petición de la casa: TODO lo que habla con el servidor pasa por un cliente armado aquí, con fetch del navegador (sin\n// axios ni otra librería). Lo que resuelve UNA vez y no en cada pantalla:\n// - Accept-Language con el idioma vigente de ToolkitProvider en CADA petición: el servidor responde title y detail ya\n// traducidos. Cambiar el idioma aplica a la siguiente petición sin recargar.\n// - errores como ApiError (ApiError.js): el problema RFC 9457 del servidor con el mensaje ya traducido; si la\n// respuesta llegó en otro idioma (un error que armó nginx), el texto genérico del paquete. Sin red o se acabó el\n// tiempo: un ApiError con status 0, la misma forma, para que la pantalla no tenga dos formas de error.\n// - cancelar NO es un error: el AbortError se relanza intacto (quien canceló ya sabe por qué).\n// - 401 (la sesión se venció a media consulta): avisa a onUnauthenticated, que normalmente lleva a iniciar sesión. Una\n// petición que espera el 401 (preguntar si hay sesión al arrancar, el login) pasa skipUnauthenticatedHandler.\n// - el aviso «Hay una versión nueva»: cada respuesta trae en un header qué compilación del front está publicada y se\n// le entrega a reportPublishedBuild.\n// - Laravel (Sanctum): con csrf, manda la cookie XSRF-TOKEN en su header (solo al mismo origen) y ante un 419 pide una\n// cookie nueva y repite la petición UNA vez.\n// - cuerpo objeto → JSON; FormData, Blob y URLSearchParams van tal cual (el navegador pone su boundary).\n// - descargas: el Blob y el nombre que mandó el servidor, con avance; un Excel que llegó cortado (el servidor falló a\n// media escritura después de responder 200) no se entrega.\n// - eventos hacia afuera, para lo que cada proyecto haga a su manera: onRequest (sale), onResponse (llegó, con su status\n// y cuánto tardó) y onError (CADA error en un solo lugar: Sentry, un aviso propio, una métrica). Cancelar no es error.\n// Si un evento del proyecto truena, la petición sigue y se anota en el registro.\n// - un backend que ya existe y contesta a su manera, sin cambiarlo: toProblem convierte su error a la forma RFC 9457\n// (Laravel manda {message, errors}) y mapResponse desenvuelve sus respuestas buenas ({data: …}).\n//\n// const api = createApiClient({baseUrl: '/api/v1', onUnauthenticated: () => navigate('/sign-in')});\n// const orders = await api.get('/orders', {params: {page: 2}, signal});\n// await api.post('/orders', {customerId: 7});\n//\n// Se arma uno por proyecto (en su core) y las pantallas lo usan desde su *Api.js.\n//\n// Funciones puras, no renderizan: function (regla de la casa).\nimport {ApiError, API_ERROR_STATUS} from '@/api/ApiError.js';\nimport {XLSX_CONTENT_TYPE, fileNameFromContentDisposition, isCompleteZip} from '@/files.js';\nimport {CSRF_TOKEN_MISMATCH_STATUS} from '@/laravel/csrfRetry.js';\nimport {createLogger} from '@/observability/logger.js';\nimport {reportPublishedBuild} from '@/observability/publishedBuild.js';\nimport {DEFAULT_LANGUAGE, toolkitSettings} from '@/toolkit/toolkitSettings.js';\n\nconst log = createLogger('api');\n\n/** Cómo se lee una respuesta que salió bien. */\nexport const API_RESPONSE_PARSE = Object.freeze({json: 'json', blob: 'blob', response: 'response'});\n\n// Las consultas se cortan a los 60 s; las descargas van sin límite (0): el servidor arma el archivo mientras lo manda y\n// uno grande no cabe en 60 s. No se queda colgada: se cancela desde el panel de descargas.\nconst DEFAULT_TIMEOUT_MILLISECONDS = 60_000;\nconst NO_TIMEOUT = 0;\nconst DEFAULT_PUBLISHED_BUILD_HEADER = 'X-Frontend-Build';\nconst NO_CONTENT_STATUS = 204;\nconst SERVER_ERROR_STATUS = 500;\n\n/**\n * La dirección completa: la base del cliente más el camino, con los parámetros. Un camino que ya trae http(s) va tal cual.\n * Los parámetros vacíos (undefined, null) no viajan; una lista viaja repetida (estado=a&estado=b).\n *\n * @param {string} baseUrl\n * @param {string} path\n * @param {Record<string, unknown>} [params]\n * @returns {string}\n */\nexport function apiUrlOf(baseUrl, path, params) {\n const url = /^https?:/i.test(path) ? path : `${baseUrl}${path}`;\n const query = new URLSearchParams();\n for (const [name, value] of Object.entries(params ?? {})) {\n for (const item of [].concat(value)) {\n if (item !== undefined && item !== null) {\n query.append(name, String(item));\n }\n }\n }\n const queryText = query.toString();\n if (!queryText) {\n return url;\n }\n return `${url}${url.includes('?') ? '&' : '?'}${queryText}`;\n}\n\n/**\n * ¿El cuerpo va tal cual? FormData (archivos), Blob y URLSearchParams los arma el navegador con su propio tipo.\n *\n * @param {unknown} body\n * @returns {boolean}\n */\nfunction isRawBody(body) {\n return (typeof FormData !== 'undefined' && body instanceof FormData)\n || (typeof Blob !== 'undefined' && body instanceof Blob)\n || body instanceof URLSearchParams\n || body instanceof ArrayBuffer;\n}\n\n/**\n * El valor de una cookie del navegador (la XSRF-TOKEN de Laravel), o null.\n *\n * @param {string} cookieName\n * @returns {string|null}\n */\nfunction readCookie(cookieName) {\n if (typeof document === 'undefined') {\n return null;\n }\n const prefix = `${cookieName}=`;\n const cookie = document.cookie.split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));\n return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;\n}\n\n/**\n * ¿La dirección es del mismo origen que la página? El token CSRF solo viaja al servidor propio: mandarlo a otro dominio\n * se lo regalaría.\n *\n * @param {string} url\n * @returns {boolean}\n */\nfunction isSameOrigin(url) {\n if (typeof window === 'undefined' || !/^https?:/i.test(url)) {\n return true;\n }\n return new URL(url).origin === window.location.origin;\n}\n\n/**\n * Retry-After en segundos: el estándar admite segundos o una fecha HTTP.\n *\n * @param {string|null} retryAfterHeader\n * @returns {number|null}\n */\nexport function retryAfterSecondsOf(retryAfterHeader) {\n if (!retryAfterHeader) {\n return null;\n }\n if (/^\\d+$/.test(retryAfterHeader.trim())) {\n return Number(retryAfterHeader.trim());\n }\n const retryAt = Date.parse(retryAfterHeader);\n return Number.isNaN(retryAt) ? null : Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));\n}\n\n/**\n * El cuerpo RFC 9457 de una respuesta de error, o null si no es JSON (un 502 del proxy en HTML).\n *\n * @param {Response} response\n * @returns {Promise<object|null>}\n */\n/**\n * El problema RFC 9457 del cuerpo de error, pasado por el adaptador del proyecto (toProblem). Lo que no sea un objeto\n * cuenta como sin problema (se usa el texto genérico del paquete).\n *\n * @param {(body: any, response: Response) => any} toProblem\n * @param {object|null} body\n * @param {Response} response\n * @returns {object|null}\n */\nfunction toProblemOrNull(toProblem, body, response) {\n const problem = body === null ? null : toProblem(body, response);\n return problem && typeof problem === 'object' && !Array.isArray(problem) ? problem : null;\n}\n\nasync function readProblem(response) {\n try {\n const body = JSON.parse(await response.text());\n return body && typeof body === 'object' && !Array.isArray(body) ? body : null;\n } catch {\n return null;\n }\n}\n\n/**\n * El ApiError de una respuesta que salió mal, para lo que no pasa por createApiClient: la subida con avance\n * (uploadWithProgress regresa la respuesta tal cual) o un fetch propio. Es el mismo error que da el cliente: el problema RFC\n * 9457 del servidor ({title, detail, errors}), su status (401 = sesión vencida, 422 = errores por campo) y el texto del\n * paquete si el cuerpo no es JSON (un 502 del proxy). Así la pantalla no lee el error a mano.\n *\n * const response = await uploadWithProgress(url, form, {headers, onProgress, signal});\n * if (!response.ok) throw await apiErrorOf(response);\n *\n * @param {Response} response\n * @param {{toProblem?: (body: any, response: Response) => any}} [options] el mismo adaptador de createApiClient, para un\n * backend que contesta a su manera (Laravel manda {message, errors})\n * @returns {Promise<ApiError>}\n */\nexport async function apiErrorOf(response, {toProblem = (body) => body} = {}) {\n const problem = toProblemOrNull(toProblem, await readProblem(response), response);\n return new ApiError({status: response.status, problem, retryAfterSeconds: retryAfterSecondsOf(response.headers.get('retry-after'))});\n}\n\n/**\n * La señal de la petición: la de quien la pidió (para cancelar) y la del tiempo máximo, juntas.\n *\n * @param {AbortSignal|undefined} signal\n * @param {number} timeoutMilliseconds 0 = sin límite\n * @returns {AbortSignal|undefined}\n */\nfunction combinedSignalOf(signal, timeoutMilliseconds) {\n const signals = [signal, timeoutMilliseconds > 0 ? AbortSignal.timeout(timeoutMilliseconds) : null].filter(Boolean);\n if (signals.length < 2) {\n return signals[0];\n }\n return AbortSignal.any(signals);\n}\n\n/**\n * Lee el cuerpo de una descarga avisando cuánto lleva. total viene en null cuando el servidor arma el archivo al vuelo\n * (sin Content-Length).\n *\n * @param {Response} response\n * @param {((progress: {loaded: number, total: number|null}) => void)|undefined} onDownloadProgress\n * @returns {Promise<Blob>}\n */\nasync function readBlobWithProgress(response, onDownloadProgress) {\n if (!onDownloadProgress || !response.body) {\n return response.blob();\n }\n const total = Number(response.headers.get('content-length')) || null;\n const reader = response.body.getReader();\n const chunks = [];\n let loaded = 0;\n for (let part = await reader.read(); !part.done; part = await reader.read()) {\n chunks.push(part.value);\n loaded += part.value.byteLength;\n onDownloadProgress({loaded, total});\n }\n return new Blob(chunks, {type: response.headers.get('content-type') ?? ''});\n}\n\n/**\n * Lo que acepta cada petición.\n *\n * @typedef {object} ApiRequestOptions\n * @property {unknown} [body] objeto → JSON; FormData, Blob o URLSearchParams tal cual\n * @property {Record<string, unknown>} [params] los de la dirección (?page=2)\n * @property {AbortSignal} [signal] para cancelar\n * @property {Record<string, string>} [headers]\n * @property {'json'|'blob'|'response'} [parse] cómo se lee la respuesta (API_RESPONSE_PARSE); por omisión JSON\n * @property {boolean} [skipUnauthenticatedHandler] un 401 que se espera (¿hay sesión?, el login)\n * @property {number} [timeoutMilliseconds] el de esta petición\n */\n\n/**\n * Lo que acepta una descarga.\n *\n * @typedef {object} ApiDownloadOptions\n * @property {Record<string, unknown>} [params]\n * @property {AbortSignal} [signal]\n * @property {(progress: {loaded: number, total: number|null}) => void} [onDownloadProgress]\n * @property {string} [fallbackFileName] si el servidor no manda el nombre\n * @property {number} [timeoutMilliseconds] por omisión sin límite\n */\n\n/**\n * El cliente del proyecto: cada método regresa el JSON de la respuesta (null si vino vacía) o truena con un ApiError.\n *\n * @typedef {object} ApiClient\n * @property {(method: string, path: string, options?: ApiRequestOptions) => Promise<any>} request\n * @property {(path: string, options?: ApiRequestOptions) => Promise<any>} get\n * @property {(path: string, body?: unknown, options?: ApiRequestOptions) => Promise<any>} post\n * @property {(path: string, body?: unknown, options?: ApiRequestOptions) => Promise<any>} put\n * @property {(path: string, body?: unknown, options?: ApiRequestOptions) => Promise<any>} patch\n * @property {(path: string, options?: ApiRequestOptions) => Promise<any>} delete\n * @property {(path: string, options?: ApiDownloadOptions) => Promise<{blob: Blob, fileName: string}>} download\n */\n\n/**\n * Arma el cliente del proyecto.\n *\n * @param {object} [options]\n * @param {string} [options.baseUrl] lo que va antes de cada camino, p. ej. '/api/v1'\n * @param {RequestCredentials} [options.credentials] 'same-origin' (por omisión, la cookie de sesión del mismo dominio) o\n * 'include' (un API en otro dominio con cookies)\n * @param {() => string|null|undefined} [options.getToken] con él cada petición lleva Authorization: Bearer; sin token, va\n * sin el header (el servidor responde 401)\n * @param {Record<string, string>} [options.headers] los que van en todas las peticiones\n * @param {string|null} [options.publishedBuildHeader] el header con la compilación publicada; null para no leerlo\n * @param {{cookieName?: string, headerName?: string, refreshPath?: string}|null} [options.csrf] Laravel Sanctum:\n * {refreshPath: '/auth/csrf-token'}; por omisión la cookie XSRF-TOKEN y el header X-XSRF-TOKEN\n * @param {number} [options.timeoutMilliseconds] el tiempo máximo de una consulta (0 = sin límite); por omisión 60 s\n * @param {(error: ApiError) => void} [options.onUnauthenticated] qué hacer con un 401 (normalmente ir a iniciar sesión)\n * @param {(request: {method: string, url: string}) => void} [options.onRequest] cada petición, al salir\n * @param {(response: {method: string, url: string, status: number, durationMilliseconds: number}) => void} [options.onResponse]\n * cada respuesta, buena o mala, al llegar\n * @param {(error: ApiError, request: {method: string, url: string}) => void} [options.onError] cada error (sin red, 4xx, 5xx,\n * una respuesta que no es JSON), en un solo lugar: Sentry, un aviso propio. Cancelar no es error\n * @param {(body: any, response: Response) => object|null} [options.toProblem] el error de un backend que no contesta en\n * RFC 9457, a esa forma: (body) => ({title: body.message, errors: body.errors}). Por omisión el cuerpo tal cual\n * @param {(body: any, request: {method: string, url: string}) => any} [options.mapResponse] la respuesta buena de un backend\n * que la envuelve: (body) => body.data. Por omisión tal cual\n * @param {typeof fetch} [options.fetch] para un servidor de mentira o para las pruebas; por omisión el del navegador\n * @returns {ApiClient}\n */\nexport function createApiClient({\n baseUrl = '',\n credentials = 'same-origin',\n getToken,\n headers: commonHeaders = {},\n publishedBuildHeader = DEFAULT_PUBLISHED_BUILD_HEADER,\n csrf = null,\n timeoutMilliseconds: defaultTimeoutMilliseconds = DEFAULT_TIMEOUT_MILLISECONDS,\n onUnauthenticated,\n onRequest,\n onResponse,\n onError,\n toProblem = (body) => body,\n mapResponse = (body) => body,\n fetch: fetchImplementation,\n} = {}) {\n // un evento del proyecto que truena no tumba la petición: se anota y se sigue (nunca en silencio)\n const notify = (eventName, handler, ...eventArguments) => {\n try {\n handler?.(...eventArguments);\n } catch (error) {\n log.error(`el evento ${eventName} del proyecto tronó`, {message: error?.message});\n }\n };\n // OJO: se lee al pedir, no al armar el cliente: así un servidor de mentira que se conecta después también sirve\n const fetchOf = () => fetchImplementation ?? globalThis.fetch;\n const csrfCookieName = csrf?.cookieName ?? 'XSRF-TOKEN';\n const csrfHeaderName = csrf?.headerName ?? 'X-XSRF-TOKEN';\n\n /** Los headers de una petición: los comunes, el idioma vigente, el token y el CSRF. */\n const headersOf = (url, body, extraHeaders) => {\n const headers = {\n Accept: 'application/json',\n 'Accept-Language': toolkitSettings().language ?? DEFAULT_LANGUAGE,\n ...commonHeaders,\n ...extraHeaders,\n };\n if (body !== undefined && !isRawBody(body)) {\n headers['Content-Type'] = 'application/json';\n }\n const token = getToken?.();\n if (token) {\n headers.Authorization = `Bearer ${token}`;\n }\n const csrfToken = csrf && isSameOrigin(url) ? readCookie(csrfCookieName) : null;\n if (csrfToken) {\n headers[csrfHeaderName] = csrfToken;\n }\n return headers;\n };\n\n /** La petición al servidor, ya con su respuesta o con el ApiError de «sin respuesta». */\n const send = async (method, url, {body, signal, headers, timeoutMilliseconds}) => {\n const finalHeaders = headersOf(url, body, headers);\n try {\n return await fetchOf()(url, {\n method,\n headers: finalHeaders,\n body: body === undefined || isRawBody(body) ? body : JSON.stringify(body),\n credentials,\n signal: combinedSignalOf(signal, timeoutMilliseconds),\n });\n } catch (error) {\n // cancelar no es un error de negocio: quien canceló ya sabe por qué\n if (signal?.aborted) {\n throw error;\n }\n const reason = error?.name === 'TimeoutError' ? 'se acabó el tiempo' : 'la petición no llegó al servidor';\n log.error(reason, {method, url, cause: error?.name});\n throw new ApiError({status: API_ERROR_STATUS.offline});\n }\n };\n\n /**\n * Cualquier petición.\n *\n * @param {string} method 'GET', 'POST', 'PUT', 'PATCH' o 'DELETE'\n * @param {string} path relativo a baseUrl (o completo con http)\n * @param {object} [options]\n * @param {unknown} [options.body] objeto → JSON; FormData, Blob o URLSearchParams tal cual\n * @param {Record<string, unknown>} [options.params] los de la dirección (?page=2)\n * @param {AbortSignal} [options.signal] para cancelar\n * @param {Record<string, string>} [options.headers]\n * @param {'json'|'blob'|'response'} [options.parse] cómo se lee la respuesta (API_RESPONSE_PARSE); por omisión JSON\n * @param {boolean} [options.skipUnauthenticatedHandler] un 401 que se espera (¿hay sesión?, el login)\n * @param {number} [options.timeoutMilliseconds] el de esta petición\n * @returns {Promise<any>} el JSON (null si vino vacío o 204), el Blob o la Response\n */\n const request = async (method, path, options = {}) => {\n const url = apiUrlOf(baseUrl, path, options.params);\n try {\n return await requestOnce(method, url, options);\n } catch (error) {\n // cada error del servidor (o sin red) sale por onError; cancelar no es error\n if (error instanceof ApiError) {\n notify('onError', onError, error, {method, url});\n }\n throw error;\n }\n };\n\n const requestOnce = async (method, url, {\n body, signal, headers, parse = API_RESPONSE_PARSE.json, skipUnauthenticatedHandler = false,\n timeoutMilliseconds = defaultTimeoutMilliseconds,\n } = {}) => {\n notify('onRequest', onRequest, {method, url});\n const startedAt = Date.now();\n let response = await send(method, url, {body, signal, headers, timeoutMilliseconds});\n\n // 419 de Laravel: la cookie CSRF venció; se pide una nueva y se repite la MISMA petición una sola vez\n if (response.status === CSRF_TOKEN_MISMATCH_STATUS && csrf?.refreshPath) {\n log.info('token CSRF vencido: se renueva y se reintenta', {method, url});\n await send('GET', apiUrlOf(baseUrl, csrf.refreshPath), {signal, timeoutMilliseconds});\n response = await send(method, url, {body, signal, headers, timeoutMilliseconds});\n }\n\n notify('onResponse', onResponse, {method, url, status: response.status, durationMilliseconds: Date.now() - startedAt});\n\n // cada respuesta (también las de error) dice qué compilación del front está publicada\n if (publishedBuildHeader) {\n reportPublishedBuild(response.headers.get(publishedBuildHeader));\n }\n\n if (!response.ok) {\n const problem = toProblemOrNull(toProblem, await readProblem(response), response);\n // un error que armó otro (nginx) puede llegar en otro idioma: ahí se usa el texto genérico del paquete\n const requestedLanguage = headersOf(url, undefined, headers)['Accept-Language'];\n const responseLanguage = response.headers.get('content-language');\n const apiError = new ApiError({\n status: response.status,\n problem,\n translatedByServer: !responseLanguage || responseLanguage.startsWith(requestedLanguage),\n retryAfterSeconds: retryAfterSecondsOf(response.headers.get('retry-after')),\n });\n // los 4xx son parte del flujo (un 422 es un formulario, un 401 al arrancar es «sin sesión»); los 5xx no\n log[response.status >= SERVER_ERROR_STATUS ? 'error' : 'debug'](`${method} ${url} → ${response.status}`, {\n code: apiError.code, requestId: apiError.requestId ?? response.headers.get('x-request-id'),\n });\n if (apiError.isUnauthenticated && !skipUnauthenticatedHandler) {\n onUnauthenticated?.(apiError);\n }\n throw apiError;\n }\n\n if (parse === API_RESPONSE_PARSE.response) {\n return response;\n }\n if (parse === API_RESPONSE_PARSE.blob) {\n return response.blob();\n }\n if (response.status === NO_CONTENT_STATUS) {\n return null;\n }\n // un 200 con el cuerpo vacío pasa de verdad; JSON.parse('') tronaría\n const text = await response.text();\n if (!text) {\n return null;\n }\n let json;\n try {\n json = JSON.parse(text);\n } catch {\n // un 200 que no es JSON: casi siempre la página de la app que respondió en lugar del API (el camino está mal)\n log.error(`${method} ${url} respondió algo que no es JSON`, {contentType: response.headers.get('content-type')});\n throw new ApiError({status: SERVER_ERROR_STATUS});\n }\n return mapResponse(json, {method, url});\n };\n\n /**\n * Descarga un archivo: el Blob y el nombre que mandó el servidor, con avance. Un Excel cortado no se entrega.\n *\n * @param {string} path\n * @param {object} [options]\n * @param {Record<string, unknown>} [options.params]\n * @param {AbortSignal} [options.signal]\n * @param {(progress: {loaded: number, total: number|null}) => void} [options.onDownloadProgress]\n * @param {string} [options.fallbackFileName] si el servidor no manda el nombre\n * @param {number} [options.timeoutMilliseconds] por omisión sin límite\n * @returns {Promise<{blob: Blob, fileName: string}>}\n * @throws {ApiError} status 500 si un Excel llegó incompleto\n */\n const download = async (path, {params, signal, onDownloadProgress, fallbackFileName = 'descarga', timeoutMilliseconds = NO_TIMEOUT} = {}) => {\n const response = await request('GET', path, {params, signal, parse: API_RESPONSE_PARSE.response, timeoutMilliseconds});\n const blob = await readBlobWithProgress(response, onDownloadProgress);\n const fileName = fileNameFromContentDisposition(response.headers.get('content-disposition'), fallbackFileName);\n // el tipo puede traer parámetros después del «;» (charset): solo cuenta lo de antes\n const contentType = String(response.headers.get('content-type') ?? '').split(';')[0].trim();\n if (contentType === XLSX_CONTENT_TYPE && !(await isCompleteZip(blob))) {\n const requestId = response.headers.get('x-request-id');\n log.error(`GET ${path} → el Excel llegó incompleto: el servidor falló a media escritura`, {requestId, fileName});\n throw new ApiError({status: SERVER_ERROR_STATUS, problem: {request_id: requestId}});\n }\n return {blob, fileName};\n };\n\n return {\n request,\n get: (path, options) => request('GET', path, options),\n post: (path, body, options) => request('POST', path, {...options, body}),\n put: (path, body, options) => request('PUT', path, {...options, body}),\n patch: (path, body, options) => request('PATCH', path, {...options, body}),\n delete: (path, options) => request('DELETE', path, options),\n download,\n };\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"createApiClient.js","sources":["../../src/api/createApiClient.js"],"sourcesContent":["// La petición de la casa: TODO lo que habla con el servidor pasa por un cliente armado aquí, con fetch del navegador (sin\n// axios ni otra librería). Lo que resuelve UNA vez y no en cada pantalla:\n// - Accept-Language con el idioma vigente de ToolkitProvider en CADA petición: el servidor responde title y detail ya\n// traducidos. Cambiar el idioma aplica a la siguiente petición sin recargar.\n// - errores como ApiError (ApiError.js): el problema RFC 9457 del servidor con el mensaje ya traducido; si la\n// respuesta llegó en otro idioma (un error que armó nginx), el texto genérico del paquete. Sin red o se acabó el\n// tiempo: un ApiError con status 0, la misma forma, para que la pantalla no tenga dos formas de error.\n// - cancelar NO es un error: el AbortError se relanza intacto (quien canceló ya sabe por qué).\n// - 401 (la sesión se venció a media consulta): avisa a onUnauthenticated, que normalmente lleva a iniciar sesión. Una\n// petición que espera el 401 (preguntar si hay sesión al arrancar, el login) pasa skipUnauthenticatedHandler.\n// - el aviso «Hay una versión nueva»: cada respuesta trae en un header qué compilación del front está publicada y se\n// le entrega a reportPublishedBuild.\n// - Laravel (Sanctum): con csrf, manda la cookie XSRF-TOKEN en su header (solo al mismo origen) y ante un 419 pide una\n// cookie nueva y repite la petición UNA vez.\n// - cuerpo objeto → JSON; FormData, Blob y URLSearchParams van tal cual (el navegador pone su boundary).\n// - descargas: el Blob y el nombre que mandó el servidor, con avance; un Excel que llegó cortado (el servidor falló a\n// media escritura después de responder 200) no se entrega.\n// - eventos hacia afuera, para lo que cada proyecto haga a su manera: onRequest (sale), onResponse (llegó, con su status\n// y cuánto tardó) y onError (CADA error en un solo lugar: Sentry, un aviso propio, una métrica). Cancelar no es error.\n// Si un evento del proyecto truena, la petición sigue y se anota en el registro.\n// - un backend que ya existe y contesta a su manera, sin cambiarlo: toProblem convierte su error a la forma RFC 9457\n// (Laravel manda {message, errors}) y mapResponse desenvuelve sus respuestas buenas ({data: …}).\n//\n// const api = createApiClient({baseUrl: '/api/v1', onUnauthenticated: () => navigate('/sign-in')});\n// const orders = await api.get('/orders', {params: {page: 2}, signal});\n// await api.post('/orders', {customerId: 7});\n//\n// Se arma uno por proyecto (en su core) y las pantallas lo usan desde su *Api.js.\n//\n// Funciones puras, no renderizan: function (regla de la casa).\nimport {ApiError, API_ERROR_STATUS} from '@/api/ApiError.js';\nimport {XLSX_CONTENT_TYPE, fileNameFromContentDisposition, isCompleteZip} from '@/files.js';\nimport {CSRF_TOKEN_MISMATCH_STATUS} from '@/laravel/csrfRetry.js';\nimport {createLogger} from '@/observability/logger.js';\nimport {reportPublishedBuild} from '@/observability/publishedBuild.js';\nimport {DEFAULT_LANGUAGE, toolkitSettings} from '@/toolkit/toolkitSettings.js';\n\nconst log = createLogger('api');\n\n/** Cómo se lee una respuesta que salió bien. */\nexport const API_RESPONSE_PARSE = Object.freeze({json: 'json', blob: 'blob', response: 'response'});\n\n// Las consultas se cortan a los 60 s; las descargas van sin límite (0): el servidor arma el archivo mientras lo manda y\n// uno grande no cabe en 60 s. No se queda colgada: se cancela desde el panel de descargas.\nconst DEFAULT_TIMEOUT_MILLISECONDS = 60_000;\nconst NO_TIMEOUT = 0;\nconst DEFAULT_PUBLISHED_BUILD_HEADER = 'X-Frontend-Build';\nconst NO_CONTENT_STATUS = 204;\nconst SERVER_ERROR_STATUS = 500;\n\n/**\n * La dirección completa: la base del cliente más el camino, con los parámetros. Un camino que ya trae http(s) va tal cual.\n * Los parámetros vacíos (undefined, null) no viajan; una lista viaja repetida (estado=a&estado=b).\n *\n * @param {string} baseUrl\n * @param {string} path\n * @param {Record<string, unknown>} [params]\n * @returns {string}\n */\nexport function apiUrlOf(baseUrl, path, params) {\n const url = /^https?:/i.test(path) ? path : `${baseUrl}${path}`;\n const query = new URLSearchParams();\n for (const [name, value] of Object.entries(params ?? {})) {\n for (const item of [].concat(value)) {\n if (item !== undefined && item !== null) {\n query.append(name, String(item));\n }\n }\n }\n const queryText = query.toString();\n if (!queryText) {\n return url;\n }\n return `${url}${url.includes('?') ? '&' : '?'}${queryText}`;\n}\n\n/**\n * ¿El cuerpo va tal cual? FormData (archivos), Blob y URLSearchParams los arma el navegador con su propio tipo.\n *\n * @param {unknown} body\n * @returns {boolean}\n */\nfunction isRawBody(body) {\n return (typeof FormData !== 'undefined' && body instanceof FormData)\n || (typeof Blob !== 'undefined' && body instanceof Blob)\n || body instanceof URLSearchParams\n || body instanceof ArrayBuffer;\n}\n\n/**\n * El valor de una cookie del navegador (la XSRF-TOKEN de Laravel), o null.\n *\n * @param {string} cookieName\n * @returns {string|null}\n */\nfunction readCookie(cookieName) {\n if (typeof document === 'undefined') {\n return null;\n }\n const prefix = `${cookieName}=`;\n const cookie = document.cookie.split(';').map((part) => part.trim()).find((part) => part.startsWith(prefix));\n return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : null;\n}\n\n/**\n * ¿La dirección es del mismo origen que la página? El token CSRF solo viaja al servidor propio: mandarlo a otro dominio\n * se lo regalaría.\n *\n * @param {string} url\n * @returns {boolean}\n */\nfunction isSameOrigin(url) {\n if (typeof window === 'undefined' || !/^https?:/i.test(url)) {\n return true;\n }\n return new URL(url).origin === window.location.origin;\n}\n\n/**\n * Retry-After en segundos: el estándar admite segundos o una fecha HTTP.\n *\n * @param {string|null} retryAfterHeader\n * @returns {number|null}\n */\nexport function retryAfterSecondsOf(retryAfterHeader) {\n if (!retryAfterHeader) {\n return null;\n }\n if (/^\\d+$/.test(retryAfterHeader.trim())) {\n return Number(retryAfterHeader.trim());\n }\n const retryAt = Date.parse(retryAfterHeader);\n return Number.isNaN(retryAt) ? null : Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));\n}\n\n/**\n * El cuerpo RFC 9457 de una respuesta de error, o null si no es JSON (un 502 del proxy en HTML).\n *\n * @param {Response} response\n * @returns {Promise<object|null>}\n */\n/**\n * El problema RFC 9457 del cuerpo de error, pasado por el adaptador del proyecto (toProblem). Lo que no sea un objeto\n * cuenta como sin problema (se usa el texto genérico del paquete).\n *\n * @param {(body: any, response: Response) => any} toProblem\n * @param {object|null} body\n * @param {Response} response\n * @returns {object|null}\n */\nfunction toProblemOrNull(toProblem, body, response) {\n const problem = body === null ? null : toProblem(body, response);\n return problem && typeof problem === 'object' && !Array.isArray(problem) ? problem : null;\n}\n\nasync function readProblem(response) {\n try {\n const body = JSON.parse(await response.text());\n return body && typeof body === 'object' && !Array.isArray(body) ? body : null;\n } catch {\n return null;\n }\n}\n\n/**\n * El ApiError de una respuesta que salió mal, para lo que no pasa por createApiClient: la subida con avance\n * (uploadWithProgress regresa la respuesta tal cual) o un fetch propio. Es el mismo error que da el cliente: el problema RFC\n * 9457 del servidor ({title, detail, errors}), su status (401 = sesión vencida, 422 = errores por campo) y el texto del\n * paquete si el cuerpo no es JSON (un 502 del proxy). Así la pantalla no lee el error a mano.\n *\n * const response = await uploadWithProgress(url, form, {headers, onProgress, signal});\n * if (!response.ok) throw await apiErrorOf(response);\n *\n * @param {Response} response\n * @param {{toProblem?: (body: any, response: Response) => any}} [options] el mismo adaptador de createApiClient, para un\n * backend que contesta a su manera (Laravel manda {message, errors})\n * @returns {Promise<ApiError>}\n */\nexport async function apiErrorOf(response, {toProblem = (body) => body} = {}) {\n const problem = toProblemOrNull(toProblem, await readProblem(response), response);\n return new ApiError({status: response.status, problem, retryAfterSeconds: retryAfterSecondsOf(response.headers.get('retry-after'))});\n}\n\n/**\n * La señal de la petición: la de quien la pidió (para cancelar) y la del tiempo máximo, juntas.\n *\n * @param {AbortSignal|undefined} signal\n * @param {number} timeoutMilliseconds 0 = sin límite\n * @returns {AbortSignal|undefined}\n */\nfunction combinedSignalOf(signal, timeoutMilliseconds) {\n const signals = [signal, timeoutMilliseconds > 0 ? AbortSignal.timeout(timeoutMilliseconds) : null].filter(Boolean);\n if (signals.length < 2) {\n return signals[0];\n }\n return AbortSignal.any(signals);\n}\n\n/**\n * Lee el cuerpo de una descarga avisando cuánto lleva. total viene en null cuando el servidor arma el archivo al vuelo\n * (sin Content-Length).\n *\n * @param {Response} response\n * @param {((progress: {loaded: number, total: number|null}) => void)|undefined} onDownloadProgress\n * @returns {Promise<Blob>}\n */\nasync function readBlobWithProgress(response, onDownloadProgress) {\n if (!onDownloadProgress || !response.body) {\n return response.blob();\n }\n const total = Number(response.headers.get('content-length')) || null;\n const reader = response.body.getReader();\n const chunks = [];\n let loaded = 0;\n for (let part = await reader.read(); !part.done; part = await reader.read()) {\n chunks.push(part.value);\n loaded += part.value.byteLength;\n onDownloadProgress({loaded, total});\n }\n return new Blob(chunks, {type: response.headers.get('content-type') ?? ''});\n}\n\n/**\n * Lo que acepta cada petición.\n *\n * @typedef {object} ApiRequestOptions\n * @property {unknown} [body] objeto → JSON; FormData, Blob o URLSearchParams tal cual\n * @property {Record<string, unknown>} [params] los de la dirección (?page=2)\n * @property {AbortSignal} [signal] para cancelar\n * @property {Record<string, string>} [headers]\n * @property {'json'|'blob'|'response'} [parse] cómo se lee la respuesta (API_RESPONSE_PARSE); por omisión JSON\n * @property {boolean} [skipUnauthenticatedHandler] un 401 que se espera (¿hay sesión?, el login)\n * @property {number} [timeoutMilliseconds] el de esta petición\n */\n\n/**\n * Lo que acepta una descarga.\n *\n * @typedef {object} ApiDownloadOptions\n * @property {string} [method] por omisión 'GET'; 'POST' para un archivo que se arma con lo que va en `body`\n * @property {unknown} [body] lo que va en la petición, como en post: objeto → JSON; FormData, Blob o URLSearchParams tal cual\n * @property {Record<string, unknown>} [params]\n * @property {AbortSignal} [signal]\n * @property {(progress: {loaded: number, total: number|null}) => void} [onDownloadProgress]\n * @property {string} [fallbackFileName] si el servidor no manda el nombre\n * @property {number} [timeoutMilliseconds] por omisión sin límite\n */\n\n/**\n * El cliente del proyecto: cada método regresa el JSON de la respuesta (null si vino vacía) o truena con un ApiError.\n *\n * @typedef {object} ApiClient\n * @property {(method: string, path: string, options?: ApiRequestOptions) => Promise<any>} request\n * @property {(path: string, options?: ApiRequestOptions) => Promise<any>} get\n * @property {(path: string, body?: unknown, options?: ApiRequestOptions) => Promise<any>} post\n * @property {(path: string, body?: unknown, options?: ApiRequestOptions) => Promise<any>} put\n * @property {(path: string, body?: unknown, options?: ApiRequestOptions) => Promise<any>} patch\n * @property {(path: string, options?: ApiRequestOptions) => Promise<any>} delete\n * @property {(path: string, options?: ApiDownloadOptions) => Promise<{blob: Blob, fileName: string}>} download\n */\n\n/**\n * Arma el cliente del proyecto.\n *\n * @param {object} [options]\n * @param {string} [options.baseUrl] lo que va antes de cada camino, p. ej. '/api/v1'\n * @param {RequestCredentials} [options.credentials] 'same-origin' (por omisión, la cookie de sesión del mismo dominio) o\n * 'include' (un API en otro dominio con cookies)\n * @param {() => string|null|undefined} [options.getToken] con él cada petición lleva Authorization: Bearer; sin token, va\n * sin el header (el servidor responde 401)\n * @param {Record<string, string>} [options.headers] los que van en todas las peticiones\n * @param {string|null} [options.publishedBuildHeader] el header con la compilación publicada; null para no leerlo\n * @param {{cookieName?: string, headerName?: string, refreshPath?: string}|null} [options.csrf] Laravel Sanctum:\n * {refreshPath: '/auth/csrf-token'}; por omisión la cookie XSRF-TOKEN y el header X-XSRF-TOKEN\n * @param {number} [options.timeoutMilliseconds] el tiempo máximo de una consulta (0 = sin límite); por omisión 60 s\n * @param {(error: ApiError) => void} [options.onUnauthenticated] qué hacer con un 401 (normalmente ir a iniciar sesión)\n * @param {(request: {method: string, url: string}) => void} [options.onRequest] cada petición, al salir\n * @param {(response: {method: string, url: string, status: number, durationMilliseconds: number}) => void} [options.onResponse]\n * cada respuesta, buena o mala, al llegar\n * @param {(error: ApiError, request: {method: string, url: string}) => void} [options.onError] cada error (sin red, 4xx, 5xx,\n * una respuesta que no es JSON), en un solo lugar: Sentry, un aviso propio. Cancelar no es error\n * @param {(body: any, response: Response) => object|null} [options.toProblem] el error de un backend que no contesta en\n * RFC 9457, a esa forma: (body) => ({title: body.message, errors: body.errors}). Por omisión el cuerpo tal cual\n * @param {(body: any, request: {method: string, url: string}) => any} [options.mapResponse] la respuesta buena de un backend\n * que la envuelve: (body) => body.data. Por omisión tal cual\n * @param {typeof fetch} [options.fetch] para un servidor de mentira o para las pruebas; por omisión el del navegador\n * @returns {ApiClient}\n */\nexport function createApiClient({\n baseUrl = '',\n credentials = 'same-origin',\n getToken,\n headers: commonHeaders = {},\n publishedBuildHeader = DEFAULT_PUBLISHED_BUILD_HEADER,\n csrf = null,\n timeoutMilliseconds: defaultTimeoutMilliseconds = DEFAULT_TIMEOUT_MILLISECONDS,\n onUnauthenticated,\n onRequest,\n onResponse,\n onError,\n toProblem = (body) => body,\n mapResponse = (body) => body,\n fetch: fetchImplementation,\n} = {}) {\n // un evento del proyecto que truena no tumba la petición: se anota y se sigue (nunca en silencio)\n const notify = (eventName, handler, ...eventArguments) => {\n try {\n handler?.(...eventArguments);\n } catch (error) {\n log.error(`el evento ${eventName} del proyecto tronó`, {message: error?.message});\n }\n };\n // OJO: se lee al pedir, no al armar el cliente: así un servidor de mentira que se conecta después también sirve\n const fetchOf = () => fetchImplementation ?? globalThis.fetch;\n const csrfCookieName = csrf?.cookieName ?? 'XSRF-TOKEN';\n const csrfHeaderName = csrf?.headerName ?? 'X-XSRF-TOKEN';\n\n /** Los headers de una petición: los comunes, el idioma vigente, el token y el CSRF. */\n const headersOf = (url, body, extraHeaders) => {\n const headers = {\n Accept: 'application/json',\n 'Accept-Language': toolkitSettings().language ?? DEFAULT_LANGUAGE,\n ...commonHeaders,\n ...extraHeaders,\n };\n if (body !== undefined && !isRawBody(body)) {\n headers['Content-Type'] = 'application/json';\n }\n const token = getToken?.();\n if (token) {\n headers.Authorization = `Bearer ${token}`;\n }\n const csrfToken = csrf && isSameOrigin(url) ? readCookie(csrfCookieName) : null;\n if (csrfToken) {\n headers[csrfHeaderName] = csrfToken;\n }\n return headers;\n };\n\n /** La petición al servidor, ya con su respuesta o con el ApiError de «sin respuesta». */\n const send = async (method, url, {body, signal, headers, timeoutMilliseconds}) => {\n const finalHeaders = headersOf(url, body, headers);\n try {\n return await fetchOf()(url, {\n method,\n headers: finalHeaders,\n body: body === undefined || isRawBody(body) ? body : JSON.stringify(body),\n credentials,\n signal: combinedSignalOf(signal, timeoutMilliseconds),\n });\n } catch (error) {\n // cancelar no es un error de negocio: quien canceló ya sabe por qué\n if (signal?.aborted) {\n throw error;\n }\n const reason = error?.name === 'TimeoutError' ? 'se acabó el tiempo' : 'la petición no llegó al servidor';\n log.error(reason, {method, url, cause: error?.name});\n throw new ApiError({status: API_ERROR_STATUS.offline});\n }\n };\n\n /**\n * Cualquier petición.\n *\n * @param {string} method 'GET', 'POST', 'PUT', 'PATCH' o 'DELETE'\n * @param {string} path relativo a baseUrl (o completo con http)\n * @param {object} [options]\n * @param {unknown} [options.body] objeto → JSON; FormData, Blob o URLSearchParams tal cual\n * @param {Record<string, unknown>} [options.params] los de la dirección (?page=2)\n * @param {AbortSignal} [options.signal] para cancelar\n * @param {Record<string, string>} [options.headers]\n * @param {'json'|'blob'|'response'} [options.parse] cómo se lee la respuesta (API_RESPONSE_PARSE); por omisión JSON\n * @param {boolean} [options.skipUnauthenticatedHandler] un 401 que se espera (¿hay sesión?, el login)\n * @param {number} [options.timeoutMilliseconds] el de esta petición\n * @returns {Promise<any>} el JSON (null si vino vacío o 204), el Blob o la Response\n */\n const request = async (method, path, options = {}) => {\n const url = apiUrlOf(baseUrl, path, options.params);\n try {\n return await requestOnce(method, url, options);\n } catch (error) {\n // cada error del servidor (o sin red) sale por onError; cancelar no es error\n if (error instanceof ApiError) {\n notify('onError', onError, error, {method, url});\n }\n throw error;\n }\n };\n\n const requestOnce = async (method, url, {\n body, signal, headers, parse = API_RESPONSE_PARSE.json, skipUnauthenticatedHandler = false,\n timeoutMilliseconds = defaultTimeoutMilliseconds,\n } = {}) => {\n notify('onRequest', onRequest, {method, url});\n const startedAt = Date.now();\n let response = await send(method, url, {body, signal, headers, timeoutMilliseconds});\n\n // 419 de Laravel: la cookie CSRF venció; se pide una nueva y se repite la MISMA petición una sola vez\n if (response.status === CSRF_TOKEN_MISMATCH_STATUS && csrf?.refreshPath) {\n log.info('token CSRF vencido: se renueva y se reintenta', {method, url});\n await send('GET', apiUrlOf(baseUrl, csrf.refreshPath), {signal, timeoutMilliseconds});\n response = await send(method, url, {body, signal, headers, timeoutMilliseconds});\n }\n\n notify('onResponse', onResponse, {method, url, status: response.status, durationMilliseconds: Date.now() - startedAt});\n\n // cada respuesta (también las de error) dice qué compilación del front está publicada\n if (publishedBuildHeader) {\n reportPublishedBuild(response.headers.get(publishedBuildHeader));\n }\n\n if (!response.ok) {\n const problem = toProblemOrNull(toProblem, await readProblem(response), response);\n // un error que armó otro (nginx) puede llegar en otro idioma: ahí se usa el texto genérico del paquete\n const requestedLanguage = headersOf(url, undefined, headers)['Accept-Language'];\n const responseLanguage = response.headers.get('content-language');\n const apiError = new ApiError({\n status: response.status,\n problem,\n translatedByServer: !responseLanguage || responseLanguage.startsWith(requestedLanguage),\n retryAfterSeconds: retryAfterSecondsOf(response.headers.get('retry-after')),\n });\n // los 4xx son parte del flujo (un 422 es un formulario, un 401 al arrancar es «sin sesión»); los 5xx no\n log[response.status >= SERVER_ERROR_STATUS ? 'error' : 'debug'](`${method} ${url} → ${response.status}`, {\n code: apiError.code, requestId: apiError.requestId ?? response.headers.get('x-request-id'),\n });\n if (apiError.isUnauthenticated && !skipUnauthenticatedHandler) {\n onUnauthenticated?.(apiError);\n }\n throw apiError;\n }\n\n if (parse === API_RESPONSE_PARSE.response) {\n return response;\n }\n if (parse === API_RESPONSE_PARSE.blob) {\n return response.blob();\n }\n if (response.status === NO_CONTENT_STATUS) {\n return null;\n }\n // un 200 con el cuerpo vacío pasa de verdad; JSON.parse('') tronaría\n const text = await response.text();\n if (!text) {\n return null;\n }\n let json;\n try {\n json = JSON.parse(text);\n } catch {\n // un 200 que no es JSON: casi siempre la página de la app que respondió en lugar del API (el camino está mal)\n log.error(`${method} ${url} respondió algo que no es JSON`, {contentType: response.headers.get('content-type')});\n throw new ApiError({status: SERVER_ERROR_STATUS});\n }\n return mapResponse(json, {method, url});\n };\n\n /**\n * Descarga un archivo: el Blob y el nombre que mandó el servidor, con avance. Un Excel cortado no se entrega.\n *\n * Por omisión con GET. Un archivo que el servidor arma con datos de la pantalla (una plantilla con de quién es, el borrador\n * de un contrato) va con method 'POST' y su body: el avance, el nombre y la revisión del Excel son los mismos.\n *\n * @param {string} path\n * @param {object} [options]\n * @param {string} [options.method] por omisión 'GET'\n * @param {unknown} [options.body] objeto → JSON; FormData, Blob o URLSearchParams tal cual\n * @param {Record<string, unknown>} [options.params]\n * @param {AbortSignal} [options.signal]\n * @param {(progress: {loaded: number, total: number|null}) => void} [options.onDownloadProgress]\n * @param {string} [options.fallbackFileName] si el servidor no manda el nombre\n * @param {number} [options.timeoutMilliseconds] por omisión sin límite\n * @returns {Promise<{blob: Blob, fileName: string}>}\n * @throws {ApiError} status 500 si un Excel llegó incompleto\n */\n const download = async (path, {\n method = 'GET', body, params, signal, onDownloadProgress, fallbackFileName = 'descarga', timeoutMilliseconds = NO_TIMEOUT,\n } = {}) => {\n const response = await request(method, path, {body, params, signal, parse: API_RESPONSE_PARSE.response, timeoutMilliseconds});\n const blob = await readBlobWithProgress(response, onDownloadProgress);\n const fileName = fileNameFromContentDisposition(response.headers.get('content-disposition'), fallbackFileName);\n // el tipo puede traer parámetros después del «;» (charset): solo cuenta lo de antes\n const contentType = String(response.headers.get('content-type') ?? '').split(';')[0].trim();\n if (contentType === XLSX_CONTENT_TYPE && !(await isCompleteZip(blob))) {\n const requestId = response.headers.get('x-request-id');\n log.error(`${method} ${path} → el Excel llegó incompleto: el servidor falló a media escritura`, {requestId, fileName});\n throw new ApiError({status: SERVER_ERROR_STATUS, problem: {request_id: requestId}});\n }\n return {blob, fileName};\n };\n\n return {\n request,\n get: (path, options) => request('GET', path, options),\n post: (path, body, options) => request('POST', path, {...options, body}),\n put: (path, body, options) => request('PUT', path, {...options, body}),\n patch: (path, body, options) => request('PATCH', path, {...options, body}),\n delete: (path, options) => request('DELETE', path, options),\n download,\n };\n}\n"],"names":[],"mappings":"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"}
@@ -57,23 +57,28 @@ function FormWizard({
57
57
  setSkippedKeys((currentKeys) => /* @__PURE__ */ new Set([...currentKeys, activeStep.key]));
58
58
  setActiveIndex((currentIndex) => currentIndex + 1);
59
59
  };
60
- return /* @__PURE__ */ jsx(
61
- WizardLayout,
62
- {
63
- steps,
64
- activeIndex,
65
- skippedKeys,
66
- isSubmitting,
67
- orientation,
68
- hideBackOnFirstStep,
69
- onBack: () => setActiveIndex((currentIndex) => currentIndex - 1),
70
- onNext: goNext,
71
- onSkip: skipStep,
72
- onStepSelect: selectStep,
73
- submitButton: /* @__PURE__ */ jsx(SubmitButton, { children: submitLabel }),
74
- problemAlert: /* @__PURE__ */ jsx(FormProblemAlert, {}),
75
- texts
76
- }
60
+ return (
61
+ // un paso con su motivo para no avanzar (steps[].nextDisabledReason): «Siguiente» apagado y, en el último, también el de
62
+ // enviar; este sí con disabled de verdad, así un Enter en un campo tampoco envía
63
+ /* @__PURE__ */ jsx(
64
+ WizardLayout,
65
+ {
66
+ steps,
67
+ activeIndex,
68
+ skippedKeys,
69
+ isSubmitting,
70
+ orientation,
71
+ hideBackOnFirstStep,
72
+ nextDisabledReason: activeStep.nextDisabledReason,
73
+ onBack: () => setActiveIndex((currentIndex) => currentIndex - 1),
74
+ onNext: goNext,
75
+ onSkip: skipStep,
76
+ onStepSelect: selectStep,
77
+ submitButton: /* @__PURE__ */ jsx(SubmitButton, { disabled: Boolean(activeStep.nextDisabledReason), children: submitLabel }),
78
+ problemAlert: /* @__PURE__ */ jsx(FormProblemAlert, {}),
79
+ texts
80
+ }
81
+ )
77
82
  );
78
83
  }
79
84
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"FormWizard.js","sources":["../../src/forms/FormWizard.jsx"],"sourcesContent":["// Formulario por pasos (wizard) con formik: un solo formulario partido en pasos, con pasos opcionales que se pueden omitir.\n//\n// Cómo se porta, para que la pantalla solo declare sus pasos:\n// - «Siguiente» revisa SOLO los campos del paso (fields) y no avanza si alguno tiene error; los marca como tocados para\n// que se vea por qué;\n// - «Omitir este paso» (solo en los opcionales) regresa sus campos a como empezaron: omitir es no dar ese dato, no dejar\n// lo que se escribió a medias;\n// - el último paso envía TODO el formulario (el esquema de yup completo vale para todos los pasos);\n// - si el envío falla por un campo de un paso anterior (yup o el 422 del servidor con applyServerProblem), el wizard\n// regresa solo a ese paso: un error que no se ve no se puede corregir;\n// - para editar un registro (se entra a corregir un dato suelto): initialStepKey abre en el paso que diga el servidor,\n// orientation=\"vertical\" pone los campos dentro de su paso, y stepSelection deja pulsar el título de otro paso para\n// saltar directo (free) o solo si el paso abierto no tiene errores (checked). onStepChange avisa cada cambio de paso.\n//\n// Va DENTRO de <Formik> y <Form>:\n// <Formik initialValues={…} validationSchema={…} onSubmit={…}>\n// <Form noValidate>\n// <FormWizard submitLabel=\"Enviar solicitud\" steps={[\n// {key: 'datos', label: 'Sus datos', fields: ['name', 'email'], content: <>…campos…</>},\n// {key: 'aval', label: 'Aval', optional: true, fields: ['guarantor'], content: <>…</>},\n// ]} />\n// </Form>\n// </Formik>\n//\n// Lo que se ve vive en WizardLayout (inputs/), el mismo del FormWizard de react-hook-form. Los textos («Siguiente»,\n// «Anterior»…) se cambian con `texts`: {wizard: {next: 'Continuar'}}.\n//\n// Genérico del paquete: function (regla de la casa).\nimport {useEffect, useRef, useState} from 'react';\nimport {getIn, useFormikContext} from 'formik';\n\nimport FormProblemAlert from '@/forms/FormProblemAlert.jsx';\nimport SubmitButton from '@/forms/SubmitButton.jsx';\nimport {useWizardSteps} from '@/inputs/useWizardSteps.js';\nimport WizardLayout from '@/inputs/WizardLayout.jsx';\nimport {warnUnknownProps} from '@/toolkit/unknownProps.js';\n\n/**\n * El primer paso que tiene un campo con error; -1 si ninguno.\n *\n * @param {Array<{fields?: string[]}>} steps\n * @param {object} errors los de formik\n * @returns {number}\n */\nfunction firstStepWithErrorOf(steps, errors) {\n return steps.findIndex((step) => (step.fields ?? []).some((field) => getIn(errors, field)));\n}\n\n/**\n * @param {object} props\n * @param {Object[]} props.steps en orden\n * @param {string} props.steps[].key\n * @param {string} props.steps[].label ya traducido\n * @param {string[]} [props.steps[].fields] los campos del paso (los que revisa «Siguiente»)\n * @param {boolean} [props.steps[].optional] se puede omitir\n * @param {import('react').ReactNode} props.steps[].content los campos del paso\n * @param {boolean} [props.steps[].completed] lleva la palomita (cuando los títulos se pulsan, solo si la pantalla lo dice)\n * @param {string} props.submitLabel ya traducido: el botón del último paso\n * @param {'horizontal'|'vertical'} [props.orientation] uno de WIZARD_ORIENTATION; por omisión horizontal\n * @param {boolean} [props.hideBackOnFirstStep] en el primer paso no se pinta «Anterior»; por omisión sale apagado, como antes\n * @param {string} [props.initialStepKey] la llave del paso en el que se abre (la que diga el servidor); por omisión el primero\n * @param {'none'|'free'|'checked'} [props.stepSelection] uno de WIZARD_STEP_SELECTION: si el título de otro paso se pulsa y qué\n * revisa antes; por omisión none (se avanza con «Siguiente», como siempre)\n * @param {(stepKey: string, index: number) => void} [props.onStepChange] cada vez que cambia el paso abierto\n * @param {object} [props.texts] los textos que cambian, con la forma de `common`: {wizard: {next, back, skip, optional, skipped, stepOf}}\n */\nexport default function FormWizard({\n steps, submitLabel, orientation, hideBackOnFirstStep, initialStepKey, stepSelection, onStepChange, texts, ...unknownProps\n}) {\n warnUnknownProps('FormWizard', unknownProps);\n const {validateForm, setFieldTouched, setFieldValue, initialValues, errors, isSubmitting} = useFormikContext();\n const [skippedKeys, setSkippedKeys] = useState(() => new Set());\n const wasSubmitting = useRef(false);\n\n // revisa SOLO los campos de un paso y los marca como tocados para que se vea por qué no avanza\n const isStepValid = async (index) => {\n const stepFields = steps[index].fields ?? [];\n const formErrors = await validateForm();\n stepFields.forEach((field) => setFieldTouched(field, true, false));\n return !stepFields.some((field) => getIn(formErrors, field));\n };\n const {activeIndex, setActiveIndex, selectStep} = useWizardSteps({steps, initialStepKey, stepSelection, onStepChange, isStepValid});\n const activeStep = steps[activeIndex];\n\n // al terminar un envío fallido, regresa al paso con el primer error (solo en ese momento: escribir en otro paso no\n // debe jalar a la persona de regreso)\n useEffect(() => {\n if (wasSubmitting.current && !isSubmitting) {\n const errorStepIndex = firstStepWithErrorOf(steps, errors);\n if (errorStepIndex >= 0) {\n setActiveIndex(errorStepIndex);\n }\n }\n wasSubmitting.current = isSubmitting;\n }, [isSubmitting, errors, steps]);\n\n const goNext = async () => {\n // el paso tiene errores: se queda ahí para que se corrijan\n if (!(await isStepValid(activeIndex))) {\n return;\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys].filter((key) => key !== activeStep.key)));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n const skipStep = () => {\n for (const field of activeStep.fields ?? []) {\n setFieldValue(field, getIn(initialValues, field), false);\n setFieldTouched(field, false, false);\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys, activeStep.key]));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n return (\n <WizardLayout steps={steps} activeIndex={activeIndex} skippedKeys={skippedKeys} isSubmitting={isSubmitting} orientation={orientation} hideBackOnFirstStep={hideBackOnFirstStep}\n onBack={() => setActiveIndex((currentIndex) => currentIndex - 1)} onNext={goNext} onSkip={skipStep} onStepSelect={selectStep}\n submitButton={<SubmitButton>{submitLabel}</SubmitButton>} problemAlert={<FormProblemAlert />} texts={texts} />\n );\n}\n"],"names":[],"mappings":";;;;;;;;;AA4CA,SAAS,qBAAqB,OAAO,QAAQ;AACzC,SAAO,MAAM,UAAU,CAAC,UAAU,KAAK,UAAU,CAAA,GAAI,KAAK,CAAC,UAAU,MAAM,QAAQ,KAAK,CAAC,CAAC;AAC9F;AAoBA,SAAwB,WAAW;AAAA,EAC/B;AAAA,EAAO;AAAA,EAAa;AAAA,EAAa;AAAA,EAAqB;AAAA,EAAgB;AAAA,EAAe;AAAA,EAAc;AAAA,EAAO,GAAG;AACjH,GAAG;AACC,mBAAiB,cAAc,YAAY;AAC3C,QAAM,EAAC,cAAc,iBAAiB,eAAe,eAAe,QAAQ,aAAA,IAAgB,iBAAA;AAC5F,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,MAAM,oBAAI,KAAK;AAC9D,QAAM,gBAAgB,OAAO,KAAK;AAGlC,QAAM,cAAc,OAAO,UAAU;AACjC,UAAM,aAAa,MAAM,KAAK,EAAE,UAAU,CAAA;AAC1C,UAAM,aAAa,MAAM,aAAA;AACzB,eAAW,QAAQ,CAAC,UAAU,gBAAgB,OAAO,MAAM,KAAK,CAAC;AACjE,WAAO,CAAC,WAAW,KAAK,CAAC,UAAU,MAAM,YAAY,KAAK,CAAC;AAAA,EAC/D;AACA,QAAM,EAAC,aAAa,gBAAgB,WAAA,IAAc,eAAe,EAAC,OAAO,gBAAgB,eAAe,cAAc,YAAA,CAAY;AAClI,QAAM,aAAa,MAAM,WAAW;AAIpC,YAAU,MAAM;AACZ,QAAI,cAAc,WAAW,CAAC,cAAc;AACxC,YAAM,iBAAiB,qBAAqB,OAAO,MAAM;AACzD,UAAI,kBAAkB,GAAG;AACrB,uBAAe,cAAc;AAAA,MACjC;AAAA,IACJ;AACA,kBAAc,UAAU;AAAA,EAC5B,GAAG,CAAC,cAAc,QAAQ,KAAK,CAAC;AAEhC,QAAM,SAAS,YAAY;AAEvB,QAAI,CAAE,MAAM,YAAY,WAAW,GAAI;AACnC;AAAA,IACJ;AACA,mBAAe,CAAC,gBAAgB,IAAI,IAAI,CAAC,GAAG,WAAW,EAAE,OAAO,CAAC,QAAQ,QAAQ,WAAW,GAAG,CAAC,CAAC;AACjG,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA,QAAM,WAAW,MAAM;AACnB,eAAW,SAAS,WAAW,UAAU,CAAA,GAAI;AACzC,oBAAc,OAAO,MAAM,eAAe,KAAK,GAAG,KAAK;AACvD,sBAAgB,OAAO,OAAO,KAAK;AAAA,IACvC;AACA,mBAAe,CAAC,gBAAgB,oBAAI,IAAI,CAAC,GAAG,aAAa,WAAW,GAAG,CAAC,CAAC;AACzE,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA,SACI;AAAA,IAAC;AAAA,IAAA;AAAA,MAAa;AAAA,MAAc;AAAA,MAA0B;AAAA,MAA0B;AAAA,MAA4B;AAAA,MAA0B;AAAA,MACxH,QAAQ,MAAM,eAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,MAAG,QAAQ;AAAA,MAAQ,QAAQ;AAAA,MAAU,cAAc;AAAA,MAClH,cAAc,oBAAC,cAAA,EAAc,UAAA,YAAA,CAAY;AAAA,MAAiB,kCAAe,kBAAA,EAAiB;AAAA,MAAI;AAAA,IAAA;AAAA,EAAA;AAEpH;"}
1
+ {"version":3,"file":"FormWizard.js","sources":["../../src/forms/FormWizard.jsx"],"sourcesContent":["// Formulario por pasos (wizard) con formik: un solo formulario partido en pasos, con pasos opcionales que se pueden omitir.\n//\n// Cómo se porta, para que la pantalla solo declare sus pasos:\n// - «Siguiente» revisa SOLO los campos del paso (fields) y no avanza si alguno tiene error; los marca como tocados para\n// que se vea por qué;\n// - «Omitir este paso» (solo en los opcionales) regresa sus campos a como empezaron: omitir es no dar ese dato, no dejar\n// lo que se escribió a medias;\n// - el último paso envía TODO el formulario (el esquema de yup completo vale para todos los pasos);\n// - si el envío falla por un campo de un paso anterior (yup o el 422 del servidor con applyServerProblem), el wizard\n// regresa solo a ese paso: un error que no se ve no se puede corregir;\n// - para editar un registro (se entra a corregir un dato suelto): initialStepKey abre en el paso que diga el servidor,\n// orientation=\"vertical\" pone los campos dentro de su paso, y stepSelection deja pulsar el título de otro paso para\n// saltar directo (free) o solo si el paso abierto no tiene errores (checked). onStepChange avisa cada cambio de paso.\n//\n// Va DENTRO de <Formik> y <Form>:\n// <Formik initialValues={…} validationSchema={…} onSubmit={…}>\n// <Form noValidate>\n// <FormWizard submitLabel=\"Enviar solicitud\" steps={[\n// {key: 'datos', label: 'Sus datos', fields: ['name', 'email'], content: <>…campos…</>},\n// {key: 'aval', label: 'Aval', optional: true, fields: ['guarantor'], content: <>…</>},\n// ]} />\n// </Form>\n// </Formik>\n//\n// Lo que se ve vive en WizardLayout (inputs/), el mismo del FormWizard de react-hook-form. Los textos («Siguiente»,\n// «Anterior»…) se cambian con `texts`: {wizard: {next: 'Continuar'}}.\n//\n// Genérico del paquete: function (regla de la casa).\nimport {useEffect, useRef, useState} from 'react';\nimport {getIn, useFormikContext} from 'formik';\n\nimport FormProblemAlert from '@/forms/FormProblemAlert.jsx';\nimport SubmitButton from '@/forms/SubmitButton.jsx';\nimport {useWizardSteps} from '@/inputs/useWizardSteps.js';\nimport WizardLayout from '@/inputs/WizardLayout.jsx';\nimport {warnUnknownProps} from '@/toolkit/unknownProps.js';\n\n/**\n * El primer paso que tiene un campo con error; -1 si ninguno.\n *\n * @param {Array<{fields?: string[]}>} steps\n * @param {object} errors los de formik\n * @returns {number}\n */\nfunction firstStepWithErrorOf(steps, errors) {\n return steps.findIndex((step) => (step.fields ?? []).some((field) => getIn(errors, field)));\n}\n\n/**\n * @param {object} props\n * @param {Object[]} props.steps en orden\n * @param {string} props.steps[].key\n * @param {string} props.steps[].label ya traducido\n * @param {string[]} [props.steps[].fields] los campos del paso (los que revisa «Siguiente»)\n * @param {boolean} [props.steps[].optional] se puede omitir\n * @param {import('react').ReactNode} [props.steps[].nextDisabledReason] ya traducido: el paso todavía no se puede dejar («Faltan 2\n * documentos»). «Siguiente» se ve apagado con el motivo en un aviso (escrito, en el teléfono); en el último paso se apaga\n * el de enviar. Con stepSelection checked tampoco se salta a un paso de adelante. Sin él, como siempre\n * @param {import('react').ReactNode} props.steps[].content los campos del paso\n * @param {boolean} [props.steps[].completed] lleva la palomita (cuando los títulos se pulsan, solo si la pantalla lo dice)\n * @param {string} props.submitLabel ya traducido: el botón del último paso\n * @param {'horizontal'|'vertical'} [props.orientation] uno de WIZARD_ORIENTATION; por omisión horizontal\n * @param {boolean} [props.hideBackOnFirstStep] en el primer paso no se pinta «Anterior»; por omisión sale apagado, como antes\n * @param {string} [props.initialStepKey] la llave del paso en el que se abre (la que diga el servidor); por omisión el primero\n * @param {'none'|'free'|'checked'} [props.stepSelection] uno de WIZARD_STEP_SELECTION: si el título de otro paso se pulsa y qué\n * revisa antes; por omisión none (se avanza con «Siguiente», como siempre)\n * @param {(stepKey: string, index: number) => void} [props.onStepChange] cada vez que cambia el paso abierto\n * @param {object} [props.texts] los textos que cambian, con la forma de `common`: {wizard: {next, back, skip, optional, skipped, stepOf}}\n */\nexport default function FormWizard({\n steps, submitLabel, orientation, hideBackOnFirstStep, initialStepKey, stepSelection, onStepChange, texts, ...unknownProps\n}) {\n warnUnknownProps('FormWizard', unknownProps);\n const {validateForm, setFieldTouched, setFieldValue, initialValues, errors, isSubmitting} = useFormikContext();\n const [skippedKeys, setSkippedKeys] = useState(() => new Set());\n const wasSubmitting = useRef(false);\n\n // revisa SOLO los campos de un paso y los marca como tocados para que se vea por qué no avanza\n const isStepValid = async (index) => {\n const stepFields = steps[index].fields ?? [];\n const formErrors = await validateForm();\n stepFields.forEach((field) => setFieldTouched(field, true, false));\n return !stepFields.some((field) => getIn(formErrors, field));\n };\n const {activeIndex, setActiveIndex, selectStep} = useWizardSteps({steps, initialStepKey, stepSelection, onStepChange, isStepValid});\n const activeStep = steps[activeIndex];\n\n // al terminar un envío fallido, regresa al paso con el primer error (solo en ese momento: escribir en otro paso no\n // debe jalar a la persona de regreso)\n useEffect(() => {\n if (wasSubmitting.current && !isSubmitting) {\n const errorStepIndex = firstStepWithErrorOf(steps, errors);\n if (errorStepIndex >= 0) {\n setActiveIndex(errorStepIndex);\n }\n }\n wasSubmitting.current = isSubmitting;\n }, [isSubmitting, errors, steps]);\n\n const goNext = async () => {\n // el paso tiene errores: se queda ahí para que se corrijan\n if (!(await isStepValid(activeIndex))) {\n return;\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys].filter((key) => key !== activeStep.key)));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n const skipStep = () => {\n for (const field of activeStep.fields ?? []) {\n setFieldValue(field, getIn(initialValues, field), false);\n setFieldTouched(field, false, false);\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys, activeStep.key]));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n return (\n // un paso con su motivo para no avanzar (steps[].nextDisabledReason): «Siguiente» apagado y, en el último, también el de\n // enviar; este sí con disabled de verdad, así un Enter en un campo tampoco envía\n <WizardLayout steps={steps} activeIndex={activeIndex} skippedKeys={skippedKeys} isSubmitting={isSubmitting} orientation={orientation} hideBackOnFirstStep={hideBackOnFirstStep}\n nextDisabledReason={activeStep.nextDisabledReason} onBack={() => setActiveIndex((currentIndex) => currentIndex - 1)}\n onNext={goNext} onSkip={skipStep} onStepSelect={selectStep}\n submitButton={<SubmitButton disabled={Boolean(activeStep.nextDisabledReason)}>{submitLabel}</SubmitButton>}\n problemAlert={<FormProblemAlert />} texts={texts} />\n );\n}\n"],"names":[],"mappings":";;;;;;;;;AA4CA,SAAS,qBAAqB,OAAO,QAAQ;AACzC,SAAO,MAAM,UAAU,CAAC,UAAU,KAAK,UAAU,CAAA,GAAI,KAAK,CAAC,UAAU,MAAM,QAAQ,KAAK,CAAC,CAAC;AAC9F;AAuBA,SAAwB,WAAW;AAAA,EAC/B;AAAA,EAAO;AAAA,EAAa;AAAA,EAAa;AAAA,EAAqB;AAAA,EAAgB;AAAA,EAAe;AAAA,EAAc;AAAA,EAAO,GAAG;AACjH,GAAG;AACC,mBAAiB,cAAc,YAAY;AAC3C,QAAM,EAAC,cAAc,iBAAiB,eAAe,eAAe,QAAQ,aAAA,IAAgB,iBAAA;AAC5F,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,MAAM,oBAAI,KAAK;AAC9D,QAAM,gBAAgB,OAAO,KAAK;AAGlC,QAAM,cAAc,OAAO,UAAU;AACjC,UAAM,aAAa,MAAM,KAAK,EAAE,UAAU,CAAA;AAC1C,UAAM,aAAa,MAAM,aAAA;AACzB,eAAW,QAAQ,CAAC,UAAU,gBAAgB,OAAO,MAAM,KAAK,CAAC;AACjE,WAAO,CAAC,WAAW,KAAK,CAAC,UAAU,MAAM,YAAY,KAAK,CAAC;AAAA,EAC/D;AACA,QAAM,EAAC,aAAa,gBAAgB,WAAA,IAAc,eAAe,EAAC,OAAO,gBAAgB,eAAe,cAAc,YAAA,CAAY;AAClI,QAAM,aAAa,MAAM,WAAW;AAIpC,YAAU,MAAM;AACZ,QAAI,cAAc,WAAW,CAAC,cAAc;AACxC,YAAM,iBAAiB,qBAAqB,OAAO,MAAM;AACzD,UAAI,kBAAkB,GAAG;AACrB,uBAAe,cAAc;AAAA,MACjC;AAAA,IACJ;AACA,kBAAc,UAAU;AAAA,EAC5B,GAAG,CAAC,cAAc,QAAQ,KAAK,CAAC;AAEhC,QAAM,SAAS,YAAY;AAEvB,QAAI,CAAE,MAAM,YAAY,WAAW,GAAI;AACnC;AAAA,IACJ;AACA,mBAAe,CAAC,gBAAgB,IAAI,IAAI,CAAC,GAAG,WAAW,EAAE,OAAO,CAAC,QAAQ,QAAQ,WAAW,GAAG,CAAC,CAAC;AACjG,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA,QAAM,WAAW,MAAM;AACnB,eAAW,SAAS,WAAW,UAAU,CAAA,GAAI;AACzC,oBAAc,OAAO,MAAM,eAAe,KAAK,GAAG,KAAK;AACvD,sBAAgB,OAAO,OAAO,KAAK;AAAA,IACvC;AACA,mBAAe,CAAC,gBAAgB,oBAAI,IAAI,CAAC,GAAG,aAAa,WAAW,GAAG,CAAC,CAAC;AACzE,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA;AAAA;AAAA;AAAA,IAGI;AAAA,MAAC;AAAA,MAAA;AAAA,QAAa;AAAA,QAAc;AAAA,QAA0B;AAAA,QAA0B;AAAA,QAA4B;AAAA,QAA0B;AAAA,QACxH,oBAAoB,WAAW;AAAA,QAAoB,QAAQ,MAAM,eAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,QAClH,QAAQ;AAAA,QAAQ,QAAQ;AAAA,QAAU,cAAc;AAAA,QAChD,kCAAe,cAAA,EAAa,UAAU,QAAQ,WAAW,kBAAkB,GAAI,UAAA,aAAY;AAAA,QAC3F,kCAAe,kBAAA,EAAiB;AAAA,QAAI;AAAA,MAAA;AAAA,IAAA;AAAA;AAE1D;"}
@@ -52,23 +52,28 @@ function FormWizard({
52
52
  setSkippedKeys((currentKeys) => /* @__PURE__ */ new Set([...currentKeys, activeStep.key]));
53
53
  setActiveIndex((currentIndex) => currentIndex + 1);
54
54
  };
55
- return /* @__PURE__ */ jsx(
56
- WizardLayout,
57
- {
58
- steps,
59
- activeIndex,
60
- skippedKeys,
61
- isSubmitting,
62
- orientation,
63
- hideBackOnFirstStep,
64
- onBack: () => setActiveIndex((currentIndex) => currentIndex - 1),
65
- onNext: goNext,
66
- onSkip: skipStep,
67
- onStepSelect: selectStep,
68
- submitButton: /* @__PURE__ */ jsx(SubmitButton, { children: submitLabel }),
69
- problemAlert: /* @__PURE__ */ jsx(FormProblemAlert, {}),
70
- texts
71
- }
55
+ return (
56
+ // un paso con su motivo para no avanzar (steps[].nextDisabledReason): «Siguiente» apagado y, en el último, también el de
57
+ // enviar; este sí con disabled de verdad, así un Enter en un campo tampoco envía
58
+ /* @__PURE__ */ jsx(
59
+ WizardLayout,
60
+ {
61
+ steps,
62
+ activeIndex,
63
+ skippedKeys,
64
+ isSubmitting,
65
+ orientation,
66
+ hideBackOnFirstStep,
67
+ nextDisabledReason: activeStep.nextDisabledReason,
68
+ onBack: () => setActiveIndex((currentIndex) => currentIndex - 1),
69
+ onNext: goNext,
70
+ onSkip: skipStep,
71
+ onStepSelect: selectStep,
72
+ submitButton: /* @__PURE__ */ jsx(SubmitButton, { disabled: Boolean(activeStep.nextDisabledReason), children: submitLabel }),
73
+ problemAlert: /* @__PURE__ */ jsx(FormProblemAlert, {}),
74
+ texts
75
+ }
76
+ )
72
77
  );
73
78
  }
74
79
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"FormWizard.js","sources":["../../src/hookForm/FormWizard.jsx"],"sourcesContent":["// Formulario por pasos (wizard) con react-hook-form: el mismo del de formik (@stackcraftmx/calcita/forms), con las mismas\n// props y el mismo comportamiento:\n// - «Siguiente» revisa SOLO los campos del paso (trigger con sus nombres) y no avanza si alguno tiene error;\n// - «Omitir este paso» (solo en los opcionales) regresa sus campos a su valor de inicio (resetField): omitir es no dar ese\n// dato, no dejar lo que se escribió a medias;\n// - el último paso envía TODO el formulario (el esquema de yup completo vale para todos los pasos);\n// - si el envío falla por un campo de un paso anterior (yup o el 422 del servidor con applyServerProblem), regresa solo a\n// ese paso: un error que no se ve no se puede corregir;\n// - para editar un registro (se entra a corregir un dato suelto): initialStepKey abre en el paso que diga el servidor,\n// orientation=\"vertical\" pone los campos dentro de su paso, y stepSelection deja pulsar el título de otro paso para\n// saltar directo (free) o solo si el paso abierto no tiene errores (checked). onStepChange avisa cada cambio de paso.\n//\n// Va DENTRO de <FormProvider> y del <form> con su handleSubmit (usa useFormContext):\n// const form = useForm({resolver: yupResolver(schema), defaultValues});\n// <FormProvider {...form}>\n// <form noValidate onSubmit={form.handleSubmit(send)}>\n// <FormWizard submitLabel=\"Enviar solicitud\" steps={[\n// {key: 'datos', label: 'Sus datos', fields: ['name', 'email'], content: <>…campos…</>},\n// {key: 'aval', label: 'Aval', optional: true, fields: ['guarantor'], content: <>…</>},\n// ]} />\n// </form>\n// </FormProvider>\n//\n// Lo que se ve vive en WizardLayout (inputs/). Los textos se cambian con `texts`: {wizard: {next: 'Continuar'}}.\n//\n// Genérico del paquete: function (regla de la casa).\nimport {useEffect, useRef, useState} from 'react';\nimport {useFormContext} from 'react-hook-form';\n\nimport FormProblemAlert from '@/hookForm/FormProblemAlert.jsx';\nimport SubmitButton from '@/hookForm/SubmitButton.jsx';\nimport {useWizardSteps} from '@/inputs/useWizardSteps.js';\nimport WizardLayout from '@/inputs/WizardLayout.jsx';\nimport {warnUnknownProps} from '@/toolkit/unknownProps.js';\n\n/**\n * @param {object} props\n * @param {Object[]} props.steps en orden\n * @param {string} props.steps[].key\n * @param {string} props.steps[].label ya traducido\n * @param {string[]} [props.steps[].fields] los campos del paso (los que revisa «Siguiente»)\n * @param {boolean} [props.steps[].optional] se puede omitir\n * @param {import('react').ReactNode} props.steps[].content los campos del paso\n * @param {boolean} [props.steps[].completed] lleva la palomita (cuando los títulos se pulsan, solo si la pantalla lo dice)\n * @param {string} props.submitLabel ya traducido: el botón del último paso\n * @param {'horizontal'|'vertical'} [props.orientation] uno de WIZARD_ORIENTATION; por omisión horizontal\n * @param {boolean} [props.hideBackOnFirstStep] en el primer paso no se pinta «Anterior»; por omisión sale apagado, como antes\n * @param {string} [props.initialStepKey] la llave del paso en el que se abre (la que diga el servidor); por omisión el primero\n * @param {'none'|'free'|'checked'} [props.stepSelection] uno de WIZARD_STEP_SELECTION: si el título de otro paso se pulsa y qué\n * revisa antes; por omisión none (se avanza con «Siguiente», como siempre)\n * @param {(stepKey: string, index: number) => void} [props.onStepChange] cada vez que cambia el paso abierto\n * @param {object} [props.texts] los textos que cambian, con la forma de `common`: {wizard: {next, back, skip, optional, skipped, stepOf}}\n */\nexport default function FormWizard({\n steps, submitLabel, orientation, hideBackOnFirstStep, initialStepKey, stepSelection, onStepChange, texts, ...unknownProps\n}) {\n warnUnknownProps('FormWizard', unknownProps);\n const {trigger, resetField, getFieldState, formState} = useFormContext();\n const {isSubmitting, submitCount} = formState;\n const [skippedKeys, setSkippedKeys] = useState(() => new Set());\n const wasSubmitting = useRef(false);\n\n // revisa SOLO los campos de un paso (trigger los marca, los pinta y pone el foco en el primero con error)\n const isStepValid = async (index) => {\n const stepFields = steps[index].fields ?? [];\n return stepFields.length === 0 || trigger(stepFields, {shouldFocus: true});\n };\n const {activeIndex, setActiveIndex, selectStep} = useWizardSteps({steps, initialStepKey, stepSelection, onStepChange, isStepValid});\n const activeStep = steps[activeIndex];\n\n // al terminar un envío fallido, regresa al paso con el primer error (solo en ese momento: escribir en otro paso no\n // debe jalar a la persona de regreso). OJO: getFieldState necesita formState para leer los errores de ahora\n useEffect(() => {\n if (wasSubmitting.current && !isSubmitting) {\n const errorStepIndex = steps.findIndex((step) => (step.fields ?? []).some((field) => getFieldState(field, formState).invalid));\n if (errorStepIndex >= 0) {\n setActiveIndex(errorStepIndex);\n }\n }\n wasSubmitting.current = isSubmitting;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isSubmitting, submitCount]);\n\n const goNext = async () => {\n // el paso tiene errores: se queda ahí para que se corrijan\n if (!(await isStepValid(activeIndex))) {\n return;\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys].filter((key) => key !== activeStep.key)));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n const skipStep = () => {\n for (const field of activeStep.fields ?? []) {\n resetField(field);\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys, activeStep.key]));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n return (\n <WizardLayout steps={steps} activeIndex={activeIndex} skippedKeys={skippedKeys} isSubmitting={isSubmitting} orientation={orientation} hideBackOnFirstStep={hideBackOnFirstStep}\n onBack={() => setActiveIndex((currentIndex) => currentIndex - 1)} onNext={goNext} onSkip={skipStep} onStepSelect={selectStep}\n submitButton={<SubmitButton>{submitLabel}</SubmitButton>} problemAlert={<FormProblemAlert />} texts={texts} />\n );\n}\n"],"names":[],"mappings":";;;;;;;;;AAqDA,SAAwB,WAAW;AAAA,EAC/B;AAAA,EAAO;AAAA,EAAa;AAAA,EAAa;AAAA,EAAqB;AAAA,EAAgB;AAAA,EAAe;AAAA,EAAc;AAAA,EAAO,GAAG;AACjH,GAAG;AACC,mBAAiB,cAAc,YAAY;AAC3C,QAAM,EAAC,SAAS,YAAY,eAAe,UAAA,IAAa,eAAA;AACxD,QAAM,EAAC,cAAc,YAAA,IAAe;AACpC,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,MAAM,oBAAI,KAAK;AAC9D,QAAM,gBAAgB,OAAO,KAAK;AAGlC,QAAM,cAAc,OAAO,UAAU;AACjC,UAAM,aAAa,MAAM,KAAK,EAAE,UAAU,CAAA;AAC1C,WAAO,WAAW,WAAW,KAAK,QAAQ,YAAY,EAAC,aAAa,MAAK;AAAA,EAC7E;AACA,QAAM,EAAC,aAAa,gBAAgB,WAAA,IAAc,eAAe,EAAC,OAAO,gBAAgB,eAAe,cAAc,YAAA,CAAY;AAClI,QAAM,aAAa,MAAM,WAAW;AAIpC,YAAU,MAAM;AACZ,QAAI,cAAc,WAAW,CAAC,cAAc;AACxC,YAAM,iBAAiB,MAAM,UAAU,CAAC,UAAU,KAAK,UAAU,CAAA,GAAI,KAAK,CAAC,UAAU,cAAc,OAAO,SAAS,EAAE,OAAO,CAAC;AAC7H,UAAI,kBAAkB,GAAG;AACrB,uBAAe,cAAc;AAAA,MACjC;AAAA,IACJ;AACA,kBAAc,UAAU;AAAA,EAE5B,GAAG,CAAC,cAAc,WAAW,CAAC;AAE9B,QAAM,SAAS,YAAY;AAEvB,QAAI,CAAE,MAAM,YAAY,WAAW,GAAI;AACnC;AAAA,IACJ;AACA,mBAAe,CAAC,gBAAgB,IAAI,IAAI,CAAC,GAAG,WAAW,EAAE,OAAO,CAAC,QAAQ,QAAQ,WAAW,GAAG,CAAC,CAAC;AACjG,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA,QAAM,WAAW,MAAM;AACnB,eAAW,SAAS,WAAW,UAAU,CAAA,GAAI;AACzC,iBAAW,KAAK;AAAA,IACpB;AACA,mBAAe,CAAC,gBAAgB,oBAAI,IAAI,CAAC,GAAG,aAAa,WAAW,GAAG,CAAC,CAAC;AACzE,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA,SACI;AAAA,IAAC;AAAA,IAAA;AAAA,MAAa;AAAA,MAAc;AAAA,MAA0B;AAAA,MAA0B;AAAA,MAA4B;AAAA,MAA0B;AAAA,MACxH,QAAQ,MAAM,eAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,MAAG,QAAQ;AAAA,MAAQ,QAAQ;AAAA,MAAU,cAAc;AAAA,MAClH,cAAc,oBAAC,cAAA,EAAc,UAAA,YAAA,CAAY;AAAA,MAAiB,kCAAe,kBAAA,EAAiB;AAAA,MAAI;AAAA,IAAA;AAAA,EAAA;AAEpH;"}
1
+ {"version":3,"file":"FormWizard.js","sources":["../../src/hookForm/FormWizard.jsx"],"sourcesContent":["// Formulario por pasos (wizard) con react-hook-form: el mismo del de formik (@stackcraftmx/calcita/forms), con las mismas\n// props y el mismo comportamiento:\n// - «Siguiente» revisa SOLO los campos del paso (trigger con sus nombres) y no avanza si alguno tiene error;\n// - «Omitir este paso» (solo en los opcionales) regresa sus campos a su valor de inicio (resetField): omitir es no dar ese\n// dato, no dejar lo que se escribió a medias;\n// - el último paso envía TODO el formulario (el esquema de yup completo vale para todos los pasos);\n// - si el envío falla por un campo de un paso anterior (yup o el 422 del servidor con applyServerProblem), regresa solo a\n// ese paso: un error que no se ve no se puede corregir;\n// - para editar un registro (se entra a corregir un dato suelto): initialStepKey abre en el paso que diga el servidor,\n// orientation=\"vertical\" pone los campos dentro de su paso, y stepSelection deja pulsar el título de otro paso para\n// saltar directo (free) o solo si el paso abierto no tiene errores (checked). onStepChange avisa cada cambio de paso.\n//\n// Va DENTRO de <FormProvider> y del <form> con su handleSubmit (usa useFormContext):\n// const form = useForm({resolver: yupResolver(schema), defaultValues});\n// <FormProvider {...form}>\n// <form noValidate onSubmit={form.handleSubmit(send)}>\n// <FormWizard submitLabel=\"Enviar solicitud\" steps={[\n// {key: 'datos', label: 'Sus datos', fields: ['name', 'email'], content: <>…campos…</>},\n// {key: 'aval', label: 'Aval', optional: true, fields: ['guarantor'], content: <>…</>},\n// ]} />\n// </form>\n// </FormProvider>\n//\n// Lo que se ve vive en WizardLayout (inputs/). Los textos se cambian con `texts`: {wizard: {next: 'Continuar'}}.\n//\n// Genérico del paquete: function (regla de la casa).\nimport {useEffect, useRef, useState} from 'react';\nimport {useFormContext} from 'react-hook-form';\n\nimport FormProblemAlert from '@/hookForm/FormProblemAlert.jsx';\nimport SubmitButton from '@/hookForm/SubmitButton.jsx';\nimport {useWizardSteps} from '@/inputs/useWizardSteps.js';\nimport WizardLayout from '@/inputs/WizardLayout.jsx';\nimport {warnUnknownProps} from '@/toolkit/unknownProps.js';\n\n/**\n * @param {object} props\n * @param {Object[]} props.steps en orden\n * @param {string} props.steps[].key\n * @param {string} props.steps[].label ya traducido\n * @param {string[]} [props.steps[].fields] los campos del paso (los que revisa «Siguiente»)\n * @param {boolean} [props.steps[].optional] se puede omitir\n * @param {import('react').ReactNode} [props.steps[].nextDisabledReason] ya traducido: el paso todavía no se puede dejar («Faltan 2\n * documentos»). «Siguiente» se ve apagado con el motivo en un aviso (escrito, en el teléfono); en el último paso se apaga\n * el de enviar. Con stepSelection checked tampoco se salta a un paso de adelante. Sin él, como siempre\n * @param {import('react').ReactNode} props.steps[].content los campos del paso\n * @param {boolean} [props.steps[].completed] lleva la palomita (cuando los títulos se pulsan, solo si la pantalla lo dice)\n * @param {string} props.submitLabel ya traducido: el botón del último paso\n * @param {'horizontal'|'vertical'} [props.orientation] uno de WIZARD_ORIENTATION; por omisión horizontal\n * @param {boolean} [props.hideBackOnFirstStep] en el primer paso no se pinta «Anterior»; por omisión sale apagado, como antes\n * @param {string} [props.initialStepKey] la llave del paso en el que se abre (la que diga el servidor); por omisión el primero\n * @param {'none'|'free'|'checked'} [props.stepSelection] uno de WIZARD_STEP_SELECTION: si el título de otro paso se pulsa y qué\n * revisa antes; por omisión none (se avanza con «Siguiente», como siempre)\n * @param {(stepKey: string, index: number) => void} [props.onStepChange] cada vez que cambia el paso abierto\n * @param {object} [props.texts] los textos que cambian, con la forma de `common`: {wizard: {next, back, skip, optional, skipped, stepOf}}\n */\nexport default function FormWizard({\n steps, submitLabel, orientation, hideBackOnFirstStep, initialStepKey, stepSelection, onStepChange, texts, ...unknownProps\n}) {\n warnUnknownProps('FormWizard', unknownProps);\n const {trigger, resetField, getFieldState, formState} = useFormContext();\n const {isSubmitting, submitCount} = formState;\n const [skippedKeys, setSkippedKeys] = useState(() => new Set());\n const wasSubmitting = useRef(false);\n\n // revisa SOLO los campos de un paso (trigger los marca, los pinta y pone el foco en el primero con error)\n const isStepValid = async (index) => {\n const stepFields = steps[index].fields ?? [];\n return stepFields.length === 0 || trigger(stepFields, {shouldFocus: true});\n };\n const {activeIndex, setActiveIndex, selectStep} = useWizardSteps({steps, initialStepKey, stepSelection, onStepChange, isStepValid});\n const activeStep = steps[activeIndex];\n\n // al terminar un envío fallido, regresa al paso con el primer error (solo en ese momento: escribir en otro paso no\n // debe jalar a la persona de regreso). OJO: getFieldState necesita formState para leer los errores de ahora\n useEffect(() => {\n if (wasSubmitting.current && !isSubmitting) {\n const errorStepIndex = steps.findIndex((step) => (step.fields ?? []).some((field) => getFieldState(field, formState).invalid));\n if (errorStepIndex >= 0) {\n setActiveIndex(errorStepIndex);\n }\n }\n wasSubmitting.current = isSubmitting;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isSubmitting, submitCount]);\n\n const goNext = async () => {\n // el paso tiene errores: se queda ahí para que se corrijan\n if (!(await isStepValid(activeIndex))) {\n return;\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys].filter((key) => key !== activeStep.key)));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n const skipStep = () => {\n for (const field of activeStep.fields ?? []) {\n resetField(field);\n }\n setSkippedKeys((currentKeys) => new Set([...currentKeys, activeStep.key]));\n setActiveIndex((currentIndex) => currentIndex + 1);\n };\n\n return (\n // un paso con su motivo para no avanzar (steps[].nextDisabledReason): «Siguiente» apagado y, en el último, también el de\n // enviar; este sí con disabled de verdad, así un Enter en un campo tampoco envía\n <WizardLayout steps={steps} activeIndex={activeIndex} skippedKeys={skippedKeys} isSubmitting={isSubmitting} orientation={orientation} hideBackOnFirstStep={hideBackOnFirstStep}\n nextDisabledReason={activeStep.nextDisabledReason} onBack={() => setActiveIndex((currentIndex) => currentIndex - 1)}\n onNext={goNext} onSkip={skipStep} onStepSelect={selectStep}\n submitButton={<SubmitButton disabled={Boolean(activeStep.nextDisabledReason)}>{submitLabel}</SubmitButton>}\n problemAlert={<FormProblemAlert />} texts={texts} />\n );\n}\n"],"names":[],"mappings":";;;;;;;;;AAwDA,SAAwB,WAAW;AAAA,EAC/B;AAAA,EAAO;AAAA,EAAa;AAAA,EAAa;AAAA,EAAqB;AAAA,EAAgB;AAAA,EAAe;AAAA,EAAc;AAAA,EAAO,GAAG;AACjH,GAAG;AACC,mBAAiB,cAAc,YAAY;AAC3C,QAAM,EAAC,SAAS,YAAY,eAAe,UAAA,IAAa,eAAA;AACxD,QAAM,EAAC,cAAc,YAAA,IAAe;AACpC,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,MAAM,oBAAI,KAAK;AAC9D,QAAM,gBAAgB,OAAO,KAAK;AAGlC,QAAM,cAAc,OAAO,UAAU;AACjC,UAAM,aAAa,MAAM,KAAK,EAAE,UAAU,CAAA;AAC1C,WAAO,WAAW,WAAW,KAAK,QAAQ,YAAY,EAAC,aAAa,MAAK;AAAA,EAC7E;AACA,QAAM,EAAC,aAAa,gBAAgB,WAAA,IAAc,eAAe,EAAC,OAAO,gBAAgB,eAAe,cAAc,YAAA,CAAY;AAClI,QAAM,aAAa,MAAM,WAAW;AAIpC,YAAU,MAAM;AACZ,QAAI,cAAc,WAAW,CAAC,cAAc;AACxC,YAAM,iBAAiB,MAAM,UAAU,CAAC,UAAU,KAAK,UAAU,CAAA,GAAI,KAAK,CAAC,UAAU,cAAc,OAAO,SAAS,EAAE,OAAO,CAAC;AAC7H,UAAI,kBAAkB,GAAG;AACrB,uBAAe,cAAc;AAAA,MACjC;AAAA,IACJ;AACA,kBAAc,UAAU;AAAA,EAE5B,GAAG,CAAC,cAAc,WAAW,CAAC;AAE9B,QAAM,SAAS,YAAY;AAEvB,QAAI,CAAE,MAAM,YAAY,WAAW,GAAI;AACnC;AAAA,IACJ;AACA,mBAAe,CAAC,gBAAgB,IAAI,IAAI,CAAC,GAAG,WAAW,EAAE,OAAO,CAAC,QAAQ,QAAQ,WAAW,GAAG,CAAC,CAAC;AACjG,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA,QAAM,WAAW,MAAM;AACnB,eAAW,SAAS,WAAW,UAAU,CAAA,GAAI;AACzC,iBAAW,KAAK;AAAA,IACpB;AACA,mBAAe,CAAC,gBAAgB,oBAAI,IAAI,CAAC,GAAG,aAAa,WAAW,GAAG,CAAC,CAAC;AACzE,mBAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,EACrD;AAEA;AAAA;AAAA;AAAA,IAGI;AAAA,MAAC;AAAA,MAAA;AAAA,QAAa;AAAA,QAAc;AAAA,QAA0B;AAAA,QAA0B;AAAA,QAA4B;AAAA,QAA0B;AAAA,QACxH,oBAAoB,WAAW;AAAA,QAAoB,QAAQ,MAAM,eAAe,CAAC,iBAAiB,eAAe,CAAC;AAAA,QAClH,QAAQ;AAAA,QAAQ,QAAQ;AAAA,QAAU,cAAc;AAAA,QAChD,kCAAe,cAAA,EAAa,UAAU,QAAQ,WAAW,kBAAkB,GAAI,UAAA,aAAY;AAAA,QAC3F,kCAAe,kBAAA,EAAiB;AAAA,QAAI;AAAA,MAAA;AAAA,IAAA;AAAA;AAE1D;"}
package/dist/index.js CHANGED
@@ -121,7 +121,7 @@ import { default as default69 } from "./inputs/ColorInput.js";
121
121
  import { default as default70 } from "./inputs/FieldFrame.js";
122
122
  import { default as default71 } from "./inputs/MaskedInput.js";
123
123
  import { INPUT_MASK, isMaskComplete, maskPatternOf, maskedTextOf, rawValueOf } from "./inputs/inputMasks.js";
124
- import { FORM_LABEL_PLACEMENT, FORM_ROW_CLASS, FormRow, default as default72 } from "./inputs/FormSection.js";
124
+ import { FORM_LABEL_PLACEMENT, FORM_LABEL_WIDTH, FORM_ROW_CLASS, FormRow, default as default72 } from "./inputs/FormSection.js";
125
125
  import { default as default73 } from "./inputs/CheckboxGroupInput.js";
126
126
  import { default as default74 } from "./inputs/NumberInput.js";
127
127
  import { default as default75 } from "./inputs/TransferListInput.js";
@@ -238,6 +238,7 @@ export {
238
238
  FILTER_FIELD_KIND,
239
239
  FONT_SIZE_LEVELS,
240
240
  FORM_LABEL_PLACEMENT,
241
+ FORM_LABEL_WIDTH,
241
242
  FORM_ROW_CLASS,
242
243
  default70 as FieldFrame,
243
244
  default25 as FileDropzone,
@@ -11,10 +11,12 @@ import { fieldVariablesOf, accentTextStylesOf, PANEL_RADIUS, fieldWellFromVariab
11
11
  import { warnUnknownProps } from "../toolkit/unknownProps.js";
12
12
  import { useToolkitTranslation } from "../toolkit/useToolkitTranslation.js";
13
13
  const FORM_LABEL_PLACEMENT = Object.freeze({ side: "side", topOnPhone: "topOnPhone" });
14
+ const FORM_LABEL_WIDTH = Object.freeze({ auto: "auto" });
14
15
  const FORM_ROW_CLASS = "calcita-form-row";
15
16
  const ROW_MIN_HEIGHT_PIXELS = 52;
16
17
  const ROW_PADDING_BLOCK_PIXELS = 8;
17
18
  const LABEL_COLUMN = Object.freeze({ share: "38%", maxPixels: 200 });
19
+ const AUTO_CONTROL_MAX_SHARE = "50%";
18
20
  const LABEL_TOP_PIXELS = 8;
19
21
  const RING = Object.freeze({ focusPercent: 45, errorPercent: 60, pixels: 2 });
20
22
  function rowControlSxOf(theme) {
@@ -61,25 +63,40 @@ const FormRowRoot = styled(Box, { name: ROW_THEME_NAME, slot: "Root", shouldForw
61
63
  ...isTopOnPhone && { [theme.breakpoints.down("sm")]: { flexDirection: "column", alignItems: "stretch", gap: 0.5 } }
62
64
  });
63
65
  });
66
+ function labelColumnStylesOf(labelWidth) {
67
+ if (labelWidth === FORM_LABEL_WIDTH.auto) {
68
+ return { flex: "1 1 auto", maxWidth: "none" };
69
+ }
70
+ if (labelWidth !== void 0 && labelWidth !== null) {
71
+ const width = typeof labelWidth === "number" ? `${labelWidth}px` : labelWidth;
72
+ return { flex: `0 0 ${width}`, maxWidth: "none" };
73
+ }
74
+ return { flex: `0 0 ${LABEL_COLUMN.share}`, maxWidth: LABEL_COLUMN.maxPixels };
75
+ }
64
76
  const FormRowLabel = styled(Typography, { name: ROW_THEME_NAME, slot: "Label", shouldForwardProp: isPartProp })(({ theme, ownerState }) => theme.unstable_sx({
65
- flex: `0 0 ${LABEL_COLUMN.share}`,
66
- maxWidth: LABEL_COLUMN.maxPixels,
77
+ ...labelColumnStylesOf(ownerState?.labelWidth),
67
78
  pt: `${LABEL_TOP_PIXELS}px`,
68
79
  lineHeight: 1.5,
69
80
  color: "text.primary",
81
+ // va después del ancho: en el teléfono, con la etiqueta arriba, la columna ya no cuenta
70
82
  ...ownerState?.labelPlacement === FORM_LABEL_PLACEMENT.topOnPhone && {
71
83
  [theme.breakpoints.down("sm")]: { flex: "none", maxWidth: "none", pt: 0 }
72
84
  },
73
85
  // el asterisco de lo obligatorio: aquí, debajo de lo que el proyecto ponga en su tema
74
86
  ...ownerState?.requiredMark && requiredMarkSxOf(ownerState.requiredMark)
75
87
  }));
76
- const FormRowControl = styled(Box, { name: ROW_THEME_NAME, slot: "Control" })({
77
- flex: 1,
78
- minWidth: 0,
79
- display: "flex",
80
- flexDirection: "column",
81
- justifyContent: "center",
82
- minHeight: 40
88
+ const FormRowControl = styled(Box, { name: ROW_THEME_NAME, slot: "Control", shouldForwardProp: isPartProp })(({ theme, ownerState }) => {
89
+ const isAuto = ownerState?.labelWidth === FORM_LABEL_WIDTH.auto;
90
+ return {
91
+ flex: isAuto ? "0 0 auto" : 1,
92
+ ...isAuto && { maxWidth: AUTO_CONTROL_MAX_SHARE },
93
+ ...isAuto && ownerState?.labelPlacement === FORM_LABEL_PLACEMENT.topOnPhone && { [theme.breakpoints.down("sm")]: { maxWidth: "none" } },
94
+ minWidth: 0,
95
+ display: "flex",
96
+ flexDirection: "column",
97
+ justifyContent: "center",
98
+ minHeight: 40
99
+ };
83
100
  });
84
101
  const FormSectionRoot = styled(Box, { name: SECTION_THEME_NAME, slot: "Root" })({});
85
102
  const FormSectionTitle = styled(Typography, { name: SECTION_THEME_NAME, slot: "Title" })(({ theme }) => theme.unstable_sx({ px: 2, mb: 0.75, fontWeight: 600 }));
@@ -104,6 +121,7 @@ function FormRow(props) {
104
121
  const sectionLayout = useFormSectionLayout();
105
122
  const ownerState = {
106
123
  labelPlacement: sectionLayout ? sectionLayout.labelPlacement : FORM_LABEL_PLACEMENT.side,
124
+ labelWidth: sectionLayout ? sectionLayout.labelWidth : void 0,
107
125
  requiredMark: required ? t("form.requiredMark") : null
108
126
  };
109
127
  return /* @__PURE__ */ jsxs(FormRowRoot, { className: FORM_ROW_CLASS, ownerState, children: [
@@ -119,15 +137,23 @@ function FormRow(props) {
119
137
  children: label
120
138
  }
121
139
  ),
122
- /* @__PURE__ */ jsx(FormRowControl, { children })
140
+ /* @__PURE__ */ jsx(FormRowControl, { ownerState, children })
123
141
  ] });
124
142
  }
125
143
  function FormSection(props) {
126
- const { title, footer, labelPlacement = FORM_LABEL_PLACEMENT.side, children, ...unknownProps } = useThemeProps({ props, name: SECTION_THEME_NAME });
144
+ const {
145
+ title,
146
+ footer,
147
+ labelPlacement = FORM_LABEL_PLACEMENT.side,
148
+ labelWidth,
149
+ sx,
150
+ children,
151
+ ...unknownProps
152
+ } = useThemeProps({ props, name: SECTION_THEME_NAME });
127
153
  warnUnknownProps(SECTION_THEME_NAME, unknownProps);
128
154
  const titleId = useId();
129
- const layout = useMemo(() => ({ labelPlacement }), [labelPlacement]);
130
- return /* @__PURE__ */ jsx(FormSectionContext, { value: layout, children: /* @__PURE__ */ jsxs(FormSectionRoot, { component: "section", "aria-labelledby": title ? titleId : void 0, children: [
155
+ const layout = useMemo(() => ({ labelPlacement, labelWidth }), [labelPlacement, labelWidth]);
156
+ return /* @__PURE__ */ jsx(FormSectionContext, { value: layout, children: /* @__PURE__ */ jsxs(FormSectionRoot, { component: "section", "aria-labelledby": title ? titleId : void 0, sx, children: [
131
157
  title && /* @__PURE__ */ jsx(FormSectionTitle, { id: titleId, variant: "subtitle2", component: "h2", color: "text.secondary", children: title }),
132
158
  /* @__PURE__ */ jsx(FormSectionCard, { variant: "outlined", children: /* @__PURE__ */ jsx(Box, { sx: { mt: "-1px" }, children }) }),
133
159
  footer && /* @__PURE__ */ jsx(FormSectionFooter, { variant: "caption", component: "p", color: "text.secondary", children: footer })
@@ -135,6 +161,7 @@ function FormSection(props) {
135
161
  }
136
162
  export {
137
163
  FORM_LABEL_PLACEMENT,
164
+ FORM_LABEL_WIDTH,
138
165
  FORM_ROW_CLASS,
139
166
  FormRow,
140
167
  FormSection as default