@firecms/core 3.3.0 → 3.4.0-canary.00f9be5

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 (220) hide show
  1. package/README.md +4 -0
  2. package/dist/app/useApp.d.ts +1 -0
  3. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  4. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  5. package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  7. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
  8. package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
  9. package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
  10. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  11. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  12. package/dist/components/EntityPreview.d.ts +3 -1
  13. package/dist/components/ErrorBoundary.d.ts +1 -3
  14. package/dist/components/LicenseBanner.d.ts +23 -0
  15. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  16. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  17. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  18. package/dist/components/common/table_url_params.d.ts +24 -0
  19. package/dist/components/common/useDataSourceTableController.d.ts +9 -1
  20. package/dist/components/common/useTableSearchHelper.d.ts +9 -1
  21. package/dist/components/index.d.ts +1 -0
  22. package/dist/contexts/LicenseStatusContext.d.ts +16 -0
  23. package/dist/core/DefaultDrawer.d.ts +10 -3
  24. package/dist/core/EntityEditView.d.ts +9 -1
  25. package/dist/core/EntityEditViewFormActions.d.ts +1 -1
  26. package/dist/core/field_configs.d.ts +1 -1
  27. package/dist/core/index.d.ts +1 -0
  28. package/dist/core/plugin_translations.d.ts +38 -0
  29. package/dist/core/pro_plugins.d.ts +40 -0
  30. package/dist/de-BF5vcQPY.js +703 -0
  31. package/dist/de-BF5vcQPY.js.map +1 -0
  32. package/dist/editor/extensions/Image.d.ts +11 -0
  33. package/dist/editor/index.d.ts +2 -1
  34. package/dist/editor/lazy_editor.d.ts +6 -0
  35. package/dist/editor-BSo_aS1I.js +4563 -0
  36. package/dist/editor-BSo_aS1I.js.map +1 -0
  37. package/dist/form/EntityForm.d.ts +8 -1
  38. package/dist/form/EntityFormActions.d.ts +2 -0
  39. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  40. package/dist/form/index.d.ts +1 -0
  41. package/dist/fr-DH9_wyFI.js +703 -0
  42. package/dist/fr-DH9_wyFI.js.map +1 -0
  43. package/dist/hi-BM8dm1Lu.js +703 -0
  44. package/dist/hi-BM8dm1Lu.js.map +1 -0
  45. package/dist/hooks/data/delete.d.ts +1 -1
  46. package/dist/hooks/data/save.d.ts +1 -1
  47. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  48. package/dist/hooks/data/useEntityFetch.d.ts +9 -1
  49. package/dist/hooks/index.d.ts +2 -0
  50. package/dist/hooks/useLicenseStatus.d.ts +16 -0
  51. package/dist/hooks/useProjectLog.d.ts +11 -6
  52. package/dist/hooks/useTranslation.d.ts +1 -1
  53. package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
  54. package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
  55. package/dist/index.es.js +4516 -10668
  56. package/dist/index.es.js.map +1 -1
  57. package/dist/index.umd.js +9403 -6774
  58. package/dist/index.umd.js.map +1 -1
  59. package/dist/it-D3K1gfe4.js +703 -0
  60. package/dist/it-D3K1gfe4.js.map +1 -0
  61. package/dist/locales/pl.d.ts +2 -0
  62. package/dist/pl-CP1zeLzn.js +705 -0
  63. package/dist/pl-CP1zeLzn.js.map +1 -0
  64. package/dist/preview/index.d.ts +1 -0
  65. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  66. package/dist/pt-Cus7eKQR.js +705 -0
  67. package/dist/pt-Cus7eKQR.js.map +1 -0
  68. package/dist/types/analytics.d.ts +1 -1
  69. package/dist/types/collections.d.ts +31 -0
  70. package/dist/types/datasource.d.ts +67 -4
  71. package/dist/types/entities.d.ts +21 -1
  72. package/dist/types/entity_actions.d.ts +6 -0
  73. package/dist/types/entity_callbacks.d.ts +21 -0
  74. package/dist/types/firecms.d.ts +18 -1
  75. package/dist/types/firecms_context.d.ts +8 -0
  76. package/dist/types/index.d.ts +1 -0
  77. package/dist/types/license.d.ts +55 -0
  78. package/dist/types/navigation.d.ts +34 -5
  79. package/dist/types/plugins.d.ts +22 -1
  80. package/dist/types/properties.d.ts +17 -1
  81. package/dist/types/side_entity_controller.d.ts +7 -0
  82. package/dist/types/translations.d.ts +43 -2
  83. package/dist/util/__tests__/collections.test.d.ts +1 -0
  84. package/dist/util/__tests__/urls.test.d.ts +1 -0
  85. package/dist/util/collections.d.ts +1 -1
  86. package/dist/util/date_fns_locales.d.ts +18 -0
  87. package/dist/util/entities.d.ts +1 -0
  88. package/dist/util/entity_cache.d.ts +11 -0
  89. package/dist/util/geopoint.d.ts +22 -0
  90. package/dist/util/index.d.ts +3 -0
  91. package/dist/util/lazy_eager.d.ts +3 -3
  92. package/dist/util/navigation_blocking.d.ts +22 -0
  93. package/dist/util/navigation_from_path.d.ts +17 -0
  94. package/dist/util/navigation_utils.d.ts +107 -3
  95. package/dist/util/parent_references_from_path.d.ts +16 -0
  96. package/dist/util/paths.d.ts +28 -0
  97. package/dist/util/permissions.d.ts +10 -4
  98. package/dist/util/urls.d.ts +22 -0
  99. package/package.json +17 -11
  100. package/src/app/Scaffold.tsx +41 -44
  101. package/src/app/useApp.tsx +1 -0
  102. package/src/components/DeleteEntityDialog.tsx +2 -0
  103. package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
  104. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  105. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  106. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  107. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  108. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  109. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
  110. package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
  111. package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
  112. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +8 -1
  113. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  114. package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
  115. package/src/components/EntityPreview.tsx +45 -41
  116. package/src/components/ErrorBoundary.tsx +12 -18
  117. package/src/components/LicenseBanner.tsx +207 -0
  118. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
  119. package/src/components/ReferenceWidget.tsx +1 -1
  120. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  121. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  122. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  123. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  124. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  125. package/src/components/common/default_entity_actions.tsx +22 -2
  126. package/src/components/common/table_url_params.ts +172 -0
  127. package/src/components/common/useDataSourceTableController.tsx +126 -163
  128. package/src/components/common/useTableSearchHelper.ts +26 -2
  129. package/src/components/index.tsx +1 -0
  130. package/src/contexts/LicenseStatusContext.tsx +50 -0
  131. package/src/contexts/SnackbarProvider.tsx +14 -1
  132. package/src/core/DefaultAppBar.tsx +28 -16
  133. package/src/core/DefaultDrawer.tsx +150 -64
  134. package/src/core/DrawerNavigationGroup.tsx +27 -29
  135. package/src/core/DrawerNavigationItem.tsx +10 -12
  136. package/src/core/EntityEditView.tsx +77 -35
  137. package/src/core/EntityEditViewFormActions.tsx +3 -2
  138. package/src/core/EntitySidePanel.tsx +8 -4
  139. package/src/core/FireCMS.tsx +73 -47
  140. package/src/core/SideDialogs.tsx +44 -1
  141. package/src/core/field_configs.tsx +15 -0
  142. package/src/core/index.tsx +1 -0
  143. package/src/core/plugin_translations.ts +87 -0
  144. package/src/core/pro_plugins.ts +79 -0
  145. package/src/editor/components/SlashCommandMenu.tsx +4 -1
  146. package/src/editor/extensions/Image.ts +17 -1
  147. package/src/editor/hooks/useProseMirror.ts +5 -0
  148. package/src/editor/index.ts +11 -1
  149. package/src/editor/lazy_editor.tsx +33 -0
  150. package/src/form/EntityForm.tsx +31 -10
  151. package/src/form/EntityFormActions.tsx +2 -0
  152. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  153. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  154. package/src/form/index.tsx +1 -0
  155. package/src/hooks/data/delete.ts +8 -0
  156. package/src/hooks/data/save.ts +6 -1
  157. package/src/hooks/data/useCollectionFetch.tsx +21 -1
  158. package/src/hooks/data/useEntityFetch.tsx +35 -4
  159. package/src/hooks/index.tsx +2 -0
  160. package/src/hooks/useBuildNavigationController.tsx +70 -15
  161. package/src/hooks/useFireCMSContext.tsx +7 -3
  162. package/src/hooks/useLicenseStatus.tsx +32 -0
  163. package/src/hooks/useProjectLog.tsx +27 -9
  164. package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
  165. package/src/hooks/useTranslation.ts +3 -1
  166. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  167. package/src/i18n/FireCMSi18nProvider.tsx +199 -23
  168. package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
  169. package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
  170. package/src/internal/useBuildDataSource.ts +37 -15
  171. package/src/internal/useBuildSideEntityController.tsx +20 -5
  172. package/src/locales/de.ts +26 -4
  173. package/src/locales/en.ts +30 -6
  174. package/src/locales/es.ts +26 -4
  175. package/src/locales/fr.ts +26 -4
  176. package/src/locales/hi.ts +26 -4
  177. package/src/locales/it.ts +26 -4
  178. package/src/locales/pl.ts +752 -0
  179. package/src/locales/pt.ts +26 -4
  180. package/src/preview/PropertyPreview.tsx +12 -0
  181. package/src/preview/components/DatePreview.tsx +2 -4
  182. package/src/preview/components/ReferencePreview.tsx +7 -3
  183. package/src/preview/components/StorageThumbnail.tsx +24 -2
  184. package/src/preview/index.ts +1 -0
  185. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  186. package/src/routes/FireCMSRoute.tsx +54 -27
  187. package/src/types/analytics.ts +3 -0
  188. package/src/types/collections.ts +34 -0
  189. package/src/types/datasource.ts +67 -4
  190. package/src/types/entities.ts +24 -1
  191. package/src/types/entity_actions.tsx +6 -0
  192. package/src/types/entity_callbacks.ts +24 -0
  193. package/src/types/firecms.tsx +19 -1
  194. package/src/types/firecms_context.tsx +9 -0
  195. package/src/types/index.ts +1 -0
  196. package/src/types/license.ts +63 -0
  197. package/src/types/navigation.ts +35 -5
  198. package/src/types/plugins.tsx +23 -1
  199. package/src/types/properties.ts +17 -0
  200. package/src/types/side_entity_controller.tsx +7 -0
  201. package/src/types/translations.ts +43 -2
  202. package/src/util/__tests__/collections.test.ts +32 -0
  203. package/src/util/__tests__/urls.test.ts +131 -0
  204. package/src/util/collections.ts +1 -1
  205. package/src/util/date_fns_locales.ts +168 -0
  206. package/src/util/entities.ts +16 -1
  207. package/src/util/entity_cache.ts +18 -0
  208. package/src/util/geopoint.ts +81 -0
  209. package/src/util/index.ts +3 -0
  210. package/src/util/lazy_eager.tsx +30 -8
  211. package/src/util/navigation_blocking.ts +45 -0
  212. package/src/util/navigation_from_path.ts +47 -7
  213. package/src/util/navigation_utils.ts +250 -5
  214. package/src/util/parent_references_from_path.ts +45 -4
  215. package/src/util/paths.ts +40 -3
  216. package/src/util/permissions.ts +22 -10
  217. package/src/util/previews.ts +8 -1
  218. package/src/util/resolutions.ts +8 -0
  219. package/src/util/urls.ts +52 -0
  220. package/src/util/useStorageUploadController.tsx +6 -1
