@firecms/core 3.3.0 → 3.4.0-canary.0ef7442

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.
Files changed (124) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  3. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  4. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +7 -0
  7. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  8. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  9. package/dist/components/EntityPreview.d.ts +3 -1
  10. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  11. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  12. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  13. package/dist/components/common/table_url_params.d.ts +24 -0
  14. package/dist/components/common/useDataSourceTableController.d.ts +7 -1
  15. package/dist/components/common/useTableSearchHelper.d.ts +7 -1
  16. package/dist/core/DefaultDrawer.d.ts +10 -3
  17. package/dist/core/EntityEditView.d.ts +7 -1
  18. package/dist/core/field_configs.d.ts +1 -1
  19. package/dist/form/EntityForm.d.ts +6 -1
  20. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  21. package/dist/form/index.d.ts +1 -0
  22. package/dist/hooks/data/save.d.ts +1 -1
  23. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  24. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  25. package/dist/index.es.js +2915 -1330
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +2913 -1328
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/locales/pl.d.ts +2 -0
  30. package/dist/preview/index.d.ts +1 -0
  31. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  32. package/dist/types/collections.d.ts +24 -0
  33. package/dist/types/datasource.d.ts +59 -4
  34. package/dist/types/entities.d.ts +21 -1
  35. package/dist/types/properties.d.ts +17 -1
  36. package/dist/types/side_entity_controller.d.ts +6 -0
  37. package/dist/types/translations.d.ts +6 -0
  38. package/dist/util/__tests__/collections.test.d.ts +1 -0
  39. package/dist/util/__tests__/urls.test.d.ts +1 -0
  40. package/dist/util/collections.d.ts +1 -1
  41. package/dist/util/entities.d.ts +1 -0
  42. package/dist/util/geopoint.d.ts +22 -0
  43. package/dist/util/index.d.ts +2 -0
  44. package/dist/util/navigation_blocking.d.ts +22 -0
  45. package/dist/util/navigation_from_path.d.ts +17 -0
  46. package/dist/util/navigation_utils.d.ts +23 -1
  47. package/dist/util/parent_references_from_path.d.ts +1 -0
  48. package/dist/util/urls.d.ts +22 -0
  49. package/package.json +17 -10
  50. package/src/app/Scaffold.tsx +34 -44
  51. package/src/app/useApp.tsx +1 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  53. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  54. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  55. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  56. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  57. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
  58. package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  60. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  61. package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
  62. package/src/components/EntityPreview.tsx +41 -40
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
  64. package/src/components/ReferenceWidget.tsx +1 -1
  65. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  66. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  67. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  68. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  69. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  70. package/src/components/common/table_url_params.ts +172 -0
  71. package/src/components/common/useDataSourceTableController.tsx +104 -159
  72. package/src/components/common/useTableSearchHelper.ts +21 -2
  73. package/src/contexts/SnackbarProvider.tsx +14 -1
  74. package/src/core/DefaultDrawer.tsx +150 -64
  75. package/src/core/DrawerNavigationGroup.tsx +27 -29
  76. package/src/core/DrawerNavigationItem.tsx +10 -12
  77. package/src/core/EntityEditView.tsx +78 -32
  78. package/src/core/EntitySidePanel.tsx +2 -0
  79. package/src/core/field_configs.tsx +15 -0
  80. package/src/form/EntityForm.tsx +9 -3
  81. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  82. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  83. package/src/form/index.tsx +1 -0
  84. package/src/hooks/data/delete.ts +2 -0
  85. package/src/hooks/data/save.ts +2 -0
  86. package/src/hooks/data/useCollectionFetch.tsx +13 -0
  87. package/src/hooks/data/useEntityFetch.tsx +18 -0
  88. package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
  89. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  90. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  91. package/src/internal/useBuildDataSource.ts +25 -4
  92. package/src/internal/useBuildSideEntityController.tsx +8 -1
  93. package/src/locales/de.ts +7 -2
  94. package/src/locales/en.ts +7 -2
  95. package/src/locales/es.ts +7 -2
  96. package/src/locales/fr.ts +7 -2
  97. package/src/locales/hi.ts +7 -2
  98. package/src/locales/it.ts +7 -2
  99. package/src/locales/pl.ts +735 -0
  100. package/src/locales/pt.ts +7 -2
  101. package/src/preview/PropertyPreview.tsx +12 -0
  102. package/src/preview/components/StorageThumbnail.tsx +24 -2
  103. package/src/preview/index.ts +1 -0
  104. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  105. package/src/routes/FireCMSRoute.tsx +47 -25
  106. package/src/types/collections.ts +26 -0
  107. package/src/types/datasource.ts +59 -4
  108. package/src/types/entities.ts +24 -1
  109. package/src/types/properties.ts +17 -0
  110. package/src/types/side_entity_controller.tsx +6 -0
  111. package/src/types/translations.ts +6 -0
  112. package/src/util/__tests__/collections.test.ts +32 -0
  113. package/src/util/__tests__/urls.test.ts +131 -0
  114. package/src/util/collections.ts +1 -1
  115. package/src/util/entities.ts +16 -1
  116. package/src/util/geopoint.ts +81 -0
  117. package/src/util/index.ts +2 -0
  118. package/src/util/navigation_blocking.ts +45 -0
  119. package/src/util/navigation_from_path.ts +41 -6
  120. package/src/util/navigation_utils.ts +40 -2
  121. package/src/util/parent_references_from_path.ts +14 -4
  122. package/src/util/previews.ts +8 -1
  123. package/src/util/resolutions.ts +8 -0
  124. package/src/util/urls.ts +52 -0
