@firecms/core 3.4.0-canary.f6a889a → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  2. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  3. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  5. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  8. package/dist/components/ErrorBoundary.d.ts +1 -3
  9. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  10. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  11. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  12. package/dist/components/common/table_url_params.d.ts +24 -0
  13. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  14. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  15. package/dist/core/EntityEditView.d.ts +9 -1
  16. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  17. package/dist/form/EntityForm.d.ts +8 -1
  18. package/dist/form/EntityFormActions.d.ts +2 -0
  19. package/dist/hooks/data/delete.d.ts +1 -1
  20. package/dist/hooks/data/save.d.ts +1 -1
  21. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  22. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  23. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  24. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  25. package/dist/index.es.js +1753 -1170
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +1751 -1168
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/types/collections.d.ts +14 -0
  30. package/dist/types/datasource.d.ts +67 -4
  31. package/dist/types/entities.d.ts +21 -1
  32. package/dist/types/entity_actions.d.ts +6 -0
  33. package/dist/types/entity_callbacks.d.ts +21 -0
  34. package/dist/types/navigation.d.ts +34 -5
  35. package/dist/types/plugins.d.ts +13 -0
  36. package/dist/types/properties.d.ts +17 -1
  37. package/dist/types/side_entity_controller.d.ts +7 -0
  38. package/dist/types/translations.d.ts +6 -0
  39. package/dist/util/__tests__/collections.test.d.ts +1 -0
  40. package/dist/util/__tests__/urls.test.d.ts +1 -0
  41. package/dist/util/collections.d.ts +1 -1
  42. package/dist/util/entity_cache.d.ts +11 -0
  43. package/dist/util/index.d.ts +1 -0
  44. package/dist/util/navigation_from_path.d.ts +7 -0
  45. package/dist/util/navigation_utils.d.ts +87 -3
  46. package/dist/util/parent_references_from_path.d.ts +16 -0
  47. package/dist/util/paths.d.ts +28 -0
  48. package/dist/util/permissions.d.ts +10 -4
  49. package/dist/util/urls.d.ts +22 -0
  50. package/package.json +4 -4
  51. package/src/components/DeleteEntityDialog.tsx +2 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  53. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +1 -0
  54. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  55. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  56. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  57. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  58. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
  60. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  61. package/src/components/EntityPreview.tsx +4 -1
  62. package/src/components/ErrorBoundary.tsx +12 -18
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  64. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  65. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  66. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  67. package/src/components/common/default_entity_actions.tsx +22 -2
  68. package/src/components/common/table_url_params.ts +172 -0
  69. package/src/components/common/useDataSourceTableController.tsx +86 -179
  70. package/src/components/common/useTableSearchHelper.ts +26 -2
  71. package/src/contexts/SnackbarProvider.tsx +14 -1
  72. package/src/core/EntityEditView.tsx +20 -3
  73. package/src/core/EntityEditViewFormActions.tsx +3 -2
  74. package/src/core/EntitySidePanel.tsx +8 -4
  75. package/src/form/EntityForm.tsx +31 -10
  76. package/src/form/EntityFormActions.tsx +2 -0
  77. package/src/hooks/data/delete.ts +8 -0
  78. package/src/hooks/data/save.ts +6 -1
  79. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  80. package/src/hooks/data/useEntityFetch.tsx +35 -4
  81. package/src/hooks/useBuildNavigationController.tsx +23 -8
  82. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  83. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  84. package/src/i18n/FireCMSi18nProvider.tsx +48 -4
  85. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  86. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  87. package/src/internal/useBuildDataSource.ts +37 -15
  88. package/src/internal/useBuildSideEntityController.tsx +19 -5
  89. package/src/locales/de.ts +5 -0
  90. package/src/locales/en.ts +5 -0
  91. package/src/locales/es.ts +5 -0
  92. package/src/locales/fr.ts +5 -0
  93. package/src/locales/hi.ts +5 -0
  94. package/src/locales/it.ts +5 -0
  95. package/src/locales/pl.ts +5 -0
  96. package/src/locales/pt.ts +5 -0
  97. package/src/preview/components/ReferencePreview.tsx +7 -3
  98. package/src/preview/components/StorageThumbnail.tsx +24 -2
  99. package/src/routes/FireCMSRoute.tsx +11 -3
  100. package/src/types/collections.ts +16 -0
  101. package/src/types/datasource.ts +67 -4
  102. package/src/types/entities.ts +24 -1
  103. package/src/types/entity_actions.tsx +6 -0
  104. package/src/types/entity_callbacks.ts +24 -0
  105. package/src/types/navigation.ts +35 -5
  106. package/src/types/plugins.tsx +13 -0
  107. package/src/types/properties.ts +17 -0
  108. package/src/types/side_entity_controller.tsx +7 -0
  109. package/src/types/translations.ts +6 -0
  110. package/src/util/__tests__/collections.test.ts +32 -0
  111. package/src/util/__tests__/urls.test.ts +131 -0
  112. package/src/util/collections.ts +1 -1
  113. package/src/util/entities.ts +3 -1
  114. package/src/util/entity_cache.ts +18 -0
  115. package/src/util/index.ts +1 -0
  116. package/src/util/navigation_from_path.ts +25 -2
  117. package/src/util/navigation_utils.ts +208 -2
  118. package/src/util/parent_references_from_path.ts +42 -3
  119. package/src/util/paths.ts +40 -3
  120. package/src/util/permissions.ts +22 -10
  121. package/src/util/previews.ts +8 -1
  122. package/src/util/resolutions.ts +8 -0
  123. package/src/util/urls.ts +52 -0
