@firecms/core 3.4.0-canary.0ef7442 → 3.4.0-canary.2575a08

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 (74) hide show
  1. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +3 -2
  2. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  4. package/dist/components/common/useDataSourceTableController.d.ts +4 -2
  5. package/dist/components/common/useTableSearchHelper.d.ts +3 -1
  6. package/dist/core/EntityEditView.d.ts +2 -0
  7. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  8. package/dist/form/EntityForm.d.ts +2 -0
  9. package/dist/form/EntityFormActions.d.ts +2 -0
  10. package/dist/hooks/data/delete.d.ts +1 -1
  11. package/dist/hooks/data/useCollectionFetch.d.ts +1 -1
  12. package/dist/hooks/data/useEntityFetch.d.ts +4 -1
  13. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  14. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  15. package/dist/index.es.js +853 -600
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/index.umd.js +852 -599
  18. package/dist/index.umd.js.map +1 -1
  19. package/dist/types/collections.d.ts +7 -0
  20. package/dist/types/datasource.d.ts +8 -0
  21. package/dist/types/entity_actions.d.ts +6 -0
  22. package/dist/types/entity_callbacks.d.ts +21 -0
  23. package/dist/types/navigation.d.ts +34 -5
  24. package/dist/types/plugins.d.ts +13 -0
  25. package/dist/types/side_entity_controller.d.ts +3 -2
  26. package/dist/util/entity_cache.d.ts +11 -0
  27. package/dist/util/navigation_utils.d.ts +84 -2
  28. package/dist/util/parent_references_from_path.d.ts +15 -0
  29. package/dist/util/paths.d.ts +28 -0
  30. package/dist/util/permissions.d.ts +10 -4
  31. package/package.json +3 -3
  32. package/src/components/DeleteEntityDialog.tsx +2 -0
  33. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  34. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +8 -0
  35. package/src/components/EntityCollectionView/EntityCollectionView.tsx +40 -15
  36. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  37. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  38. package/src/components/EntityCollectionView/useBoardDataController.tsx +7 -2
  39. package/src/components/EntityPreview.tsx +4 -1
  40. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +8 -2
  41. package/src/components/common/default_entity_actions.tsx +22 -2
  42. package/src/components/common/useDataSourceTableController.tsx +26 -8
  43. package/src/components/common/useTableSearchHelper.ts +5 -0
  44. package/src/core/EntityEditView.tsx +8 -12
  45. package/src/core/EntityEditViewFormActions.tsx +3 -2
  46. package/src/core/EntitySidePanel.tsx +6 -4
  47. package/src/form/EntityForm.tsx +22 -7
  48. package/src/form/EntityFormActions.tsx +2 -0
  49. package/src/hooks/data/delete.ts +8 -2
  50. package/src/hooks/data/save.ts +4 -1
  51. package/src/hooks/data/useCollectionFetch.tsx +9 -2
  52. package/src/hooks/data/useEntityFetch.tsx +19 -6
  53. package/src/hooks/useBuildNavigationController.tsx +23 -8
  54. package/src/hooks/useResolvedNavigationFrom.tsx +1 -1
  55. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  56. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  57. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  58. package/src/internal/useBuildDataSource.ts +13 -12
  59. package/src/internal/useBuildSideEntityController.tsx +13 -5
  60. package/src/preview/components/ReferencePreview.tsx +7 -3
  61. package/src/routes/FireCMSRoute.tsx +9 -4
  62. package/src/types/collections.ts +8 -0
  63. package/src/types/datasource.ts +8 -0
  64. package/src/types/entity_actions.tsx +6 -0
  65. package/src/types/entity_callbacks.ts +24 -0
  66. package/src/types/navigation.ts +35 -5
  67. package/src/types/plugins.tsx +13 -0
  68. package/src/types/side_entity_controller.tsx +3 -2
  69. package/src/util/entity_cache.ts +18 -0
  70. package/src/util/navigation_from_path.ts +6 -1
  71. package/src/util/navigation_utils.ts +204 -2
  72. package/src/util/parent_references_from_path.ts +32 -1
  73. package/src/util/paths.ts +40 -3
  74. package/src/util/permissions.ts +22 -10
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from "react";
5
+ import { render, screen } from "@testing-library/react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { FireCMSi18nProvider } from "../FireCMSi18nProvider";
8
+
9
+ function Str({ k }: { k: string }) {
10
+ const { t } = useTranslation("firecms_core");
11
+ return <span data-testid={k}>{t(k)}</span>;
12
+ }
13
+
14
+ const host = { en: { host_key: "Host" } } as any;
15
+ const projectA = { en: { only_in_a: "From project A" } } as any;
16
+ const projectB = { en: { only_in_b: "From project B" } } as any;
17
+
18
+ describe("nested provider lifecycle", () => {
19
+ beforeEach(() => window.localStorage.clear());
20
+
21
+ it("does not carry one project's translations into the next", () => {
22
+ // The real shape of a project switch: App.tsx's provider stays mounted while
23
+ // FireCMSCloudApp's inner one is torn down and rebuilt with another project's
24
+ // translations. Both share the outer i18next instance now, so anything the
25
+ // first project added could outlive it.
26
+ const { rerender } = render(
27
+ <FireCMSi18nProvider translations={host}>
28
+ <FireCMSi18nProvider key={"a"} translations={projectA}>
29
+ <Str k={"only_in_a"}/>
30
+ </FireCMSi18nProvider>
31
+ </FireCMSi18nProvider>
32
+ );
33
+ expect(screen.getByTestId("only_in_a").textContent).toBe("From project A");
34
+
35
+ rerender(
36
+ <FireCMSi18nProvider translations={host}>
37
+ <FireCMSi18nProvider key={"b"} translations={projectB}>
38
+ <>
39
+ <Str k={"only_in_b"}/>
40
+ <Str k={"only_in_a"}/>
41
+ </>
42
+ </FireCMSi18nProvider>
43
+ </FireCMSi18nProvider>
44
+ );
45
+
46
+ expect(screen.getByTestId("only_in_b").textContent).toBe("From project B");
47
+ expect(screen.getByTestId("only_in_a").textContent).toBe("only_in_a");
48
+ });
49
+
50
+ it("keeps the host's own translations across the switch", () => {
51
+ const { rerender } = render(
52
+ <FireCMSi18nProvider translations={host}>
53
+ <FireCMSi18nProvider key={"a"} translations={projectA}>
54
+ <Str k={"host_key"}/>
55
+ </FireCMSi18nProvider>
56
+ </FireCMSi18nProvider>
57
+ );
58
+ rerender(
59
+ <FireCMSi18nProvider translations={host}>
60
+ <FireCMSi18nProvider key={"b"} translations={projectB}>
61
+ <Str k={"host_key"}/>
62
+ </FireCMSi18nProvider>
63
+ </FireCMSi18nProvider>
64
+ );
65
+ expect(screen.getByTestId("host_key").textContent).toBe("Host");
66
+ });
67
+ });
@@ -112,7 +112,7 @@ export function useBuildDataSource({
112
112
  }: ListenCollectionProps<M>
113
113
  ): () => void => {
114
114
 
115
- const collection = collectionProp ?? navigationController.getCollection(path);
115
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
116
116
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
117
117
 
118
118
  if (!usedDelegate.listenCollection)
@@ -213,7 +213,7 @@ export function useBuildDataSource({
213
213
  status
214
214
  }: SaveEntityProps<M>): Promise<Entity<M>> => {
215
215
 
216
- const collection = collectionProp ?? navigationController.getCollection(path);
216
+ const collection = collectionProp ?? navigationController.getCollection(path, false, pathSegments);
217
217
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
218
218
 
219
219
  const resolvedCollection = collection
@@ -286,9 +286,15 @@ export function useBuildDataSource({
286
286
  values: finalValues,
287
287
  status
288
288
  }).then((res) => {
289
+ // Keep everything the delegate resolved — `databaseId`, and `pathSegments` in
290
+ // particular — so a just-saved entity is as resolvable as a fetched one.
291
+ // Only the values are re-mapped back to the CMS model.
289
292
  return {
290
- id: res.id,
291
- path: res.path,
293
+ ...res,
294
+ // A delegate that predates `pathSegments` returns none; the segments we
295
+ // just saved with describe exactly this entity's collection, so they are
296
+ // the authoritative answer rather than a guess.
297
+ pathSegments: res.pathSegments ?? pathSegments,
292
298
  values: usedDelegate.delegateToCMSModel(finalValues)
293
299
  } as Entity<M>;
294
300
  });
@@ -343,6 +349,8 @@ export function useBuildDataSource({
343
349
  return usedDelegate.generateEntityId(path, collection, pathSegments);
344
350
  }, [delegate.generateEntityId]),
345
351
 
352
+ // Typed with the declared props rather than an inline duplicate: the duplicate is
353
+ // free to drift from the interface, and a field it forgets is dropped in silence.
346
354
  countEntities: delegate.countEntities ? async ({
347
355
  path,
348
356
  pathSegments,
@@ -350,14 +358,7 @@ export function useBuildDataSource({
350
358
  filter,
351
359
  order,
352
360
  orderBy
353
- }: {
354
- path: string,
355
- pathSegments?: string[],
356
- collection: EntityCollection<any>,
357
- filter?: FilterValues<Extract<keyof any, string>>,
358
- orderBy?: string,
359
- order?: "desc" | "asc",
360
- }): Promise<number> => {
361
+ }: FetchCollectionProps<any>): Promise<number> => {
361
362
  const usedDelegate = collection?.overrides?.dataSourceDelegate ?? delegate;
362
363
  return usedDelegate.countEntities!({
363
364
  path,
@@ -281,11 +281,17 @@ const propsToSidePanel = (props: EntitySidePanelProps,
281
281
  locationSearch: string
282
282
  ): SideDialogPanelProps => {
283
283
 
284
- const collectionPath = removeInitialAndTrailingSlashes(props.path);
284
+ // The URL is the ESCAPED representation of the chain — it is what `buildSidePanelsFromUrl`
285
+ // and `FireCMSRoute` parse back on reload, on a deep link, and on every pathname change.
286
+ // `props.path` is the RAW datasource path, so a parent entity id containing "/" reads back
287
+ // there as extra collection/entity hops and resolves to a different entity entirely.
288
+ // `fullIdPath` carries the same chain with its ids already escaped, which is what it is
289
+ // threaded alongside `path` for.
290
+ const urlChain = removeInitialAndTrailingSlashes(props.fullIdPath ?? props.path);
285
291
 
286
292
  const urlPath = props.entityId
287
- ? buildUrlCollectionPath(`${collectionPath}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
288
- : buildUrlCollectionPath(`${collectionPath}${locationSearch}#${NEW_URL_HASH}`);
293
+ ? buildUrlCollectionPath(`${urlChain}/${encodeEntityId(props.entityId)}${props.selectedTab ? "/" + props.selectedTab : ""}${locationSearch}#${SIDE_URL_HASH}`)
294
+ : buildUrlCollectionPath(`${urlChain}${locationSearch}#${NEW_URL_HASH}`);
289
295
 
290
296
  const resolvedPanelProps: EntitySidePanelProps<any> = {
291
297
  ...props,
@@ -294,10 +300,12 @@ const propsToSidePanel = (props: EntitySidePanelProps,
294
300
 
295
301
  const entityViewWidth = getEntityViewWidth(props, smallLayout, customizationController, authController);
296
302
  return {
297
- key: `${props.path}/${props.entityId}`,
303
+ // Built from the escaped chain so that two different chains which happen to flatten
304
+ // to the same raw string are still two different panels.
305
+ key: `${urlChain}/${props.entityId ? encodeEntityId(props.entityId) : ""}`,
298
306
  component: undefined, // Lazy render in SideDialogs for better performance
299
307
  urlPath: urlPath,
300
- parentUrlPath: buildUrlCollectionPath(collectionPath),
308
+ parentUrlPath: buildUrlCollectionPath(urlChain),
301
309
  width: entityViewWidth,
302
310
  onClose: props.onClose,
303
311
  additional: resolvedPanelProps
@@ -7,6 +7,7 @@ import { Skeleton } from "@firecms/ui";
7
7
  import { ErrorBoundary, ErrorView } from "../../components";
8
8
  import { EntityPreview, EntityPreviewContainer } from "../../components/EntityPreview";
9
9
  import { jsonStringifyReplacer } from "../../util/objects";
10
+ import { entityCacheKey } from "../../util/entity_cache";
10
11
 
11
12
  export type ReferencePreviewProps = {
12
13
  disabled?: boolean;
@@ -53,7 +54,7 @@ function ReferencePreviewInternal({
53
54
 
54
55
  const navigationController = useNavigationController();
55
56
 
56
- const collection = navigationController.getCollection(reference.path);
57
+ const collection = navigationController.getCollection(reference.path, false, reference.pathSegments);
57
58
  if (!collection) {
58
59
  if (customizationController.components?.missingReference) {
59
60
  return <customizationController.components.missingReference path={reference.path}/>;
@@ -99,16 +100,19 @@ function ReferencePreviewExisting<M extends Record<string, any> = any>({
99
100
  dataLoadingError
100
101
  } = useEntityFetch({
101
102
  path: reference.path,
103
+ // References carry the segments of the collection they point at, when the code that
104
+ // built them knew them (see getReferenceFrom).
105
+ pathSegments: reference.pathSegments,
102
106
  entityId: reference.id,
103
107
  collection,
104
108
  useCache: true
105
109
  });
106
110
 
107
111
  if (entity) {
108
- referencesCache.set(reference.pathWithId, entity);
112
+ referencesCache.set(entityCacheKey(reference.path, reference.id), entity);
109
113
  }
110
114
 
111
- const usedEntity = entity ?? referencesCache.get(reference.pathWithId);
115
+ const usedEntity = entity ?? referencesCache.get(entityCacheKey(reference.path, reference.id));
112
116
 
113
117
  let body: React.ReactNode;
114
118
 
@@ -90,7 +90,7 @@ export function FireCMSRoute() {
90
90
  let collection: EntityCollection<any> | undefined;
91
91
  collection = navigation.getCollectionById(navigationEntries[0].id);
92
92
  if (!collection)
93
- collection = navigation.getCollection(navigationEntries[0].path);
93
+ collection = navigation.getCollection(navigationEntries[0].path, false, navigationEntries[0].pathSegments);
94
94
  if (!collection)
95
95
  return null;
96
96
  return <React.Suspense fallback={null}>
@@ -114,7 +114,7 @@ export function FireCMSRoute() {
114
114
  const firstEntry = navigationEntries[0] as NavigationViewCollectionInternal<any>;
115
115
  collection = navigation.getCollectionById(firstEntry.id);
116
116
  if (!collection)
117
- collection = navigation.getCollection(firstEntry.path);
117
+ collection = navigation.getCollection(firstEntry.path, false, firstEntry.pathSegments);
118
118
  if (!collection)
119
119
  return null;
120
120
  return <React.Suspense fallback={null}>
@@ -186,6 +186,8 @@ function EntityFullScreenRoute({
186
186
  const urlTab = getSelectedTabFromUrl(isNew, lastCustomView);
187
187
  const [selectedTab, setSelectedTab] = useState<string | undefined>(urlTab);
188
188
 
189
+ // `navigationPath` comes from the URL, where entity ids are still escaped (see
190
+ // encodeEntityId), so splitting it on "/" inside is correct and no segments are needed.
189
191
  const parentCollectionIds = navigation.getParentCollectionIds(navigationPath);
190
192
  useEffect(() => {
191
193
  if (urlTab !== selectedTab) {
@@ -250,14 +252,17 @@ function EntityFullScreenRoute({
250
252
  // `fullIdPath` is used downstream to build URLs, so it carries the escaped chain;
251
253
  // `collectionPath` addresses the datasource, so it is resolved from the raw one.
252
254
  const fullIdPath = isNew ? lastCollectionEntry!.fullPath : (parentCollectionEntry?.fullPath ?? lastEntityEntry!.fullPath);
253
- const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path);
255
+ // The navigation entries know the real segment boundaries, so the path is resolved
256
+ // from them rather than re-parsed out of the flattened string.
257
+ const pathSegments = isNew ? lastCollectionEntry!.pathSegments : lastEntityEntry!.pathSegments;
258
+ const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path, pathSegments);
254
259
  return <>
255
260
  <React.Suspense fallback={null}>
256
261
  <EntityEditView
257
262
  key={collection.id + "_" + (isNew ? "new" : (isCopy ? entityId + "_copy" : entityId))}
258
263
  entityId={isNew ? undefined : entityId}
259
264
  fullIdPath={fullIdPath}
260
- pathSegments={isNew ? lastCollectionEntry!.pathSegments : lastEntityEntry!.pathSegments}
265
+ pathSegments={pathSegments}
261
266
  collection={collection}
262
267
  layout={"full_screen"}
263
268
  path={collectionPath}
@@ -497,6 +497,14 @@ export interface CollectionActionsProps<M extends Record<string, any> = any, USE
497
497
  */
498
498
  path: string;
499
499
 
500
+ /**
501
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
502
+ *
503
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
504
+ * contain "/". Absent means "not known here", not "no slashes".
505
+ */
506
+ pathSegments?: string[];
507
+
500
508
  /**
501
509
  * Path of the last collection, like `addresses`
502
510
  */
@@ -283,6 +283,10 @@ export interface DataSource {
283
283
  initTextSearch?: (props: {
284
284
  context: FireCMSContext,
285
285
  path: string,
286
+ /**
287
+ * `path` split at its real segment boundaries, when known. Never derived from `path`.
288
+ */
289
+ pathSegments?: string[],
286
290
  collection: EntityCollection,
287
291
  parentCollectionIds?: string[]
288
292
  }) => Promise<boolean>;
@@ -453,6 +457,10 @@ export interface DataSourceDelegate {
453
457
  initTextSearch?: (props: {
454
458
  context: FireCMSContext,
455
459
  path: string,
460
+ /**
461
+ * `path` split at its real segment boundaries, when known. Never derived from `path`.
462
+ */
463
+ pathSegments?: string[],
456
464
  databaseId?: string,
457
465
  collection: EntityCollection,
458
466
  parentCollectionIds?: string[]
@@ -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
 
@@ -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