@firecms/core 3.3.0 → 3.4.0-canary.0ef7442

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 (124) 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 +7 -0
  7. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  8. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  9. package/dist/components/EntityPreview.d.ts +3 -1
  10. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  11. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  12. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  13. package/dist/components/common/table_url_params.d.ts +24 -0
  14. package/dist/components/common/useDataSourceTableController.d.ts +7 -1
  15. package/dist/components/common/useTableSearchHelper.d.ts +7 -1
  16. package/dist/core/DefaultDrawer.d.ts +10 -3
  17. package/dist/core/EntityEditView.d.ts +7 -1
  18. package/dist/core/field_configs.d.ts +1 -1
  19. package/dist/form/EntityForm.d.ts +6 -1
  20. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  21. package/dist/form/index.d.ts +1 -0
  22. package/dist/hooks/data/save.d.ts +1 -1
  23. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  24. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  25. package/dist/index.es.js +2915 -1330
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +2913 -1328
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/locales/pl.d.ts +2 -0
  30. package/dist/preview/index.d.ts +1 -0
  31. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  32. package/dist/types/collections.d.ts +24 -0
  33. package/dist/types/datasource.d.ts +59 -4
  34. package/dist/types/entities.d.ts +21 -1
  35. package/dist/types/properties.d.ts +17 -1
  36. package/dist/types/side_entity_controller.d.ts +6 -0
  37. package/dist/types/translations.d.ts +6 -0
  38. package/dist/util/__tests__/collections.test.d.ts +1 -0
  39. package/dist/util/__tests__/urls.test.d.ts +1 -0
  40. package/dist/util/collections.d.ts +1 -1
  41. package/dist/util/entities.d.ts +1 -0
  42. package/dist/util/geopoint.d.ts +22 -0
  43. package/dist/util/index.d.ts +2 -0
  44. package/dist/util/navigation_blocking.d.ts +22 -0
  45. package/dist/util/navigation_from_path.d.ts +17 -0
  46. package/dist/util/navigation_utils.d.ts +23 -1
  47. package/dist/util/parent_references_from_path.d.ts +1 -0
  48. package/dist/util/urls.d.ts +22 -0
  49. package/package.json +17 -10
  50. package/src/app/Scaffold.tsx +34 -44
  51. package/src/app/useApp.tsx +1 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  53. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  54. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  55. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  56. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  57. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
  58. package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  60. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  61. package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
  62. package/src/components/EntityPreview.tsx +41 -40
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
  64. package/src/components/ReferenceWidget.tsx +1 -1
  65. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  66. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  67. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  68. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  69. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  70. package/src/components/common/table_url_params.ts +172 -0
  71. package/src/components/common/useDataSourceTableController.tsx +104 -159
  72. package/src/components/common/useTableSearchHelper.ts +21 -2
  73. package/src/contexts/SnackbarProvider.tsx +14 -1
  74. package/src/core/DefaultDrawer.tsx +150 -64
  75. package/src/core/DrawerNavigationGroup.tsx +27 -29
  76. package/src/core/DrawerNavigationItem.tsx +10 -12
  77. package/src/core/EntityEditView.tsx +78 -32
  78. package/src/core/EntitySidePanel.tsx +2 -0
  79. package/src/core/field_configs.tsx +15 -0
  80. package/src/form/EntityForm.tsx +9 -3
  81. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  82. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  83. package/src/form/index.tsx +1 -0
  84. package/src/hooks/data/delete.ts +2 -0
  85. package/src/hooks/data/save.ts +2 -0
  86. package/src/hooks/data/useCollectionFetch.tsx +13 -0
  87. package/src/hooks/data/useEntityFetch.tsx +18 -0
  88. package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
  89. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  90. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  91. package/src/internal/useBuildDataSource.ts +25 -4
  92. package/src/internal/useBuildSideEntityController.tsx +8 -1
  93. package/src/locales/de.ts +7 -2
  94. package/src/locales/en.ts +7 -2
  95. package/src/locales/es.ts +7 -2
  96. package/src/locales/fr.ts +7 -2
  97. package/src/locales/hi.ts +7 -2
  98. package/src/locales/it.ts +7 -2
  99. package/src/locales/pl.ts +735 -0
  100. package/src/locales/pt.ts +7 -2
  101. package/src/preview/PropertyPreview.tsx +12 -0
  102. package/src/preview/components/StorageThumbnail.tsx +24 -2
  103. package/src/preview/index.ts +1 -0
  104. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  105. package/src/routes/FireCMSRoute.tsx +47 -25
  106. package/src/types/collections.ts +26 -0
  107. package/src/types/datasource.ts +59 -4
  108. package/src/types/entities.ts +24 -1
  109. package/src/types/properties.ts +17 -0
  110. package/src/types/side_entity_controller.tsx +6 -0
  111. package/src/types/translations.ts +6 -0
  112. package/src/util/__tests__/collections.test.ts +32 -0
  113. package/src/util/__tests__/urls.test.ts +131 -0
  114. package/src/util/collections.ts +1 -1
  115. package/src/util/entities.ts +16 -1
  116. package/src/util/geopoint.ts +81 -0
  117. package/src/util/index.ts +2 -0
  118. package/src/util/navigation_blocking.ts +45 -0
  119. package/src/util/navigation_from_path.ts +41 -6
  120. package/src/util/navigation_utils.ts +40 -2
  121. package/src/util/parent_references_from_path.ts +14 -4
  122. package/src/util/previews.ts +8 -1
  123. package/src/util/resolutions.ts +8 -0
  124. 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()) {
@@ -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
 
@@ -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}
@@ -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)} />
@@ -189,27 +193,51 @@ function EntityFullScreenRoute({
189
193
  }
190
194
  }, [urlTab]);
