@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 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
- urlMap[className] = deriveApiUrl(propId);
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: await fetchHydraDoc(httpClient, JSONLD_URL)
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) return {
820
- fields: [],
821
- isLoading: false,
822
- error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc`),
823
- supportedOperations: []
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
- urlMap[className] = deriveApiUrl(propId);
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: await fetchHydraDoc(httpClient, JSONLD_URL)
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) return {
796
- fields: [],
797
- isLoading: false,
798
- error: /* @__PURE__ */ new Error(`No schema found for ${apiUrl} in API doc`),
799
- supportedOperations: []
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.2.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/crud": "^0.2.0",
54
- "@nubitio/core": "^0.2.0"
53
+ "@nubitio/core": "^0.3.0",
54
+ "@nubitio/crud": "^0.3.0"
55
55
  }
56
56
  }