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