@stackcraftmx/calcita 0.53.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.
Files changed (47) hide show
  1. package/README.md +87 -2
  2. package/dist/api/createApiClient.js +11 -3
  3. package/dist/api/createApiClient.js.map +1 -1
  4. package/dist/filepond/FileDropzone.js +60 -19
  5. package/dist/filepond/FileDropzone.js.map +1 -1
  6. package/dist/filepond/pondServer.js +86 -27
  7. package/dist/filepond/pondServer.js.map +1 -1
  8. package/dist/forms/FormWizard.js +22 -17
  9. package/dist/forms/FormWizard.js.map +1 -1
  10. package/dist/hookForm/FormWizard.js +22 -17
  11. package/dist/hookForm/FormWizard.js.map +1 -1
  12. package/dist/index.js +4 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/inputs/FormSection.js +40 -13
  15. package/dist/inputs/FormSection.js.map +1 -1
  16. package/dist/inputs/WizardLayout.js +25 -4
  17. package/dist/inputs/WizardLayout.js.map +1 -1
  18. package/dist/inputs/formLayoutContext.js +1 -1
  19. package/dist/inputs/formLayoutContext.js.map +1 -1
  20. package/dist/inputs/useWizardSteps.js +3 -0
  21. package/dist/inputs/useWizardSteps.js.map +1 -1
  22. package/dist/locales/en.js +1 -0
  23. package/dist/locales/en.js.map +1 -1
  24. package/dist/locales/es.js +1 -0
  25. package/dist/locales/es.js.map +1 -1
  26. package/dist/types/api/createApiClient.d.ts +10 -0
  27. package/dist/types/filepond/FileDropzone.d.ts +5 -0
  28. package/dist/types/filepond/chunkedUpload.d.ts +122 -0
  29. package/dist/types/filepond/pondServer.d.ts +25 -3
  30. package/dist/types/forms/FormWizard.d.ts +4 -0
  31. package/dist/types/hookForm/FormWizard.d.ts +4 -0
  32. package/dist/types/index.d.ts +2 -1
  33. package/dist/types/inputs/FormSection.d.ts +14 -0
  34. package/dist/types/inputs/WizardLayout.d.ts +1 -0
  35. package/dist/types/inputs/formLayoutContext.d.ts +8 -7
  36. package/dist/types/inputs/useWizardSteps.d.ts +2 -1
  37. package/dist/types/locales/en.d.ts +1 -0
  38. package/dist/types/locales/es.d.ts +1 -0
  39. package/dist/types/upload/chunkedUpload.d.ts +189 -0
  40. package/dist/types/upload/uploadWithProgress.d.ts +4 -2
  41. package/dist/upload/FileUploadInput.js +1 -1
  42. package/dist/upload/FileUploadInput.js.map +1 -1
  43. package/dist/upload/chunkedUpload.js +261 -0
  44. package/dist/upload/chunkedUpload.js.map +1 -0
  45. package/dist/upload/uploadWithProgress.js +8 -3
  46. package/dist/upload/uploadWithProgress.js.map +1 -1
  47. package/package.json +1 -1
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,
@@ -1259,6 +1288,62 @@ import {FormFileUploadField} from '@stackcraftmx/calcita/forms';
1259
1288
  omisión `result.id`). Lo del negocio (Aprobado, Falta información) va junto al estado con `actions`, y `onStatusChange`
1260
1289
  dice en qué va cada renglón para contar «2 de 3 cargados».
1261
1290
 