@@ -0,0 +1,131 @@
1
+ import { getFileExtensionFromUrl, getPreviewTypeFromUrl, isAbsoluteHttpUrl } from "../urls";
2
+
3
+ describe("url utilities", () => {
4
+
5
+ describe("isAbsoluteHttpUrl", () => {
6
+
7
+ it("should detect absolute http and https URLs", () => {
8
+ expect(isAbsoluteHttpUrl("https://abc.com/abc.png")).toBe(true);
9
+ expect(isAbsoluteHttpUrl("http://abc.com/abc.png")).toBe(true);
10
+ expect(isAbsoluteHttpUrl("HTTPS://ABC.COM/ABC.PNG")).toBe(true);
11
+ });
12
+
13
+ it("should detect Firebase Storage download URLs as absolute URLs", () => {
14
+ expect(isAbsoluteHttpUrl("https://firebasestorage.googleapis.com/v0/b/demo.appspot.com/o/images%2Fcat.png?alt=media&token=abc")).toBe(true);
15
+ expect(isAbsoluteHttpUrl("https://storage.googleapis.com/demo.appspot.com/images/cat.png")).toBe(true);
16
+ });
17
+
18
+ it("should not consider storage paths absolute URLs", () => {
19
+ expect(isAbsoluteHttpUrl("images/cat.png")).toBe(false);
20
+ expect(isAbsoluteHttpUrl("/images/cat.png")).toBe(false);
21
+ expect(isAbsoluteHttpUrl("cat.png")).toBe(false);
22
+ });
23
+
24
+ it("should not consider other protocols absolute http URLs", () => {
25
+ expect(isAbsoluteHttpUrl("gs://demo.appspot.com/images/cat.png")).toBe(false);
26
+ expect(isAbsoluteHttpUrl("data:image/png;base64,AAAA")).toBe(false);
27
+ expect(isAbsoluteHttpUrl("//abc.com/abc.png")).toBe(false);
28
+ });
29
+
30
+ it("should handle empty values", () => {
31
+ expect(isAbsoluteHttpUrl("")).toBe(false);
32
+ expect(isAbsoluteHttpUrl(undefined)).toBe(false);
33
+ expect(isAbsoluteHttpUrl(null)).toBe(false);
34
+ });
35
+
36
+ it("should not match http appearing later in the value", () => {
37
+ expect(isAbsoluteHttpUrl("images/https://abc.com/abc.png")).toBe(false);
38
+ });
39
+
40
+ });
41
+
42
+ describe("getFileExtensionFromUrl", () => {
43
+
44
+ it("should extract the extension of a plain URL", () => {
45
+ expect(getFileExtensionFromUrl("https://abc.com/abc.png")).toBe("png");
46
+ });
47
+
48
+ it("should lowercase the extension", () => {
49
+ expect(getFileExtensionFromUrl("https://abc.com/ABC.PNG")).toBe("png");
50
+ });
51
+
52
+ it("should ignore query strings and hash fragments", () => {
53
+ expect(getFileExtensionFromUrl("https://abc.com/abc.png?width=200")).toBe("png");
54
+ expect(getFileExtensionFromUrl("https://abc.com/abc.png#anchor")).toBe("png");
55
+ expect(getFileExtensionFromUrl("https://firebasestorage.googleapis.com/v0/b/demo.appspot.com/o/images%2Fcat.jpeg?alt=media&token=abc")).toBe("jpeg");
56
+ });
57
+
58
+ it("should work with storage paths", () => {
59
+ expect(getFileExtensionFromUrl("images/cat.webp")).toBe("webp");
60
+ });
61
+
62
+ it("should use the last dot of the file name", () => {
63
+ expect(getFileExtensionFromUrl("https://abc.com/archive.tar.gz")).toBe("gz");
64
+ });
65
+
66
+ it("should return undefined when there is no extension", () => {
67
+ expect(getFileExtensionFromUrl("https://abc.com/abc")).toBeUndefined();
68
+ expect(getFileExtensionFromUrl("https://abc.com/")).toBeUndefined();
69
+ expect(getFileExtensionFromUrl("images/cat")).toBeUndefined();
70
+ });
71
+
72
+ it("should not take dots of the host or the path as extensions", () => {
73
+ expect(getFileExtensionFromUrl("https://abc.com/images/cat")).toBeUndefined();
74
+ expect(getFileExtensionFromUrl("https://abc.com/abc.png/download")).toBeUndefined();
75
+ });
76
+
77
+ it("should return undefined for dotfiles and trailing dots", () => {
78
+ expect(getFileExtensionFromUrl("https://abc.com/.gitignore")).toBeUndefined();
79
+ expect(getFileExtensionFromUrl("https://abc.com/abc.")).toBeUndefined();
80
+ });
81
+
82
+ it("should handle empty values", () => {
83
+ expect(getFileExtensionFromUrl("")).toBeUndefined();
84
+ expect(getFileExtensionFromUrl(undefined)).toBeUndefined();
85
+ expect(getFileExtensionFromUrl(null)).toBeUndefined();
86
+ });
87
+
88
+ });
89
+
90
+ describe("getPreviewTypeFromUrl", () => {
91
+
92
+ it("should infer image previews", () => {
93
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.png")).toBe("image");
94
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.jpg")).toBe("image");
95
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.jpeg")).toBe("image");
96
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.gif")).toBe("image");
97
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.webp")).toBe("image");
98
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.svg")).toBe("image");
99
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.avif")).toBe("image");
100
+ });
101
+
102
+ it("should infer video previews", () => {
103
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.mp4")).toBe("video");
104
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.webm")).toBe("video");
105
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.mov")).toBe("video");
106
+ });
107
+
108
+ it("should infer audio previews", () => {
109
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.mp3")).toBe("audio");
110
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.wav")).toBe("audio");
111
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.m4a")).toBe("audio");
112
+ });
113
+
114
+ it("should infer the type of Firebase Storage download URLs", () => {
115
+ expect(getPreviewTypeFromUrl("https://firebasestorage.googleapis.com/v0/b/demo.appspot.com/o/images%2Fcat.png?alt=media&token=abc")).toBe("image");
116
+ });
117
+
118
+ it("should be case insensitive", () => {
119
+ expect(getPreviewTypeFromUrl("https://abc.com/ABC.PNG")).toBe("image");
120
+ });
121
+
122
+ it("should return undefined for unknown or missing extensions", () => {
123
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.pdf")).toBeUndefined();
124
+ expect(getPreviewTypeFromUrl("https://abc.com/abc.zip")).toBeUndefined();
125
+ expect(getPreviewTypeFromUrl("https://abc.com/abc")).toBeUndefined();
126
+ expect(getPreviewTypeFromUrl(undefined)).toBeUndefined();
127
+ });
128
+
129
+ });
130
+
131
+ });
@@ -117,7 +117,7 @@ export const applyPermissionsFunctionIfEmpty = (collections: EntityCollection[],
117
117
  }
