@firecms/core 3.4.0-canary.f6a889a → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  2. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  5. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  8. package/dist/components/ErrorBoundary.d.ts +1 -3
  9. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  10. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  11. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  12. package/dist/components/common/table_url_params.d.ts +24 -0
  13. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  14. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  15. package/dist/core/EntityEditView.d.ts +9 -1
  16. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  17. package/dist/form/EntityForm.d.ts +8 -1
  18. package/dist/form/EntityFormActions.d.ts +2 -0
  19. package/dist/hooks/data/delete.d.ts +1 -1
  20. package/dist/hooks/data/save.d.ts +1 -1
  21. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  22. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  23. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  24. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  25. package/dist/index.es.js +1753 -1170
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +1751 -1168
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/types/collections.d.ts +14 -0
  30. package/dist/types/datasource.d.ts +67 -4
  31. package/dist/types/entities.d.ts +21 -1
  32. package/dist/types/entity_actions.d.ts +6 -0
  33. package/dist/types/entity_callbacks.d.ts +21 -0
  34. package/dist/types/navigation.d.ts +34 -5
  35. package/dist/types/plugins.d.ts +13 -0
  36. package/dist/types/properties.d.ts +17 -1
  37. package/dist/types/side_entity_controller.d.ts +7 -0
  38. package/dist/types/translations.d.ts +6 -0
  39. package/dist/util/__tests__/collections.test.d.ts +1 -0
  40. package/dist/util/__tests__/urls.test.d.ts +1 -0
  41. package/dist/util/collections.d.ts +1 -1
  42. package/dist/util/entity_cache.d.ts +11 -0
  43. package/dist/util/index.d.ts +1 -0
  44. package/dist/util/navigation_from_path.d.ts +7 -0
  45. package/dist/util/navigation_utils.d.ts +87 -3
  46. package/dist/util/parent_references_from_path.d.ts +16 -0
  47. package/dist/util/paths.d.ts +28 -0
  48. package/dist/util/permissions.d.ts +10 -4
  49. package/dist/util/urls.d.ts +22 -0
  50. package/package.json +4 -4
  51. package/src/components/DeleteEntityDialog.tsx +2 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  53. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
  54. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  55. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  56. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  57. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  58. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  60. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  61. package/src/components/EntityPreview.tsx +4 -1
  62. package/src/components/ErrorBoundary.tsx +12 -18
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  64. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  65. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  66. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  67. package/src/components/common/default_entity_actions.tsx +22 -2
  68. package/src/components/common/table_url_params.ts +172 -0
  69. package/src/components/common/useDataSourceTableController.tsx +86 -179
  70. package/src/components/common/useTableSearchHelper.ts +26 -2
  71. package/src/contexts/SnackbarProvider.tsx +14 -1
  72. package/src/core/EntityEditView.tsx +20 -3
  73. package/src/core/EntityEditViewFormActions.tsx +3 -2
  74. package/src/core/EntitySidePanel.tsx +8 -4
  75. package/src/form/EntityForm.tsx +31 -10
  76. package/src/form/EntityFormActions.tsx +2 -0
  77. package/src/hooks/data/delete.ts +8 -0
  78. package/src/hooks/data/save.ts +6 -1
  79. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  80. package/src/hooks/data/useEntityFetch.tsx +35 -4
  81. package/src/hooks/useBuildNavigationController.tsx +23 -8
  82. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  83. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  84. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  85. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  86. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  87. package/src/internal/useBuildDataSource.ts +37 -15
  88. package/src/internal/useBuildSideEntityController.tsx +19 -5
  89. package/src/locales/de.ts +5 -0
  90. package/src/locales/en.ts +5 -0
  91. package/src/locales/es.ts +5 -0
  92. package/src/locales/fr.ts +5 -0
  93. package/src/locales/hi.ts +5 -0
  94. package/src/locales/it.ts +5 -0
  95. package/src/locales/pl.ts +5 -0
  96. package/src/locales/pt.ts +5 -0
  97. package/src/preview/components/ReferencePreview.tsx +7 -3
  98. package/src/preview/components/StorageThumbnail.tsx +24 -2
  99. package/src/routes/FireCMSRoute.tsx +11 -3
  100. package/src/types/collections.ts +16 -0
  101. package/src/types/datasource.ts +67 -4
  102. package/src/types/entities.ts +24 -1
  103. package/src/types/entity_actions.tsx +6 -0
  104. package/src/types/entity_callbacks.ts +24 -0
  105. package/src/types/navigation.ts +35 -5
  106. package/src/types/plugins.tsx +13 -0
  107. package/src/types/properties.ts +17 -0
  108. package/src/types/side_entity_controller.tsx +7 -0
  109. package/src/types/translations.ts +6 -0
  110. package/src/util/__tests__/collections.test.ts +32 -0
  111. package/src/util/__tests__/urls.test.ts +131 -0
  112. package/src/util/collections.ts +1 -1
  113. package/src/util/entities.ts +3 -1
  114. package/src/util/entity_cache.ts +18 -0
  115. package/src/util/index.ts +1 -0
  116. package/src/util/navigation_from_path.ts +25 -2
  117. package/src/util/navigation_utils.ts +208 -2
  118. package/src/util/parent_references_from_path.ts +42 -3
  119. package/src/util/paths.ts +40 -3
  120. package/src/util/permissions.ts +22 -10
  121. package/src/util/previews.ts +8 -1
  122. package/src/util/resolutions.ts +8 -0
  123. package/src/util/urls.ts +52 -0