1291
+ ### Por trozos, con FilePond
1292
+
1293
+ Para archivos grandes o servidores que solo reciben por trozos: la zona de `/filepond` con `createPondServer` sube cada
1294
+ archivo con el protocolo de trozos de FilePond (un POST da el folio, cada trozo va en su PATCH a `patchUrl` + folio, uno
1295
+ detrás de otro, y un HEAD dice hasta dónde llegó para retomar). Las opciones llevan los nombres de FilePond, así que un
1296
+ backend que ya recibía trozos de FilePond no cambia.
1297
+
1298
+ ```jsx
1299
+ import {FileDropzone, createPondServer} from '@stackcraftmx/calcita/filepond';
1300
+
1301
+ const backupServer = createPondServer({
1302
+ url: '/upload',
1303
+ patchUrl: '/upload?patch=',
1304
+ headers: {Authorization: `Bearer ${token}`, 'X-Upload-Context': JSON.stringify({branch: 'Querétaro'})},
1305
+ // un backend que contesta {message, errors} en lugar de RFC 9457: la tarjeta enseña el title
1306
+ toProblem: (body) => ({title: body.message, detail: body.message, errors: body.errors}),
1307
+ });
1308
+
1309
+ <FileDropzone value={file} onChange={setFile} server={backupServer} apiRef={pondApi}
1310
+ chunkUploads chunkForce chunkSize="1 MB" chunkRetryDelays={[500, 1000, 3000]}
1311
+ pondProps={{maxParallelUploads: 1}} />
1312
+ ```
1313
+
1314
+ - La barra es honesta: cuenta lo que el servidor ya guardó y no dice 100 % hasta que guardó el último trozo (hay servidores
1315
+ que esperan medio segundo por trozo). Nunca regresa, ni cuando un trozo se reintenta.
1316
+ - Un trozo sin respuesta o con 5xx se reintenta con las esperas de `chunkRetryDelays` (`null` o `[]`, sin reintentos); un 4xx
1317
+ es el servidor diciendo que no y su motivo sale en la tarjeta en seguida. Con el botón de reintentar de la tarjeta, sigue
1318
+ desde el último trozo completo, con el mismo folio.
1319
+ - `chunkSize` va en bytes o con unidades, como `maxSize` (por omisión 5 MB, el de FilePond); `chunkForce` manda por trozos
1320
+ también lo chico.
1321
+ - Los headers van en las tres peticiones y por trozos viajan en UTF-8 (el nombre del archivo también): páselos como texto
1322
+ normal. Un sistema que los convertía a mano para una versión vieja de FilePond (`unescape(encodeURIComponent(...))`) ya no
1323
+ debe hacerlo: convertidos dos veces, los acentos llegan rotos.
1324
+ - `sendRequest` cambia por dónde viaja cada petición (su cliente, o uno de mentira para probar sin servidor); por omisión es
1325
+ `uploadWithProgress`, que ahora también acepta `method`.
1326
+ - `pondProps` pasa cualquier otra opción de FilePond tal cual. Lo que la zona ya maneja gana (los tipos y pesos, los textos,
1327
+ el candado de `disabled`/`busy`), y sus eventos (`onupdatefiles`, `onprocessfile`, `onwarning`, `onerror`, `beforeAddFile`)
1328
+ corren después de los de la zona.
1329
+
1330
+ El cargador mini también sube por trozos: `createChunkedUpload` arma la función `upload` de `FileUploadInput` y
1331
+ `FormFileUploadField` con el mismo protocolo, las mismas opciones (`patchUrl`, `chunkSize`, `chunkRetryDelays`, `headers`,
1332
+ `toProblem`, `sendRequest`) y sus estados de siempre. Con el último byte enviado la barra llega a 100 % y el renglón dice
1333
+ «Procesando» hasta que el servidor guardó el último trozo; al renglón llega el folio como id.
1334
+
1335
+ ```jsx
1336
+ import {FileUploadInput, createChunkedUpload} from '@stackcraftmx/calcita';
1337
+
1338
+ const uploadBackup = createChunkedUpload({url: '/upload', patchUrl: '/upload?patch=', chunkSize: '1 MB',
1339
+ toProblem: (body) => ({title: body.message, detail: body.message, errors: body.errors})});
1340
+
1341
+ <FileUploadInput label="Respaldo de la base" value={backupId} onChange={setBackupId} upload={uploadBackup} />
1342
+ ```
1343
+
1344
+ Todo va por trozos (uno chico, en un solo trozo). Si solo los grandes deben ir así, la pantalla elige:
1345
+ `upload={(file, options) => (file.size > LIMITE ? uploadBackup : subidaNormal)(file, options)}`.
1346
+
1262
1347
  ## Documentos, avisos legales y cuenta regresiva
1263
1348
 
1264
1349
  - `PdfViewerDialog`: un PDF en una ventana con el visor del navegador (sin librerías), con «Abrir en otra pestaña» y
@@ -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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"}
@@ -34,6 +34,30 @@ function detectFileType(file) {
34
34
  const dotPosition = file.name.lastIndexOf(".");
35
35
  return Promise.resolve(dotPosition >= 0 ? file.name.slice(dotPosition).toLowerCase() : mimeType);
36
36
  }
