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