@rebasepro/admin 0.13.0 → 0.13.1-canary.g1822133

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 (210) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-CwTpdzWF.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-CwTpdzWF.js.map +1 -0
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  11. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  12. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  13. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  14. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  15. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  16. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  17. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  18. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  19. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  20. package/dist/components/DetailViewBinding.d.ts +15 -0
  21. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  22. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  23. package/dist/components/EditViewBinding.d.ts +30 -1
  24. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  25. package/dist/components/EntityIdentityBar.d.ts +43 -1
  26. package/dist/components/EntityViewBinding.d.ts +60 -6
  27. package/dist/components/RebaseLayout.d.ts +1 -1
  28. package/dist/components/RebaseShell.d.ts +1 -1
  29. package/dist/components/RelationSelector.d.ts +21 -1
  30. package/dist/components/UserSelector.d.ts +8 -1
  31. package/dist/components/app/Scaffold.d.ts +9 -3
  32. package/dist/components/index.d.ts +2 -0
  33. package/dist/data_export/export/export.d.ts +7 -0
  34. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  35. package/dist/data_export/export/index.d.ts +1 -0
  36. package/dist/data_import/utils/csv.d.ts +30 -0
  37. package/dist/data_import/utils/index.d.ts +2 -0
  38. package/dist/data_import/utils/save_entities.d.ts +38 -0
  39. package/dist/data_import/utils/transforms.d.ts +9 -0
  40. package/dist/editor.js +3 -0
  41. package/dist/editor.js.map +1 -1
  42. package/dist/{export-J79rMoNj.js → export-L53WektO.js} +175 -39
  43. package/dist/export-L53WektO.js.map +1 -0
  44. package/dist/form/components/FieldBlock.d.ts +58 -4
  45. package/dist/form/components/FormRail.d.ts +13 -0
  46. package/dist/form/components/FormSections.d.ts +15 -1
  47. package/dist/form/components/useRailVisible.d.ts +5 -4
  48. package/dist/form/form_utils.d.ts +38 -1
  49. package/dist/{history-D7L_JlLO.js → history-D5SKygpJ.js} +4 -4
  50. package/dist/{history-D7L_JlLO.js.map → history-D5SKygpJ.js.map} +1 -1
  51. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  52. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  53. package/dist/{import-BvYl--U4.js → import-C_4edkoD.js} +6 -5
  54. package/dist/import-C_4edkoD.js.map +1 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +237 -123
  57. package/dist/index.js.map +1 -1
  58. package/dist/preview/compact_text.d.ts +23 -0
  59. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  60. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  61. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  62. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  63. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  64. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  65. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  66. package/dist/routes/RebaseRoute.d.ts +1 -1
  67. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  68. package/dist/util/number_format.d.ts +14 -0
  69. package/dist/util/previews.d.ts +9 -2
  70. package/dist/util/view_constants.d.ts +24 -0
  71. package/dist/util/view_mode.d.ts +18 -0
  72. package/dist/{util-GiwPgxnL.js → util-C-D5yD2n.js} +4161 -1809
  73. package/dist/util-C-D5yD2n.js.map +1 -0
  74. package/package.json +10 -9
  75. package/src/collection_editor/serializable_types.ts +1 -0
  76. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  77. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  78. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  79. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  80. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  81. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  82. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  83. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  84. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  85. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  86. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  87. package/src/components/AdditionalFieldValue.tsx +68 -0
  88. package/src/components/ClearFilterSortButton.tsx +16 -7
  89. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  90. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  91. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  92. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  93. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  94. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  95. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  96. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  97. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
  98. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  99. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +52 -12
  100. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  101. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
  102. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  103. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  104. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  105. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  106. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  107. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  108. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  109. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  110. package/src/components/CollectionViewBinding/useBoardDataController.tsx +23 -6
  111. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  112. package/src/components/CollectionViewBinding/utils.ts +7 -4
  113. package/src/components/DefaultDrawer.tsx +26 -23
  114. package/src/components/DetailViewBinding.tsx +109 -62
  115. package/src/components/DrawerNavigationGroup.tsx +16 -39
  116. package/src/components/DrawerNavigationItem.tsx +56 -16
  117. package/src/components/EditViewBinding.tsx +233 -50
  118. package/src/components/EntityDisplayHeader.tsx +170 -0
  119. package/src/components/EntityIdentityBar.tsx +163 -23
  120. package/src/components/EntityInspector.tsx +24 -6
  121. package/src/components/EntityPreviewBinding.tsx +58 -18
  122. package/src/components/EntityViewBinding.tsx +344 -41
  123. package/src/components/InlineEntityPreview.tsx +10 -10
  124. package/src/components/RebaseLayout.tsx +2 -2
  125. package/src/components/RebaseShell.tsx +2 -2
  126. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  127. package/src/components/RelationSelector.tsx +76 -15
  128. package/src/components/SearchIconsView.tsx +14 -33
  129. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  130. package/src/components/SideDialogs.tsx +15 -3
  131. package/src/components/SidePanelBinding.tsx +122 -66
  132. package/src/components/UserSelector.tsx +67 -13
  133. package/src/components/app/Scaffold.tsx +112 -10
  134. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  135. package/src/components/index.ts +3 -0
  136. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  137. package/src/data_export/export/export.ts +63 -16
  138. package/src/data_export/export/fetch_export_data.ts +90 -0
  139. package/src/data_export/export/index.ts +1 -0
  140. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  141. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  142. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  143. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  144. package/src/data_import/utils/csv.ts +153 -0
  145. package/src/data_import/utils/data.ts +16 -2
  146. package/src/data_import/utils/file_to_json.ts +77 -6
  147. package/src/data_import/utils/index.ts +2 -0
  148. package/src/data_import/utils/save_entities.ts +138 -0
  149. package/src/data_import/utils/transforms.ts +23 -0
  150. package/src/editor/components/image-bubble.tsx +2 -0
  151. package/src/editor/selectors/link-selector.tsx +1 -0
  152. package/src/form/EntityForm.tsx +74 -53
  153. package/src/form/EntityFormBinding.tsx +24 -11
  154. package/src/form/PropertyFieldBinding.tsx +2 -2
  155. package/src/form/components/FieldBlock.tsx +80 -10
  156. package/src/form/components/FormRail.tsx +19 -2
  157. package/src/form/components/FormSections.tsx +32 -5
  158. package/src/form/components/StorageUploadProgress.tsx +9 -3
  159. package/src/form/components/useRailVisible.tsx +5 -4
  160. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  161. package/src/form/field_bindings/BinaryFieldBinding.tsx +11 -5
  162. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  163. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  164. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  165. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  166. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  167. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  168. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  169. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  170. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  171. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  172. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  173. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  174. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  175. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  176. package/src/form/field_bindings/VectorFieldBinding.tsx +7 -4
  177. package/src/form/form_utils.ts +78 -0
  178. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  179. package/src/hooks/useEntityDisplay.tsx +262 -0
  180. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  181. package/src/index.ts +9 -1
  182. package/src/preview/PropertyPreview.tsx +25 -5
  183. package/src/preview/compact_text.ts +68 -0
  184. package/src/preview/components/BooleanPreview.tsx +5 -2
  185. package/src/preview/components/DatePreview.tsx +15 -2
  186. package/src/preview/components/ReferencePreview.tsx +5 -8
  187. package/src/preview/components/RelationPreview.tsx +8 -7
  188. package/src/preview/components/StorageThumbnail.tsx +5 -2
  189. package/src/preview/components/date_fns_locales.ts +125 -0
  190. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  191. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  192. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  193. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  194. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  195. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  196. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  197. package/src/routes/RebaseRoute.tsx +62 -4
  198. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  199. package/src/util/navigation_utils.ts +4 -2
  200. package/src/util/number_format.ts +46 -0
  201. package/src/util/previews.ts +8 -55
  202. package/src/util/property_utils.tsx +6 -1
  203. package/src/util/view_constants.ts +27 -0
  204. package/src/util/view_mode.ts +36 -0
  205. package/dist/CollectionEditorDialog-CjXkWZ64.js.map +0 -1
  206. package/dist/PropertyEditView-W8I3YV89.js.map +0 -1
  207. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  208. package/dist/export-J79rMoNj.js.map +0 -1
  209. package/dist/import-BvYl--U4.js.map +0 -1
  210. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -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" && inputValue instanceof Date) {