@@ -4,18 +4,19 @@ import { useLocation } from "react-router-dom";
4
4
  import { useDataSource, useFireCMSContext, useNavigationController } from "../../hooks";
5
5
  import { useDataOrder } from "../../hooks/data/useDataOrder";
6
6
  import {
7
+ DataType,
7
8
  Entity,
8
9
  EntityCollection,
9
- EntityReference,
10
10
  EntityTableController,
11
11
  FilterValues,
12
12
  FireCMSContext,
13
13
  SelectedCellProps,
14
- User,
15
- WhereFilterOp
14
+ User
16
15
  } from "../../types";
17
16
  import { useDebouncedData } from "./useDebouncedData";
18
17
  import { ScrollRestorationController } from "./useScrollRestoration";
18
+ import { isDataTypeFilterable } from "../../util";
19
+ import { encodeFilterAndSort, parseFilterAndSort } from "./table_url_params";
19
20
 
20
21
  const DEFAULT_PAGE_SIZE = 50;
21
22
 
@@ -24,6 +25,14 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
24
25
  * Full path where the data of this table is located
25
26
  */
26
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[];
27
36
  /**
28
37
  * The collection that is represented by this config.
29
38
  */
@@ -68,6 +77,7 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
68
77
  export function useDataSourceTableController<M extends Record<string, any> = any, USER extends User = User>(
69
78
  {
70
79
  fullPath,
80
+ pathSegments,
71
81
  collection,
72
82
  scrollRestoration,
73
83
  entitiesDisplayedFirst,
@@ -86,20 +96,24 @@ export function useDataSourceTableController<M extends Record<string, any> = any
86
96
  const [popupCell, setPopupCell] = React.useState<SelectedCellProps<M> | undefined>(undefined);
87
97
  const navigation = useNavigationController();
88
98
  const dataSource = useDataSource(collection);
89
- 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]);
90
105
 
