@firecms/core 3.4.0-canary.63041d0 → 3.4.0-canary.cfeed0b

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 (82) hide show
  1. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  2. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  4. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  7. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  8. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  9. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  10. package/dist/core/EntityEditView.d.ts +2 -0
  11. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  12. package/dist/form/EntityForm.d.ts +8 -1
  13. package/dist/form/EntityFormActions.d.ts +2 -0
  14. package/dist/hooks/data/delete.d.ts +1 -1
  15. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  16. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  17. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  18. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  19. package/dist/index.es.js +967 -649
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +966 -648
  22. package/dist/index.umd.js.map +1 -1
  23. package/dist/types/collections.d.ts +7 -0
  24. package/dist/types/datasource.d.ts +25 -4
  25. package/dist/types/entities.d.ts +21 -1
  26. package/dist/types/entity_actions.d.ts +6 -0
  27. package/dist/types/entity_callbacks.d.ts +21 -0
  28. package/dist/types/navigation.d.ts +34 -5
  29. package/dist/types/plugins.d.ts +13 -0
  30. package/dist/types/side_entity_controller.d.ts +3 -2
  31. package/dist/util/entity_cache.d.ts +11 -0
  32. package/dist/util/navigation_utils.d.ts +84 -2
  33. package/dist/util/parent_references_from_path.d.ts +16 -0
  34. package/dist/util/paths.d.ts +28 -0
  35. package/dist/util/permissions.d.ts +10 -4
  36. package/package.json +4 -4
  37. package/src/components/DeleteEntityDialog.tsx +2 -0
  38. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  39. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  40. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  41. package/src/components/EntityCollectionView/EntityCollectionView.tsx +49 -16
  42. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  43. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  44. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  45. package/src/components/EntityPreview.tsx +4 -1
  46. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  47. package/src/components/common/default_entity_actions.tsx +22 -2
  48. package/src/components/common/useDataSourceTableController.tsx +17 -5
  49. package/src/components/common/useTableSearchHelper.ts +5 -0
  50. package/src/core/EntityEditView.tsx +11 -12
  51. package/src/core/EntityEditViewFormActions.tsx +3 -2
  52. package/src/core/EntitySidePanel.tsx +6 -4
  53. package/src/form/EntityForm.tsx +30 -9
  54. package/src/form/EntityFormActions.tsx +2 -0
  55. package/src/hooks/data/delete.ts +8 -0
  56. package/src/hooks/data/save.ts +5 -2
  57. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  58. package/src/hooks/data/useEntityFetch.tsx +28 -6
  59. package/src/hooks/useBuildNavigationController.tsx +23 -8
  60. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  61. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  62. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  63. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  64. package/src/internal/useBuildDataSource.ts +26 -15
  65. package/src/internal/useBuildSideEntityController.tsx +13 -5
  66. package/src/preview/components/ReferencePreview.tsx +7 -3
  67. package/src/routes/FireCMSRoute.tsx +9 -4
  68. package/src/types/collections.ts +8 -0
  69. package/src/types/datasource.ts +25 -4
  70. package/src/types/entities.ts +24 -1
  71. package/src/types/entity_actions.tsx +6 -0
  72. package/src/types/entity_callbacks.ts +24 -0
  73. package/src/types/navigation.ts +35 -5
  74. package/src/types/plugins.tsx +13 -0
  75. package/src/types/side_entity_controller.tsx +3 -2
  76. package/src/util/entities.ts +3 -1
  77. package/src/util/entity_cache.ts +18 -0
  78. package/src/util/navigation_from_path.ts +6 -1
  79. package/src/util/navigation_utils.ts +204 -2
  80. package/src/util/parent_references_from_path.ts +42 -3
  81. package/src/util/paths.ts +40 -3
  82. package/src/util/permissions.ts +22 -10
