@firecms/core 3.3.0 → 3.4.0-canary.2575a08
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.
- package/dist/app/useApp.d.ts +1 -0
- package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
- package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
- package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +8 -0
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/EntityPreview.d.ts +3 -1
- package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
- package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
- package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
- package/dist/components/common/table_url_params.d.ts +24 -0
- package/dist/components/common/useDataSourceTableController.d.ts +9 -1
- package/dist/components/common/useTableSearchHelper.d.ts +9 -1
- package/dist/core/DefaultDrawer.d.ts +10 -3
- package/dist/core/EntityEditView.d.ts +9 -1
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +8 -1
- package/dist/form/EntityFormActions.d.ts +2 -0
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
- package/dist/form/index.d.ts +1 -0
- package/dist/hooks/data/delete.d.ts +1 -1
- package/dist/hooks/data/save.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
- package/dist/hooks/data/useEntityFetch.d.ts +9 -1
- package/dist/i18n/__tests__/FireCMSi18nProvider.test.d.ts +1 -0
- package/dist/i18n/__tests__/nested_provider_lifecycle.test.d.ts +1 -0
- package/dist/index.es.js +3658 -1820
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +3655 -1817
- package/dist/index.umd.js.map +1 -1
- package/dist/locales/pl.d.ts +2 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
- package/dist/types/collections.d.ts +31 -0
- package/dist/types/datasource.d.ts +67 -4
- package/dist/types/entities.d.ts +21 -1
- package/dist/types/entity_actions.d.ts +6 -0
- package/dist/types/entity_callbacks.d.ts +21 -0
- package/dist/types/navigation.d.ts +34 -5
- package/dist/types/plugins.d.ts +13 -0
- package/dist/types/properties.d.ts +17 -1
- package/dist/types/side_entity_controller.d.ts +7 -0
- package/dist/types/translations.d.ts +6 -0
- package/dist/util/__tests__/collections.test.d.ts +1 -0
- package/dist/util/__tests__/urls.test.d.ts +1 -0
- package/dist/util/collections.d.ts +1 -1
- package/dist/util/entities.d.ts +1 -0
- package/dist/util/entity_cache.d.ts +11 -0
- package/dist/util/geopoint.d.ts +22 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/navigation_blocking.d.ts +22 -0
- package/dist/util/navigation_from_path.d.ts +17 -0
- package/dist/util/navigation_utils.d.ts +107 -3
- package/dist/util/parent_references_from_path.d.ts +16 -0
- package/dist/util/paths.d.ts +28 -0
- package/dist/util/permissions.d.ts +10 -4
- package/dist/util/urls.d.ts +22 -0
- package/package.json +17 -10
- package/src/app/Scaffold.tsx +34 -44
- package/src/app/useApp.tsx +1 -0
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
- package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
- package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
- package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +12 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +8 -1
- package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
- package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
- package/src/components/EntityPreview.tsx +45 -41
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -2
- package/src/components/ReferenceWidget.tsx +1 -1
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
- package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
- package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
- package/src/components/VirtualTable/VirtualTable.tsx +19 -8
- package/src/components/common/default_entity_actions.tsx +22 -2
- package/src/components/common/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +126 -163
- package/src/components/common/useTableSearchHelper.ts +26 -2
- package/src/contexts/SnackbarProvider.tsx +14 -1
- package/src/core/DefaultDrawer.tsx +150 -64
- package/src/core/DrawerNavigationGroup.tsx +27 -29
- package/src/core/DrawerNavigationItem.tsx +10 -12
- package/src/core/EntityEditView.tsx +77 -35
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +8 -4
- package/src/core/field_configs.tsx +15 -0
- package/src/form/EntityForm.tsx +31 -10
- package/src/form/EntityFormActions.tsx +2 -0
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
- package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
- package/src/form/index.tsx +1 -0
- package/src/hooks/data/delete.ts +8 -0
- package/src/hooks/data/save.ts +6 -1
- package/src/hooks/data/useCollectionFetch.tsx +21 -1
- package/src/hooks/data/useEntityFetch.tsx +35 -4
- package/src/hooks/useBuildNavigationController.tsx +23 -8
- package/src/hooks/useResolvedNavigationFrom.tsx +3 -1
- package/src/hooks/useValidateAuthenticator.tsx +4 -0
- package/src/i18n/FireCMSi18nProvider.tsx +50 -4
- package/src/i18n/__tests__/FireCMSi18nProvider.test.tsx +91 -0
- package/src/i18n/__tests__/nested_provider_lifecycle.test.tsx +67 -0
- package/src/internal/useBuildDataSource.ts +37 -15
- package/src/internal/useBuildSideEntityController.tsx +20 -5
- package/src/locales/de.ts +7 -2
- package/src/locales/en.ts +7 -2
- package/src/locales/es.ts +7 -2
- package/src/locales/fr.ts +7 -2
- package/src/locales/hi.ts +7 -2
- package/src/locales/it.ts +7 -2
- package/src/locales/pl.ts +735 -0
- package/src/locales/pt.ts +7 -2
- package/src/preview/PropertyPreview.tsx +12 -0
- package/src/preview/components/ReferencePreview.tsx +7 -3
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
- package/src/routes/FireCMSRoute.tsx +54 -27
- package/src/types/collections.ts +34 -0
- package/src/types/datasource.ts +67 -4
- package/src/types/entities.ts +24 -1
- package/src/types/entity_actions.tsx +6 -0
- package/src/types/entity_callbacks.ts +24 -0
- package/src/types/navigation.ts +35 -5
- package/src/types/plugins.tsx +13 -0
- package/src/types/properties.ts +17 -0
- package/src/types/side_entity_controller.tsx +7 -0
- package/src/types/translations.ts +6 -0
- package/src/util/__tests__/collections.test.ts +32 -0
- package/src/util/__tests__/urls.test.ts +131 -0
- package/src/util/collections.ts +1 -1
- package/src/util/entities.ts +16 -1
- package/src/util/entity_cache.ts +18 -0
- package/src/util/geopoint.ts +81 -0
- package/src/util/index.ts +2 -0
- package/src/util/navigation_blocking.ts +45 -0
- package/src/util/navigation_from_path.ts +47 -7
- package/src/util/navigation_utils.ts +244 -4
- package/src/util/parent_references_from_path.ts +45 -4
- package/src/util/paths.ts +40 -3
- package/src/util/permissions.ts +22 -10
- package/src/util/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- 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,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
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -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>
|