@firecms/core 3.3.0 → 3.4.0-canary.63041d0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/EntityCollectionView/EntityCollectionView.d.ts +7 -0
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
- package/dist/components/EntityPreview.d.ts +3 -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/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/useEntityFetch.d.ts +6 -1
- package/dist/index.es.js +2790 -1270
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2788 -1268
- 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 +42 -0
- 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/urls.d.ts +22 -0
- package/package.json +16 -9
- 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/EntityCollectionView/EntityCollectionView.tsx +19 -1
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
- package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
- package/src/components/EntityPreview.tsx +41 -40
- 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 +98 -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 +75 -32
- package/src/core/EntitySidePanel.tsx +2 -0
- package/src/core/field_configs.tsx +15 -0
- package/src/form/EntityForm.tsx +1 -1
- 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/save.ts +2 -0
- package/src/hooks/data/useEntityFetch.tsx +9 -0
- package/src/hooks/useValidateAuthenticator.tsx +4 -0
- package/src/i18n/FireCMSi18nProvider.tsx +2 -0
- package/src/internal/useBuildDataSource.ts +11 -0
- 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 +42 -0
- 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 +13 -0
- package/src/util/geopoint.ts +81 -0
- package/src/util/index.ts +2 -0
- package/src/util/navigation_blocking.ts +45 -0
- package/src/util/navigation_from_path.ts +41 -6
- package/src/util/navigation_utils.ts +40 -2
- package/src/util/parent_references_from_path.ts +4 -2
- package/src/util/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- package/src/util/urls.ts +52 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { filterValueToText, parseFilterTextInput } from "./filter_text_input";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type `text` one character at a time, the way the filter field does, and keep track
|
|
6
|
+
* of the filter value left after every keystroke.
|
|
7
|
+
*/
|
|
8
|
+
function typeCharByChar(text: string, dataType: "string" | "number" = "number") {
|
|
9
|
+
let value: string | number | undefined;
|
|
10
|
+
const valueAfterEachKeystroke: (string | number | undefined)[] = [];
|
|
11
|
+
for (let i = 1; i <= text.length; i++) {
|
|
12
|
+
const result = parseFilterTextInput(text.slice(0, i), dataType);
|
|
13
|
+
if (result.updateValue)
|
|
14
|
+
value = result.value;
|
|
15
|
+
valueAfterEachKeystroke.push(value);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
value,
|
|
19
|
+
valueAfterEachKeystroke
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("parseFilterTextInput with numbers", () => {
|
|
24
|
+
|
|
25
|
+
it("should parse complete numbers", () => {
|
|
26
|
+
expect(parseFilterTextInput("0.01", "number")).toEqual({
|
|
27
|
+
updateValue: true,
|
|
28
|
+
value: 0.01
|
|
29
|
+
});
|
|
30
|
+
expect(parseFilterTextInput("1", "number")).toEqual({
|
|
31
|
+
updateValue: true,
|
|
32
|
+
value: 1
|
|
33
|
+
});
|
|
34
|
+
expect(parseFilterTextInput("1.5", "number")).toEqual({
|
|
35
|
+
updateValue: true,
|
|
36
|
+
value: 1.5
|
|
37
|
+
});
|
|
38
|
+
expect(parseFilterTextInput("-0.5", "number")).toEqual({
|
|
39
|
+
updateValue: true,
|
|
40
|
+
value: -0.5
|
|
41
|
+
});
|
|
42
|
+
expect(parseFilterTextInput(".5", "number")).toEqual({
|
|
43
|
+
updateValue: true,
|
|
44
|
+
value: 0.5
|
|
45
|
+
});
|
|
46
|
+
expect(parseFilterTextInput("1e5", "number")).toEqual({
|
|
47
|
+
updateValue: true,
|
|
48
|
+
value: 100000
|
|
49
|
+
});
|
|
50
|
+
expect(parseFilterTextInput("0.0100", "number")).toEqual({
|
|
51
|
+
updateValue: true,
|
|
52
|
+
value: 0.01
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should keep 0 as a valid filter value", () => {
|
|
57
|
+
expect(parseFilterTextInput("0", "number")).toEqual({
|
|
58
|
+
updateValue: true,
|
|
59
|
+
value: 0
|
|
60
|
+
});
|
|
61
|
+
expect(parseFilterTextInput("0.0", "number")).toEqual({
|
|
62
|
+
updateValue: true,
|
|
63
|
+
value: 0
|
|
64
|
+
});
|
|
65
|
+
expect(parseFilterTextInput("00", "number")).toEqual({
|
|
66
|
+
updateValue: true,
|
|
67
|
+
value: 0
|
|
68
|
+
});
|
|
69
|
+
expect(parseFilterTextInput("-0", "number").updateValue).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should clear the filter for an empty input", () => {
|
|
73
|
+
expect(parseFilterTextInput("", "number")).toEqual({
|
|
74
|
+
updateValue: true,
|
|
75
|
+
value: undefined
|
|
76
|
+
});
|
|
77
|
+
expect(parseFilterTextInput(" ", "number")).toEqual({
|
|
78
|
+
updateValue: true,
|
|
79
|
+
value: undefined
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should not touch the filter value for intermediate inputs", () => {
|
|
84
|
+
// these are states the user goes through while typing, the text must be kept
|
|
85
|
+
// in the input without the filter value being changed
|
|
86
|
+
expect(parseFilterTextInput("0.", "number")).toEqual({ updateValue: false });
|
|
87
|
+
expect(parseFilterTextInput(".", "number")).toEqual({ updateValue: false });
|
|
88
|
+
expect(parseFilterTextInput("1.", "number")).toEqual({ updateValue: false });
|
|
89
|
+
expect(parseFilterTextInput("-", "number")).toEqual({ updateValue: false });
|
|
90
|
+
expect(parseFilterTextInput("-.", "number")).toEqual({ updateValue: false });
|
|
91
|
+
expect(parseFilterTextInput("+", "number")).toEqual({ updateValue: false });
|
|
92
|
+
expect(parseFilterTextInput("-0.", "number")).toEqual({ updateValue: false });
|
|
93
|
+
expect(parseFilterTextInput("1e", "number")).toEqual({ updateValue: false });
|
|
94
|
+
expect(parseFilterTextInput("1e-", "number")).toEqual({ updateValue: false });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should not touch the filter value for text that is not a number", () => {
|
|
98
|
+
expect(parseFilterTextInput("abc", "number")).toEqual({ updateValue: false });
|
|
99
|
+
expect(parseFilterTextInput("12abc", "number")).toEqual({ updateValue: false });
|
|
100
|
+
expect(parseFilterTextInput("1.2.3", "number")).toEqual({ updateValue: false });
|
|
101
|
+
expect(parseFilterTextInput("--1", "number")).toEqual({ updateValue: false });
|
|
102
|
+
expect(parseFilterTextInput("0x10", "number")).toEqual({ updateValue: false });
|
|
103
|
+
expect(parseFilterTextInput("Infinity", "number")).toEqual({ updateValue: false });
|
|
104
|
+
expect(parseFilterTextInput("1e999", "number")).toEqual({ updateValue: false });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should allow typing 0.01 character by character", () => {
|
|
108
|
+
const { value, valueAfterEachKeystroke } = typeCharByChar("0.01");
|
|
109
|
+
expect(value).toBe(0.01);
|
|
110
|
+
// "0" -> 0, "0." -> unchanged, "0.0" -> 0, "0.01" -> 0.01
|
|
111
|
+
expect(valueAfterEachKeystroke).toEqual([0, 0, 0, 0.01]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should allow typing other decimals character by character", () => {
|
|
115
|
+
expect(typeCharByChar("0.001").value).toBe(0.001);
|
|
116
|
+
expect(typeCharByChar("-0.5").value).toBe(-0.5);
|
|
117
|
+
expect(typeCharByChar(".5").value).toBe(0.5);
|
|
118
|
+
expect(typeCharByChar(".01").value).toBe(0.01);
|
|
119
|
+
expect(typeCharByChar("1.05").value).toBe(1.05);
|
|
120
|
+
expect(typeCharByChar("10.00001").value).toBe(10.00001);
|
|
121
|
+
expect(typeCharByChar("-.5").value).toBe(-0.5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should keep the previous value while typing a trailing decimal separator", () => {
|
|
125
|
+
// typing "1." leaves the value at 1, the text "1." stays in the input
|
|
126
|
+
expect(typeCharByChar("1.").value).toBe(1);
|
|
127
|
+
expect(typeCharByChar("1.").valueAfterEachKeystroke).toEqual([1, 1]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should not commit a value until a leading sign is followed by digits", () => {
|
|
131
|
+
expect(typeCharByChar("-").value).toBeUndefined();
|
|
132
|
+
expect(typeCharByChar("-1").valueAfterEachKeystroke).toEqual([undefined, -1]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should clear the value when the input is emptied", () => {
|
|
136
|
+
// deleting "0.01" one character at a time, from the end
|
|
137
|
+
const texts = ["0.0", "0.", "0", ""];
|
|
138
|
+
let value: string | number | undefined = 0.01;
|
|
139
|
+
for (const text of texts) {
|
|
140
|
+
const result = parseFilterTextInput(text, "number");
|
|
141
|
+
if (result.updateValue)
|
|
142
|
+
value = result.value;
|
|
143
|
+
}
|
|
144
|
+
expect(value).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("parseFilterTextInput with strings", () => {
|
|
149
|
+
|
|
150
|
+
it("should use the text as the value", () => {
|
|
151
|
+
expect(parseFilterTextInput("abc", "string")).toEqual({
|
|
152
|
+
updateValue: true,
|
|
153
|
+
value: "abc"
|
|
154
|
+
});
|
|
155
|
+
expect(parseFilterTextInput("0.", "string")).toEqual({
|
|
156
|
+
updateValue: true,
|
|
157
|
+
value: "0."
|
|
158
|
+
});
|
|
159
|
+
expect(parseFilterTextInput("", "string")).toEqual({
|
|
160
|
+
updateValue: true,
|
|
161
|
+
value: ""
|
|
162
|
+
});
|
|
163
|
+
expect(parseFilterTextInput(" spaced ", "string")).toEqual({
|
|
164
|
+
updateValue: true,
|
|
165
|
+
value: " spaced "
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("filterValueToText", () => {
|
|
171
|
+
|
|
172
|
+
it("should render values as text", () => {
|
|
173
|
+
expect(filterValueToText(0)).toBe("0");
|
|
174
|
+
expect(filterValueToText(0.01)).toBe("0.01");
|
|
175
|
+
expect(filterValueToText(-0.5)).toBe("-0.5");
|
|
176
|
+
expect(filterValueToText("abc")).toBe("abc");
|
|
177
|
+
expect(filterValueToText("")).toBe("");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should render empty text for no value", () => {
|
|
181
|
+
expect(filterValueToText(undefined)).toBe("");
|
|
182
|
+
expect(filterValueToText(null)).toBe("");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matches text that is a complete number, as opposed to an intermediate state
|
|
3
|
+
* that the user may be in the middle of typing, like "-", "0." or ".".
|
|
4
|
+
* A decimal separator must be followed by at least one digit.
|
|
5
|
+
*/
|
|
6
|
+
const completeNumberRegex = /^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
7
|
+
|
|
8
|
+
export type FilterTextInputParseResult = {
|
|
9
|
+
/**
|
|
10
|
+
* Whether the filter value should be updated with `value`.
|
|
11
|
+
* It is `false` for intermediate inputs that are not a valid value yet,
|
|
12
|
+
* like "-" or "0.", so that the text typed by the user can be kept as it is
|
|
13
|
+
* while the current filter value is left untouched.
|
|
14
|
+
*/
|
|
15
|
+
updateValue: boolean;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parsed value, only relevant when `updateValue` is `true`.
|
|
19
|
+
* `undefined` means the filter should be cleared.
|
|
20
|
+
*/
|
|
21
|
+
value?: string | number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turn the raw text of a filter input into the value used by the filter.
|
|
26
|
+
*
|
|
27
|
+
* The text shown to the user is never derived back from the parsed value, so
|
|
28
|
+
* intermediate states like "0.", ".0" or "-" survive while typing. Numbers are
|
|
29
|
+
* only committed to the filter once the text is a complete number, which makes
|
|
30
|
+
* values such as 0.01 reachable by typing them character by character.
|
|
31
|
+
*
|
|
32
|
+
* Note that `0` is a perfectly valid filter value, only an empty input clears
|
|
33
|
+
* the filter.
|
|
34
|
+
*/
|
|
35
|
+
export function parseFilterTextInput(text: string, dataType: "string" | "number"): FilterTextInputParseResult {
|
|
36
|
+
|
|
37
|
+
if (dataType !== "number")
|
|
38
|
+
return {
|
|
39
|
+
updateValue: true,
|
|
40
|
+
value: text
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const trimmedText = text.trim();
|
|
44
|
+
|
|
45
|
+
if (trimmedText === "")
|
|
46
|
+
return {
|
|
47
|
+
updateValue: true,
|
|
48
|
+
value: undefined
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (!completeNumberRegex.test(trimmedText))
|
|
52
|
+
return { updateValue: false };
|
|
53
|
+
|
|
54
|
+
const parsedValue = Number(trimmedText);
|
|
55
|
+
if (!isFinite(parsedValue))
|
|
56
|
+
return { updateValue: false };
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
updateValue: true,
|
|
60
|
+
value: parsedValue
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Text a filter input should display for a given filter value.
|
|
66
|
+
*/
|
|
67
|
+
export function filterValueToText(value: unknown): string {
|
|
68
|
+
if (value === undefined || value === null)
|
|
69
|
+
return "";
|
|
70
|
+
return String(value);
|
|
71
|
+
}
|
|
@@ -135,7 +135,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
|
|
|
135
135
|
const [columns, setColumns] = useState(columnsProp);
|
|
136
136
|
|
|
137
137
|
const tableRef = useRef<HTMLDivElement>(null);
|
|
138
|
-
const
|
|
138
|
+
const lastEndReachedDataLength = useRef<number | undefined>(undefined);
|
|
139
139
|
|
|
140
140
|
const debouncedScroll = useDebounceCallback(onScrollProp, 200);
|
|
141
141
|
|
|
@@ -242,7 +242,7 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
|
|
|
242
242
|
}, [filterInput]);
|
|
243
243
|
|
|
244
244
|
const scrollToTop = useCallback(() => {
|
|
245
|
-
|
|
245
|
+
lastEndReachedDataLength.current = undefined;
|
|
246
246
|
if (tableRef.current) {
|
|
247
247
|
tableRef.current.scrollTo(tableRef.current?.scrollLeft, 0);
|
|
248
248
|
}
|
|
@@ -278,13 +278,24 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
|
|
|
278
278
|
|
|
279
279
|
const maxScroll = Math.max((data?.length ?? 0) * rowHeight - bounds.height, 0);
|
|
280
280
|
|
|
281
|
-
const onEndReachedInternal = useCallback((
|
|
282
|
-
|
|
283
|
-
|
|
281
|
+
const onEndReachedInternal = useCallback(() => {
|
|
282
|
+
const dataLength = data?.length ?? 0;
|
|
283
|
+
|
|
284
|
+
if (onEndReached && dataLength > 0 && lastEndReachedDataLength.current !== dataLength) {
|
|
285
|
+
lastEndReachedDataLength.current = dataLength;
|
|
284
286
|
onEndReached();
|
|
285
287
|
}
|
|
286
288
|
}, [data?.length, onEndReached]);
|
|
287
289
|
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
if (!onEndReached || loading || !data?.length || !bounds.height || !tableRef.current) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (tableRef.current.scrollHeight <= tableRef.current.clientHeight) {
|
|
295
|
+
onEndReachedInternal();
|
|
296
|
+
}
|
|
297
|
+
}, [bounds.height, data?.length, loading, onEndReached, onEndReachedInternal]);
|
|
298
|
+
|
|
288
299
|
const onScroll = useCallback(({
|
|
289
300
|
scrollDirection,
|
|
290
301
|
scrollOffset,
|
|
@@ -302,12 +313,12 @@ export const VirtualTable = React.memo<VirtualTableProps<any>>(
|
|
|
302
313
|
})
|
|
303
314
|
}
|
|
304
315
|
if (!scrollUpdateWasRequested && (scrollOffset >= maxScroll - endOffset))
|
|
305
|
-
onEndReachedInternal(
|
|
306
|
-
}, [maxScroll, onEndReachedInternal]);
|
|
316
|
+
onEndReachedInternal();
|
|
317
|
+
}, [endOffset, maxScroll, onEndReachedInternal]);
|
|
307
318
|
|
|
308
319
|
const onFilterUpdateInternal = useCallback((column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, any]) => {
|
|
309
320
|
|
|
310
|
-
|
|
321
|
+
lastEndReachedDataLength.current = undefined;
|
|
311
322
|
const filter = filterRef.current;
|
|
312
323
|
let newFilterValue: VirtualTableFilterValues<any> = filter ? { ...filter } : {};
|
|
313
324
|
|
|
@@ -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
|
+
}
|