@firecms/core 3.4.0-canary.8635253 → 3.4.0-canary.d20cc85

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 (87) 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 +7 -0
  5. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  6. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  7. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  8. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  9. package/dist/components/common/table_url_params.d.ts +24 -0
  10. package/dist/components/common/useDataSourceTableController.d.ts +7 -1
  11. package/dist/components/common/useTableSearchHelper.d.ts +7 -1
  12. package/dist/core/EntityEditView.d.ts +7 -1
  13. package/dist/form/EntityForm.d.ts +6 -1
  14. package/dist/hooks/data/save.d.ts +1 -1
  15. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  16. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  17. package/dist/index.es.js +949 -622
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/index.umd.js +948 -621
  20. package/dist/index.umd.js.map +1 -1
  21. package/dist/types/collections.d.ts +7 -0
  22. package/dist/types/datasource.d.ts +59 -4
  23. package/dist/types/entities.d.ts +21 -1
  24. package/dist/types/properties.d.ts +17 -1
  25. package/dist/types/side_entity_controller.d.ts +6 -0
  26. package/dist/types/translations.d.ts +6 -0
  27. package/dist/util/__tests__/collections.test.d.ts +1 -0
  28. package/dist/util/__tests__/urls.test.d.ts +1 -0
  29. package/dist/util/collections.d.ts +1 -1
  30. package/dist/util/index.d.ts +1 -0
  31. package/dist/util/navigation_from_path.d.ts +7 -0
  32. package/dist/util/navigation_utils.d.ts +3 -1
  33. package/dist/util/parent_references_from_path.d.ts +1 -0
  34. package/dist/util/urls.d.ts +22 -0
  35. package/package.json +4 -4
  36. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
  37. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  38. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  39. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
  40. package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
  41. package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
  42. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
  43. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  44. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  45. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  46. package/src/components/common/table_url_params.ts +172 -0
  47. package/src/components/common/useDataSourceTableController.tsx +64 -175
  48. package/src/components/common/useTableSearchHelper.ts +21 -2
  49. package/src/contexts/SnackbarProvider.tsx +14 -1
  50. package/src/core/EntityEditView.tsx +21 -0
  51. package/src/core/EntitySidePanel.tsx +2 -0
  52. package/src/form/EntityForm.tsx +9 -3
  53. package/src/hooks/data/delete.ts +2 -0
  54. package/src/hooks/data/save.ts +2 -0
  55. package/src/hooks/data/useCollectionFetch.tsx +13 -0
  56. package/src/hooks/data/useEntityFetch.tsx +18 -0
  57. package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
  58. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  59. package/src/internal/useBuildDataSource.ts +25 -4
  60. package/src/internal/useBuildSideEntityController.tsx +6 -0
  61. package/src/locales/de.ts +5 -0
  62. package/src/locales/en.ts +5 -0
  63. package/src/locales/es.ts +5 -0
  64. package/src/locales/fr.ts +5 -0
  65. package/src/locales/hi.ts +5 -0
  66. package/src/locales/it.ts +5 -0
  67. package/src/locales/pl.ts +5 -0
  68. package/src/locales/pt.ts +5 -0
  69. package/src/preview/components/StorageThumbnail.tsx +24 -2
  70. package/src/routes/FireCMSRoute.tsx +3 -0
  71. package/src/types/collections.ts +8 -0
  72. package/src/types/datasource.ts +59 -4
  73. package/src/types/entities.ts +24 -1
  74. package/src/types/properties.ts +17 -0
  75. package/src/types/side_entity_controller.tsx +6 -0
  76. package/src/types/translations.ts +6 -0
  77. package/src/util/__tests__/collections.test.ts +32 -0
  78. package/src/util/__tests__/urls.test.ts +131 -0
  79. package/src/util/collections.ts +1 -1
  80. package/src/util/entities.ts +3 -1
  81. package/src/util/index.ts +1 -0
  82. package/src/util/navigation_from_path.ts +19 -1
  83. package/src/util/navigation_utils.ts +4 -0
  84. package/src/util/parent_references_from_path.ts +10 -2
  85. package/src/util/previews.ts +8 -1
  86. package/src/util/resolutions.ts +8 -0
  87. package/src/util/urls.ts +52 -0
