@nubitio/hydra 0.2.0 → 0.3.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 +72 -15
- package/dist/index.d.cts +31 -2
- package/dist/index.d.mts +31 -2
- package/dist/index.mjs +73 -16
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -359,9 +359,17 @@ function extractEntrypointCollectionOperations(doc) {
|
|
|
359
359
|
* Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
|
|
360
360
|
* Record<className, HydraResourceSchema>.
|
|
361
361
|
*
|
|
362
|
+
* @param entrypointHrefs - Optional map of entrypoint property name → real
|
|
363
|
+
* collection href, read from the API entrypoint document (`GET /api`).
|
|
364
|
+
* When provided it is the **authoritative** source for resource URLs;
|
|
365
|
+
* the dash-case + pluralize heuristic only fills the gaps. The heuristic
|
|
366
|
+
* cannot know the backend's path segment generator (underscores vs dashes)
|
|
367
|
+
* nor handle irregular plurals (Series, Settings, …), so always prefer
|
|
368
|
+
* passing the real hrefs.
|
|
369
|
+
*
|
|
362
370
|
* @throws Error if doc is not a valid Hydra ApiDocumentation.
|
|
363
371
|
*/
|
|
364
|
-
function parseHydraDoc(doc) {
|
|
372
|
+
function parseHydraDoc(doc, entrypointHrefs) {
|
|
365
373
|
const result = {};
|
|
366
374
|
const collectionOperationsMap = extractEntrypointCollectionOperations(doc);
|
|
367
375
|
const urlMap = {};
|
|
@@ -372,7 +380,8 @@ function parseHydraDoc(doc) {
|
|
|
372
380
|
const propId = sp.property?.["@id"];
|
|
373
381
|
if (range && propId) {
|
|
374
382
|
const className = range.replace("#", "");
|
|
375
|
-
|
|
383
|
+
const propName = propId.split("/").pop() ?? "";
|
|
384
|
+
urlMap[className] = entrypointHrefs?.[propName] ?? deriveApiUrl(propId);
|
|
376
385
|
}
|
|
377
386
|
}
|
|
378
387
|
for (const cls of doc["supportedClass"]) {
|
|
@@ -391,7 +400,8 @@ function parseHydraDoc(doc) {
|
|
|
391
400
|
readable,
|
|
392
401
|
writeable,
|
|
393
402
|
"hydra:title": sp["title"] ?? sp["hydra:title"] ?? void 0,
|
|
394
|
-
crudHints: sp["x-crud"]
|
|
403
|
+
crudHints: sp["x-crud"],
|
|
404
|
+
enumOptions: Array.isArray(sp["enum"]) ? sp["enum"] : void 0
|
|
395
405
|
});
|
|
396
406
|
}
|
|
397
407
|
result[className] = {
|
|
@@ -424,7 +434,8 @@ function parseOpenApiDoc(doc) {
|
|
|
424
434
|
propertyType: "rdf:Property",
|
|
425
435
|
required: required.has(fieldName),
|
|
426
436
|
readable,
|
|
427
|
-
writeable
|
|
437
|
+
writeable,
|
|
438
|
+
enumOptions: Array.isArray(prop.enum) ? prop.enum : void 0
|
|
428
439
|
});
|
|
429
440
|
}
|
|
430
441
|
result[className] = {
|
|
@@ -495,6 +506,7 @@ function resolveRangeTag(range, propertyType) {
|
|
|
495
506
|
* Only properties that are explicitly set (`!== undefined`) are applied.
|
|
496
507
|
*
|
|
497
508
|
* `hidden: true` sets `visible: false` so the column is hidden in the grid.
|
|
509
|
+
* `visibleOnForm: false` excludes the field from the create/edit form only.
|
|
498
510
|
* `order` maps to the `Field.order` property used by native grid column ordering.
|
|
499
511
|
*/
|
|
500
512
|
function applyCrudHints(field, hints) {
|
|
@@ -502,6 +514,7 @@ function applyCrudHints(field, hints) {
|
|
|
502
514
|
if (hints.filterable !== void 0) field.filterable = hints.filterable;
|
|
503
515
|
if (hints.sortable !== void 0) field.sortable = hints.sortable;
|
|
504
516
|
if (hints.hidden !== void 0 && hints.hidden) field.visible = false;
|
|
517
|
+
if (hints.visibleOnForm !== void 0) field.visibleOnForm = hints.visibleOnForm;
|
|
505
518
|
if (hints.order !== void 0) field.order = hints.order;
|
|
506
519
|
if (hints.width !== void 0) field.width = hints.width;
|
|
507
520
|
}
|
|
@@ -586,11 +599,11 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
586
599
|
}
|
|
587
600
|
if (!readable) continue;
|
|
588
601
|
const hydraTitle = fieldSchema["hydra:title"];
|
|
589
|
-
const label = hydraTitle && hydraTitle.trim() !== "" ? hydraTitle : toLabel(name);
|
|
602
|
+
const label = hydraTitle && hydraTitle.trim() !== "" && hydraTitle !== name ? hydraTitle : toLabel(name);
|
|
590
603
|
const filterable = filterableProperties.size === 0 ? true : filterableProperties.has(name);
|
|
591
604
|
if (!writeable) {
|
|
592
605
|
const field = {
|
|
593
|
-
...(0, _nubitio_crud.noneField)().name(name).label(label).required(required).build(),
|
|
606
|
+
...(fieldSchema.crudHints?.format === "currency" ? (0, _nubitio_crud.currencyField)().readonly(true) : (0, _nubitio_crud.noneField)()).name(name).label(label).required(required).build(),
|
|
594
607
|
filterable
|
|
595
608
|
};
|
|
596
609
|
applyCrudHints(field, fieldSchema.crudHints);
|
|
@@ -598,6 +611,19 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
598
611
|
continue;
|
|
599
612
|
}
|
|
600
613
|
const tag = resolveRangeTag(range, propertyType);
|
|
614
|
+
const enumOptions = fieldSchema.enumOptions;
|
|
615
|
+
if (enumOptions && enumOptions.length > 0 && (tag === "text" || tag === "integer" || tag === "decimal")) {
|
|
616
|
+
const field = {
|
|
617
|
+
...(0, _nubitio_crud.enumField)(enumOptions.map((value) => ({
|
|
618
|
+
value,
|
|
619
|
+
text: toLabel(String(value)).replace(/\b[a-z]/g, (c) => c.toUpperCase())
|
|
620
|
+
}))).name(name).label(label).required(required).build(),
|
|
621
|
+
filterable
|
|
622
|
+
};
|
|
623
|
+
applyCrudHints(field, fieldSchema.crudHints);
|
|
624
|
+
fields.push(field);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
601
627
|
if (tag === "boolean") {
|
|
602
628
|
const field = {
|
|
603
629
|
...(0, _nubitio_crud.switchField)().name(name).label(label).required(required).build(),
|
|
@@ -627,7 +653,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
627
653
|
}
|
|
628
654
|
if (tag === "decimal") {
|
|
629
655
|
const field = {
|
|
630
|
-
...(0, _nubitio_crud.numberField)().name(name).label(label).required(required).build(),
|
|
656
|
+
...(fieldSchema.crudHints?.format === "currency" ? (0, _nubitio_crud.currencyField)() : (0, _nubitio_crud.numberField)()).name(name).label(label).required(required).build(),
|
|
631
657
|
filterable
|
|
632
658
|
};
|
|
633
659
|
applyCrudHints(field, fieldSchema.crudHints);
|
|
@@ -673,6 +699,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
673
699
|
//#region packages/hydra/useHydraMetadata.ts
|
|
674
700
|
const JSONLD_URL = "/api/docs.jsonld";
|
|
675
701
|
const JSON_URL = "/api/docs.json";
|
|
702
|
+
const ENTRYPOINT_URL = "/api";
|
|
676
703
|
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
|
|
677
704
|
/**
|
|
678
705
|
* Try to fetch and validate a Hydra JSON-LD doc.
|
|
@@ -694,6 +721,30 @@ async function fetchOpenApiDoc(httpClient, url) {
|
|
|
694
721
|
return json;
|
|
695
722
|
}
|
|
696
723
|
/**
|
|
724
|
+
* Fetch the API entrypoint (`GET /api`) and extract its property → collection
|
|
725
|
+
* href map: `{ "salesDocument": "/api/sales-documents", … }`.
|
|
726
|
+
*
|
|
727
|
+
* This is the canonical source for resource URLs — it reflects the backend's
|
|
728
|
+
* actual route generator instead of guessing via dash-case + pluralize.
|
|
729
|
+
* Returns `undefined` on any failure (auth-gated entrypoint, network error,
|
|
730
|
+
* unexpected shape) so the caller can fall back to the heuristic; URL
|
|
731
|
+
* discovery must never take the whole admin down.
|
|
732
|
+
*/
|
|
733
|
+
async function fetchEntrypointHrefs(httpClient) {
|
|
734
|
+
try {
|
|
735
|
+
const { data } = await httpClient.get(ENTRYPOINT_URL);
|
|
736
|
+
if (!data || typeof data !== "object") return void 0;
|
|
737
|
+
const hrefs = {};
|
|
738
|
+
for (const [key, value] of Object.entries(data)) {
|
|
739
|
+
if (key.startsWith("@")) continue;
|
|
740
|
+
if (typeof value === "string" && value.startsWith("/")) hrefs[key] = value;
|
|
741
|
+
}
|
|
742
|
+
return Object.keys(hrefs).length > 0 ? hrefs : void 0;
|
|
743
|
+
} catch {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
697
748
|
* Stable query key used by useHydraMetadata.
|
|
698
749
|
* Export this so that consumers (e.g. SmartCrudPage retry button) can
|
|
699
750
|
* invalidate exactly the right query without coupling to an internal string.
|
|
@@ -707,9 +758,11 @@ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
|
|
|
707
758
|
*/
|
|
708
759
|
async function fetchApiDoc(httpClient) {
|
|
709
760
|
try {
|
|
761
|
+
const [doc, entrypointHrefs] = await Promise.all([fetchHydraDoc(httpClient, JSONLD_URL), fetchEntrypointHrefs(httpClient)]);
|
|
710
762
|
return {
|
|
711
763
|
format: "hydra",
|
|
712
|
-
doc
|
|
764
|
+
doc,
|
|
765
|
+
entrypointHrefs
|
|
713
766
|
};
|
|
714
767
|
} catch {}
|
|
715
768
|
try {
|
|
@@ -813,15 +866,19 @@ function useResourceSchema(apiUrl) {
|
|
|
813
866
|
error,
|
|
814
867
|
supportedOperations: []
|
|
815
868
|
};
|
|
816
|
-
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
|
|
869
|
+
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc, data.entrypointHrefs) : parseOpenApiDoc(data.doc);
|
|
817
870
|
const normalizedInput = normalizeUrl(apiUrl);
|
|
818
871
|
const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
|
|
819
|
-
if (!resourceSchema)
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
872
|
+
if (!resourceSchema) {
|
|
873
|
+
const knownUrls = Object.values(resourceMap).map((r) => r.apiUrl).sort();
|
|
874
|
+
const slugMatch = knownUrls.find((url) => normalizeUrl(url).replace(/[-_]/g, "") === normalizedInput.replace(/[-_]/g, ""));
|
|
875
|
+
return {
|
|
876
|
+
fields: [],
|
|
877
|
+
isLoading: false,
|
|
878
|
+
error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc.` + (slugMatch ? ` Did you mean ${slugMatch}? If your backend generates underscore paths, configure API Platform's dash generator (path_segment_name_generator: api_platform.metadata.path_segment_name_generator.dash) or update the frontend resource path to match.` : "") + ` Known resource URLs: ${knownUrls.join(", ") || "(none)"}`),
|
|
879
|
+
supportedOperations: []
|
|
880
|
+
};
|
|
881
|
+
}
|
|
825
882
|
return {
|
|
826
883
|
fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
|
|
827
884
|
isLoading: false,
|
package/dist/index.d.cts
CHANGED
|
@@ -65,12 +65,16 @@ interface CrudHints {
|
|
|
65
65
|
filterable?: boolean;
|
|
66
66
|
/** Override whether the field can be sorted in the grid. */
|
|
67
67
|
sortable?: boolean;
|
|
68
|
-
/** When true, the column is hidden from the grid by default. */
|
|
68
|
+
/** When true, the column is hidden from the grid by default (the form still shows the field). */
|
|
69
69
|
hidden?: boolean;
|
|
70
|
+
/** When false, the field is excluded from the create/edit form (the grid still shows the column). */
|
|
71
|
+
visibleOnForm?: boolean;
|
|
70
72
|
/** Column display order. */
|
|
71
73
|
order?: number;
|
|
72
74
|
/** Column width in pixels or as a CSS string. */
|
|
73
75
|
width?: number;
|
|
76
|
+
/** Render hint for scalar fields. 'currency' maps decimals to a currency control. */
|
|
77
|
+
format?: 'currency';
|
|
74
78
|
}
|
|
75
79
|
interface HydraSupportedProperty {
|
|
76
80
|
'@type': 'SupportedProperty';
|
|
@@ -99,6 +103,12 @@ interface HydraSupportedProperty {
|
|
|
99
103
|
* `#[ApiProperty(openapiContext: ['x-crud' => [...]])]` on the PHP entity.
|
|
100
104
|
*/
|
|
101
105
|
'x-crud'?: CrudHints;
|
|
106
|
+
/**
|
|
107
|
+
* Allowed values forwarded by `TranslatedDocumentationNormalizer` from
|
|
108
|
+
* `#[ApiProperty(openapiContext: ['enum' => [...]])]`. When present, the
|
|
109
|
+
* field mapper renders a select control instead of a free-text input.
|
|
110
|
+
*/
|
|
111
|
+
enum?: Array<string | number>;
|
|
102
112
|
}
|
|
103
113
|
/**
|
|
104
114
|
* A single entry from `supportedOperation` on a Hydra class.
|
|
@@ -170,6 +180,8 @@ interface HydraFieldSchema {
|
|
|
170
180
|
* When present, these values override inferred field properties in the mapper.
|
|
171
181
|
*/
|
|
172
182
|
crudHints?: CrudHints;
|
|
183
|
+
/** Allowed values (renders as a select). From `enum` on the supported property. */
|
|
184
|
+
enumOptions?: Array<string | number>;
|
|
173
185
|
}
|
|
174
186
|
/**
|
|
175
187
|
* A single entry from a Hydra `hydra:search` / `hydra:mapping` block.
|
|
@@ -218,9 +230,18 @@ interface OpenApiDoc {
|
|
|
218
230
|
schemas: Record<string, OpenApiSchema>;
|
|
219
231
|
};
|
|
220
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Map of entrypoint property name → real collection href, read from the API
|
|
235
|
+
* entrypoint document (`GET /api` → `{ "salesDocument": "/api/sales-documents", … }`).
|
|
236
|
+
* This is the canonical source for resource URLs: when present it overrides
|
|
237
|
+
* the dash-case + pluralize heuristic, which cannot know the backend's path
|
|
238
|
+
* generator or handle irregular plurals.
|
|
239
|
+
*/
|
|
240
|
+
type HydraEntrypointHrefs = Record<string, string>;
|
|
221
241
|
type ApiDoc = {
|
|
222
242
|
format: 'hydra';
|
|
223
243
|
doc: HydraApiDoc;
|
|
244
|
+
entrypointHrefs?: HydraEntrypointHrefs;
|
|
224
245
|
} | {
|
|
225
246
|
format: 'openapi';
|
|
226
247
|
doc: OpenApiDoc;
|
|
@@ -231,9 +252,17 @@ type ApiDoc = {
|
|
|
231
252
|
* Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
|
|
232
253
|
* Record<className, HydraResourceSchema>.
|
|
233
254
|
*
|
|
255
|
+
* @param entrypointHrefs - Optional map of entrypoint property name → real
|
|
256
|
+
* collection href, read from the API entrypoint document (`GET /api`).
|
|
257
|
+
* When provided it is the **authoritative** source for resource URLs;
|
|
258
|
+
* the dash-case + pluralize heuristic only fills the gaps. The heuristic
|
|
259
|
+
* cannot know the backend's path segment generator (underscores vs dashes)
|
|
260
|
+
* nor handle irregular plurals (Series, Settings, …), so always prefer
|
|
261
|
+
* passing the real hrefs.
|
|
262
|
+
*
|
|
234
263
|
* @throws Error if doc is not a valid Hydra ApiDocumentation.
|
|
235
264
|
*/
|
|
236
|
-
declare function parseHydraDoc(doc: HydraApiDoc): Record<string, HydraResourceSchema>;
|
|
265
|
+
declare function parseHydraDoc(doc: HydraApiDoc, entrypointHrefs?: HydraEntrypointHrefs): Record<string, HydraResourceSchema>;
|
|
237
266
|
/**
|
|
238
267
|
* Parse a raw `/api/docs.json` OpenAPI 3.1 response into the same
|
|
239
268
|
* Record<className, HydraResourceSchema> output shape so that
|
package/dist/index.d.mts
CHANGED
|
@@ -65,12 +65,16 @@ interface CrudHints {
|
|
|
65
65
|
filterable?: boolean;
|
|
66
66
|
/** Override whether the field can be sorted in the grid. */
|
|
67
67
|
sortable?: boolean;
|
|
68
|
-
/** When true, the column is hidden from the grid by default. */
|
|
68
|
+
/** When true, the column is hidden from the grid by default (the form still shows the field). */
|
|
69
69
|
hidden?: boolean;
|
|
70
|
+
/** When false, the field is excluded from the create/edit form (the grid still shows the column). */
|
|
71
|
+
visibleOnForm?: boolean;
|
|
70
72
|
/** Column display order. */
|
|
71
73
|
order?: number;
|
|
72
74
|
/** Column width in pixels or as a CSS string. */
|
|
73
75
|
width?: number;
|
|
76
|
+
/** Render hint for scalar fields. 'currency' maps decimals to a currency control. */
|
|
77
|
+
format?: 'currency';
|
|
74
78
|
}
|
|
75
79
|
interface HydraSupportedProperty {
|
|
76
80
|
'@type': 'SupportedProperty';
|
|
@@ -99,6 +103,12 @@ interface HydraSupportedProperty {
|
|
|
99
103
|
* `#[ApiProperty(openapiContext: ['x-crud' => [...]])]` on the PHP entity.
|
|
100
104
|
*/
|
|
101
105
|
'x-crud'?: CrudHints;
|
|
106
|
+
/**
|
|
107
|
+
* Allowed values forwarded by `TranslatedDocumentationNormalizer` from
|
|
108
|
+
* `#[ApiProperty(openapiContext: ['enum' => [...]])]`. When present, the
|
|
109
|
+
* field mapper renders a select control instead of a free-text input.
|
|
110
|
+
*/
|
|
111
|
+
enum?: Array<string | number>;
|
|
102
112
|
}
|
|
103
113
|
/**
|
|
104
114
|
* A single entry from `supportedOperation` on a Hydra class.
|
|
@@ -170,6 +180,8 @@ interface HydraFieldSchema {
|
|
|
170
180
|
* When present, these values override inferred field properties in the mapper.
|
|
171
181
|
*/
|
|
172
182
|
crudHints?: CrudHints;
|
|
183
|
+
/** Allowed values (renders as a select). From `enum` on the supported property. */
|
|
184
|
+
enumOptions?: Array<string | number>;
|
|
173
185
|
}
|
|
174
186
|
/**
|
|
175
187
|
* A single entry from a Hydra `hydra:search` / `hydra:mapping` block.
|
|
@@ -218,9 +230,18 @@ interface OpenApiDoc {
|
|
|
218
230
|
schemas: Record<string, OpenApiSchema>;
|
|
219
231
|
};
|
|
220
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Map of entrypoint property name → real collection href, read from the API
|
|
235
|
+
* entrypoint document (`GET /api` → `{ "salesDocument": "/api/sales-documents", … }`).
|
|
236
|
+
* This is the canonical source for resource URLs: when present it overrides
|
|
237
|
+
* the dash-case + pluralize heuristic, which cannot know the backend's path
|
|
238
|
+
* generator or handle irregular plurals.
|
|
239
|
+
*/
|
|
240
|
+
type HydraEntrypointHrefs = Record<string, string>;
|
|
221
241
|
type ApiDoc = {
|
|
222
242
|
format: 'hydra';
|
|
223
243
|
doc: HydraApiDoc;
|
|
244
|
+
entrypointHrefs?: HydraEntrypointHrefs;
|
|
224
245
|
} | {
|
|
225
246
|
format: 'openapi';
|
|
226
247
|
doc: OpenApiDoc;
|
|
@@ -231,9 +252,17 @@ type ApiDoc = {
|
|
|
231
252
|
* Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
|
|
232
253
|
* Record<className, HydraResourceSchema>.
|
|
233
254
|
*
|
|
255
|
+
* @param entrypointHrefs - Optional map of entrypoint property name → real
|
|
256
|
+
* collection href, read from the API entrypoint document (`GET /api`).
|
|
257
|
+
* When provided it is the **authoritative** source for resource URLs;
|
|
258
|
+
* the dash-case + pluralize heuristic only fills the gaps. The heuristic
|
|
259
|
+
* cannot know the backend's path segment generator (underscores vs dashes)
|
|
260
|
+
* nor handle irregular plurals (Series, Settings, …), so always prefer
|
|
261
|
+
* passing the real hrefs.
|
|
262
|
+
*
|
|
234
263
|
* @throws Error if doc is not a valid Hydra ApiDocumentation.
|
|
235
264
|
*/
|
|
236
|
-
declare function parseHydraDoc(doc: HydraApiDoc): Record<string, HydraResourceSchema>;
|
|
265
|
+
declare function parseHydraDoc(doc: HydraApiDoc, entrypointHrefs?: HydraEntrypointHrefs): Record<string, HydraResourceSchema>;
|
|
237
266
|
/**
|
|
238
267
|
* Parse a raw `/api/docs.json` OpenAPI 3.1 response into the same
|
|
239
268
|
* Record<className, HydraResourceSchema> output shape so that
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCoreHttpClient, useCoreConfig, useCoreHttpClient } from "@nubitio/core";
|
|
2
2
|
import { createContext, useContext, useMemo } from "react";
|
|
3
|
-
import { ResourceSchemaProvider, ResourceStoreProvider, datetimeField, entityField, noneField, numberField, switchField, textField } from "@nubitio/crud";
|
|
3
|
+
import { ResourceSchemaProvider, ResourceStoreProvider, currencyField, datetimeField, entityField, enumField, noneField, numberField, switchField, textField } from "@nubitio/crud";
|
|
4
4
|
import { jsx } from "react/jsx-runtime";
|
|
5
5
|
import { useQuery } from "@tanstack/react-query";
|
|
6
6
|
//#region packages/hydra/HydraRemoteDataSource.ts
|
|
@@ -335,9 +335,17 @@ function extractEntrypointCollectionOperations(doc) {
|
|
|
335
335
|
* Parse a raw `/api/docs.jsonld` Hydra JSON-LD response into a typed
|
|
336
336
|
* Record<className, HydraResourceSchema>.
|
|
337
337
|
*
|
|
338
|
+
* @param entrypointHrefs - Optional map of entrypoint property name → real
|
|
339
|
+
* collection href, read from the API entrypoint document (`GET /api`).
|
|
340
|
+
* When provided it is the **authoritative** source for resource URLs;
|
|
341
|
+
* the dash-case + pluralize heuristic only fills the gaps. The heuristic
|
|
342
|
+
* cannot know the backend's path segment generator (underscores vs dashes)
|
|
343
|
+
* nor handle irregular plurals (Series, Settings, …), so always prefer
|
|
344
|
+
* passing the real hrefs.
|
|
345
|
+
*
|
|
338
346
|
* @throws Error if doc is not a valid Hydra ApiDocumentation.
|
|
339
347
|
*/
|
|
340
|
-
function parseHydraDoc(doc) {
|
|
348
|
+
function parseHydraDoc(doc, entrypointHrefs) {
|
|
341
349
|
const result = {};
|
|
342
350
|
const collectionOperationsMap = extractEntrypointCollectionOperations(doc);
|
|
343
351
|
const urlMap = {};
|
|
@@ -348,7 +356,8 @@ function parseHydraDoc(doc) {
|
|
|
348
356
|
const propId = sp.property?.["@id"];
|
|
349
357
|
if (range && propId) {
|
|
350
358
|
const className = range.replace("#", "");
|
|
351
|
-
|
|
359
|
+
const propName = propId.split("/").pop() ?? "";
|
|
360
|
+
urlMap[className] = entrypointHrefs?.[propName] ?? deriveApiUrl(propId);
|
|
352
361
|
}
|
|
353
362
|
}
|
|
354
363
|
for (const cls of doc["supportedClass"]) {
|
|
@@ -367,7 +376,8 @@ function parseHydraDoc(doc) {
|
|
|
367
376
|
readable,
|
|
368
377
|
writeable,
|
|
369
378
|
"hydra:title": sp["title"] ?? sp["hydra:title"] ?? void 0,
|
|
370
|
-
crudHints: sp["x-crud"]
|
|
379
|
+
crudHints: sp["x-crud"],
|
|
380
|
+
enumOptions: Array.isArray(sp["enum"]) ? sp["enum"] : void 0
|
|
371
381
|
});
|
|
372
382
|
}
|
|
373
383
|
result[className] = {
|
|
@@ -400,7 +410,8 @@ function parseOpenApiDoc(doc) {
|
|
|
400
410
|
propertyType: "rdf:Property",
|
|
401
411
|
required: required.has(fieldName),
|
|
402
412
|
readable,
|
|
403
|
-
writeable
|
|
413
|
+
writeable,
|
|
414
|
+
enumOptions: Array.isArray(prop.enum) ? prop.enum : void 0
|
|
404
415
|
});
|
|
405
416
|
}
|
|
406
417
|
result[className] = {
|
|
@@ -471,6 +482,7 @@ function resolveRangeTag(range, propertyType) {
|
|
|
471
482
|
* Only properties that are explicitly set (`!== undefined`) are applied.
|
|
472
483
|
*
|
|
473
484
|
* `hidden: true` sets `visible: false` so the column is hidden in the grid.
|
|
485
|
+
* `visibleOnForm: false` excludes the field from the create/edit form only.
|
|
474
486
|
* `order` maps to the `Field.order` property used by native grid column ordering.
|
|
475
487
|
*/
|
|
476
488
|
function applyCrudHints(field, hints) {
|
|
@@ -478,6 +490,7 @@ function applyCrudHints(field, hints) {
|
|
|
478
490
|
if (hints.filterable !== void 0) field.filterable = hints.filterable;
|
|
479
491
|
if (hints.sortable !== void 0) field.sortable = hints.sortable;
|
|
480
492
|
if (hints.hidden !== void 0 && hints.hidden) field.visible = false;
|
|
493
|
+
if (hints.visibleOnForm !== void 0) field.visibleOnForm = hints.visibleOnForm;
|
|
481
494
|
if (hints.order !== void 0) field.order = hints.order;
|
|
482
495
|
if (hints.width !== void 0) field.width = hints.width;
|
|
483
496
|
}
|
|
@@ -562,11 +575,11 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
562
575
|
}
|
|
563
576
|
if (!readable) continue;
|
|
564
577
|
const hydraTitle = fieldSchema["hydra:title"];
|
|
565
|
-
const label = hydraTitle && hydraTitle.trim() !== "" ? hydraTitle : toLabel(name);
|
|
578
|
+
const label = hydraTitle && hydraTitle.trim() !== "" && hydraTitle !== name ? hydraTitle : toLabel(name);
|
|
566
579
|
const filterable = filterableProperties.size === 0 ? true : filterableProperties.has(name);
|
|
567
580
|
if (!writeable) {
|
|
568
581
|
const field = {
|
|
569
|
-
...noneField().name(name).label(label).required(required).build(),
|
|
582
|
+
...(fieldSchema.crudHints?.format === "currency" ? currencyField().readonly(true) : noneField()).name(name).label(label).required(required).build(),
|
|
570
583
|
filterable
|
|
571
584
|
};
|
|
572
585
|
applyCrudHints(field, fieldSchema.crudHints);
|
|
@@ -574,6 +587,19 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
574
587
|
continue;
|
|
575
588
|
}
|
|
576
589
|
const tag = resolveRangeTag(range, propertyType);
|
|
590
|
+
const enumOptions = fieldSchema.enumOptions;
|
|
591
|
+
if (enumOptions && enumOptions.length > 0 && (tag === "text" || tag === "integer" || tag === "decimal")) {
|
|
592
|
+
const field = {
|
|
593
|
+
...enumField(enumOptions.map((value) => ({
|
|
594
|
+
value,
|
|
595
|
+
text: toLabel(String(value)).replace(/\b[a-z]/g, (c) => c.toUpperCase())
|
|
596
|
+
}))).name(name).label(label).required(required).build(),
|
|
597
|
+
filterable
|
|
598
|
+
};
|
|
599
|
+
applyCrudHints(field, fieldSchema.crudHints);
|
|
600
|
+
fields.push(field);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
577
603
|
if (tag === "boolean") {
|
|
578
604
|
const field = {
|
|
579
605
|
...switchField().name(name).label(label).required(required).build(),
|
|
@@ -603,7 +629,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
603
629
|
}
|
|
604
630
|
if (tag === "decimal") {
|
|
605
631
|
const field = {
|
|
606
|
-
...numberField().name(name).label(label).required(required).build(),
|
|
632
|
+
...(fieldSchema.crudHints?.format === "currency" ? currencyField() : numberField()).name(name).label(label).required(required).build(),
|
|
607
633
|
filterable
|
|
608
634
|
};
|
|
609
635
|
applyCrudHints(field, fieldSchema.crudHints);
|
|
@@ -649,6 +675,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
649
675
|
//#region packages/hydra/useHydraMetadata.ts
|
|
650
676
|
const JSONLD_URL = "/api/docs.jsonld";
|
|
651
677
|
const JSON_URL = "/api/docs.json";
|
|
678
|
+
const ENTRYPOINT_URL = "/api";
|
|
652
679
|
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
|
|
653
680
|
/**
|
|
654
681
|
* Try to fetch and validate a Hydra JSON-LD doc.
|
|
@@ -670,6 +697,30 @@ async function fetchOpenApiDoc(httpClient, url) {
|
|
|
670
697
|
return json;
|
|
671
698
|
}
|
|
672
699
|
/**
|
|
700
|
+
* Fetch the API entrypoint (`GET /api`) and extract its property → collection
|
|
701
|
+
* href map: `{ "salesDocument": "/api/sales-documents", … }`.
|
|
702
|
+
*
|
|
703
|
+
* This is the canonical source for resource URLs — it reflects the backend's
|
|
704
|
+
* actual route generator instead of guessing via dash-case + pluralize.
|
|
705
|
+
* Returns `undefined` on any failure (auth-gated entrypoint, network error,
|
|
706
|
+
* unexpected shape) so the caller can fall back to the heuristic; URL
|
|
707
|
+
* discovery must never take the whole admin down.
|
|
708
|
+
*/
|
|
709
|
+
async function fetchEntrypointHrefs(httpClient) {
|
|
710
|
+
try {
|
|
711
|
+
const { data } = await httpClient.get(ENTRYPOINT_URL);
|
|
712
|
+
if (!data || typeof data !== "object") return void 0;
|
|
713
|
+
const hrefs = {};
|
|
714
|
+
for (const [key, value] of Object.entries(data)) {
|
|
715
|
+
if (key.startsWith("@")) continue;
|
|
716
|
+
if (typeof value === "string" && value.startsWith("/")) hrefs[key] = value;
|
|
717
|
+
}
|
|
718
|
+
return Object.keys(hrefs).length > 0 ? hrefs : void 0;
|
|
719
|
+
} catch {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
673
724
|
* Stable query key used by useHydraMetadata.
|
|
674
725
|
* Export this so that consumers (e.g. SmartCrudPage retry button) can
|
|
675
726
|
* invalidate exactly the right query without coupling to an internal string.
|
|
@@ -683,9 +734,11 @@ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
|
|
|
683
734
|
*/
|
|
684
735
|
async function fetchApiDoc(httpClient) {
|
|
685
736
|
try {
|
|
737
|
+
const [doc, entrypointHrefs] = await Promise.all([fetchHydraDoc(httpClient, JSONLD_URL), fetchEntrypointHrefs(httpClient)]);
|
|
686
738
|
return {
|
|
687
739
|
format: "hydra",
|
|
688
|
-
doc
|
|
740
|
+
doc,
|
|
741
|
+
entrypointHrefs
|
|
689
742
|
};
|
|
690
743
|
} catch {}
|
|
691
744
|
try {
|
|
@@ -789,15 +842,19 @@ function useResourceSchema(apiUrl) {
|
|
|
789
842
|
error,
|
|
790
843
|
supportedOperations: []
|
|
791
844
|
};
|
|
792
|
-
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
|
|
845
|
+
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc, data.entrypointHrefs) : parseOpenApiDoc(data.doc);
|
|
793
846
|
const normalizedInput = normalizeUrl(apiUrl);
|
|
794
847
|
const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
|
|
795
|
-
if (!resourceSchema)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
848
|
+
if (!resourceSchema) {
|
|
849
|
+
const knownUrls = Object.values(resourceMap).map((r) => r.apiUrl).sort();
|
|
850
|
+
const slugMatch = knownUrls.find((url) => normalizeUrl(url).replace(/[-_]/g, "") === normalizedInput.replace(/[-_]/g, ""));
|
|
851
|
+
return {
|
|
852
|
+
fields: [],
|
|
853
|
+
isLoading: false,
|
|
854
|
+
error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc.` + (slugMatch ? ` Did you mean ${slugMatch}? If your backend generates underscore paths, configure API Platform's dash generator (path_segment_name_generator: api_platform.metadata.path_segment_name_generator.dash) or update the frontend resource path to match.` : "") + ` Known resource URLs: ${knownUrls.join(", ") || "(none)"}`),
|
|
855
|
+
supportedOperations: []
|
|
856
|
+
};
|
|
857
|
+
}
|
|
801
858
|
return {
|
|
802
859
|
fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
|
|
803
860
|
isLoading: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubitio/hydra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hydra / OpenAPI adapter for @nubitio/crud — automatic schema discovery and data source from API Platform docs.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"react": "^19.0.0",
|
|
51
51
|
"react-dom": "^19.0.0",
|
|
52
52
|
"react-i18next": "^14.0.0",
|
|
53
|
-
"@nubitio/
|
|
54
|
-
"@nubitio/
|
|
53
|
+
"@nubitio/core": "^0.3.0",
|
|
54
|
+
"@nubitio/crud": "^0.3.0"
|
|
55
55
|
}
|
|
56
56
|
}
|