@@ -18,6 +18,7 @@ export const editEntityAction: EntityAction = {
18
18
  entity,
19
19
  collection,
20
20
  fullPath,
21
+ pathSegments,
21
22
  fullIdPath,
22
23
  context,
23
24
  highlightEntity,
@@ -41,6 +42,12 @@ export const editEntityAction: EntityAction = {
41
42
  }
42
43
 
43
44
  const path = collection?.collectionGroup ? entity.path : (fullPath ?? collection?.path ?? entity.path);
45
+ // In a collection group `path` is the entity's own, so the entity's segments describe
46
+ // it. Otherwise the caller's segments win, falling back to the ones the entity was
47
+ // loaded with — both describe the same collection. Never derived from `path`.
48
+ const resolvedPathSegments = collection?.collectionGroup
49
+ ? entity.pathSegments
50
+ : (pathSegments ?? entity.pathSegments);
44
51
  const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id ?? entity.path);
45
52
  const defaultSelectedView = resolveDefaultSelectedView(
46
53
  collection ? collection.defaultSelectedView : undefined,
@@ -54,6 +61,7 @@ export const editEntityAction: EntityAction = {
54
61
  collection,
55
62
  entityId: entity.id,
56
63
  path,
64
+ pathSegments: resolvedPathSegments,
57
65
  fullIdPath: newFullIdPath,
58
66
  sideEntityController: context.sideEntityController,
59
67
  onClose: () => unhighlightEntity?.(entity),
@@ -75,6 +83,8 @@ export const copyEntityAction: EntityAction = {
75
83
  collection,
76
84
  context,
77
85
  fullPath,
86
+ pathSegments,
87
+ fullIdPath,
78
88
  highlightEntity,
79
89
  unhighlightEntity,
80
90
  openEntityMode
@@ -89,13 +99,23 @@ export const copyEntityAction: EntityAction = {
89
99
  });
90
100
 
91
101
  const path = collection?.collectionGroup ? collection.path : (fullPath ?? collection?.path ?? entity.path);
92
- const fullIdPath = collection?.collectionGroup ? collection.id : (fullPath ?? collection?.id ?? entity.path);
102
+ // In a collection group `path` is the group's own root, which the entity's segments
103
+ // (its real nested location) do not describe — so they are deliberately not reused,
104
+ // and no substitute is invented.
105
+ const resolvedPathSegments = collection?.collectionGroup
106
+ ? undefined
107
+ : (pathSegments ?? entity.pathSegments);
108
+ // `fullIdPath` is the ESCAPED chain and is what becomes the URL; `fullPath` is the raw
109
+ // datasource path, whose parent ids may contain "/". Using the latter here wrote a URL
110
+ // that read back as a different entity. Mirrors editEntityAction.
111
+ const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id);
93
112
  navigateToEntity({
94
113
  openEntityMode,
95
114
  collection,
96
115
  entityId: entity.id,
97
116
  path,
98
- fullIdPath,
117
+ pathSegments: resolvedPathSegments,
118
+ fullIdPath: newFullIdPath,
99
119
  copy: true,
100
120
  sideEntityController: context.sideEntityController,
101
121
  onClose: () => unhighlightEntity?.(entity),
@@ -0,0 +1,172 @@
1
+ import { EntityReference, FilterValues, WhereFilterOp } from "../../types";
2
+ import { decodeEntityId, encodeEntityId } from "../../util/navigation_utils";
3
+
4
+ /**
5
+ * Internal helpers used by `useDataSourceTableController` to persist the state of a
6
+ * collection table (filters, sort and text search) in the URL query string.
7
+ *
8
+ * These functions are intentionally NOT re-exported by the package barrel files:
9
+ * they are pure and live in their own module only so that they can be unit tested.
10
+ * `encodeFilterAndSort` and `parseFilterAndSort` must stay exact inverses of each
11
+ * other, otherwise state written to the URL is silently dropped on reload.
12
+ */
13
+
14
+ /**
15
+ * Serialise the state of a collection table into a URL query string,
16
+ * without the leading `?`. Returns an empty string when there is no state to persist.
17
+ */
18
+ export function encodeFilterAndSort(filterValues?: FilterValues<string>,
19
+ sortBy?: [string, "asc" | "desc"] | undefined,
20
+ searchString?: string) {
21
+ const entries: Record<string, string> = {};
22
+ if (sortBy) {
23
+ entries["__sort"] = encodeURIComponent(sortBy[0]);
24
+ entries["__sort_order"] = encodeURIComponent(sortBy[1]);
25
+ }
26
+ if (filterValues) {
27
+ Object.entries(filterValues).forEach(([key, value]) => {
28
+ if (value) {
29
+ const [op, val] = value;
30
+ let encodedValue: any = val;
31
+ try {
32
+ if (typeof val === "object") {
33
+ if (val instanceof Date) {
34
+ encodedValue = val.toISOString();
35
+ } else if (Array.isArray(val)) {
36
+ encodedValue = JSON.stringify(val, (key, value) => {
37
+ if (value instanceof EntityReference) {
38
+ return encodeRef(value);
39
+ }
40
+ return value;
41
+ });
42
+ } else if (val instanceof EntityReference) {
43
+ encodedValue = encodeRef(val);
44
+ }
45
+ } else if (typeof val === "string") {
46
+ // JSON.stringify wraps the string in quotes (e.g. "4" → '"4"')
47
+ // so that decodeString's JSON.parse restores the string type,
48
+ // not a number. Without this, "4" round-trips as the number 4.
49
+ encodedValue = JSON.stringify(val);
50
+ }
51
+ } catch (e) {
52
+ encodedValue = val;
53
+ }
54
+ if (encodedValue !== undefined) {
55
+ entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op);
56
+ // Note: check for null/undefined explicitly instead of truthiness,
57
+ // otherwise falsy-but-valid values (boolean `false`, number `0`)
58
+ // would be serialized as "null" and round-trip back as `null`,
59
+ // breaking filters like `archived == false`.
60
+ entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined
61
+ ? encodeURIComponent(encodedValue.toString())
62
+ : "null";
63
+ }
64
+ }
65
+ });
66
+ }
67
+ // The text search term is encoded once here; `parseFilterAndSort` reads it back with
68
+ // `URLSearchParams`, which decodes exactly once. Do NOT decode it a second time or
69
+ // terms containing a literal `%` (e.g. "50%") would throw a URIError.
70
+ if (searchString) {
71
+ entries["search"] = encodeURIComponent(searchString);
72
+ }
73
+ if (!Object.keys(entries).length) {
74
+ return "";
75
+ }
76
+ return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("&");
77
+ }
78
+
79
+ /**
80
+ * Restore the state of a collection table from a URL query string.
81
+ * The inverse of {@link encodeFilterAndSort}.
82
+ */
83
+ export function parseFilterAndSort<M>(search: string): {
84
+ filterValues: FilterValues<string> | undefined,
85
+ sortBy?: [Extract<keyof M, string>, "asc" | "desc"],
86
+ searchString?: string
87
+ } {
88
+ const entries = new URLSearchParams(search);
89
+ const filterValues: FilterValues<string> = {};
90
+ let sortBy: [string, "asc" | "desc"] | undefined = undefined;
91
+ entries.forEach((value, key) => {
92
+ if (key === "__sort") {
93
+ sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"];
94
+ } else if (key.endsWith("_op")) {
95
+ const field = key.replace("_op", "");
96
+ const filterOp = decodeURIComponent(value) as WhereFilterOp;
97
+ const filterValStr = entries.get(`${field}_value`);
98
+ if (filterValStr !== null) {
99
+ filterValues[field] = [filterOp, decodeString(filterValStr)];
100
+ }
101
+ }
102
+ });
103
+
104
+ // `URLSearchParams` has already percent-decoded the value, so it is used as is.
105
+ const searchString = entries.get("search") ?? undefined;
106
+
107
+ return {
108
+ filterValues: Object.keys(filterValues).length ? filterValues : undefined,
109
+ sortBy,
110
+ searchString: searchString ? searchString : undefined
111
+ }
112
+ }
113
+
114
+ function isDate(dateString: string): boolean {
115
+ // Define a regex pattern that matches the exact date format: 2025-01-07T23:00:00.000Z
116
+ const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
117
+
118
+ // Test the dateString against the regex pattern
119
+ if (!regexPattern.test(dateString)) {
120
+ return false;
121
+ }
122
+
123
+ // If the regex matches, further validate if it is a valid UTC date
124
+ const date = new Date(dateString);
125
+ return date.toISOString() === dateString;
126
+ }
127
+
128
+ function encodeRef(val: EntityReference) {
129
+ return `ref::${val.path}/${encodeEntityId(val.id)}`;
130
+ }
131
+
132
+ /**
133
+ * Split a "path/id" reference on its LAST separator: the path may itself be a
134
+ * subcollection path with several segments, and the id is escaped so it is always exactly
135
+ * one segment.
136
+ */
137
+ function decodeRef(encoded: string): EntityReference {
138
+ const separatorIndex = encoded.lastIndexOf("/");
139
+ if (separatorIndex < 0) return new EntityReference(encoded, "");
140
+ return new EntityReference(
141
+ decodeEntityId(encoded.substring(separatorIndex + 1)),
142
+ encoded.substring(0, separatorIndex)
143
+ );
144
+ }
145
+
146
+ function decodeString(val: string): EntityReference | Date | string {
147
+ let parsedFilterVal: any = val;
148
+ if (isDate(val)) {
149
+ try {
150
+ parsedFilterVal = new Date(val);
151
+ } catch (e) {
152
+ // ignore
153
+ }
154
+ }
155
+ if (typeof parsedFilterVal === "string") {
156
+ try {
157
+ parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => {
158
+ if (typeof value === "string" && value.startsWith("ref::")) {
159
+ return decodeRef(value.substring(5));
160
+ }
161
+ return value;
162
+ });
163
+ } catch (e) {
164
+ // ignore
165
+ }
166
+ }
167
+
168
+ if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) {
169
+ return decodeRef(parsedFilterVal.substring(5));
170
+ }
171
+ return parsedFilterVal;
172
+ }
@@ -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,14 @@ 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.
31
+ *
32
+ * Optional, and never derived by splitting `fullPath` — a guess would be wrong in
33
+ * exactly the case the field exists for. Absent means "not known here", not "no slashes".
34
+ */
35
+ pathSegments?: string[];
30
36
  /**
31
37
  * The collection that is represented by this config.
32
38
  */
