@firecms/core 3.3.0 → 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 (155) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  3. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  4. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  7. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  8. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  9. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  10. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  11. package/dist/components/EntityPreview.d.ts +3 -1
  12. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  13. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  14. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  15. package/dist/components/common/table_url_params.d.ts +24 -0
  16. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  17. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  18. package/dist/core/DefaultDrawer.d.ts +10 -3
  19. package/dist/core/EntityEditView.d.ts +9 -1
  20. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  21. package/dist/core/field_configs.d.ts +1 -1
  22. package/dist/form/EntityForm.d.ts +8 -1
  23. package/dist/form/EntityFormActions.d.ts +2 -0
  24. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  25. package/dist/form/index.d.ts +1 -0
  26. package/dist/hooks/data/delete.d.ts +1 -1
  27. package/dist/hooks/data/save.d.ts +1 -1
  28. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  29. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  30. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  31. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  32. package/dist/index.es.js +3658 -1820
  33. package/dist/index.es.js.map +1 -1
  34. package/dist/index.umd.js +3655 -1817
  35. package/dist/index.umd.js.map +1 -1
  36. package/dist/locales/pl.d.ts +2 -0
  37. package/dist/preview/index.d.ts +1 -0
  38. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  39. package/dist/types/collections.d.ts +31 -0
  40. package/dist/types/datasource.d.ts +67 -4
  41. package/dist/types/entities.d.ts +21 -1
  42. package/dist/types/entity_actions.d.ts +6 -0
  43. package/dist/types/entity_callbacks.d.ts +21 -0
  44. package/dist/types/navigation.d.ts +34 -5
  45. package/dist/types/plugins.d.ts +13 -0
  46. package/dist/types/properties.d.ts +17 -1
  47. package/dist/types/side_entity_controller.d.ts +7 -0
  48. package/dist/types/translations.d.ts +6 -0
  49. package/dist/util/__tests__/collections.test.d.ts +1 -0
  50. package/dist/util/__tests__/urls.test.d.ts +1 -0
  51. package/dist/util/collections.d.ts +1 -1
  52. package/dist/util/entities.d.ts +1 -0
  53. package/dist/util/entity_cache.d.ts +11 -0
  54. package/dist/util/geopoint.d.ts +22 -0
  55. package/dist/util/index.d.ts +2 -0
  56. package/dist/util/navigation_blocking.d.ts +22 -0
  57. package/dist/util/navigation_from_path.d.ts +17 -0
  58. package/dist/util/navigation_utils.d.ts +107 -3
  59. package/dist/util/parent_references_from_path.d.ts +16 -0
  60. package/dist/util/paths.d.ts +28 -0
  61. package/dist/util/permissions.d.ts +10 -4
  62. package/dist/util/urls.d.ts +22 -0
  63. package/package.json +17 -10
  64. package/src/app/Scaffold.tsx +34 -44
  65. package/src/app/useApp.tsx +1 -0
  66. package/src/components/DeleteEntityDialog.tsx +2 -0
  67. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  68. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  69. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  70. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  71. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  72. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  73. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  74. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  75. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  76. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +8 -1
  77. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  78. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  79. package/src/components/EntityPreview.tsx +45 -41
  80. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  81. package/src/components/ReferenceWidget.tsx +1 -1
  82. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  83. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  84. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  85. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  86. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  87. package/src/components/common/default_entity_actions.tsx +22 -2
  88. package/src/components/common/table_url_params.ts +172 -0
  89. package/src/components/common/useDataSourceTableController.tsx +126 -163
  90. package/src/components/common/useTableSearchHelper.ts +26 -2
  91. package/src/contexts/SnackbarProvider.tsx +14 -1
  92. package/src/core/DefaultDrawer.tsx +150 -64
  93. package/src/core/DrawerNavigationGroup.tsx +27 -29
  94. package/src/core/DrawerNavigationItem.tsx +10 -12
  95. package/src/core/EntityEditView.tsx +77 -35
  96. package/src/core/EntityEditViewFormActions.tsx +3 -2
  97. package/src/core/EntitySidePanel.tsx +8 -4
  98. package/src/core/field_configs.tsx +15 -0
  99. package/src/form/EntityForm.tsx +31 -10
  100. package/src/form/EntityFormActions.tsx +2 -0
  101. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  102. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  103. package/src/form/index.tsx +1 -0
  104. package/src/hooks/data/delete.ts +8 -0
  105. package/src/hooks/data/save.ts +6 -1
  106. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  107. package/src/hooks/data/useEntityFetch.tsx +35 -4
  108. package/src/hooks/useBuildNavigationController.tsx +23 -8
  109. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  110. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  111. package/src/i18n/FireCMSi18nProvider.tsx +50 -4
  112. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  113. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  114. package/src/internal/useBuildDataSource.ts +37 -15
  115. package/src/internal/useBuildSideEntityController.tsx +20 -5
  116. package/src/locales/de.ts +7 -2
  117. package/src/locales/en.ts +7 -2
  118. package/src/locales/es.ts +7 -2
  119. package/src/locales/fr.ts +7 -2
  120. package/src/locales/hi.ts +7 -2
  121. package/src/locales/it.ts +7 -2
  122. package/src/locales/pl.ts +735 -0
  123. package/src/locales/pt.ts +7 -2
  124. package/src/preview/PropertyPreview.tsx +12 -0
  125. package/src/preview/components/ReferencePreview.tsx +7 -3
  126. package/src/preview/components/StorageThumbnail.tsx +24 -2
  127. package/src/preview/index.ts +1 -0
  128. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  129. package/src/routes/FireCMSRoute.tsx +54 -27
  130. package/src/types/collections.ts +34 -0
  131. package/src/types/datasource.ts +67 -4
  132. package/src/types/entities.ts +24 -1
  133. package/src/types/entity_actions.tsx +6 -0
  134. package/src/types/entity_callbacks.ts +24 -0
  135. package/src/types/navigation.ts +35 -5
  136. package/src/types/plugins.tsx +13 -0
  137. package/src/types/properties.ts +17 -0
  138. package/src/types/side_entity_controller.tsx +7 -0
  139. package/src/types/translations.ts +6 -0
  140. package/src/util/__tests__/collections.test.ts +32 -0
  141. package/src/util/__tests__/urls.test.ts +131 -0
  142. package/src/util/collections.ts +1 -1
  143. package/src/util/entities.ts +16 -1
  144. package/src/util/entity_cache.ts +18 -0
  145. package/src/util/geopoint.ts +81 -0
  146. package/src/util/index.ts +2 -0
  147. package/src/util/navigation_blocking.ts +45 -0
  148. package/src/util/navigation_from_path.ts +47 -7
  149. package/src/util/navigation_utils.ts +244 -4
  150. package/src/util/parent_references_from_path.ts +45 -4
  151. package/src/util/paths.ts +40 -3
  152. package/src/util/permissions.ts +22 -10
  153. package/src/util/previews.ts +8 -1
  154. package/src/util/resolutions.ts +8 -0
  155. package/src/util/urls.ts +52 -0
