@firecms/core 3.3.0 → 3.4.0-canary.0ef7442
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 +7 -0
- 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 +7 -1
- package/dist/components/common/useTableSearchHelper.d.ts +7 -1
- package/dist/core/DefaultDrawer.d.ts +10 -3
- package/dist/core/EntityEditView.d.ts +7 -1
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +6 -1
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
- package/dist/form/index.d.ts +1 -0
- 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 +2915 -1330
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2913 -1328
- 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 +24 -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/entities.d.ts +1 -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 +23 -1
- package/dist/util/parent_references_from_path.d.ts +1 -0
- 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/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 +4 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
- package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
- package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
- package/src/components/EntityPreview.tsx +41 -40
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
- 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/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +104 -159
- package/src/components/common/useTableSearchHelper.ts +21 -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 +78 -32
- package/src/core/EntitySidePanel.tsx +2 -0
- package/src/core/field_configs.tsx +15 -0
- package/src/form/EntityForm.tsx +9 -3
- 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 +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/i18n/FireCMSi18nProvider.tsx +2 -0
- package/src/internal/useBuildDataSource.ts +25 -4
- package/src/internal/useBuildSideEntityController.tsx +8 -1
- 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/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 +47 -25
- package/src/types/collections.ts +26 -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 +16 -1
- 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 +41 -6
- package/src/util/navigation_utils.ts +40 -2
- package/src/util/parent_references_from_path.ts +14 -4
- package/src/util/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- package/src/util/urls.ts +52 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { EntityReference, FilterValues, WhereFilterOp } from "../../types";
|
|
2
|
+
import { decodeEntityId, encodeEntityId } from "../../util/navigation_utils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Internal helpers used by `useDataSourceTableController` to persist the state of a
|
|
6
|
+
* collection table (filters, sort and text search) in the URL query string.
|
|
7
|
+
*
|
|
8
|
+
* These functions are intentionally NOT re-exported by the package barrel files:
|
|
9
|
+
* they are pure and live in their own module only so that they can be unit tested.
|
|
10
|
+
* `encodeFilterAndSort` and `parseFilterAndSort` must stay exact inverses of each
|
|
11
|
+
* other, otherwise state written to the URL is silently dropped on reload.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Serialise the state of a collection table into a URL query string,
|
|
16
|
+
* without the leading `?`. Returns an empty string when there is no state to persist.
|
|
17
|
+
*/
|
|
18
|
+
export function encodeFilterAndSort(filterValues?: FilterValues<string>,
|
|
19
|
+
sortBy?: [string, "asc" | "desc"] | undefined,
|
|
20
|
+
searchString?: string) {
|
|
21
|
+
const entries: Record<string, string> = {};
|
|
22
|
+
if (sortBy) {
|
|
23
|
+
entries["__sort"] = encodeURIComponent(sortBy[0]);
|
|
24
|
+
entries["__sort_order"] = encodeURIComponent(sortBy[1]);
|
|
25
|
+
}
|
|
26
|
+
if (filterValues) {
|
|
27
|
+
Object.entries(filterValues).forEach(([key, value]) => {
|
|
28
|
+
if (value) {
|
|
29
|
+
const [op, val] = value;
|
|
30
|
+
let encodedValue: any = val;
|
|
31
|
+
try {
|
|
32
|
+
if (typeof val === "object") {
|
|
33
|
+
if (val instanceof Date) {
|
|
34
|
+
encodedValue = val.toISOString();
|
|
35
|
+
} else if (Array.isArray(val)) {
|
|
36
|
+
encodedValue = JSON.stringify(val, (key, value) => {
|
|
37
|
+
if (value instanceof EntityReference) {
|
|
38
|
+
return encodeRef(value);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
});
|
|
42
|
+
} else if (val instanceof EntityReference) {
|
|
43
|
+
encodedValue = encodeRef(val);
|
|
44
|
+
}
|
|
45
|
+
} else if (typeof val === "string") {
|
|
46
|
+
// JSON.stringify wraps the string in quotes (e.g. "4" → '"4"')
|
|
47
|
+
// so that decodeString's JSON.parse restores the string type,
|
|
48
|
+
// not a number. Without this, "4" round-trips as the number 4.
|
|
49
|
+
encodedValue = JSON.stringify(val);
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
encodedValue = val;
|
|
53
|
+
}
|
|
54
|
+
if (encodedValue !== undefined) {
|
|
55
|
+
entries[encodeURIComponent(`${key}_op`)] = encodeURIComponent(op);
|
|
56
|
+
// Note: check for null/undefined explicitly instead of truthiness,
|
|
57
|
+
// otherwise falsy-but-valid values (boolean `false`, number `0`)
|
|
58
|
+
// would be serialized as "null" and round-trip back as `null`,
|
|
59
|
+
// breaking filters like `archived == false`.
|
|
60
|
+
entries[encodeURIComponent(`${key}_value`)] = encodedValue !== null && encodedValue !== undefined
|
|
61
|
+
? encodeURIComponent(encodedValue.toString())
|
|
62
|
+
: "null";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// The text search term is encoded once here; `parseFilterAndSort` reads it back with
|
|
68
|
+
// `URLSearchParams`, which decodes exactly once. Do NOT decode it a second time or
|
|
69
|
+
// terms containing a literal `%` (e.g. "50%") would throw a URIError.
|
|
70
|
+
if (searchString) {
|
|
71
|
+
entries["search"] = encodeURIComponent(searchString);
|
|
72
|
+
}
|
|
73
|
+
if (!Object.keys(entries).length) {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join("&");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Restore the state of a collection table from a URL query string.
|
|
81
|
+
* The inverse of {@link encodeFilterAndSort}.
|
|
82
|
+
*/
|
|
83
|
+
export function parseFilterAndSort<M>(search: string): {
|
|
84
|
+
filterValues: FilterValues<string> | undefined,
|
|
85
|
+
sortBy?: [Extract<keyof M, string>, "asc" | "desc"],
|
|
86
|
+
searchString?: string
|
|
87
|
+
} {
|
|
88
|
+
const entries = new URLSearchParams(search);
|
|
89
|
+
const filterValues: FilterValues<string> = {};
|
|
90
|
+
let sortBy: [string, "asc" | "desc"] | undefined = undefined;
|
|
91
|
+
entries.forEach((value, key) => {
|
|
92
|
+
if (key === "__sort") {
|
|
93
|
+
sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"];
|
|
94
|
+
} else if (key.endsWith("_op")) {
|
|
95
|
+
const field = key.replace("_op", "");
|
|
96
|
+
const filterOp = decodeURIComponent(value) as WhereFilterOp;
|
|
97
|
+
const filterValStr = entries.get(`${field}_value`);
|
|
98
|
+
if (filterValStr !== null) {
|
|
99
|
+
filterValues[field] = [filterOp, decodeString(filterValStr)];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// `URLSearchParams` has already percent-decoded the value, so it is used as is.
|
|
105
|
+
const searchString = entries.get("search") ?? undefined;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
filterValues: Object.keys(filterValues).length ? filterValues : undefined,
|
|
109
|
+
sortBy,
|
|
110
|
+
searchString: searchString ? searchString : undefined
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isDate(dateString: string): boolean {
|
|
115
|
+
// Define a regex pattern that matches the exact date format: 2025-01-07T23:00:00.000Z
|
|
116
|
+
const regexPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
117
|
+
|
|
118
|
+
// Test the dateString against the regex pattern
|
|
119
|
+
if (!regexPattern.test(dateString)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// If the regex matches, further validate if it is a valid UTC date
|
|
124
|
+
const date = new Date(dateString);
|
|
125
|
+
return date.toISOString() === dateString;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function encodeRef(val: EntityReference) {
|
|
129
|
+
return `ref::${val.path}/${encodeEntityId(val.id)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Split a "path/id" reference on its LAST separator: the path may itself be a
|
|
134
|
+
* subcollection path with several segments, and the id is escaped so it is always exactly
|
|
135
|
+
* one segment.
|
|
136
|
+
*/
|
|
137
|
+
function decodeRef(encoded: string): EntityReference {
|
|
138
|
+
const separatorIndex = encoded.lastIndexOf("/");
|
|
139
|
+
if (separatorIndex < 0) return new EntityReference(encoded, "");
|
|
140
|
+
return new EntityReference(
|
|
141
|
+
decodeEntityId(encoded.substring(separatorIndex + 1)),
|
|
142
|
+
encoded.substring(0, separatorIndex)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function decodeString(val: string): EntityReference | Date | string {
|
|
147
|
+
let parsedFilterVal: any = val;
|
|
148
|
+
if (isDate(val)) {
|
|
149
|
+
try {
|
|
150
|
+
parsedFilterVal = new Date(val);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (typeof parsedFilterVal === "string") {
|
|
156
|
+
try {
|
|
157
|
+
parsedFilterVal = JSON.parse(parsedFilterVal, (key, value) => {
|
|
158
|
+
if (typeof value === "string" && value.startsWith("ref::")) {
|
|
159
|
+
return decodeRef(value.substring(5));
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
});
|
|
163
|
+
} catch (e) {
|
|
164
|
+
// ignore
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (typeof parsedFilterVal === "string" && parsedFilterVal.startsWith("ref::")) {
|
|
169
|
+
return decodeRef(parsedFilterVal.substring(5));
|
|
170
|
+
}
|
|
171
|
+
return parsedFilterVal;
|
|
172
|
+
}
|
|
@@ -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,19 +95,20 @@ 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
|
+
// Passed through as-is; never derived from `resolvedPath` (see useEntityFetch).
|
|
99
|
+
const resolvedPathSegments = pathSegments;
|
|
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)
|
|
100
108
|
return true;
|
|
101
109
|
return dataSource.isFilterCombinationValid({
|
|
102
110
|
path: resolvedPath,
|
|
111
|
+
pathSegments: resolvedPathSegments,
|
|
103
112
|
collection,
|
|
104
113
|
filterValues,
|
|
105
114
|
sortBy
|
|
@@ -134,10 +143,49 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
134
143
|
const {
|
|
135
144
|
filterValues: initialFilterUrl,
|
|
136
145
|
sortBy: initialSortUrl,
|
|
146
|
+
searchString: initialSearchUrl
|
|
137
147
|
} = parseFilterAndSort(location.search);
|
|
138
148
|
|
|
139
|
-
const
|
|
149
|
+
const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
|
|
150
|
+
const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
|
|
151
|
+
|
|
152
|
+
const allowedFilterKeys = useMemo(() => {
|
|
153
|
+
const availableKeys = availableFilterKeys.filter((key) => {
|
|
154
|
+
const property = collection.properties[key];
|
|
155
|
+
|
|
156
|
+
if (!property) return false;
|
|
157
|
+
|
|
158
|
+
if (typeof property === "function") return false;
|
|
159
|
+
|
|
160
|
+
const dataType = property.dataType;
|
|
161
|
+
const filterable = dataType === "array"
|
|
162
|
+
? isDataTypeFilterable((property as { of?: { dataType: DataType } }).of?.dataType as DataType, true)
|
|
163
|
+
: isDataTypeFilterable(dataType);
|
|
164
|
+
|
|
165
|
+
return filterable;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const forcedKeys = forcedFilterKeys.filter((key) => !availableKeys.includes(key));
|
|
169
|
+
|
|
170
|
+
return [...availableKeys, ...forcedKeys];
|
|
171
|
+
|
|
172
|
+
}, [collection.properties, availableFilterKeys, forcedFilterKeys]);
|
|
173
|
+
|
|
174
|
+
const removeUnallowedFilters = useCallback((filters?: FilterValues<Extract<keyof M, string>>) => {
|
|
175
|
+
if (!filters) return;
|
|
176
|
+
|
|
177
|
+
return Object.fromEntries(Object.entries(filters).filter(([key]) => allowedFilterKeys.includes(key as keyof M))) as FilterValues<Extract<keyof M, string>>;
|
|
178
|
+
}, [allowedFilterKeys]);
|
|
179
|
+
|
|
180
|
+
const initFilters = forceFilter ?? (updateUrl ? initialFilterUrl : undefined) ?? initialFilter ?? undefined
|
|
181
|
+
|
|
182
|
+
const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
|
|
140
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);
|
|
141
189
|
|
|
142
190
|
useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
|
|
143
191
|
|
|
@@ -168,7 +216,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
168
216
|
const [dataLoadingError, setDataLoadingError] = useState<Error | undefined>();
|
|
169
217
|
const [noMoreToLoad, setNoMoreToLoad] = useState<boolean>(false);
|
|
170
218
|
|
|
171
|
-
const clearFilter = useCallback(() => setFilterValues(forceFilter
|
|
219
|
+
const clearFilter = useCallback(() => setFilterValues(removeUnallowedFilters(forceFilter)), [forceFilter, removeUnallowedFilters]);
|
|
172
220
|
|
|
173
221
|
const updateFilterValues = useCallback((updatedFilter: FilterValues<Extract<keyof M, string>> | undefined) => {
|
|
174
222
|
if (forceFilter) {
|
|
@@ -178,15 +226,20 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
178
226
|
if (updatedFilter && Object.keys(updatedFilter).length === 0) {
|
|
179
227
|
setFilterValues(undefined);
|
|
180
228
|
} else {
|
|
181
|
-
setFilterValues(updatedFilter);
|
|
229
|
+
setFilterValues(removeUnallowedFilters(updatedFilter));
|
|
182
230
|
}
|
|
183
|
-
}, [forceFilter]);
|
|
231
|
+
}, [forceFilter, removeUnallowedFilters]);
|
|
184
232
|
|
|
185
233
|
useEffect(() => {
|
|
186
234
|
|
|
187
235
|
setDataLoading(true);
|
|
188
236
|
|
|
189
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
|
+
}
|
|
190
243
|
if (collection.callbacks?.onFetch) {
|
|
191
244
|
try {
|
|
192
245
|
entities = await Promise.all(
|
|
@@ -217,32 +270,45 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
217
270
|
setDataLoadingError(error);
|
|
218
271
|
};
|
|
219
272
|
|
|
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
|
-
|
|
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)));
|
|
246
312
|
return () => {
|
|
247
313
|
};
|
|
248
314
|
}
|
|
@@ -268,6 +334,8 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
268
334
|
dataLoadingError,
|
|
269
335
|
filterValues,
|
|
270
336
|
setFilterValues: updateFilterValues,
|
|
337
|
+
allowedFilters: allowedFilterKeys,
|
|
338
|
+
forcedFilters: forcedFilterKeys,
|
|
271
339
|
sortBy,
|
|
272
340
|
setSortBy,
|
|
273
341
|
searchString,
|
|
@@ -294,9 +362,7 @@ function useUpdateUrl<M extends Record<string, any> = any>(
|
|
|
294
362
|
|
|
295
363
|
useEffect(() => {
|
|
296
364
|
if (updateUrl) {
|
|
297
|
-
const
|
|
298
|
-
const search = searchString ? `&search=${encodeURIComponent(searchString)}` : "";
|
|
299
|
-
const state = `${newUrl}${search}`;
|
|
365
|
+
const state = encodeFilterAndSort(filterValues, sortBy, searchString);
|
|
300
366
|
const hash = window.location.hash;
|
|
301
367
|
if (state === "")
|
|
302
368
|
window.history.replaceState({}, "", `${window.location.pathname}${hash}`);
|
|
@@ -305,124 +371,3 @@ function useUpdateUrl<M extends Record<string, any> = any>(
|
|
|
305
371
|
}
|
|
306
372
|
}, [filterValues, sortBy, searchString, updateUrl]);
|
|
307
373
|
}
|
|
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>
|