@nubitio/core 0.1.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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/index.cjs +946 -0
- package/dist/index.d.cts +548 -0
- package/dist/index.d.mts +548 -0
- package/dist/index.mjs +894 -0
- package/package.json +54 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useTranslation } from "react-i18next";
|
|
4
|
+
import i18next from "i18next";
|
|
5
|
+
//#region packages/core/config/CoreConfig.tsx
|
|
6
|
+
const _coreConfig = {
|
|
7
|
+
locale: "es",
|
|
8
|
+
timezone: "UTC",
|
|
9
|
+
apiBaseUrl: "/api/"
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Configure core runtime values (locale, timezone, apiBaseUrl).
|
|
13
|
+
* This updates the module-level singleton so it can be read from non-React code
|
|
14
|
+
* (e.g. inside defineResource, entityField builders, DateUtils, etc.).
|
|
15
|
+
*/
|
|
16
|
+
function configureCore(config) {
|
|
17
|
+
if (config.locale !== void 0) _coreConfig.locale = config.locale;
|
|
18
|
+
if (config.timezone !== void 0) _coreConfig.timezone = config.timezone;
|
|
19
|
+
if (config.apiBaseUrl !== void 0) _coreConfig.apiBaseUrl = config.apiBaseUrl;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* @deprecated Use configureCore() instead.
|
|
23
|
+
*/
|
|
24
|
+
const configureCoreDate = configureCore;
|
|
25
|
+
function getCoreLocale() {
|
|
26
|
+
return _coreConfig.locale;
|
|
27
|
+
}
|
|
28
|
+
function getCoreTimezone() {
|
|
29
|
+
return _coreConfig.timezone;
|
|
30
|
+
}
|
|
31
|
+
function getCoreApiBaseUrl() {
|
|
32
|
+
return _coreConfig.apiBaseUrl;
|
|
33
|
+
}
|
|
34
|
+
const CoreConfigContext = React.createContext(_coreConfig);
|
|
35
|
+
const CoreConfigProvider = ({ locale, timezone, apiBaseUrl, children }) => {
|
|
36
|
+
configureCore({
|
|
37
|
+
locale,
|
|
38
|
+
timezone,
|
|
39
|
+
apiBaseUrl
|
|
40
|
+
});
|
|
41
|
+
const value = React.useMemo(() => ({
|
|
42
|
+
locale,
|
|
43
|
+
timezone,
|
|
44
|
+
apiBaseUrl
|
|
45
|
+
}), [
|
|
46
|
+
locale,
|
|
47
|
+
timezone,
|
|
48
|
+
apiBaseUrl
|
|
49
|
+
]);
|
|
50
|
+
return /* @__PURE__ */ jsx(CoreConfigContext.Provider, {
|
|
51
|
+
value,
|
|
52
|
+
children
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
function useCoreConfig() {
|
|
56
|
+
return React.useContext(CoreConfigContext);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region packages/core/date/DateUtils.ts
|
|
60
|
+
/**
|
|
61
|
+
* Recommended default for new projects using @nubitio/core.
|
|
62
|
+
* You should almost always override this via CoreConfigProvider.
|
|
63
|
+
*/
|
|
64
|
+
const DEFAULT_TIMEZONE = "UTC";
|
|
65
|
+
function coreDateParts(date) {
|
|
66
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
67
|
+
timeZone: getCoreTimezone(),
|
|
68
|
+
year: "numeric",
|
|
69
|
+
month: "2-digit",
|
|
70
|
+
day: "2-digit"
|
|
71
|
+
}).formatToParts(date);
|
|
72
|
+
const get = (type) => parseInt(parts.find((part) => part.type === type).value, 10);
|
|
73
|
+
return {
|
|
74
|
+
year: get("year"),
|
|
75
|
+
month: get("month"),
|
|
76
|
+
day: get("day")
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
var DateUtils = class {
|
|
80
|
+
static dateFormatter(date) {
|
|
81
|
+
const { year, month, day } = coreDateParts(date);
|
|
82
|
+
return `${year}-${month < 10 ? `0${month}` : month}-${day < 10 ? `0${day}` : day}`;
|
|
83
|
+
}
|
|
84
|
+
static dateParser(dateString) {
|
|
85
|
+
if (!dateString) return /* @__PURE__ */ new Date();
|
|
86
|
+
const dateParts = dateString.split("-");
|
|
87
|
+
const year = parseInt(dateParts[0], 10);
|
|
88
|
+
const month = parseInt(dateParts[1], 10);
|
|
89
|
+
const day = parseInt(dateParts[2], 10);
|
|
90
|
+
if (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) return new Date(year, month - 1, day);
|
|
91
|
+
return /* @__PURE__ */ new Date();
|
|
92
|
+
}
|
|
93
|
+
static format(dateString, formatOptions) {
|
|
94
|
+
return new Date(dateString).toLocaleDateString(getCoreLocale(), {
|
|
95
|
+
timeZone: getCoreTimezone(),
|
|
96
|
+
...formatOptions ?? {
|
|
97
|
+
day: "numeric",
|
|
98
|
+
month: "short",
|
|
99
|
+
year: "numeric"
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
static addDays(fieldValue, days) {
|
|
104
|
+
const date = new Date(fieldValue);
|
|
105
|
+
date.setDate(date.getDate() + days);
|
|
106
|
+
return date;
|
|
107
|
+
}
|
|
108
|
+
static addMonths(fieldValue, months) {
|
|
109
|
+
const date = new Date(fieldValue);
|
|
110
|
+
date.setMonth(date.getMonth() + months);
|
|
111
|
+
return date;
|
|
112
|
+
}
|
|
113
|
+
static addYears(fieldValue, years) {
|
|
114
|
+
const date = new Date(fieldValue);
|
|
115
|
+
date.setFullYear(date.getFullYear() + years);
|
|
116
|
+
return date;
|
|
117
|
+
}
|
|
118
|
+
static dayDiff(startDate, endDate) {
|
|
119
|
+
const dateStart = new Date(startDate);
|
|
120
|
+
const dateEnd = new Date(endDate);
|
|
121
|
+
const timeDiff = Math.abs(dateEnd.getTime() - dateStart.getTime());
|
|
122
|
+
return Math.ceil(timeDiff / (1e3 * 3600 * 24));
|
|
123
|
+
}
|
|
124
|
+
static monthDiff(startDate, endDate) {
|
|
125
|
+
const dateStart = new Date(startDate);
|
|
126
|
+
const dateEnd = new Date(endDate);
|
|
127
|
+
let months = (dateEnd.getFullYear() - dateStart.getFullYear()) * 12;
|
|
128
|
+
months -= dateStart.getMonth();
|
|
129
|
+
months += dateEnd.getMonth();
|
|
130
|
+
return months <= 0 ? 0 : months;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region packages/core/event/EventHook.ts
|
|
135
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
136
|
+
function subscribe(name, handler) {
|
|
137
|
+
if (!listeners.has(name)) listeners.set(name, /* @__PURE__ */ new Set());
|
|
138
|
+
const set = listeners.get(name);
|
|
139
|
+
set.add(handler);
|
|
140
|
+
const subscription = {
|
|
141
|
+
unsubscribe: () => {
|
|
142
|
+
set.delete(handler);
|
|
143
|
+
},
|
|
144
|
+
add: (teardown) => {
|
|
145
|
+
const outer = subscription.unsubscribe;
|
|
146
|
+
subscription.unsubscribe = () => {
|
|
147
|
+
outer();
|
|
148
|
+
teardown();
|
|
149
|
+
};
|
|
150
|
+
return subscription;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
return subscription;
|
|
154
|
+
}
|
|
155
|
+
const useEvents = () => {
|
|
156
|
+
const subsRef = useRef([]);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
return () => {
|
|
159
|
+
subsRef.current.forEach((sub) => sub.unsubscribe());
|
|
160
|
+
subsRef.current = [];
|
|
161
|
+
};
|
|
162
|
+
}, []);
|
|
163
|
+
const on = (name, handler) => {
|
|
164
|
+
const sub = subscribe(name, handler);
|
|
165
|
+
subsRef.current.push(sub);
|
|
166
|
+
return sub;
|
|
167
|
+
};
|
|
168
|
+
const emit = (name, payload) => {
|
|
169
|
+
dispatch(name, payload);
|
|
170
|
+
};
|
|
171
|
+
return [on, emit];
|
|
172
|
+
};
|
|
173
|
+
const dispatch = (name, payload) => {
|
|
174
|
+
listeners.get(name)?.forEach((handler) => {
|
|
175
|
+
try {
|
|
176
|
+
handler(payload);
|
|
177
|
+
} catch {}
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region packages/core/event/createCrudEvents.ts
|
|
182
|
+
/**
|
|
183
|
+
* Generic factory that builds a typed set of CRUD event name strings for a
|
|
184
|
+
* given `prefix`. The returned object is `const`-asserted so every value is
|
|
185
|
+
* a template-literal type — callers get full type inference without having to
|
|
186
|
+
* cast anything.
|
|
187
|
+
*
|
|
188
|
+
* This helper belongs in `core/` because it has no feature-specific logic; it
|
|
189
|
+
* is used by both the core event bus and by every feature module that needs
|
|
190
|
+
* its own event namespace.
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* const productEvents = createCrudEvents('product');
|
|
194
|
+
* // productEvents.ADD === 'product:add'
|
|
195
|
+
*/
|
|
196
|
+
const createCrudEvents = (prefix) => ({
|
|
197
|
+
ADD: `${prefix}:add`,
|
|
198
|
+
EDIT: `${prefix}:edit`,
|
|
199
|
+
DELETE: `${prefix}:delete`,
|
|
200
|
+
SAVE: `${prefix}:save`,
|
|
201
|
+
CANCEL: `${prefix}:cancel`,
|
|
202
|
+
SUCCESS: `${prefix}:success`,
|
|
203
|
+
LOADING: `${prefix}:loading`
|
|
204
|
+
});
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region packages/core/event/createScopedEventBus.ts
|
|
207
|
+
/**
|
|
208
|
+
* Creates a scoped event bus for the given resource.
|
|
209
|
+
*
|
|
210
|
+
* Internally this generates an 8-character random scope token and delegates to
|
|
211
|
+
* `createCrudEvents(`${resourceId}:${scopeId}`)`. This means each call produces
|
|
212
|
+
* a unique set of event name strings, even for the same `resourceId`, which
|
|
213
|
+
* prevents accidental cross-instance event coupling when multiple CRUD pages
|
|
214
|
+
* for the same resource are mounted simultaneously.
|
|
215
|
+
*
|
|
216
|
+
* @param resourceId - The resource id (e.g. `'product'`).
|
|
217
|
+
* @returns A `ScopedFormEventNames` object with a stable `_scopeId` tag.
|
|
218
|
+
*/
|
|
219
|
+
function createScopedEventBus(resourceId) {
|
|
220
|
+
const scopeId = Math.random().toString(36).slice(2, 10);
|
|
221
|
+
return {
|
|
222
|
+
...createCrudEvents(`${resourceId}:${scopeId}`),
|
|
223
|
+
_scopeId: scopeId
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region packages/core/i18n/coreTranslations.ts
|
|
228
|
+
const coreTranslationsEs = {
|
|
229
|
+
"crudPage.selectedCount": "{{count}} seleccionados",
|
|
230
|
+
"crudPage.auditTrailButton": "Historial",
|
|
231
|
+
"crudPage.allColumns": "Todas",
|
|
232
|
+
"crudPage.columnsLabel": "Columnas:",
|
|
233
|
+
"crudPage.dialogTitleAdd": "Nuevo",
|
|
234
|
+
"crudPage.dialogTitleEdit": "Editar",
|
|
235
|
+
"crudPage.dialogTitleView": "Ver",
|
|
236
|
+
"crudPage.schemaErrorTitle": "No se pudo cargar esta vista",
|
|
237
|
+
"crudPage.schemaErrorMessage": "La configuración del recurso no está disponible. Intenta nuevamente o contacta a soporte si el problema continúa.",
|
|
238
|
+
"crudPage.schemaErrorDetails": "Detalle técnico",
|
|
239
|
+
"crudPage.schemaErrorRetry": "Reintentar",
|
|
240
|
+
"auditTrail.title": "Historial de cambios",
|
|
241
|
+
"auditTrail.closeButton": "Cerrar historial de cambios",
|
|
242
|
+
"auditTrail.selectRecord": "Seleccione un registro...",
|
|
243
|
+
"auditTrail.loading": "Cargando...",
|
|
244
|
+
"auditTrail.error": "Error al cargar el historial.",
|
|
245
|
+
"auditTrail.empty": "No hay entradas de historial.",
|
|
246
|
+
"auditTrail.action.create": "Creado",
|
|
247
|
+
"auditTrail.action.update": "Actualizado",
|
|
248
|
+
"auditTrail.action.delete": "Eliminado",
|
|
249
|
+
"form.detailRequired": "Agrega al menos un ítem para continuar",
|
|
250
|
+
"form.detailEmpty": "No hay ítems agregados",
|
|
251
|
+
"form.detailEmptyHint": "Usa el botón + para agregar el primero",
|
|
252
|
+
"form.detailAdd": "Agregar detalle",
|
|
253
|
+
"form.detailRemove": "Eliminar detalle",
|
|
254
|
+
"form.validationError": "Uno o más campos del formulario no son válidos",
|
|
255
|
+
"form.loading": "Cargando...",
|
|
256
|
+
"form.groupOther": "Otros",
|
|
257
|
+
"grid.noRecords": "No hay registros para mostrar",
|
|
258
|
+
"grid.selectRequired": "Debe seleccionar un registro",
|
|
259
|
+
"grid.loading": "Cargando...",
|
|
260
|
+
"grid.buttonNew": "Nuevo",
|
|
261
|
+
"grid.buttonEdit": "Editar",
|
|
262
|
+
"grid.buttonDelete": "Eliminar",
|
|
263
|
+
"grid.buttonRefresh": "Actualizar",
|
|
264
|
+
"grid.buttonActions": "Acciones",
|
|
265
|
+
"grid.buttonView": "Ver",
|
|
266
|
+
"grid.rowsPerPage": "Filas por página",
|
|
267
|
+
"grid.recordRange": "{{start}}-{{end}} de {{total}} registros",
|
|
268
|
+
"grid.noRecordCount": "0 registros",
|
|
269
|
+
"grid.selectedCount": "{{count}} seleccionados",
|
|
270
|
+
"grid.pageStatus": "Página {{page}} de {{total}}",
|
|
271
|
+
"grid.firstPage": "Primera página",
|
|
272
|
+
"grid.previousPage": "Página anterior",
|
|
273
|
+
"grid.nextPage": "Página siguiente",
|
|
274
|
+
"grid.lastPage": "Última página",
|
|
275
|
+
"grid.back": "Volver",
|
|
276
|
+
"grid.expandDetail": "Expandir detalle",
|
|
277
|
+
"grid.collapseDetail": "Contraer detalle",
|
|
278
|
+
"grid.selectRow": "Seleccionar fila",
|
|
279
|
+
"grid.resizeColumn": "Arrastrar para redimensionar",
|
|
280
|
+
"grid.allFilter": "Todos",
|
|
281
|
+
"grid.clearFilter": "Limpiar filtro",
|
|
282
|
+
"grid.filterColumn": "Filtrar {{column}}",
|
|
283
|
+
"grid.filterFrom": "Filtrar {{column}} desde",
|
|
284
|
+
"grid.filterTo": "Filtrar {{column}} hasta",
|
|
285
|
+
"grid.sortColumn": "Ordenar por {{column}}",
|
|
286
|
+
"grid.filters": "Filtros",
|
|
287
|
+
"grid.sortBy": "Ordenar por",
|
|
288
|
+
"grid.sortNone": "Sin ordenar",
|
|
289
|
+
"grid.sortAscending": "Ascendente",
|
|
290
|
+
"grid.sortDescending": "Descendente",
|
|
291
|
+
"grid.clearFilters": "Limpiar filtros",
|
|
292
|
+
"grid.done": "Listo",
|
|
293
|
+
"grid.searchPlaceholder": "Buscar por {{column}}",
|
|
294
|
+
"grid.showMore": "Ver más",
|
|
295
|
+
"grid.showLess": "Ver menos",
|
|
296
|
+
"grid.filterOperator.contains": "Contiene",
|
|
297
|
+
"grid.filterOperator.notcontains": "No contiene",
|
|
298
|
+
"grid.filterOperator.startswith": "Empieza con",
|
|
299
|
+
"grid.filterOperator.equals": "Igual",
|
|
300
|
+
"grid.filterOperator.notEquals": "No es igual",
|
|
301
|
+
"grid.filterOperator.greaterThan": "Mayor que",
|
|
302
|
+
"grid.filterOperator.greaterOrEqual": "Mayor o igual",
|
|
303
|
+
"grid.filterOperator.lessThan": "Menor que",
|
|
304
|
+
"grid.filterOperator.lessOrEqual": "Menor o igual",
|
|
305
|
+
"grid.filterOperator.between": "Entre",
|
|
306
|
+
"grid.filterOperator.reset": "Restablecer",
|
|
307
|
+
"dialog.buttonCancel": "Cancelar",
|
|
308
|
+
"dialog.buttonSave": "Guardar",
|
|
309
|
+
"dialog.close": "Cerrar",
|
|
310
|
+
"dialog.confirmDelete": "¿Está seguro de eliminar el registro?",
|
|
311
|
+
"dialog.confirmDeleteTitle": "Confirmar eliminación",
|
|
312
|
+
"common.yes": "Sí",
|
|
313
|
+
"common.no": "No",
|
|
314
|
+
"validation.defaultError": "Valor inválido",
|
|
315
|
+
"form.searchButton": "Buscar",
|
|
316
|
+
"form.actionButton": "Acción",
|
|
317
|
+
"form.detailTitle": "Detalle",
|
|
318
|
+
"form.lookupSearching": "Buscando...",
|
|
319
|
+
"form.lookupNoResults": "Sin resultados",
|
|
320
|
+
"form.lookupLoadingMore": "Cargando más...",
|
|
321
|
+
"form.fieldRequired": "{{label}} es requerido",
|
|
322
|
+
"form.invalidEmail": "Correo inválido",
|
|
323
|
+
"form.invalidNumeric": "Valor numérico inválido",
|
|
324
|
+
"form.invalidPattern": "Formato inválido",
|
|
325
|
+
"form.stringTooShort": "Texto demasiado corto",
|
|
326
|
+
"form.stringTooLong": "Texto demasiado largo",
|
|
327
|
+
"form.outOfRange": "Valor fuera de rango",
|
|
328
|
+
"form.fileUploadPrompt": "Arrastra un archivo o haz clic para seleccionar",
|
|
329
|
+
"form.fileUploadDrop": "Suelta el archivo aquí",
|
|
330
|
+
"form.fileUploadHint": "Se sube automáticamente al seleccionar.",
|
|
331
|
+
"form.fileUploading": "Subiendo archivo…",
|
|
332
|
+
"form.fileUploadReplace": "Cambiar",
|
|
333
|
+
"form.fileUploadRemove": "Quitar",
|
|
334
|
+
"form.fileUploadOpen": "Ver archivo",
|
|
335
|
+
"form.fileUploadFailed": "No se pudo subir el archivo",
|
|
336
|
+
"form.fileUploadInvalidResponse": "La respuesta del servidor no incluye el identificador del archivo",
|
|
337
|
+
"form.imageUploadPrompt": "Arrastra una imagen o haz clic para seleccionar",
|
|
338
|
+
"form.imageUploadHint": "PNG, JPG, WEBP o GIF. Se sube automáticamente al seleccionar.",
|
|
339
|
+
"crudPage.confirmActionTitle": "Confirmar acción"
|
|
340
|
+
};
|
|
341
|
+
const coreTranslationsEn = {
|
|
342
|
+
"crudPage.selectedCount": "{{count}} selected",
|
|
343
|
+
"crudPage.auditTrailButton": "History",
|
|
344
|
+
"crudPage.allColumns": "All",
|
|
345
|
+
"crudPage.columnsLabel": "Columns:",
|
|
346
|
+
"crudPage.dialogTitleAdd": "New",
|
|
347
|
+
"crudPage.dialogTitleEdit": "Edit",
|
|
348
|
+
"crudPage.dialogTitleView": "View",
|
|
349
|
+
"crudPage.schemaErrorTitle": "This view could not be loaded",
|
|
350
|
+
"crudPage.schemaErrorMessage": "The resource configuration is not available. Try again or contact support if the problem continues.",
|
|
351
|
+
"crudPage.schemaErrorDetails": "Technical details",
|
|
352
|
+
"crudPage.schemaErrorRetry": "Retry",
|
|
353
|
+
"auditTrail.title": "Change history",
|
|
354
|
+
"auditTrail.closeButton": "Close change history",
|
|
355
|
+
"auditTrail.selectRecord": "Select a record...",
|
|
356
|
+
"auditTrail.loading": "Loading...",
|
|
357
|
+
"auditTrail.error": "Error loading history.",
|
|
358
|
+
"auditTrail.empty": "No history entries.",
|
|
359
|
+
"auditTrail.action.create": "Created",
|
|
360
|
+
"auditTrail.action.update": "Updated",
|
|
361
|
+
"auditTrail.action.delete": "Deleted",
|
|
362
|
+
"form.detailRequired": "Add at least one item to continue",
|
|
363
|
+
"form.detailEmpty": "No items added",
|
|
364
|
+
"form.detailEmptyHint": "Use the + button to add the first one",
|
|
365
|
+
"form.detailAdd": "Add item",
|
|
366
|
+
"form.detailRemove": "Remove item",
|
|
367
|
+
"form.validationError": "One or more form fields are invalid",
|
|
368
|
+
"form.loading": "Loading...",
|
|
369
|
+
"form.groupOther": "Other",
|
|
370
|
+
"grid.noRecords": "No records to display",
|
|
371
|
+
"grid.selectRequired": "You must select a record",
|
|
372
|
+
"grid.loading": "Loading...",
|
|
373
|
+
"grid.buttonNew": "New",
|
|
374
|
+
"grid.buttonEdit": "Edit",
|
|
375
|
+
"grid.buttonDelete": "Delete",
|
|
376
|
+
"grid.buttonRefresh": "Refresh",
|
|
377
|
+
"grid.buttonActions": "Actions",
|
|
378
|
+
"grid.buttonView": "View",
|
|
379
|
+
"grid.rowsPerPage": "Rows per page",
|
|
380
|
+
"grid.recordRange": "{{start}}-{{end}} of {{total}} records",
|
|
381
|
+
"grid.noRecordCount": "0 records",
|
|
382
|
+
"grid.selectedCount": "{{count}} selected",
|
|
383
|
+
"grid.pageStatus": "Page {{page}} of {{total}}",
|
|
384
|
+
"grid.firstPage": "First page",
|
|
385
|
+
"grid.previousPage": "Previous page",
|
|
386
|
+
"grid.nextPage": "Next page",
|
|
387
|
+
"grid.lastPage": "Last page",
|
|
388
|
+
"grid.back": "Back",
|
|
389
|
+
"grid.expandDetail": "Expand detail",
|
|
390
|
+
"grid.collapseDetail": "Collapse detail",
|
|
391
|
+
"grid.selectRow": "Select row",
|
|
392
|
+
"grid.resizeColumn": "Drag to resize",
|
|
393
|
+
"grid.allFilter": "All",
|
|
394
|
+
"grid.clearFilter": "Clear filter",
|
|
395
|
+
"grid.filterColumn": "Filter {{column}}",
|
|
396
|
+
"grid.filterFrom": "Filter {{column}} from",
|
|
397
|
+
"grid.filterTo": "Filter {{column}} to",
|
|
398
|
+
"grid.sortColumn": "Sort by {{column}}",
|
|
399
|
+
"grid.filters": "Filters",
|
|
400
|
+
"grid.sortBy": "Sort by",
|
|
401
|
+
"grid.sortNone": "Unsorted",
|
|
402
|
+
"grid.sortAscending": "Ascending",
|
|
403
|
+
"grid.sortDescending": "Descending",
|
|
404
|
+
"grid.clearFilters": "Clear filters",
|
|
405
|
+
"grid.done": "Done",
|
|
406
|
+
"grid.searchPlaceholder": "Search by {{column}}",
|
|
407
|
+
"grid.showMore": "Show more",
|
|
408
|
+
"grid.showLess": "Show less",
|
|
409
|
+
"grid.filterOperator.contains": "Contains",
|
|
410
|
+
"grid.filterOperator.notcontains": "Does not contain",
|
|
411
|
+
"grid.filterOperator.startswith": "Starts with",
|
|
412
|
+
"grid.filterOperator.equals": "Equals",
|
|
413
|
+
"grid.filterOperator.notEquals": "Does not equal",
|
|
414
|
+
"grid.filterOperator.greaterThan": "Greater than",
|
|
415
|
+
"grid.filterOperator.greaterOrEqual": "Greater or equal",
|
|
416
|
+
"grid.filterOperator.lessThan": "Less than",
|
|
417
|
+
"grid.filterOperator.lessOrEqual": "Less or equal",
|
|
418
|
+
"grid.filterOperator.between": "Between",
|
|
419
|
+
"grid.filterOperator.reset": "Reset",
|
|
420
|
+
"dialog.buttonCancel": "Cancel",
|
|
421
|
+
"dialog.buttonSave": "Save",
|
|
422
|
+
"dialog.close": "Close",
|
|
423
|
+
"dialog.confirmDelete": "Are you sure you want to delete this record?",
|
|
424
|
+
"dialog.confirmDeleteTitle": "Confirm deletion",
|
|
425
|
+
"common.yes": "Yes",
|
|
426
|
+
"common.no": "No",
|
|
427
|
+
"validation.defaultError": "Invalid value",
|
|
428
|
+
"form.searchButton": "Search",
|
|
429
|
+
"form.actionButton": "Action",
|
|
430
|
+
"form.detailTitle": "Detail",
|
|
431
|
+
"form.lookupSearching": "Searching...",
|
|
432
|
+
"form.lookupNoResults": "No results",
|
|
433
|
+
"form.lookupLoadingMore": "Loading more...",
|
|
434
|
+
"form.fieldRequired": "{{label}} is required",
|
|
435
|
+
"form.invalidEmail": "Invalid email",
|
|
436
|
+
"form.invalidNumeric": "Invalid numeric value",
|
|
437
|
+
"form.invalidPattern": "Invalid format",
|
|
438
|
+
"form.stringTooShort": "Text too short",
|
|
439
|
+
"form.stringTooLong": "Text too long",
|
|
440
|
+
"form.outOfRange": "Value out of range",
|
|
441
|
+
"form.fileUploadPrompt": "Drag a file here or click to browse",
|
|
442
|
+
"form.fileUploadDrop": "Drop the file here",
|
|
443
|
+
"form.fileUploadHint": "Uploads automatically on selection.",
|
|
444
|
+
"form.fileUploading": "Uploading file…",
|
|
445
|
+
"form.fileUploadReplace": "Replace",
|
|
446
|
+
"form.fileUploadRemove": "Remove",
|
|
447
|
+
"form.fileUploadOpen": "Open file",
|
|
448
|
+
"form.fileUploadFailed": "Could not upload the file",
|
|
449
|
+
"form.fileUploadInvalidResponse": "Server response did not include the file identifier",
|
|
450
|
+
"form.imageUploadPrompt": "Drag an image here or click to browse",
|
|
451
|
+
"form.imageUploadHint": "PNG, JPG, WEBP or GIF. Uploads automatically on selection.",
|
|
452
|
+
"crudPage.confirmActionTitle": "Confirm action"
|
|
453
|
+
};
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region packages/core/i18n/useCoreTranslation.ts
|
|
456
|
+
function useCoreTranslation() {
|
|
457
|
+
const { t } = useTranslation("core");
|
|
458
|
+
return { t: (key, options) => t(key, options) };
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region packages/core/i18n/initCoreI18n.ts
|
|
462
|
+
function initCoreI18n() {
|
|
463
|
+
i18next.addResourceBundle("es", "core", coreTranslationsEs, true, false);
|
|
464
|
+
i18next.addResourceBundle("en", "core", coreTranslationsEn, true, false);
|
|
465
|
+
}
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region packages/core/http/CoreHttpClient.ts
|
|
468
|
+
function joinUrl(baseUrl, url) {
|
|
469
|
+
if (/^https?:\/\//.test(url) || url.startsWith("/")) return url;
|
|
470
|
+
return `${baseUrl.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`;
|
|
471
|
+
}
|
|
472
|
+
function serializeParams(params) {
|
|
473
|
+
const parts = [];
|
|
474
|
+
function append(prefix, value) {
|
|
475
|
+
if (value === null || value === void 0) return;
|
|
476
|
+
if (Array.isArray(value)) {
|
|
477
|
+
value.forEach((item, index) => append(`${prefix}[${index}]`, item));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (typeof value === "object") {
|
|
481
|
+
Object.entries(value).forEach(([key, nestedValue]) => {
|
|
482
|
+
append(`${prefix}[${key}]`, nestedValue);
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
parts.push(`${encodeURIComponent(prefix)}=${encodeURIComponent(String(value))}`);
|
|
487
|
+
}
|
|
488
|
+
Object.entries(params).forEach(([key, value]) => append(key, value));
|
|
489
|
+
return parts.join("&");
|
|
490
|
+
}
|
|
491
|
+
function withParams(url, params) {
|
|
492
|
+
if (!params || Object.keys(params).length === 0) return url;
|
|
493
|
+
const query = serializeParams(params);
|
|
494
|
+
if (!query) return url;
|
|
495
|
+
return `${url}${url.includes("?") ? "&" : "?"}${query}`;
|
|
496
|
+
}
|
|
497
|
+
function createHttpError(message, status, data) {
|
|
498
|
+
return Object.assign(new Error(message), {
|
|
499
|
+
status,
|
|
500
|
+
data
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async function readResponseBody(response, responseType) {
|
|
504
|
+
if (response.status === 204) return;
|
|
505
|
+
if (responseType === "arraybuffer") return await response.arrayBuffer();
|
|
506
|
+
if (responseType === "blob") return await response.blob();
|
|
507
|
+
if (responseType === "text") return await response.text();
|
|
508
|
+
return await response.json();
|
|
509
|
+
}
|
|
510
|
+
var CoreHttpClient = class {
|
|
511
|
+
config;
|
|
512
|
+
refreshPromise = null;
|
|
513
|
+
constructor(config = {}) {
|
|
514
|
+
this.config = config;
|
|
515
|
+
}
|
|
516
|
+
headers(extraHeaders) {
|
|
517
|
+
const browserLocale = typeof navigator !== "undefined" ? navigator.language?.split("-")[0] : void 0;
|
|
518
|
+
return {
|
|
519
|
+
"Accept-Language": this.config.locale ?? browserLocale ?? "es",
|
|
520
|
+
...extraHeaders
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Built-in cookie-based refresh (the original behavior).
|
|
525
|
+
* Only used when `refreshFn` is not provided in config.
|
|
526
|
+
*/
|
|
527
|
+
async performBuiltInRefresh() {
|
|
528
|
+
const refreshPath = this.config.refreshPath ?? "auth/refresh";
|
|
529
|
+
const refreshUrl = joinUrl(this.config.baseUrl ?? "/api/", refreshPath);
|
|
530
|
+
this.refreshPromise ??= globalThis.fetch(refreshUrl, {
|
|
531
|
+
method: "POST",
|
|
532
|
+
credentials: this.config.credentials ?? "include"
|
|
533
|
+
}).then(async (response) => {
|
|
534
|
+
if (!response.ok) throw createHttpError("Session refresh failed", response.status, await this.safeErrorData(response));
|
|
535
|
+
}).finally(() => {
|
|
536
|
+
this.refreshPromise = null;
|
|
537
|
+
});
|
|
538
|
+
return this.refreshPromise;
|
|
539
|
+
}
|
|
540
|
+
async performRefresh() {
|
|
541
|
+
if (this.config.refreshFn) return this.config.refreshFn(this);
|
|
542
|
+
return this.performBuiltInRefresh();
|
|
543
|
+
}
|
|
544
|
+
async safeErrorData(response) {
|
|
545
|
+
try {
|
|
546
|
+
return await response.json();
|
|
547
|
+
} catch {
|
|
548
|
+
return {};
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async request(method, url, body, config, retryOnUnauthorized = true) {
|
|
552
|
+
const requestUrl = withParams(joinUrl(this.config.baseUrl ?? "/api/", url), config?.params);
|
|
553
|
+
const headers = this.headers(config?.headers);
|
|
554
|
+
if (body !== void 0 && body !== null && !(body instanceof FormData)) headers["Content-Type"] = headers["Content-Type"] ?? "application/json";
|
|
555
|
+
const response = await globalThis.fetch(requestUrl, {
|
|
556
|
+
method,
|
|
557
|
+
headers,
|
|
558
|
+
credentials: this.config.credentials ?? "include",
|
|
559
|
+
signal: config?.signal,
|
|
560
|
+
body: body === void 0 || body === null ? void 0 : body instanceof FormData ? body : JSON.stringify(body)
|
|
561
|
+
});
|
|
562
|
+
if (response.ok) return {
|
|
563
|
+
response,
|
|
564
|
+
data: await readResponseBody(response, config?.responseType),
|
|
565
|
+
headers: response.headers,
|
|
566
|
+
status: response.status
|
|
567
|
+
};
|
|
568
|
+
const errorData = await this.safeErrorData(response);
|
|
569
|
+
const error = createHttpError(errorData.detail ?? errorData.message ?? "HTTP request failed", response.status, errorData);
|
|
570
|
+
const loginPath = this.config.loginPath ?? "auth/login";
|
|
571
|
+
const refreshPath = this.config.refreshPath ?? "auth/refresh";
|
|
572
|
+
const isAuthEndpoint = url.includes(loginPath) || url.includes(refreshPath);
|
|
573
|
+
const shouldAutoRefresh = this.config.autoRefresh !== false;
|
|
574
|
+
if (response.status === 401 && retryOnUnauthorized && !isAuthEndpoint && shouldAutoRefresh) try {
|
|
575
|
+
await this.performRefresh();
|
|
576
|
+
return this.request(method, url, body, config, false);
|
|
577
|
+
} catch (refreshError) {
|
|
578
|
+
const unauthorizedError = refreshError instanceof Error ? Object.assign(refreshError, { status: 401 }) : error;
|
|
579
|
+
this.config.onUnauthorized?.(unauthorizedError);
|
|
580
|
+
throw unauthorizedError;
|
|
581
|
+
}
|
|
582
|
+
if (response.status === 401) this.config.onUnauthorized?.(error);
|
|
583
|
+
else this.config.onError?.(error);
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
get(url, config) {
|
|
587
|
+
return this.request("GET", url, void 0, config);
|
|
588
|
+
}
|
|
589
|
+
post(url, data, config) {
|
|
590
|
+
return this.request("POST", url, data, config);
|
|
591
|
+
}
|
|
592
|
+
put(url, data, config) {
|
|
593
|
+
return this.request("PUT", url, data, config);
|
|
594
|
+
}
|
|
595
|
+
patch(url, data, config) {
|
|
596
|
+
return this.request("PATCH", url, data, {
|
|
597
|
+
...config,
|
|
598
|
+
headers: {
|
|
599
|
+
...config?.headers,
|
|
600
|
+
"Content-Type": "application/merge-patch+json"
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
delete(url, config) {
|
|
605
|
+
return this.request("DELETE", url, void 0, config);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
function createCoreHttpClient(config) {
|
|
609
|
+
return new CoreHttpClient(config);
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region packages/core/http/CoreHttpContext.tsx
|
|
613
|
+
const CoreHttpContext = createContext(createCoreHttpClient());
|
|
614
|
+
function CoreHttpProvider({ children, client, config }) {
|
|
615
|
+
const value = useMemo(() => {
|
|
616
|
+
if (client) return client;
|
|
617
|
+
return createCoreHttpClient({
|
|
618
|
+
...config,
|
|
619
|
+
baseUrl: config?.baseUrl ?? getCoreApiBaseUrl()
|
|
620
|
+
});
|
|
621
|
+
}, [client, config]);
|
|
622
|
+
return /* @__PURE__ */ jsx(CoreHttpContext.Provider, {
|
|
623
|
+
value,
|
|
624
|
+
children
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
function useCoreHttpClient() {
|
|
628
|
+
return useContext(CoreHttpContext);
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
631
|
+
//#region packages/core/runtime/CoreRuntimeContext.tsx
|
|
632
|
+
const defaultRuntime = {
|
|
633
|
+
notify: () => void 0,
|
|
634
|
+
confirm: (message) => {
|
|
635
|
+
if (typeof window === "undefined") return false;
|
|
636
|
+
return window.confirm(message);
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
const CoreRuntimeContext = createContext(defaultRuntime);
|
|
640
|
+
function CoreRuntimeProvider({ children, runtime }) {
|
|
641
|
+
const value = useMemo(() => ({
|
|
642
|
+
...defaultRuntime,
|
|
643
|
+
...runtime
|
|
644
|
+
}), [runtime]);
|
|
645
|
+
return /* @__PURE__ */ jsx(CoreRuntimeContext.Provider, {
|
|
646
|
+
value,
|
|
647
|
+
children
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
function useCoreRuntime() {
|
|
651
|
+
return useContext(CoreRuntimeContext);
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region packages/core/provider/CoreProvider.tsx
|
|
655
|
+
function CoreProvider({ children, http, httpClient, runtime }) {
|
|
656
|
+
return /* @__PURE__ */ jsx(CoreHttpProvider, {
|
|
657
|
+
client: httpClient,
|
|
658
|
+
config: http,
|
|
659
|
+
children: /* @__PURE__ */ jsx(CoreRuntimeProvider, {
|
|
660
|
+
runtime,
|
|
661
|
+
children
|
|
662
|
+
})
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region packages/core/mercure/MercureManager.ts
|
|
667
|
+
var MercureManager = class {
|
|
668
|
+
hubUrl = null;
|
|
669
|
+
topics = /* @__PURE__ */ new Map();
|
|
670
|
+
hubUrlListeners = /* @__PURE__ */ new Set();
|
|
671
|
+
/**
|
|
672
|
+
* Update the hub URL (called by the Axios interceptor when the `Link` header is discovered,
|
|
673
|
+
* or by MercureProvider). Notifies all registered listeners.
|
|
674
|
+
*
|
|
675
|
+
* Idempotent: if the URL is already set to the same value, listeners are NOT re-notified.
|
|
676
|
+
*/
|
|
677
|
+
setHubUrl(url) {
|
|
678
|
+
if (this.hubUrl === url) return;
|
|
679
|
+
this.disconnectAll();
|
|
680
|
+
this.hubUrl = url;
|
|
681
|
+
this.hubUrlListeners.forEach((cb) => cb(url));
|
|
682
|
+
}
|
|
683
|
+
getHubUrl() {
|
|
684
|
+
return this.hubUrl;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Register a callback that fires whenever the hub URL changes.
|
|
688
|
+
*
|
|
689
|
+
* Returns an unsubscribe function — call it in a `useEffect` cleanup to avoid memory leaks.
|
|
690
|
+
*
|
|
691
|
+
* @example
|
|
692
|
+
* ```tsx
|
|
693
|
+
* useEffect(() => {
|
|
694
|
+
* return MercureManager.onHubUrlChange(setHubUrl);
|
|
695
|
+
* }, []);
|
|
696
|
+
* ```
|
|
697
|
+
*/
|
|
698
|
+
onHubUrlChange(callback) {
|
|
699
|
+
this.hubUrlListeners.add(callback);
|
|
700
|
+
return () => {
|
|
701
|
+
this.hubUrlListeners.delete(callback);
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Subscribe to a Mercure topic.
|
|
706
|
+
*
|
|
707
|
+
* @param topic Full topic URI or URI Template, e.g. `https://host/api/products/{id}`.
|
|
708
|
+
* @param callback Called with the parsed JSON payload on each SSE message.
|
|
709
|
+
*
|
|
710
|
+
* If `hubUrl` is null (hub not configured), this is a no-op (graceful degradation).
|
|
711
|
+
* If an EventSource for this topic already exists, it is reused (ref-counting).
|
|
712
|
+
*/
|
|
713
|
+
subscribe(topic, callback) {
|
|
714
|
+
if (this.hubUrl === null) return;
|
|
715
|
+
const existing = this.topics.get(topic);
|
|
716
|
+
if (existing) {
|
|
717
|
+
const listener = (e) => {
|
|
718
|
+
try {
|
|
719
|
+
callback(JSON.parse(e.data));
|
|
720
|
+
} catch {
|
|
721
|
+
callback(e.data);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
existing.eventSource.addEventListener("message", listener);
|
|
725
|
+
existing.listeners.set(callback, listener);
|
|
726
|
+
existing.count += 1;
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const url = new URL(this.hubUrl);
|
|
730
|
+
url.searchParams.append("topic", topic);
|
|
731
|
+
const eventSource = new EventSource(url.toString(), { withCredentials: true });
|
|
732
|
+
const listener = (e) => {
|
|
733
|
+
try {
|
|
734
|
+
callback(JSON.parse(e.data));
|
|
735
|
+
} catch {
|
|
736
|
+
callback(e.data);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
eventSource.addEventListener("message", listener);
|
|
740
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
741
|
+
listeners.set(callback, listener);
|
|
742
|
+
this.topics.set(topic, {
|
|
743
|
+
eventSource,
|
|
744
|
+
count: 1,
|
|
745
|
+
listeners
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Unsubscribe a specific callback from a topic.
|
|
750
|
+
*
|
|
751
|
+
* Decrements the ref count. When count reaches 0, the EventSource is closed
|
|
752
|
+
* and removed from the internal map.
|
|
753
|
+
*/
|
|
754
|
+
unsubscribe(topic, callback) {
|
|
755
|
+
const entry = this.topics.get(topic);
|
|
756
|
+
if (!entry) return;
|
|
757
|
+
const listener = entry.listeners.get(callback);
|
|
758
|
+
if (listener) {
|
|
759
|
+
entry.eventSource.removeEventListener("message", listener);
|
|
760
|
+
entry.listeners.delete(callback);
|
|
761
|
+
}
|
|
762
|
+
entry.count -= 1;
|
|
763
|
+
if (entry.count <= 0) {
|
|
764
|
+
entry.eventSource.close();
|
|
765
|
+
this.topics.delete(topic);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Close all active EventSource connections and clear the topic map.
|
|
770
|
+
*
|
|
771
|
+
* Called internally when the hub URL changes so stale connections
|
|
772
|
+
* bound to the previous hub are not leaked.
|
|
773
|
+
*/
|
|
774
|
+
disconnectAll() {
|
|
775
|
+
this.topics.forEach((entry) => {
|
|
776
|
+
entry.eventSource.close();
|
|
777
|
+
});
|
|
778
|
+
this.topics.clear();
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
/** Singleton instance — shared across the entire application. */
|
|
782
|
+
const mercureManager = new MercureManager();
|
|
783
|
+
//#endregion
|
|
784
|
+
//#region packages/core/mercure/MercureProvider.tsx
|
|
785
|
+
/**
|
|
786
|
+
* MercureProvider — React context provider for the Mercure hub URL.
|
|
787
|
+
*
|
|
788
|
+
* Wraps the application (or a subtree) and makes the hub URL available to all
|
|
789
|
+
* descendant hooks via `useMercureHub()`.
|
|
790
|
+
*
|
|
791
|
+
* Also keeps `MercureManager` in sync with the hub URL whenever it changes.
|
|
792
|
+
*
|
|
793
|
+
* ## Usage
|
|
794
|
+
*
|
|
795
|
+
* ```tsx
|
|
796
|
+
* // In your app root:
|
|
797
|
+
* <MercureProvider hubUrl={discoveredHubUrl}>
|
|
798
|
+
* <App />
|
|
799
|
+
* </MercureProvider>
|
|
800
|
+
* ```
|
|
801
|
+
*
|
|
802
|
+
* The `hubUrl` is typically discovered from the `Link` header of API responses
|
|
803
|
+
* (common pattern with API Platform + Mercure).
|
|
804
|
+
*/
|
|
805
|
+
/** The hub URL, or null if Mercure is not configured / not yet discovered. */
|
|
806
|
+
const MercureContext = createContext(null);
|
|
807
|
+
/**
|
|
808
|
+
* Provides the Mercure hub URL to the React tree and keeps `MercureManager`
|
|
809
|
+
* in sync whenever the URL changes.
|
|
810
|
+
*/
|
|
811
|
+
function MercureProvider({ hubUrl, children }) {
|
|
812
|
+
useEffect(() => {
|
|
813
|
+
mercureManager.setHubUrl(hubUrl);
|
|
814
|
+
}, [hubUrl]);
|
|
815
|
+
return /* @__PURE__ */ jsx(MercureContext.Provider, {
|
|
816
|
+
value: hubUrl,
|
|
817
|
+
children
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
//#endregion
|
|
821
|
+
//#region packages/core/mercure/useMercureHub.ts
|
|
822
|
+
/**
|
|
823
|
+
* useMercureHub — Hook that returns the Mercure hub URL from context.
|
|
824
|
+
*
|
|
825
|
+
* Returns `null` if:
|
|
826
|
+
* - The component is rendered outside a `<MercureProvider>` (default context value).
|
|
827
|
+
* - The hub URL has not been discovered yet (header `Link` not received).
|
|
828
|
+
* - Mercure is not configured in the backend.
|
|
829
|
+
*
|
|
830
|
+
* This hook does NOT perform any fetch or side effect — it only reads from context.
|
|
831
|
+
* Hub discovery (usually from a `Link` header) must be done by the host application.
|
|
832
|
+
*
|
|
833
|
+
* ## Usage
|
|
834
|
+
*
|
|
835
|
+
* ```tsx
|
|
836
|
+
* const hubUrl = useMercureHub();
|
|
837
|
+
* // hubUrl is string | null
|
|
838
|
+
* ```
|
|
839
|
+
*/
|
|
840
|
+
/**
|
|
841
|
+
* Returns the Mercure hub URL from the nearest `<MercureProvider>`,
|
|
842
|
+
* or `null` if the hub is not available.
|
|
843
|
+
*/
|
|
844
|
+
function useMercureHub() {
|
|
845
|
+
return useContext(MercureContext);
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region packages/core/mercure/useMercureSubscription.ts
|
|
849
|
+
/**
|
|
850
|
+
* useMercureSubscription — Hook that subscribes to a Mercure topic and calls
|
|
851
|
+
* a callback whenever an SSE message is received for that topic.
|
|
852
|
+
*
|
|
853
|
+
* ## Behaviour
|
|
854
|
+
* - If `hubUrl` is null (Mercure not configured) or `enabled` is false → no-op.
|
|
855
|
+
* - Subscribes to the wildcard topic `<origin>/<apiUrl>/{id}` (URI Template RFC 6570),
|
|
856
|
+
* which captures any item-level event (create / update / delete) for the collection.
|
|
857
|
+
* - Cleanup: unsubscribes on unmount or when dependencies change (no memory leaks).
|
|
858
|
+
*
|
|
859
|
+
* ## Usage
|
|
860
|
+
*
|
|
861
|
+
* ```tsx
|
|
862
|
+
* useMercureSubscription(
|
|
863
|
+
* resource.apiUrl,
|
|
864
|
+
* () => { gridRef.current?.instance().refresh(); },
|
|
865
|
+
* resource.mercure !== false,
|
|
866
|
+
* );
|
|
867
|
+
* ```
|
|
868
|
+
*
|
|
869
|
+
* @param apiUrl The resource API URL (e.g. `'api/products'` or `'/api/products'`).
|
|
870
|
+
* Used to build the wildcard topic URI.
|
|
871
|
+
* @param onUpdate Callback invoked on every SSE message for the topic.
|
|
872
|
+
* @param enabled When false, the subscription is skipped entirely. Defaults to true.
|
|
873
|
+
*/
|
|
874
|
+
function useMercureSubscription(apiUrl, onUpdate, enabled = true) {
|
|
875
|
+
const hubUrl = useMercureHub();
|
|
876
|
+
useEffect(() => {
|
|
877
|
+
if (!hubUrl || !enabled || !apiUrl) return;
|
|
878
|
+
const normalizedPath = apiUrl.replace(/^\//, "");
|
|
879
|
+
const topic = `${window.location.origin}/${normalizedPath}/{id}`;
|
|
880
|
+
const handler = (_data) => {
|
|
881
|
+
onUpdate();
|
|
882
|
+
};
|
|
883
|
+
mercureManager.subscribe(topic, handler);
|
|
884
|
+
return () => {
|
|
885
|
+
mercureManager.unsubscribe(topic, handler);
|
|
886
|
+
};
|
|
887
|
+
}, [
|
|
888
|
+
hubUrl,
|
|
889
|
+
enabled,
|
|
890
|
+
apiUrl
|
|
891
|
+
]);
|
|
892
|
+
}
|
|
893
|
+
//#endregion
|
|
894
|
+
export { CoreConfigProvider, CoreHttpClient, CoreHttpProvider, CoreProvider, CoreRuntimeProvider, DEFAULT_TIMEZONE, DateUtils, mercureManager as MercureManager, MercureProvider, configureCore, configureCoreDate, coreTranslationsEn, coreTranslationsEs, createCoreHttpClient, createCrudEvents, createScopedEventBus, dispatch, getCoreApiBaseUrl, getCoreLocale, getCoreTimezone, initCoreI18n, useCoreConfig, useCoreHttpClient, useCoreRuntime, useCoreTranslation, useEvents, useMercureHub, useMercureSubscription };
|