91
106
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
92
107
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
93
108
  const pageSize = typeof collection.pagination === "number" ? collection.pagination : DEFAULT_PAGE_SIZE;
94
109
 
95
- const [searchString, setSearchString] = React.useState<string | undefined>();
96
-
97
110
  const checkFilterCombination = useCallback((filterValues: FilterValues<any>,
98
111
  sortBy?: [string, "asc" | "desc"]) => {
99
112
  if (!dataSource.isFilterCombinationValid)
100
113
  return true;
101
114
  return dataSource.isFilterCombinationValid({
102
115
  path: resolvedPath,
116
+ pathSegments: resolvedPathSegments,
103
117
  collection,
104
118
  filterValues,
105
119
  sortBy
@@ -134,18 +148,69 @@ export function useDataSourceTableController<M extends Record<string, any> = any
134
148
  const {
135
149
  filterValues: initialFilterUrl,
136
150
  sortBy: initialSortUrl,
151
+ searchString: initialSearchUrl
137
152
  } = parseFilterAndSort(location.search);
138
153
 
139
- const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined);
154
+ const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
155
+ const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
156
+
157
+ const allowedFilterKeys = useMemo(() => {
158
+ const availableKeys = availableFilterKeys.filter((key) => {
159
+ const property = collection.properties[key];
160
+
161
+ if (!property) return false;
162
+
163
+ if (typeof property === "function") return false;
164
+
165
+ const dataType = property.dataType;
166
+ const filterable = dataType === "array"
167
+ ? isDataTypeFilterable((property as { of?: { dataType: DataType } }).of?.dataType as DataType, true)
168
+ : isDataTypeFilterable(dataType);
169
+
170
+ return filterable;
171
+ });
172
+
173
+ const forcedKeys = forcedFilterKeys.filter((key) => !availableKeys.includes(key));
174
+
175
+ return [...availableKeys, ...forcedKeys];
176
+
177
+ }, [collection.properties, availableFilterKeys, forcedFilterKeys]);
178
+
179
+ const removeUnallowedFilters = useCallback((filters?: FilterValues<Extract<keyof M, string>>) => {
180
+ if (!filters) return;
181
+
182
+ return Object.fromEntries(Object.entries(filters).filter(([key]) => allowedFilterKeys.includes(key as keyof M))) as FilterValues<Extract<keyof M, string>>;
183
+ }, [allowedFilterKeys]);
184
+
185
+ const initFilters = forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined
186
+
187
+ const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
140
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);
141
194
 
142
195
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
143
196
 
144
- const collectionScroll = scrollRestoration?.getCollectionScroll(fullPath, filterValues);
145
- 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;
146
206
 
147
207
  useEffect(() => {
148
- 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) {
149
214
  scrollRestoration.updateCollectionScroll({
150
215
  fullPath: resolvedPath,
151
216
  scrollOffset: collectionScroll?.scrollOffset ?? 0,
@@ -168,7 +233,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
168
233
  const [dataLoadingError, setDataLoadingError] = useState<Error | undefined>();
169
234
  const [noMoreToLoad, setNoMoreToLoad] = useState<boolean>(false);
170
235
 
171
- const clearFilter = useCallback(() => setFilterValues(forceFilter ?? undefined), [forceFilter]);
236
+ const clearFilter = useCallback(() => setFilterValues(removeUnallowedFilters(forceFilter)), [forceFilter, removeUnallowedFilters]);
172
237
 
173
238
  const updateFilterValues = useCallback((updatedFilter: FilterValues<Extract<keyof M, string>> | undefined) => {
174
239
  if (forceFilter) {
@@ -178,15 +243,20 @@ export function useDataSourceTableController<M extends Record<string, any> = any
178
243
  if (updatedFilter && Object.keys(updatedFilter).length === 0) {
179
244
  setFilterValues(undefined);
180
245
  } else {
181
- setFilterValues(updatedFilter);
246
+ setFilterValues(removeUnallowedFilters(updatedFilter));
182
247
  }
183
- }, [forceFilter]);
248
+ }, [forceFilter, removeUnallowedFilters]);
184
249
 
185
250
  useEffect(() => {
186
251
 
187
252
  setDataLoading(true);
188
253
 
189
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
+ }
190
260
  if (collection.callbacks?.onFetch) {
191
261
  try {
192
262
  entities = await Promise.all(
@@ -194,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
194
264
  collection.callbacks!.onFetch!({
195
265
  collection,
196
266
  path: resolvedPath,
267
+ pathSegments: resolvedPathSegments,
197
268
  entity,
198
269
  context
199
270
  })));
@@ -217,32 +288,45 @@ export function useDataSourceTableController<M extends Record<string, any> = any
217
288
  setDataLoadingError(error);
218
289
  };
219
290
 
220
- if (dataSource.listenCollection) {
221
- return dataSource.listenCollection<M>({
222
- path: resolvedPath,
223
- collection,
224
- onUpdate: onEntitiesUpdate,
225
- onError,
226
- searchString,
227
- filter: filterValues,
228
- limit: itemCount,
229
- startAfter: undefined,
230
- orderBy: sortByProperty,
231
- order: currentSort
232
- });
233
- } else {
234
- dataSource.fetchCollection<M>({
235
- path: resolvedPath,
236
- collection,
237
- searchString,
238
- filter: filterValues,
239
- limit: itemCount,
240
- startAfter: undefined,
241
- orderBy: sortByProperty,
242
- order: currentSort
243
- })
244
- .then(onEntitiesUpdate)
245
- .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)));
246
330
  return () => {
247
331
  };
248
332
  }
@@ -268,6 +352,8 @@ export function useDataSourceTableController<M extends Record<string, any> = any
268
352
  dataLoadingError,
269
353
  filterValues,
270
354
  setFilterValues: updateFilterValues,
355
+ allowedFilters: allowedFilterKeys,
356
+ forcedFilters: forcedFilterKeys,
271
357
  sortBy,
272
358
  setSortBy,
273
359
  searchString,
@@ -294,9 +380,7 @@ function useUpdateUrl<M extends Record<string, any> = any>(
294
380
 
295
381
  useEffect(() => {
296
382
  if (updateUrl) {
297
- const newUrl = encodeFilterAndSort(filterValues, sortBy);
298
- const search = searchString ? `&search=${encodeURIComponent(searchString)}` : "";
299
- const state = `${newUrl}${search}`;
383
+ const state = encodeFilterAndSort(filterValues, sortBy, searchString);
300
384
  const hash = window.location.hash;
301
385
  if (state === "")
302
386
  window.history.replaceState({}, "", `${window.location.pathname}${hash}`);
@@ -305,124 +389,3 @@ function useUpdateUrl<M extends Record<string, any> = any>(
305
389
  }
306
390
  }, [filterValues, sortBy, searchString, updateUrl]);
307
391
  }
308
-
309
- function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: [string, "asc" | "desc"] | undefined) {
310
- const entries: Record<string, string> = {};
311
- if (sortBy) {
312
- entries["__sort"] = encodeURIComponent(sortBy[0]);
313
- entries["__sort_order"] = encodeURIComponent(sortBy[1]);
314
- }
315
- if (filterValues) {
316
- Object.entries(filterValues).forEach(([key, value]) => {
317
- if (value) {
318
- const [op, val] = value;
319
- let encodedValue: any = val;
320
- try {
321
- if (typeof val === "object") {
322
- if (val instanceof Date) {
323
- encodedValue = val.toISOString();
324
- } else if (Array.isArray(val)) {
325
- encodedValue = JSON.stringify(val, (key, value) => {
326
- if (value instanceof EntityReference) {
327
- return encodeRef(value);
328
- }
329
- return value;
330
- });
331
- } else if (val instanceof EntityReference) {
332
- encodedValue = encodeRef(val);
333
- }
334
- } else if (typeof val === "string") {
335
- // JSON.stringify wraps the string in quotes (e.g. "4" → '"4"')
336
- // so that decodeString's JSON.parse restores the string type,
337
- // not a number. Without this, "4" round-trips as the number 4.
338
- encodedValue = JSON.stringify(val);
339
- }
340
- } catch (e) {
341
- encodedValue = val;
342
- }
343
- if (encodedValue !== undefined) {
344
- entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op);
345
- entries[encodeURIComponent(`${key}_value`)] = encodedValue ? encodeURIComponent(encodedValue.toString()) : "null";
346
- }
347
- }
348
- });
349
- }
350
- if (!Object.keys(entries).length) {
351
- return "";
352
- }
353
- return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("&");
354
- }
355
-
356
- function parseFilterAndSort<M>(search: string): {
357
- filterValues: FilterValues<string> | undefined,
358
- sortBy?: [Extract<keyof M, string>, "asc" | "desc"]
359
- } {
360
- const entries = new URLSearchParams(search);
361
- const filterValues: FilterValues<string> = {};
362
- let sortBy: [string, "asc" | "desc"] | undefined = undefined;
363
- entries.forEach((value, key) => {
364
- if (key === "__sort") {
365
- sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"];
366
- } else if (key.endsWith("_op")) {
367
- const field = key.replace("_op", "");
368
- const filterOp = decodeURIComponent(value) as WhereFilterOp;
369
- const filterValStr = entries.get(`${field}_value`);
370
- if (filterValStr !== null) {
371
- filterValues[field] = [filterOp, decodeString(filterValStr)];
372
- }
373
- }
374
- });
375
-
376
- return {
377
- filterValues: Object.keys(filterValues).length ? filterValues : undefined,
378
- sortBy
379
- }
380
- }
381
-
382
- function isDate(dateString: string): boolean {
383
- // Define a regex pattern that matches the exact date format: 2025-01-07T23:00:00.000Z
384
- const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
385
-
386
- // Test the dateString against the regex pattern
387
- if (!regexPattern.test(dateString)) {
388
- return false;
389
- }
390
-
391
- // If the regex matches, further validate if it is a valid UTC date
392
- const date = new Date(dateString);
393
- return date.toISOString() === dateString;
394
- }
395
-
396
- function encodeRef(val: EntityReference) {
397
- return `ref::${val.path}/${val.id}`;
398
- }
399
-
400
- function decodeString(val: string): EntityReference | Date | string {
401
- let parsedFilterVal: any = val;
402
- if (isDate(val)) {
403
- try {
404
- parsedFilterVal = new Date(val);
405
- } catch (e) {
406
- // ignore
407
- }
408
- }
409
- if (typeof parsedFilterVal === "string") {
410
- try {
411
- parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => {
412
- if (typeof value === "string" && value.startsWith("ref::")) {
413
- const [path, id] = value.substring(5).split("/");
414
- return new EntityReference(id, path);
415
- }
416
- return value;
417
- });
418
- } catch (e) {
419
- // ignore
420
- }
421
- }
422
-
423
- if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) {
424
- const [path, id] = parsedFilterVal.substring(5).split("/");
425
- return new EntityReference(id, path);
426
- }
427
- return parsedFilterVal;
428
- }
@@ -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,
@@ -43,3 +43,4 @@ export * from "./EntityPreview";
43
43
 