package/src/locales/pt.ts CHANGED
@@ -151,8 +151,8 @@ export const pt: FireCMSTranslations = {
151
151
  // ─── Error states ─────────────────────────────────────────────
152
152
  error: "Erro",
153
153
  error_uploading_file: "Erro ao carregar ficheiro",
154
- error_deleting: "Erro ao eliminar",
155
- error_before_delete: "Erro antes de eliminar",
154
+ error_deleting: "Erro ao eliminar: {{message}}",
155
+ error_before_delete: "Erro antes de eliminar: {{message}}",
156
156
  error_updating_asset: "Erro ao atualizar recurso",
157
157
  error_deleting_asset: "Erro ao eliminar recurso",
158
158
  error_firestore_index: "É necessário um índice Firestore para esta consulta.",
@@ -394,6 +394,7 @@ export const pt: FireCMSTranslations = {
394
394
  download: "Descarregar",
395
395
  large_number_of_documents: "Esta coleção tem um grande número de documentos ({{count}}).",
396
396
  include_undefined_values: "Incluir valores indefinidos",
397
+ export_apply_filter_sort: "Exportar apenas os resultados que correspondem ao filtro/ordenação atual",
397
398
  submit: "Submeter",
398
399
 
399
400
  no_filterable_properties: "Não há propriedades filtráveis disponíveis",
@@ -595,6 +596,10 @@ export const pt: FireCMSTranslations = {
595
596
  order_property_not_found: "A propriedade \"{{property}}\" não existe ou não é uma propriedade numérica. Selecione uma propriedade válida ou limpe a seleção.",
596
597
  no_number_properties: "Nenhuma propriedade numérica encontrada. Adicione uma propriedade numérica para habilitar a ordenação.",
597
598
  order_property_description: "Selecione uma propriedade numérica para manter a ordem dos itens",
599
+ image_property: "Propriedade de imagem",
600
+ image_property_not_found: "A propriedade \"{{property}}\" não existe ou não é uma propriedade de imagem. Selecione uma propriedade válida ou limpe a seleção.",
601
+ no_image_properties: "Nenhuma propriedade de imagem encontrada. Adicione uma propriedade de armazenamento ou URL de imagem para usar como miniatura.",
602
+ image_property_description: "Selecione a propriedade usada como miniatura nas visualizações de Cartões e Kanban. Se não for definida, a primeira propriedade de imagem correspondente será usada automaticamente.",
598
603
  display_settings: "Configurações de exibição",
599
604
  default_row_size: "Tamanho de linha padrão",
600
605
  side_dialog_width: "Largura do diálogo lateral",
@@ -4,6 +4,7 @@ import equal from "react-fast-compare"
4
4
  import {
5
5
  CMSType,
6
6
  EntityReference,
7
+ GeoPoint,
7
8
  ResolvedArrayProperty,
8
9
  ResolvedMapProperty,
9
10
  ResolvedNumberProperty,
@@ -27,12 +28,14 @@ import { ArrayPropertyEnumPreview } from "./property_previews/ArrayPropertyEnumP
27
28
  import { ArrayOfStringsPreview } from "./property_previews/ArrayOfStringsPreview";
28
29
  import { ArrayOneOfPreview } from "./property_previews/ArrayOneOfPreview";
29
30
  import { MapPropertyPreview } from "./property_previews/MapPropertyPreview";
31
+ import { GeopointPropertyPreview } from "./property_previews/GeopointPropertyPreview";
30
32
  import { ReferencePreview } from "./components/ReferencePreview";
31
33
  import { DatePreview } from "./components/DatePreview";
32
34
  import { BooleanPreview } from "./components/BooleanPreview";
33
35
  import { NumberPropertyPreview } from "./property_previews/NumberPropertyPreview";
34
36
  import { ErrorView } from "../components";
35
37
  import { UserPreview } from "./components/UserPreview";
38
+ import { getGeoPointCoordinates } from "../util";
36
39
 
37
40
  /**
38
41
  * @group Preview components
@@ -202,6 +205,15 @@ export const PropertyPreview = React.memo(function PropertyPreview<T extends CMS
202
205
  } else {
203
206
  content = buildWrongValueType(propertyKey, property.dataType, value);
204
207
  }
208
+ } else if (property.dataType === "geopoint") {
209
+ const coordinates = getGeoPointCoordinates(value as GeoPoint);
210
+ if (coordinates) {
211
+ content = <GeopointPropertyPreview {...props}
212
+ property={property}
213
+ value={value as GeoPoint}/>;
214
+ } else {
215
+ content = buildWrongValueType(propertyKey, property.dataType, value);
216
+ }
205
217
  } else if (property.dataType === "reference") {
206
218
  if (typeof property.path === "string") {
207
219
  if (typeof value === "object" && "isEntityReference" in value && value.isEntityReference()) {
@@ -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
 
@@ -6,6 +6,7 @@ import { useStorageSource } from "../../hooks";
6
6
  import { DownloadConfig, FileType } from "../../types";
7
7
  import { PreviewSize } from "../PropertyPreviewProps";
8
8
  import { ErrorView } from "../../components";
9
+ import { getPreviewTypeFromUrl, isAbsoluteHttpUrl } from "../../util/urls";
9
10
 
10
11
  type StorageThumbnailProps = {
11
12
  storagePathOrDownloadUrl: string;
@@ -41,10 +42,20 @@ export function StorageThumbnailInternal({
41
42
  const [error, setError] = React.useState<Error | undefined>(undefined);
42
43
  const storage = useStorageSource();
43
44
 
45
+ /**
46
+ * The saved value may already be a resolved download URL instead of a
47
+ * storage path: either because the property is configured with
48
+ * `storeUrl: true`, or because the value was written directly in the
49
+ * datasource pointing to an externally hosted file. Such values can be
50
+ * previewed as they are, and must not be handed over to the storage source,
51
+ * which can only resolve paths of its own bucket.
52
+ */
53
+ const valueIsUrl = storeUrl || isAbsoluteHttpUrl(storagePathOrDownloadUrl);
54
+
44
55
  const [downloadConfig, setDownloadConfig] = React.useState<DownloadConfig>(URL_CACHE[storagePathOrDownloadUrl]);
45
56
 
46
57
  useEffect(() => {
47
- if (!storagePathOrDownloadUrl)
58
+ if (!storagePathOrDownloadUrl || valueIsUrl)
48
59
  return;
49
60
  let unmounted = false;
50
61
  storage.getDownloadURL(storagePathOrDownloadUrl)
@@ -57,10 +68,21 @@ export function StorageThumbnailInternal({
57
68
  return () => {
58
69
  unmounted = true;
59
70
  };
60
- }, [storagePathOrDownloadUrl]);
71
+ }, [storagePathOrDownloadUrl, valueIsUrl]);
61
72
 
62
73
  if (!storagePathOrDownloadUrl) return null;
63
74
 
75
+ if (valueIsUrl) {
76
+ // There is no storage metadata for a value that is already a URL, so the
77
+ // preview type is inferred from its file extension when possible.
78
+ return <UrlComponentPreview previewType={getPreviewTypeFromUrl(storagePathOrDownloadUrl) ?? "file"}
79
+ url={storagePathOrDownloadUrl}
80
+ interactive={interactive}
81
+ size={size}
82
+ fill={fill}
83
+ hint={storagePathOrDownloadUrl} />;
84
+ }
85
+
64
86
  const filetype = downloadConfig?.metadata ? getFiletype(downloadConfig?.metadata.contentType) : undefined;
65
87
  const previewType = filetype?.startsWith("image")
66
88
  ? "image"
@@ -7,6 +7,7 @@ export * from "./property_previews/ArrayOfStringsPreview";
7
7
  export * from "./property_previews/ArrayPropertyEnumPreview";
8
8
  export * from "./property_previews/ArrayOfMapsPreview";
9
9
  export * from "./property_previews/NumberPropertyPreview";
10
+ export * from "./property_previews/GeopointPropertyPreview";
10
11
  export * from "./property_previews/StringPropertyPreview";
11
12
  export * from "./property_previews/ArrayOfStorageComponentsPreview";
12
13
  export * from "./property_previews/ArrayOfReferencesPreview";
@@ -0,0 +1,23 @@
1
+ import React from "react";
2
+
3
+ import { GeoPoint } from "../../types";
4
+ import { PropertyPreviewProps } from "../PropertyPreviewProps";
5
+ import { formatGeoPoint, getGeoPointCoordinates } from "../../util";
6
+
7
+ export function GeopointPropertyPreview({
8
+ value,
9
+ size
10
+ }: PropertyPreviewProps<GeoPoint>): React.ReactElement {
11
+
12
+ const coordinates = getGeoPointCoordinates(value);
13
+
14
+ if (!coordinates) {
15
+ return <span className={size === "small" ? "text-sm text-text-secondary dark:text-text-secondary-dark" : "text-text-secondary dark:text-text-secondary-dark"}>—</span>;
16
+ }
17
+
18
+ return (
19
+ <span className={size === "small" ? "text-sm font-mono" : "font-mono"}>
20
+ {formatGeoPoint(coordinates)}
21
+ </span>
22
+ );
23
+ }
@@ -10,6 +10,8 @@ import {
10
10
  } from "../util/navigation_from_path";
11
11
  import { useBreadcrumbsController } from "../hooks/useBreadcrumbsController";
12
12
  import { toArray } from "../util/arrays";
13
+ import { addInitialSlash, encodeEntityId } from "../util/navigation_utils";
14
+ import { shouldBlockEntityNavigation } from "../util/navigation_blocking";
13
15
  import { NotFoundPage } from "../components";
14
16
  import { lazyEager } from "../util/lazy_eager";
15
17
 
@@ -88,7 +90,7 @@ export function FireCMSRoute() {
88
90
  let collection: EntityCollection<any> | undefined;
89
91
  collection = navigation.getCollectionById(navigationEntries[0].id);
90
92
  if (!collection)
91
- collection = navigation.getCollection(navigationEntries[0].path);
93
+ collection = navigation.getCollection(navigationEntries[0].path, false, navigationEntries[0].pathSegments);
92
94
  if (!collection)
93
95
  return null;
94
96
  return <React.Suspense fallback={null}>
@@ -97,6 +99,7 @@ export function FireCMSRoute() {
97
99
  isSubCollection={false}
98
100
  parentCollectionIds={[]}
99
101
  fullPath={collection.path}
102
+ pathSegments={navigationEntries[0].pathSegments}
100
103
  fullIdPath={collection.id}
101
104
  updateUrl={true}
102
105
  {...collection}
@@ -111,7 +114,7 @@ export function FireCMSRoute() {
111
114
  const firstEntry = navigationEntries[0] as NavigationViewCollectionInternal<any>;
112
115
  collection = navigation.getCollectionById(firstEntry.id);
113
116
  if (!collection)
114
- collection = navigation.getCollection(firstEntry.path);
117
+ collection = navigation.getCollection(firstEntry.path, false, firstEntry.pathSegments);
115
118
  if (!collection)
116
119
  return null;
117
120
  return <React.Suspense fallback={null}>
@@ -121,6 +124,7 @@ export function FireCMSRoute() {
121
124
  isSubCollection={false}
122
125
  parentCollectionIds={[]}
123
126
  fullPath={collection.path}
127
+ pathSegments={firstEntry.pathSegments}
124
128
  updateUrl={true}
125
129
  {...collection}
126
130
  Actions={toArray(collection.Actions)} />
@@ -182,6 +186,8 @@ function EntityFullScreenRoute({
182
186
  const urlTab = getSelectedTabFromUrl(isNew, lastCustomView);
183
187
  const [selectedTab, setSelectedTab] = useState<string | undefined>(urlTab);
184
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.
185
191
  const parentCollectionIds = navigation.getParentCollectionIds(navigationPath);
186
192
  useEffect(() => {
187
193
  if (urlTab !== selectedTab) {
@@ -189,27 +195,51 @@ function EntityFullScreenRoute({
189
195
  }
190
196
  }, [urlTab]);
191
197
 
192
- const basePath = !entityId || isNew
198
+ const lastCollectionEntry = navigationEntries.findLast((entry) => entry.type === "collection");
199
+
200
+ // The entity id reaches the URL escaped AND percent-encoded, so it cannot be located by
201
+ // searching `pathname` for the raw id — that misses for any id needing encoding (a "/",
202
+ // but equally a space or a non-ASCII character). Rebuild the URLs from the navigation
203
+ // entries instead, which already carry the escaped chain in `fullPath`.
204
+ const entityEntryIndex = lastEntityEntry ? navigationEntries.indexOf(lastEntityEntry) : -1;
205
+ const parentCollectionEntry = entityEntryIndex > 0
206
+ ? navigationEntries[entityEntryIndex - 1] as NavigationViewCollectionInternal<any>
207
+ : undefined;
208
+
209
+ const buildUrl = (escapedPath: string) => addInitialSlash(navigation.buildUrlCollectionPath(escapedPath));
210
+
211
+ const basePath = !entityId || isNew || !parentCollectionEntry
193
212
  ? pathname
194
- : pathname.substring(0, pathname.lastIndexOf(`/${entityId}`));
213
+ : buildUrl(parentCollectionEntry.fullPath);
214
+
215
+ const entityPath = lastEntityEntry ? buildUrl(lastEntityEntry.fullPath) : basePath;
195
216
 
196
- const entityPath = basePath + `/${entityId}`;
217
+ /**
218
+ * Build the URL of an entity in the current collection. `id` is raw, so it is escaped
219
+ * before being joined; `buildUrlCollectionPath` then percent-encodes the whole path.
220
+ */
221
+ const buildEntityUrl = (id: string, tab?: string) => {
222
+ const parentPath = parentCollectionEntry?.fullPath ?? lastCollectionEntry?.fullPath;
223
+ if (!parentPath) return pathname;
224
+ return buildUrl(`${parentPath}/${encodeEntityId(id)}${tab ? "/" + tab : ""}`);
225
+ };
197
226
 
198
227
  let blocker: Blocker | undefined = undefined;
199
228
  try {
200
229
  blocker = useBlocker(({
230
+ currentLocation,
201
231
  nextLocation
202
- }) => {
203
- if (nextLocation.pathname.startsWith(entityPath))
204
- return false;
205
- return blocked.current;
206
- });
232
+ }) => shouldBlockEntityNavigation({
233
+ currentLocation,
234
+ nextLocation,
235
+ entityPath,
236
+ basePath,
237
+ blocked: blocked.current
238
+ }));
207
239
  } catch (e) {
208
240
  // console.warn("Blocker not available, navigation will not be blocked");
209
241
  }
210
242
 
211
- const lastCollectionEntry = navigationEntries.findLast((entry) => entry.type === "collection");
212
-
213
243
  if (isNew && !lastCollectionEntry) {
214
244
  throw new Error("INTERNAL: No collection found in the navigation");
215
245
  }
@@ -219,14 +249,20 @@ function EntityFullScreenRoute({
219
249
  }
220
250
 
221
251
  const collection = isNew ? lastCollectionEntry!.collection : lastEntityEntry!.parentCollection;
222
- const fullIdPath = isNew ? lastCollectionEntry!.path : lastEntityEntry!.path;
223
- const collectionPath = navigation.resolveIdsFrom(fullIdPath);
252
+ // `fullIdPath` is used downstream to build URLs, so it carries the escaped chain;
253
+ // `collectionPath` addresses the datasource, so it is resolved from the raw one.
254
+ const fullIdPath = isNew ? lastCollectionEntry!.fullPath : (parentCollectionEntry?.fullPath ?? lastEntityEntry!.fullPath);
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);
224
259
  return <>
225
260
  <React.Suspense fallback={null}>
226
261
  <EntityEditView
227
262
  key={collection.id + "_" + (isNew ? "new" : (isCopy ? entityId + "_copy" : entityId))}
228
263
  entityId={isNew ? undefined : entityId}
229
264
  fullIdPath={fullIdPath}
265
+ pathSegments={pathSegments}
230
266
  collection={collection}
231
267
  layout={"full_screen"}
232
268
  path={collectionPath}
@@ -234,25 +270,16 @@ function EntityFullScreenRoute({
234
270
  selectedTab={selectedTab ?? undefined}
235
271
  onValuesModified={(modified) => blocked.current = modified}
236
272
  onSaved={(params) => {
237
- const newSelectedTab = params.selectedTab;
238
273
  const newEntityId = params.entityId;
239
- if (newSelectedTab) {
240
- navigate(`${basePath}/${newEntityId}/${newSelectedTab}`, { replace: true });
241
- } else {
242
- navigate(`${basePath}/${newEntityId}`, { replace: true });
243
- }
274
+ if (!newEntityId) return;
275
+ navigate(buildEntityUrl(newEntityId, params.selectedTab), { replace: true });
244
276
  }}
245
277
  onTabChange={(params) => {
246
278
  setSelectedTab(params.selectedTab);
247
- if (isNew) {
279
+ if (isNew || !entityId) {
248
280
  return;
249
281
  }
250
- const newSelectedTab = params.selectedTab;
251
- if (newSelectedTab) {
252
- navigate(`${basePath}/${entityId}/${newSelectedTab}`, { replace: true });
253
- } else {
254
- navigate(`${basePath}/${entityId}`, { replace: true });
255
- }
282
+ navigate(buildEntityUrl(entityId, params.selectedTab), { replace: true });
256
283
  }}
257
284
  parentCollectionIds={parentCollectionIds}
258
285
  />
@@ -101,6 +101,14 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
101
101
  */
102
102
  titleProperty?: keyof M;
103
103
 
104
+ /**
105
+ * Property used as the image thumbnail in Cards and Kanban view modes.
106
+ * Must reference a storage/image property (or an array of them), or a
107
+ * string property with `url: "image"`.
108
+ * If not specified, the first matching image property will be used.
109
+ */
110
+ imageProperty?: keyof M;
111
+
104
112
  /**
105
113
  * When editing an entity, you can choose to open the entity in a side dialog
106
114
  * or in a full screen dialog. Defaults to `full_screen`.
@@ -261,6 +269,22 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
261
269
  */
262
270
  initialFilter?: FilterValues<Extract<keyof M, string>>; // setting FilterValues<M> can break defining collections by code
263
271
 
272
+ /**
273
+ * Array of property keys that are allowed for filtering. Allowed filters will be displayed in the collection view, table row headers
274
+ * and will be used to filter data.
275
+ *
276
+ * If not specified, all properties that are filterable will be allowed.
277
+ *
278
+ * If you specify this prop, the filters will be displayed in the collection view if they are filterable.
279
+ *
280
+ * If you set it as an empty array, no filters will be displayed.
281
+ *
282
+ * e.g. `allowedFilters: ["name", "category"]`
283
+ *
284
+ * e.g. `allowedFilters: []`
285
+ */
286
+ allowedFilters?: (keyof M)[];
287
+
264
288
  /**
265
289
  * Default sort applied to this collection.
266
290
  * When setting this prop, entities will have a default order
@@ -473,6 +497,14 @@ export interface CollectionActionsProps<M extends Record<string, any> = any, USE
473
497
  */
474
498
  path: string;
475
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
+
476
508
  /**
477
509
  * Path of the last collection, like `addresses`
478
510
  */
@@ -725,6 +757,8 @@ export type EntityTableController<M extends Record<string, any> = any> = {
725
757
  dataLoadingError?: Error;
726
758
  filterValues?: FilterValues<Extract<keyof M, string>>;
727
759
  setFilterValues?: (filterValues: FilterValues<Extract<keyof M, string>>) => void;
760
+ allowedFilters?: (keyof M)[];
761
+ forcedFilters?: (keyof M)[];
728
762
  sortBy?: [Extract<keyof M, string>, "asc" | "desc"];
729
763
  setSortBy?: (sortBy?: [Extract<keyof M, string>, "asc" | "desc"]) => void;
730
764
  searchString?: string;
@@ -8,6 +8,20 @@ import { FireCMSContext } from "./firecms_context";
8
8
  */
9
9
  export interface FetchEntityProps<M extends Record<string, any> = any> {
10
10
  path: string;
11
+ /**
12
+ * The path split at its real segment boundaries, e.g.
13
+ * `["nodes", "node/42", "edges"]`.
14
+ *
15
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
16
+ * recovered from it — `"nodes/node/42/edges"` is indistinguishable from a three-level
17
+ * nesting. This is the unambiguous form: one array element per segment, entity ids
18
+ * kept whole however many slashes they contain.
19
+ *
20
+ * For any backend whose ids cannot contain "/" this is exactly `path.split("/")`, so
21
+ * delegates that do not care can keep using `path`. Delegates that do should prefer
22
+ * `pathSegments ?? path.split("/")`.
23
+ */
24
+ pathSegments?: string[];
11
25
  entityId: string;
12
26
  databaseId?: string;
13
27
  collection?: EntityCollection<M, any>
@@ -28,6 +42,20 @@ export type ListenEntityProps<M extends Record<string, any> = any> =
28
42
  */
29
43
  export interface FetchCollectionProps<M extends Record<string, any> = any> {
30
44
  path: string;
45
+ /**
46
+ * The path split at its real segment boundaries, e.g.
47
+ * `["nodes", "node/42", "edges"]`.
48
+ *
49
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
50
+ * recovered from it — `"nodes/node/42/edges"` is indistinguishable from a three-level
51
+ * nesting. This is the unambiguous form: one array element per segment, entity ids
52
+ * kept whole however many slashes they contain.
53
+ *
54
+ * For any backend whose ids cannot contain "/" this is exactly `path.split("/")`, so
55
+ * delegates that do not care can keep using `path`. Delegates that do should prefer
56
+ * `pathSegments ?? path.split("/")`.
57
+ */
58
+ pathSegments?: string[];
31
59
  collection?: EntityCollection<M> | ResolvedEntityCollection<M>;
32
60
  filter?: FilterValues<Extract<keyof M, string>>,
33
61
  limit?: number;
@@ -52,6 +80,20 @@ export type ListenCollectionProps<M extends Record<string, any> = any> =
52
80
  */
53
81
  export interface SaveEntityProps<M extends Record<string, any> = any> {
54
82
  path: string;
83
+ /**
84
+ * The path split at its real segment boundaries, e.g.
85
+ * `["nodes", "node/42", "edges"]`.
86
+ *
87
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
88
+ * recovered from it — `"nodes/node/42/edges"` is indistinguishable from a three-level
89
+ * nesting. This is the unambiguous form: one array element per segment, entity ids
90
+ * kept whole however many slashes they contain.
91
+ *
92
+ * For any backend whose ids cannot contain "/" this is exactly `path.split("/")`, so
93
+ * delegates that do not care can keep using `path`. Delegates that do should prefer
94
+ * `pathSegments ?? path.split("/")`.
95
+ */
96
+ pathSegments?: string[];
55
97
  values: Partial<EntityValues<M>>;
56
98
  entityId?: string; // can be empty for new entities
57
99
  previousValues?: Partial<EntityValues<M>>;
@@ -64,6 +106,11 @@ export interface SaveEntityProps<M extends Record<string, any> = any> {
64
106
  */
65
107
  export interface DeleteEntityProps<M extends Record<string, any> = any> {
66
108
  entity: Entity<M>;
109
+ /**
110
+ * `entity.path` split at its real segment boundaries, when the caller knows them.
111
+ * Never derived from `entity.path` — see `pathSegments` on FetchCollectionProps.
112
+ */
113
+ pathSegments?: string[];
67
114
  collection?: EntityCollection<M> | ResolvedEntityCollection<M>;
68
115
  }
69
116
 
@@ -203,13 +250,19 @@ export interface DataSource {
203
250
  name: string,
204
251
  value: any,
205
252
  entityId?: string,
206
- collection?: EntityCollection
253
+ collection?: EntityCollection,
254
+ /**
255
+ * Appended last, and optional, so existing implementations and callers keep
256
+ * working unchanged — a function declaring fewer parameters stays assignable.
257
+ * See `pathSegments` on FetchCollectionProps.
258
+ */
259
+ pathSegments?: string[]
207
260
  ): Promise<boolean>;
208
261
 
209
262
  /**
210
263
  * Generate an id for a new entity
211
264
  */
212
- generateEntityId(path: string, collection: EntityCollection): string;
265
+ generateEntityId(path: string, collection: EntityCollection, pathSegments?: string[]): string;
213
266
 
214
267
  /**
215
268
  * Count the number of entities in a collection
@@ -230,6 +283,10 @@ export interface DataSource {
230
283
  initTextSearch?: (props: {
231
284
  context: FireCMSContext,
232
285
  path: string,
286
+ /**
287
+ * `path` split at its real segment boundaries, when known. Never derived from `path`.
288
+ */
289
+ pathSegments?: string[],
233
290
  collection: EntityCollection,
234
291
  parentCollectionIds?: string[]
235
292
  }) => Promise<boolean>;
@@ -237,6 +294,8 @@ export interface DataSource {
237
294
  }
238
295
 
239
296
  export type FilterCombinationValidProps = {
297
+ /** `path` split at its real segment boundaries — see `pathSegments` on FetchCollectionProps. */
298
+ pathSegments?: string[];
240
299
  path: string;
241
300
  collection: EntityCollection<any>;
242
301
  filterValues: FilterValues<any>;
@@ -366,12 +425,12 @@ export interface DataSourceDelegate {
366
425
  * @param collection
367
426
  * @return `true` if there are no other fields besides the given entity
368
427
  */
369
- checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection): Promise<boolean>;
428
+ checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection, pathSegments?: string[]): Promise<boolean>;
370
429
 
371
430
  /**
372
431
  * Generate an id for a new entity
373
432
  */
374
- generateEntityId(path: string, collection?: EntityCollection): string;
433
+ generateEntityId(path: string, collection?: EntityCollection, pathSegments?: string[]): string;
375
434
 
376
435
  /**
377
436
  * Count the number of entities in a collection
@@ -398,6 +457,10 @@ export interface DataSourceDelegate {
398
457
  initTextSearch?: (props: {
399
458
  context: FireCMSContext,
400
459
  path: string,
460
+ /**
461
+ * `path` split at its real segment boundaries, when known. Never derived from `path`.
462
+ */
463
+ pathSegments?: string[],
401
464
  databaseId?: string,
402
465
  collection: EntityCollection,
403
466
  parentCollectionIds?: string[]
@@ -21,6 +21,17 @@ export interface Entity<M extends object = any> {
21
21
  */
22
22
  path: string;
23
23
 
24
+ /**
25
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
26
+ *
27
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
28
+ * recovered from it. This keeps each segment whole.
29
+ *
30
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
31
+ * the case the field exists for. Absent means "not known here", not "no slashes".
32
+ */
33
+ pathSegments?: string[];
34
+
24
35
  /**
25
36
  * Current values
26
37
  */
@@ -55,10 +66,22 @@ export class EntityReference {
55
66
  */
56
67
  readonly databaseId?: string;
57
68
 
58
- constructor(id: string, path: string, databaseId?: string) {
69
+ /**
70
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
71
+ *
72
+ * `path` is a single flattened string, so a parent entity id containing "/" cannot be
73
+ * recovered from it. This keeps each segment whole.
74
+ *
75
+ * Optional, and never derived by splitting `path` — a guess would be wrong in exactly
76
+ * the case the field exists for. Absent means "not known here", not "no slashes".
77
+ */
78
+ readonly pathSegments?: string[];
79
+
80
+ constructor(id: string, path: string, databaseId?: string, pathSegments?: string[]) {
59
81
  this.id = id;
60
82
  this.path = path;
61
83
  this.databaseId = databaseId;
84
+ this.pathSegments = pathSegments;
62
85
  }
63
86
 
64
87
  get pathWithId() {
@@ -65,6 +65,12 @@ export type EntityActionClickProps<M extends object, USER extends User = User> =
65
65
  context: FireCMSContext<USER>;
66
66
 
67
67
  fullPath?: string;
68
+ /**
69
+ * `fullPath` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`,
70
+ * when the caller knows them. Absent means "not known here" — never derived from
71
+ * `fullPath`, since a parent id may itself contain "/".
72
+ */
73
+ pathSegments?: string[];
68
74
  fullIdPath?: string;
69
75
  collection?: EntityCollection<M>;
70
76
 
@@ -86,6 +86,14 @@ export interface EntityOnFetchProps<M extends Record<string, any> = any, USER ex
86
86
  */
87
87
  path: string;
88
88
 
89
+ /**
90
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
91
+ *
92
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
93
+ * contain "/". Absent means "not known here", not "no slashes".
94
+ */
95
+ pathSegments?: string[];
96
+
89
97
  /**
90
98
  * Fetched entity
91
99
  */
@@ -133,6 +141,14 @@ export interface EntityOnSaveProps<M extends Record<string, any> = any, USER ext
133
141
  */
134
142
  path: string;
135
143
 
144
+ /**
145
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
146
+ *
147
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
148
+ * contain "/". Absent means "not known here", not "no slashes".
149
+ */
150
+ pathSegments?: string[];
151
+
136
152
  /**
137
153
  * Full path where this entity is being saved, with alias resolved
138
154
  */
@@ -180,6 +196,14 @@ export interface EntityOnDeleteProps<M extends Record<string, any> = any, USER e
180
196
  */
181
197
  path: string;
182
198
 
199
+ /**
200
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
201
+ *
202
+ * Optional, and never derived by splitting `path` — a parent entity id may itself
203
+ * contain "/". Absent means "not known here", not "no slashes".
204
+ */
205
+ pathSegments?: string[];
206
+
183
207
  /**
184
208
  * Deleted entity id
185
209
  */