@@ -106,6 +106,11 @@ export interface SaveEntityProps<M extends Record<string, any> = any> {
106
106
  */
107
107
  export interface DeleteEntityProps<M extends Record<string, any> = any> {
108
108
  entity: Entity<M>;
109
+ /**
110
+ * `entity.path` split at its real segment boundaries, when the caller knows them.
111
+ * Never derived from `entity.path` — see `pathSegments` on FetchCollectionProps.
112
+ */
113
+ pathSegments?: string[];
109
114
  collection?: EntityCollection<M> | ResolvedEntityCollection<M>;
110
115
  }
111
116
 
@@ -245,13 +250,19 @@ export interface DataSource {
245
250
  name: string,
246
251
  value: any,
247
252
  entityId?: string,
248
- collection?: EntityCollection
253
+ collection?: EntityCollection,
254
+ /**
255
+ * Appended last, and optional, so existing implementations and callers keep
256
+ * working unchanged — a function declaring fewer parameters stays assignable.
257
+ * See `pathSegments` on FetchCollectionProps.
258
+ */
259
+ pathSegments?: string[]
249
260
  ): Promise<boolean>;
250
261
 
251
262
  /**
252
263
  * Generate an id for a new entity
253
264
  */
254
- generateEntityId(path: string, collection: EntityCollection): string;
265
+ generateEntityId(path: string, collection: EntityCollection, pathSegments?: string[]): string;
255
266
 
256
267
  /**
257
268
  * Count the number of entities in a collection
@@ -272,6 +283,10 @@ export interface DataSource {
272
283
  initTextSearch?: (props: {
273
284
  context: FireCMSContext,
274
285
  path: string,
286
+ /**
287
+ * `path` split at its real segment boundaries, when known. Never derived from `path`.
288
+ */
289
+ pathSegments?: string[],
275
290
  collection: EntityCollection,
276
291
  parentCollectionIds?: string[]
277
292
  }) => Promise<boolean>;
@@ -279,6 +294,8 @@ export interface DataSource {
279
294
  }
280
295
 
281
296
  export type FilterCombinationValidProps = {
297
+ /** `path` split at its real segment boundaries — see `pathSegments` on FetchCollectionProps. */
298
+ pathSegments?: string[];
282
299
  path: string;
283
300
  collection: EntityCollection<any>;
284
301
  filterValues: FilterValues<any>;
@@ -408,12 +425,12 @@ export interface DataSourceDelegate {
408
425
  * @param collection
409
426
  * @return `true` if there are no other fields besides the given entity
410
427
  */
411
- checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection): Promise<boolean>;
428
+ checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection, pathSegments?: string[]): Promise<boolean>;
412
429
 
413
430
  /**
414
431
  * Generate an id for a new entity
415
432
  */
416
- generateEntityId(path: string, collection?: EntityCollection): string;
433
+ generateEntityId(path: string, collection?: EntityCollection, pathSegments?: string[]): string;
417
434
 
418
435
  /**
419
436
  * Count the number of entities in a collection
@@ -440,6 +457,10 @@ export interface DataSourceDelegate {
440
457
  initTextSearch?: (props: {
441
458
  context: FireCMSContext,
442
459
  path: string,
460
+ /**
461
+ * `path` split at its real segment boundaries, when known. Never derived from `path`.
462
+ */
463
+ pathSegments?: string[],
443
464
  databaseId?: string,
444
465
  collection: EntityCollection,
445
466
  parentCollectionIds?: string[]