37
+ function stablePondPropsOf(pondProps, stableFunctions, latest) {
38
+ const { files: ignoredFiles, ...pondOptions } = pondProps ?? {};
39
+ if (ignoredFiles !== void 0) {
40
+ warnUnknownProps(THEME_NAME, { "pondProps.files": ignoredFiles });
41
+ }
42
+ return Object.fromEntries(Object.entries(pondOptions).map(([option, value]) => {
43
+ if (typeof value !== "function") {
44
+ return [option, value];
45
+ }
46
+ if (!stableFunctions.current.has(option)) {
47
+ stableFunctions.current.set(option, (...args) => latest.current.pondProps?.[option]?.(...args));
48
+ }
49
+ return [option, stableFunctions.current.get(option)];
50
+ }));
51
+ }
52
+ function chunkOptionsOf({ chunkUploads, chunkForce, chunkSize, chunkRetryDelays }) {
53
+ return {
54
+ ...chunkUploads !== void 0 && { chunkUploads },
55
+ ...chunkForce !== void 0 && { chunkForce },
56
+ ...chunkSize !== void 0 && { chunkSize: parseFileSize(chunkSize) },
57
+ // FilePond truena con null: sin reintentos es una lista vacía
58
+ ...chunkRetryDelays !== void 0 && { chunkRetryDelays: chunkRetryDelays ?? [] }
59
+ };
60
+ }
37
61
  function sameFiles(firstFiles, secondFiles) {
38
62
  return firstFiles.length === secondFiles.length && firstFiles.every((file, position) => file === secondFiles[position]);
39
63
  }
