@firecms/core 3.3.0-canary.d3242eb → 3.4.0-canary.00f9be5

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 (221) hide show
  1. package/README.md +4 -0
  2. package/dist/app/useApp.d.ts +1 -0
  3. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  4. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  5. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  8. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  9. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  10. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  11. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  12. package/dist/components/EntityPreview.d.ts +3 -1
  13. package/dist/components/ErrorBoundary.d.ts +1 -3
  14. package/dist/components/LicenseBanner.d.ts +23 -0
  15. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  16. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  17. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  18. package/dist/components/common/table_url_params.d.ts +24 -0
  19. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  20. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  21. package/dist/components/index.d.ts +1 -0
  22. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  23. package/dist/core/DefaultDrawer.d.ts +10 -3
  24. package/dist/core/EntityEditView.d.ts +9 -1
  25. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  26. package/dist/core/field_configs.d.ts +1 -1
  27. package/dist/core/index.d.ts +1 -0
  28. package/dist/core/plugin_translations.d.ts +38 -0
  29. package/dist/core/pro_plugins.d.ts +40 -0
  30. package/dist/de-BF5vcQPY.js +703 -0
  31. package/dist/de-BF5vcQPY.js.map +1 -0
  32. package/dist/editor/extensions/Image.d.ts +11 -0
  33. package/dist/editor/index.d.ts +2 -1
  34. package/dist/editor/lazy_editor.d.ts +6 -0
  35. package/dist/editor-BSo_aS1I.js +4563 -0
  36. package/dist/editor-BSo_aS1I.js.map +1 -0
  37. package/dist/form/EntityForm.d.ts +8 -1
  38. package/dist/form/EntityFormActions.d.ts +2 -0
  39. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  40. package/dist/form/index.d.ts +1 -0
  41. package/dist/fr-DH9_wyFI.js +703 -0
  42. package/dist/fr-DH9_wyFI.js.map +1 -0
  43. package/dist/hi-BM8dm1Lu.js +703 -0
  44. package/dist/hi-BM8dm1Lu.js.map +1 -0
  45. package/dist/hooks/data/delete.d.ts +1 -1
  46. package/dist/hooks/data/save.d.ts +1 -1
  47. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  48. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  49. package/dist/hooks/index.d.ts +2 -0
  50. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  51. package/dist/hooks/useProjectLog.d.ts +11 -6
  52. package/dist/hooks/useTranslation.d.ts +1 -1
  53. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  54. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  55. package/dist/index.es.js +4429 -10479
  56. package/dist/index.es.js.map +1 -1
  57. package/dist/index.umd.js +9313 -6582
  58. package/dist/index.umd.js.map +1 -1
  59. package/dist/it-D3K1gfe4.js +703 -0
  60. package/dist/it-D3K1gfe4.js.map +1 -0
  61. package/dist/locales/pl.d.ts +2 -0
  62. package/dist/pl-CP1zeLzn.js +705 -0
  63. package/dist/pl-CP1zeLzn.js.map +1 -0
  64. package/dist/preview/index.d.ts +1 -0
  65. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  66. package/dist/pt-Cus7eKQR.js +705 -0
  67. package/dist/pt-Cus7eKQR.js.map +1 -0
  68. package/dist/types/analytics.d.ts +1 -1
  69. package/dist/types/collections.d.ts +31 -0
  70. package/dist/types/datasource.d.ts +67 -4
  71. package/dist/types/entities.d.ts +21 -1
  72. package/dist/types/entity_actions.d.ts +6 -0
  73. package/dist/types/entity_callbacks.d.ts +21 -0
  74. package/dist/types/firecms.d.ts +18 -1
  75. package/dist/types/firecms_context.d.ts +8 -0
  76. package/dist/types/index.d.ts +1 -0
  77. package/dist/types/license.d.ts +55 -0
  78. package/dist/types/navigation.d.ts +34 -5
  79. package/dist/types/plugins.d.ts +22 -1
  80. package/dist/types/properties.d.ts +17 -1
  81. package/dist/types/side_entity_controller.d.ts +7 -0
  82. package/dist/types/translations.d.ts +54 -5
  83. package/dist/util/__tests__/collections.test.d.ts +1 -0
  84. package/dist/util/__tests__/urls.test.d.ts +1 -0
  85. package/dist/util/collections.d.ts +1 -1
  86. package/dist/util/date_fns_locales.d.ts +18 -0
  87. package/dist/util/entities.d.ts +1 -0
  88. package/dist/util/entity_cache.d.ts +11 -0
  89. package/dist/util/geopoint.d.ts +22 -0
  90. package/dist/util/index.d.ts +3 -0
  91. package/dist/util/lazy_eager.d.ts +3 -3
  92. package/dist/util/navigation_blocking.d.ts +22 -0
  93. package/dist/util/navigation_from_path.d.ts +17 -0
  94. package/dist/util/navigation_utils.d.ts +107 -3
  95. package/dist/util/parent_references_from_path.d.ts +16 -0
  96. package/dist/util/paths.d.ts +28 -0
  97. package/dist/util/permissions.d.ts +10 -4
  98. package/dist/util/urls.d.ts +22 -0
  99. package/package.json +17 -11
  100. package/src/app/Scaffold.tsx +54 -45
  101. package/src/app/useApp.tsx +1 -0
  102. package/src/components/DeleteEntityDialog.tsx +2 -0
  103. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  104. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  105. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  106. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  107. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  108. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  109. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  110. package/src/components/EntityCollectionView/EntityCollectionView.tsx +71 -17
  111. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  112. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +8 -1
  113. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  114. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  115. package/src/components/EntityPreview.tsx +45 -41
  116. package/src/components/ErrorBoundary.tsx +12 -18
  117. package/src/components/HomePage/NavigationCardBinding.tsx +6 -3
  118. package/src/components/LicenseBanner.tsx +207 -0
  119. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  120. package/src/components/ReferenceWidget.tsx +1 -1
  121. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  122. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  123. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  124. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  125. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  126. package/src/components/common/default_entity_actions.tsx +22 -2
  127. package/src/components/common/table_url_params.ts +172 -0
  128. package/src/components/common/useDataSourceTableController.tsx +126 -163
  129. package/src/components/common/useTableSearchHelper.ts +26 -2
  130. package/src/components/index.tsx +1 -0
  131. package/src/contexts/LicenseStatusContext.tsx +50 -0
  132. package/src/contexts/SnackbarProvider.tsx +14 -1
  133. package/src/core/DefaultAppBar.tsx +28 -16
  134. package/src/core/DefaultDrawer.tsx +151 -65
  135. package/src/core/DrawerNavigationGroup.tsx +27 -29
  136. package/src/core/DrawerNavigationItem.tsx +10 -12
  137. package/src/core/EntityEditView.tsx +77 -35
  138. package/src/core/EntityEditViewFormActions.tsx +3 -2
  139. package/src/core/EntitySidePanel.tsx +8 -4
  140. package/src/core/FireCMS.tsx +73 -47
  141. package/src/core/SideDialogs.tsx +44 -1
  142. package/src/core/field_configs.tsx +15 -0
  143. package/src/core/index.tsx +1 -0
  144. package/src/core/plugin_translations.ts +87 -0
  145. package/src/core/pro_plugins.ts +79 -0
  146. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  147. package/src/editor/extensions/Image.ts +17 -1
  148. package/src/editor/hooks/useProseMirror.ts +5 -0
  149. package/src/editor/index.ts +11 -1
  150. package/src/editor/lazy_editor.tsx +33 -0
  151. package/src/form/EntityForm.tsx +31 -10
  152. package/src/form/EntityFormActions.tsx +2 -0
  153. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  154. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  155. package/src/form/index.tsx +1 -0
  156. package/src/hooks/data/delete.ts +8 -0
  157. package/src/hooks/data/save.ts +6 -1
  158. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  159. package/src/hooks/data/useEntityFetch.tsx +35 -4
  160. package/src/hooks/index.tsx +2 -0
  161. package/src/hooks/useBuildNavigationController.tsx +75 -16
  162. package/src/hooks/useFireCMSContext.tsx +7 -3
  163. package/src/hooks/useLicenseStatus.tsx +32 -0
  164. package/src/hooks/useProjectLog.tsx +27 -9
  165. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  166. package/src/hooks/useTranslation.ts +3 -1
  167. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  168. package/src/i18n/FireCMSi18nProvider.tsx +199 -23
  169. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  170. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  171. package/src/internal/useBuildDataSource.ts +37 -15
  172. package/src/internal/useBuildSideEntityController.tsx +20 -5
  173. package/src/locales/de.ts +37 -7
  174. package/src/locales/en.ts +41 -9
  175. package/src/locales/es.ts +37 -7
  176. package/src/locales/fr.ts +37 -7
  177. package/src/locales/hi.ts +37 -7
  178. package/src/locales/it.ts +37 -7
  179. package/src/locales/pl.ts +752 -0
  180. package/src/locales/pt.ts +37 -7
  181. package/src/preview/PropertyPreview.tsx +12 -0
  182. package/src/preview/components/DatePreview.tsx +2 -4
  183. package/src/preview/components/ReferencePreview.tsx +7 -3
  184. package/src/preview/components/StorageThumbnail.tsx +24 -2
  185. package/src/preview/index.ts +1 -0
  186. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  187. package/src/routes/FireCMSRoute.tsx +54 -27
  188. package/src/types/analytics.ts +3 -0
  189. package/src/types/collections.ts +34 -0
  190. package/src/types/datasource.ts +67 -4
  191. package/src/types/entities.ts +24 -1
  192. package/src/types/entity_actions.tsx +6 -0
  193. package/src/types/entity_callbacks.ts +24 -0
  194. package/src/types/firecms.tsx +19 -1
  195. package/src/types/firecms_context.tsx +9 -0
  196. package/src/types/index.ts +1 -0
  197. package/src/types/license.ts +63 -0
  198. package/src/types/navigation.ts +35 -5
  199. package/src/types/plugins.tsx +23 -1
  200. package/src/types/properties.ts +17 -0
  201. package/src/types/side_entity_controller.tsx +7 -0
  202. package/src/types/translations.ts +54 -5
  203. package/src/util/__tests__/collections.test.ts +32 -0
  204. package/src/util/__tests__/urls.test.ts +131 -0
  205. package/src/util/collections.ts +1 -1
  206. package/src/util/date_fns_locales.ts +168 -0
  207. package/src/util/entities.ts +16 -1
  208. package/src/util/entity_cache.ts +18 -0
  209. package/src/util/geopoint.ts +81 -0
  210. package/src/util/index.ts +3 -0
  211. package/src/util/lazy_eager.tsx +30 -8
  212. package/src/util/navigation_blocking.ts +45 -0
  213. package/src/util/navigation_from_path.ts +47 -7
  214. package/src/util/navigation_utils.ts +250 -5
  215. package/src/util/parent_references_from_path.ts +45 -4
  216. package/src/util/paths.ts +40 -3
  217. package/src/util/permissions.ts +22 -10
  218. package/src/util/previews.ts +8 -1
  219. package/src/util/resolutions.ts +11 -0
  220. package/src/util/urls.ts +52 -0
  221. package/src/util/useStorageUploadController.tsx +6 -1
