@firecms/core 3.3.0 → 3.4.0-canary.63041d0

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 (108) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  3. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +7 -0
  5. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  6. package/dist/components/EntityPreview.d.ts +3 -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/DefaultDrawer.d.ts +10 -3
  13. package/dist/core/EntityEditView.d.ts +7 -1
  14. package/dist/core/field_configs.d.ts +1 -1
  15. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  16. package/dist/form/index.d.ts +1 -0
  17. package/dist/hooks/data/save.d.ts +1 -1
  18. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  19. package/dist/index.es.js +2790 -1270
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +2788 -1268
  22. package/dist/index.umd.js.map +1 -1
  23. package/dist/locales/pl.d.ts +2 -0
  24. package/dist/preview/index.d.ts +1 -0
  25. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  26. package/dist/types/collections.d.ts +24 -0
  27. package/dist/types/datasource.d.ts +42 -0
  28. package/dist/types/properties.d.ts +17 -1
  29. package/dist/types/side_entity_controller.d.ts +6 -0
  30. package/dist/types/translations.d.ts +6 -0
  31. package/dist/util/__tests__/collections.test.d.ts +1 -0
  32. package/dist/util/__tests__/urls.test.d.ts +1 -0
  33. package/dist/util/collections.d.ts +1 -1
  34. package/dist/util/entities.d.ts +1 -0
  35. package/dist/util/geopoint.d.ts +22 -0
  36. package/dist/util/index.d.ts +2 -0
  37. package/dist/util/navigation_blocking.d.ts +22 -0
  38. package/dist/util/navigation_from_path.d.ts +17 -0
  39. package/dist/util/navigation_utils.d.ts +23 -1
  40. package/dist/util/urls.d.ts +22 -0
  41. package/package.json +16 -9
  42. package/src/app/Scaffold.tsx +34 -44
  43. package/src/app/useApp.tsx +1 -0
  44. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  45. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  46. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  47. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  48. package/src/components/EntityCollectionView/EntityCollectionView.tsx +19 -1
  49. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  50. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  51. package/src/components/EntityPreview.tsx +41 -40
  52. package/src/components/ReferenceWidget.tsx +1 -1
  53. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  54. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  55. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  56. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  57. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  58. package/src/components/common/table_url_params.ts +172 -0
  59. package/src/components/common/useDataSourceTableController.tsx +98 -159
  60. package/src/components/common/useTableSearchHelper.ts +21 -2
  61. package/src/contexts/SnackbarProvider.tsx +14 -1
  62. package/src/core/DefaultDrawer.tsx +150 -64
  63. package/src/core/DrawerNavigationGroup.tsx +27 -29
  64. package/src/core/DrawerNavigationItem.tsx +10 -12
  65. package/src/core/EntityEditView.tsx +75 -32
  66. package/src/core/EntitySidePanel.tsx +2 -0
  67. package/src/core/field_configs.tsx +15 -0
  68. package/src/form/EntityForm.tsx +1 -1
  69. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  70. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  71. package/src/form/index.tsx +1 -0
  72. package/src/hooks/data/save.ts +2 -0
  73. package/src/hooks/data/useEntityFetch.tsx +9 -0
  74. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  75. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  76. package/src/internal/useBuildDataSource.ts +11 -0
  77. package/src/internal/useBuildSideEntityController.tsx +8 -1
  78. package/src/locales/de.ts +7 -2
  79. package/src/locales/en.ts +7 -2
  80. package/src/locales/es.ts +7 -2
  81. package/src/locales/fr.ts +7 -2
  82. package/src/locales/hi.ts +7 -2
  83. package/src/locales/it.ts +7 -2
  84. package/src/locales/pl.ts +735 -0
  85. package/src/locales/pt.ts +7 -2
  86. package/src/preview/PropertyPreview.tsx +12 -0
  87. package/src/preview/components/StorageThumbnail.tsx +24 -2
  88. package/src/preview/index.ts +1 -0
  89. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  90. package/src/routes/FireCMSRoute.tsx +47 -25
  91. package/src/types/collections.ts +26 -0
  92. package/src/types/datasource.ts +42 -0
  93. package/src/types/properties.ts +17 -0
  94. package/src/types/side_entity_controller.tsx +6 -0
  95. package/src/types/translations.ts +6 -0
  96. package/src/util/__tests__/collections.test.ts +32 -0
  97. package/src/util/__tests__/urls.test.ts +131 -0
  98. package/src/util/collections.ts +1 -1
  99. package/src/util/entities.ts +13 -0
  100. package/src/util/geopoint.ts +81 -0
  101. package/src/util/index.ts +2 -0
  102. package/src/util/navigation_blocking.ts +45 -0
  103. package/src/util/navigation_from_path.ts +41 -6
  104. package/src/util/navigation_utils.ts +40 -2
  105. package/src/util/parent_references_from_path.ts +4 -2
  106. package/src/util/previews.ts +8 -1
  107. package/src/util/resolutions.ts +8 -0
  108. package/src/util/urls.ts +52 -0
