@firecms/core 3.4.0-canary.f6a889a → 3.4.0
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 +8 -0
- package/dist/components/EntityCollectionView/EntityCollectionViewActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionViewStartActions.d.ts +3 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/ErrorBoundary.d.ts +1 -3
- 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/EntityEditView.d.ts +9 -1
- package/dist/core/EntityEditViewFormActions.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +8 -1
- package/dist/form/EntityFormActions.d.ts +2 -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 +1753 -1170
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1751 -1168
- package/dist/index.umd.js.map +1 -1
- package/dist/types/collections.d.ts +14 -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/entity_cache.d.ts +11 -0
- 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 +87 -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 +4 -4
- package/src/components/DeleteEntityDialog.tsx +2 -0
- package/src/components/EntityCollectionTable/EntityCollectionRowActions.tsx +2 -2
- 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 +12 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +66 -15
- package/src/components/EntityCollectionView/EntityCollectionViewActions.tsx +6 -2
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -0
- package/src/components/EntityCollectionView/useBoardDataController.tsx +17 -1
- package/src/components/EntityPreview.tsx +4 -1
- package/src/components/ErrorBoundary.tsx +12 -18
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +19 -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/common/default_entity_actions.tsx +22 -2
- package/src/components/common/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +86 -179
- package/src/components/common/useTableSearchHelper.ts +26 -2
- package/src/contexts/SnackbarProvider.tsx +14 -1
- package/src/core/EntityEditView.tsx +20 -3
- package/src/core/EntityEditViewFormActions.tsx +3 -2
- package/src/core/EntitySidePanel.tsx +8 -4
- package/src/form/EntityForm.tsx +31 -10
- package/src/form/EntityFormActions.tsx +2 -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 +48 -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 +19 -5
- 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/ReferencePreview.tsx +7 -3
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/routes/FireCMSRoute.tsx +11 -3
- package/src/types/collections.ts +16 -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 +3 -1
- package/src/util/entity_cache.ts +18 -0
- package/src/util/index.ts +1 -0
- package/src/util/navigation_from_path.ts +25 -2
- package/src/util/navigation_utils.ts +208 -2
- package/src/util/parent_references_from_path.ts +42 -3
- 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
|
@@ -18,6 +18,7 @@ export const editEntityAction: EntityAction = {
|
|
|
18
18
|
entity,
|
|
19
19
|
collection,
|
|
20
20
|
fullPath,
|
|
21
|
+
pathSegments,
|
|
21
22
|
fullIdPath,
|
|
22
23
|
context,
|
|
23
24
|
highlightEntity,
|
|
@@ -41,6 +42,12 @@ export const editEntityAction: EntityAction = {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
const path = collection?.collectionGroup ? entity.path : (fullPath ?? collection?.path ?? entity.path);
|
|
45
|
+
// In a collection group `path` is the entity's own, so the entity's segments describe
|
|
46
|
+
// it. Otherwise the caller's segments win, falling back to the ones the entity was
|
|
47
|
+
// loaded with — both describe the same collection. Never derived from `path`.
|
|
48
|
+
const resolvedPathSegments = collection?.collectionGroup
|
|
49
|
+
? entity.pathSegments
|
|
50
|
+
: (pathSegments ?? entity.pathSegments);
|
|
44
51
|
const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id ?? entity.path);
|
|
45
52
|
const defaultSelectedView = resolveDefaultSelectedView(
|
|
46
53
|
collection ? collection.defaultSelectedView : undefined,
|
|
@@ -54,6 +61,7 @@ export const editEntityAction: EntityAction = {
|
|
|
54
61
|
collection,
|
|
55
62
|
entityId: entity.id,
|
|
56
63
|
path,
|
|
64
|
+
pathSegments: resolvedPathSegments,
|
|
57
65
|
fullIdPath: newFullIdPath,
|
|
58
66
|
sideEntityController: context.sideEntityController,
|
|
59
67
|
onClose: () => unhighlightEntity?.(entity),
|
|
@@ -75,6 +83,8 @@ export const copyEntityAction: EntityAction = {
|
|
|
75
83
|
collection,
|
|
76
84
|
context,
|
|
77
85
|
fullPath,
|
|
86
|
+
pathSegments,
|
|
87
|
+
fullIdPath,
|
|
78
88
|
highlightEntity,
|
|
79
89
|
unhighlightEntity,
|
|
80
90
|
openEntityMode
|
|
@@ -89,13 +99,23 @@ export const copyEntityAction: EntityAction = {
|
|
|
89
99
|
});
|
|
90
100
|
|
|
91
101
|
const path = collection?.collectionGroup ? collection.path : (fullPath ?? collection?.path ?? entity.path);
|
|
92
|
-
|
|
102
|
+
// In a collection group `path` is the group's own root, which the entity's segments
|
|
103
|
+
// (its real nested location) do not describe — so they are deliberately not reused,
|
|
104
|
+
// and no substitute is invented.
|
|
105
|
+
const resolvedPathSegments = collection?.collectionGroup
|
|
106
|
+
? undefined
|
|
107
|
+
: (pathSegments ?? entity.pathSegments);
|
|
108
|
+
// `fullIdPath` is the ESCAPED chain and is what becomes the URL; `fullPath` is the raw
|
|
109
|
+
// datasource path, whose parent ids may contain "/". Using the latter here wrote a URL
|
|
110
|
+
// that read back as a different entity. Mirrors editEntityAction.
|
|
111
|
+
const newFullIdPath = collection?.collectionGroup ? collection.id : (fullIdPath ?? collection?.id);
|
|
93
112
|
navigateToEntity({
|
|
94
113
|
openEntityMode,
|
|
95
114
|
collection,
|
|
96
115
|
entityId: entity.id,
|
|
97
116
|
path,
|
|
98
|
-
|
|
117
|
+
pathSegments: resolvedPathSegments,
|
|
118
|
+
fullIdPath: newFullIdPath,
|
|
99
119
|
copy: true,
|
|
100
120
|
sideEntityController: context.sideEntityController,
|
|
101
121
|
onClose: () => unhighlightEntity?.(entity),
|
|
@@ -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
|
+
}
|
|
@@ -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,14 @@ 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.
|
|
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[];
|
|
30
36
|
/**
|
|
31
37
|
* The collection that is represented by this config.
|
|
32
38
|
*/
|
|
@@ -71,6 +77,7 @@ export type DataSourceTableControllerProps<M extends Record<string, any> = any>
|
|
|
71
77
|
export function useDataSourceTableController<M extends Record<string, any> = any, USER extends User = User>(
|
|
72
78
|
{
|
|
73
79
|
fullPath,
|
|
80
|
+
pathSegments,
|
|
74
81
|
collection,
|
|
75
82
|
scrollRestoration,
|
|
76
83
|
entitiesDisplayedFirst,
|
|
@@ -89,20 +96,24 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
89
96
|
const [popupCell, setPopupCell] = React.useState<SelectedCellProps<M> | undefined>(undefined);
|
|
90
97
|
const navigation = useNavigationController();
|
|
91
98
|
const dataSource = useDataSource(collection);
|
|
92
|
-
|
|
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]);
|
|
93
105
|
|
|
94
106
|
const forceFilter = forceFilterFromProps ?? forceFilterFromCollection;
|
|
95
107
|
const paginationEnabled = collection.pagination === undefined || Boolean(collection.pagination);
|
|
96
108
|
const pageSize = typeof collection.pagination === "number" ? collection.pagination : DEFAULT_PAGE_SIZE;
|
|
97
109
|
|
|
98
|
-
const [searchString, setSearchString] = React.useState<string | undefined>();
|
|
99
|
-
|
|
100
110
|
const checkFilterCombination = useCallback((filterValues: FilterValues<any>,
|
|
101
111
|
sortBy?: [string, "asc" | "desc"]) => {
|
|
102
112
|
if (!dataSource.isFilterCombinationValid)
|
|
103
113
|
return true;
|
|
104
114
|
return dataSource.isFilterCombinationValid({
|
|
105
115
|
path: resolvedPath,
|
|
116
|
+
pathSegments: resolvedPathSegments,
|
|
106
117
|
collection,
|
|
107
118
|
filterValues,
|
|
108
119
|
sortBy
|
|
@@ -137,8 +148,9 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
137
148
|
const {
|
|
138
149
|
filterValues: initialFilterUrl,
|
|
139
150
|
sortBy: initialSortUrl,
|
|
151
|
+
searchString: initialSearchUrl
|
|
140
152
|
} = parseFilterAndSort(location.search);
|
|
141
|
-
|
|
153
|
+
|
|
142
154
|
const availableFilterKeys = collection.allowedFilters ?? Object.keys(collection.properties);
|
|
143
155
|
const forcedFilterKeys = collection.forceFilter ? Object.keys(collection.forceFilter) : [];
|
|
144
156
|
|
|
@@ -174,14 +186,31 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
174
186
|
|
|
175
187
|
const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string>> | undefined>(removeUnallowedFilters(initFilters));
|
|
176
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);
|
|
177
194
|
|
|
178
195
|
useUpdateUrl(filterValues, sortBy, searchString, updateUrl);
|
|
179
196
|
|
|
180
|
-
|
|
181
|
-
|
|
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;
|
|
182
206
|
|
|
183
207
|
useEffect(() => {
|
|
184
|
-
|
|
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) {
|
|
185
214
|
scrollRestoration.updateCollectionScroll({
|
|
186
215
|
fullPath: resolvedPath,
|
|
187
216
|
scrollOffset: collectionScroll?.scrollOffset ?? 0,
|
|
@@ -223,6 +252,11 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
223
252
|
setDataLoading(true);
|
|
224
253
|
|
|
225
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
|
+
}
|
|
226
260
|
if (collection.callbacks?.onFetch) {
|
|
227
261
|
try {
|
|
228
262
|
entities = await Promise.all(
|
|
@@ -230,6 +264,7 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
230
264
|
collection.callbacks!.onFetch!({
|
|
231
265
|
collection,
|
|
232
266
|
path: resolvedPath,
|
|
267
|
+
pathSegments: resolvedPathSegments,
|
|
233
268
|
entity,
|
|
234
269
|
context
|
|
235
270
|
})));
|
|
@@ -253,32 +288,45 @@ export function useDataSourceTableController<M extends Record<string, any> = any
|
|
|
253
288
|
setDataLoadingError(error);
|
|
254
289
|
};
|
|
255
290
|
|
|
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
|
-
|
|
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)));
|
|
282
330
|
return () => {
|
|
283
331
|
};
|
|
284
332
|
}
|
|
@@ -332,9 +380,7 @@ function useUpdateUrl<M extends Record<string, any> = any>(
|
|
|
332
380
|
|
|
333
381
|
useEffect(() => {
|
|
334
382
|
if (updateUrl) {
|
|
335
|
-
const
|
|
336
|
-
const search = searchString ? `&search=${encodeURIComponent(searchString)}` : "";
|
|
337
|
-
const state = `${newUrl}${search}`;
|
|
383
|
+
const state = encodeFilterAndSort(filterValues, sortBy, searchString);
|
|
338
384
|
const hash = window.location.hash;
|
|
339
385
|
if (state === "")
|
|
340
386
|
window.history.replaceState({}, "", `${window.location.pathname}${hash}`);
|
|
@@ -343,142 +389,3 @@ function useUpdateUrl<M extends Record<string, any> = any>(
|
|
|
343
389
|
}
|
|
344
390
|
}, [filterValues, sortBy, searchString, updateUrl]);
|
|
345
391
|
}
|
|
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";
|
|
@@ -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,
|