@@ -102,6 +126,11 @@ function FileDropzoneContent(props) {
102
126
  apiRef = null,
103
127
  onProcessProgress,
104
128
  onReject,
129
+ chunkUploads,
130
+ chunkForce,
131
+ chunkSize,
132
+ chunkRetryDelays,
133
+ pondProps = null,
105
134
  actions,
106
135
  children,
107
136
  sx,
@@ -123,7 +152,10 @@ function FileDropzoneContent(props) {
123
152
  const files = useMemo(() => filesOf(value, multiple), [value, multiple]);
124
153
  const pondRef = useRef(null);
125
154
  const latest = useRef({});
126
- latest.current = { files, multiple, onChange, onReject, autoReject, validate, onProcessProgress, maxFiles, typeErrorLabel, totalErrorLabel, t };
155
+ latest.current = { files, multiple, onChange, onReject, autoReject, validate, onProcessProgress, maxFiles, typeErrorLabel, totalErrorLabel, t, pondProps };
156
+ const stableFunctions = useRef(/* @__PURE__ */ new Map());
157
+ const pondOptions = stablePondPropsOf(pondProps, stableFunctions, latest);
158
+ const pondAllows = (option) => !isLocked && (pondProps?.[option] ?? true);
127
159
  useEffect(() => {
128
160
  if (apiRef) {
129
161
  apiRef.current = pondRef.current;
@@ -144,7 +176,7 @@ function FileDropzoneContent(props) {
144
176
  emitChange(nextFile);
145
177
  }
146
178
  }, []);
147
- const handleError = useCallback((error, item) => {
179
+ const handleError = useCallback((error, item, status) => {
148
180
  syncFromPond();
149
181
  const { onReject: reject, autoReject: shouldAutoReject, typeErrorLabel: typeLabel, totalErrorLabel: totalLabel } = latest.current;
150
182
  let reason = FILE_REJECTION.size;
@@ -163,11 +195,13 @@ function FileDropzoneContent(props) {
163
195
  if (reject && item?.file) {
164
196
  reject(item.file, reason);
165
197
  }
198
+ latest.current.pondProps?.onerror?.(error, item, status);
166
199
  }, [syncFromPond]);
167
200
  const handleBeforeAdd = useCallback(async (item) => {
168
- const { validate: judge, onReject: reject, typeErrorLabel: fallbackMessage } = latest.current;
201
+ const { validate: judge, onReject: reject, typeErrorLabel: fallbackMessage, pondProps: projectOptions } = latest.current;
202
+ const projectAccepts = async () => projectOptions?.beforeAddFile ? await projectOptions.beforeAddFile(item) !== false : true;
169
203
  if (!judge) {
170
- return true;
204
+ return projectAccepts();
171
205
  }
172
206
  let verdict;
173
207
  try {
@@ -176,7 +210,7 @@ function FileDropzoneContent(props) {
176
210
  verdict = false;
177
211
  }
178
212
  if (verdict === true) {
179
- return true;
213
+ return projectAccepts();
180
214
  }
181
215
  const message = typeof verdict === "string" ? verdict : fallbackMessage;
182
216
  if (reject) {
@@ -186,16 +220,16 @@ function FileDropzoneContent(props) {
186
220
  }
187
221
  return false;
188
222
  }, []);
189
- const handleProcessed = useCallback(() => {
190
- const report = latest.current.onProcessProgress;
191
- if (!report) {
192
- return;
223
+ const handleProcessed = useCallback((error, processedItem) => {
224
+ const { onProcessProgress: report, pondProps: projectOptions } = latest.current;
225
+ if (report) {
226
+ const pondItems = pondRef.current?.getFiles?.() ?? [];
227
+ report({ done: pondItems.filter((item) => item.status === FileStatus.PROCESSING_COMPLETE).length, total: pondItems.length });
193
228
  }
194
- const pondItems = pondRef.current?.getFiles?.() ?? [];
195
- report({ done: pondItems.filter((item) => item.status === FileStatus.PROCESSING_COMPLETE).length, total: pondItems.length });
229
+ projectOptions?.onprocessfile?.(error, processedItem);
196
230
  }, []);
197
- const handleWarning = useCallback((warning) => {
198
- const { maxFiles: maxFileCount, onReject: reject, t: translate } = latest.current;
231
+ const handleWarning = useCallback((warning, file, status) => {
232
+ const { maxFiles: maxFileCount, onReject: reject, t: translate, pondProps: projectOptions } = latest.current;
199
233
  if (maxFileCount && String(warning?.body ?? "").toLowerCase().includes(MAX_FILES_WARNING)) {
200
234
  if (reject) {
201
235
  reject(null, FILE_REJECTION.count);
@@ -203,7 +237,12 @@ function FileDropzoneContent(props) {
203
237
  toastError(translate("upload.tooManyFiles", { count: maxFileCount }));
204
238
  }
205
239
  }
240
+ projectOptions?.onwarning?.(warning, file, status);
206
241
  }, []);
242
+ const handleUpdateFiles = useCallback((items) => {
243
+ syncFromPond();
244
+ latest.current.pondProps?.onupdatefiles?.(items);
245
+ }, [syncFromPond]);
207
246
  useEffect(() => {
208
247
  const pondItems = pondRef.current?.getFiles?.() ?? [];
209
248
  if (files.length === 0 && pondItems.length > 0) {
@@ -222,21 +261,23 @@ function FileDropzoneContent(props) {
222
261
  /* @__PURE__ */ jsx(
223
262
  FilePond,
224
263
  {
264
+ ...pondOptions,
265
+ ...chunkOptionsOf({ chunkUploads, chunkForce, chunkSize, chunkRetryDelays }),
225
266
  ref: pondRef,
226
267
  name: pondName,
227
268
  server,
228
269
  instantUpload,
229
270
  beforeAddFile: handleBeforeAdd,
230
271
  onprocessfile: handleProcessed,
231
- onupdatefiles: syncFromPond,
272
+ onupdatefiles: handleUpdateFiles,
232
273
  allowMultiple: multiple,
233
274
  maxFiles: multiple ? maxFiles ?? null : 1,
234
275
  disabled: isLocked,
235
- allowDrop: !isLocked,
236
- allowBrowse: !isLocked,
237
- allowPaste: !isLocked,
238
- allowRemove: !isLocked,
239
- allowReplace: !isLocked,
276
+ allowDrop: pondAllows("allowDrop"),
277
+ allowBrowse: pondAllows("allowBrowse"),
278
+ allowPaste: pondAllows("allowPaste"),
279
+ allowRemove: pondAllows("allowRemove"),
280
+ allowReplace: pondAllows("allowReplace"),
240
281
  credits: false,
241
282
  acceptedFileTypes: acceptedTypes,
242
283
  fileValidateTypeDetectType: detectFileType,