@@ -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) {
@@ -114,7 +147,14 @@ export function getNavigationEntriesFromPath(props: {
114
147
  path: newPath,
115
148
  collections: collection.subcollections,
116
149
  currentFullPath: fullPath,
117
- currentFullIdPath: fullIdPath,
150
+ // The entity id is a hop in the id chain exactly as it is in the
151
+ // other two. Without it a nested `fullIdPath` was
152
+ // "products/locales" rather than "products/pid/locales", so any
153
+ // URL built from it pointed at a collection that does not exist.
154
+ // Escaped, because `fullIdPath` is URL-facing.
155
+ currentFullIdPath: fullIdPath + "/" + encodedEntityId,
156
+ currentFullUrlPath: fullUrlPath,
157
+ currentPathSegments: entitySegments,
118
158
  contextEntityViews: props.contextEntityViews
119
159
  }));
120
160
  }
@@ -22,6 +22,64 @@ 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
+
59
+ /**
60
+ * Segments of a subcollection sitting under the entity `entityId` of `parentPathSegments`.
61
+ *
62
+ * The parent entity id is kept whole however many slashes it contains, while the
63
+ * subcollection's own configured path is spread — a collection path never contains an
64
+ * entity id, so splitting that one is unambiguous.
65
+ *
66
+ * So `["test"] + "test/test" + "accommodation"` yields three segments,
67
+ * `["test", "test/test", "accommodation"]`, where splitting the flattened path
68
+ * `"test/test/test/accommodation"` would wrongly yield four.
69
+ *
70
+ * Returns undefined when the parent segments are unknown: the parent chain cannot be
71
+ * recovered from a flattened path, so there is nothing honest to build on.
72
+ *
73
+ * @group Hooks and utilities
74
+ */
75
+ export function buildSubcollectionPathSegments(parentPathSegments: string[] | undefined,
76
+ entityId: string | undefined,
77
+ subcollectionPath: string): string[] | undefined {
78
+ if (!parentPathSegments || !entityId) return undefined;
79
+ const ownSegments = removeInitialAndTrailingSlashes(subcollectionPath).split("/");
80
+ return [...parentPathSegments, entityId, ...ownSegments];
81
+ }
82
+
25
83
  export function getLastSegment(path: string) {
26
84
  const cleanPath = removeInitialAndTrailingSlashes(path);
27
85
  if (cleanPath.includes("/")) {
@@ -31,7 +89,171 @@ export function getLastSegment(path: string) {
31
89
  return cleanPath;
32
90
  }
33
91
 
34
- export function resolveCollectionPathIds(path: string, allCollections: EntityCollection[]): string {
92
+ /**
93
+ * The outcome of walking a segment chain against a collection tree.
94
+ * @group Hooks and utilities
95
+ */
96
+ export type PathSegmentsWalkStep = {
97
+ collection: EntityCollection;
98
+ /** Resolved path of this collection, e.g. `"products/pid/locales"`. */
99
+ collectionPath: string;
100
+ /** `collectionPath` split at its real segment boundaries. */
101
+ collectionSegments: string[];
102
+ /** The entity addressed inside it, when the chain continues past the collection. */
103
+ entityId?: string;
104
+ };
105
+
106
+ export type PathSegmentsWalk = {
107
+ /** Segments with every collection id replaced by the collection's real `path`. */
108
+ resolved: string[];
109
+ /** The collection the chain ends in, if the whole chain matched. */
110
+ collection?: EntityCollection;
111
+ /** One entry per collection level traversed, outermost first. */
112
+ steps: PathSegmentsWalkStep[];
113
+ /** The first segment that could not be matched, if the walk stopped early. */
114
+ unmatched?: string;
115
+ };
116
+
117
+ /**
118
+ * Walk a segment chain against a collection tree, matching a collection by either its
119
+ * `path` or its `id` and treating exactly one segment as each entity id.
120
+ *
121
+ * This is the primitive the string-based helpers cannot have: working on the flattened
122
+ * path they must *guess* where an entity id ends by reading up to the next "/", which is
123
+ * wrong for any id that contains one. Here the boundaries are given.
124
+ *
125
+ * Stops at the first segment it cannot match and reports it, rather than throwing.
126
+ *
127
+ * @group Hooks and utilities
128
+ */
129
+ export function walkPathSegments(pathSegments: string[], allCollections: EntityCollection[]): PathSegmentsWalk {
130
+
131
+ const resolved: string[] = [];
132
+ const steps: PathSegmentsWalkStep[] = [];
133
+ let currentCollections: EntityCollection[] | undefined = allCollections;
134
+ let index = 0;
135
+
136
+ while (index < pathSegments.length) {
137
+
138
+ const remaining = pathSegments.slice(index);
139
+
140
+ if (!currentCollections || currentCollections.length === 0) {
141
+ resolved.push(...remaining);
142
+ return {
143
+ resolved,
144
+ steps,
145
+ unmatched: remaining[0]
146
+ };
147
+ }
148
+
149
+ // A collection's own `path` may span several segments ("users/uid/experiences"), so
150
+ // candidates are matched segment by segment and the longest match wins.
151
+ let match: { collection: EntityCollection, length: number } | undefined;
152
+ for (const collection of currentCollections) {
153
+ for (const candidate of [collection.path, collection.id]) {
154
+ if (!candidate) continue;
155
+ const parts = removeInitialAndTrailingSlashes(candidate).split("/");
156
+ if (parts.length > remaining.length) continue;
157
+ if (parts.some((part, i) => part !== remaining[i])) continue;
158
+ if (!match || parts.length > match.length) match = { collection, length: parts.length };
159
+ }
160
+ }
161
+
162
+ if (!match) {
163
+ resolved.push(...remaining);
164
+ return {
165
+ resolved,
166
+ steps,
167
+ unmatched: remaining[0]
168
+ };
169
+ }
170
+
171
+ resolved.push(...removeInitialAndTrailingSlashes(match.collection.path).split("/"));
172
+ index += match.length;
173
+
174
+ const step: PathSegmentsWalkStep = {
175
+ collection: match.collection,
176
+ collectionPath: resolved.join("/"),
177
+ collectionSegments: [...resolved]
178
+ };
179
+ steps.push(step);
180
+
181
+ // The chain ends on a collection, which is the one being addressed.
182
+ if (index >= pathSegments.length) {
183
+ return {
184
+ resolved,
185
+ steps,
186
+ collection: match.collection
187
+ };
188
+ }
189
+
190
+ // Exactly one segment is the entity id, however many slashes it contains.
191
+ step.entityId = pathSegments[index];
192
+ resolved.push(pathSegments[index]);
193
+ index += 1;
194
+
195
+ // The chain ends on an entity, which lives in the collection just matched.
196
+ if (index >= pathSegments.length) {
197
+ return {
198
+ resolved,
199
+ steps,
200
+ collection: match.collection
201
+ };
202
+ }
203
+
204
+ currentCollections = match.collection.subcollections;
205
+ }
206
+
207
+ return { resolved, steps };
208
+ }
209
+
210
+ /**
211
+ * Resolve collection aliases in a segment array: every collection id is replaced by the
212
+ * collection's real `path`, and every entity id is carried through untouched.
213
+ *
214
+ * The segment-wise counterpart of {@link resolveCollectionPathIds}, and the reason it
215
+ * exists: that one works on the flattened string, so an entity id containing "/" shifts
216
+ * the rest of the chain by a segment and resolution fails with
217
+ * "Collection definition not found for segment starting with …".
218
+ *
219
+ * Matching accepts either a collection's `path` or its `id`, so already-resolved segments
220
+ * pass through unchanged — the function is idempotent.
221
+ *
222
+ * Mirrors the string version when a chain cannot be matched: it warns and carries the
223
+ * remainder through unresolved, rather than throwing.
224
+ *
225
+ * @group Hooks and utilities
226
+ */
227
+ export function resolveCollectionPathSegments(pathSegments: string[], allCollections: EntityCollection[]): string[] {
228
+ const walk = walkPathSegments(pathSegments, allCollections);
229
+ if (walk.unmatched !== undefined) {
230
+ console.warn(`resolveCollectionPathSegments: Collection definition not found for segment "${walk.unmatched}" in [${pathSegments.join(", ")}]. Carrying the remaining segments through unresolved.`);
231
+ }
232
+ return walk.resolved;
233
+ }
234
+
235
+ /**
236
+ * Find the collection a segment chain addresses, whether it ends on the collection itself
237
+ * or on one of its entities.
238
+ *
239
+ * The segment-wise counterpart of {@link getCollectionByPathOrId}, which asserts an odd
240
+ * number of "/"-separated parts — an assertion a slash-bearing entity id fails, throwing
241
+ * where it should simply have resolved.
242
+ *
243
+ * @group Hooks and utilities
244
+ */
245
+ export function getCollectionByPathSegments(pathSegments: string[], collections: EntityCollection[]): EntityCollection | undefined {
246
+ return walkPathSegments(pathSegments, collections).collection;
247
+ }
248
+
249
+ export function resolveCollectionPathIds(path: string, allCollections: EntityCollection[], pathSegments?: string[]): string {
250
+
251
+ // When the caller knows the real segment boundaries there is nothing to parse: the
252
+ // ambiguity the string walk below has to guess its way through simply is not present.
253
+ if (pathSegments) {
254
+ return resolveCollectionPathSegments(pathSegments, allCollections).join("/");
255
+ }
256
+
35
257
  let remainingPath = removeInitialAndTrailingSlashes(path);
36
258
  if (!remainingPath) {
37
259
  return "";
@@ -59,7 +281,12 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
59
281
  col,
60
282
  match: col.id
61
283
  }])
62
- .filter(p => p.match && remainingPath.startsWith(p.match))
284
+ // A match has to end where a path segment ends. Matching any prefix made
285
+ // "products-test" resolve to the "products" collection with an entity called
286
+ // "-test" (FirebaseError: Invalid document reference), and any collection whose
287
+ // path merely starts with another's — "products_archive", which the collection
288
+ // editor generates from "Products archive" — resolve to the wrong one.
289
+ .filter(p => p.match && (remainingPath === p.match || remainingPath.startsWith(p.match + "/")))
63
290
  .sort((a, b) => b.match.length - a.match.length);
64
291
 
65
292
  if (potentialMatches.length > 0) {
@@ -124,7 +351,14 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
124
351
  * @param pathOrId
125
352
  * @param collections
126
353
  */
127
- export function getCollectionByPathOrId(pathOrId: string, collections: EntityCollection[]): EntityCollection | undefined {
354
+ export function getCollectionByPathOrId(pathOrId: string, collections: EntityCollection[], pathSegments?: string[]): EntityCollection | undefined {
355
+
356
+ // Given the real boundaries there is nothing to parse, and no parity to assert: a chain
357
+ // whose entity id contains "/" splits into an even number of parts below and would be
358
+ // rejected outright, even though it is perfectly valid.
359
+ if (pathSegments) {
360
+ return getCollectionByPathSegments(pathSegments, collections);
361
+ }
128
362
 
129
363
  const subpaths = removeInitialAndTrailingSlashes(pathOrId).split("/");
130
364
  if (subpaths.length % 2 === 0) {
@@ -160,7 +394,7 @@ export function getCollectionByPathOrId(pathOrId: string, collections: EntityCol
160
394
  * @param subpaths
161
395
  */
162
396
  export function getCollectionPathsCombinations(subpaths: string[]): string[] {
163
- const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.splice(0, subpaths.length - 1) : subpaths;
397
+ const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.slice(0, subpaths.length - 1) : subpaths;
164
398
 
165
399
  const length = entries.length;
166
400
  const result: string[] = [];
@@ -178,6 +412,7 @@ export function navigateToEntity({
178
412
  copy,
179
413
  path,
180
414
  fullIdPath,
415
+ pathSegments,
181
416
  selectedTab,
182
417
  sideEntityController,
183
418
  onClose,
@@ -192,6 +427,8 @@ export function navigateToEntity({
192
427
  copy?: boolean;
193
428
  path: string;
194
429
  fullIdPath?: string;
430
+ /** `path` split at its real segment boundaries. */
431
+ pathSegments?: string[];
195
432
  sideEntityController: SideEntityController;
196
433
  onClose?: () => void;
197
434
  navigation: NavigationController
@@ -203,6 +440,7 @@ export function navigateToEntity({
203
440
  entityId,
204
441
  path,
205
442
  fullIdPath,
443
+ pathSegments,
206
444
  copy,
207
445
  selectedTab,
208
446
  collection,
@@ -211,7 +449,14 @@ export function navigateToEntity({
211
449
  });
212
450
 
213
451
  } else {
214
- let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${entityId}` : fullIdPath ?? path);
452
+ // A URL must be built from the ESCAPED chain. `fullIdPath` is that chain; `path` is
453
+ // the raw datasource one, and falling back to it is only safe while no id in it
454
+ // contains "/". When the segments show that it does, the resulting URL would address
455
+ // a different entity, so say so instead of navigating somewhere wrong in silence.
456
+ if (!fullIdPath && pathSegments?.some(segment => segment.includes("/"))) {
457
+ console.warn(`navigateToEntity: no "fullIdPath" was given and "${path}" contains an entity id with a "/", so no unambiguous URL can be built for it. The link will point at a different location. Pass "fullIdPath" — the escaped chain — alongside "path".`);
458
+ }
459
+ let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${encodeEntityId(entityId)}` : fullIdPath ?? path);
215
460
  if (entityId && selectedTab) {
216
461
  to += `/${selectedTab}`;
217
462
  }
@@ -1,18 +1,40 @@
1
1
  import { EntityCollection, EntityReference } from "../types";
2
- import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
2
+ import {
3
+ decodeEntityId,
4
+ getCollectionPathsCombinations,
5
+ removeInitialAndTrailingSlashes,
6
+ walkPathSegments
7
+ } from "./navigation_utils";
3
8
 
9
+ /**
10
+ * References to every parent entity in a path.
11
+ *
12
+ * NOTE on encoding: `path` is expected ESCAPED, because it comes from the URL — the entity
13
+ * ids in it are decoded on the way out. Pass `pathSegments` instead whenever the path at
14
+ * hand is a raw datasource path (`entity.path`), which is not escaped and whose ids may
15
+ * therefore contain a bare "/": splitting that would silently produce the wrong references.
16
+ */
4
17
  export function getParentReferencesFromPath(props: {
5
18
  path: string,
6
19
  collections: EntityCollection[] | undefined,
7
20
  currentFullPath?: string,
21
+ currentPathSegments?: string[],
22
+ /** `path` split at its real segment boundaries, with RAW ids. Takes precedence. */
23
+ pathSegments?: string[],
8
24
  }): EntityReference [] {
9
25
 
10
26
  const {
11
27
  path,
12
28
  collections = [],
13
29
  currentFullPath,
30
+ currentPathSegments = [],
31
+ pathSegments,
14
32
  } = props;
15
33
 
34
+ if (pathSegments) {
35
+ return getParentReferencesFromPathSegments(pathSegments, collections);
36
+ }
37
+
16
38
  const subpaths = removeInitialAndTrailingSlashes(path).split("/");
17
39
  const subpathCombinations = getCollectionPathsCombinations(subpaths);
18
40
 
@@ -27,13 +49,20 @@ export function getParentReferencesFromPath(props: {
27
49
  const collectionPath = currentFullPath && currentFullPath.length > 0
28
50
  ? (currentFullPath + "/" + collection.path)
29
51
  : collection.path;
52
+ // A collection's own path may span several segments, and those are
53
+ // unambiguous, so they are spread rather than kept whole.
54
+ const collectionSegments = [...currentPathSegments, ...collection.path.split("/")];
30
55
 
31
56
  const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
32
57
  const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
33
58
  if (nextSegments.length > 0) {
34
- const entityId = nextSegments[0];
59
+ // `path` comes from the URL, so the id segment is escaped. References are
60
+ // datasource-facing, so they carry the raw id.
61
+ const entityId = decodeEntityId(nextSegments[0]);
35
62
  const fullPath = collectionPath + "/" + entityId;
36
- result.push(new EntityReference(entityId, collectionPath));
63
+ // The id is ONE segment however many slashes it contains.
64
+ const entitySegments = [...collectionSegments, entityId];
65
+ result.push(new EntityReference(entityId, collectionPath, undefined, collectionSegments));
37
66
  if (nextSegments.length > 1) {
38
67
  const newPath = nextSegments.slice(1).join("/");
39
68
  if (!collection) {
@@ -43,7 +72,8 @@ export function getParentReferencesFromPath(props: {
43
72
  result.push(...getParentReferencesFromPath({
44
73
  path: newPath,
45
74
  collections: collection.subcollections,
46
- currentFullPath: fullPath
75
+ currentFullPath: fullPath,
76
+ currentPathSegments: entitySegments
47
77
  }));
48
78
  }
49
79
  }
@@ -54,3 +84,14 @@ export function getParentReferencesFromPath(props: {
54
84
  }
55
85
  return result;
56
86
  }
87
+
88
+ /**
89
+ * The segment-wise counterpart: the boundaries are given, so an entity id keeps its
90
+ * slashes and nothing has to be decoded.
91
+ */
92
+ export function getParentReferencesFromPathSegments(pathSegments: string[],
93
+ collections: EntityCollection[]): EntityReference[] {
94
+ return walkPathSegments(pathSegments, collections).steps
95
+ .filter(step => step.entityId !== undefined)
96
+ .map(step => new EntityReference(step.entityId!, step.collectionPath, undefined, step.collectionSegments));
97
+ }
package/src/util/paths.ts CHANGED
@@ -18,10 +18,47 @@ export function segmentsToStrippedPath(paths: string[]) {
18
18
  /**
19
19
  * Extract the collection path routes
20
20
  * `products/B44RG6APH/locales` => [`products`, `locales`]
21
+ *
22
+ * Finds the entity ids by position, which only works while no id contains "/" — with one,
23
+ * every following segment shifts and the wrong elements are kept. Use
24
+ * {@link collectionSegmentsFrom} when the real boundaries are known.
25
+ *
21
26
  * @param path
22
27
  */
23
28
  export function fullPathToCollectionSegments(path: string): string[] {
24
- return path
25
- .split("/")
26
- .filter((e, i) => i % 2 === 0);
29
+ return positionalCollectionSegments(path);
30
+ }
31
+
32
+ /**
33
+ * The collection chain of an already-segmented path:
34
+ * `['products', 'B44RG6APH', 'locales']` => [`products`, `locales`]
35
+ *
36
+ * The counterpart of {@link fullPathToCollectionSegments} for callers that know the real
37
+ * segment boundaries. Kept as a separate function rather than an extra parameter on that
38
+ * one: these helpers are small enough to be passed point-free, and `paths.map(fn)` would
39
+ * then hand the array index in as the new argument.
40
+ */
41
+ export function collectionSegmentsFrom(pathSegments: string[]): string[] {
42
+ return keepCollectionPositions(pathSegments);
43
+ }
44
+
45
+ /**
46
+ * The historical, positional reading of a flattened path:
47
+ * `products/B44RG6APH/locales` => [`products`, `locales`]
48
+ *
49
+ * This produces a **collection chain**, not a datasource `pathSegments` value, and must
50
+ * never be used as one — see `path_segments_no_fabrication.test.ts`. It is only correct
51
+ * while no entity id contains "/", which is why every caller prefers real segments when
52
+ * they are available, and why it is named for what it assumes.
53
+ *
54
+ * It is still the right reading for a path that came from a URL, where entity ids arrive
55
+ * escaped and so cannot contain a bare "/".
56
+ */
57
+ export function positionalCollectionSegments(path: string): string[] {
58
+ return keepCollectionPositions(path.split("/"));
59
+ }
60
+
61
+ /** Keep the even positions of an alternating collection/entity-id chain. */
62
+ function keepCollectionPositions(segments: string[]): string[] {
63
+ return segments.filter((e, i) => i % 2 === 0);
27
64
  }
@@ -1,5 +1,5 @@
1
1
  import { AuthController, Entity, EntityCollection, Permissions, User } from "../types";
2
- import { fullPathToCollectionSegments } from "./paths";
2
+ import { collectionSegmentsFrom, fullPathToCollectionSegments } from "./paths";
3
3
 
4
4
  const DEFAULT_PERMISSIONS = {
5
5
  read: true,
@@ -8,11 +8,18 @@ const DEFAULT_PERMISSIONS = {
8
8
  delete: true
9
9
  };
10
10
 
11
+ /**
12
+ * `pathSegments` is `path` split at its real segment boundaries. It is forwarded so the
13
+ * collection chain handed to a permissions builder stays correct when an entity id contains
14
+ * "/" — splitting `path` positionally would keep the wrong elements. Optional everywhere:
15
+ * omitted, behaviour is exactly as before.
16
+ */
11
17
  export function resolvePermissions<M extends Record<string, any>, USER extends User>
12
18
  (collection: EntityCollection<M>,
13
19
  authController: AuthController<USER>,
14
20
  path: string,
15
- entity: Entity<M> | null): Permissions | undefined {
21
+ entity: Entity<M> | null,
22
+ pathSegments?: string[]): Permissions | undefined {
16
23
 
17
24
  const permission = collection.permissions;
18
25
  if (permission === undefined) {
@@ -20,14 +27,16 @@ export function resolvePermissions<M extends Record<string, any>, USER extends U
20
27
  } else if (typeof permission === "object") {
21
28
  return permission as Permissions;
22
29
  } else if (typeof permission === "function") {
23
- const pathSegments = fullPathToCollectionSegments(path);
30
+ const collectionSegments = pathSegments
31
+ ? collectionSegmentsFrom(pathSegments)
32
+ : fullPathToCollectionSegments(path);
24
33
  return permission({
25
34
  entity,
26
35
  path,
27
36
  user: authController.user,
28
37
  authController,
29
38
  collection,
30
- pathSegments
39
+ pathSegments: collectionSegments
31
40
  });
32
41
  }
33
42
  console.error("Permissions:", permission);
@@ -39,8 +48,9 @@ export function canEditEntity<M extends Record<string, any>, USER extends User>
39
48
  collection: EntityCollection<M>,
40
49
  authController: AuthController<USER>,
41
50
  path: string,
42
- entity: Entity<M> | null): boolean {
43
- return resolvePermissions(collection, authController, path, entity)?.edit ?? DEFAULT_PERMISSIONS.edit;
51
+ entity: Entity<M> | null,
52
+ pathSegments?: string[]): boolean {
53
+ return resolvePermissions(collection, authController, path, entity, pathSegments)?.edit ?? DEFAULT_PERMISSIONS.edit;
44
54
  }
45
55
 
46
56
  export function canCreateEntity<M extends Record<string, any>, USER extends User>
@@ -48,9 +58,10 @@ export function canCreateEntity<M extends Record<string, any>, USER extends User
48
58
  collection: EntityCollection<M>,
49
59
  authController: AuthController<USER>,
50
60
  path: string,
51
- entity: Entity<M> | null): boolean {
61
+ entity: Entity<M> | null,
62
+ pathSegments?: string[]): boolean {
52
63
  if (collection.collectionGroup) return false;
53
- return resolvePermissions(collection, authController, path, entity)?.create ?? DEFAULT_PERMISSIONS.create;
64
+ return resolvePermissions(collection, authController, path, entity, pathSegments)?.create ?? DEFAULT_PERMISSIONS.create;
54
65
  }
55
66
 
56
67
  export function canDeleteEntity<M extends Record<string, any>, USER extends User>
@@ -58,8 +69,9 @@ export function canDeleteEntity<M extends Record<string, any>, USER extends User
58
69
  collection: EntityCollection<M>,
59
70
  authController: AuthController<USER>,
60
71
  path: string,
61
- entity: Entity<M> | null): boolean {
62
- return resolvePermissions(collection, authController, path, entity)?.delete ?? DEFAULT_PERMISSIONS.delete;
72
+ entity: Entity<M> | null,
73
+ pathSegments?: string[]): boolean {
74
+ return resolvePermissions(collection, authController, path, entity, pathSegments)?.delete ?? DEFAULT_PERMISSIONS.delete;
63
75
  }
64
76
 
65
77
  // export function resolveCollectionsPermissions(roles: Role[]): Record<string, Permissions> {