@@ -7,18 +7,16 @@ import {
7
7
  DataType,
8
8
  Entity,
9
9
  EntityCollection,
10
- EntityReference,
11
10
  EntityTableController,
12
11
  FilterValues,
13
12
  FireCMSContext,
14
13
  SelectedCellProps,
15
- User,
16
- WhereFilterOp
14
+ User
17
15
  } from "../../types";
18
16
  import { useDebouncedData } from "./useDebouncedData";
19
17
  import { ScrollRestorationController } from "./useScrollRestoration";
20
18
  import { isDataTypeFilterable } from "../../util";
21
- import { decodeEntityId, encodeEntityId } from "../../util/navigation_utils";
19
+ import { encodeFilterAndSort, parseFilterAndSort } from "./table_url_params";
22
20
 
23
21
  const DEFAULT_PAGE_SIZE = 50;
24
22
 
@@ -27,6 +25,12 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
27
25
  * Full path where the data of this table is located
28
26
  */
29
27
  fullPath: string;
28
+ /**
29
+ * `fullPath` split at its real segment boundaries. Passed through to the datasource so
30
+ * a parent entity id containing "/" stays unambiguous. Defaults to splitting
31
+ * `fullPath`, which is correct for any backend whose ids cannot contain "/".
32
+ */
33
+ pathSegments?: string[];
30
34
  /**
31
35
  * The collection that is represented by this config.
32
36
  */