@@ -71,6 +77,7 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
71
77
  export function useDataSourceTableController<M extends Record<string, any> = any, USER extends User = User>(
72
78
  {
73
79
  fullPath,
80
+ pathSegments,
74
81
  collection,
75
82
  scrollRestoration,
76
83
  entitiesDisplayedFirst,
@@ -89,20 +96,24 @@ export function useDataSourceTableController<M extends Record<string, any> = any
89
96
  const [popupCell, setPopupCell] = React.useState<SelectedCellProps<M> | undefined>(undefined);
90
97
  const navigation = useNavigationController();
91
98
  const dataSource = useDataSource(collection);
92
- const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
99
+ // Resolved together so the two representations always describe the same chain.
100
+ // Segments are resolved, never derived (see useEntityFetch).
101
+ const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath, pathSegments), [fullPath, pathSegments, navigation.resolveIdsFrom]);
102
+ const resolvedPathSegments = useMemo(() => pathSegments
103
+ ? (navigation.resolveSegmentsFrom?.(pathSegments) ?? pathSegments)
104
+ : undefined, [pathSegments, navigation.resolveSegmentsFrom]);
93
105
 
94
106
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
95
107
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
96
108
  const pageSize = typeof collection.pagination === "number" ? collection.pagination : DEFAULT_PAGE_SIZE;
