@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
- package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
- package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
- package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-BJylABq3.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
- package/dist/RouterCollectionsStudioView-CwTpdzWF.js.map +1 -0
- package/dist/collection_editor/serializable_types.d.ts +1 -0
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/ClearFilterSortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
- package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
- package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
- package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
- package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
- package/dist/components/DetailViewBinding.d.ts +7 -0
- package/dist/components/DrawerNavigationGroup.d.ts +4 -2
- package/dist/components/DrawerNavigationItem.d.ts +13 -5
- package/dist/components/EditViewBinding.d.ts +22 -7
- package/dist/components/EntityIdentityBar.d.ts +24 -1
- package/dist/components/RelationSelector.d.ts +13 -0
- package/dist/data_export/export/export.d.ts +7 -0
- package/dist/data_export/export/fetch_export_data.d.ts +48 -0
- package/dist/data_export/export/index.d.ts +1 -0
- package/dist/data_import/utils/csv.d.ts +30 -0
- package/dist/data_import/utils/index.d.ts +2 -0
- package/dist/data_import/utils/save_entities.d.ts +38 -0
- package/dist/data_import/utils/transforms.d.ts +9 -0
- package/dist/editor.js +3 -0
- package/dist/editor.js.map +1 -1
- package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
- package/dist/export-L53WektO.js.map +1 -0
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
- package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
- package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
- package/dist/import-C_4edkoD.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +96 -80
- package/dist/index.js.map +1 -1
- package/dist/preview/components/date_fns_locales.d.ts +9 -0
- package/dist/util/view_constants.d.ts +24 -0
- package/dist/util/view_mode.d.ts +18 -0
- package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
- package/dist/util-C-D5yD2n.js.map +1 -0
- package/package.json +10 -9
- package/src/collection_editor/serializable_types.ts +1 -0
- package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
- package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
- package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
- package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
- package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
- package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
- package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
- package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
- package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
- package/src/components/ClearFilterSortButton.tsx +16 -7
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
- package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
- package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
- package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
- package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
- package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
- package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
- package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
- package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
- package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
- package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
- package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
- package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +7 -0
- package/src/components/DetailViewBinding.tsx +33 -9
- package/src/components/DrawerNavigationGroup.tsx +12 -32
- package/src/components/DrawerNavigationItem.tsx +33 -12
- package/src/components/EditViewBinding.tsx +175 -28
- package/src/components/EntityIdentityBar.tsx +109 -17
- package/src/components/EntityInspector.tsx +24 -6
- package/src/components/EntityViewBinding.tsx +4 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +61 -11
- package/src/components/SearchIconsView.tsx +14 -33
- package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
- package/src/components/SidePanelBinding.tsx +36 -25
- package/src/components/UserSelector.tsx +52 -9
- package/src/components/history/LastEditedByIndicator.tsx +7 -12
- package/src/data_export/export/ExportCollectionAction.tsx +101 -48
- package/src/data_export/export/export.ts +63 -16
- package/src/data_export/export/fetch_export_data.ts +90 -0
- package/src/data_export/export/index.ts +1 -0
- package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
- package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
- package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
- package/src/data_import/import/ImportCollectionAction.tsx +13 -2
- package/src/data_import/utils/csv.ts +153 -0
- package/src/data_import/utils/data.ts +16 -2
- package/src/data_import/utils/file_to_json.ts +77 -6
- package/src/data_import/utils/index.ts +2 -0
- package/src/data_import/utils/save_entities.ts +138 -0
- package/src/data_import/utils/transforms.ts +23 -0
- package/src/editor/components/image-bubble.tsx +2 -0
- package/src/editor/selectors/link-selector.tsx +1 -0
- package/src/form/EntityForm.tsx +12 -3
- package/src/form/EntityFormBinding.tsx +8 -12
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
- package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
- package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
- package/src/form/form_utils.ts +78 -0
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +7 -3
- package/src/preview/components/DatePreview.tsx +15 -2
- package/src/preview/components/ReferencePreview.tsx +5 -8
- package/src/preview/components/RelationPreview.tsx +8 -7
- package/src/preview/components/date_fns_locales.ts +125 -0
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/util/navigation_utils.ts +4 -2
- package/src/util/property_utils.tsx +6 -1
- package/src/util/view_constants.ts +27 -0
- package/src/util/view_mode.ts +36 -0
- package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
- package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
- package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
- package/dist/export-C-XG3aRh.js.map +0 -1
- package/dist/import-B0GyllB0.js.map +0 -1
- package/dist/util-DHNXodBo.js.map +0 -1
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
PopoverPrimitive,
|
|
15
15
|
SearchIcon,
|
|
16
16
|
Separator,
|
|
17
|
-
|
|
17
|
+
usePortalContainer,
|
|
18
18
|
XIcon
|
|
19
19
|
} from "@rebasepro/ui";
|
|
20
20
|
import * as React from "react";
|
|
@@ -23,6 +23,7 @@ import { Command as CommandPrimitive } from "cmdk";
|
|
|
23
23
|
import { User } from "@rebasepro/types";
|
|
24
24
|
import { apiBaseOf, useRebaseClient, useAuthController, UserDisplay } from "@rebasepro/app";
|
|
25
25
|
import { EmptyValue } from "../preview";
|
|
26
|
+
import { useResolvedUser } from "../hooks/useResolvedUsers";
|
|
26
27
|
|
|
27
28
|
interface UserSelectorItem {
|
|
28
29
|
uid: string;
|
|
@@ -46,10 +47,19 @@ function useUserSelector({ pageSize = DEFAULT_PAGE_SIZE }: { pageSize?: number }
|
|
|
46
47
|
const [currentSearch, setCurrentSearch] = useState("");
|
|
47
48
|
const offsetRef = useRef(0);
|
|
48
49
|
const userCacheRef = useRef<Map<string, User>>(new Map());
|
|
50
|
+
// Stamped by every request; only the newest one is allowed to write state.
|
|
51
|
+
// Typing "alice" issues one request per keystroke and nothing cancels them,
|
|
52
|
+
// so the response for "al" landing after the response for "alice" replaced
|
|
53
|
+
// the list — and `offsetRef` — with results for a string the user had
|
|
54
|
+
// already moved past. Same shape as `useRelationSelector`.
|
|
55
|
+
const requestIdRef = useRef(0);
|
|
56
|
+
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
49
57
|
|
|
50
58
|
const fetchUsers = useCallback(async (searchStr: string, offset: number, append: boolean) => {
|
|
51
59
|
const apiBase = apiBaseOf(client);
|
|
52
60
|
if (!apiBase) return;
|
|
61
|
+
const requestId = ++requestIdRef.current;
|
|
62
|
+
const superseded = () => requestId !== requestIdRef.current;
|
|
53
63
|
setIsLoading(true);
|
|
54
64
|
try {
|
|
55
65
|
const token = await getAuthToken?.();
|
|
@@ -68,6 +78,7 @@ function useUserSelector({ pageSize = DEFAULT_PAGE_SIZE }: { pageSize?: number }
|
|
|
68
78
|
});
|
|
69
79
|
if (!response.ok) throw new Error("Failed to fetch users");
|
|
70
80
|
const data = await response.json();
|
|
81
|
+
if (superseded()) return;
|
|
71
82
|
const rows: Record<string, unknown>[] = data.data ?? data.users ?? [];
|
|
72
83
|
const newItems: UserSelectorItem[] = rows.map((r: Record<string, unknown>) => {
|
|
73
84
|
const user: User = {
|
|
@@ -88,9 +99,10 @@ user };
|
|
|
88
99
|
setHasMore(newItems.length >= pageSize);
|
|
89
100
|
offsetRef.current = offset + newItems.length;
|
|
90
101
|
} catch {
|
|
102
|
+
if (superseded()) return;
|
|
91
103
|
setHasMore(false);
|
|
92
104
|
} finally {
|
|
93
|
-
setIsLoading(false);
|
|
105
|
+
if (!superseded()) setIsLoading(false);
|
|
94
106
|
}
|
|
95
107
|
}, [client?.baseUrl, client?.apiPath, getAuthToken, pageSize]);
|
|
96
108
|
|
|
@@ -100,11 +112,23 @@ user };
|
|
|
100
112
|
}, [fetchUsers]);
|
|
101
113
|
|
|
102
114
|
const search = useCallback((searchStr: string) => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
115
|
+
// Debounced like the relation picker's type-ahead: a request per
|
|
116
|
+
// keystroke is both wasteful and the thing that makes an out-of-order
|
|
117
|
+
// response likely in the first place.
|
|
118
|
+
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
119
|
+
searchTimeoutRef.current = setTimeout(() => {
|
|
120
|
+
setCurrentSearch(searchStr);
|
|
121
|
+
offsetRef.current = 0;
|
|
122
|
+
fetchUsers(searchStr, 0, false);
|
|
123
|
+
}, searchStr.trim() ? 300 : 0);
|
|
106
124
|
}, [fetchUsers]);
|
|
107
125
|
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
return () => {
|
|
128
|
+
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
|
129
|
+
};
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
108
132
|
const loadMore = useCallback(() => {
|
|
109
133
|
fetchUsers(currentSearch, offsetRef.current, true);
|
|
110
134
|
}, [fetchUsers, currentSearch]);
|
|
@@ -178,6 +202,7 @@ export const UserSelector = React.forwardRef<
|
|
|
178
202
|
|
|
179
203
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
180
204
|
const [searchString, setSearchString] = useState<string>("");
|
|
205
|
+
const contextPortalContainer = usePortalContainer();
|
|
181
206
|
|
|
182
207
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
183
208
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
@@ -199,9 +224,19 @@ export const UserSelector = React.forwardRef<
|
|
|
199
224
|
const selectedUser: User | null = value ? getUser(value) ?? null : null;
|
|
200
225
|
|
|
201
226
|
// If the user isn't in the cache, try to find in available items
|
|
202
|
-
const
|
|
227
|
+
const listedUser: User | null = selectedUser
|
|
203
228
|
?? (value ? availableItems.find(i => i.uid === value)?.user ?? null : null);
|
|
204
229
|
|
|
230
|
+
// Neither the loaded page nor the cache holds it: ask for that uid
|
|
231
|
+
// specifically. The list is one page of ten, so any installation with
|
|
232
|
+
// more users than that rendered an assigned field as *empty* — and an
|
|
233
|
+
// editor who is shown "no value" sets one, overwriting an assignment
|
|
234
|
+
// the UI never admitted was there. Lookups are coalesced and cached
|
|
235
|
+
// per API base by `useResolvedUser`, so a table of these costs one
|
|
236
|
+
// request.
|
|
237
|
+
const fetchedUser = useResolvedUser(value && !listedUser ? value : undefined);
|
|
238
|
+
const resolvedUser: User | null = listedUser ?? fetchedUser;
|
|
239
|
+
|
|
205
240
|
// IntersectionObserver for infinite scroll
|
|
206
241
|
const sentinelCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
|
207
242
|
if (observerRef.current) {
|
|
@@ -301,10 +336,10 @@ export const UserSelector = React.forwardRef<
|
|
|
301
336
|
};
|
|
302
337
|
}, [isPopoverOpen]);
|
|
303
338
|
|
|
304
|
-
useInjectStyles("UserSelector", " [cmdk-group] { max-height: 40vh; overflow-y: auto; } ");
|
|
305
|
-
|
|
306
339
|
const resolvedPlaceholder = placeholder || <EmptyValue className={"ml-2"}/>;
|
|
307
|
-
|
|
340
|
+
// See RelationSelector: a modal dialog cancels wheel events outside its
|
|
341
|
+
// own content, so a list portaled to the body opens unscrollable.
|
|
342
|
+
const portalContainer = (contextPortalContainer ?? (typeof document !== "undefined" ? document.body : undefined)) ?? undefined;
|
|
308
343
|
|
|
309
344
|
return (
|
|
310
345
|
<>
|
|
@@ -341,6 +376,14 @@ export const UserSelector = React.forwardRef<
|
|
|
341
376
|
<div className="flex flex-row items-center gap-1 truncate flex-1 min-w-0 mr-2">
|
|
342
377
|
<UserDisplay user={resolvedUser}/>
|
|
343
378
|
</div>
|
|
379
|
+
) : value ? (
|
|
380
|
+
// A uid we could not resolve — deleted, or
|
|
381
|
+
// hidden from this viewer. Show it raw: the
|
|
382
|
+
// placeholder here would claim the field is
|
|
383
|
+
// unset, which is the one thing it is not.
|
|
384
|
+
<span className="text-sm truncate text-text-primary dark:text-text-primary-dark">
|
|
385
|
+
{value}
|
|
386
|
+
</span>
|
|
344
387
|
) : (
|
|
345
388
|
<span className="text-sm text-text-secondary dark:text-text-secondary-dark">
|
|
346
389
|
{resolvedPlaceholder}
|
|
@@ -3,22 +3,17 @@ import React, { useEffect, useState, useCallback } from "react";
|
|
|
3
3
|
;
|
|
4
4
|
import { useApiBase, useApiConfig } from "@rebasepro/app";
|
|
5
5
|
import { useAuthController } from "@rebasepro/app";
|
|
6
|
+
import { formatRelativeTime } from "@rebasepro/utils";
|
|
6
7
|
import { HistoryEntryData } from "../../hooks";
|
|
7
8
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
const now = new Date();
|
|
11
|
-
const diffMs = now.getTime() - date.getTime();
|
|
12
|
-
const diffSeconds = Math.floor(diffMs / 1000);
|
|
13
|
-
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
14
|
-
const diffHours = Math.floor(diffMinutes / 60);
|
|
15
|
-
const diffDays = Math.floor(diffHours / 24);
|
|
10
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return date.toLocaleDateString();
|
|
12
|
+
function getRelativeTimeString(date: Date): string {
|
|
13
|
+
// An edit is in the past, but the timestamp comes from the server and the
|
|
14
|
+
// phrase is rendered against the browser's clock — a few seconds of skew
|
|
15
|
+
// used to be enough to make a fresh edit read as the wrong thing entirely.
|
|
16
|
+
return formatRelativeTime(date, { maxMs: THIRTY_DAYS_MS }) ?? date.toLocaleDateString();
|
|
22
17
|
}
|
|
23
18
|
|
|
24
19
|
/**
|
|
@@ -29,9 +29,26 @@ import {
|
|
|
29
29
|
Tooltip
|
|
30
30
|
} from "@rebasepro/ui";
|
|
31
31
|
import { downloadEntitiesExport } from "./export";
|
|
32
|
+
import { fetchAllEntitiesForExport, MAX_EXPORT_ROWS } from "./fetch_export_data";
|
|
32
33
|
|
|
33
34
|
const DOCS_LIMIT = 500;
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Additional-field builders run per row and may do I/O, so a `Promise.all` over
|
|
38
|
+
* the whole export is one request per row all at once. That was bounded only by
|
|
39
|
+
* the 50-row cap the export used to have; now that the read is paginated, it is
|
|
40
|
+
* bounded here instead.
|
|
41
|
+
*/
|
|
42
|
+
const ADDITIONAL_FIELDS_CONCURRENCY = 50;
|
|
43
|
+
|
|
44
|
+
async function mapInChunks<T, R>(items: T[], fn: (item: T) => Promise<R>, chunkSize = ADDITIONAL_FIELDS_CONCURRENCY): Promise<R[]> {
|
|
45
|
+
const results: R[] = [];
|
|
46
|
+
for (let i = 0; i < items.length; i += chunkSize) {
|
|
47
|
+
results.push(...await Promise.all(items.slice(i, i + chunkSize).map(fn)));
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
export function ExportCollectionAction<M extends Record<string, unknown>, USER extends User>({
|
|
36
53
|
collection,
|
|
37
54
|
path,
|
|
@@ -59,6 +76,11 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
59
76
|
const context = useAdminContext<USER>();
|
|
60
77
|
const dataClient = useData();
|
|
61
78
|
|
|
79
|
+
// Said before the download starts, not discovered halfway through it: the
|
|
80
|
+
// walk refuses rather than writing a short file, so the dialog has to name
|
|
81
|
+
// the ceiling it is about to hit.
|
|
82
|
+
const tooManyToExport = collectionEntitiesCount !== undefined && collectionEntitiesCount > MAX_EXPORT_ROWS;
|
|
83
|
+
|
|
62
84
|
const canExport = !exportAllowed || exportAllowed({
|
|
63
85
|
collectionEntitiesCount: collectionEntitiesCount ?? 0,
|
|
64
86
|
path,
|
|
@@ -67,6 +89,7 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
67
89
|
|
|
68
90
|
const [dataLoading, setDataLoading] = React.useState<boolean>(false);
|
|
69
91
|
const [dataLoadingError, setDataLoadingError] = React.useState<Error | undefined>();
|
|
92
|
+
const [progress, setProgress] = React.useState<{ loaded: number, total?: number }>({ loaded: 0 });
|
|
70
93
|
|
|
71
94
|
const [open, setOpen] = React.useState(false);
|
|
72
95
|
|
|
@@ -84,7 +107,7 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
84
107
|
const additionalFields = collection.additionalFields;
|
|
85
108
|
|
|
86
109
|
const resolvedExportColumnsValues: Record<string, any>[] = additionalExportFields
|
|
87
|
-
? await
|
|
110
|
+
? await mapInChunks(entities, async (entity) => {
|
|
88
111
|
return (await Promise.all(additionalExportFields.map(async (column) => {
|
|
89
112
|
return {
|
|
90
113
|
[column.key]: await column.builder({
|
|
@@ -94,11 +117,11 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
94
117
|
};
|
|
95
118
|
}))).reduce((a, b) => ({ ...a,
|
|
96
119
|
...b }), {});
|
|
97
|
-
})
|
|
120
|
+
})
|
|
98
121
|
: [];
|
|
99
122
|
|
|
100
123
|
const resolvedColumnsValues: Record<string, any>[] = additionalFields
|
|
101
|
-
? await
|
|
124
|
+
? await mapInChunks(entities, async (entity) => {
|
|
102
125
|
return (await Promise.all(additionalFields
|
|
103
126
|
.map(async (field) => {
|
|
104
127
|
if (!field.value)
|
|
@@ -111,64 +134,74 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
111
134
|
};
|
|
112
135
|
}))).reduce((a, b) => ({ ...a,
|
|
113
136
|
...b }), {});
|
|
114
|
-
})
|
|
137
|
+
})
|
|
115
138
|
: [];
|
|
116
139
|
return [...resolvedExportColumnsValues, ...resolvedColumnsValues];
|
|
117
140
|
}, [exportConfig?.additionalFields]);
|
|
118
141
|
|
|
119
142
|
const doDownload = useCallback(async (collection: AdminCollection<M>,
|
|
120
|
-
exportConfig: ExportConfig<any> | undefined) => {
|
|
143
|
+
exportConfig: ExportConfig<any> | undefined): Promise<boolean> => {
|
|
121
144
|
|
|
122
145
|
onAnalyticsEvent?.("export_collection", {
|
|
123
146
|
collection: collection.slug
|
|
124
147
|
});
|
|
125
148
|
setDataLoading(true);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
setDataLoadingError(undefined);
|
|
150
|
+
setProgress({ loaded: 0 });
|
|
151
|
+
try {
|
|
152
|
+
// Paginated, not `find({})`: an absent limit resolves to 50 rows
|
|
153
|
+
// server-side, so the export used to be the first page of the
|
|
154
|
+
// collection under a filename that read like all of it.
|
|
155
|
+
const data = await fetchAllEntitiesForExport<M>({
|
|
156
|
+
accessor: dataClient.collection(path) as { find: (params?: any) => Promise<any> },
|
|
157
|
+
onProgress: (loaded, total) => setProgress({ loaded,
|
|
158
|
+
total })
|
|
159
|
+
});
|
|
160
|
+
const additionalData = await fetchAdditionalFields(data);
|
|
161
|
+
const additionalHeaders = [
|
|
162
|
+
...exportConfig?.additionalFields?.map(column => column.key) ?? [],
|
|
163
|
+
...collection.additionalFields?.map(field => field.key) ?? []
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const defaultValues = includeUndefinedValues ? getDefaultValuesFor(collection.properties) : undefined;
|
|
167
|
+
const dataWithDefaults = defaultValues
|
|
168
|
+
? data.map(entity => ({
|
|
169
|
+
...entity,
|
|
170
|
+
values: { ...defaultValues,
|
|
142
171
|
...entity.values }
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
}))
|
|
173
|
+
: data;
|
|
174
|
+
downloadEntitiesExport({
|
|
175
|
+
data: dataWithDefaults,
|
|
176
|
+
additionalData,
|
|
177
|
+
properties: collection.properties,
|
|
178
|
+
propertiesOrder: collection.propertiesOrder,
|
|
179
|
+
name: collection.name,
|
|
180
|
+
flattenArrays,
|
|
181
|
+
additionalHeaders,
|
|
182
|
+
exportType,
|
|
183
|
+
dateExportType
|
|
184
|
+
});
|
|
185
|
+
onAnalyticsEvent?.("export_collection_success", {
|
|
186
|
+
collection: collection.slug
|
|
187
|
+
});
|
|
188
|
+
return true;
|
|
189
|
+
} catch (e) {
|
|
190
|
+
console.error("Error loading export data", e);
|
|
191
|
+
setDataLoadingError(e as Error);
|
|
192
|
+
return false;
|
|
193
|
+
} finally {
|
|
194
|
+
setDataLoading(false);
|
|
195
|
+
}
|
|
166
196
|
|
|
167
197
|
}, [onAnalyticsEvent, dataClient, path, fetchAdditionalFields, includeUndefinedValues, flattenArrays, exportType, dateExportType]);
|
|
168
198
|
|
|
169
199
|
const onOkClicked = useCallback(() => {
|
|
170
|
-
|
|
171
|
-
|
|
200
|
+
// The dialog stays open until the walk finishes: it is the only place a
|
|
201
|
+
// multi-page export reports progress, or reports that it failed.
|
|
202
|
+
doDownload(collection, exportConfig).then((downloaded) => {
|
|
203
|
+
if (downloaded) handleClose();
|
|
204
|
+
});
|
|
172
205
|
}, [doDownload, collection, exportConfig, handleClose]);
|
|
173
206
|
|
|
174
207
|
return <>
|
|
@@ -194,13 +227,23 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
194
227
|
|
|
195
228
|
<div>{t("download_table_csv")}</div>
|
|
196
229
|
|
|
197
|
-
{collectionEntitiesCount !== undefined && collectionEntitiesCount > DOCS_LIMIT &&
|
|
230
|
+
{collectionEntitiesCount !== undefined && collectionEntitiesCount > DOCS_LIMIT && !tooManyToExport &&
|
|
198
231
|
<Alert color={"warning"}>
|
|
199
232
|
<div>
|
|
200
233
|
{t("large_number_of_documents", { count: collectionEntitiesCount.toString() })}
|
|
201
234
|
</div>
|
|
202
235
|
</Alert>}
|
|
203
236
|
|
|
237
|
+
{tooManyToExport &&
|
|
238
|
+
<Alert color={"error"}>
|
|
239
|
+
<div>
|
|
240
|
+
{t("too_many_documents_to_export", {
|
|
241
|
+
count: (collectionEntitiesCount ?? 0).toString(),
|
|
242
|
+
limit: MAX_EXPORT_ROWS.toString()
|
|
243
|
+
})}
|
|
244
|
+
</div>
|
|
245
|
+
</Alert>}
|
|
246
|
+
|
|
204
247
|
<div className={"flex flex-row gap-4"}>
|
|
205
248
|
<div className={"p-4 flex flex-col"}>
|
|
206
249
|
<RadioGroup value={exportType} onValueChange={(v) => setExportType(v as "csv" | "json")}>
|
|
@@ -242,6 +285,10 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
242
285
|
onValueChange={setIncludeUndefinedValues}
|
|
243
286
|
label={t("include_undefined_values")}/>
|
|
244
287
|
|
|
288
|
+
{dataLoadingError && <Alert color={"error"}>
|
|
289
|
+
<div>{dataLoadingError.message}</div>
|
|
290
|
+
</Alert>}
|
|
291
|
+
|
|
245
292
|
{!canExport && notAllowedView}
|
|
246
293
|
|
|
247
294
|
</DialogContent>
|
|
@@ -250,13 +297,19 @@ export function ExportCollectionAction<M extends Record<string, unknown>, USER e
|
|
|
250
297
|
|
|
251
298
|
{dataLoading && <CircularProgress size={"smallest"}/>}
|
|
252
299
|
|
|
300
|
+
{dataLoading && <Label>
|
|
301
|
+
{progress.total !== undefined
|
|
302
|
+
? `${progress.loaded} / ${progress.total}`
|
|
303
|
+
: `${progress.loaded}`}
|
|
304
|
+
</Label>}
|
|
305
|
+
|
|
253
306
|
<Button onClick={handleClose}
|
|
254
307
|
variant={"text"}>
|
|
255
308
|
{t("cancel")}
|
|
256
309
|
</Button>
|
|
257
310
|
|
|
258
311
|
<Button onClick={onOkClicked}
|
|
259
|
-
disabled={dataLoading || !canExport}>
|
|
312
|
+
disabled={dataLoading || !canExport || tooManyToExport}>
|
|
260
313
|
{t("download")}
|
|
261
314
|
</Button>
|
|
262
315
|
|
|
@@ -154,6 +154,24 @@ function getHeaders(property: Property, propertyKey: string, prefix = ""): Heade
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Read whatever a `date` column actually arrived as into a `Date`.
|
|
159
|
+
*
|
|
160
|
+
* The driver/WebSocket path revives `{ __type: "date" }` into a `Date`; the REST
|
|
161
|
+
* path — which is the one the admin is on — leaves an ISO string, and an epoch
|
|
162
|
+
* number is what a re-imported export carries. Returns `undefined` for anything
|
|
163
|
+
* that is not a date, so the caller can pass the raw value through.
|
|
164
|
+
*/
|
|
165
|
+
function toExportableDate(inputValue: unknown): Date | undefined {
|
|
166
|
+
if (inputValue instanceof Date) return Number.isNaN(inputValue.getTime()) ? undefined : inputValue;
|
|
167
|
+
if (typeof inputValue === "number" && Number.isFinite(inputValue)) return new Date(inputValue);
|
|
168
|
+
if (typeof inputValue === "string" && inputValue.trim() !== "") {
|
|
169
|
+
const parsed = new Date(inputValue);
|
|
170
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
157
175
|
function processValueForExport(inputValue: unknown,
|
|
158
176
|
property: Property,
|
|
159
177
|
exportType: "csv" | "json",
|
|
@@ -181,8 +199,17 @@ function processValueForExport(inputValue: unknown,
|
|
|
181
199
|
} else if (property.type === "reference" && inputValue && typeof inputValue === "object" && "isEntityReference" in inputValue && typeof (inputValue as EntityReference).isEntityReference === "function" && (inputValue as EntityReference).isEntityReference()) {
|
|
182
200
|
const ref = inputValue ? inputValue as EntityReference : undefined;
|
|
183
201
|
value = ref ? ref.fullPath : null;
|
|
184
|
-
} else if (property.type === "date"
|
|
185
|
-
|
|
202
|
+
} else if (property.type === "date") {
|
|
203
|
+
// Not `instanceof Date`: the admin reads over REST, and the REST row
|
|
204
|
+
// pipeline hands dates back as the database wrote them — a string. That
|
|
205
|
+
// made this branch dead on every live export, so both radio options
|
|
206
|
+
// produced identical files. Normalise here, where the declared type says
|
|
207
|
+
// the column is a date, and leave anything unparseable untouched rather
|
|
208
|
+
// than exporting an invalid date as `null`.
|
|
209
|
+
const date = toExportableDate(inputValue);
|
|
210
|
+
value = date
|
|
211
|
+
? (dateExportType === "timestamp" ? date.getTime() : date.toISOString())
|
|
212
|
+
: inputValue;
|
|
186
213
|
} else {
|
|
187
214
|
value = inputValue;
|
|
188
215
|
}
|
|
@@ -209,15 +236,40 @@ function processValuesForExport<M extends Record<string, unknown>>
|
|
|
209
236
|
...updatedValues };
|
|
210
237
|
}
|
|
211
238
|
|
|
212
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Characters that make a spreadsheet read a cell as a formula rather than text.
|
|
241
|
+
*
|
|
242
|
+
* Quoting is CSV *escaping* and is not enough: Excel, LibreOffice Calc and
|
|
243
|
+
* Sheets strip the CSV quoting at parse time and then evaluate a cell whose
|
|
244
|
+
* first character is one of these — `=HYPERLINK(…)` exfiltrates neighbouring
|
|
245
|
+
* cells on click, `=cmd|'…'!A0` is worse.
|
|
246
|
+
*/
|
|
247
|
+
const FORMULA_TRIGGERS = ["=", "+", "-", "@", "\t", "\r"];
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Neutralise a cell that a spreadsheet would otherwise evaluate, by prefixing a
|
|
251
|
+
* single quote — the standard OWASP mitigation. The apostrophe is consumed by
|
|
252
|
+
* the spreadsheet as "treat the rest as text", so the value reads back intact.
|
|
253
|
+
*/
|
|
254
|
+
export function escapeCsvFormula(value: string): string {
|
|
255
|
+
const first = value.charAt(0);
|
|
256
|
+
if (!FORMULA_TRIGGERS.includes(first)) return value;
|
|
257
|
+
// A signed number is not a formula, and prefixing it would turn every
|
|
258
|
+
// negative amount in the file into text. `-1+1` is still neutralised,
|
|
259
|
+
// because it is not a number.
|
|
260
|
+
if ((first === "-" || first === "+") && value.trim() !== "" && Number.isFinite(Number(value))) return value;
|
|
261
|
+
return "'" + value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function toCSVCell(v: unknown): string {
|
|
265
|
+
if (v === null || v === undefined) return "";
|
|
266
|
+
const s = Array.isArray(v) ? JSON.stringify(v) : String(v);
|
|
267
|
+
return "\"" + escapeCsvFormula(s).replaceAll("\"", "\"\"") + "\"";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function entryToCSVRow(entry: unknown[]) {
|
|
213
271
|
return entry
|
|
214
|
-
.map(
|
|
215
|
-
if (v === null || v === undefined) return "";
|
|
216
|
-
if (Array.isArray(v))
|
|
217
|
-
return "\"" + JSON.stringify(v).replaceAll("\"", "\"\"") + "\"";
|
|
218
|
-
const s = String(v);
|
|
219
|
-
return "\"" + s.replaceAll("\"", "\"\"") + "\"";
|
|
220
|
-
})
|
|
272
|
+
.map(toCSVCell)
|
|
221
273
|
.join(",") + "\r\n";
|
|
222
274
|
}
|
|
223
275
|
|
|
@@ -236,12 +288,7 @@ export function downloadDataAsCsv(data: object[], name: string) {
|
|
|
236
288
|
const headers = Object.keys(data[0]);
|
|
237
289
|
const csvContent = [
|
|
238
290
|
headers.join(","),
|
|
239
|
-
...data.map(row => headers.map(header =>
|
|
240
|
-
const value = (row as Record<string, unknown>)[header];
|
|
241
|
-
if (value === null || value === undefined) return "";
|
|
242
|
-
if (Array.isArray(value)) return `"${JSON.stringify(value).replace(/"/g, '""')}"`;
|
|
243
|
-
return `"${String(value).replace(/"/g, '""')}"`;
|
|
244
|
-
}).join(","))
|
|
291
|
+
...data.map(row => headers.map(header => toCSVCell((row as Record<string, unknown>)[header])).join(","))
|
|
245
292
|
].join("\r\n");
|
|
246
293
|
|
|
247
294
|
downloadBlob([csvContent], `${name}.csv`, "text/csv");
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Entity, FindParams, FindResponse } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rows asked for per request.
|
|
5
|
+
*
|
|
6
|
+
* Kept at or below the server's `MAX_LIST_LIMIT`, which is now the largest page
|
|
7
|
+
* a read will serve *and refuses* anything larger — it used to clamp silently,
|
|
8
|
+
* and asking for 100 000 while receiving 1 000 looked exactly like a collection
|
|
9
|
+
* with 1 000 rows in it. The walk still never trusts the page size it asked for:
|
|
10
|
+
* it advances by the rows it was actually handed.
|
|
11
|
+
*/
|
|
12
|
+
export const EXPORT_PAGE_SIZE = 500;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Most rows one browser-side export will materialise.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is built in memory and handed to a Blob, so there is a
|
|
18
|
+
* ceiling whether or not it is named. Naming it means going over it **throws**
|
|
19
|
+
* instead of writing a short file that reads like the whole collection.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_EXPORT_ROWS = 100_000;
|
|
22
|
+
|
|
23
|
+
export interface FetchAllForExportParams<M extends Record<string, unknown>> {
|
|
24
|
+
/** The collection accessor to read through — `dataClient.collection(path)`. */
|
|
25
|
+
accessor: { find(params?: FindParams<M>): Promise<FindResponse<M>> };
|
|
26
|
+
/** Query to export. The window (`limit` / `offset` / `page`) is owned by the walk. */
|
|
27
|
+
params?: Omit<FindParams<M>, "limit" | "offset" | "page">;
|
|
28
|
+
pageSize?: number;
|
|
29
|
+
maxRows?: number;
|
|
30
|
+
/** Called after every page with the rows so far, and the total when the server reports one. */
|
|
31
|
+
onProgress?: (loaded: number, total: number | undefined) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read **every** row matching a query, one page at a time.
|
|
36
|
+
*
|
|
37
|
+
* `find({})` sends no `limit`, and an absent limit is resolved server-side to
|
|
38
|
+
* `DEFAULT_LIST_LIMIT` — 50 rows. The export used to hand those 50 rows back as
|
|
39
|
+
* `<collection>.csv` with nothing saying it was a sample, next to a dialog
|
|
40
|
+
* warning that the collection had 100 000 documents.
|
|
41
|
+
*
|
|
42
|
+
* Termination is driven by the server's `meta.hasMore` where it is reported and
|
|
43
|
+
* by an empty page otherwise — never by comparing a page's length against the
|
|
44
|
+
* requested limit, since a final page that happens to be exactly full is
|
|
45
|
+
* indistinguishable that way and a walk that stops there drops rows.
|
|
46
|
+
*
|
|
47
|
+
* @throws When more than `maxRows` rows match. A truncated export is worse than
|
|
48
|
+
* no export: it is indistinguishable from a complete one.
|
|
49
|
+
*/
|
|
50
|
+
export async function fetchAllEntitiesForExport<M extends Record<string, unknown>>({
|
|
51
|
+
accessor,
|
|
52
|
+
params,
|
|
53
|
+
pageSize = EXPORT_PAGE_SIZE,
|
|
54
|
+
maxRows = MAX_EXPORT_ROWS,
|
|
55
|
+
onProgress
|
|
56
|
+
}: FetchAllForExportParams<M>): Promise<Entity<M>[]> {
|
|
57
|
+
|
|
58
|
+
const rows: Entity<M>[] = [];
|
|
59
|
+
let offset = 0;
|
|
60
|
+
|
|
61
|
+
for (;;) {
|
|
62
|
+
const res = await accessor.find({
|
|
63
|
+
...params,
|
|
64
|
+
limit: pageSize,
|
|
65
|
+
offset
|
|
66
|
+
} as FindParams<M>);
|
|
67
|
+
|
|
68
|
+
const page = res?.data ?? [];
|
|
69
|
+
rows.push(...page);
|
|
70
|
+
onProgress?.(rows.length, res?.meta?.total);
|
|
71
|
+
|
|
72
|
+
if (rows.length > maxRows) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`This export would contain more than ${maxRows.toLocaleString()} rows, which is more than the ` +
|
|
75
|
+
"browser can build in one file. Filter the collection down, or export it from the server."
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// An empty page ends the walk whatever the metadata says, so a server
|
|
80
|
+
// that keeps answering `hasMore` cannot spin forever.
|
|
81
|
+
if (page.length === 0) break;
|
|
82
|
+
if (res?.meta && !res.meta.hasMore) break;
|
|
83
|
+
|
|
84
|
+
// By rows received, not by `pageSize`: a server whose ceiling is lower
|
|
85
|
+
// than the page we asked for would otherwise skip everything it clamped.
|
|
86
|
+
offset += page.length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return rows;
|
|
90
|
+
}
|