@@ -71,6 +75,7 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
71
75
  export function useDataSourceTableController<M extends Record<string, any> = any, USER extends User = User>(
72
76
  {
73
77
  fullPath,
78
+ pathSegments,
74
79
  collection,
75
80
  scrollRestoration,
76
81
  entitiesDisplayedFirst,
@@ -90,19 +95,20 @@ export function useDataSourceTableController<M extends Record<string, any> = any
90
95
  const navigation = useNavigationController();
91
96
  const dataSource = useDataSource(collection);
92
97
  const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
98
+ // Passed through as-is; never derived from `resolvedPath` (see useEntityFetch).
99
+ const resolvedPathSegments = pathSegments;
93
100
 
94
101
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
95
102
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
96
103
  const pageSize = typeof collection.pagination === "number" ? collection.pagination : DEFAULT_PAGE_SIZE;
97
104
 
98
- const [searchString, setSearchString] = React.useState<string | undefined>();
99
-
100
105
  const checkFilterCombination = useCallback((filterValues: FilterValues<any>,
101
106
  sortBy?: [string, "asc" | "desc"]) => {
102
107
  if (!dataSource.isFilterCombinationValid)
103
108
  return true;
104
109
  return dataSource.isFilterCombinationValid({
105
110
  path: resolvedPath,
111
+ pathSegments: resolvedPathSegments,
106
112
  collection,
107
113
  filterValues,
108
114
  sortBy
@@ -137,8 +143,9 @@ export function useDataSourceTableController<M extends Record<string, any> = any
137
143
  const {
138
144
  filterValues: initialFilterUrl,
139
145
  sortBy: initialSortUrl,
146
+ searchString: initialSearchUrl
140
147
  } = parseFilterAndSort(location.search);
141
-
148
+
142
149
  const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
143
150
  const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
144
151
 
@@ -174,6 +181,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
174
181
 
175
182
  const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
176
183
  const [sortBy, setSortBy] = React.useState<[Extract<keyof M, string>, "asc" | "desc"] | undefined>((updateUrl ? initialSortUrl : undefined) ?? initialSortInternal);
184
+ // Like the filters and the sort, the text search term is only restored from the URL when
185
+ // this controller owns the URL. Controllers rendered inside a dialog (e.g. a reference
186
+ // selection dialog) get `updateUrl: false` and must not inherit the state of the
187
+ // collection behind them. See https://github.com/firecmsco/firecms/issues/702
188
+ const [searchString, setSearchString] = React.useState<string | undefined>(updateUrl ? initialSearchUrl : undefined);
177
189
 
178
190
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
179
191
 
@@ -223,6 +235,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
223
235
  setDataLoading(true);
224
236
 
225
237
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
238
+ // See useEntityFetch: entities carry the segments they were loaded with, so a
239
+ // reference or a delete derived from a table row stays resolvable.
240
+ if (resolvedPathSegments) {
241
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments: resolvedPathSegments });
242
+ }
226
243
  if (collection.callbacks?.onFetch) {
227
244
  try {
228
245
  entities = await Promise.all(
@@ -253,32 +270,45 @@ export function useDataSourceTableController<M extends Record<string, any> = any
253
270
  setDataLoadingError(error);
254
271
  };
255
272
 
256
- if (dataSource.listenCollection) {
257
- return dataSource.listenCollection<M>({
258
- path: resolvedPath,
259
- collection,
260
- onUpdate: onEntitiesUpdate,
261
- onError,
262
- searchString,
263
- filter: filterValues,
264
- limit: itemCount,
265
- startAfter: undefined,
266
- orderBy: sortByProperty,
267
- order: currentSort
268
- });
269
- } else {
270
- dataSource.fetchCollection<M>({
271
- path: resolvedPath,
272
- collection,
273
- searchString,
274
- filter: filterValues,
275
- limit: itemCount,
276
- startAfter: undefined,
277
- orderBy: sortByProperty,
278
- order: currentSort
279
- })
280
- .then(onEntitiesUpdate)
281
- .catch(onError);
273
+ // Data sources may throw synchronously, e.g. when a text search is requested before
274
+ // the text search backend of the collection has been initialised. That can happen
275
+ // when the search term is restored from the URL instead of typed in the search bar,
276
+ // so it is reported as a data loading error rather than being left to blow up the
277
+ // whole view from inside this effect.
278
+ try {
279
+ if (dataSource.listenCollection) {
280
+ return dataSource.listenCollection<M>({
281
+ path: resolvedPath,
282
+ pathSegments: resolvedPathSegments,
283
+ collection,
284
+ onUpdate: onEntitiesUpdate,
285
+ onError,
286
+ searchString,
287
+ filter: filterValues,
288
+ limit: itemCount,
289
+ startAfter: undefined,
290
+ orderBy: sortByProperty,
291
+ order: currentSort
292
+ });
293
+ } else {
294
+ dataSource.fetchCollection<M>({
295
+ path: resolvedPath,
296
+ pathSegments: resolvedPathSegments,
297
+ collection,
298
+ searchString,
299
+ filter: filterValues,
300
+ limit: itemCount,
301
+ startAfter: undefined,
302
+ orderBy: sortByProperty,
303
+ order: currentSort
304
+ })
305
+ .then(onEntitiesUpdate)
306
+ .catch(onError);
307
+ return () => {
308
+ };
309
+ }
310
+ } catch (e: any) {
311
+ onError(e instanceof Error ? e : new Error(String(e)));
282
312
  return () => {
283
313
  };
284
314
  }
@@ -332,9 +362,7 @@ function useUpdateUrl<M extends Record<string, any> = any>(
332
362
 
333
363
  useEffect(() => {
334
364
  if (updateUrl) {
335
- const newUrl = encodeFilterAndSort(filterValues, sortBy);
336
- const search = searchString ? `&search=${encodeURIComponent(searchString)}` : "";
337
- const state = `${newUrl}${search}`;
365
+ const state = encodeFilterAndSort(filterValues, sortBy, searchString);
338
366
  const hash = window.location.hash;
339
367
  if (state === "")
340
368
  window.history.replaceState({}, "", `${window.location.pathname}${hash}`);
@@ -343,142 +371,3 @@ function useUpdateUrl<M extends Record<string, any> = any>(
343
371
  }
344
372
  }, [filterValues, sortBy, searchString, updateUrl]);
345
373
  }
346
-
347
- function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: [string, "asc" | "desc"] | undefined) {
348
- const entries: Record<string, string> = {};
349
- if (sortBy) {
350
- entries["__sort"] = encodeURIComponent(sortBy[0]);
351
- entries["__sort_order"] = encodeURIComponent(sortBy[1]);
352
- }
353
- if (filterValues) {
354
- Object.entries(filterValues).forEach(([key, value]) => {
355
- if (value) {
356
- const [op, val] = value;
357
- let encodedValue: any = val;
358
- try {
359
- if (typeof val === "object") {
360
- if (val instanceof Date) {
361
- encodedValue = val.toISOString();
362
- } else if (Array.isArray(val)) {
363
- encodedValue = JSON.stringify(val, (key, value) => {
364
- if (value instanceof EntityReference) {
365
- return encodeRef(value);
366
- }
367
- return value;
368
- });
369
- } else if (val instanceof EntityReference) {
370
- encodedValue = encodeRef(val);
371
- }
372
- } else if (typeof val === "string") {
373
- // JSON.stringify wraps the string in quotes (e.g. "4" → '"4"')
374
- // so that decodeString's JSON.parse restores the string type,
375
- // not a number. Without this, "4" round-trips as the number 4.
376
- encodedValue = JSON.stringify(val);
377
- }
378
- } catch (e) {
379
- encodedValue = val;
380
- }
381
- if (encodedValue !== undefined) {
382
- entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op);
383
- // Note: check for null/undefined explicitly instead of truthiness,
384
- // otherwise falsy-but-valid values (boolean `false`, number `0`)
385
- // would be serialized as "null" and round-trip back as `null`,
386
- // breaking filters like `archived == false`.
387
- entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined
388
- ? encodeURIComponent(encodedValue.toString())
389
- : "null";
390
- }
391
- }
392
- });
393
- }
394
- if (!Object.keys(entries).length) {
395
- return "";
396
- }
397
- return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("&");
398
- }
399
-
400
- function parseFilterAndSort<M>(search: string): {
401
- filterValues: FilterValues<string> | undefined,
402
- sortBy?: [Extract<keyof M, string>, "asc" | "desc"]
403
- } {
404
- const entries = new URLSearchParams(search);
405
- const filterValues: FilterValues<string> = {};
406
- let sortBy: [string, "asc" | "desc"] | undefined = undefined;
407
- entries.forEach((value, key) => {
408
- if (key === "__sort") {
409
- sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"];
410
- } else if (key.endsWith("_op")) {
411
- const field = key.replace("_op", "");
412
- const filterOp = decodeURIComponent(value) as WhereFilterOp;
413
- const filterValStr = entries.get(`${field}_value`);
414
- if (filterValStr !== null) {
415
- filterValues[field] = [filterOp, decodeString(filterValStr)];
416
- }
417
- }
418
- });
419
-
420
- return {
421
- filterValues: Object.keys(filterValues).length ? filterValues : undefined,
422
- sortBy
423
- }
424
- }
425
-
426
- function isDate(dateString: string): boolean {
427
- // Define a regex pattern that matches the exact date format: 2025-01-07T23:00:00.000Z
428
- const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
429
-
430
- // Test the dateString against the regex pattern
431
- if (!regexPattern.test(dateString)) {
432
- return false;
433
- }
434
-
435
- // If the regex matches, further validate if it is a valid UTC date
436
- const date = new Date(dateString);
437
- return date.toISOString() === dateString;
438
- }
439
-
440
- function encodeRef(val: EntityReference) {
441
- return `ref::${val.path}/${encodeEntityId(val.id)}`;
442
- }
443
-
444
- /**
445
- * Split a "path/id" reference on its LAST separator: the path may itself be a
446
- * subcollection path with several segments, and the id is escaped so it is always exactly
447
- * one segment.
448
- */
449
- function decodeRef(encoded: string): EntityReference {
450
- const separatorIndex = encoded.lastIndexOf("/");
451
- if (separatorIndex < 0) return new EntityReference(encoded, "");
452
- return new EntityReference(
453
- decodeEntityId(encoded.substring(separatorIndex + 1)),
454
- encoded.substring(0, separatorIndex)
455
- );
456
- }
457
-
458
- function decodeString(val: string): EntityReference | Date | string {
459
- let parsedFilterVal: any = val;
460
- if (isDate(val)) {
461
- try {
462
- parsedFilterVal = new Date(val);
463
- } catch (e) {
464
- // ignore
465
- }
466
- }
467
- if (typeof parsedFilterVal === "string") {
468
- try {
469
- parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => {
470
- if (typeof value === "string" && value.startsWith("ref::")) {
471
- return decodeRef(value.substring(5));
472
- }
473
- return value;
474
- });
475
- } catch (e) {
476
- // ignore
477
- }
478
- }
479
-
480
- if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) {
481
- return decodeRef(parsedFilterVal.substring(5));
482
- }
483
- return parsedFilterVal;
484
- }
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { EntityCollection } from "../../types";
4
4
  import { useCustomizationController, useDataSource, useFireCMSContext } from "../../hooks";
@@ -7,12 +7,19 @@ export interface UseTableSearchHelperParams<M extends Record<string, any>> {
7
7
  collection: EntityCollection<M>;
8
8
  fullPath: string;
9
9
  parentCollectionIds?: string[];
10
+ /**
11
+ * Search term that is already being applied without the user having gone through the
12
+ * search bar, e.g. one restored from the URL query params. When set, text search is
13
+ * initialised automatically instead of waiting for a click on the search bar.
14
+ */
15
+ initialSearchString?: string;
10
16
  }
11
17
 
12
18
  export function useTableSearchHelper<M extends Record<string, any>>({
13
19
  collection,
14
20
  fullPath,
15
- parentCollectionIds
21
+ parentCollectionIds,
22
+ initialSearchString
16
23
  }: UseTableSearchHelperParams<M>) {
17
24
 
18
25
  const context = useFireCMSContext();
@@ -21,6 +28,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
21
28
 
22
29
  const [textSearchLoading, setTextSearchLoading] = useState<boolean>(false);
23
30
  const [textSearchInitialised, setTextSearchInitialised] = useState<boolean>(false);
31
+ const autoInitialisedRef = useRef<boolean>(false);
24
32
 
25
33
  let onTextSearchClick: (() => void) | undefined;
26
34
  let textSearchEnabled = Boolean(collection.textSearchEnabled);
@@ -79,6 +87,17 @@ export function useTableSearchHelper<M extends Record<string, any>>({
79
87
  }
80
88
  })
81
89
  }
90
+
91
+ // Only ever runs once, and only when a search term was restored from outside the search
92
+ // bar: otherwise the search bar would show a term the user can neither edit nor clear,
93
+ // because it stays read only until text search is initialised.
94
+ const shouldAutoInitialise = Boolean(initialSearchString) && textSearchEnabled && !textSearchInitialised && Boolean(onTextSearchClick);
95
+ useEffect(() => {
96
+ if (autoInitialisedRef.current || !shouldAutoInitialise) return;
97
+ autoInitialisedRef.current = true;
98
+ onTextSearchClick?.();
99
+ }, [shouldAutoInitialise]);
100
+
82
101
  return {
83
102
  textSearchLoading,
84
103
  textSearchInitialised,
@@ -1,12 +1,25 @@
1
1
  "use client";
2
2
 
3
- import React, { PropsWithChildren } from "react";
3
+ import React, { PropsWithChildren, useEffect, useState } from "react";
4
4
  import { SnackbarProvider as NotistackSnackbarProvider } from "notistack";
5
5
 
6
6
  export const SnackbarProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
7
7
 
8
+ // Notistack renders the snackbars inline, right where this provider sits in the
9
+ // tree, unless it is given a `domRoot`. Dialogs and side dialogs, on the other
10
+ // hand, are portalled to the body, so any ancestor of this provider that creates
11
+ // a stacking context (a transform, a filter, an `isolate`, a positioned element
12
+ // with a z-index...) traps the snackbars underneath them. Portalling the
13
+ // snackbars to the body as well puts them in the same stacking context as the
14
+ // dialogs, where their z-index actually wins.
15
+ const [domRoot, setDomRoot] = useState<HTMLElement | undefined>(undefined);
16
+ useEffect(() => {
17
+ setDomRoot(document.body);
18
+ }, []);
19
+
8
20
  return (
9
21
  <NotistackSnackbarProvider maxSnack={3}
22
+ domRoot={domRoot}
10
23
  autoHideDuration={3500}>
11
24
  {children}
12
25
  </NotistackSnackbarProvider>
@@ -71,6 +71,12 @@ export interface EntityEditViewProps<M extends Record<string, any>> {
71
71
  * The navigation path to the entity.
72
72
  */
73
73
  fullIdPath?: string;
74
+ /**
75
+ * `path` split at its real segment boundaries, e.g. `["nodes", "node/42", "edges"]`.
76
+ * Forwarded to the datasource, and extended for any subcollection rendered inside this
77
+ * view, so a parent entity id containing "/" stays unambiguous.
78
+ */
79
+ pathSegments?: string[];
74
80
  collection: EntityCollection<M>;
75
81
  entityId?: string;
76
82
  databaseId?: string;
@@ -101,6 +107,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
101
107
  dataLoadingError
102
108
  } = useEntityFetch<M, USER>({
103
109
  path: props.path,
110
+ pathSegments: props.pathSegments,
104
111
  entityId: entityId,
105
112
  collection: props.collection,
106
113
  databaseId: props.databaseId,
@@ -146,6 +153,7 @@ export function EntityEditView<M extends Record<string, any>, USER extends User>
146
153
  export function EntityEditViewInner<M extends Record<string, any>>({
147
154
  path,
148
155
  fullIdPath,
156
+ pathSegments,
149
157
  entityId,
150
158
  selectedTab: selectedTabProp,
151
159
  collection,
@@ -351,6 +359,17 @@ export function EntityEditViewInner<M extends Record<string, any>>({
351
359
  const newFullPath = usedEntity ? `${path}/${usedEntity?.id}/${removeInitialAndTrailingSlashes(subcollection.path)}` : undefined;
352
360
  // `newFullIdPath` is fed to buildUrlCollectionPath downstream, so it stays escaped.
353
361
  const newFullIdPath = fullIdPath && usedEntity ? `${fullIdPath}/${encodeEntityId(usedEntity.id)}/${removeInitialAndTrailingSlashes(subcollectionId)}` : undefined;
362
+ // The subcollection sits under this entity, so its segments are ours plus this
363
+ // entity's id (kept whole, slashes and all) plus the subcollection's own path.
364
+ // Only extendable when we were given real segments — deriving the parent chain
365
+ // from the flattened `path` would drop the boundary of a slash-bearing parent id.
366
+ const newPathSegments = usedEntity && pathSegments
367
+ ? [
368
+ ...pathSegments,
369
+ usedEntity.id,
370
+ ...removeInitialAndTrailingSlashes(subcollection.path).split("/")
371
+ ]
372
+ : undefined;
354
373
 
355
374
  if (selectedTab !== subcollectionId) return null;
356
375
  return (
@@ -365,6 +384,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
365
384
  (usedEntity && newFullPath
366
385
  ? <EntityCollectionView
367
386
  fullPath={newFullPath}
387
+ pathSegments={newPathSegments}
368
388
  fullIdPath={newFullIdPath}
369
389
  parentCollectionIds={[...parentCollectionIds, collection.id]}
370
390
  isSubCollection={true}
@@ -414,6 +434,7 @@ export function EntityEditViewInner<M extends Record<string, any>>({
414
434
  fullIdPath={fullIdPath}
415
435
  collection={collection}
416
436
  path={path}
437
+ pathSegments={pathSegments}
417
438
  entityId={entityId ?? usedEntity?.id}
418
439
  onValuesModified={onValuesModified}
419
440
  entity={entity}
@@ -25,6 +25,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
25
25
  path,
26
26
  entityId,
27
27
  fullIdPath,
28
+ pathSegments,
28
29
  formProps,
29
30
  } = props;
30
31
 
@@ -116,6 +117,7 @@ export function EntitySidePanel(props: EntitySidePanelProps) {
116
117
  <EntityEditView
117
118
  {...props}
118
119
  fullIdPath={fullIdPath}
120
+ pathSegments={pathSegments}
119
121
  layout={"side_panel"}
120
122
  collection={collection as EntityCollection}
121
123
  parentCollectionIds={parentCollectionIds}
@@ -71,6 +71,11 @@ export type OnUpdateParams = {
71
71
 
72
72
  export type EntityFormProps<M extends Record<string, any>> = {
73
73
  path: string;
74
+ /**
75
+ * `path` split at its real segment boundaries; forwarded to the datasource for
76
+ * generateEntityId and checkUniqueField. Never derived from `path`.
77
+ */
78
+ pathSegments?: string[];
74
79
  fullIdPath?: string;
75
80
  collection: EntityCollection<M>;
76
81
  entityId?: string;
@@ -214,6 +219,7 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
214
219
 
215
220
  export function EntityForm<M extends Record<string, any>>({
216
221
  path,
222
+ pathSegments,
217
223
  fullIdPath,
218
224
  entityId: entityIdProp,
219
225
  collection,
@@ -292,7 +298,7 @@ export function EntityForm<M extends Record<string, any>>({
292
298
  if (mustSetCustomId) {
293
299
  return undefined;
294
300
  } else {
295
- return dataSource.generateEntityId(path, collection);
301
+ return dataSource.generateEntityId(path, collection, pathSegments);
296
302
  }
297
303
  } else {
298
304
  return entityIdProp;
@@ -388,7 +394,7 @@ export function EntityForm<M extends Record<string, any>>({
388
394
  },
389
395
  onValuesChangeDeferred: (values: M, controller: FormexController<M>) => {
390
396
  const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
391
- if (controller.dirty) {
397
+ if (controller.dirty && localChangesBackup !== false) {
392
398
  const touchedValues = removeEmptyContainers(extractTouchedValues(values, controller.touched));
393
399
  if (touchedValues && Object.keys(touchedValues).length > 0) {
394
400
  saveEntityToCache(key, touchedValues);
@@ -692,7 +698,7 @@ export function EntityForm<M extends Record<string, any>>({
692
698
  const uniqueFieldValidator: CustomFieldValidator = useCallback(({
693
699
  name,
694
700
  value
695
- }) => dataSource.checkUniqueField(path, name, value, entityId, collection),
701
+ }) => dataSource.checkUniqueField(path, name, value, entityId, collection, pathSegments),
696
702
  [dataSource, path, entityId]);
697
703
 
698
704
  const validationSchema = useMemo(() => entityId
@@ -81,6 +81,8 @@ export async function deleteEntityWithCallbacks<M extends Record<string, any>, U
81
81
  }
82
82
  }
83
83
  return dataSource.deleteEntity({
84
+ // Carried by the entity from the fetch that produced it; undefined if unknown.
85
+ pathSegments: entity.pathSegments,
84
86
  entity,
85
87
  collection
86
88
  }).then(() => {
@@ -43,6 +43,7 @@ export type SaveEntityWithCallbacksProps<M extends Record<string, any>> =
43
43
  export async function saveEntityWithCallbacks<M extends Record<string, any>, USER extends User>({
44
44
  collection,
45
45
  path,
46
+ pathSegments,
46
47
  entityId,
47
48
  values,
48
49
  previousValues,
@@ -103,6 +104,7 @@ export async function saveEntityWithCallbacks<M extends Record<string, any>, USE
103
104
  return dataSource.saveEntity({
104
105
  collection,
105
106
  path: resolvedPath,
107
+ pathSegments,
106
108
  entityId,
107
109
  values: updatedValues,
108
110
  previousValues,
@@ -13,6 +13,12 @@ export interface CollectionFetchProps<M extends Record<string, any>> {
13
13
  * Absolute collection path
14
14
  */
15
15
  path: string;
16
+ /**
17
+ * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
18
+ * entity id containing "/" stays unambiguous. Never derived from `path` — see
19
+ * `pathSegments` on the datasource props.
20
+ */
21
+ pathSegments?: string[];
16
22
 
17
23
  /**
18
24
  * collection of the entity displayed by this collection
@@ -63,6 +69,7 @@ export interface CollectionFetchResult<M extends Record<string, any>> {
63
69
  export function useCollectionFetch<M extends Record<string, any>, USER extends User>(
64
70
  {
65
71
  path: inputPath,
72
+ pathSegments,
66
73
  collection,
67
74
  filterValues,
68
75
  sortBy,
@@ -91,6 +98,10 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
91
98
  setDataLoading(true);
92
99
 
93
100
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
101
+ // See useEntityFetch: entities carry the segments they were loaded with.
102
+ if (pathSegments) {
103
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments });
104
+ }
94
105
  if (collection.callbacks?.onFetch) {
95
106
  try {
96
107
  entities = await Promise.all(
@@ -124,6 +135,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
124
135
  if (dataSource.listenCollection) {
125
136
  return dataSource.listenCollection<M>({
126
137
  path,
138
+ pathSegments,
127
139
  collection,
128
140
  onUpdate: onEntitiesUpdate,
129
141
  onError,
@@ -137,6 +149,7 @@ export function useCollectionFetch<M extends Record<string, any>, USER extends U
137
149
  } else {
138
150
  dataSource.fetchCollection<M>({
139
151
  path,
152
+ pathSegments,
140
153
  collection,
141
154
  searchString,
142
155
  filter: filterValues,
@@ -9,6 +9,11 @@ import { useFireCMSContext } from "../useFireCMSContext";
9
9
  */
10
10
  export interface EntityFetchProps<M extends Record<string, any>, USER extends User = User> {
11
11
  path: string;
12
+ /**
13
+ * `path` split at its real segment boundaries. Forwarded to the datasource so a parent
14
+ * entity id containing "/" stays unambiguous. Defaults to splitting the resolved path.
15
+ */
16
+ pathSegments?: string[];
12
17
  entityId?: string;
13
18
  databaseId?: string;
14
19
  collection: EntityCollection<M, USER>;
@@ -39,6 +44,7 @@ const CACHE: Record<string, Entity<any> | undefined> = {};
39
44
  export function useEntityFetch<M extends Record<string, any>, USER extends User>(
40
45
  {
41
46
  path: inputPath,
47
+ pathSegments: inputPathSegments,
42
48
  entityId,
43
49
  collection,
44
50
  databaseId,
@@ -49,6 +55,10 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
49
55
  const navigationController = useNavigationController();
50
56
 
51
57
  const path = navigationController.resolveIdsFrom(inputPath);
58
+ // Never fabricate segments. If the caller did not thread them, pass undefined so a
59
+ // delegate can tell "not provided" from a real answer — splitting `path` here would
60
+ // produce a confidently wrong array for any id containing "/".
61
+ const pathSegments = inputPathSegments;
52
62
 
53
63
  const context: FireCMSContext<USER> = useFireCMSContext();
54
64
 
@@ -61,6 +71,12 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
61
71
  setDataLoading(true);
62
72
 
63
73
  const onEntityUpdate = async (updatedEntity: Entity<M> | undefined) => {
74
+ // Attach the segments this entity was loaded with, so anything derived from it
75
+ // later — a reference, a delete — stays resolvable without re-deriving them
76
+ // from the flattened path. Delegates are not required to set this themselves.
77
+ if (updatedEntity && pathSegments && !updatedEntity.pathSegments) {
78
+ updatedEntity = { ...updatedEntity, pathSegments };
79
+ }
64
80
  if (collection.callbacks?.onFetch && updatedEntity) {
65
81
  try {
66
82
  updatedEntity = await collection.callbacks.onFetch({
@@ -97,6 +113,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
97
113
  if (dataSource.listenEntity) {
98
114
  return dataSource.listenEntity<M>({
99
115
  path,
116
+ pathSegments,
100
117
  entityId,
101
118
  databaseId,
102
119
  collection,
@@ -106,6 +123,7 @@ export function useEntityFetch<M extends Record<string, any>, USER extends User>
106
123
  } else {
107
124
  dataSource.fetchEntity<M>({
108
125
  path,
126
+ pathSegments,
109
127
  entityId,
110
128
  databaseId,
111
129
  collection
@@ -86,6 +86,8 @@ export function resolveNavigationFrom<M extends Record<string, any>, USER extend
86
86
  }
87
87
  return dataSource.fetchEntity({
88
88
  path: entry.path,
89
+ // The navigation entry already knows the real segment boundaries.
90
+ pathSegments: entry.pathSegments,
89
91
  entityId: entry.entityId,
90
92
  collection
91
93
  })