97
109
 
98
- const [searchString, setSearchString] = React.useState<string | undefined>();
99
-
100
110
  const checkFilterCombination = useCallback((filterValues: FilterValues<any>,
101
111
  sortBy?: [string, "asc" | "desc"]) => {
102
112
  if (!dataSource.isFilterCombinationValid)
103
113
  return true;
104
114
  return dataSource.isFilterCombinationValid({
105
115
  path: resolvedPath,
116
+ pathSegments: resolvedPathSegments,
106
117
  collection,
107
118
  filterValues,
108
119
  sortBy
@@ -137,8 +148,9 @@ export function useDataSourceTableController<M extends Record<string, any> = any
137
148
  const {
138
149
  filterValues: initialFilterUrl,
139
150
  sortBy: initialSortUrl,
151
+ searchString: initialSearchUrl
140
152
  } = parseFilterAndSort(location.search);
141
-
153
+
142
154
  const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
143
155
  const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
144
156
 
@@ -174,14 +186,31 @@ export function useDataSourceTableController<M extends Record<string, any> = any
174
186
 
175
187
  const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
176
188
  const [sortBy, setSortBy] = React.useState<[Extract<keyof M, string>, "asc" | "desc"] | undefined>((updateUrl ? initialSortUrl : undefined) ?? initialSortInternal);
189
+ // Like the filters and the sort, the text search term is only restored from the URL when
190
+ // this controller owns the URL. Controllers rendered inside a dialog (e.g. a reference
191
+ // selection dialog) get `updateUrl: false` and must not inherit the state of the
192
+ // collection behind them. See https://github.com/firecmsco/firecms/issues/702
193
+ const [searchString, setSearchString] = React.useState<string | undefined>(updateUrl ? initialSearchUrl : undefined);
177
194
 
178
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
179
196
 
180
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
181
- const initialItemCount = collectionScroll?.data.length ?? pageSize;
197
+ // Keyed by `resolvedPath`, not `fullPath`, so the read agrees with the two writes below
198
+ // and in `onScroll`. What the cache holds is a slice of a dataset plus the offset into
199
+ // it, and the dataset is identified by what the datasource was actually queried with:
200
+ // `resolvedPath` + filters. Reading with the unresolved `fullPath` meant that for every
201
+ // collection reached through an id alias, or a subcollection whose parent ids need
202
+ // resolving, the entry was written under one key and looked up under another, so scroll
203
+ // restoration silently did nothing there.
204
+ const collectionScroll = scrollRestoration?.getCollectionScroll(resolvedPath, filterValues);
205
+ const initialItemCount = collectionScroll?.data.length || pageSize;
182
206
 
183
207
  useEffect(() => {
184
- if (scrollRestoration) {
208
+ // Only re-seed an entry we actually have something to restore for. Writing an empty
209
+ // `data` array here poisoned the cache: this is a module level Map that outlives the
210
+ // mount, so the next mount of the same collection read `data.length === 0` back as the
211
+ // initial item count and called the datasource with `limit: 0`. Custom datasources
212
+ // treat that as falsy and drop the limit altogether, loading the whole collection.
213
+ if (scrollRestoration && rawData.length > 0) {
185
214
  scrollRestoration.updateCollectionScroll({
186
215
  fullPath: resolvedPath,
187
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -223,6 +252,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
223
252
  setDataLoading(true);
224
253
 
225
254
  const onEntitiesUpdate = async (entities: Entity<M>[]) => {
255
+ // See useEntityFetch: entities carry the segments they were loaded with, so a
256
+ // reference or a delete derived from a table row stays resolvable.
257
+ if (resolvedPathSegments) {
258
+ entities = entities.map(e => e.pathSegments ? e : { ...e, pathSegments: resolvedPathSegments });
259
+ }
226
260
  if (collection.callbacks?.onFetch) {
227
261
  try {
228
262
  entities = await Promise.all(
@@ -230,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
230
264
  collection.callbacks!.onFetch!({
231
265
  collection,
232
266
  path: resolvedPath,
267
+ pathSegments: resolvedPathSegments,
233
268
  entity,
234
269
  context
235
270
  })));
@@ -253,32 +288,45 @@ export function useDataSourceTableController<M extends Record<string, any> = any
253
288
  setDataLoadingError(error);
254
289
  };
255
290
 
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);
291
+ // Data sources may throw synchronously, e.g. when a text search is requested before
292
+ // the text search backend of the collection has been initialised. That can happen
293
+ // when the search term is restored from the URL instead of typed in the search bar,
294
+ // so it is reported as a data loading error rather than being left to blow up the
295
+ // whole view from inside this effect.
296
+ try {
297
+ if (dataSource.listenCollection) {
298
+ return dataSource.listenCollection<M>({
299
+ path: resolvedPath,
300
+ pathSegments: resolvedPathSegments,
301
+ collection,
302
+ onUpdate: onEntitiesUpdate,
303
+ onError,
304
+ searchString,
305
+ filter: filterValues,
306
+ limit: itemCount,
307
+ startAfter: undefined,
308
+ orderBy: sortByProperty,
309
+ order: currentSort
310
+ });
311
+ } else {
312
+ dataSource.fetchCollection<M>({
313
+ path: resolvedPath,
314
+ pathSegments: resolvedPathSegments,
315
+ collection,
316
+ searchString,
317
+ filter: filterValues,
318
+ limit: itemCount,
319
+ startAfter: undefined,
320
+ orderBy: sortByProperty,
321
+ order: currentSort
322
+ })
323
+ .then(onEntitiesUpdate)
324
+ .catch(onError);
325
+ return () => {
326
+ };
327
+ }
328
+ } catch (e: any) {
329
+ onError(e instanceof Error ? e : new Error(String(e)));
282
330
  return () => {
283
331
  };
284
332
  }
@@ -332,9 +380,7 @@ function useUpdateUrl<M extends Record<string, any> = any>(
332
380
 
333
381
  useEffect(() => {
334
382
  if (updateUrl) {
335
- const newUrl = encodeFilterAndSort(filterValues, sortBy);
336
- const search = searchString ? `&search=${encodeURIComponent(searchString)}` : "";
337
- const state = `${newUrl}${search}`;
383
+ const state = encodeFilterAndSort(filterValues, sortBy, searchString);
338
384
  const hash = window.location.hash;
339
385
  if (state === "")
340
386
  window.history.replaceState({}, "", `${window.location.pathname}${hash}`);
@@ -343,142 +389,3 @@ function useUpdateUrl<M extends Record<string, any> = any>(
343
389
  }
344
390
  }, [filterValues, sortBy, searchString, updateUrl]);
345
391
  }
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";
@@ -6,13 +6,23 @@ import { useCustomizationController, useDataSource, useFireCMSContext } from "..
6
6
  export interface UseTableSearchHelperParams<M extends Record<string, any>> {
7
7
  collection: EntityCollection<M>;
8
8
  fullPath: string;
9
+ /** `fullPath` split at its real segment boundaries, when known. Never derived from it. */
10
+ pathSegments?: string[];
9
11
  parentCollectionIds?: string[];
12
+ /**
13
+ * Search term that is already being applied without the user having gone through the
14
+ * search bar, e.g. one restored from the URL query params. When set, text search is
15
+ * initialised automatically instead of waiting for a click on the search bar.
16
+ */
17
+ initialSearchString?: string;
10
18
  }
11
19
 
12
20
  export function useTableSearchHelper<M extends Record<string, any>>({
13
21
  collection,
14
22
  fullPath,
15
- parentCollectionIds
23
+ pathSegments,
24
+ parentCollectionIds,
25
+ initialSearchString
16
26
  }: UseTableSearchHelperParams<M>) {
17
27
 
18
28
  const context = useFireCMSContext();
@@ -21,6 +31,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
21
31
 
22
32
  const [textSearchLoading, setTextSearchLoading] = useState<boolean>(false);
23
33
  const [textSearchInitialised, setTextSearchInitialised] = useState<boolean>(false);
34
+ const autoInitialisedRef = useRef<boolean>(false);
24
35
 
25
36
  let onTextSearchClick: (() => void) | undefined;
26
37
  let textSearchEnabled = Boolean(collection.textSearchEnabled);
@@ -28,6 +39,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
28
39
  const props = {
29
40
  context,
30
41
  path: fullPath,
42
+ pathSegments,
31
43
  databaseId: collection.databaseId,
32
44
  collection,
33
45
  parentCollectionIds
@@ -54,6 +66,7 @@ export function useTableSearchHelper<M extends Record<string, any>>({
54
66
  promises.push(p.collectionView.onTextSearchClick({
55
67
  context,
56
68
  path: fullPath,
69
+ pathSegments,
57
70
  collection,
58
71
  parentCollectionIds
59
72
  }));
@@ -79,6 +92,17 @@ export function useTableSearchHelper<M extends Record<string, any>>({
79
92
  }
80
93
  })
81
94
  }
95
+
96
+ // Only ever runs once, and only when a search term was restored from outside the search
97
+ // bar: otherwise the search bar would show a term the user can neither edit nor clear,
98
+ // because it stays read only until text search is initialised.
99
+ const shouldAutoInitialise = Boolean(initialSearchString) && textSearchEnabled && !textSearchInitialised && Boolean(onTextSearchClick);
100
+ useEffect(() => {
101
+ if (autoInitialisedRef.current || !shouldAutoInitialise) return;
102
+ autoInitialisedRef.current = true;
103
+ onTextSearchClick?.();
104
+ }, [shouldAutoInitialise]);
105
+
82
106
  return {
83
107
  textSearchLoading,
84
108
  textSearchInitialised,