191
195
 
192
- const basePath = !entityId || isNew
196
+ const lastCollectionEntry = navigationEntries.findLast((entry) => entry.type === "collection");
197
+
198
+ // The entity id reaches the URL escaped AND percent-encoded, so it cannot be located by
199
+ // searching `pathname` for the raw id — that misses for any id needing encoding (a "/",
200
+ // but equally a space or a non-ASCII character). Rebuild the URLs from the navigation
201
+ // entries instead, which already carry the escaped chain in `fullPath`.
202
+ const entityEntryIndex = lastEntityEntry ? navigationEntries.indexOf(lastEntityEntry) : -1;
203
+ const parentCollectionEntry = entityEntryIndex > 0
204
+ ? navigationEntries[entityEntryIndex - 1] as NavigationViewCollectionInternal<any>
205
+ : undefined;
206
+
207
+ const buildUrl = (escapedPath: string) => addInitialSlash(navigation.buildUrlCollectionPath(escapedPath));
208
+
209
+ const basePath = !entityId || isNew || !parentCollectionEntry
193
210
  ? pathname
194
- : pathname.substring(0, pathname.lastIndexOf(`/${entityId}`));
211
+ : buildUrl(parentCollectionEntry.fullPath);
212
+
213
+ const entityPath = lastEntityEntry ? buildUrl(lastEntityEntry.fullPath) : basePath;
195
214
 
196
- const entityPath = basePath + `/${entityId}`;
215
+ /**
216
+ * Build the URL of an entity in the current collection. `id` is raw, so it is escaped
217
+ * before being joined; `buildUrlCollectionPath` then percent-encodes the whole path.
218
+ */
219
+ const buildEntityUrl = (id: string, tab?: string) => {
220
+ const parentPath = parentCollectionEntry?.fullPath ?? lastCollectionEntry?.fullPath;
221
+ if (!parentPath) return pathname;
222
+ return buildUrl(`${parentPath}/${encodeEntityId(id)}${tab ? "/" + tab : ""}`);
223
+ };
197
224
 
198
225
  let blocker: Blocker | undefined = undefined;
199
226
  try {
200
227
  blocker = useBlocker(({
228
+ currentLocation,
201
229
  nextLocation
202
- }) => {
203
- if (nextLocation.pathname.startsWith(entityPath))
204
- return false;
205
- return blocked.current;
206
- });
230
+ }) => shouldBlockEntityNavigation({
231
+ currentLocation,
232
+ nextLocation,
233
+ entityPath,
234
+ basePath,
235
+ blocked: blocked.current
236
+ }));
207
237
  } catch (e) {
208
238
  // console.warn("Blocker not available, navigation will not be blocked");
209
239
  }
210
240
 
211
- const lastCollectionEntry = navigationEntries.findLast((entry) => entry.type === "collection");
212
-
213
241
  if (isNew && !lastCollectionEntry) {
214
242
  throw new Error("INTERNAL: No collection found in the navigation");
215
243
  }