44
44
  export * from "./AIIcon";
45
45
  export * from "./LanguageToggle";
46
+ export * from "./LicenseBanner";
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { AccessResponse } from "../types";
3
+
4
+ /**
5
+ * License status of the enclosing `FireCMS`. `undefined` means there is no
6
+ * `FireCMS` above; `null` means the check has not answered (or was skipped).
7
+ * @internal
8
+ */
9
+ export const LicenseStatusContext = React.createContext<AccessResponse | null | undefined>(undefined);
10
+
11
+ /*
12
+ * The same status, published outside React's tree.
13
+ *
14
+ * `useBuildNavigationController` runs in the app component that renders
15
+ * `<FireCMS>`, above it, so it cannot read the context. It still has to know
16
+ * when PRO is paused: it applies the plugins' collection, view and home page
17
+ * navigation contributions itself. `FireCMS` publishes here, and the navigation
18
+ * controller subscribes with `useSyncExternalStore`.
19
+ *
20
+ * One value per page: two `FireCMS` instances on one page would share it, which
21
+ * is harmless because both check the same project.
22
+ */
23
+
24
+ let currentStatus: AccessResponse | null = null;
25
+ const listeners = new Set<() => void>();
26
+
27
+ /** @internal */
28
+ export function getLicenseStatusSnapshot(): AccessResponse | null {
29
+ return currentStatus;
30
+ }
31
+
32
+ /** @internal */
33
+ export function getServerLicenseStatusSnapshot(): AccessResponse | null {
34
+ return null;
35
+ }
36
+
37
+ /** @internal */
38
+ export function subscribeLicenseStatus(listener: () => void): () => void {
39
+ listeners.add(listener);
40
+ return () => {
41
+ listeners.delete(listener);
42
+ };
43
+ }
44
+
45
+ /** @internal */
46
+ export function publishLicenseStatus(status: AccessResponse | null): void {
47
+ if (status === currentStatus) return;
48
+ currentStatus = status;
49
+ listeners.forEach(listener => listener());
50
+ }
@@ -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>
@@ -1,7 +1,7 @@
1
1
  import React from "react";