@@ -21,6 +21,17 @@ export interface Entity<M extends object = any> {
21
21
  */
22
22
  path: string;
23
23
 
24
+ /**
25
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
26
+ *
27
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
28
+ * recovered from it. This keeps each segment whole.
29
+ *
30
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
31
+ * the case the field exists for. Absent means "not known here", not "no slashes".
32
+ */
33
+ pathSegments?: string[];
34
+
24
35
  /**
25
36
  * Current values
26
37
  */
@@ -55,10 +66,22 @@ export class EntityReference {
55
66
  */
56
67
  readonly databaseId?: string;
57
68
 
58
- constructor(id: string, path: string, databaseId?: string) {
69
+ /**
70
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
71
+ *
72
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
73
+ * recovered from it. This keeps each segment whole.
74
+ *
75
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
76
+ * the case the field exists for. Absent means "not known here", not "no slashes".
77
+ */
78
+ readonly pathSegments?: string[];
79
+
80
+ constructor(id: string, path: string, databaseId?: string, pathSegments?: string[]) {
59
81
  this.id = id;
60
82
  this.path = path;
61
83
  this.databaseId = databaseId;
84
+ this.pathSegments = pathSegments;
62
85
  }
63
86
 
64
87
  get pathWithId() {
@@ -65,6 +65,12 @@ export type EntityActionClickProps<M extends object, USER extends User = User> =
65
65
  context: FireCMSContext<USER>;
66
66
 
67
67
  fullPath?: string;
68
+ /**
69
+ * `fullPath` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`,
70
+ * when the caller knows them. Absent means "not known here" — never derived from
71
+ * `fullPath`, since a parent id may itself contain "/".
72
+ */
73
+ pathSegments?: string[];
68
74
  fullIdPath?: string;
69
75
  collection?: EntityCollection<M>;
70
76
 
@@ -86,6 +86,14 @@ export interface EntityOnFetchProps<M extends Record<string, any> = any, USER ex
86
86
  */
87
87
  path: string;
88
88
 
89
+ /**
90
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
91
+ *
92
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
93
+ * contain "/". Absent means "not known here", not "no slashes".
94
+ */
95
+ pathSegments?: string[];
96
+
89
97
  /**
90
98
  * Fetched entity
91
99
  */
@@ -133,6 +141,14 @@ export interface EntityOnSaveProps<M extends Record<string, any> = any, USER ext
133
141
  */
134
142
  path: string;
135
143
 
144
+ /**
145
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
146
+ *
147
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
148
+ * contain "/". Absent means "not known here", not "no slashes".
149
+ */
150
+ pathSegments?: string[];
151
+
136
152
  /**
137
153
  * Full path where this entity is being saved, with alias resolved
138
154
  */
@@ -180,6 +196,14 @@ export interface EntityOnDeleteProps<M extends Record<string, any> = any, USER e
180
196
  */
181
197
  path: string;
182
198
 
199
+ /**
200
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
201
+ *
202
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
203
+ * contain "/". Absent means "not known here", not "no slashes".
204
+ */
205
+ pathSegments?: string[];
206
+
183
207
  /**
184
208
  * Deleted entity id
185
209
  */
@@ -57,8 +57,12 @@ export type NavigationController<EC extends EntityCollection = EntityCollection<
57
57
  /**
58
58
  * Get the collection configuration for a given path.
59
59
  * The collection is resolved from the given path or alias.
60
+ *
61
+ * Pass `pathSegments` when you have them: without them the path is split on "/" and
62
+ * asserted to have an odd number of parts, which a chain containing a slash-bearing
63
+ * entity id fails — it throws instead of resolving.
60
64
  */
61
- getCollection: (pathOrId: string, includeUserOverride?: boolean) => EC | undefined;
65
+ getCollection: (pathOrId: string, includeUserOverride?: boolean, pathSegments?: string[]) => EC | undefined;
62
66
 
63
67
  /**
64
68
  * Get the top level collection configuration for a given id
@@ -118,9 +122,30 @@ export type NavigationController<EC extends EntityCollection = EntityCollection<
118
122
  /**
119
123
  * Turn a path with collection ids into a resolved path.
120
124
  * The ids (typically used in urls) will be replaced with relative paths (typically used in database paths)
125
+ *
126
+ * Pass `pathSegments` whenever you have them. Without them this has to find the entity
127
+ * ids inside the flattened string by reading up to the next "/", which is wrong for any
128
+ * id that contains one — the chain then shifts by a segment and resolution fails.
129
+ *
121
130
  * @param pathWithAliases
131
+ * @param pathSegments `pathWithAliases` split at its real segment boundaries, if known
122
132
  */
123
- resolveIdsFrom: (pathWithAliases: string) => string;
133
+ resolveIdsFrom: (pathWithAliases: string, pathSegments?: string[]) => string;
134
+
135
+ /**
136
+ * The segment-wise counterpart of {@link resolveIdsFrom}: replaces collection ids with
137
+ * their real paths while carrying every entity id through whole, slashes included.
138
+ *
139
+ * Idempotent — segments that are already resolved pass through unchanged.
140
+ *
141
+ * Optional so that a `navigationController` built against an earlier version keeps
142
+ * working: callers fall back to using the segments as given, which is what happened
143
+ * before this method existed. Controllers built by `useBuildNavigationController`
144
+ * always provide it.
145
+ *
146
+ * @param pathSegments
147
+ */
148
+ resolveSegmentsFrom?: (pathSegments: string[]) => string[];
124
149
 
125
150
  /**
126
151
  * Call this method to recalculate the navigation
@@ -128,16 +153,21 @@ export type NavigationController<EC extends EntityCollection = EntityCollection<
128
153
  refreshNavigation: () => void;
129
154
 
130
155
  /**
131
- * Retrieve all the related parent references for a given path
156
+ * Retrieve all the related parent references for a given path.
157
+ *
158
+ * `path` is expected escaped, as it comes from the URL. Pass `pathSegments` when the
159
+ * path at hand is a raw datasource path, whose ids may contain a bare "/".
160
+ *
132
161
  * @param path
162
+ * @param pathSegments `path` split at its real segment boundaries, with raw ids
133
163
  */
134
- getParentReferencesFromPath: (path: string) => EntityReference[];
164
+ getParentReferencesFromPath: (path: string, pathSegments?: string[]) => EntityReference[];
135
165
 
136
166
  /**
137
167
  * Retrieve all the related parent collection ids for a given path
138
168
  * @param path
139
169
  */
140
- getParentCollectionIds: (path: string) => string[];
170
+ getParentCollectionIds: (path: string, pathSegments?: string[]) => string[];
141
171
 
142
172
  /**
143
173
  * Resolve paths from a list of ids
@@ -146,6 +146,8 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
146
146
  blockSearch?: (props: {
147
147
  context: FireCMSContext,
148
148
  path: string,
149
+ /** `path` split at its real segment boundaries, when known. */
150
+ pathSegments?: string[],
149
151
  collection: EC,
150
152
  parentCollectionIds?: string[]
151
153
  }) => boolean;
@@ -153,6 +155,8 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
153
155
  showTextSearchBar?: (props: {
154
156
  context: FireCMSContext,
155
157
  path: string,
158
+ /** `path` split at its real segment boundaries, when known. */
159
+ pathSegments?: string[],
156
160
  collection: EC,
157
161
  parentCollectionIds?: string[]
158
162
  }) => boolean;
@@ -160,6 +164,8 @@ export type FireCMSPlugin<PROPS = any, FORM_PROPS = any, EC extends EntityCollec
160
164
  onTextSearchClick?: (props: {
161
165
  context: FireCMSContext,
162
166
  path: string,
167
+ /** `path` split at its real segment boundaries, when known. */
168
+ pathSegments?: string[],
163
169
  collection: EC,
164
170
  parentCollectionIds?: string[]
165
171
  }) => Promise<boolean>;
@@ -308,6 +314,13 @@ export interface PluginHomePageActionsProps<EP extends object = object, M extend
308
314
  export interface PluginFormActionProps<USER extends User = User, EC extends EntityCollection = EntityCollection> {
309
315
  entityId?: string;
310
316
  path: string;
317
+ /**
318
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
319
+ *
320
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
321
+ * contain "/". Absent means "not known here", not "no slashes".
322
+ */
323
+ pathSegments?: string[];
311
324
  parentCollectionIds: string[];
312
325
  status: EntityStatus;
313
326
  collection: EC;
@@ -21,8 +21,9 @@ export interface EntitySidePanelProps<M extends Record<string, any> = any> {
21
21
  fullIdPath?: string;
22
22
  /**
23
23
  * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
24
- * Optional: when omitted the panel falls back to splitting `path`, which is correct for
25
- * any backend whose entity ids cannot contain "/".
24
+ *
25
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
26
+ * the case the field exists for. Absent means "not known here", not "no slashes".
26
27
  */
27
28
  pathSegments?: string[];
28
29
 
@@ -142,7 +142,9 @@ export function getReferenceFrom<M extends Record<string, any>>(entity: Entity<M
142
142
  if (!entity) {
143
143
  throw new Error("getReferenceFrom: entity is null or undefined");
144
144
  }
145
- return new EntityReference(entity.id, entity.path, entity.databaseId);
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);
146
148
  }
147
149
 
148
150
  export function traverseValuesProperties<M extends Record<string, any>>(
@@ -1,5 +1,23 @@
1
1
  import { EntityReference, GeoPoint, Vector } from "../types";
2
2
  import { isObject, isPlainObject } from "./objects";
3
+ import { encodeEntityId } from "./navigation_utils";
4
+
5
+ /**
6
+ * Key identifying one entity in a cache.
7
+ *
8
+ * The id is escaped so that a `(path, entityId)` pair maps to exactly one key. Without it
9
+ * `("a", "b/c")` and `("a/b", "c")` both flatten to `"a/b/c"`, and one entity's cached
10
+ * values are served for the other.
11
+ *
12
+ * `encodeEntityId` is the identity for any id without "/", "?", "#" or "%", so this changes
13
+ * no key in an app whose ids cannot contain them.
14
+ */
15
+ export function entityCacheKey(path: string | undefined, entityId: string | undefined): string {
16
+ // Either part may be undefined at a call site that used to build the key by string
17
+ // concatenation; both are stringified exactly as `path + "/" + entityId` did, so such a
18
+ // caller keeps the key it had.
19
+ return `${path}/${entityId === undefined ? entityId : encodeEntityId(entityId)}`;
20
+ }
3
21
 
4
22
  // Define a unique prefix for entity keys in localStorage to avoid key collisions
5
23
  const LOCAL_STORAGE_PREFIX = "entity_cache::";
@@ -147,7 +147,12 @@ export function getNavigationEntriesFromPath(props: {
147
147
  path: newPath,
148
148
  collections: collection.subcollections,
149
149
  currentFullPath: fullPath,
150
- 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,
151
156
  currentFullUrlPath: fullUrlPath,
152
157
  currentPathSegments: entitySegments,
153
158
  contextEntityViews: props.contextEntityViews
@@ -56,6 +56,30 @@ export function decodeEntityId(encodedEntityId: string): string {
56
56
  .replaceAll("%25", "%");
57
57
  }
58
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
+
59
83
  export function getLastSegment(path: string) {
60
84
  const cleanPath = removeInitialAndTrailingSlashes(path);
61
85
  if (cleanPath.includes("/")) {
@@ -65,7 +89,171 @@ export function getLastSegment(path: string) {
65
89
  return cleanPath;
66
90
  }
67
91
 
68
- 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
+
69
257
  let remainingPath = removeInitialAndTrailingSlashes(path);
70
258
  if (!remainingPath) {
71
259
  return "";
@@ -158,7 +346,14 @@ export function resolveCollectionPathIds(path: string, allCollections: EntityCol
158
346
  * @param pathOrId
159
347
  * @param collections
160
348
  */
161
- export function getCollectionByPathOrId(pathOrId: string, collections: EntityCollection[]): EntityCollection | undefined {
349
+ export function getCollectionByPathOrId(pathOrId: string, collections: EntityCollection[], pathSegments?: string[]): EntityCollection | undefined {
350
+
351
+ // Given the real boundaries there is nothing to parse, and no parity to assert: a chain
352
+ // whose entity id contains "/" splits into an even number of parts below and would be
353
+ // rejected outright, even though it is perfectly valid.
354
+ if (pathSegments) {
355
+ return getCollectionByPathSegments(pathSegments, collections);
356
+ }
162
357
 
163
358
  const subpaths = removeInitialAndTrailingSlashes(pathOrId).split("/");
164
359
  if (subpaths.length % 2 === 0) {
@@ -249,6 +444,13 @@ export function navigateToEntity({
249
444
  });
250
445
 
251
446
  } else {
447
+ // A URL must be built from the ESCAPED chain. `fullIdPath` is that chain; `path` is
448
+ // the raw datasource one, and falling back to it is only safe while no id in it
449
+ // contains "/". When the segments show that it does, the resulting URL would address
450
+ // a different entity, so say so instead of navigating somewhere wrong in silence.
451
+ if (!fullIdPath && pathSegments?.some(segment => segment.includes("/"))) {
452
+ 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".`);
453
+ }
252
454
  let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${encodeEntityId(entityId)}` : fullIdPath ?? path);
253
455
  if (entityId && selectedTab) {
254
456
  to += `/${selectedTab}`;