@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.
Files changed (124) 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/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
  5. package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
  6. package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +7 -0
  7. package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
  8. package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
  9. package/dist/components/EntityPreview.d.ts +3 -1
  10. package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
  11. package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
  12. package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
  13. package/dist/components/common/table_url_params.d.ts +24 -0
  14. package/dist/components/common/useDataSourceTableController.d.ts +7 -1
  15. package/dist/components/common/useTableSearchHelper.d.ts +7 -1
  16. package/dist/core/DefaultDrawer.d.ts +10 -3
  17. package/dist/core/EntityEditView.d.ts +7 -1
  18. package/dist/core/field_configs.d.ts +1 -1
  19. package/dist/form/EntityForm.d.ts +6 -1
  20. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
  21. package/dist/form/index.d.ts +1 -0
  22. package/dist/hooks/data/save.d.ts +1 -1
  23. package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
  24. package/dist/hooks/data/useEntityFetch.d.ts +6 -1
  25. package/dist/index.es.js +2915 -1330
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/index.umd.js +2913 -1328
  28. package/dist/index.umd.js.map +1 -1
  29. package/dist/locales/pl.d.ts +2 -0
  30. package/dist/preview/index.d.ts +1 -0
  31. package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
  32. package/dist/types/collections.d.ts +24 -0
  33. package/dist/types/datasource.d.ts +59 -4
  34. package/dist/types/entities.d.ts +21 -1
  35. package/dist/types/properties.d.ts +17 -1
  36. package/dist/types/side_entity_controller.d.ts +6 -0
  37. package/dist/types/translations.d.ts +6 -0
  38. package/dist/util/__tests__/collections.test.d.ts +1 -0
  39. package/dist/util/__tests__/urls.test.d.ts +1 -0
  40. package/dist/util/collections.d.ts +1 -1
  41. package/dist/util/entities.d.ts +1 -0
  42. package/dist/util/geopoint.d.ts +22 -0
  43. package/dist/util/index.d.ts +2 -0
  44. package/dist/util/navigation_blocking.d.ts +22 -0
  45. package/dist/util/navigation_from_path.d.ts +17 -0
  46. package/dist/util/navigation_utils.d.ts +23 -1
  47. package/dist/util/parent_references_from_path.d.ts +1 -0
  48. package/dist/util/urls.d.ts +22 -0
  49. package/package.json +17 -10
  50. package/src/app/Scaffold.tsx +34 -44
  51. package/src/app/useApp.tsx +1 -0
  52. package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
  53. package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
  54. package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
  55. package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
  56. package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
  57. package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
  58. package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
  59. package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
  60. package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
  61. package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
  62. package/src/components/EntityPreview.tsx +41 -40
  63. package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
  64. package/src/components/ReferenceWidget.tsx +1 -1
  65. package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
  66. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
  67. package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
  68. package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
  69. package/src/components/VirtualTable/VirtualTable.tsx +19 -8
  70. package/src/components/common/table_url_params.ts +172 -0
  71. package/src/components/common/useDataSourceTableController.tsx +104 -159
  72. package/src/components/common/useTableSearchHelper.ts +21 -2
  73. package/src/contexts/SnackbarProvider.tsx +14 -1
  74. package/src/core/DefaultDrawer.tsx +150 -64
  75. package/src/core/DrawerNavigationGroup.tsx +27 -29
  76. package/src/core/DrawerNavigationItem.tsx +10 -12
  77. package/src/core/EntityEditView.tsx +78 -32
  78. package/src/core/EntitySidePanel.tsx +2 -0
  79. package/src/core/field_configs.tsx +15 -0
  80. package/src/form/EntityForm.tsx +9 -3
  81. package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
  82. package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
  83. package/src/form/index.tsx +1 -0
  84. package/src/hooks/data/delete.ts +2 -0
  85. package/src/hooks/data/save.ts +2 -0
  86. package/src/hooks/data/useCollectionFetch.tsx +13 -0
  87. package/src/hooks/data/useEntityFetch.tsx +18 -0
  88. package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
  89. package/src/hooks/useValidateAuthenticator.tsx +4 -0
  90. package/src/i18n/FireCMSi18nProvider.tsx +2 -0
  91. package/src/internal/useBuildDataSource.ts +25 -4
  92. package/src/internal/useBuildSideEntityController.tsx +8 -1
  93. package/src/locales/de.ts +7 -2
  94. package/src/locales/en.ts +7 -2
  95. package/src/locales/es.ts +7 -2
  96. package/src/locales/fr.ts +7 -2
  97. package/src/locales/hi.ts +7 -2
  98. package/src/locales/it.ts +7 -2
  99. package/src/locales/pl.ts +735 -0
  100. package/src/locales/pt.ts +7 -2
  101. package/src/preview/PropertyPreview.tsx +12 -0
  102. package/src/preview/components/StorageThumbnail.tsx +24 -2
  103. package/src/preview/index.ts +1 -0
  104. package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
  105. package/src/routes/FireCMSRoute.tsx +47 -25
  106. package/src/types/collections.ts +26 -0
  107. package/src/types/datasource.ts +59 -4
  108. package/src/types/entities.ts +24 -1
  109. package/src/types/properties.ts +17 -0
  110. package/src/types/side_entity_controller.tsx +6 -0
  111. package/src/types/translations.ts +6 -0
  112. package/src/util/__tests__/collections.test.ts +32 -0
  113. package/src/util/__tests__/urls.test.ts +131 -0
  114. package/src/util/collections.ts +1 -1
  115. package/src/util/entities.ts +16 -1
  116. package/src/util/geopoint.ts +81 -0
  117. package/src/util/index.ts +2 -0
  118. package/src/util/navigation_blocking.ts +45 -0
  119. package/src/util/navigation_from_path.ts +41 -6
  120. package/src/util/navigation_utils.ts +40 -2
  121. package/src/util/parent_references_from_path.ts +14 -4
  122. package/src/util/previews.ts +8 -1
  123. package/src/util/resolutions.ts +8 -0
  124. package/src/util/urls.ts +52 -0