@@ -1,12 +1,25 @@
1
1
  "use client";
2
2
 
3
- import React, { PropsWithChildren } from "react";
3
+ import React, { PropsWithChildren, useEffect, useState } from "react";
4
4
  import { SnackbarProvider as NotistackSnackbarProvider } from "notistack";
5
5
 
6
6
  export const SnackbarProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
7
7
 
8
+ // Notistack renders the snackbars inline, right where this provider sits in the
9
+ // tree, unless it is given a `domRoot`. Dialogs and side dialogs, on the other
10
+ // hand, are portalled to the body, so any ancestor of this provider that creates
11
+ // a stacking context (a transform, a filter, an `isolate`, a positioned element
12
+ // with a z-index...) traps the snackbars underneath them. Portalling the
13
+ // snackbars to the body as well puts them in the same stacking context as the
14
+ // dialogs, where their z-index actually wins.
15
+ const [domRoot, setDomRoot] = useState<HTMLElement | undefined>(undefined);
16
+ useEffect(() => {
17
+ setDomRoot(document.body);
18
+ }, []);
19
+
8
20
  return (
9
21
  <NotistackSnackbarProvider maxSnack={3}
22
+ domRoot={domRoot}
10
23
  autoHideDuration={3500}>
11
24
  {children}
12
25
  </NotistackSnackbarProvider>
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  import { CircularProgressCenter, EntityCollectionView, EntityView, ErrorBoundary } from "../components";
14
14
  import {
15
+ buildSubcollectionPathSegments,
15
16
  canEditEntity,
16
17
  encodeEntityId,
17
18
  removeInitialAndTrailingSlashes,
@@ -28,7 +29,7 @@ import {
28
29
  useLargeLayout
29
30
  } from "../hooks";
30
31
  import { CircularProgress, cls, CodeIcon, defaultBorderMixin, Tab, Tabs, Typography, Menu, MenuItem, ExpandMoreIcon } from "@firecms/ui";
31
- import { getEntityFromMemoryCache } from "../util/entity_cache";
32
+ import { entityCacheKey, getEntityFromMemoryCache } from "../util/entity_cache";
32
33
  import { EntityForm, EntityFormProps } from "../form";
33
34
  import { EntityEditViewFormActions } from "./EntityEditViewFormActions";
34
35
  import { EntityJsonPreview } from "../components/EntityJsonPreview";
@@ -42,6 +43,8 @@ export type OnUpdateParams = {
42
43
  entity: Entity<any>,
43
44
  status: EntityStatus,
44
45
  path: string,
46
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
47
+ pathSegments?: string[],
45
48
  entityId?: string;
46
49
  selectedTab?: string;
47
50
  collection: EntityCollection<any>
@@ -71,6 +74,12 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
71
74
  * The navigation path to the entity.
72
75
  */
73
76
  fullIdPath?: string;
77
+ /**
78
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
79
+ * Forwarded to the datasource, and extended for any subcollection rendered inside this
80
+ * view, so a parent entity id containing "/" stays unambiguous.
81
+ */
82
+ pathSegments?: string[];
74
83
  collection: EntityCollection<M>;
75
84
  entityId?: string;
76
85
  databaseId?: string;
@@ -101,6 +110,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
101
110
  dataLoadingError
102
111
  } = useEntityFetch<M, USER>({
103
112
  path: props.path,
113
+ pathSegments: props.pathSegments,
104
114
  entityId: entityId,
105
115
  collection: props.collection,
106
116
  databaseId: props.databaseId,
@@ -108,7 +118,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
108
118
  });
109
119
 
110
120
  const initialDirtyValues = entityId
111
- ? getEntityFromMemoryCache(props.path + "/" + entityId)
121
+ ? getEntityFromMemoryCache(entityCacheKey(props.path, entityId))
112
122
  : getEntityFromMemoryCache(props.path + "#new");
113
123
 
114
124
  const authController = useAuthController();
@@ -120,7 +130,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
120
130
  if (status === "new" || status === "copy") {
121
131
  return true;
122
132
  } else {
123
- return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null) : undefined;
133
+ return entity ? canEditEntity(props.collection, authController, props.path, entity ?? null, props.pathSegments) : undefined;
124
134
  }
125
135
  }, [authController, entity, status]);