@@ -219,14 +247,17 @@ function EntityFullScreenRoute({
219
247
  }
220
248
 
221
249
  const collection = isNew ? lastCollectionEntry!.collection : lastEntityEntry!.parentCollection;
222
- const fullIdPath = isNew ? lastCollectionEntry!.path : lastEntityEntry!.path;
223
- const collectionPath = navigation.resolveIdsFrom(fullIdPath);
250
+ // `fullIdPath` is used downstream to build URLs, so it carries the escaped chain;
251
+ // `collectionPath` addresses the datasource, so it is resolved from the raw one.
252
+ const fullIdPath = isNew ? lastCollectionEntry!.fullPath : (parentCollectionEntry?.fullPath ?? lastEntityEntry!.fullPath);
253
+ const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path);
224
254
  return <>
225
255
  <React.Suspense fallback={null}>
226
256
  <EntityEditView
227
257
  key={collection.id + "_" + (isNew ? "new" : (isCopy ? entityId + "_copy" : entityId))}
228
258
  entityId={isNew ? undefined : entityId}
229
259
  fullIdPath={fullIdPath}
260
+ pathSegments={isNew ? lastCollectionEntry!.pathSegments : lastEntityEntry!.pathSegments}
230
261
  collection={collection}
231
262
  layout={"full_screen"}
232
263
  path={collectionPath}