@@ -45,6 +45,8 @@ export interface BoardDataController<M extends Record<string, any> = any, COLUMN
45
45
  export interface UseBoardDataControllerProps<M extends Record<string, any> = any> {
46
46
  /** Full path to the collection */
47
47
  fullPath: string;
48
+ /** `fullPath` split at its real segment boundaries; forwarded to the datasource. */
49
+ pathSegments?: string[];
48
50
  /** The entity collection configuration */
49
51
  collection: EntityCollection<M>;
50
52
  /** Property key used for column assignment */
@@ -67,6 +69,7 @@ export interface UseBoardDataControllerProps<M extends Record<string, any> = any
67
69
  */
68
70
  export function useBoardDataController<M extends Record<string, any> = any, COLUMN extends string = string>({
69
71
  fullPath,
72
+ pathSegments,
70
73
  collection,
71
74
  columnProperty,
72
75
  columns,
@@ -80,6 +83,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
80
83
  const dataSource = useDataSource(collection);
81
84
  const navigation = useNavigationController();
82
85
  const resolvedPath = useMemo(() => navigation.resolveIdsFrom(fullPath), [fullPath, navigation.resolveIdsFrom]);
86
+ const resolvedPathSegments = pathSegments;
83
87
 
84
88
  // Stable refs for objects that shouldn't trigger re-subscriptions
85
89
  const dataSourceRef = useRef(dataSource);
@@ -95,11 +99,13 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
95
99
  const orderPropertyRef = useRef(orderProperty);
96
100
  const searchStringRef = useRef(searchString);
97
101
  const resolvedPathRef = useRef(resolvedPath);
102
+ const resolvedPathSegmentsRef = useRef(resolvedPathSegments);
98
103
  filterValuesRef.current = filterValues;
99
104
  columnPropertyRef.current = columnProperty;
100
105
  orderPropertyRef.current = orderProperty;
101
106
  searchStringRef.current = searchString;
102
107
  resolvedPathRef.current = resolvedPath;
108
+ resolvedPathSegmentsRef.current = resolvedPathSegments;
103
109
 
104
110
  // Track item count per column for pagination
105
111
  const [columnItemCounts, setColumnItemCounts] = useState<Record<string, number>>(() => {
@@ -168,6 +174,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
168
174
  const currentOrderProperty = orderPropertyRef.current;
169
175
  const currentSearchString = searchStringRef.current;
170
176
  const currentResolvedPath = resolvedPathRef.current;
177
+ const currentPathSegments = resolvedPathSegmentsRef.current;
171
178
 
172
179
  // Build filter for this column
173
180
  const columnFilter: FilterValues<string> = {
@@ -248,6 +255,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
248
255
  if (currentDataSource.listenCollection) {
249
256
  const unsubscribe = currentDataSource.listenCollection<M>({
250
257
  path: currentResolvedPath,
258
+ pathSegments: currentPathSegments,
251
259
  collection: currentCollection,
252
260
  onUpdate,
253
261
  onError,
@@ -262,6 +270,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
262
270
  } else {
263
271
  currentDataSource.fetchCollection<M>({
264
272
  path: currentResolvedPath,
273
+ pathSegments: currentPathSegments,
265
274
  collection: currentCollection,
266
275
  searchString: currentSearchString,
267
276
  filter: columnFilter,
@@ -297,6 +306,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
297
306
  const currentColumnProperty = columnPropertyRef.current;
298
307
  const currentSearchString = searchStringRef.current;
299
308
  const currentResolvedPath = resolvedPathRef.current;
309
+ const currentPathSegments = resolvedPathSegmentsRef.current;
300
310
  const currentColumns = columns;
301
311
  const currentColumnItemCounts = columnItemCounts;
302
312
 
@@ -316,6 +326,7 @@ export function useBoardDataController<M extends Record<string, any> = any, COLU
316
326
  };
317
327
  currentDataSource.countEntities({
318
328
  path: currentResolvedPath,
329
+ pathSegments: currentPathSegments,
319
330
  collection: currentCollection,
320
331
  filter: columnFilter,
321
332
  searchString: currentSearchString
@@ -200,43 +200,44 @@ export type EntityPreviewContainerProps = {
200
200
  onClick?: (e: React.SyntheticEvent) => void;
201
201
  };
202
202
 
203
- export const EntityPreviewContainer = React.forwardRef<HTMLDivElement, EntityPreviewContainerProps>(({
204
- children,
205
- hover,
206
- onClick,
207
- size = "medium",
208
- style,
209
- className,
210
- fullwidth = true,
211
- ...props
212
- }, ref) => {
213
- return <div
214
- ref={ref}
215
- style={{
216
- ...style,
217
- // @ts-ignore
218
- tabindex: 0
219
- }}
220
- className={cls(
221
- "bg-white dark:bg-surface-900",
222
- "min-h-[44px]",
223
- fullwidth ? "w-full" : "",
224
- "items-center",
225
- hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
226
- size === "small" ? "p-1" : "px-2 py-1",
227
- "flex border rounded-lg",
228
- onClick ? "cursor-pointer" : "",
229
- defaultBorderMixin,
230
- className)}
231
- onClick={(event) => {
232
- if (onClick) {
233
- event.preventDefault();
234
- onClick(event);
235
- }
236
- }}
237
- {...props}>
238
- {children}
239
- </div>;
240
- });
241
-
242
- EntityPreviewContainer.displayName = "EntityPreviewContainer";
203
+ export function EntityPreviewContainer({
204
+ children,
205
+ hover,
206
+ onClick,
207
+ size = "medium",
208
+ style,
209
+ className,
210
+ fullwidth = true,
211
+ ref
212
+ }: EntityPreviewContainerProps & { ref?: React.Ref<HTMLDivElement> }) {
213
+ const divClassName = cls(
214
+ "bg-white dark:bg-surface-900",
215
+ "min-h-[44px]",
216
+ fullwidth ? "w-full" : "",
217
+ "items-center",
218
+ hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
219
+ size === "small" ? "p-1" : "px-2 py-1",
220
+ "flex border rounded-lg",
221
+ onClick ? "cursor-pointer" : "",
222
+ defaultBorderMixin,
223
+ className
224
+ );
225
+
226
+ const handleClick = onClick
227
+ ? (event: React.MouseEvent<HTMLDivElement>) => {
228
+ event.preventDefault();
229
+ onClick(event);
230
+ }
231
+ : undefined;
232
+
233
+ const divProps: React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement> } = {
234
+ ref,
235
+ tabIndex: 0,
236
+ style,
237
+ className: divClassName,
238
+ onClick: handleClick,
239
+ };
240
+
241
+ return <div {...divProps}>{children}</div>;
242
+ }
243
+
@@ -44,6 +44,15 @@ export interface ReferenceSelectionInnerProps<M extends Record<string, any>> {
44
44
  * dynamic. If not set, the dialog won't open.
45
45
  */
46
46
  path: string;
47
+ /**
48
+ * `path` split at its real segment boundaries, when the caller knows them.
49
+ *
50
+ * Left undefined by default and deliberately NOT derived from `path`: a reference
51
+ * path comes from a property's configuration, so there is no entity chain to recover
52
+ * the boundaries from. Splitting it would silently produce the wrong segments for a
53
+ * subcollection under a slash-bearing parent id, which is worse than not answering.
54
+ */
55
+ pathSegments?: string[];
47
56
 
48
57
  /**
49
58
  * If you are opening the dialog for the first time, you can select some
@@ -96,6 +105,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
96
105
  multiselect,
97
106
  collection,
98
107
  path: pathInput,
108
+ pathSegments,
99
109
  selectedEntityIds: selectedEntityIdsProp,
100
110
  description,
101
111
  forceFilter,
@@ -153,6 +163,7 @@ export function ReferenceSelectionTable<M extends Record<string, any>>(
153
163
  selectedEntityIds.map((entityId) =>
154
164
  dataSource.fetchEntity({
155
165
  path: fullPath,
166
+ pathSegments,
156
167
  entityId,
157
168
  collection
158
169
  })))
@@ -73,7 +73,7 @@ export function ReferenceWidget<M extends Record<string, any>>({
73
73
  if (disabled)
74
74
  return;
75
75
  if (onMultipleReferenceSelected) {
76
- const references = entities ? entities.map(e => getReferenceFrom(e)) : null;
76
+ const references = entities ? entities.filter(Boolean).map(e => getReferenceFrom(e)) : null;
77
77
  onMultipleReferenceSelected({
78
78
  references,
79
79
  entities
@@ -113,11 +113,11 @@ export function ReferenceFilterField({
113
113
  }, [path]);
114
114
 
115
115
  const onSingleEntitySelected = (entity: Entity<any>) => {
116
- updateFilter(operation, getReferenceFrom(entity));
116
+ if (entity) updateFilter(operation, getReferenceFrom(entity));
117
117
  };
118
118
 
119
119
  const onMultipleEntitiesSelected = (entities: Entity<any>[]) => {
120
- updateFilter(operation, entities.map(e => getReferenceFrom(e)));
120
+ updateFilter(operation, entities.filter(Boolean).map(e => getReferenceFrom(e)));
121
121
  };
122
122
 
123
123
  const multiple = multipleSelectOperations.includes(operation);
@@ -11,6 +11,7 @@ import {
11
11
  TextField
12
12
  } from "@firecms/ui";
13
13
  import { EnumValueConfig } from "../../../types";
14
+ import { filterValueToText, parseFilterTextInput } from "./filter_text_input";
14
15
 
15
16
  interface StringNumberFilterFieldProps {
16
17
  name: string,
@@ -60,8 +61,15 @@ export function StringNumberFilterField({
60
61
  const [fieldOperation, fieldValue] = value || [possibleOperations[0], undefined];
61
62
  const [operation, setOperation] = useState<VirtualTableWhereFilterOp | "is-null">(fieldOperation === "==" && fieldValue === null ? "is-null" : fieldOperation);
62
63
  const [internalValue, setInternalValue] = useState<string | number | string[] | number[] | null | undefined>(fieldValue);
64
+ // Raw text of the text input, kept apart from `internalValue` so that intermediate
65
+ // inputs like "-", "0." or ".0" are not destroyed while the user is typing.
66
+ const [inputText, setInputText] = useState<string>(filterValueToText(fieldValue));
67
+ // Value the current `inputText` was built from, used to tell updates coming from
68
+ // outside apart from the ones triggered by the user typing.
69
+ const syncedValueRef = React.useRef(fieldValue);
63
70
 
64
71
  React.useEffect(() => {
72
+ const newValue = value ? value[1] : undefined;
65
73
  if (value) {
66
74
  const [op, val] = value;
67
75
  setOperation(op === "==" && val === null ? "is-null" : op);
@@ -70,15 +78,25 @@ export function StringNumberFilterField({
70
78
  setOperation(possibleOperations[0]);
71
79
  setInternalValue(undefined);
72
80
  }
81
+ // only reset the text when the value was changed somewhere else, so we don't
82
+ // interfere with what the user is currently typing
83
+ if (syncedValueRef.current !== newValue) {
84
+ syncedValueRef.current = newValue;
85
+ setInputText(filterValueToText(newValue));
86
+ }
73
87
  }, [value, possibleOperations[0]]);
74
88
 
75
89
  const isNullOperation = operation === "is-null";
76
90
 
77
- function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined) {
91
+ // `keepInputText` is used when the update originates from the text input, where the
92
+ // text typed by the user is authoritative and must not be overwritten with `val`
93
+ function updateFilter(op: VirtualTableWhereFilterOp | "is-null", val: string | number | string[] | number[] | null | undefined, keepInputText = false) {
78
94
  // Handle "is null" operation
79
95
  if (op === "is-null") {
80
96
  setOperation(op);
81
97
  setInternalValue(null);
98
+ syncedValueRef.current = null;
99
+ setInputText("");
82
100
  setValue(["==", null]);
83
101
  return;
84
102
  }
@@ -96,6 +114,9 @@ export function StringNumberFilterField({
96
114
 
97
115
  setOperation(op);
98
116
  setInternalValue(newValue);
117
+ syncedValueRef.current = newValue;
118
+ if (!keepInputText)
119
+ setInputText(filterValueToText(newValue));
99
120
 
100
121
  const hasNewValue = newValue !== null && Array.isArray(newValue)
101
122
  ? newValue.length > 0
@@ -111,6 +132,14 @@ export function StringNumberFilterField({
111
132
  }
112
133
  }
113
134
 
135
+ function updateFilterFromText(text: string) {
136
+ setInputText(text);
137
+ const { updateValue, value: parsedValue } = parseFilterTextInput(text, dataType);
138
+ // intermediate inputs like "-" or "0." keep the filter as it is
139
+ if (updateValue)
140
+ updateFilter(operation, parsedValue, true);
141
+ }
142
+
114
143
  const multiple = multipleSelectOperations.includes(operation);
115
144
 
116
145
  return (
@@ -138,16 +167,13 @@ export function StringNumberFilterField({
138
167
  {!enumValues && <TextField
139
168
  size={"medium"}
140
169
  type={dataType === "number" ? "number" : undefined}
141
- value={internalValue !== undefined && internalValue != null ? String(internalValue) : ""}
170
+ value={inputText}
142
171
  disabled={isNullOperation}
143
172
  placeholder={isNullOperation ? "null" : undefined}
144
173
  onChange={(evt) => {
145
- const val = dataType === "number"
146
- ? parseFloat(evt.target.value)
147
- : evt.target.value;
148
- updateFilter(operation, val);
174
+ updateFilterFromText(evt.target.value);
149
175
  }}
150
- endAdornment={internalValue !== undefined && internalValue != null && <IconButton
176
+ endAdornment={inputText !== "" && <IconButton
151
177
  onClick={(e) => updateFilter(operation, undefined)}>
152
178
  <CloseIcon />
153
179
  </IconButton>}
@@ -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