126
136
 
@@ -146,6 +156,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
146
156
  export function EntityEditViewInner<M extends Record<string, any>>({
147
157
  path,
148
158
  fullIdPath,
159
+ pathSegments,
149
160
  entityId,
150
161
  selectedTab: selectedTabProp,
151
162
  collection,
@@ -194,6 +205,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
194
205
  entityId,
195
206
  parentCollectionIds,
196
207
  path,
208
+ pathSegments,
197
209
  status,
198
210
  collection,
199
211
  context,
@@ -351,6 +363,9 @@ export function EntityEditViewInner<M extends Record<string, any>>({
351
363
  const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
352
364
  // `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
353
365
  const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
366
+ // Only extendable when we were given real segments — deriving the parent chain
367
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
368
+ const newPathSegments = buildSubcollectionPathSegments(pathSegments, usedEntity?.id, subcollection.path);
354
369
 
355
370
  if (selectedTab !== subcollectionId) return null;
356
371
  return (
@@ -365,6 +380,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
365
380
  (usedEntity && newFullPath
366
381
  ? <EntityCollectionView
367
382
  fullPath={newFullPath}
383
+ pathSegments={newPathSegments}
368
384
  fullIdPath={newFullIdPath}
369
385
  parentCollectionIds={[...parentCollectionIds, collection.id]}
370
386
  isSubCollection={true}
@@ -414,6 +430,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
414
430
  fullIdPath={fullIdPath}
415
431
  collection={collection}
416
432
  path={path}
433
+ pathSegments={pathSegments}
417
434
  entityId={entityId ?? usedEntity?.id}
418
435
  onValuesModified={onValuesModified}
419
436
  entity={entity}
@@ -39,6 +39,7 @@ import { ErrorTooltip } from "../components/ErrorTooltip";
39
39
  export function EntityEditViewFormActions({
40
40
  collection,
41
41
  path,
42
+ pathSegments,
42
43
  entity,
43
44
  layout,
44
45
  savingError,
@@ -63,8 +64,8 @@ export function EntityEditViewFormActions({
63
64
  const customEntityActions = (collection.entityActions ?? [])
64
65
  .map(action => resolveEntityAction(action, customizationController.entityActions))
65
66
  .filter(Boolean) as EntityAction[];
66
- const createEnabled = canCreateEntity(collection, authController, path, null);
67
- const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity) : false;
67
+ const createEnabled = canCreateEntity(collection, authController, path, null, pathSegments);
68
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, path, entity, entity.pathSegments ?? pathSegments) : false;
68
69
  const actions: EntityAction[] = [];
69
70
  if (createEnabled)
70
71
  actions.push(copyEntityAction);
@@ -8,7 +8,7 @@ import { EntityEditView, OnUpdateParams } from "./EntityEditView";
8
8
  import { useSideDialogContext } from "./SideDialogs";
9
9
  import { CloseIcon, IconButton, OpenInFullIcon } from "@firecms/ui";
10
10
  import { useLocation, useNavigate } from "react-router-dom";
11
- import { saveEntityToMemoryCache } from "../util/entity_cache";
11
+ import { entityCacheKey, saveEntityToMemoryCache } from "../util/entity_cache";
12
12
 
13
13
  /**
14
14
  * This is the component in charge of rendering the side dialog used
@@ -25,6 +25,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
25
25
  path,
26
26
  entityId,
27
27
  fullIdPath,
28
+ pathSegments,
28
29
  formProps,
29
30
  } = props;
30
31
 
@@ -59,6 +60,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
59
60
  if (params.status !== "existing") {
60
61
  sideEntityController.replace({
61
62
  path: params.path,
63
+ pathSegments: params.pathSegments ?? pathSegments,
62
64
  entityId: params.entityId,
63
65
  fullIdPath: props.fullIdPath,
64
66
  selectedTab: params.selectedTab,
@@ -75,10 +77,10 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
75
77
  }
76
78
 
77
79
  const parentCollectionIds = useMemo(() => {
78
- return navigationController.getParentCollectionIds(path);
80
+ return navigationController.getParentCollectionIds(path, pathSegments);
79
81
  }, [navigationController, path]);
80
82
 
81
- const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path);
83
+ const collection = props.collection ?? navigationController.getCollection(fullIdPath ?? path, false, pathSegments);
82
84
 
83
85
  useEffect(() => {
84
86
  function beforeunload(e: any) {
@@ -116,6 +118,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
116
118
  <EntityEditView
117
119
  {...props}
118
120
  fullIdPath={fullIdPath}
121
+ pathSegments={pathSegments}
119
122
  layout={"side_panel"}
120
123
  collection={collection as EntityCollection}
121
124
  parentCollectionIds={parentCollectionIds}
@@ -135,7 +138,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
135
138
  className="self-center"
136
139
  size={"smallest"}
137
140
  onClick={() => {
138
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
141
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
139
142
  saveEntityToMemoryCache(key, values);
140
143
  if (entityId)
141
144
  navigate(location.pathname + location.search);
@@ -152,6 +155,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
152
155
  }) => {
153
156
  sideEntityController.replace({
154
157
  path,
158
+ pathSegments,
155
159
  entityId,
156
160
  fullIdPath: props.fullIdPath,
157
161
  selectedTab,
@@ -48,6 +48,7 @@ import { useAnalyticsController } from "../hooks/useAnalyticsController";
48
48
  import { FormEntry, FormLayout, LabelWithIconAndTooltip, PropertyFieldBinding } from "../form";
49
49
  import { ValidationError } from "yup";
50
50
  import {
51
+ entityCacheKey,
51
52
  flattenKeys,
52
53
  getEntityFromCache,
53
54
  removeEntityFromCache,
@@ -64,6 +65,8 @@ export type OnUpdateParams = {
64
65
  entity: Entity<any>,
65
66
  status: EntityStatus,
66
67
  path: string,
68
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
69
+ pathSegments?: string[],
67
70
  entityId?: string;
68
71
  selectedTab?: string;
69
72
  collection: EntityCollection<any>
@@ -71,6 +74,11 @@ export type OnUpdateParams = {
71
74
 
72
75
  export type EntityFormProps<M extends Record<string, any>> = {
73
76
  path: string;
77
+ /**
78
+ * `path` split at its real segment boundaries; forwarded to the datasource for
79
+ * generateEntityId and checkUniqueField. Never derived from `path`.
80
+ */
81
+ pathSegments?: string[];
74
82
  fullIdPath?: string;
75
83
  collection: EntityCollection<M>;
76
84
  entityId?: string;
@@ -214,6 +222,7 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
214
222
 
215
223
  export function EntityForm<M extends Record<string, any>>({
216
224
  path,
225
+ pathSegments,
217
226
  fullIdPath,
218
227
  entityId: entityIdProp,
219
228
  collection,
@@ -270,6 +279,7 @@ export function EntityForm<M extends Record<string, any>>({
270
279
  entityId: entityIdProp,
271
280
  collection,
272
281
  path,
282
+ pathSegments,
273
283
  values: valuesToBeSaved
274
284
  });
275
285
  }, false, 2000);
@@ -292,7 +302,7 @@ export function EntityForm<M extends Record<string, any>>({
292
302
  if (mustSetCustomId) {
293
303
  return undefined;
294
304
  } else {
295
- return dataSource.generateEntityId(path, collection);
305
+ return dataSource.generateEntityId(path, collection, pathSegments);
296
306
  }
297
307
  } else {
298
308
  return entityIdProp;
@@ -308,7 +318,7 @@ export function EntityForm<M extends Record<string, any>>({
308
318
  const baseInitialValues = useMemo(() => getInitialEntityValues(authController, collection, path, status, entity, customizationController.propertyConfigs), [authController, collection, path, status, entity, customizationController.propertyConfigs]);
309
319
 
310
320
  const localChangesDataRaw = useMemo(() => entityId
311
- ? getEntityFromCache(path + "/" + entityId)
321
+ ? getEntityFromCache(entityCacheKey(path, entityId))
312
322
  : getEntityFromCache(path + "#new"), [entityId, path]);
313
323
 
314
324
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
@@ -387,8 +397,8 @@ export function EntityForm<M extends Record<string, any>>({
387
397
  onValuesModified?.(false, initialValues as M);
388
398
  },
389
399
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
390
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
391
- if (controller.dirty) {
400
+ const key = (status === "new" || status === "copy") ? path + "#new" : entityCacheKey(path, entityId);
401
+ if (controller.dirty && localChangesBackup !== false) {
392
402
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
393
403
  if (touchedValues && Object.keys(touchedValues).length > 0) {
394
404
  saveEntityToCache(key, touchedValues);
@@ -468,8 +478,8 @@ export function EntityForm<M extends Record<string, any>>({
468
478
  removeEntityFromMemoryCache(path + "#new");
469
479
  removeEntityFromCache(path + "#new");
470
480
  } else {
471
- removeEntityFromMemoryCache(path + "/" + entityId);
472
- removeEntityFromCache(path + "/" + entityId);
481
+ removeEntityFromMemoryCache(entityCacheKey(path, entityId));
482
+ removeEntityFromCache(entityCacheKey(path, entityId));
473
483
  }
474
484
  }
475
485
 
@@ -491,6 +501,7 @@ export function EntityForm<M extends Record<string, any>>({
491
501
  entity: updatedEntity,
492
502
  status,
493
503
  path,
504
+ pathSegments,
494
505
  entityId: updatedEntity.id,
495
506
  collection
496
507
  });
@@ -511,16 +522,20 @@ export function EntityForm<M extends Record<string, any>>({
511
522
  previousValues,
512
523
  entityId,
513
524
  collection,
514
- path
525
+ path,
526
+ pathSegments
515
527
  }: {
516
528
  collection: EntityCollection<M>,
517
529
  path: string,
530
+ /** `path` split at its real segment boundaries, travelling with the `path` above. */
531
+ pathSegments?: string[],
518
532
  entityId: string | undefined,
519
533
  values: M,
520
534
  previousValues?: M,
521
535
  }) => {
522
536
  return saveEntityWithCallbacks({
523
537
  path,
538
+ pathSegments,
524
539
  entityId,
525
540
  values,
526
541
  previousValues,
@@ -538,6 +553,7 @@ export function EntityForm<M extends Record<string, any>>({
538
553
  type EntityFormSaveParams<M extends Record<string, any>> = {
539
554
  collection: ResolvedEntityCollection<M>,
540
555
  path: string,
556
+ pathSegments?: string[],
541
557
  entityId: string | undefined,
542
558
  values: EntityValues<M>,
543
559
  previousValues?: EntityValues<M>,
@@ -547,6 +563,7 @@ export function EntityForm<M extends Record<string, any>>({
547
563
  const onSaveEntityRequest = async ({
548
564
  collection,
549
565
  path,
566
+ pathSegments,
550
567
  entityId,
551
568
  values,
552
569
  previousValues,
@@ -560,6 +577,7 @@ export function EntityForm<M extends Record<string, any>>({
560
577
  return saveEntity({
561
578
  collection,
562
579
  path,
580
+ pathSegments,
563
581
  entityId,
564
582
  values,
565
583
  previousValues
@@ -573,6 +591,7 @@ export function EntityForm<M extends Record<string, any>>({
573
591
  return onSaveEntityRequest({
574
592
  collection: resolvedCollection,
575
593
  path,
594
+ pathSegments,
576
595
  entityId,
577
596
  values,
578
597
  previousValues: entity?.values,
@@ -623,13 +642,14 @@ export function EntityForm<M extends Record<string, any>>({
623
642
  const plugins = customizationController.plugins;
624
643
 
625
644
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
626
- const parentCollectionIds = navigationController.getParentCollectionIds(path);
645
+ const parentCollectionIds = navigationController.getParentCollectionIds(path, pathSegments);
627
646
 
628
647
  if (plugins && collection) {
629
648
  const actionProps: PluginFormActionProps = {
630
649
  entityId,
631
650
  parentCollectionIds,
632
651
  path,
652
+ pathSegments,
633
653
  status,
634
654
  collection,
635
655
  context,
@@ -692,7 +712,7 @@ export function EntityForm<M extends Record<string, any>>({
692
712
  const uniqueFieldValidator: CustomFieldValidator = useCallback(({
693
713
  name,
694
714
  value
695
- }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
715
+ }) => dataSource.checkUniqueField(path, name, value, entityId, collection, pathSegments),
696
716
  [dataSource, path, entityId]);
697
717
 
698
718
  const validationSchema = useMemo(() => entityId
@@ -890,6 +910,7 @@ export function EntityForm<M extends Record<string, any>>({
890
910
  const dialogActions = <EntityFormActionsComponent
891
911
  collection={resolvedCollection}
892
912
  path={path}
913
+ pathSegments={pathSegments}
893
914
  fullPath={path}
894
915
  fullIdPath={fullIdPath}
895
916
  entity={entity}
@@ -924,7 +945,7 @@ export function EntityForm<M extends Record<string, any>>({
924
945
 
925
946
  {manualApplyLocalChanges && hasLocalChanges &&
926
947
  <LocalChangesMenu
927
- cacheKey={status === "new" || status === "copy" ? path + "#new" : path + "/" + entityId}
948
+ cacheKey={status === "new" || status === "copy" ? path + "#new" : entityCacheKey(path, entityId)}
928
949
  properties={resolvedCollection.properties}
929
950
  cachedData={localChangesDataRaw as Partial<M>}
930
951
  formex={formex}
@@ -26,6 +26,8 @@ export interface EntityFormActionsProps {
26
26
  fullIdPath?: string;
27
27
  collection: ResolvedEntityCollection;
28
28
  path: string;
29
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
30
+ pathSegments?: string[];
29
31
  entity?: Entity;
30
32
  layout: "bottom" | "side";
31
33
  savingError?: Error;
@@ -46,6 +46,7 @@ export type DeleteEntityWithCallbacksProps<M extends Record<string, any>, USER e
46
46
  export async function deleteEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
47
47
  dataSource,
48
48
  entity,
49
+ pathSegments,
49
50
  collection,
50
51
  callbacks,
51
52
  onDeleteSuccess,
@@ -62,11 +63,17 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
62
63
 
63
64
  console.debug("Deleting entity", entity.path, entity.id);
64
65
 
66
+ // An explicit `pathSegments` wins, then the ones the entity was loaded with. The prop
67
+ // used to be accepted and silently ignored, which is the same class of bug this field
68
+ // exists to prevent.
69
+ const resolvedPathSegments = pathSegments ?? entity.pathSegments;
70
+
65
71
  const entityDeleteProps: EntityOnDeleteProps<M, any> = {
66
72
  entity,
67
73
  collection,
68
74
  entityId: entity.id,
69
75
  path: entity.path,
76
+ pathSegments: resolvedPathSegments,
70
77
  context
71
78
  };
72
79
 
@@ -81,6 +88,7 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
88
  }
82
89
  }
83
90
  return dataSource.deleteEntity({
91
+ pathSegments: resolvedPathSegments,
84
92
  entity,
85
93
  collection
86
94
  }).then(() => {
@@ -43,6 +43,7 @@ export type SaveEntityWithCallbacksProps<M extends Record<string, any>> =
43
43
  export async function saveEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
44
44
  collection,
45
45
  path,
46
+ pathSegments,
46
47
  entityId,
47
48
  values,
48
49
  previousValues,
@@ -67,7 +68,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
67
68
 
68
69
  const customizationController = context.customizationController;
69
70
 
70
- const resolvedPath = context.navigation.resolveIdsFrom(path);
71
+ const resolvedPath = context.navigation.resolveIdsFrom(path, pathSegments);
71
72
 
72
73
  const callbacks = collection.callbacks;
73
74
  if (callbacks?.onPreSave) {
@@ -83,6 +84,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
83
84
  updatedValues = await callbacks.onPreSave({
84
85
  collection: resolvedCollection,
85
86
  path,
87
+ pathSegments,
86
88
  resolvedPath,
87
89
  entityId,
88
90
  values,
@@ -103,6 +105,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
103
105
  return dataSource.saveEntity({
104
106
  collection,
105
107
  path: resolvedPath,
108
+ pathSegments,
106
109
  entityId,
107
110
  values: updatedValues,
108
111
  previousValues,
@@ -121,6 +124,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
121
124
  callbacks.onSaveSuccess({
122
125
  collection: resolvedCollection,
123
126
  path,
127
+ pathSegments,
124
128
  resolvedPath,
125
129
  entityId: entity.id,
126
130
  values: updatedValues,
@@ -150,6 +154,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
150
154
  callbacks.onSaveFailure({
151
155
  collection: resolvedCollection,
152
156
  path,
157
+ pathSegments,
153
158
  resolvedPath,
154
159
  entityId,
155
160
  values: updatedValues,
@@ -13,6 +13,12 @@ export interface CollectionFetchProps<M extends Record<string, any>> {
13
13
  * Absolute collection path
14
14
  */
15
15
  path: string;
16
+ /**
17
+ * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
18
+ * entity id containing "/" stays unambiguous. Never derived from `path` — see
19
+ * `pathSegments` on the datasource props.
20
+ */
21
+ pathSegments?: string[];
16
22
 
17
23
  /**
18
24
  * collection of the entity displayed by this collection
@@ -63,6 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
63
69
  export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
64
70
  {
65
71
  path: inputPath,
72
+ pathSegments: inputPathSegments,
66
73
  collection,
67
74
  filterValues,
68
75
  sortBy,
@@ -73,7 +80,13 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
73
80
  const dataSource = useDataSource(collection);
74
81
  const navigationController = useNavigationController();
75
82
 
76
- const path = navigationController.resolveIdsFrom(inputPath);
83
+ // Resolved together so the two representations always describe the same chain.
84
+ // Segments are resolved, never derived: only collection ids are swapped for their
85
+ // real paths, and entity ids are carried through whole (see useEntityFetch).
86
+ const pathSegments = inputPathSegments
87
+ ? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
88
+ : undefined;
89
+ const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
77
90
 
78
91
  const sortByProperty = sortBy ? sortBy[0] : undefined;
79
92
  const currentSort = sortBy ? sortBy[1] : undefined;
@@ -91,6 +104,10 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
91
104
  setDataLoading(true);
92
105
 
93
106
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
107
+ // See useEntityFetch: entities carry the segments they were loaded with.
108
+ if (pathSegments) {
109
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments });
110
+ }
94
111
  if (collection.callbacks?.onFetch) {
95
112
  try {
96
113
  entities = await Promise.all(
@@ -98,6 +115,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
98
115
  collection.callbacks!.onFetch!({
99
116
  collection,
100
117
  path,
118
+ pathSegments,
101
119
  entity,
102
120
  context
103
121
  })));
@@ -124,6 +142,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
124
142
  if (dataSource.listenCollection) {
125
143
  return dataSource.listenCollection<M>({
126
144
  path,
145
+ pathSegments,
127
146
  collection,
128
147
  onUpdate: onEntitiesUpdate,
129
148
  onError,
@@ -137,6 +156,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
137
156
  } else {
138
157
  dataSource.fetchCollection<M>({
139
158
  path,
159
+ pathSegments,
140
160
  collection,
141
161
  searchString,
142
162
  filter: filterValues,
@@ -3,12 +3,21 @@ import { Entity, EntityCollection, FireCMSContext, User } from "../../types";
3
3
  import { useDataSource } from "./useDataSource";
4
4
  import { useNavigationController } from "../useNavigationController";
5
5
  import { useFireCMSContext } from "../useFireCMSContext";
6
+ import { entityCacheKey } from "../../util/entity_cache";
6
7
 
7
8
  /**
8
9
  * @group Hooks and utilities
9
10
  */
10
11
  export interface EntityFetchProps<M extends Record<string, any>, USER extends User = User> {
11
12
  path: string;
13
+ /**
14
+ * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
15
+ * entity id containing "/" stays unambiguous.
16
+ *
17
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
18
+ * the case the field exists for. Absent means "not known here", not "no slashes".
19
+ */
20
+ pathSegments?: string[];
12
21
  entityId?: string;
13
22
  databaseId?: string;
14
23
  collection: EntityCollection<M, USER>;
@@ -39,6 +48,7 @@ const CACHE: Record<string, Entity<any> | undefined> = {};
39
48
  export function useEntityFetch<M extends Record<string, any>, USER extends User>(
40
49
  {
41
50
  path: inputPath,
51
+ pathSegments: inputPathSegments,
42
52
  entityId,
43
53
  collection,
44
54
  databaseId,
@@ -48,7 +58,19 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
48
58
  const dataSource = useDataSource(collection);
49
59
  const navigationController = useNavigationController();
50
60
 
51
- const path = navigationController.resolveIdsFrom(inputPath);
61
+ // Never fabricate segments. If the caller did not thread them, pass undefined so a
62
+ // delegate can tell "not provided" from a real answer — splitting `path` here would
63
+ // produce a confidently wrong array for any id containing "/".
64
+ //
65
+ // When they were threaded, both representations are resolved from them together, so
66
+ // `path` cannot end up describing a different chain than `pathSegments`.
67
+ // `resolveSegmentsFrom` is optional on the controller: a host app may be supplying one
68
+ // built against an earlier version. Falling back to the segments as given is what
69
+ // happened before it existed — they are used unresolved, never invented.
70
+ const pathSegments = inputPathSegments
71
+ ? (navigationController.resolveSegmentsFrom?.(inputPathSegments) ?? inputPathSegments)
72
+ : undefined;
73
+ const path = navigationController.resolveIdsFrom(inputPath, inputPathSegments);
52
74
 
53
75
  const context: FireCMSContext<USER> = useFireCMSContext();
54
76
 
@@ -61,11 +83,18 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
61
83
  setDataLoading(true);
62
84
 
63
85
  const onEntityUpdate = async (updatedEntity: Entity<M> | undefined) => {
86
+ // Attach the segments this entity was loaded with, so anything derived from it
87
+ // later — a reference, a delete — stays resolvable without re-deriving them
88
+ // from the flattened path. Delegates are not required to set this themselves.
89
+ if (updatedEntity && pathSegments && !updatedEntity.pathSegments) {
90
+ updatedEntity = { ...updatedEntity, pathSegments };
91
+ }
64
92
  if (collection.callbacks?.onFetch && updatedEntity) {
65
93
  try {
66
94
  updatedEntity = await collection.callbacks.onFetch({
67
95
  collection,
68
96
  path,
97
+ pathSegments,
69
98
  entity: updatedEntity,
70
99
  context
71
100
  });
@@ -73,7 +102,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
73
102
  console.error(e);
74
103
  }
75
104
  }
76
- CACHE[`${path}/${entityId}`] = updatedEntity;
105
+ CACHE[entityCacheKey(path, entityId)] = updatedEntity;
77
106
  setEntity(updatedEntity);
78
107
  setDataLoading(false);
79
108
  setDataLoadingError(undefined);
@@ -86,8 +115,8 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
86
115
  setDataLoadingError(error);
87
116
  };
88
117
 
89
- if (entityId && useCache && CACHE[`${path}/${entityId}`]) {
90
- setEntity(CACHE[`${path}/${entityId}`]);
118
+ if (entityId && useCache && CACHE[entityCacheKey(path, entityId)]) {
119
+ setEntity(CACHE[entityCacheKey(path, entityId)]);
91
120
  setDataLoading(false);
92
121
  setDataLoadingError(undefined);
93
122
  // eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -97,6 +126,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
97
126
  if (dataSource.listenEntity) {
98
127
  return dataSource.listenEntity<M>({
99
128
  path,
129
+ pathSegments,
100
130
  entityId,
101
131
  databaseId,
102
132
  collection,
@@ -106,6 +136,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
106
136
  } else {
107
137
  dataSource.fetchEntity<M>({
108
138
  path,
139
+ pathSegments,
109
140
  entityId,
110
141
  databaseId,
111
142
  collection