2
2
 
3
3
  import { Link, useNavigate } from "react-router-dom";
4
- import { ErrorBoundary, FireCMSLogo, LanguageToggle } from "../components";
4
+ import { ErrorBoundary, LanguageToggle } from "../components";
5
5
  import {
6
6
  Avatar,
7
7
  BrightnessMediumIcon,
@@ -20,6 +20,7 @@ import { useAuthController, useLargeLayout, useModeController, useNavigationCont
20
20
  import { User } from "../types";
21
21
  import { useApp } from "../app/useApp";
22
22
  import { useBreadcrumbsController } from "../hooks/useBreadcrumbsController";
23
+ import { DrawerLogo } from "./DefaultDrawer";
23
24
 
24
25
  export type DefaultAppBarProps<ADDITIONAL_PROPS = object> = {
25
26
 
@@ -119,32 +120,43 @@ export const DefaultAppBar = function DefaultAppBar({
119
120
  "flex flex-row gap-2 px-4 items-center",
120
121
  {
121
122
  "pl-[19rem]": drawerOpen && largeLayout,
122
- "pl-24": hasDrawer && !(drawerOpen && largeLayout),
123
+ // The title has to sit at the same x whether or not a Drawer
124
+ // is mounted, so the bar reserves the collapsed rail's width
125
+ // in both cases. Only a pinned-open drawer moves it, in step
126
+ // with the drawer itself.
127
+ "pl-24": !(drawerOpen && largeLayout) && (hasDrawer || largeLayout),
123
128
  "z-10": largeLayout,
124
129
  "duration-100": drawerOpen && largeLayout,
125
130
  },
126
131
  className)}>
127
132
 
128
133
 
134
+ {/* Rendering DrawerLogo itself, rather than rebuilding its box here,
135
+ is what keeps the mark from moving when a screen with a drawer
136
+ follows one without: matching it by hand still left it 4px high,
137
+ because DrawerLogo centres a 40px box under `pt-4` instead of
138
+ centring in the 64px bar.
139
+
140
+ Gated on `largeLayout`, not on `lg:`: useLargeLayout resolves at
141
+ min-width 1025px while Tailwind's `lg:` starts at 1024px, and at
142
+ exactly 1024px the logo would render into a bar that had not
143
+ reserved room for it, landing on top of the title. */}
144
+ {navigation && !hasDrawer && largeLayout &&
145
+ <div className={"absolute left-0 top-0"}>
146
+ <DrawerLogo logo={logo} />
147
+ </div>}
148
+
129
149
  {navigation && <div className="mr-2 hidden lg:block">
130
150
  <Link
131
151
  className="visited:text-inherit visited:dark:text-inherit block"
132
152
  to={navigation?.basePath ?? "/"}
133
153
  >
134
- <div className={"flex flex-row gap-4"}>
135
- {!hasDrawer && (logo
136
- ? <img src={logo}
137
- alt="Logo"
138
- className={cls("w-[32px] h-[32px] object-contain")} />
139
- : <FireCMSLogo width={"32px"} height={"32px"} />)}
140
-
141
- {typeof title === "string"
142
- ? <Typography variant="subtitle1"
143
- noWrap>
144
- {title}
145
- </Typography>
146
- : title}
147
- </div>
154
+ {typeof title === "string"
155
+ ? <Typography variant="subtitle1"
156
+ noWrap>
157
+ {title}
158
+ </Typography>
159
+ : title}
148
160
  </Link>
149
161
  </div>}
150
162