@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.
Files changed (108) hide show
  1. package/dist/app/useApp.d.ts +1 -0
  2. package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
  3. package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
  4. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +7 -0
  5. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  6. package/dist/components/EntityPreview.d.ts +3 -1
  7. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  8. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  9. package/dist/components/common/table_url_params.d.ts +24 -0
  10. package/dist/components/common/useDataSourceTableController.d.ts +7 -1
  11. package/dist/components/common/useTableSearchHelper.d.ts +7 -1
  12. package/dist/core/DefaultDrawer.d.ts +10 -3
  13. package/dist/core/EntityEditView.d.ts +7 -1
  14. package/dist/core/field_configs.d.ts +1 -1
  15. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  16. package/dist/form/index.d.ts +1 -0
  17. package/dist/hooks/data/save.d.ts +1 -1
  18. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  19. package/dist/index.es.js +2790 -1270
  20. package/dist/index.es.js.map +1 -1
  21. package/dist/index.umd.js +2788 -1268
  22. package/dist/index.umd.js.map +1 -1
  23. package/dist/locales/pl.d.ts +2 -0
  24. package/dist/preview/index.d.ts +1 -0
  25. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  26. package/dist/types/collections.d.ts +24 -0
  27. package/dist/types/datasource.d.ts +42 -0
  28. package/dist/types/properties.d.ts +17 -1
  29. package/dist/types/side_entity_controller.d.ts +6 -0
  30. package/dist/types/translations.d.ts +6 -0
  31. package/dist/util/__tests__/collections.test.d.ts +1 -0
  32. package/dist/util/__tests__/urls.test.d.ts +1 -0
  33. package/dist/util/collections.d.ts +1 -1
  34. package/dist/util/entities.d.ts +1 -0
  35. package/dist/util/geopoint.d.ts +22 -0
  36. package/dist/util/index.d.ts +2 -0
  37. package/dist/util/navigation_blocking.d.ts +22 -0
  38. package/dist/util/navigation_from_path.d.ts +17 -0
  39. package/dist/util/navigation_utils.d.ts +23 -1
  40. package/dist/util/urls.d.ts +22 -0
  41. package/package.json +16 -9
  42. package/src/app/Scaffold.tsx +34 -44
  43. package/src/app/useApp.tsx +1 -0
  44. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  45. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  46. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  47. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  48. package/src/components/EntityCollectionView/EntityCollectionView.tsx +19 -1
  49. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  50. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  51. package/src/components/EntityPreview.tsx +41 -40
  52. package/src/components/ReferenceWidget.tsx +1 -1
  53. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  54. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  55. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  56. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  57. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  58. package/src/components/common/table_url_params.ts +172 -0
  59. package/src/components/common/useDataSourceTableController.tsx +98 -159
  60. package/src/components/common/useTableSearchHelper.ts +21 -2
  61. package/src/contexts/SnackbarProvider.tsx +14 -1
  62. package/src/core/DefaultDrawer.tsx +150 -64
  63. package/src/core/DrawerNavigationGroup.tsx +27 -29
  64. package/src/core/DrawerNavigationItem.tsx +10 -12
  65. package/src/core/EntityEditView.tsx +75 -32
  66. package/src/core/EntitySidePanel.tsx +2 -0
  67. package/src/core/field_configs.tsx +15 -0
  68. package/src/form/EntityForm.tsx +1 -1
  69. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  70. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  71. package/src/form/index.tsx +1 -0
  72. package/src/hooks/data/save.ts +2 -0
  73. package/src/hooks/data/useEntityFetch.tsx +9 -0
  74. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  75. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  76. package/src/internal/useBuildDataSource.ts +11 -0
  77. package/src/internal/useBuildSideEntityController.tsx +8 -1
  78. package/src/locales/de.ts +7 -2
  79. package/src/locales/en.ts +7 -2
  80. package/src/locales/es.ts +7 -2
  81. package/src/locales/fr.ts +7 -2
  82. package/src/locales/hi.ts +7 -2
  83. package/src/locales/it.ts +7 -2
  84. package/src/locales/pl.ts +735 -0
  85. package/src/locales/pt.ts +7 -2
  86. package/src/preview/PropertyPreview.tsx +12 -0
  87. package/src/preview/components/StorageThumbnail.tsx +24 -2
  88. package/src/preview/index.ts +1 -0
  89. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  90. package/src/routes/FireCMSRoute.tsx +47 -25
  91. package/src/types/collections.ts +26 -0
  92. package/src/types/datasource.ts +42 -0
  93. package/src/types/properties.ts +17 -0
  94. package/src/types/side_entity_controller.tsx +6 -0
  95. package/src/types/translations.ts +6 -0
  96. package/src/util/__tests__/collections.test.ts +32 -0
  97. package/src/util/__tests__/urls.test.ts +131 -0
  98. package/src/util/collections.ts +1 -1
  99. package/src/util/entities.ts +13 -0
  100. package/src/util/geopoint.ts +81 -0
  101. package/src/util/index.ts +2 -0
  102. package/src/util/navigation_blocking.ts +45 -0
  103. package/src/util/navigation_from_path.ts +41 -6
  104. package/src/util/navigation_utils.ts +40 -2
  105. package/src/util/parent_references_from_path.ts +4 -2
  106. package/src/util/previews.ts +8 -1
  107. package/src/util/resolutions.ts +8 -0
  108. 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 endReachCallbackThreshold = useRef<number>(0);
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
- endReachCallbackThreshold.current = 0;
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((scrollOffset: number) => {
282
- if (onEndReached && (data?.length ?? 0) > 0 && scrollOffset > endReachCallbackThreshold.current + endOffset) {
283
- endReachCallbackThreshold.current = scrollOffset;
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(scrollOffset);
306
- }, [maxScroll, onEndReachedInternal]);
316
+ onEndReachedInternal();
317
+ }, [endOffset, maxScroll, onEndReachedInternal]);
307
318
 
308
319
  const onFilterUpdateInternal = useCallback((column: VirtualTableColumn, filterForProperty?: [VirtualTableWhereFilterOp, any]) => {
309
320
 
310
- endReachCallbackThreshold.current = 0;
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
+ }