118
118
 
119
119
  export function getLocalChangesBackup(collection: EntityCollection) {
120
- if (!collection.localChangesBackup) {
120
+ if (collection.localChangesBackup === undefined) {
121
121
  return "manual_apply";
122
122
  }
123
123
 
@@ -75,6 +75,8 @@ export function getDefaultValueForDataType(dataType: DataType) {
75
75
  return [];
76
76
  } else if (dataType === "map") {
77
77
  return {};
78
+ } else if (dataType === "geopoint") {
79
+ return null;
78
80
  } else {
79
81
  return null;
80
82
  }
@@ -137,7 +139,12 @@ export function sanitizeData<M extends Record<string, any>>
137
139
  }
138
140
 
139
141
  export function getReferenceFrom<M extends Record<string, any>>(entity: Entity<M>): EntityReference {
140
- return new EntityReference(entity.id, entity.path, entity.databaseId);
142
+ if (!entity) {
143
+ throw new Error("getReferenceFrom: entity is null or undefined");
144
+ }
145
+ // Carry the segments the entity was loaded with, so a reference to an entity under a
146
+ // slash-bearing parent stays resolvable. Undefined when the entity does not have them.
147
+ return new EntityReference(entity.id, entity.path, entity.databaseId, entity.pathSegments);
141
148
  }
142
149
 
143
150
  export function traverseValuesProperties<M extends Record<string, any>>(
@@ -196,3 +203,11 @@ export function traverseValueProperty(inputValue: any,
196
203
 
197
204
  return value;
198
205
  }
206
+
207
+ export function isDataTypeFilterable(dataType: DataType, isPartOfArray = false) {
208
+ if (isPartOfArray) {
209
+ return ["string", "number", "date", "reference"].includes(dataType);
210
+ }
211
+
212
+ return ["string", "number", "boolean", "date", "reference", "array"].includes(dataType);
213
+ }
@@ -0,0 +1,81 @@
1
+ import { GeoPoint } from "../types";
2
+
3
+ export type GeoPointLike = GeoPoint | {
4
+ latitude?: number;
5
+ longitude?: number;
6
+ lat?: number;
7
+ lng?: number;
8
+ _lat?: number;
9
+ _long?: number;
10
+ } | null | undefined;
11
+
12
+ export interface GeoPointCoordinates {
13
+ latitude: number;
14
+ longitude: number;
15
+ }
16
+
17
+ function toNumber(value: any): number | undefined {
18
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
19
+ }
20
+
21
+ export function getGeoPointCoordinates(value: GeoPointLike): GeoPointCoordinates | undefined {
22
+ if (!value) return undefined;
23
+ if (value instanceof GeoPoint) {
24
+ return { latitude: value.latitude, longitude: value.longitude };
25
+ }
26
+ if (typeof value !== "object") return undefined;
27
+
28
+ const latitude = toNumber((value as any).latitude ?? (value as any).lat ?? (value as any)._lat);
29
+ const longitude = toNumber((value as any).longitude ?? (value as any).lng ?? (value as any)._long);
30
+
31
+ if (latitude === undefined || longitude === undefined) return undefined;
32
+
33
+ return { latitude, longitude };
34
+ }
35
+
36
+ export function normalizeGeoPoint(value: GeoPointLike): GeoPoint | undefined {
37
+ const coordinates = getGeoPointCoordinates(value);
38
+ if (!coordinates) return undefined;
39
+ if (value instanceof GeoPoint) return value;
40
+ return new GeoPoint(coordinates.latitude, coordinates.longitude);
41
+ }
42
+
43
+ export function formatGeoPoint(value: GeoPointLike, options?: { maximumFractionDigits?: number }): string {
44
+ const coordinates = getGeoPointCoordinates(value);
45
+ if (!coordinates) return "";
46
+
47
+ const maximumFractionDigits = options?.maximumFractionDigits ?? 6;
48
+ // Coordinates are always formatted with a dot as decimal separator, independently of the
49
+ // user locale. Locale-aware formatting would use a comma in locales such as de or fr,
50
+ // making the "lat, lng" output ambiguous and impossible to parse back.
51
+ const formatter = new Intl.NumberFormat("en-US", {
52
+ maximumFractionDigits,
53
+ minimumFractionDigits: Math.min(2, maximumFractionDigits),
54
+ useGrouping: false
55
+ });
56
+
57
+ return `${formatter.format(coordinates.latitude)}, ${formatter.format(coordinates.longitude)}`;
58
+ }
59
+
60
+ export function parseGeoPoint(input: string): { point: GeoPoint | null; error?: string } {
61
+ const trimmed = input.trim();
62
+ if (!trimmed) return { point: null };
63
+
64
+ const parts = trimmed.split(",").map((part) => part.trim()).filter((part) => part !== "");
65
+ if (parts.length !== 2) return { point: null, error: "Use \"lat, lng\" format" };
66
+
67
+ const latitude = parseFloat(parts[0]);
68
+ const longitude = parseFloat(parts[1]);
69
+
70
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
71
+ return { point: null, error: "Latitude and longitude must be numbers" };
72
+ }
73
+ if (latitude < -90 || latitude > 90) {
74
+ return { point: null, error: "Latitude must be between -90 and 90" };
75
+ }
76
+ if (longitude < -180 || longitude > 180) {
77
+ return { point: null, error: "Longitude must be between -180 and 180" };
78
+ }
79
+
80
+ return { point: new GeoPoint(latitude, longitude) };
81
+ }
package/src/util/index.ts CHANGED
@@ -6,12 +6,14 @@ export * from "./enums";
6
6
  export * from "./objects";
7
7
  export * from "./paths";
8
8
  export * from "./regexp";
9
+ export * from "./urls";
9
10
  export * from "./navigation_utils";
10
11
  export * from "./entity_actions";
11
12
  export * from "./useDebouncedCallback";
12
13
  export * from "./property_utils";
13
14
  export * from "./resolutions";
14
15
  export * from "./permissions";
16
+ export * from "./geopoint";
15
17
  export * from "./icon_list";
16
18
  export * from "./icon_synonyms";
17
19
  export * from "./icons";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure predicate deciding whether an in-progress navigation away from an open
3
+ * entity form should be blocked (to warn about unsaved changes).
4
+ *
5
+ * Extracted from FireCMSRoute so the branching can be unit-tested without
6
+ * mounting the router. This is an internal helper — it is intentionally not
7
+ * re-exported from the package index.
8
+ */
9
+ export interface NavigationBlockLocation {
10
+ pathname: string;
11
+ hash: string;
12
+ }
13
+
14
+ const SIDE_PANEL_HASHES = ["#side", "#new_side"];
15
+
16
+ export function shouldBlockEntityNavigation(params: {
17
+ currentLocation: NavigationBlockLocation;
18
+ nextLocation: NavigationBlockLocation;
19
+ /** Path of the currently open entity (basePath + "/" + entityId). */
20
+ entityPath: string;
21
+ /** Collection path the entity form lives under. */
22
+ basePath: string;
23
+ /** Whether the form currently has unsaved modifications. */
24
+ blocked: boolean;
25
+ }): boolean {
26
+ const { currentLocation, nextLocation, entityPath, basePath, blocked } = params;
27
+
28
+ // Navigating deeper within the same entity — never block.
29
+ if (nextLocation.pathname.startsWith(entityPath))
30
+ return false;
31
+
32
+ // Side panel overlay navigations preserve the underlying form via
33
+ // base_location in router state — no data is lost in either direction.
34
+
35
+ // Opening a side panel (e.g. clicking a relation/reference arrow).
36
+ if (SIDE_PANEL_HASHES.includes(nextLocation.hash))
37
+ return false;
38
+
39
+ // Closing a side panel (navigate(-1) back to the form's own path).
40
+ if (SIDE_PANEL_HASHES.includes(currentLocation.hash) &&
41
+ nextLocation.pathname === basePath)
42
+ return false;
43
+
44
+ return blocked;
45
+ }
@@ -1,5 +1,5 @@
1
1
  import { EntityCollection, EntityCustomView } from "../types";
2
- import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
2
+ import { decodeEntityId, getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
3
3
  import { resolveEntityView } from "./resolutions";
4
4
 
5
5
  export type NavigationViewInternal<M extends Record<string, any> = any> =
@@ -13,6 +13,8 @@ export interface NavigationViewEntityInternal<M extends Record<string, any>> {
13
13
  path: string;
14
14
  fullIdPath: string;
15
15
  fullPath: string;
16
+ /** `path` split at its real boundaries — see `pathSegments` on the datasource props. */
17
+ pathSegments: string[];
16
18
  parentCollection: EntityCollection<M>;
17
19
  }
18
20
 
@@ -22,6 +24,8 @@ export interface NavigationViewCollectionInternal<M extends Record<string, any>>
22
24
  path: string;
23
25
  fullIdPath: string;
24
26
  fullPath: string;
27
+ /** `path` split at its real boundaries — see `pathSegments` on the datasource props. */
28
+ pathSegments: string[];
25
29
  collection: EntityCollection<M>;
26
30
  }
27
31
 
@@ -30,15 +34,28 @@ export interface NavigationViewEntityCustomInternal<M extends Record<string, any
30
34
  path: string;
31
35
  fullIdPath: string;
32
36
  fullPath: string;
37
+ /** `path` split at its real boundaries — see `pathSegments` on the datasource props. */
38
+ pathSegments: string[];
33
39
  entityId: string;
34
40
  view: EntityCustomView<M>;
35
41
  }
