@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
@@ -9,6 +9,34 @@ export declare function segmentsToStrippedPath(paths: string[]): string;
9
9
  /**
10
10
  * Extract the collection path routes
11
11
  * `products/B44RG6APH/locales` => [`products`, `locales`]
12
+ *
13
+ * Finds the entity ids by position, which only works while no id contains "/" — with one,
14
+ * every following segment shifts and the wrong elements are kept. Use
15
+ * {@link collectionSegmentsFrom} when the real boundaries are known.
16
+ *
12
17
  * @param path
13
18
  */
14
19
  export declare function fullPathToCollectionSegments(path: string): string[];
20
+ /**
21
+ * The collection chain of an already-segmented path:
22
+ * `['products', 'B44RG6APH', 'locales']` => [`products`, `locales`]
23
+ *
24
+ * The counterpart of {@link fullPathToCollectionSegments} for callers that know the real
25
+ * segment boundaries. Kept as a separate function rather than an extra parameter on that
26
+ * one: these helpers are small enough to be passed point-free, and `paths.map(fn)` would
27
+ * then hand the array index in as the new argument.
28
+ */
29
+ export declare function collectionSegmentsFrom(pathSegments: string[]): string[];
30
+ /**
31
+ * The historical, positional reading of a flattened path:
32
+ * `products/B44RG6APH/locales` => [`products`, `locales`]
33
+ *
34
+ * This produces a **collection chain**, not a datasource `pathSegments` value, and must
35
+ * never be used as one — see `path_segments_no_fabrication.test.ts`. It is only correct
36
+ * while no entity id contains "/", which is why every caller prefers real segments when
37
+ * they are available, and why it is named for what it assumes.
38
+ *
39
+ * It is still the right reading for a path that came from a URL, where entity ids arrive
40
+ * escaped and so cannot contain a bare "/".
41
+ */
42
+ export declare function positionalCollectionSegments(path: string): string[];
@@ -1,5 +1,11 @@
1
1
  import { AuthController, Entity, EntityCollection, Permissions, User } from "../types";
2
- export declare function resolvePermissions<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): Permissions | undefined;
3
- export declare function canEditEntity<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): boolean;
4
- export declare function canCreateEntity<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): boolean;
5
- export declare function canDeleteEntity<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null): boolean;
2
+ /**
3
+ * `pathSegments` is `path` split at its real segment boundaries. It is forwarded so the
4
+ * collection chain handed to a permissions builder stays correct when an entity id contains
5
+ * "/" splitting `path` positionally would keep the wrong elements. Optional everywhere:
6
+ * omitted, behaviour is exactly as before.
7
+ */
8
+ export declare function resolvePermissions<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null, pathSegments?: string[]): Permissions | undefined;
9
+ export declare function canEditEntity<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null, pathSegments?: string[]): boolean;
10
+ export declare function canCreateEntity<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null, pathSegments?: string[]): boolean;
11
+ export declare function canDeleteEntity<M extends Record<string, any>, USER extends User>(collection: EntityCollection<M>, authController: AuthController<USER>, path: string, entity: Entity<M> | null, pathSegments?: string[]): boolean;
@@ -0,0 +1,22 @@
1
+ import type { PreviewType } from "../types";
2
+ /**
3
+ * Is the given value an absolute `http(s)` URL, as opposed to a relative
4
+ * storage path such as `images/my_image.png`?
5
+ *
6
+ * Values saved in the datasource for storage properties can be either, e.g.
7
+ * when the property is configured with `storeUrl: true`, or when the value was
8
+ * written directly pointing to an externally hosted file.
9
+ */
10
+ export declare function isAbsoluteHttpUrl(value?: string | null): boolean;
11
+ /**
12
+ * Extract the lowercase file extension of a URL or path, ignoring any query
13
+ * string or hash fragment. Returns `undefined` when there is no extension.
14
+ */
15
+ export declare function getFileExtensionFromUrl(urlOrPath?: string | null): string | undefined;
16
+ /**
17
+ * Best effort guess of the preview type of a URL or path, based on its file
18
+ * extension. Used when no storage metadata (and therefore no content type) is
19
+ * available, e.g. for externally hosted files.
20
+ * Returns `undefined` when the extension is missing or not recognised.
21
+ */
22
+ export declare function getPreviewTypeFromUrl(urlOrPath?: string | null): PreviewType | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firecms/core",
3
3
  "type": "module",