@@ -234,25 +265,16 @@ function EntityFullScreenRoute({
234
265
  selectedTab={selectedTab ?? undefined}
235
266
  onValuesModified={(modified) => blocked.current = modified}
236
267
  onSaved={(params) => {
237
- const newSelectedTab = params.selectedTab;
238
268
  const newEntityId = params.entityId;
239
- if (newSelectedTab) {
240
- navigate(`${basePath}/${newEntityId}/${newSelectedTab}`, { replace: true });
241
- } else {
242
- navigate(`${basePath}/${newEntityId}`, { replace: true });
243
- }
269
+ if (!newEntityId) return;
270
+ navigate(buildEntityUrl(newEntityId, params.selectedTab), { replace: true });
244
271
  }}
245
272
  onTabChange={(params) => {
246
273
  setSelectedTab(params.selectedTab);
247
- if (isNew) {
274
+ if (isNew || !entityId) {
248
275
  return;
249
276
  }
250
- const newSelectedTab = params.selectedTab;
251
- if (newSelectedTab) {
252
- navigate(`${basePath}/${entityId}/${newSelectedTab}`, { replace: true });
253
- } else {
254
- navigate(`${basePath}/${entityId}`, { replace: true });
255
- }
277
+ navigate(buildEntityUrl(entityId, params.selectedTab), { replace: true });
256
278
  }}
257
279
  parentCollectionIds={parentCollectionIds}
258
280
  />
@@ -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
@@ -725,6 +749,8 @@ export type EntityTableController<M extends Record<string, any> = any> = {
725
749
  dataLoadingError?: Error;
726
750
  filterValues?: FilterValues<Extract<keyof M, string>>;
727
751
  setFilterValues?: (filterValues: FilterValues<Extract<keyof M, string>>) => void;
752
+ allowedFilters?: (keyof M)[];
753
+ forcedFilters?: (keyof M)[];
728
754
  sortBy?: [Extract<keyof M, string>, "asc" | "desc"];
729
755
  setSortBy?: (sortBy?: [Extract<keyof M, string>, "asc" | "desc"]) => void;
730
756
  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
@@ -237,6 +290,8 @@ export interface DataSource {
237
290
  }
238
291
 
239
292
  export type FilterCombinationValidProps = {
293
+ /** `path` split at its real segment boundaries — see `pathSegments` on FetchCollectionProps. */
294
+ pathSegments?: string[];
240
295
  path: string;
241
296
  collection: EntityCollection<any>;
242
297
  filterValues: FilterValues<any>;
@@ -366,12 +421,12 @@ export interface DataSourceDelegate {
366
421
  * @param collection
367
422
  * @return `true` if there are no other fields besides the given entity
368
423
  */
369
- checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection): Promise<boolean>;
424
+ checkUniqueField(path: string, name: string, value: any, entityId?: string, collection?: EntityCollection, pathSegments?: string[]): Promise<boolean>;
370
425
 
371
426
  /**
372
427
  * Generate an id for a new entity
373
428
  */
374
- generateEntityId(path: string, collection?: EntityCollection): string;
429
+ generateEntityId(path: string, collection?: EntityCollection, pathSegments?: string[]): string;
375
430
 
376
431
  /**
377
432
  * Count the number of entities in a collection
@@ -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() {
@@ -562,6 +562,22 @@ export type PropertyBuilderProps<M extends Record<string, any> = any> =
562
562
  * Current value of this property
563
563
  */
564
564
  propertyValue?: any;
565
+ /**
566
+ * Path of this property within the entity, in dot notation, e.g.
567
+ * `my_map.my_field` or `my_array.0.my_field`.
568
+ * Useful to look up sibling values in `values` with `getValueInPath`.
569
+ *
570
+ * When a property builder is used as the `of` prop of an array property,
571
+ * it is resolved twice: once per existing element, with an index
572
+ * qualified key (`my_array.0`), and once as the template for the element
573
+ * shape, with the array's own key (`my_array`). In the latter case
574
+ * `propertyValue` is the whole array, since the template is not bound to
575
+ * any element.
576
+ *
577
+ * It may be undefined if the property is resolved outside the context of
578
+ * an entity.
579
+ */
580
+ propertyKey?: string;
565
581
  /**
566
582
  * Index of this property (only for arrays)
567
583
  */
@@ -592,6 +608,7 @@ export type PropertyBuilder<T extends CMSType = any, M extends Record<string, an
592
608
  values,
593
609
  previousValues,
594
610
  propertyValue,
611
+ propertyKey,
595
612
  index,
596
613
  path,
597
614
  entityId,
@@ -19,6 +19,12 @@ export interface EntitySidePanelProps<M extends Record<string, any> = any> {
19
19
  * Full CMS path of the entity, including the collection and sub-collections.
20
20
  */
21
21
  fullIdPath?: string;
22
+ /**
23
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
24
+ * Optional: when omitted the panel falls back to splitting `path`, which is correct for
25
+ * any backend whose entity ids cannot contain "/".
26
+ */
27
+ pathSegments?: string[];
22
28
 
23
29
  /**
24
30
  * ID of the entity, if not set, it means we are creating a new entity
@@ -419,6 +419,8 @@ export interface FireCMSTranslations {
419
419
  download: string;
420
420
  large_number_of_documents: string;
421
421
  include_undefined_values: string;
422
+ /** Toggle in the export dialog, only shown when a filter or sort is active */
423
+ export_apply_filter_sort: string;
422
424
  submit: string;
423
425
 
424
426
  no_filterable_properties: string;
@@ -620,6 +622,10 @@ export interface FireCMSTranslations {
620
622
  order_property_not_found: string;
621
623
  no_number_properties: string;
622
624
  order_property_description: string;
625
+ image_property: string;
626
+ image_property_not_found: string;
627
+ no_image_properties: string;
628
+ image_property_description: string;
623
629
  display_settings: string;
624
630
  default_row_size: string;
625
631
  side_dialog_width: string;
@@ -0,0 +1,32 @@
1
+ import { getLocalChangesBackup } from "../collections";
2
+ import { EntityCollection } from "../../types";
3
+
4
+ function collectionWith(localChangesBackup?: "manual_apply" | "auto_apply" | false): EntityCollection {
5
+ return {
6
+ id: "products",
7
+ name: "Products",
8
+ path: "products",
9
+ properties: {},
10
+ localChangesBackup
11
+ } as EntityCollection;
12
+ }
13
+
14
+ describe("getLocalChangesBackup", () => {
15
+
16
+ it("should default to manual_apply when not set", () => {
17
+ expect(getLocalChangesBackup(collectionWith(undefined))).toEqual("manual_apply");
18
+ });
19
+
20
+ it("should return manual_apply when explicitly set", () => {
21
+ expect(getLocalChangesBackup(collectionWith("manual_apply"))).toEqual("manual_apply");
22
+ });
23
+
24
+ it("should return auto_apply when explicitly set", () => {
25
+ expect(getLocalChangesBackup(collectionWith("auto_apply"))).toEqual("auto_apply");
26
+ });
27
+
28
+ it("should return false when explicitly disabled, and not fall back to the default", () => {
29
+ expect(getLocalChangesBackup(collectionWith(false))).toEqual(false);
30
+ });
31
+
32
+ });