@firecms/core 3.3.0 → 3.4.0-canary.0ef7442
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/useApp.d.ts +1 -0
- package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
- package/dist/components/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
- package/dist/components/EntityCollectionTable/internal/popup_field/PopupFormField.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionBoardView.d.ts +3 -1
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +7 -0
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
- package/dist/components/EntityCollectionView/useBoardDataController.d.ts +3 -1
- package/dist/components/EntityPreview.d.ts +3 -1
- package/dist/components/ReferenceTable/ReferenceSelectionTable.d.ts +10 -1
- package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
- package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
- package/dist/components/common/table_url_params.d.ts +24 -0
- package/dist/components/common/useDataSourceTableController.d.ts +7 -1
- package/dist/components/common/useTableSearchHelper.d.ts +7 -1
- package/dist/core/DefaultDrawer.d.ts +10 -3
- package/dist/core/EntityEditView.d.ts +7 -1
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/EntityForm.d.ts +6 -1
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
- package/dist/form/index.d.ts +1 -0
- package/dist/hooks/data/save.d.ts +1 -1
- package/dist/hooks/data/useCollectionFetch.d.ts +7 -1
- package/dist/hooks/data/useEntityFetch.d.ts +6 -1
- package/dist/index.es.js +2915 -1330
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2913 -1328
- package/dist/index.umd.js.map +1 -1
- package/dist/locales/pl.d.ts +2 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
- package/dist/types/collections.d.ts +24 -0
- package/dist/types/datasource.d.ts +59 -4
- package/dist/types/entities.d.ts +21 -1
- package/dist/types/properties.d.ts +17 -1
- package/dist/types/side_entity_controller.d.ts +6 -0
- package/dist/types/translations.d.ts +6 -0
- package/dist/util/__tests__/collections.test.d.ts +1 -0
- package/dist/util/__tests__/urls.test.d.ts +1 -0
- package/dist/util/collections.d.ts +1 -1
- package/dist/util/entities.d.ts +1 -0
- package/dist/util/geopoint.d.ts +22 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/navigation_blocking.d.ts +22 -0
- package/dist/util/navigation_from_path.d.ts +17 -0
- package/dist/util/navigation_utils.d.ts +23 -1
- package/dist/util/parent_references_from_path.d.ts +1 -0
- package/dist/util/urls.d.ts +22 -0
- package/package.json +17 -10
- package/src/app/Scaffold.tsx +34 -44
- package/src/app/useApp.tsx +1 -0
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +3 -1
- package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
- package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
- package/src/components/EntityCollectionTable/internal/popup_field/PopupFormField.tsx +5 -1
- package/src/components/EntityCollectionView/EntityCollectionBoardView.tsx +4 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +29 -3
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
- package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
- package/src/components/EntityCollectionView/useBoardDataController.tsx +11 -0
- package/src/components/EntityPreview.tsx +41 -40
- package/src/components/ReferenceTable/ReferenceSelectionTable.tsx +11 -0
- package/src/components/ReferenceWidget.tsx +1 -1
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
- package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
- package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
- package/src/components/VirtualTable/VirtualTable.tsx +19 -8
- package/src/components/common/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +104 -159
- package/src/components/common/useTableSearchHelper.ts +21 -2
- package/src/contexts/SnackbarProvider.tsx +14 -1
- package/src/core/DefaultDrawer.tsx +150 -64
- package/src/core/DrawerNavigationGroup.tsx +27 -29
- package/src/core/DrawerNavigationItem.tsx +10 -12
- package/src/core/EntityEditView.tsx +78 -32
- package/src/core/EntitySidePanel.tsx +2 -0
- package/src/core/field_configs.tsx +15 -0
- package/src/form/EntityForm.tsx +9 -3
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
- package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
- package/src/form/index.tsx +1 -0
- package/src/hooks/data/delete.ts +2 -0
- package/src/hooks/data/save.ts +2 -0
- package/src/hooks/data/useCollectionFetch.tsx +13 -0
- package/src/hooks/data/useEntityFetch.tsx +18 -0
- package/src/hooks/useResolvedNavigationFrom.tsx +2 -0
- package/src/hooks/useValidateAuthenticator.tsx +4 -0
- package/src/i18n/FireCMSi18nProvider.tsx +2 -0
- package/src/internal/useBuildDataSource.ts +25 -4
- package/src/internal/useBuildSideEntityController.tsx +8 -1
- package/src/locales/de.ts +7 -2
- package/src/locales/en.ts +7 -2
- package/src/locales/es.ts +7 -2
- package/src/locales/fr.ts +7 -2
- package/src/locales/hi.ts +7 -2
- package/src/locales/it.ts +7 -2
- package/src/locales/pl.ts +735 -0
- package/src/locales/pt.ts +7 -2
- package/src/preview/PropertyPreview.tsx +12 -0
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
- package/src/routes/FireCMSRoute.tsx +47 -25
- package/src/types/collections.ts +26 -0
- package/src/types/datasource.ts +59 -4
- package/src/types/entities.ts +24 -1
- package/src/types/properties.ts +17 -0
- package/src/types/side_entity_controller.tsx +6 -0
- package/src/types/translations.ts +6 -0
- package/src/util/__tests__/collections.test.ts +32 -0
- package/src/util/__tests__/urls.test.ts +131 -0
- package/src/util/collections.ts +1 -1
- package/src/util/entities.ts +16 -1
- package/src/util/geopoint.ts +81 -0
- package/src/util/index.ts +2 -0
- package/src/util/navigation_blocking.ts +45 -0
- package/src/util/navigation_from_path.ts +41 -6
- package/src/util/navigation_utils.ts +40 -2
- package/src/util/parent_references_from_path.ts +14 -4
- package/src/util/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- package/src/util/urls.ts +52 -0
|
@@ -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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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={
|
|
170
|
+
value={inputText}
|
|
142
171
|
disabled={isNullOperation}
|
|
143
172
|
placeholder={isNullOperation ? "null" : undefined}
|
|
144
173
|
onChange={(evt) => {
|
|
145
|
-
|
|
146
|
-
? parseFloat(evt.target.value)
|
|
147
|
-
: evt.target.value;
|
|
148
|
-
updateFilter(operation, val);
|
|
174
|
+
updateFilterFromText(evt.target.value);
|
|
149
175
|
}}
|
|
150
|
-
endAdornment={
|
|
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
|
|
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
|
|