4
- "version": "3.4.0-canary.f6a889a",
4
+ "version": "3.4.0",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/firecmsco"
@@ -53,8 +53,8 @@
53
53
  "@dnd-kit/core": "^6.3.1",
54
54
  "@dnd-kit/modifiers": "^9.0.0",
55
55
  "@dnd-kit/sortable": "^10.0.0",
56
- "@firecms/formex": "3.4.0-canary.f6a889a",
57
- "@firecms/ui": "3.4.0-canary.f6a889a",
56
+ "@firecms/formex": "3.4.0",
57
+ "@firecms/ui": "3.4.0",
58
58
  "@floating-ui/dom": "^1.7.4",
59
59
  "@radix-ui/react-portal": "^1.1.10",
60
60
  "@radix-ui/react-slot": "^1.2.4",
@@ -89,7 +89,7 @@
89
89
  "prosemirror-view": "^1.33.8",
90
90
  "react-dropzone": "^14.3.8",
91
91
  "react-fast-compare": "^3.2.2",
92
- "react-i18next": "^14.1.3",
92
+ "react-i18next": "^17.0.11",
93
93
  "react-image-crop": "^11.0.10",
94
94
  "react-markdown": "^9.1.0",
95
95
  "react-moveable": "^0.56.0",
@@ -96,6 +96,8 @@ export function DeleteEntityDialog<M extends Record<string, any>>({
96
96
  deleteEntityWithCallbacks({
97
97
  dataSource,
98
98
  entity,
99
+ // The entity is the authority on where it lives; stated rather than left implicit.
100
+ pathSegments: entity.pathSegments,
99
101
  collection: resolvedCollection,
100
102
  callbacks,
101
103
  onDeleteSuccess,
@@ -3,7 +3,7 @@ import React, { MouseEvent, useCallback } from "react";
3
3
  import { CollectionSize, Entity, EntityAction, EntityCollection, SelectionController } from "../../types";
4
4
  import { Badge, Checkbox, cls, IconButton, Menu, MenuItem, MoreVertIcon, Skeleton, Tooltip } from "@firecms/ui";
5
5
  import { useFireCMSContext, useLargeLayout } from "../../hooks";
6
- import { getEntityFromCache } from "../../util/entity_cache";
6
+ import { entityCacheKey, getEntityFromCache } from "../../util/entity_cache";
7
7
  import { getLocalChangesBackup } from "../../util";
8
8
  import { getChanges } from "../../form/EntityForm";
9
9
 
@@ -82,7 +82,7 @@ export const EntityCollectionRowActions = function EntityCollectionRowActions({
82
82
  const collapsedActions = actions.filter(a => a.collapsed || a.collapsed === undefined);
83
83
  const uncollapsedActions = actions.filter(a => a.collapsed === false);
84
84
  const enableLocalChangesBackup = collection ? getLocalChangesBackup(collection) : false;
85
- const cachedData = enableLocalChangesBackup ? getEntityFromCache(fullPath + "/" + entity.id) : undefined;
85
+ const cachedData = enableLocalChangesBackup ? getEntityFromCache(entityCacheKey(fullPath, entity.id)) : undefined;
86
86
  const hasDraft = (() => {
87
87
  if (!cachedData || typeof cachedData !== "object" || Object.keys(cachedData).length === 0) return false;
88
88
  const realChanges = getChanges(cachedData as any, (entity?.values ?? {}) as any);
@@ -354,6 +354,7 @@ export const EntityCollectionTable = function EntityCollectionTable<M extends Re
354
354
  onTextSearch={textSearchEnabled ? onTextSearch : undefined}
355
355
  textSearchLoading={textSearchLoading}
356
356
  onTextSearchClick={textSearchEnabled ? onTextSearchClick : undefined}
357
+ initialSearchString={tableController.searchString}
357
358
  title={title}
358
359
  actionsStart={actionsStart}
359
360
  actions={actions}
@@ -20,6 +20,11 @@ interface CollectionTableToolbarProps {
20
20
  onTextSearchClick?: () => void;
21
21
  onTextSearch?: (searchString?: string) => void;
22
22
  textSearchLoading?: boolean;
23
+ /**
24
+ * Search term the search bar should start with, typically the one restored from the
25
+ * URL query params by the table controller.
26
+ */
27
+ initialSearchString?: string;
23
28
  }
24
29
 
25
30
  export function CollectionTableToolbar({
@@ -29,6 +34,7 @@ export function CollectionTableToolbar({
29
34
  onTextSearch,
30
35
  onTextSearchClick,
31
36
  textSearchLoading,
37
+ initialSearchString,
32
38
  title,
33
39
  viewModeToggle
34
40
  }: CollectionTableToolbarProps) {
@@ -79,6 +85,7 @@ export function CollectionTableToolbar({
79
85
  disabled={Boolean(onTextSearchClick)}
80
86
  onClick={onTextSearchClick}
81
87
  onTextSearch={onTextSearchClick ? undefined : onTextSearch}
88
+ initialValue={initialSearchString}
82
89
  placeholder={t("search")}
83
90
  expandable={true} />}
84
91
 
@@ -26,6 +26,8 @@ import { OnCellValueChangeParams } from "../../../common";
26
26
  interface PopupFormFieldProps<M extends Record<string, any>> {
27
27
  customFieldValidator?: CustomFieldValidator;
28
28
  path: string;
29
+ /** `path` split at its real segment boundaries; forwarded to the datasource. */
30
+ pathSegments?: string[];
29
31
  entityId: string;
30
32
  tableKey: string;
31
33
  propertyKey?: Extract<keyof M, string>;
@@ -54,6 +56,7 @@ export function PopupFormFieldLoading<M extends Record<string, any>>({
54
56
  propertyKey,
55
57
  collection: inputCollection,
56
58
  path,
59
+ pathSegments,
57
60
  cellRect,
58
61
  open,
59
62
  onClose,
@@ -66,11 +69,12 @@ export function PopupFormFieldLoading<M extends Record<string, any>>({
66
69
  if (entityId && inputCollection) {
67
70
  dataSource.fetchEntity({
68
71
  path,
72
+ pathSegments,
69
73
  entityId,
70
74
  collection: inputCollection
71
75
  }).then(setEntity);
72
76
  }
73
- }, [entityId, inputCollection, dataSource, path]);
77
+ }, [entityId, inputCollection, dataSource, path, pathSegments]);
74
78
 
75
79
  if (!entity) return null;
76
80
  return <PopupFormFieldInternal {...{
@@ -42,6 +42,8 @@ export type EntityCollectionBoardViewProps<M extends Record<string, any> = any>
42
42
  collection: EntityCollection<M>;
43
43
  tableController: EntityTableController<M>;
44
44
  fullPath: string;
45
+ /** `fullPath` split at its real segment boundaries; forwarded to the datasource. */
46
+ pathSegments?: string[];
45
47
  parentCollectionIds?: string[];
46
48
  columnProperty: string;
47
49
  onEntityClick?: (entity: Entity<M>) => void;
@@ -60,6 +62,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
60
62
  collection,
61
63
  tableController,
62
64
  fullPath,
65
+ pathSegments,
63
66
  parentCollectionIds = [],
64
67
  columnProperty,
65
68
  onEntityClick,
@@ -169,6 +172,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
169
172
  // Use the new per-column data controller
170
173
  const boardDataController = useBoardDataController<M>({
171
174
  fullPath,
175
+ pathSegments,
172
176
  collection,
173
177
  columnProperty,
174
178
  columns,
@@ -270,6 +274,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
270
274
 
271
275
  currentDataSource.countEntities({
272
276
  path: fullPath,
277
+ pathSegments,
273
278
  collection: currentCollection
274
279
  }).then(count => {
275
280
  totalCount = count;
@@ -281,6 +286,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
281
286
 
282
287
  currentDataSource.countEntities({
283
288
  path: fullPath,
289
+ pathSegments,
284
290
  collection: currentCollection,
285
291
  filter: { [orderProperty]: ["!=", null] } as FilterValues<string>
286
292
  }).then(count => {
@@ -432,6 +438,8 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
432
438
 
433
439
  const saveProps: SaveEntityProps = {
434
440
  path: entity.path,
441
+ // Paired with `entity.path`: the segments the entity was loaded with.
442
+ pathSegments: entity.pathSegments,
435
443
  entityId: entity.id,
436
444
  values: updatedValues as M,
437
445
  previousValues: entity.values,
@@ -472,6 +480,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
472
480
  console.log("Fetching all documents from collection...");
473
481
  const allDocs = await dataSource.fetchCollection<M>({
474
482
  path: fullPath,
483
+ pathSegments,
475
484
  collection,
476
485
  limit: 10000 // Fetch all
477
486
  });
@@ -492,6 +501,8 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
492
501
 
493
502
  const saveProps: SaveEntityProps = {
494
503
  path: entity.path,
504
+ // Paired with `entity.path`: the segments the entity was loaded with.
505
+ pathSegments: entity.pathSegments,
495
506
  entityId: entity.id,
496
507
  values: updatedValues as M,
497
508
  previousValues: entity.values,
@@ -663,6 +674,7 @@ export function EntityCollectionBoardView<M extends Record<string, any> = any>({
663
674
  });
664
675
  sideEntityController.open({
665
676
  path: fullPath,
677
+ pathSegments,
666
678
  collection,
667
679
  entityId: undefined,
668
680
  updateUrl: true,
@@ -90,6 +90,7 @@ import { useSelectionController } from "./useSelectionController";
90
90
  import { EntityCollectionViewStartActions } from "./EntityCollectionViewStartActions";
91
91
  import { addRecentId, getRecentIds } from "./utils";
92
92
  import { useScrollRestoration } from "../common/useScrollRestoration";
93
+ import { entityCacheKey } from "../../util/entity_cache";
93
94
 
94
95
  const DEFAULT_ENTITY_OPEN_MODE: "side_panel" | "full_screen" = "side_panel";
95
96
 
@@ -106,6 +107,14 @@ export type EntityCollectionViewProps<M extends Record<string, any>> = {
106
107
  * Full path using navigation ids.
107
108
  */
108
109
  fullIdPath?: string;
110
+ /**
111
+ * `fullPath` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
112
+ * Forwarded to the datasource so a parent entity id containing "/" stays unambiguous.
113
+ *
114
+ * Optional, and never derived by splitting `fullPath` — a guess would be wrong in
115
+ * exactly the case the field exists for. Absent means "not known here", not "no slashes".
116
+ */
117
+ pathSegments?: string[];
109
118
  /**
110
119
  * If this is a subcollection, specify the parent collection ids.
111
120
  */
@@ -152,6 +161,7 @@ export const EntityCollectionView = React.memo(
152
161
  function EntityCollectionView<M extends Record<string, any>>({
153
162
  fullPath: fullPathProp,
154
163
  fullIdPath,
164
+ pathSegments,
155
165
  parentCollectionIds,
156
166
  isSubCollection,
157
167
  className,
@@ -190,7 +200,7 @@ export const EntityCollectionView = React.memo(
190
200
  collectionRef.current = collection;
191
201
  }, [collection]);
192
202
 
193
- const canCreateEntities = canCreateEntity(collection, authController, fullPath, null);
203
+ const canCreateEntities = canCreateEntity(collection, authController, fullPath, null, pathSegments);
194
204
  const [highlightedEntity, setHighlightedEntity] = useState<Entity<M> | undefined>(undefined);
195
205
  const [deleteEntityClicked, setDeleteEntityClicked] = React.useState<Entity<M> | Entity<M>[] | undefined>(undefined);
196
206
 
@@ -220,7 +230,7 @@ export const EntityCollectionView = React.memo(
220
230
 
221
231
  const checkInlineEditing = useCallback((entity?: Entity<any>): boolean => {
222
232
  const collection = collectionRef.current;
223
- if (!canEditEntity(collection, authController, fullPath, entity ?? null)) {
233
+ if (!canEditEntity(collection, authController, fullPath, entity ?? null, pathSegments)) {
224
234
  return false;
225
235
  }
226
236
  return collection.inlineEditing === undefined || collection.inlineEditing;
@@ -317,6 +327,7 @@ export const EntityCollectionView = React.memo(
317
327
 
318
328
  const tableController = useDataSourceTableController<M>({
319
329
  fullPath,
330
+ pathSegments,
320
331
  collection,
321
332
  lastDeleteTimestamp,
322
333
  scrollRestoration,
@@ -342,10 +353,16 @@ export const EntityCollectionView = React.memo(
342
353
  addRecentId(collection.id, clickedEntity.id);
343
354
  }
344
355
 
345
- const path = collection?.collectionGroup ? clickedEntity.path : (fullPath ?? clickedEntity.path);
356
+ // In a collection group the row's own path is the real one, so the row's segments
357
+ // are the matching ones; otherwise both come from this view. `path` and
358
+ // `pathSegments` are picked from the same source so they cannot describe
359
+ // different chains.
360
+ const isCollectionGroup = Boolean(collection?.collectionGroup);
361
+ const path = isCollectionGroup ? clickedEntity.path : (fullPath ?? clickedEntity.path);
346
362
  navigateToEntity({
347
363
  navigation,
348
364
  path,
365
+ pathSegments: isCollectionGroup ? clickedEntity.pathSegments : (pathSegments ?? clickedEntity.pathSegments),
349
366
  fullIdPath,
350
367
  sideEntityController,
351
368
  openEntityMode,
@@ -365,6 +382,7 @@ export const EntityCollectionView = React.memo(
365
382
  collection,
366
383
  entityId: undefined,
367
384
  path: fullPath,
385
+ pathSegments,
368
386
  fullIdPath,
369
387
  sideEntityController,
370
388
  navigation,
@@ -440,6 +458,7 @@ export const EntityCollectionView = React.memo(
440
458
  const onViewModeChange = useCallback((mode: ViewMode) => {
441
459
  analyticsController.onAnalyticsEvent?.("view_mode_changed", {
442
460
  path: fullPath,
461
+ pathSegments,
443
462
  from: viewMode,
444
463
  to: mode
445
464
  });
@@ -450,7 +469,7 @@ export const EntityCollectionView = React.memo(
450
469
  }
451
470
  }, [setViewMode, userConfigPersistence, onCollectionModifiedForUser, fullPath, analyticsController, viewMode]);
452
471
 
453
- const createEnabled = canCreateEntity(collection, authController, fullPath, null);
472
+ const createEnabled = canCreateEntity(collection, authController, fullPath, null, pathSegments);
454
473
 
455
474
  const uniqueFieldValidator: UniqueFieldValidator = useCallback(
456
475
  ({
@@ -458,8 +477,8 @@ export const EntityCollectionView = React.memo(
458
477
  value,
459
478
  property,
460
479
  entityId
461
- }) => dataSource.checkUniqueField(fullPath, name, value, entityId, collection),
462
- [fullPath]);
480
+ }) => dataSource.checkUniqueField(fullPath, name, value, entityId, collection, pathSegments),
481
+ [fullPath, pathSegments]);
463
482
 
464
483
  const onValueChange: OnCellValueChange<any, any> = ({
465
484
  value,
@@ -473,6 +492,8 @@ export const EntityCollectionView = React.memo(
473
492
 
474
493
  const saveProps: SaveEntityProps = {
475
494
  path: entity.path ?? fullPath,
495
+ // Paired with the `path` above: the row's segments describe the row's path.
496
+ pathSegments: entity.path ? entity.pathSegments : pathSegments,
476
497
  entityId: entity.id,
477
498
  values: updatedValues,
478
499
  previousValues: entity.values,
@@ -503,7 +524,7 @@ export const EntityCollectionView = React.memo(
503
524
 
504
525
  };
505
526
 
506
- const resolvedFullPath = navigation.resolveIdsFrom(fullPath);
527
+ const resolvedFullPath = navigation.resolveIdsFrom(fullPath, pathSegments);
507
528
  const resolvedCollection = useMemo(() => resolveCollection<M>({
508
529
  collection,
509
530
  path: fullPath,
@@ -581,6 +602,7 @@ export const EntityCollectionView = React.memo(
581
602
  const onKanbanPropertyChange = useCallback((property: string) => {
582
603
  analyticsController.onAnalyticsEvent?.("kanban_property_changed", {
583
604
  path: fullPath,
605
+ pathSegments,
584
606
  property
585
607
  });
586
608
  setSelectedKanbanProperty(property);
@@ -645,6 +667,7 @@ export const EntityCollectionView = React.memo(
645
667
  entityId: entity.id,
646
668
  selectedTab: subcollection.id ?? subcollection.path,
647
669
  path: fullPath,
670
+ pathSegments,
648
671
  fullIdPath,
649
672
  navigation,
650
673
  sideEntityController
@@ -663,13 +686,15 @@ export const EntityCollectionView = React.memo(
663
686
  width: 260,
664
687
  dependencies: [],
665
688
  Builder: ({ entity }) => {
666
- const collectionsWithPath = navigation.getParentReferencesFromPath(entity.path);
689
+ // `entity.path` is a RAW datasource path, so its segments are passed:
690
+ // splitting it would mis-read any parent id containing "/".
691
+ const collectionsWithPath = navigation.getParentReferencesFromPath(entity.path, entity.pathSegments);
667
692
  return (
668
693
  <div className={"flex flex-col gap-2 w-full"}>
669
694
  {collectionsWithPath.map((reference) => {
670
695
  return (
671
696
  <ReferencePreview
672
- key={reference.path + "/" + reference.id}
697
+ key={entityCacheKey(reference.path, reference.id)}
673
698
  reference={reference}
674
699
  size={"small"} />
675
700
  );
@@ -700,7 +725,7 @@ export const EntityCollectionView = React.memo(
700
725
  entity?: Entity<M>,
701
726
  customEntityActions?: EntityAction[]
702
727
  }): EntityAction[] => {
703
- const deleteEnabled = entity ? canDeleteEntity(collection, authController, fullPath, entity) : true;
728
+ const deleteEnabled = entity ? canDeleteEntity(collection, authController, fullPath, entity, entity.pathSegments ?? pathSegments) : true;
704
729
  const actions: EntityAction[] = [
705
730
  { ...editEntityAction, name: t("edit") }
706
731
  ];
@@ -776,6 +801,7 @@ export const EntityCollectionView = React.memo(
776
801
  // EntitiesCount fetches count and updates breadcrumb - no visual rendering needed here
777
802
  const countFetcher = <EntitiesCount
778
803
  fullPath={fullPath}
804
+ pathSegments={pathSegments}
779
805
  collection={collection}
780
806
  filter={tableController.filterValues}
781
807
  sortBy={tableController.sortBy}
@@ -829,9 +855,14 @@ export const EntityCollectionView = React.memo(
829
855
  onTextSearchClick,
830
856
  textSearchEnabled
831
857
  } = useTableSearchHelper({
858
+ pathSegments,
832
859
  collection,
833
860
  fullPath: resolvedFullPath,
834
- parentCollectionIds
861
+ parentCollectionIds,
862
+ // When the search term comes from the URL instead of the search bar, text search
863
+ // has never been initialised for this collection. Initialise it so the search bar
864
+ // is usable (editable and clearable) right away.
865
+ initialSearchString: tableController.searchString
835
866
  });
836
867
 
837
868
  // Popover open state managed at parent level to prevent closing when view changes
@@ -881,6 +912,7 @@ export const EntityCollectionView = React.memo(
881
912
  loading={tableController.dataLoading}
882
913
  onTextSearch={textSearchEnabled && textSearchInitialised ? tableController.setSearchString : undefined}
883
914
  onTextSearchClick={textSearchEnabled && !textSearchInitialised ? onTextSearchClick : undefined}
915
+ initialSearchString={tableController.searchString}
884
916
  textSearchLoading={textSearchLoading}
885
917
  viewModeToggle={viewModeToggleElement}
886
918
  actionsStart={<EntityCollectionViewStartActions
@@ -888,6 +920,7 @@ export const EntityCollectionView = React.memo(
888
920
  collection={collection}
889
921
  tableController={tableController}
890
922
  path={fullPath}
923
+ pathSegments={pathSegments}
891
924
  relativePath={collection.path}
892
925
  selectionController={usedSelectionController}
893
926
  collectionEntitiesCount={docsCount}
@@ -899,6 +932,7 @@ export const EntityCollectionView = React.memo(
899
932
  onMultipleDeleteClick={onMultipleDeleteClick}
900
933
  onNewClick={onNewClick}
901
934
  path={fullPath}
935
+ pathSegments={pathSegments}
902
936
  relativePath={collection.path}
903
937
  selectionController={usedSelectionController}
904
938
  selectionEnabled={selectionEnabled}
@@ -915,6 +949,7 @@ export const EntityCollectionView = React.memo(
915
949
  collection={collection}
916
950
  tableController={tableController}
917
951
  fullPath={fullPath}
952
+ pathSegments={pathSegments}
918
953
  parentCollectionIds={parentCollectionIds}
919
954
  columnProperty={selectedKanbanProperty}
920
955
  onEntityClick={onEntityClick}
@@ -1006,6 +1041,7 @@ export const EntityCollectionView = React.memo(
1006
1041
  getIdColumnWidth={getIdColumnWidth}
1007
1042
  additionalIDHeaderWidget={<EntityIdHeaderWidget
1008
1043
  path={fullPath}
1044
+ pathSegments={pathSegments}
1009
1045
  fullIdPath={fullIdPath ?? fullPath}
1010
1046
  collection={collection} />}
1011
1047
  openEntityMode={openEntityMode}
@@ -1045,6 +1081,7 @@ export const EntityCollectionView = React.memo(
1045
1081
 
1046
1082
  {popupCell && <PopupFormField
1047
1083
  key={`popup_form_${popupCell?.propertyKey}_${popupCell?.entityId}`}
1084
+ pathSegments={pathSegments}
1048
1085
  open={Boolean(popupCell)}
1049
1086
  onClose={onPopupClose}
1050
1087
  cellRect={popupCell?.cellRect}
@@ -1100,13 +1137,16 @@ function EntitiesCount({
1100
1137
  collection,
1101
1138
  filter,
1102
1139
  sortBy,
1103
- onCountChange
1140
+ onCountChange,
1141
+ pathSegments
1104
1142
  }: {
1105
1143
  fullPath: string,
1106
1144
  collection: EntityCollection,
1107
1145
  filter?: FilterValues<any>,
1108
1146
  sortBy?: [string, "asc" | "desc"],
1109
1147
  onCountChange?: (count: number) => void,
1148
+ /** `fullPath` split at its real segment boundaries; forwarded to countEntities. */
1149
+ pathSegments?: string[],
1110
1150
  }) {
1111
1151
 
1112
1152
  const dataSource = useDataSource(collection);
@@ -1116,18 +1156,24 @@ function EntitiesCount({
1116
1156
 
1117
1157
  const sortByProperty = sortBy ? sortBy[0] : undefined;
1118
1158
  const currentSort = sortBy ? sortBy[1] : undefined;
1119
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
1159
+ // Resolved together so the two representations always describe the same chain.
1160
+ // Segments are resolved, never derived (see useEntityFetch).
1161
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
1162
+ const resolvedPathSegments = useMemo(() => pathSegments
1163
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
1164
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
1120
1165
 
1121
1166
  useEffect(() => {
1122
1167
  if (dataSource.countEntities)
1123
1168
  dataSource.countEntities({
1124
1169
  path: resolvedPath,
1170
+ pathSegments: resolvedPathSegments,
1125
1171
  collection,
1126
1172
  filter,
1127
1173
  orderBy: sortByProperty,
1128
1174
  order: currentSort
1129
1175
  }).then(setCount).catch(setError);
1130
- }, [fullPath, dataSource.countEntities, resolvedPath, collection, filter, sortByProperty, currentSort]);
1176
+ }, [fullPath, dataSource.countEntities, resolvedPath, resolvedPathSegments, collection, filter, sortByProperty, currentSort]);
1131
1177
 
1132
1178
  useEffect(() => {
1133
1179
  if (onCountChange && count !== undefined) {
@@ -1156,10 +1202,13 @@ function buildPropertyWidthOverwrite(key: string, width: number): PartialEntityC
1156
1202
  function EntityIdHeaderWidget({
1157
1203
  collection,
1158
1204
  path,
1205
+ pathSegments,
1159
1206
  fullIdPath
1160
1207
  }: {
1161
1208
  collection: EntityCollection,
1162
1209
  path: string,
1210
+ /** `path` split at its real segment boundaries, when the caller knows them. */
1211
+ pathSegments?: string[],
1163
1212
  fullIdPath: string
1164
1213
  }) {
1165
1214
 
@@ -1199,6 +1248,7 @@ function EntityIdHeaderWidget({
1199
1248
  collection,
1200
1249
  entityId,
1201
1250
  path,
1251
+ pathSegments,
1202
1252
  fullIdPath,
1203
1253
  sideEntityController,
1204
1254
  navigation
@@ -1224,7 +1274,7 @@ function EntityIdHeaderWidget({
1224
1274
  </form>
1225
1275
  {recentIds && recentIds.length > 0 && <div className="flex flex-col gap-2 p-2">
1226
1276
  {recentIds.map(id => (
1227
- <ReferencePreview reference={new EntityReference(id, path)}
1277
+ <ReferencePreview reference={new EntityReference(id, path, undefined, pathSegments)}
1228
1278
  key={id}
1229
1279
  hover={true}
1230
1280
  onClick={() => {
@@ -1234,6 +1284,7 @@ function EntityIdHeaderWidget({
1234
1284
  collection,
1235
1285
  entityId: id,
1236
1286
  path,
1287
+ pathSegments,
1237
1288
  fullIdPath,
1238
1289
  sideEntityController,
1239
1290
  navigation
@@ -22,6 +22,8 @@ import { useTranslation } from "../../hooks/useTranslation";
22
22
  export type EntityCollectionViewActionsProps<M extends Record<string, any>> = {
23
23
  collection: EntityCollection<M>;
24
24
  path: string;
25
+ /** `path` split at its real segment boundaries, when known. Never derived from `path`. */
26
+ pathSegments?: string[];
25
27
  relativePath: string;
26
28
  parentCollectionIds: string[];
27
29
  selectionEnabled: boolean;
@@ -40,6 +42,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
40
42
  onMultipleDeleteClick,
41
43
  selectionEnabled,
42
44
  path,
45
+ pathSegments,
43
46
  selectionController,
44
47
  tableController,
45
48
  collectionEntitiesCount,
@@ -56,7 +59,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
56
59
 
57
60
  const selectedEntities = selectionController.selectedEntities;
58
61
 
59
- const addButton = canCreateEntity(collection, authController, path, null) &&
62
+ const addButton = canCreateEntity(collection, authController, path, null, pathSegments) &&
60
63
  onNewClick && (largeLayout
61
64
  ? <Button
62
65
  id={`add_entity_${path}`}
@@ -75,7 +78,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
75
78
  <AddIcon size={"small"} />
76
79
  </Button>);
77
80
 
78
- const multipleDeleteEnabled = canDeleteEntity(collection, authController, path, null);
81
+ const multipleDeleteEnabled = canDeleteEntity(collection, authController, path, null, pathSegments);
79
82
 
80
83
  let multipleDeleteButton: React.ReactNode | undefined;
81
84
  if (selectionEnabled) {
@@ -107,6 +110,7 @@ export function EntityCollectionViewActions<M extends Record<string, any>>({
107
110
 
108
111
  const actionProps: CollectionActionsProps = {
109
112
  path,
113
+ pathSegments,
110
114
  relativePath,
111
115
  parentCollectionIds,
112
116
  collection,