@nubitio/hydra 0.2.1 → 0.3.1
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 +79 -13
- package/dist/index.d.cts +31 -2
- package/dist/index.d.mts +31 -2
- package/dist/index.mjs +80 -14
- 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
|
}
|
|
@@ -590,7 +603,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
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,28 @@ 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
|
+
}
|
|
627
|
+
if (fieldSchema.crudHints?.format === "currency" && (tag === "text" || tag === "integer" || tag === "decimal")) {
|
|
628
|
+
const field = {
|
|
629
|
+
...(0, _nubitio_crud.currencyField)().name(name).label(label).required(required).build(),
|
|
630
|
+
filterable
|
|
631
|
+
};
|
|
632
|
+
applyCrudHints(field, fieldSchema.crudHints);
|
|
633
|
+
fields.push(field);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
601
636
|
if (tag === "boolean") {
|
|
602
637
|
const field = {
|
|
603
638
|
...(0, _nubitio_crud.switchField)().name(name).label(label).required(required).build(),
|
|
@@ -673,6 +708,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
673
708
|
//#region packages/hydra/useHydraMetadata.ts
|
|
674
709
|
const JSONLD_URL = "/api/docs.jsonld";
|
|
675
710
|
const JSON_URL = "/api/docs.json";
|
|
711
|
+
const ENTRYPOINT_URL = "/api";
|
|
676
712
|
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
|
|
677
713
|
/**
|
|
678
714
|
* Try to fetch and validate a Hydra JSON-LD doc.
|
|
@@ -694,6 +730,30 @@ async function fetchOpenApiDoc(httpClient, url) {
|
|
|
694
730
|
return json;
|
|
695
731
|
}
|
|
696
732
|
/**
|
|
733
|
+
* Fetch the API entrypoint (`GET /api`) and extract its property → collection
|
|
734
|
+
* href map: `{ "salesDocument": "/api/sales-documents", … }`.
|
|
735
|
+
*
|
|
736
|
+
* This is the canonical source for resource URLs — it reflects the backend's
|
|
737
|
+
* actual route generator instead of guessing via dash-case + pluralize.
|
|
738
|
+
* Returns `undefined` on any failure (auth-gated entrypoint, network error,
|
|
739
|
+
* unexpected shape) so the caller can fall back to the heuristic; URL
|
|
740
|
+
* discovery must never take the whole admin down.
|
|
741
|
+
*/
|
|
742
|
+
async function fetchEntrypointHrefs(httpClient) {
|
|
743
|
+
try {
|
|
744
|
+
const { data } = await httpClient.get(ENTRYPOINT_URL);
|
|
745
|
+
if (!data || typeof data !== "object") return void 0;
|
|
746
|
+
const hrefs = {};
|
|
747
|
+
for (const [key, value] of Object.entries(data)) {
|
|
748
|
+
if (key.startsWith("@")) continue;
|
|
749
|
+
if (typeof value === "string" && value.startsWith("/")) hrefs[key] = value;
|
|
750
|
+
}
|
|
751
|
+
return Object.keys(hrefs).length > 0 ? hrefs : void 0;
|
|
752
|
+
} catch {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
697
757
|
* Stable query key used by useHydraMetadata.
|
|
698
758
|
* Export this so that consumers (e.g. SmartCrudPage retry button) can
|
|
699
759
|
* invalidate exactly the right query without coupling to an internal string.
|
|
@@ -707,9 +767,11 @@ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
|
|
|
707
767
|
*/
|
|
708
768
|
async function fetchApiDoc(httpClient) {
|
|
709
769
|
try {
|
|
770
|
+
const [doc, entrypointHrefs] = await Promise.all([fetchHydraDoc(httpClient, JSONLD_URL), fetchEntrypointHrefs(httpClient)]);
|
|
710
771
|
return {
|
|
711
772
|
format: "hydra",
|
|
712
|
-
doc
|
|
773
|
+
doc,
|
|
774
|
+
entrypointHrefs
|
|
713
775
|
};
|
|
714
776
|
} catch {}
|
|
715
777
|
try {
|
|
@@ -813,15 +875,19 @@ function useResourceSchema(apiUrl) {
|
|
|
813
875
|
error,
|
|
814
876
|
supportedOperations: []
|
|
815
877
|
};
|
|
816
|
-
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
|
|
878
|
+
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc, data.entrypointHrefs) : parseOpenApiDoc(data.doc);
|
|
817
879
|
const normalizedInput = normalizeUrl(apiUrl);
|
|
818
880
|
const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
|
|
819
|
-
if (!resourceSchema)
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
881
|
+
if (!resourceSchema) {
|
|
882
|
+
const knownUrls = Object.values(resourceMap).map((r) => r.apiUrl).sort();
|
|
883
|
+
const slugMatch = knownUrls.find((url) => normalizeUrl(url).replace(/[-_]/g, "") === normalizedInput.replace(/[-_]/g, ""));
|
|
884
|
+
return {
|
|
885
|
+
fields: [],
|
|
886
|
+
isLoading: false,
|
|
887
|
+
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)"}`),
|
|
888
|
+
supportedOperations: []
|
|
889
|
+
};
|
|
890
|
+
}
|
|
825
891
|
return {
|
|
826
892
|
fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
|
|
827
893
|
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
|
}
|
|
@@ -566,7 +579,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
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,28 @@ 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
|
+
}
|
|
603
|
+
if (fieldSchema.crudHints?.format === "currency" && (tag === "text" || tag === "integer" || tag === "decimal")) {
|
|
604
|
+
const field = {
|
|
605
|
+
...currencyField().name(name).label(label).required(required).build(),
|
|
606
|
+
filterable
|
|
607
|
+
};
|
|
608
|
+
applyCrudHints(field, fieldSchema.crudHints);
|
|
609
|
+
fields.push(field);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
577
612
|
if (tag === "boolean") {
|
|
578
613
|
const field = {
|
|
579
614
|
...switchField().name(name).label(label).required(required).build(),
|
|
@@ -649,6 +684,7 @@ function mapHydraSchemaToFields(schema, urlLookup, schemaLookup) {
|
|
|
649
684
|
//#region packages/hydra/useHydraMetadata.ts
|
|
650
685
|
const JSONLD_URL = "/api/docs.jsonld";
|
|
651
686
|
const JSON_URL = "/api/docs.json";
|
|
687
|
+
const ENTRYPOINT_URL = "/api";
|
|
652
688
|
const isDev = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
|
|
653
689
|
/**
|
|
654
690
|
* Try to fetch and validate a Hydra JSON-LD doc.
|
|
@@ -670,6 +706,30 @@ async function fetchOpenApiDoc(httpClient, url) {
|
|
|
670
706
|
return json;
|
|
671
707
|
}
|
|
672
708
|
/**
|
|
709
|
+
* Fetch the API entrypoint (`GET /api`) and extract its property → collection
|
|
710
|
+
* href map: `{ "salesDocument": "/api/sales-documents", … }`.
|
|
711
|
+
*
|
|
712
|
+
* This is the canonical source for resource URLs — it reflects the backend's
|
|
713
|
+
* actual route generator instead of guessing via dash-case + pluralize.
|
|
714
|
+
* Returns `undefined` on any failure (auth-gated entrypoint, network error,
|
|
715
|
+
* unexpected shape) so the caller can fall back to the heuristic; URL
|
|
716
|
+
* discovery must never take the whole admin down.
|
|
717
|
+
*/
|
|
718
|
+
async function fetchEntrypointHrefs(httpClient) {
|
|
719
|
+
try {
|
|
720
|
+
const { data } = await httpClient.get(ENTRYPOINT_URL);
|
|
721
|
+
if (!data || typeof data !== "object") return void 0;
|
|
722
|
+
const hrefs = {};
|
|
723
|
+
for (const [key, value] of Object.entries(data)) {
|
|
724
|
+
if (key.startsWith("@")) continue;
|
|
725
|
+
if (typeof value === "string" && value.startsWith("/")) hrefs[key] = value;
|
|
726
|
+
}
|
|
727
|
+
return Object.keys(hrefs).length > 0 ? hrefs : void 0;
|
|
728
|
+
} catch {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
673
733
|
* Stable query key used by useHydraMetadata.
|
|
674
734
|
* Export this so that consumers (e.g. SmartCrudPage retry button) can
|
|
675
735
|
* invalidate exactly the right query without coupling to an internal string.
|
|
@@ -683,9 +743,11 @@ const API_DOC_QUERY_KEY = ["api-doc-discovery"];
|
|
|
683
743
|
*/
|
|
684
744
|
async function fetchApiDoc(httpClient) {
|
|
685
745
|
try {
|
|
746
|
+
const [doc, entrypointHrefs] = await Promise.all([fetchHydraDoc(httpClient, JSONLD_URL), fetchEntrypointHrefs(httpClient)]);
|
|
686
747
|
return {
|
|
687
748
|
format: "hydra",
|
|
688
|
-
doc
|
|
749
|
+
doc,
|
|
750
|
+
entrypointHrefs
|
|
689
751
|
};
|
|
690
752
|
} catch {}
|
|
691
753
|
try {
|
|
@@ -789,15 +851,19 @@ function useResourceSchema(apiUrl) {
|
|
|
789
851
|
error,
|
|
790
852
|
supportedOperations: []
|
|
791
853
|
};
|
|
792
|
-
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc) : parseOpenApiDoc(data.doc);
|
|
854
|
+
const resourceMap = data.format === "hydra" ? parseHydraDoc(data.doc, data.entrypointHrefs) : parseOpenApiDoc(data.doc);
|
|
793
855
|
const normalizedInput = normalizeUrl(apiUrl);
|
|
794
856
|
const resourceSchema = Object.values(resourceMap).find((r) => normalizeUrl(r.apiUrl) === normalizedInput);
|
|
795
|
-
if (!resourceSchema)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
857
|
+
if (!resourceSchema) {
|
|
858
|
+
const knownUrls = Object.values(resourceMap).map((r) => r.apiUrl).sort();
|
|
859
|
+
const slugMatch = knownUrls.find((url) => normalizeUrl(url).replace(/[-_]/g, "") === normalizedInput.replace(/[-_]/g, ""));
|
|
860
|
+
return {
|
|
861
|
+
fields: [],
|
|
862
|
+
isLoading: false,
|
|
863
|
+
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)"}`),
|
|
864
|
+
supportedOperations: []
|
|
865
|
+
};
|
|
866
|
+
}
|
|
801
867
|
return {
|
|
802
868
|
fields: mapHydraSchemaToFields(resourceSchema, (className) => resourceMap[className]?.apiUrl, (className) => resourceMap[className]),
|
|
803
869
|
isLoading: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nubitio/hydra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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/core": "^0.
|
|
54
|
-
"@nubitio/crud": "^0.
|
|
53
|
+
"@nubitio/core": "^0.3.1",
|
|
54
|
+
"@nubitio/crud": "^0.3.1"
|
|
55
55
|
}
|
|
56
56
|
}
|