185
- value = inputValue ? (dateExportType === "timestamp" ? inputValue.getTime() : inputValue.toISOString()) : null;
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
- function entryToCSVRow(entry: unknown[]) {
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((v: unknown) => {
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
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./export";
2
+ export * from "./fetch_export_data";
2
3
  export * from "./BasicExportAction";
3
4
  export * from "./ExportCollectionAction";
@@ -17,6 +17,7 @@ import {
17
17
  Typography
18
18
  } from "@rebasepro/ui";
19
19
  import { getIn, setIn } from "@rebasepro/forms";
20
+ import { useTranslation } from "@rebasepro/app";
20
21
  import { ImportConfig } from "../types";
21
22
 
22
23
  export interface DataPropertyMappingProps {
@@ -36,6 +37,7 @@ export function DataNewPropertiesMapping({
36
37
  buildPropertyView
37
38
  }: DataPropertyMappingProps) {
38
39
 
40
+ const { t } = useTranslation();
39
41
  const headersMapping = importConfig.headersMapping;
40
42
  const headingsOrder = importConfig.headingsOrder;
41
43
  const idColumn = importConfig.idColumn;
@@ -117,7 +119,7 @@ export function DataNewPropertiesMapping({
117
119
  <TableCell header={true}>
118
120
  </TableCell>
119
121
  <TableCell header={true} style={{ width: "65%" }}>
120
- Default value
122
+ {t("default_value")}
121
123
  </TableCell>
122
124
  </TableHeader>
123
125
  <TableBody>
@@ -205,16 +207,31 @@ function DefaultValuesField({
205
207
  onValueChange,
206
208
  defaultValue
207
209
  }: { property: Property, onValueChange: (value: any) => void, defaultValue?: any }) {
210
+ const { t } = useTranslation();
211
+ const defaultValueLabel = t("default_value");
212
+ // Named fields stay distinguishable from each other — several of these sit
213
+ // in one table, and an aria-label repeated verbatim tells a screen reader
214
+ // nothing about which row it is on.
215
+ //
216
+ // Composed with a separator rather than a phrase: there is no key for the
217
+ // possessive form, and `t` returns the key itself for one that does not
218
+ // exist, so inventing `default_value_for` here would read out the literal
219
+ // string "default_value_for" in every locale, English included.
220
+ const ariaLabel = property.name
221
+ ? `${defaultValueLabel}: ${property.name}`
222
+ : defaultValueLabel;
208
223
  if (property.type === "string") {
209
224
  return <TextField size={"medium"}
210
- placeholder={"Default value"}
225
+ aria-label={ariaLabel}
226
+ placeholder={defaultValueLabel}
211
227
  value={defaultValue ?? ""}
212
228
  onChange={(event) => onValueChange(event.target.value)}/>;
213
229
  } else if (property.type === "number") {
214
230
  return <TextField size={"medium"}
215
231
  type={"number"}
232
+ aria-label={ariaLabel}
216
233
  value={defaultValue ?? ""}
217
- placeholder={"Default value"}
234
+ placeholder={defaultValueLabel}
218
235
  onChange={(event) => onValueChange(event.target.value)}/>;
219
236
  } else if (property.type === "boolean") {
220
237
  return <BooleanSwitchWithLabel
@@ -38,6 +38,7 @@ export function ImportNewPropertyFieldPreview({
38
38
  {includeName &&
39
39
  <TextField
40
40
  size={"medium"}
41
+ aria-label={propertyKey ? `Name of property ${propertyKey}` : "Property name"}
41
42
  className={"text-base grow"}
42
43
  value={property?.name ?? ""}
43
44
  onChange={(e) => {
@@ -1,10 +1,9 @@
1
1
  import { useData } from "@rebasepro/app";
2
- import { Entity } from "@rebasepro/types";
3
- import { RebaseData } from "@rebasepro/types";
4
2
  import { Button, CenteredView, CircularProgress, Typography } from "@rebasepro/ui";
5
3
  import { useEffect, useRef, useState } from "react";
6
4
  import { ImportConfig } from "../types";
7
5
  import type { AdminCollection } from "@rebasepro/admin-types";
6
+ import { IMPORT_BATCH_SIZE, ImportSaveError, saveImportedEntities } from "../utils/save_entities";
8
7
 
9
8
  export function ImportSaveInProgress<C extends AdminCollection<any>>
10
9
  ({
@@ -20,11 +19,16 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
20
19
  onImportSuccess: (collection: C) => void
21
20
  }) {
22
21
 
23
- const [errorSaving, setErrorSaving] = useState<Error | undefined>(undefined);
22
+ const [errorSaving, setErrorSaving] = useState<ImportSaveError | undefined>(undefined);
24
23
  const dataClient = useData();
25
24
 
26
25
  const savingRef = useRef<boolean>(false);
27
26
 
27
+ // Rows confirmed as written. A retry resumes here rather than re-sending
28
+ // rows that now exist and conflicting on its first batch, forever.
29
+ const committedRef = useRef<number>(0);
30
+ const bulkUnsupportedRef = useRef<{ current: boolean }>({ current: false });
31
+
28
32
  const [processedEntities, setProcessedEntities] = useState<number>(0);
29
33
 
30
34
  function save() {
@@ -33,20 +37,29 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
33
37
  return;
34
38
 
35
39
  savingRef.current = true;
40
+ setErrorSaving(undefined);
36
41
 
37
- saveDataBatch(
42
+ saveImportedEntities(
38
43
  dataClient,
39
- collection,
40
44
  path,
41
45
  importConfig.entities,
42
- 0,
43
- 25,
44
- setProcessedEntities
46
+ {
47
+ offset: committedRef.current,
48
+ batchSize: IMPORT_BATCH_SIZE,
49
+ onBatchCommitted: (written) => {
50
+ committedRef.current = written;
51
+ setProcessedEntities(written);
52
+ },
53
+ bulkUnsupported: bulkUnsupportedRef.current
54
+ }
45
55
  ).then(() => {
46
56
  onImportSuccess(collection);
47
57
  savingRef.current = false;
48
58
  }).catch((e) => {
49
- setErrorSaving(e);
59
+ const error = e as ImportSaveError;
60
+ committedRef.current = error.committed ?? committedRef.current;
61
+ setProcessedEntities(committedRef.current);
62
+ setErrorSaving(error);
50
63
  savingRef.current = false;
51
64
  });
52
65
  }
@@ -56,6 +69,10 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
56
69
  }, []);
57
70
 
58
71
  if (errorSaving) {
72
+ const total = importConfig.entities.length;
73
+ const failedRows = errorSaving.failedTo - errorSaving.failedFrom > 1
74
+ ? `rows ${errorSaving.failedFrom + 1}–${errorSaving.failedTo}`
75
+ : `row ${errorSaving.failedFrom + 1}`;
59
76
  return (
60
77
  <CenteredView className={"flex flex-col gap-4 items-center"}>
61
78
  <Typography variant={"h6"}>
@@ -65,10 +82,18 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
65
82
  <Typography variant={"body2"} color={"error"}>
66
83
  {errorSaving.message}
67
84
  </Typography>
85
+
86
+ <Typography variant={"body2"}>
87
+ {errorSaving.committed} of {total} rows were imported.
88
+ The import stopped on {failedRows}, which {errorSaving.failedTo - errorSaving.failedFrom > 1 ? "were" : "was"} not written.
89
+ </Typography>
90
+
68
91
  <Button
69
92
  onClick={save}
70
93
  >
71
- Retry
94
+ {errorSaving.committed > 0
95
+ ? `Retry from row ${errorSaving.committed + 1}`
96
+ : "Retry"}
72
97
  </Button>
73
98
  </CenteredView>
74
99
  );
@@ -94,29 +119,3 @@ export function ImportSaveInProgress<C extends AdminCollection<any>>
94
119
  );
95
120
 
96
121
  }
97
-
98
- function saveDataBatch(dataClient: RebaseData,
99
- collection: AdminCollection,
100
- path: string,
101
- data: Partial<Entity<any>>[],
102
- offset = 0,
103
- batchSize = 25,
104
- onProgressUpdate: (progress: number) => void): Promise<void> {
105
-
106
- console.debug("Saving imported data", offset, batchSize);
107
-
108
- const batch = data.slice(offset, offset + batchSize);
109
- return Promise.all(batch.map(d =>
110
- dataClient.collection(path).create(
111
- d.values,
112
- d.id
113
- )))
114
- .then(() => {
115
- if (offset + batchSize < data.length) {
116
- onProgressUpdate(offset + batchSize);
117
- return saveDataBatch(dataClient, collection, path, data, offset + batchSize, batchSize, onProgressUpdate);
118
- }
119
- onProgressUpdate(data.length);
120
- return Promise.resolve();
121
- });
122
- }
@@ -35,7 +35,7 @@ import { DataNewPropertiesMapping } from "../components/DataNewPropertiesMapping
35
35
  import { ImportFileUpload } from "../components/ImportFileUpload";
36
36
  import { ImportSaveInProgress } from "../components/ImportSaveInProgress";
37
37
  import { ImportConfig } from "../types";
38
- import { slugify } from "@rebasepro/utils";
38
+ import { isPrototypePollutingKey, slugify } from "@rebasepro/utils";
39
39
 
40
40
  type ImportState = "initial" | "mapping" | "preview" | "import_data_saving";
41
41
 
@@ -400,7 +400,15 @@ export function ImportDataPreview<M extends Record<string, unknown>>({
400
400
  return <CollectionTableBinding
401
401
  title={<div>
402
402
  <Typography variant={"subtitle2"}>Imported data preview</Typography>
403
- <Typography variant={"caption"}>Entities with the same id will be overwritten</Typography>
403
+ {/* Conditional because it is only true when a column was chosen as
404
+ the id: without one, every row is created and nothing can be
405
+ overwritten. With one, the import asks for an upsert — which it
406
+ did not, back when this sentence was unconditional and false. */}
407
+ <Typography variant={"caption"}>
408
+ {importConfig.idColumn
409
+ ? "Entities with the same id will be overwritten"
410
+ : "All rows will be imported as new entities"}
411
+ </Typography>
404
412
  </div>}
405
413
  tableController={{
406
414
  data: importConfig.entities,
@@ -421,6 +429,9 @@ function buildHeadersMappingFromData(objArr: object[], properties?: Properties)
421
429
  const headersMapping: Record<string, string> = {};
422
430
  objArr.filter(Boolean).forEach((obj) => {
423
431
  Object.keys(obj).forEach((key) => {
432
+ // The keys are the uploaded file's header row; `headersMapping[key] = …`
433
+ // is the same setter the import pipeline refuses elsewhere.
434
+ if (isPrototypePollutingKey(key)) return;
424
435
  const child = (obj as Record<string, unknown>)[key];
425
436
  if (child != null && typeof child === "object" && !Array.isArray(child)) {
426
437
  const childProperty = properties?.[key];
@@ -0,0 +1,153 @@
1
+ import { isPrototypePollutingKey } from "@rebasepro/utils";
2
+
3
+ /**
4
+ * Delimiters sniffed from the header row, most common first.
5
+ *
6
+ * A spreadsheet saved as "CSV" in a locale that uses the comma as a decimal
7
+ * separator is semicolon-delimited, and a tab-separated file saved as `.csv`
8
+ * happens often enough to be worth reading.
9
+ */
10
+ const CANDIDATE_DELIMITERS = [",", ";", "\t"];
11
+
12
+ /**
13
+ * Split CSV text into rows of raw cells, RFC 4180 style.
14
+ *
15
+ * Quoted fields may contain the delimiter, newlines and doubled quotes; this is
16
+ * the exact inverse of what the export's `entryToCSVRow` writes, so a file this
17
+ * admin produced reads back into the cells it was built from.
18
+ */
19
+ export function parseCsvRows(text: string, delimiter = ","): string[][] {
20
+ const rows: string[][] = [];
21
+ let row: string[] = [];
22
+ let field = "";
23
+ let quoted = false;
24
+ let inQuotes = false;
25
+ let i = 0;
26
+
27
+ const endField = () => {
28
+ row.push(field);
29
+ field = "";
30
+ quoted = false;
31
+ };
32
+
33
+ while (i < text.length) {
34
+ const char = text[i];
35
+
36
+ if (inQuotes) {
37
+ if (char === "\"") {
38
+ // A doubled quote is a literal one; a single quote closes the field.
39
+ if (text[i + 1] === "\"") {
40
+ field += "\"";
41
+ i += 2;
42
+ continue;
43
+ }
44
+ inQuotes = false;
45
+ i++;
46
+ continue;
47
+ }
48
+ field += char;
49
+ i++;
50
+ continue;
51
+ }
52
+
53
+ if (char === "\"" && field === "") {
54
+ inQuotes = true;
55
+ quoted = true;
56
+ i++;
57
+ continue;
58
+ }
59
+ if (char === delimiter) {
60
+ endField();
61
+ i++;
62
+ continue;
63
+ }
64
+ if (char === "\r") {
65
+ // CRLF: the line ends on the \n. A lone CR outside quotes is dropped.
66
+ i++;
67
+ continue;
68
+ }
69
+ if (char === "\n") {
70
+ endField();
71
+ rows.push(row);
72
+ row = [];
73
+ i++;
74
+ continue;
75
+ }
76
+ field += char;
77
+ i++;
78
+ }
79
+
80
+ // A file that does not end in a newline still has a last row.
81
+ if (field !== "" || quoted || row.length > 0) {
82
+ endField();
83
+ rows.push(row);
84
+ }
85
+
86
+ return rows;
87
+ }
88
+
89
+ /**
90
+ * Pick the delimiter that splits the header row into the most cells. A
91
+ * single-column file matches nothing and stays on the comma.
92
+ */
93
+ export function detectCsvDelimiter(text: string): string {
94
+ const sample = text.slice(0, 64 * 1024);
95
+ let best = CANDIDATE_DELIMITERS[0];
96
+ let bestCount = 1;
97
+ for (const candidate of CANDIDATE_DELIMITERS) {
98
+ const count = parseCsvRows(sample, candidate)[0]?.length ?? 0;
99
+ if (count > bestCount) {
100
+ best = candidate;
101
+ bestCount = count;
102
+ }
103
+ }
104
+ return best;
105
+ }
106
+
107
+ export interface ParsedCsv {
108
+ headers: string[];
109
+ data: Record<string, string>[];
110
+ }
111
+
112
+ /**
113
+ * Parse CSV text into one object per row, keyed by the header row.
114
+ *
115
+ * The picker accepts `.csv` and the dialog promises "Upload a CSV, Excel or JSON
116
+ * file", but every non-JSON file used to be handed to ExcelJS'
117
+ * `workbook.xlsx.load`, which is a zip reader — so every CSV failed with
118
+ * "Can't find end of central directory : is this a zip file ?". The export
119
+ * writes CSV by default, so the round trip could not close.
120
+ *
121
+ * A blank header keeps its column rather than collapsing it, so the cells to its
122
+ * right stay under their own names.
123
+ */
124
+ export function parseCsvToObjects(text: string): ParsedCsv {
125
+ // A BOM is one character of the first header, and Excel writes one.
126
+ const cleaned = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
127
+ const delimiter = detectCsvDelimiter(cleaned);
128
+ const rows = parseCsvRows(cleaned, delimiter)
129
+ .filter(row => row.some(cell => cell.trim() !== ""));
130
+
131
+ if (rows.length === 0) {
132
+ return { headers: [],
133
+ data: [] };
134
+ }
135
+
136
+ const headers = rows[0].map((header, index) => header.trim() || `Column${index + 1}`);
137
+
138
+ const data = rows.slice(1).map((cells) => {
139
+ const obj: Record<string, string> = {};
140
+ headers.forEach((header, index) => {
141
+ // The header row is uploaded data: `obj["__proto__"] = …` is the
142
+ // prototype setter, not a column. Refused, as elsewhere in import.
143
+ if (isPrototypePollutingKey(header)) return;
144
+ const cell = cells[index];
145
+ if (cell === undefined) return;
146
+ obj[header] = cell;
147
+ });
148
+ return obj;
149
+ });
150
+
151
+ return { headers,
152
+ data };
153
+ }
@@ -5,7 +5,7 @@ import { isPropertyBuilder } from "@rebasepro/common";
5
5
  import { unflattenObject } from "./file_to_json";
6
6
  import { getIn } from "@rebasepro/forms";
7
7
  import { inferTypeFromValue } from "@rebasepro/inference";
8
- import { mergeDeep } from "@rebasepro/utils";
8
+ import { isPrototypePollutingKey, mergeDeep } from "@rebasepro/utils";
9
9
 
10
10
  export function convertDataToEntity(authController: AuthController,
11
11
  navigation: CollectionRegistryController,
@@ -57,6 +57,12 @@ export function convertDataToEntity(authController: AuthController,
57
57
 
58
58
  export function flattenEntry(obj: Record<string, unknown>, parent = ""): Record<string, unknown> {
59
59
  return Object.keys(obj).reduce<Record<string, unknown>>((acc, key) => {
60
+ // Keys come from the uploaded file, so `acc[key] = …` is the class-22
61
+ // primitive when the key is `__proto__`; refuse the column instead.
62
+ if (isPrototypePollutingKey(key)) {
63
+ console.warn(`Skipping column "${key}": a header may not reach the prototype chain`);
64
+ return acc;
65
+ }
60
66
  const prefixedKey = parent ? `${parent}.${key}` : key;
61
67
 
62
68
  if (typeof obj[key] === "object" && !(obj[key] instanceof Date) && obj[key] !== null && !Array.isArray(obj[key])) {
@@ -100,7 +106,15 @@ export function processValueMapping(authController: AuthController, value: any,
100
106
  if (from === "array" && to === "array" && Array.isArray(value) && usedProperty.of && !Array.isArray(usedProperty.of) && !isPropertyBuilder(usedProperty.of)) {
101
107
  return value.map(v => processValueMapping(authController, v, navigation, usedProperty.of as Property));
102
108
  } else if (from === "string" && to === "number" && typeof value === "string") {
103
- return Number(value);
109
+ // `Number("")` is 0, so a blank cell used to import as a real zero — a
110
+ // price of nothing rather than a price nobody filled in — and `Number`
111
+ // of anything unreadable imported as NaN. Both are absent values, and
112
+ // saying so lets the importer's own validation see them. The `vector`
113
+ // branch above already draws the same distinction.
114
+ const trimmed = value.trim();
115
+ if (trimmed === "") return null;
116
+ const num = Number(trimmed);
117
+ return Number.isNaN(num) ? null : num;
104
118
  } else if (from === "string" && to === "array" && typeof value === "string" && usedProperty.of && !Array.isArray(usedProperty.of) && !isPropertyBuilder(usedProperty.of)) {
105
119
  return value.split(",").map((v: string) => processValueMapping(authController, v, navigation, usedProperty.of as Property));
106
120
  } else if (from === "string" && to === "boolean") {