36
42
 
43
+ /**
44
+ * Note on encoding: `path` arrives from the URL, so any entity id in it is escaped (see
45
+ * `encodeEntityId`). Two chains are threaded through the recursion:
46
+ *
47
+ * - `currentFullPath` carries RAW ids and feeds the `path` / `fullPath` used to address
48
+ * the datasource.
49
+ * - `currentFullUrlPath` carries ESCAPED ids and feeds `fullPath`, which is handed back
50
+ * to `buildUrlCollectionPath` for breadcrumbs and links.
51
+ */
37
52
  export function getNavigationEntriesFromPath(props: {
38
53
  path: string,
39
54
  collections: EntityCollection[] | undefined,
40
55
  currentFullPath?: string,
41
56
  currentFullIdPath?: string,
57
+ currentFullUrlPath?: string,
58
+ currentPathSegments?: string[],
42
59
  contextEntityViews?: EntityCustomView<any>[]
43
60
  }): NavigationViewInternal [] {
44
61
 
@@ -46,7 +63,9 @@ export function getNavigationEntriesFromPath(props: {
46
63
  path,
47
64
  collections = [],
48
65
  currentFullPath,
49
- currentFullIdPath
66
+ currentFullIdPath,
67
+ currentFullUrlPath,
68
+ currentPathSegments = []
50
69
  } = props;
51
70
 
52
71
  const subpaths = removeInitialAndTrailingSlashes(path).split("/");
@@ -66,28 +85,41 @@ export function getNavigationEntriesFromPath(props: {
66
85
  const collectionPath = currentFullPath && currentFullPath.length > 0
67
86
  ? (currentFullPath + "/" + collection.path)
68
87
  : collection.path;
88
+ const collectionUrlPath = currentFullUrlPath && currentFullUrlPath.length > 0
89
+ ? (currentFullUrlPath + "/" + collection.path)
90
+ : collection.path;
69
91
  const fullIdPath = currentFullIdPath && currentFullIdPath.length > 0
70
92
  ? (currentFullIdPath + "/" + collection.id)
71
93
  : collection.id;
94
+ // A collection `path` may itself span several segments (e.g. "users/uid/experiences"),
95
+ // and those are unambiguous, so they are spread rather than kept whole.
96
+ const collectionSegments = [...currentPathSegments, ...collection.path.split("/")];
72
97
  result.push({
73
98
  type: "collection",
74
99
  id: collection.id,
75
100
  path: collectionPath,
76
- fullPath: collectionPath,
101
+ fullPath: collectionUrlPath,
77
102
  fullIdPath,
103
+ pathSegments: collectionSegments,
78
104
  collection
79
105
  });
80
106
  const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
81
107
  const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
82
108
  if (nextSegments.length > 0) {
83
- const entityId = nextSegments[0];
109
+ const encodedEntityId = nextSegments[0];
110
+ const entityId = decodeEntityId(encodedEntityId);
84
111
  const fullPath = collectionPath + "/" + entityId;
112
+ const fullUrlPath = collectionUrlPath + "/" + encodedEntityId;
113
+ // The id is ONE segment however many slashes it contains — that is the
114
+ // whole point of this array.
115
+ const entitySegments = [...collectionSegments, entityId];
85
116
  result.push({
86
117
  type: "entity",
87
118
  entityId,
88
119
  path: collectionPath,
89
120
  fullIdPath,
90
- fullPath: fullPath,
121
+ fullPath: fullUrlPath,
122
+ pathSegments: collectionSegments,
91
123
  parentCollection: collection
92
124
  });
93
125
  if (nextSegments.length > 1) {
@@ -106,7 +138,8 @@ export function getNavigationEntriesFromPath(props: {
106
138
  path: collectionPath,
107
139
  entityId: entityId,
108
140
  fullIdPath,
109
- fullPath: fullPath + "/" + customView.key,
141
+ fullPath: fullUrlPath + "/" + customView.key,
142
+ pathSegments: collectionSegments,
110
143
  view: customView
111
144
  });
112
145
  } else if (collection.subcollections) {
@@ -115,6 +148,8 @@ export function getNavigationEntriesFromPath(props: {
115
148
  collections: collection.subcollections,
116
149
  currentFullPath: fullPath,
117
150
  currentFullIdPath: fullIdPath,
151
+ currentFullUrlPath: fullUrlPath,
152
+ currentPathSegments: entitySegments,
118
153
  contextEntityViews: props.contextEntityViews
119
154
  }));
120
155
  }
@@ -22,6 +22,40 @@ export function addInitialSlash(s: string) {
22
22
  else return `/${s}`;
23
23
  }
24
24
 
25
+ /**
26
+ * Characters that would otherwise be read as structure once an entity id is joined into a
27
+ * path: "/" separates segments, "?" and "#" start the query and hash in a URL, and "%"
28
+ * has to be escaped first so that no escape sequence we introduce can be misread as one
29
+ * that was already part of the id.
30
+ *
31
+ * Entity ids are escaped ONLY inside URL-facing strings — anything handed to
32
+ * `buildUrlCollectionPath` or `navigate`. Every other path string (the `path` field of a
33
+ * navigation entry, datasource paths, `EntityReference.path`) carries raw ids.
34
+ *
35
+ * @group Hooks and utilities
36
+ */
37
+ export function encodeEntityId(entityId: string): string {
38
+ return entityId
39
+ .replaceAll("%", "%25")
40
+ .replaceAll("/", "%2F")
41
+ .replaceAll("#", "%23")
42
+ .replaceAll("?", "%3F");
43
+ }
44
+
45
+ /**
46
+ * Inverse of {@link encodeEntityId}. "%25" is undone last, mirroring the encoder, so an id
47
+ * that legitimately contains the text "%2F" survives the round trip.
48
+ *
49
+ * @group Hooks and utilities
50
+ */
51
+ export function decodeEntityId(encodedEntityId: string): string {
52
+ return encodedEntityId
53
+ .replaceAll("%2F", "/")
54
+ .replaceAll("%23", "#")
55
+ .replaceAll("%3F", "?")
56
+ .replaceAll("%25", "%");
57
+ }
58
+
25
59
  export function getLastSegment(path: string) {
26
60
  const cleanPath = removeInitialAndTrailingSlashes(path);
27
61
  if (cleanPath.includes("/")) {
@@ -160,7 +194,7 @@ export function getCollectionByPathOrId(pathOrId: string, collections: EntityCol
160
194
  * @param subpaths
161
195
  */
162
196
  export function getCollectionPathsCombinations(subpaths: string[]): string[] {
163
- const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.splice(0, subpaths.length - 1) : subpaths;
197
+ const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.slice(0, subpaths.length - 1) : subpaths;
164
198
 
165
199
  const length = entries.length;
166
200
  const result: string[] = [];
@@ -178,6 +212,7 @@ export function navigateToEntity({
178
212
  copy,
179
213
  path,
180
214
  fullIdPath,
215
+ pathSegments,
181
216
  selectedTab,
182
217
  sideEntityController,
183
218
  onClose,
@@ -192,6 +227,8 @@ export function navigateToEntity({
192
227
  copy?: boolean;
193
228
  path: string;
194
229
  fullIdPath?: string;
230
+ /** `path` split at its real segment boundaries. */
231
+ pathSegments?: string[];
195
232
  sideEntityController: SideEntityController;
196
233
  onClose?: () => void;
197
234
  navigation: NavigationController
@@ -203,6 +240,7 @@ export function navigateToEntity({
203
240
  entityId,
204
241
  path,
205
242
  fullIdPath,
243
+ pathSegments,
206
244
  copy,
207
245
  selectedTab,
208
246
  collection,
@@ -211,7 +249,7 @@ export function navigateToEntity({
211
249
  });
212
250
 
213
251
  } else {
214
- let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${entityId}` : fullIdPath ?? path);
252
+ let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${encodeEntityId(entityId)}` : fullIdPath ?? path);
215
253
  if (entityId && selectedTab) {
216
254
  to += `/${selectedTab}`;
217
255
  }
@@ -1,16 +1,18 @@
1
1
  import { EntityCollection, EntityReference } from "../types";
2
- import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
2
+ import { decodeEntityId, getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
3
3
 
4
4
  export function getParentReferencesFromPath(props: {
5
5
  path: string,
6
6
  collections: EntityCollection[] | undefined,
7
7
  currentFullPath?: string,
8
+ currentPathSegments?: string[],
8
9
  }): EntityReference [] {
9
10
 
10
11
  const {
11
12
  path,
12
13
  collections = [],
13
14
  currentFullPath,
15
+ currentPathSegments = [],
14
16
  } = props;
15
17
 
16
18
  const subpaths = removeInitialAndTrailingSlashes(path).split("/");
@@ -27,13 +29,20 @@ export function getParentReferencesFromPath(props: {
27
29
  const collectionPath = currentFullPath && currentFullPath.length > 0
28
30
  ? (currentFullPath + "/" + collection.path)
29
31
  : collection.path;
32
+ // A collection's own path may span several segments, and those are
33
+ // unambiguous, so they are spread rather than kept whole.
34
+ const collectionSegments = [...currentPathSegments, ...collection.path.split("/")];
30
35
 
31
36
  const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
32
37
  const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
33
38
  if (nextSegments.length > 0) {
34
- const entityId = nextSegments[0];
39
+ // `path` comes from the URL, so the id segment is escaped. References are
40
+ // datasource-facing, so they carry the raw id.
41
+ const entityId = decodeEntityId(nextSegments[0]);
35
42
  const fullPath = collectionPath + "/" + entityId;
36
- result.push(new EntityReference(entityId, collectionPath));
43
+ // The id is ONE segment however many slashes it contains.
44
+ const entitySegments = [...collectionSegments, entityId];
45
+ result.push(new EntityReference(entityId, collectionPath, undefined, collectionSegments));
37
46
  if (nextSegments.length > 1) {
38
47
  const newPath = nextSegments.slice(1).join("/");
39
48
  if (!collection) {
@@ -43,7 +52,8 @@ export function getParentReferencesFromPath(props: {
43
52
  result.push(...getParentReferencesFromPath({
44
53
  path: newPath,
45
54
  collections: collection.subcollections,
46
- currentFullPath: fullPath
55
+ currentFullPath: fullPath,
56
+ currentPathSegments: entitySegments
47
57
  }));
48
58
  }
49
59
  }
@@ -1,4 +1,4 @@
1
- import { AuthController, EntityCollection, Property, PropertyConfig, ResolvedEntityCollection } from "../types";
1
+ import { AuthController, EntityCollection, Property, PropertyConfig, ResolvedEntityCollection, ResolvedProperties } from "../types";
2
2
  import { isReferenceProperty } from "./property_utils";
3
3
  import { isPropertyBuilder } from "./entities";
4
4
  import { getFieldConfig } from "../core";
@@ -44,6 +44,13 @@ export function getEntityTitlePropertyKey<M extends Record<string, any>>(collect
44
44
 
45
45
  export function getEntityImagePreviewPropertyKey<M extends object>(collection: ResolvedEntityCollection<M>): string | undefined {
46
46
 
47
+ if (collection.imageProperty) {
48
+ const imagePropertyKey = collection.imageProperty as keyof ResolvedProperties<M>;
49
+ if (collection.properties[imagePropertyKey]) {
50
+ return collection.imageProperty as string;
51
+ }
52
+ }
53
+
47
54
  // find first storage property of type image
48
55
  for (const key in collection.properties) {
49
56
  const property = collection.properties[key];
@@ -305,7 +305,15 @@ export function resolveArrayProperty<T extends any[], M>({
305
305
  ignoreMissingFields,
306
306
  ...props
307
307
  });
308
+ // `of` describes the shape of any element of the array, not one
309
+ // specific element, so there is no index to qualify the key with.
310
+ // We pass the array's own key, mirroring what the `oneOf` branch
311
+ // below does for `oneOf.properties`. Without this, a property
312
+ // builder used as `of` (or nested inside it) would be invoked with
313
+ // `propertyKey: undefined`, while the per-index resolutions above
314
+ // receive `${propertyKey}.${index}`.
308
315
  const ofProperty = resolveProperty({
316
+ propertyKey,
309
317
  propertyOrBuilder: of,
310
318
  ignoreMissingFields,
311
319
  ...props