@nubitio/hydra 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/dist/index.cjs ADDED
@@ -0,0 +1,862 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
23
+ //#endregion
24
+ let _nubitio_core = require("@nubitio/core");
25
+ let react = require("react");
26
+ react = __toESM(react, 1);
27
+ let _nubitio_crud = require("@nubitio/crud");
28
+ let react_jsx_runtime = require("react/jsx-runtime");
29
+ let _tanstack_react_query = require("@tanstack/react-query");
30
+ //#region packages/hydra/HydraRemoteDataSource.ts
31
+ const defaultHttpClient = (0, _nubitio_core.createCoreHttpClient)({ baseUrl: "" });
32
+ function findPreloadedItem(loadOptions, idField, id) {
33
+ const prependData = Array.isArray(loadOptions.prependData) ? loadOptions.prependData : [];
34
+ const appendData = Array.isArray(loadOptions.appendData) ? loadOptions.appendData : [];
35
+ return [...prependData, ...appendData].find((item) => item[idField] === id || item["@id"] === id) ?? null;
36
+ }
37
+ function addIriField(url, item) {
38
+ const itemId = item["id"] ?? item["code"] ?? item["uuid"] ?? item["slug"];
39
+ const iri = typeof item["@id"] === "string" ? item["@id"] : typeof itemId === "string" || typeof itemId === "number" ? `${url}/${itemId}` : void 0;
40
+ return {
41
+ ...item,
42
+ _iri: iri
43
+ };
44
+ }
45
+ function normalizeFilterRules(filterRules, defaultFilterRules) {
46
+ const filters = [];
47
+ defaultFilterRules.forEach((rule) => filters.push(rule));
48
+ if (filterRules.length === 3 && typeof filterRules[0] !== "object") filters.push([
49
+ filterRules[0],
50
+ filterRules[1],
51
+ filterRules[2]
52
+ ]);
53
+ else filterRules.forEach((rule) => {
54
+ filters.push(Array.isArray(rule) ? rule : [rule]);
55
+ });
56
+ return filters;
57
+ }
58
+ function applyLoadOptionDefaults(loadOptions, options) {
59
+ const nextOptions = { ...loadOptions };
60
+ options.forEach((option) => {
61
+ for (const key in option) if (Object.prototype.hasOwnProperty.call(option, key)) nextOptions[key] = option[key];
62
+ });
63
+ return nextOptions;
64
+ }
65
+ function normalizeSortRules(sortRules, defaultSortRules, idField) {
66
+ const [firstSort] = sortRules;
67
+ const firstSelector = typeof firstSort === "string" ? firstSort : firstSort?.selector;
68
+ if (sortRules.length === 1 && firstSelector === idField && defaultSortRules.length === 0) return [];
69
+ if (sortRules.length === 1 && firstSelector === idField) return [...defaultSortRules];
70
+ return sortRules;
71
+ }
72
+ function stripAdapterOnlyParams(loadOptions) {
73
+ const nextOptions = { ...loadOptions };
74
+ delete nextOptions.searchOperation;
75
+ if (nextOptions.searchExpr === void 0) delete nextOptions.searchValue;
76
+ return nextOptions;
77
+ }
78
+ function convertPaginationParams(loadOptions) {
79
+ const nextOptions = { ...loadOptions };
80
+ const take = typeof nextOptions.take === "number" ? nextOptions.take : void 0;
81
+ const skip = typeof nextOptions.skip === "number" ? nextOptions.skip : void 0;
82
+ if (take !== void 0) {
83
+ nextOptions.itemsPerPage = take;
84
+ delete nextOptions.take;
85
+ }
86
+ if (take !== void 0 && skip !== void 0) {
87
+ nextOptions.page = Math.floor(skip / take) + 1;
88
+ delete nextOptions.skip;
89
+ } else if (skip !== void 0) delete nextOptions.skip;
90
+ return nextOptions;
91
+ }
92
+ function getFlatIriFilter(filter) {
93
+ if (!Array.isArray(filter)) return null;
94
+ return filter.length === 1 && Array.isArray(filter[0]) ? filter[0] : filter;
95
+ }
96
+ function parseCollectionResponse(responseData, responseHeaders) {
97
+ if (Array.isArray(responseData)) {
98
+ const headerCount = Number(responseHeaders.get("x-total-count"));
99
+ return {
100
+ data: responseData,
101
+ totalCount: Number.isFinite(headerCount) ? headerCount : responseData.length
102
+ };
103
+ }
104
+ if (responseData && typeof responseData === "object") {
105
+ const body = responseData;
106
+ const member = body["hydra:member"] ?? body["member"];
107
+ if (Array.isArray(member)) {
108
+ const rawTotal = body["hydra:totalItems"] ?? body["totalItems"] ?? member.length;
109
+ const totalCount = Number(rawTotal);
110
+ return {
111
+ data: member,
112
+ totalCount: Number.isFinite(totalCount) ? totalCount : member.length
113
+ };
114
+ }
115
+ }
116
+ return {
117
+ data: [],
118
+ totalCount: 0
119
+ };
120
+ }
121
+ var HydraRemoteDataSource = class {
122
+ config;
123
+ httpClient;
124
+ constructor(config) {
125
+ this.config = config;
126
+ this.httpClient = config.httpClient ?? defaultHttpClient;
127
+ }
128
+ makeFilterRules(filterRules) {
129
+ return filterRules.map((filter) => `filter[]=["${filter.field}","${filter.operator}","${filter.value}"]`).join("&");
130
+ }
131
+ prepareLoadOptions(loadOptions) {
132
+ const withDefaults = applyLoadOptionDefaults(loadOptions, this.config.options ?? []);
133
+ const normalizedFilters = normalizeFilterRules(withDefaults.filter ?? [], this.config.defaultFilterRules ?? []);
134
+ return convertPaginationParams(stripAdapterOnlyParams({
135
+ ...withDefaults,
136
+ filter: normalizedFilters,
137
+ sort: normalizeSortRules(withDefaults.sort ?? [], this.config.defaultSortRules ?? [], this.config.idField)
138
+ }));
139
+ }
140
+ async load(loadOptions) {
141
+ const preparedOptions = this.prepareLoadOptions(loadOptions);
142
+ if (this.config.iriMode) {
143
+ const flatFilter = getFlatIriFilter(preparedOptions.filter);
144
+ if (Array.isArray(flatFilter) && flatFilter.length === 3 && flatFilter[0] === "_iri" && flatFilter[1] === "=" && typeof flatFilter[2] === "string") {
145
+ const item = await this.byKey(flatFilter[2]);
146
+ return {
147
+ data: item ? [addIriField(this.config.url, item)] : [],
148
+ totalCount: item ? 1 : 0,
149
+ summary: null
150
+ };
151
+ }
152
+ const result = await this.fetchAll(preparedOptions);
153
+ return {
154
+ ...result,
155
+ data: result.data.map((item) => addIriField(this.config.url, item))
156
+ };
157
+ }
158
+ return this.fetchAll(preparedOptions);
159
+ }
160
+ async byKey(key) {
161
+ const id = typeof key === "object" && key !== null ? key[this.config.idField] : key;
162
+ if (id === void 0) return null;
163
+ const loadOptions = applyLoadOptionDefaults({}, this.config.options ?? []);
164
+ const item = await this.fetchById(this.config.byKeyUrl ?? this.config.url, id, loadOptions);
165
+ return this.config.iriMode && item ? addIriField(this.config.url, item) : item;
166
+ }
167
+ async fetchAll(loadOptions) {
168
+ const response = await this.httpClient.get(this.config.url, { params: loadOptions });
169
+ const parsed = parseCollectionResponse(response.data, response.headers);
170
+ let data = parsed.data;
171
+ if (loadOptions.appendData) data = [...data, ...loadOptions.appendData];
172
+ if (loadOptions.prependData) data = [...loadOptions.prependData, ...data];
173
+ const totalCount = parsed.totalCount === parsed.data.length ? data.length : parsed.totalCount;
174
+ return {
175
+ data,
176
+ totalCount,
177
+ summary: null
178
+ };
179
+ }
180
+ async fetchById(url, id, loadOptions) {
181
+ const preloadedItem = findPreloadedItem(loadOptions, this.config.idField, id);
182
+ if (preloadedItem) return preloadedItem;
183
+ const resolvedUrl = typeof id === "string" && id.startsWith("/") ? id : `${url}/${id}`;
184
+ return this.httpClient.get(resolvedUrl).then((response) => response.data).catch(() => findPreloadedItem(loadOptions, this.config.idField, id));
185
+ }
186
+ };
187
+ const createHydraResourceStore = (options) => new HydraRemoteDataSource(options);
188
+ //#endregion
189
+ //#region packages/hydra/HydraResourceStoreProvider.tsx
190
+ function HydraResourceStoreProvider({ children }) {
191
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_crud.ResourceStoreProvider, {
192
+ factory: createHydraResourceStore,
193
+ children
194
+ });
195
+ }
196
+ //#endregion
197
+ //#region packages/hydra/openApiParser.ts
198
+ /**
199
+ * Converts a camelCase or PascalCase name to dash-case.
200
+ * Mirrors API Platform's DashPathSegmentNameGenerator behaviour.
201
+ * Examples:
202
+ * "Category" → "category"
203
+ * "SunatCatalog" → "sunat-catalog"
204
+ * "CashMovementCategory" → "cash-movement-category"
205
+ * "cashMovementCategory" → "cash-movement-category"
206
+ */
207
+ function toDashCase(name) {
208
+ return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "");
209
+ }
210
+ /**
211
+ * Pluralizes an English word (snake_case or lowercase) with common rules.
212
+ *
213
+ * - Words ending in a consonant + 'y' → replace 'y' with 'ies'
214
+ * category → categories, company → companies
215
+ * - Words ending in s/x/z/ch/sh → append 'es'
216
+ * branch → branches, box → boxes
217
+ * - Everything else → append 's'
218
+ * warehouse → warehouses, product → products
219
+ */
220
+ function pluralize(word) {
221
+ if (word.endsWith("y") && ![
222
+ "ay",
223
+ "ey",
224
+ "iy",
225
+ "oy",
226
+ "uy"
227
+ ].some((v) => word.endsWith(v))) return word.slice(0, -1) + "ies";
228
+ if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) return word + "es";
229
+ return word + "s";
230
+ }
231
+ /** Entrypoint property names that are already in plural form (dash-case). */
232
+ const ALREADY_PLURAL = new Set([
233
+ "purchases",
234
+ "sales",
235
+ "invoices",
236
+ "transfers",
237
+ "movements",
238
+ "shifts",
239
+ "outbounds",
240
+ "cash-movements",
241
+ "cash-registers",
242
+ "cash-shifts"
243
+ ]);
244
+ /**
245
+ * Derives the API URL from an Entrypoint property ID.
246
+ * "#Entrypoint/branch" -> "/api/branches"
247
+ * "#Entrypoint/cashMovementCategory" -> "/api/cash-movement-categories"
248
+ */
249
+ function deriveApiUrl(entrypointPropertyId) {
250
+ const dashed = toDashCase(entrypointPropertyId.split("/").pop() ?? "");
251
+ return `/api/${ALREADY_PLURAL.has(dashed) ? dashed : pluralize(dashed)}`;
252
+ }
253
+ /**
254
+ * Safely extracts a string range from whatever `property.range` actually is.
255
+ *
256
+ * Real API Platform responses can return:
257
+ * - a plain string: "xmls:string", "#Company"
258
+ * - an IRI object: { "@id": "http://www.w3.org/2001/XMLSchema#string" }
259
+ * - null or undefined
260
+ * - a Hydra collection range array where the resource class lives under
261
+ * `owl:equivalentClass.owl:allValuesFrom.@id`
262
+ */
263
+ function normalizeRange(raw) {
264
+ if (typeof raw === "string") return raw;
265
+ if (Array.isArray(raw)) for (const item of raw) {
266
+ if (!item || typeof item !== "object") continue;
267
+ const equivalentClass = item["owl:equivalentClass"];
268
+ if (!equivalentClass || typeof equivalentClass !== "object") continue;
269
+ const allValuesFrom = equivalentClass["owl:allValuesFrom"];
270
+ if (!allValuesFrom || typeof allValuesFrom !== "object") continue;
271
+ const id = allValuesFrom["@id"];
272
+ if (typeof id === "string") return id;
273
+ }
274
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
275
+ const id = raw["@id"];
276
+ if (typeof id === "string") return id;
277
+ }
278
+ }
279
+ /**
280
+ * Convert an OpenAPI property descriptor to a short-form xmls: range string.
281
+ */
282
+ function mapOpenApiTypeToRange(prop) {
283
+ if (prop.format === "date-time") return "xmls:dateTime";
284
+ switch (prop.type) {
285
+ case "integer": return "xmls:integer";
286
+ case "number": return "xmls:decimal";
287
+ case "boolean": return "xmls:boolean";
288
+ case "string": return "xmls:string";
289
+ default: return;
290
+ }
291
+ }
292
+ /**
293
+ * Extracts the technical property name from a HydraSupportedProperty.
294
+ *
295
+ * The technical name (used in API JSON payloads) is found in sp.property['@id'],
296
+ * which looks like "#Branch/businessName" or "#Category/name".
297
+ * We extract the part after the last '/'.
298
+ *
299
+ * Fallback chain: @id suffix → property.label → title → ''
300
+ */
301
+ function technicalName(sp) {
302
+ const fromId = sp.property?.["@id"]?.split("/").pop();
303
+ if (fromId && fromId !== "") return fromId;
304
+ return sp.property?.label ?? sp.title ?? "";
305
+ }
306
+ /**
307
+ * Extract `hydra:search` mappings from a HydraClass.
308
+ * Returns an empty array if the class has no `hydra:search` block,
309
+ * or if any mapping entry is missing the required `hydra:property` field.
310
+ * Never throws.
311
+ */
312
+ function extractSearchMappings(cls) {
313
+ const searchBlock = cls["hydra:search"];
314
+ if (!searchBlock) return [];
315
+ const mappings = searchBlock["hydra:mapping"] ?? [];
316
+ const result = [];
317
+ for (const mapping of mappings) {
318
+ const property = mapping["hydra:property"];
319
+ const variable = mapping["hydra:variable"];
320
+ if (!property || !variable) continue;
321
+ result.push({
322
+ property,
323
+ variable,
324
+ required: mapping["hydra:required"] ?? false
325
+ });
326
+ }
327
+ return result;
328
+ }
329
+ /**
330
+ * Extract the HTTP method names from `supportedOperation` on a HydraClass.
331
+ * Returns an uppercase string array, e.g. ['GET', 'POST', 'PATCH', 'DELETE'].
332
+ * Returns an empty array when the class has no `supportedOperation` block.
333
+ *
334
+ * Wire format (confirmed from /api/docs.jsonld): compact key `supportedOperation`
335
+ * (no `hydra:` prefix). API Platform compact context strips the namespace prefix.
336
+ *
337
+ * Accepts both `hydra:method` and `method` field names inside each operation entry
338
+ * to be safe across different API Platform serialisation variants.
339
+ */
340
+ function extractSupportedOperations(cls) {
341
+ const operations = cls["supportedOperation"];
342
+ if (!operations || operations.length === 0) return [];
343
+ return operations.map((op) => (op["hydra:method"] ?? op["method"] ?? "").toUpperCase()).filter(Boolean);
344
+ }
345
+ function extractEntrypointCollectionOperations(doc) {
346
+ const entrypoint = doc["supportedClass"].find((c) => c["@id"] === "#Entrypoint");
347
+ if (!entrypoint) return {};
348
+ const collectionOperations = {};
349
+ for (const sp of entrypoint.supportedProperty ?? []) {
350
+ const rawRange = sp.property?.range;
351
+ const range = normalizeRange(rawRange);
352
+ if (!range) continue;
353
+ const className = range.replace("#", "");
354
+ collectionOperations[className] = (sp.property?.supportedOperation ?? []).map((op) => (op["hydra:method"] ?? op["method"] ?? "").toUpperCase()).filter(Boolean);
355
+ }
356
+ return collectionOperations;
357
+ }
358
+ /**
359
+ * Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
360
+ * Record<className, HydraResourceSchema>.
361
+ *
362
+ * @throws Error if doc is not a valid Hydra ApiDocumentation.
363
+ */
364
+ function parseHydraDoc(doc) {
365
+ const result = {};
366
+ const collectionOperationsMap = extractEntrypointCollectionOperations(doc);
367
+ const urlMap = {};
368
+ const entrypoint = doc["supportedClass"].find((c) => c["@id"] === "#Entrypoint");
369
+ if (entrypoint) for (const sp of entrypoint.supportedProperty ?? []) {
370
+ const rawRange = sp.property?.range;
371
+ const range = normalizeRange(rawRange);
372
+ const propId = sp.property?.["@id"];
373
+ if (range && propId) {
374
+ const className = range.replace("#", "");
375
+ urlMap[className] = deriveApiUrl(propId);
376
+ }
377
+ }
378
+ for (const cls of doc["supportedClass"]) {
379
+ if (cls["@id"] === "#Entrypoint") continue;
380
+ const className = cls["@id"].replace("#", "");
381
+ const fields = [];
382
+ for (const sp of cls.supportedProperty ?? []) {
383
+ const readable = sp.readable !== false;
384
+ const writeable = sp.writeable !== false;
385
+ if (!readable) continue;
386
+ fields.push({
387
+ name: technicalName(sp),
388
+ range: normalizeRange(sp.property?.range),
389
+ propertyType: sp.property?.["@type"] ?? "rdf:Property",
390
+ required: sp.required ?? false,
391
+ readable,
392
+ writeable,
393
+ "hydra:title": sp["title"] ?? sp["hydra:title"] ?? void 0,
394
+ crudHints: sp["x-crud"]
395
+ });
396
+ }
397
+ result[className] = {
398
+ className,
399
+ apiUrl: urlMap[className] ?? `/api/${pluralize(toDashCase(className))}`,
400
+ fields,
401
+ searchMappings: extractSearchMappings(cls),
402
+ supportedOperations: Array.from(new Set([...collectionOperationsMap[className] ?? [], ...extractSupportedOperations(cls)]))
403
+ };
404
+ }
405
+ return result;
406
+ }
407
+ /**
408
+ * Parse a raw `/api/docs.json` OpenAPI 3.1 response into the same
409
+ * Record<className, HydraResourceSchema> output shape so that
410
+ * `useResourceSchema` can consume it transparently.
411
+ */
412
+ function parseOpenApiDoc(doc) {
413
+ const result = {};
414
+ for (const [className, schema] of Object.entries(doc.components?.schemas ?? {})) {
415
+ const fields = [];
416
+ const required = new Set(schema.required ?? []);
417
+ for (const [fieldName, prop] of Object.entries(schema.properties ?? {})) {
418
+ const readable = !prop.writeOnly;
419
+ const writeable = !prop.readOnly;
420
+ if (!readable) continue;
421
+ fields.push({
422
+ name: fieldName,
423
+ range: mapOpenApiTypeToRange(prop),
424
+ propertyType: "rdf:Property",
425
+ required: required.has(fieldName),
426
+ readable,
427
+ writeable
428
+ });
429
+ }
430
+ result[className] = {
431
+ className,
432
+ apiUrl: `/api/${pluralize(toDashCase(className))}`,
433
+ fields
434
+ };
435
+ }
436
+ return result;
437
+ }
438
+ //#endregion
439
+ //#region packages/hydra/HydraToFieldMapper.ts
440
+ /**
441
+ * Returns true when the given `range` string declares an XSD integer type.
442
+ * Supports both short-form `xmls:` prefixes and full XSD IRIs.
443
+ *
444
+ * Used to decide whether the `id` field should be a `numberField()` (integer
445
+ * primary key) or a `textField()` (UUID / string primary key — safe default).
446
+ */
447
+ function isIntegerRange(range) {
448
+ if (!range) return false;
449
+ if (range.startsWith("http://www.w3.org/2001/XMLSchema#")) {
450
+ const local = range.split("#").pop() ?? "";
451
+ return local === "integer" || local === "int" || local === "long";
452
+ }
453
+ return range === "xmls:integer" || range === "xmls:int" || range === "xmls:long" || range === "xsd:integer" || range === "xsd:int" || range === "xsd:long";
454
+ }
455
+ /**
456
+ * Convert a camelCase or snake_case field name to a human-readable label.
457
+ * Examples:
458
+ * "firstName" → "First Name"
459
+ * "createdAt" → "Created At"
460
+ * "x_resource" → "X Resource"
461
+ */
462
+ function toLabel(name) {
463
+ return name.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
464
+ }
465
+ function resolveRangeTag(range, propertyType) {
466
+ if (!range) return "text";
467
+ if (range.startsWith("http://www.w3.org/2001/XMLSchema#")) {
468
+ const local = range.split("#").pop() ?? "";
469
+ if (local === "boolean") return "boolean";
470
+ if (local === "dateTime") return "dateTime";
471
+ if (local === "integer") return "integer";
472
+ if (local === "decimal" || local === "float" || local === "double") return "decimal";
473
+ if (local === "string") return "text";
474
+ return "text";
475
+ }
476
+ if (range === "xsd:boolean") return "boolean";
477
+ if (range === "xsd:dateTime" || range === "xsd:date") return "dateTime";
478
+ if (range === "xsd:integer" || range === "xsd:int" || range === "xsd:long") return "integer";
479
+ if (range === "xsd:decimal" || range === "xsd:float" || range === "xsd:double") return "decimal";
480
+ if (range === "xsd:string") return "text";
481
+ if (range === "xmls:boolean") return "boolean";
482
+ if (range === "xmls:dateTime") return "dateTime";
483
+ if (range === "xmls:integer") return "integer";
484
+ if (range === "xmls:decimal") return "decimal";
485
+ if (range === "xmls:string") return "text";
486
+ if (range.startsWith("#")) return "entity";
487
+ if (range.startsWith("http")) return "entity";
488
+ if (propertyType === "Link") return "entity";
489
+ return "text";
490
+ }
491
+ /**
492
+ * Apply `x-crud` backend hints on top of an already-built Field.
493
+ *
494
+ * Hints override inferred values; inference is the fallback when hints are absent.
495
+ * Only properties that are explicitly set (`!== undefined`) are applied.
496
+ *
497
+ * `hidden: true` sets `visible: false` so the column is hidden in the grid.
498
+ * `order` maps to the `Field.order` property used by native grid column ordering.
499
+ */
500
+ function applyCrudHints(field, hints) {
501
+ if (!hints) return;
502
+ if (hints.filterable !== void 0) field.filterable = hints.filterable;
503
+ if (hints.sortable !== void 0) field.sortable = hints.sortable;
504
+ if (hints.hidden !== void 0 && hints.hidden) field.visible = false;
505
+ if (hints.order !== void 0) field.order = hints.order;
506
+ if (hints.width !== void 0) field.width = hints.width;
507
+ }
508
+ function resolveEntityValueField(relatedSchema) {
509
+ if (!relatedSchema) return "_iri";
510
+ const fieldNames = new Set(relatedSchema.fields.map((field) => field.name));
511
+ if (fieldNames.has("id") || fieldNames.has("@id")) return "_iri";
512
+ return "_iri";
513
+ }
514
+ function isStringLikeField(field) {
515
+ return field.range === void 0 || field.range === "xmls:string" || field.range === "xsd:string";
516
+ }
517
+ function resolveEntityTextField(relatedSchema, valueField) {
518
+ if (!relatedSchema) return "name";
519
+ for (const fieldName of [
520
+ "name",
521
+ "businessName",
522
+ "description",
523
+ "fullNumber",
524
+ "title",
525
+ "series"
526
+ ]) {
527
+ const match = relatedSchema.fields.find((field) => field.name === fieldName && isStringLikeField(field));
528
+ if (match) return match.name;
529
+ }
530
+ const firstStringField = relatedSchema.fields.find((field) => field.name !== valueField && isStringLikeField(field));
531
+ if (firstStringField) return firstStringField.name;
532
+ return valueField;
533
+ }
534
+ /**
535
+ * Map a single HydraResourceSchema to an array of Field objects using the
536
+ * existing field builder utilities.
537
+ *
538
+ * Mapping rules (in priority order):
539
+ * 1. name === 'id' or name === '@id' → always emit as a hidden identity field
540
+ * (visible: false, readonly: true, isIdentity: true)
541
+ * Required so native grids and forms always have a stable row key.
542
+ * 2. readable: false → skip (already filtered upstream, but defensive)
543
+ * 3. writeable: false (display-only) → noneField
544
+ * 4. range resolves to 'boolean' → switchField
545
+ * 5. range resolves to 'dateTime' → datetimeField
546
+ * 6. range resolves to 'integer' → numberField with precision(0)
547
+ * 7. range resolves to 'decimal' → numberField
548
+ * 8. range resolves to 'entity' OR propertyType === 'Link' → entityField
549
+ * 9. range resolves to 'text' OR fallback → textField
550
+ *
551
+ * For each field, `filterable` is `true` when the field's property name appears in
552
+ * `schema.searchMappings` (from `hydra:search`). When `searchMappings` is empty
553
+ * (e.g. the resource uses a catch-all data-grid filter that doesn't
554
+ * enumerate field-level mappings), ALL fields default to `filterable: true`.
555
+ *
556
+ * After processing all schema fields, if no `id` field was found in the schema,
557
+ * a synthetic hidden identity field is injected so the grid always has a key.
558
+ *
559
+ * @param schema - The Hydra resource schema to map.
560
+ * @param urlLookup - Optional lookup function to resolve the API URL for a related
561
+ * entity class. When provided, Rule 8 uses this before falling back to the
562
+ * automatic pluralization heuristic. Example: `(cls) => resourceMap[cls]?.apiUrl`
563
+ *
564
+ * @pure — no React, no hooks, no side effects.
565
+ */
566
+ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
567
+ const fields = [];
568
+ const filterableProperties = new Set((schema.searchMappings ?? []).map((m) => m.property));
569
+ let hasIdField = false;
570
+ for (const fieldSchema of schema.fields) {
571
+ const { name, range, propertyType, required, readable, writeable } = fieldSchema;
572
+ if (name === "id" || name === "@id") {
573
+ hasIdField = true;
574
+ const idField = {
575
+ ...isIntegerRange(range) ? (0, _nubitio_crud.numberField)().name("id").label("Id").required(false).precision(0).build() : (0, _nubitio_crud.textField)().name("id").label("Id").required(false).build(),
576
+ isIdentity: true,
577
+ visible: false,
578
+ readonly: true,
579
+ filterable: false,
580
+ sortable: false,
581
+ hideable: false,
582
+ visibleOnForm: false
583
+ };
584
+ fields.push(idField);
585
+ continue;
586
+ }
587
+ if (!readable) continue;
588
+ const hydraTitle = fieldSchema["hydra:title"];
589
+ const label = hydraTitle && hydraTitle.trim() !== "" ? hydraTitle : toLabel(name);
590
+ const filterable = filterableProperties.size === 0 ? true : filterableProperties.has(name);
591
+ if (!writeable) {
592
+ const field = {
593
+ ...(0, _nubitio_crud.noneField)().name(name).label(label).required(required).build(),
594
+ filterable
595
+ };
596
+ applyCrudHints(field, fieldSchema.crudHints);
597
+ fields.push(field);
598
+ continue;
599
+ }
600
+ const tag = resolveRangeTag(range, propertyType);
601
+ if (tag === "boolean") {
602
+ const field = {
603
+ ...(0, _nubitio_crud.switchField)().name(name).label(label).required(required).build(),
604
+ filterable
605
+ };
606
+ applyCrudHints(field, fieldSchema.crudHints);
607
+ fields.push(field);
608
+ continue;
609
+ }
610
+ if (tag === "dateTime") {
611
+ const field = {
612
+ ...(0, _nubitio_crud.datetimeField)().name(name).label(label).required(required).build(),
613
+ filterable
614
+ };
615
+ applyCrudHints(field, fieldSchema.crudHints);
616
+ fields.push(field);
617
+ continue;
618
+ }
619
+ if (tag === "integer") {
620
+ const field = {
621
+ ...(0, _nubitio_crud.numberField)().name(name).label(label).required(required).precision(0).build(),
622
+ filterable
623
+ };
624
+ applyCrudHints(field, fieldSchema.crudHints);
625
+ fields.push(field);
626
+ continue;
627
+ }
628
+ if (tag === "decimal") {
629
+ const field = {
630
+ ...(0, _nubitio_crud.numberField)().name(name).label(label).required(required).build(),
631
+ filterable
632
+ };
633
+ applyCrudHints(field, fieldSchema.crudHints);
634
+ fields.push(field);
635
+ continue;
636
+ }
637
+ if (tag === "entity" || propertyType === "Link") {
638
+ const resourceClass = range ? range.replace("#", "") : "";
639
+ const relatedSchema = resourceClass ? schemaLookup?.(resourceClass) : void 0;
640
+ const url = resourceClass ? urlLookup?.(resourceClass) ?? `/api/${pluralize(toDashCase(resourceClass))}` : "";
641
+ const valueField = resolveEntityValueField(relatedSchema);
642
+ const field = {
643
+ ...(0, _nubitio_crud.entityField)(url, valueField, resolveEntityTextField(relatedSchema, valueField)).name(name).label(label).required(required).build(),
644
+ filterable
645
+ };
646
+ applyCrudHints(field, fieldSchema.crudHints);
647
+ fields.push(field);
648
+ continue;
649
+ }
650
+ const field = {
651
+ ...(0, _nubitio_crud.textField)().name(name).label(label).required(required).build(),
652
+ filterable
653
+ };
654
+ applyCrudHints(field, fieldSchema.crudHints);
655
+ fields.push(field);
656
+ }
657
+ if (!hasIdField) {
658
+ const syntheticId = {
659
+ ...(0, _nubitio_crud.textField)().name("id").label("Id").required(false).build(),
660
+ isIdentity: true,
661
+ visible: false,
662
+ readonly: true,
663
+ filterable: false,
664
+ sortable: false,
665
+ hideable: false,
666
+ visibleOnForm: false
667
+ };
668
+ fields.unshift(syntheticId);
669
+ }
670
+ return fields;
671
+ }
672
+ //#endregion
673
+ //#region packages/hydra/useHydraMetadata.ts
674
+ const JSONLD_URL = "/api/docs.jsonld";
675
+ const JSON_URL = "/api/docs.json";
676
+ const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
677
+ /**
678
+ * Try to fetch and validate a Hydra JSON-LD doc.
679
+ * Returns the typed HydraApiDoc on success, or throws on failure.
680
+ */
681
+ async function fetchHydraDoc(httpClient, url) {
682
+ const { data: json } = await httpClient.get(url);
683
+ const jsonType = json["@type"];
684
+ if (jsonType !== "ApiDocumentation" && jsonType !== "hydra:ApiDocumentation" || !Array.isArray(json["supportedClass"])) throw new Error("useHydraMetadata: response is not a valid Hydra ApiDocumentation");
685
+ return json;
686
+ }
687
+ /**
688
+ * Try to fetch and validate an OpenAPI 3.1 doc.
689
+ * Returns the typed OpenApiDoc on success, or throws on failure.
690
+ */
691
+ async function fetchOpenApiDoc(httpClient, url) {
692
+ const { data: json } = await httpClient.get(url);
693
+ if (typeof json.openapi !== "string" || !json.openapi.startsWith("3")) throw new Error("useHydraMetadata: response is not a valid OpenAPI 3.x doc");
694
+ return json;
695
+ }
696
+ /**
697
+ * Stable query key used by useHydraMetadata.
698
+ * Export this so that consumers (e.g. SmartCrudPage retry button) can
699
+ * invalidate exactly the right query without coupling to an internal string.
700
+ */
701
+ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
702
+ /**
703
+ * Waterfall discovery:
704
+ * 1. Try /api/docs.jsonld (prefer Hydra — richer type info)
705
+ * 2. Fall back to /api/docs.json (OpenAPI 3.1)
706
+ * 3. Throw an Error if both fail so React Query sets isError = true
707
+ */
708
+ async function fetchApiDoc(httpClient) {
709
+ try {
710
+ return {
711
+ format: "hydra",
712
+ doc: await fetchHydraDoc(httpClient, JSONLD_URL)
713
+ };
714
+ } catch {}
715
+ try {
716
+ return {
717
+ format: "openapi",
718
+ doc: await fetchOpenApiDoc(httpClient, JSON_URL)
719
+ };
720
+ } catch {}
721
+ throw new Error(`Failed to load API documentation from ${JSONLD_URL} and ${JSON_URL}. Make sure the API server is running.`);
722
+ }
723
+ function useHydraMetadata() {
724
+ const httpClient = (0, _nubitio_core.useCoreHttpClient)();
725
+ const { locale } = (0, _nubitio_core.useCoreConfig)();
726
+ const { data, error, isPending } = (0, _tanstack_react_query.useQuery)({
727
+ queryKey: [...API_DOC_QUERY_KEY, locale],
728
+ queryFn: () => fetchApiDoc(httpClient),
729
+ staleTime: isDev ? 0 : 5 * 6e4,
730
+ refetchOnWindowFocus: isDev,
731
+ refetchOnReconnect: isDev
732
+ });
733
+ return {
734
+ data,
735
+ isLoading: isPending,
736
+ error: error ?? void 0
737
+ };
738
+ }
739
+ //#endregion
740
+ //#region packages/hydra/SchemaContext.tsx
741
+ /**
742
+ * SchemaContext — shared parsed schema, no per-page re-fetch.
743
+ *
744
+ * Provides a single shared `useHydraMetadata()` call at the provider level
745
+ * so that multiple `SmartCrudPage` instances mounted simultaneously share
746
+ * one `/api/docs.jsonld` request instead of each issuing their own.
747
+ *
748
+ * ## Usage
749
+ *
750
+ * ```tsx
751
+ * // In your app root (optional — SmartCrudPage still works without it):
752
+ * <SchemaProvider>
753
+ * <App />
754
+ * </SchemaProvider>
755
+ * ```
756
+ *
757
+ * ## Fallback behaviour
758
+ * If `useSchemaContext()` is called outside a `<SchemaProvider>`, it falls
759
+ * back to calling `useHydraMetadata()` directly, keeping backward compat.
760
+ */
761
+ /** Sentinel value used to detect "outside a provider" */
762
+ const OUTSIDE_PROVIDER = Symbol("OUTSIDE_PROVIDER");
763
+ const SchemaContext = (0, react.createContext)(OUTSIDE_PROVIDER);
764
+ /**
765
+ * Wrap your app (or a subtree) with `SchemaProvider` to share a single
766
+ * `/api/docs.jsonld` fetch across all `SmartCrudPage` instances.
767
+ */
768
+ function SchemaProvider({ children }) {
769
+ const metadata = useHydraMetadata();
770
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SchemaContext.Provider, {
771
+ value: metadata,
772
+ children
773
+ });
774
+ }
775
+ /**
776
+ * Returns the shared schema context if inside a `<SchemaProvider>`,
777
+ * otherwise falls back to calling `useHydraMetadata()` directly.
778
+ *
779
+ * This makes `SmartCrudPage` work standalone without requiring a provider.
780
+ */
781
+ function useSchemaContext() {
782
+ const ctx = (0, react.useContext)(SchemaContext);
783
+ const fallback = useHydraMetadata();
784
+ if (ctx === OUTSIDE_PROVIDER) return {
785
+ data: fallback.data,
786
+ isLoading: fallback.isLoading,
787
+ isError: fallback.error !== void 0,
788
+ error: fallback.error
789
+ };
790
+ return {
791
+ data: ctx.data,
792
+ isLoading: ctx.isLoading,
793
+ isError: ctx.error !== void 0,
794
+ error: ctx.error
795
+ };
796
+ }
797
+ //#endregion
798
+ //#region packages/hydra/useResourceSchema.ts
799
+ /**
800
+ * Normalizes an API URL by stripping any leading slash and query string so that
801
+ * 'api/categories', '/api/categories', and '/api/categories?foo=bar' are treated as equal.
802
+ */
803
+ function normalizeUrl(url) {
804
+ const base = url.split("?")[0];
805
+ return base.startsWith("/") ? base.slice(1) : base;
806
+ }
807
+ function useResourceSchema(apiUrl) {
808
+ const { data, isLoading, error } = useSchemaContext();
809
+ return (0, react.useMemo)(() => {
810
+ if (!data) return {
811
+ fields: [],
812
+ isLoading,
813
+ error,
814
+ supportedOperations: []
815
+ };
816
+ const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
817
+ const normalizedInput = normalizeUrl(apiUrl);
818
+ const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
819
+ if (!resourceSchema) return {
820
+ fields: [],
821
+ isLoading: false,
822
+ error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc`),
823
+ supportedOperations: []
824
+ };
825
+ return {
826
+ fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
827
+ isLoading: false,
828
+ error: void 0,
829
+ supportedOperations: resourceSchema.supportedOperations ?? []
830
+ };
831
+ }, [
832
+ data,
833
+ isLoading,
834
+ error,
835
+ apiUrl
836
+ ]);
837
+ }
838
+ //#endregion
839
+ //#region packages/hydra/HydraResourceSchemaProvider.tsx
840
+ function useHydraResourceSchema({ apiUrl }) {
841
+ return useResourceSchema(apiUrl);
842
+ }
843
+ function HydraResourceSchemaProvider({ children }) {
844
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_crud.ResourceSchemaProvider, {
845
+ resolver: (0, react.useMemo)(() => ({ useResourceSchema: useHydraResourceSchema }), []),
846
+ children
847
+ });
848
+ }
849
+ //#endregion
850
+ exports.API_DOC_QUERY_KEY = API_DOC_QUERY_KEY;
851
+ exports.HydraRemoteDataSource = HydraRemoteDataSource;
852
+ exports.HydraResourceSchemaProvider = HydraResourceSchemaProvider;
853
+ exports.HydraResourceStoreProvider = HydraResourceStoreProvider;
854
+ exports.SchemaProvider = SchemaProvider;
855
+ exports.createHydraResourceStore = createHydraResourceStore;
856
+ exports.mapHydraSchemaToFields = mapHydraSchemaToFields;
857
+ exports.parseHydraDoc = parseHydraDoc;
858
+ exports.parseOpenApiDoc = parseOpenApiDoc;
859
+ exports.resolveRangeTag = resolveRangeTag;
860
+ exports.useHydraMetadata = useHydraMetadata;
861
+ exports.useResourceSchema = useResourceSchema;
862
+ exports.useSchemaContext = useSchemaContext;