@@ -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,12 @@ 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. Defaults to splitting
31
+ * `fullPath`, which is correct for any backend whose ids cannot contain "/".
32
+ */
33
+ pathSegments?: string[];
27
34
  /**
28
35
  * The collection that is represented by this config.
29
36
  */
@@ -68,6 +75,7 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
68
75
  export function useDataSourceTableController<M extends Record<string, any> = any, USER extends User = User>(
69
76
  {
70
77
  fullPath,
78
+ pathSegments,
71
79
  collection,
72
80
  scrollRestoration,
73
81
  entitiesDisplayedFirst,
@@ -87,13 +95,13 @@ export function useDataSourceTableController<M extends Record<string, any> = any
87
95
  const navigation = useNavigationController();
88
96
  const dataSource = useDataSource(collection);
89
97
  const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
98
+ const resolvedPathSegments = useMemo(() => pathSegments ?? resolvedPath.split("/"),
99
+ [pathSegments, resolvedPath]);
90
100
 
91
101
  const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
92
102
  const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
93
103
  const pageSize = typeof collection.pagination === "number" ? collection.pagination : DEFAULT_PAGE_SIZE;
94
104
 
95
- const [searchString, setSearchString] = React.useState<string | undefined>();
96
-
97
105
  const checkFilterCombination = useCallback((filterValues: FilterValues<any>,
98
106
  sortBy?: [string, "asc" | "desc"]) => {
99
107
  if (!dataSource.isFilterCombinationValid)
@@ -134,10 +142,49 @@ export function useDataSourceTableController<M extends Record<string, any> = any
134
142
  const {
135
143
  filterValues: initialFilterUrl,
136
144
  sortBy: initialSortUrl,
145
+ searchString: initialSearchUrl
137
146
  } = parseFilterAndSort(location.search);
138
147
 
139
- const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined);
148
+ const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
149
+ const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
150
+
151
+ const allowedFilterKeys = useMemo(() => {
152
+ const availableKeys = availableFilterKeys.filter((key) => {
153
+ const property = collection.properties[key];
154
+
155
+ if (!property) return false;
156
+
157
+ if (typeof property === "function") return false;
158
+
159
+ const dataType = property.dataType;
160
+ const filterable = dataType === "array"
161
+ ? isDataTypeFilterable((property as { of?: { dataType: DataType } }).of?.dataType as DataType, true)
162
+ : isDataTypeFilterable(dataType);
163
+
164
+ return filterable;
165
+ });
166
+
167
+ const forcedKeys = forcedFilterKeys.filter((key) => !availableKeys.includes(key));
168
+
169
+ return [...availableKeys, ...forcedKeys];
170
+
171
+ }, [collection.properties, availableFilterKeys, forcedFilterKeys]);
172
+
173
+ const removeUnallowedFilters = useCallback((filters?: FilterValues<Extract<keyof M, string>>) => {
174
+ if (!filters) return;
175
+
176
+ return Object.fromEntries(Object.entries(filters).filter(([key]) => allowedFilterKeys.includes(key as keyof M))) as FilterValues<Extract<keyof M, string>>;
177
+ }, [allowedFilterKeys]);
178
+
179
+ const initFilters = forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined
180
+
181
+ const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
140
182
  const [sortBy, setSortBy] = React.useState<[Extract<keyof M, string>, "asc" | "desc"] | undefined>((updateUrl ? initialSortUrl : undefined) ?? initialSortInternal);
183
+ // Like the filters and the sort, the text search term is only restored from the URL when
184
+ // this controller owns the URL. Controllers rendered inside a dialog (e.g. a reference
185
+ // selection dialog) get `updateUrl: false` and must not inherit the state of the
186
+ // collection behind them. See https://github.com/firecmsco/firecms/issues/702
187
+ const [searchString, setSearchString] = React.useState<string | undefined>(updateUrl ? initialSearchUrl : undefined);
141
188
 
142
189
  useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
143
190
 
@@ -168,7 +215,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
168
215
  const [dataLoadingError, setDataLoadingError] = useState<Error | undefined>();
169
216
  const [noMoreToLoad, setNoMoreToLoad] = useState<boolean>(false);
170
217
 
171
- const clearFilter = useCallback(() => setFilterValues(forceFilter ?? undefined), [forceFilter]);
218
+ const clearFilter = useCallback(() => setFilterValues(removeUnallowedFilters(forceFilter)), [forceFilter, removeUnallowedFilters]);
172
219
 
173
220
  const updateFilterValues = useCallback((updatedFilter: FilterValues<Extract<keyof M, string>> | undefined) => {
174
221
  if (forceFilter) {
@@ -178,9 +225,9 @@ export function useDataSourceTableController<M extends Record<string, any> = any
178
225
  if (updatedFilter && Object.keys(updatedFilter).length === 0) {
179
226
  setFilterValues(undefined);
180
227
  } else {
181
- setFilterValues(updatedFilter);
228
+ setFilterValues(removeUnallowedFilters(updatedFilter));
182
229
  }
183
- }, [forceFilter]);
230
+ }, [forceFilter, removeUnallowedFilters]);
184
231
 
185
232
  useEffect(() => {
186
233
 
@@ -217,32 +264,45 @@ export function useDataSourceTableController<M extends Record<string, any> = any
217
264
  setDataLoadingError(error);
218
265
  };
219
266
 
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);
267
+ // Data sources may throw synchronously, e.g. when a text search is requested before
268
+ // the text search backend of the collection has been initialised. That can happen
269
+ // when the search term is restored from the URL instead of typed in the search bar,
270
+ // so it is reported as a data loading error rather than being left to blow up the
271
+ // whole view from inside this effect.
272
+ try {
273
+ if (dataSource.listenCollection) {
274
+ return dataSource.listenCollection<M>({
275
+ path: resolvedPath,
276
+ pathSegments: resolvedPathSegments,
277
+ collection,
278
+ onUpdate: onEntitiesUpdate,
279
+ onError,
280
+ searchString,
281
+ filter: filterValues,
282
+ limit: itemCount,
283
+ startAfter: undefined,
284
+ orderBy: sortByProperty,
285
+ order: currentSort
286
+ });
287
+ } else {
288
+ dataSource.fetchCollection<M>({
289
+ path: resolvedPath,
290
+ pathSegments: resolvedPathSegments,
291
+ collection,
292
+ searchString,
293
+ filter: filterValues,
294
+ limit: itemCount,
295
+ startAfter: undefined,
296
+ orderBy: sortByProperty,
297
+ order: currentSort
298
+ })
299
+ .then(onEntitiesUpdate)
300
+ .catch(onError);
301
+ return () => {
302
+ };
303
+ }
304
+ } catch (e: any) {
305
+ onError(e instanceof Error ? e : new Error(String(e)));
246
306
  return () => {
247
307
  };
248
308
  }
@@ -268,6 +328,8 @@ export function useDataSourceTableController<M extends Record<string, any> = any
268
328
  dataLoadingError,
269
329
  filterValues,
270
330
  setFilterValues: updateFilterValues,
331
+ allowedFilters: allowedFilterKeys,
332
+ forcedFilters: forcedFilterKeys,
271
333
  sortBy,
272
334
  setSortBy,
273
335
  searchString,
@@ -294,9 +356,7 @@ function useUpdateUrl<M extends Record<string, any> = any>(
294
356
 
295
357
  useEffect(() => {
296
358
  if (updateUrl) {
297
- const newUrl = encodeFilterAndSort(filterValues, sortBy);
298
- const search = searchString ? `&search=${encodeURIComponent(searchString)}` : "";
299
- const state = `${newUrl}${search}`;
359
+ const state = encodeFilterAndSort(filterValues, sortBy, searchString);
300
360
  const hash = window.location.hash;
301
361
  if (state === "")
302
362
  window.history.replaceState({}, "", `${window.location.pathname}${hash}`);
@@ -305,124 +365,3 @@ function useUpdateUrl<M extends Record<string, any> = any>(
305
365
  }
306
366
  }, [filterValues, sortBy, searchString, updateUrl]);
307
367
  }
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";
@@ -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>