@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
@@ -1,6 +1,7 @@
1
- import ExcelJS from "exceljs";
2
1
  import { getWorksheetHeaders } from "./file_headers";
3
2
  import { mapJsonParse, unflattenObject } from "./transforms";
3
+ import { parseCsvToObjects } from "./csv";
4
+ import { isPrototypePollutingKey } from "@rebasepro/utils";
4
5
 
5
6
  // Re-export for backwards compat (tests import from this file)
6
7
  export { unflattenObject } from "./transforms";
@@ -10,9 +11,78 @@ type ConversionResult = {
10
11
  propertiesOrder: string[]
11
12
  }
12
13
 
14
+ /**
15
+ * ExcelJS, fetched the first time somebody actually opens a workbook.
16
+ *
17
+ * It was a top-level `import ExcelJS from "exceljs"`, and that one line put
18
+ * 940 kB of spreadsheet reader into the admin's entry chunk — preloaded on the
19
+ * login screen, before anyone has authenticated, let alone clicked Import.
20
+ * `CollectionViewActions` lazy-loads the import and export actions and says so
21
+ * in a comment, but the package's own barrel (`src/index.ts` re-exports
22
+ * `./data_import`) puts this module back in the entry's static graph, so the
23
+ * `lazy()` bought nothing. A static import inside a module the barrel reaches
24
+ * is eager no matter what the component above it does; the only thing that
25
+ * makes a dependency lazy is importing it lazily, here.
26
+ */
27
+ type ExcelJsModule = typeof import("exceljs");
28
+
29
+ let excelJsModule: Promise<ExcelJsModule> | undefined;
30
+
31
+ function loadExcelJs(): Promise<ExcelJsModule> {
32
+ // exceljs is CommonJS, so the namespace a bundler hands back wraps the real
33
+ // module under `default`. Native ESM (and the type declarations) put the
34
+ // members at the top level. Accept either.
35
+ excelJsModule ??= import("exceljs").then(mod => (mod as { default?: ExcelJsModule }).default ?? mod);
36
+ return excelJsModule;
37
+ }
38
+
39
+ /**
40
+ * Whether this file is delimited text rather than a workbook.
41
+ *
42
+ * Browsers report `text/csv`, `application/csv` or nothing at all for the same
43
+ * `.csv`, so the extension is the reliable half of the test.
44
+ */
45
+ function isCsvFile(file: File): boolean {
46
+ const name = (file.name ?? "").toLowerCase();
47
+ if (name.endsWith(".csv") || name.endsWith(".tsv")) return true;
48
+ return file.type === "text/csv" || file.type === "application/csv";
49
+ }
50
+
51
+ /** Turn the parsed cells of one sheet/file into the entity-shaped rows the import maps. */
52
+ function toImportRows(rows: Array<Record<string, unknown>>): object[] {
53
+ return rows.map(mapJsonParse).map(unflattenObject);
54
+ }
55
+
13
56
  export function convertFileToJson(file: File): Promise<ConversionResult> {
14
57
  return new Promise((resolve, reject) => {
15
- if (file.type === "application/json") {
58
+ if (isCsvFile(file)) {
59
+ // Not the ExcelJS branch: `workbook.xlsx.load` is a zip reader, and
60
+ // every CSV — the format this admin exports by default — died on it
61
+ // with "Can't find end of central directory : is this a zip file ?".
62
+ console.debug("Converting CSV file to JSON", file.name);
63
+ const reader = new FileReader();
64
+ reader.onload = function (e) {
65
+ try {
66
+ const { headers, data } = parseCsvToObjects(e.target?.result as string);
67
+ if (headers.length === 0) {
68
+ reject(new Error("The CSV file is empty"));
69
+ return;
70
+ }
71
+ resolve({
72
+ data: toImportRows(data),
73
+ propertiesOrder: headers
74
+ });
75
+ } catch (err) {
76
+ console.error("Error parsing CSV file", err);
77
+ reject(err);
78
+ }
79
+ };
80
+ reader.onerror = () => reject(reader.error ?? new Error("Could not read the file"));
81
+ // Explicit UTF-8: the pre-ExcelJS implementation passed
82
+ // `codepage: 65001` for the same reason, and the browser's default
83
+ // guess mangles accented and CJK text.
84
+ reader.readAsText(file, "utf-8");
85
+ } else if (file.type === "application/json") {
16
86
  console.debug("Converting JSON file to JSON", file.name);
17
87
  const reader = new FileReader();
18
88
  reader.onload = function (e) {
@@ -40,6 +110,7 @@ export function convertFileToJson(file: File): Promise<ConversionResult> {
40
110
  reader.onload = async function (e) {
41
111
  try {
42
112
  const buffer = e.target?.result as ArrayBuffer;
113
+ const ExcelJS = await loadExcelJs();
43
114
  const workbook = new ExcelJS.Workbook();
44
115
  await workbook.xlsx.load(buffer);
45
116
  const worksheet = workbook.worksheets[0];
@@ -57,17 +128,17 @@ export function convertFileToJson(file: File): Promise<ConversionResult> {
57
128
  const obj: Record<string, any> = {};
58
129
  row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
59
130
  const header = headers[colNumber - 1];
60
- if (header) {
131
+ // A `__proto__` header would be the prototype setter
132
+ // here rather than a column; refused, as in `csv.ts`.
133
+ if (header && !isPrototypePollutingKey(header)) {
61
134
  obj[header] = cell.value;
62
135
  }
63
136
  });
64
137
  parsedData.push(obj);
65
138
  });
66
139
 
67
- const cleanedData = parsedData.map(mapJsonParse);
68
- const jsonData = cleanedData.map(unflattenObject);
69
140
  resolve({
70
- data: jsonData,
141
+ data: toImportRows(parsedData),
71
142
  propertiesOrder: headers
72
143
  });
73
144
  } catch (err) {
@@ -1,3 +1,5 @@
1
1
  export * from "./file_to_json";
2
+ export * from "./csv";
3
+ export * from "./save_entities";
2
4
  export * from "./data";
3
5
  export * from "./get_import_inference_type";
@@ -0,0 +1,138 @@
1
+ import { CollectionAccessor, Entity, RebaseData } from "@rebasepro/types";
2
+
3
+ /** Rows written per request, and per transaction where the data source has one. */
4
+ export const IMPORT_BATCH_SIZE = 25;
5
+
6
+ /**
7
+ * An import failure that says where it stopped.
8
+ *
9
+ * "Error saving data / null value in column "sku" violates not-null constraint"
10
+ * on a ten-thousand-row import named no row, and the Retry button restarted at
11
+ * zero — where the rows already written now conflicted, so it could never
12
+ * succeed. Both halves of that are what this carries.
13
+ */
14
+ export interface ImportSaveError extends Error {
15
+ /** Rows written and confirmed before the failure. Where a retry resumes. */
16
+ committed: number;
17
+ /** Index of the first row that was not written, 0-based. */
18
+ failedFrom: number;
19
+ /** Index after the last row of the batch that was not written. */
20
+ failedTo: number;
21
+ }
22
+
23
+ /** The row a bulk write takes: the values, with the chosen id as a column. */
24
+ function toBulkRow(entity: Partial<Entity<any>>): Record<string, unknown> {
25
+ const values = { ...(entity.values ?? {}) } as Record<string, unknown>;
26
+ if (entity.id !== undefined && entity.id !== null && entity.id !== "") {
27
+ // Exactly what single-row `create(values, id)` puts on the wire.
28
+ values.id = entity.id;
29
+ }
30
+ return values;
31
+ }
32
+
33
+ function isBulkUnsupported(error: unknown): boolean {
34
+ if ((error as { code?: string })?.code === "BULK_UNSUPPORTED") return true;
35
+ const message = (error as Error)?.message ?? "";
36
+ return /bulk writes are not supported|does not support bulk/i.test(message);
37
+ }
38
+
39
+ function isConflict(error: unknown): boolean {
40
+ const status = (error as { status?: number; statusCode?: number })?.status
41
+ ?? (error as { statusCode?: number })?.statusCode;
42
+ if (status === 409) return true;
43
+ const code = (error as { code?: string })?.code;
44
+ return code === "CONFLICT" || code === "23505";
45
+ }
46
+
47
+ /**
48
+ * Write one batch, all-or-nothing wherever the data source can.
49
+ *
50
+ * `POST /<collection>/bulk` validates every row before the transaction opens,
51
+ * names a failure by row index, and takes the `upsert` flag the preview screen
52
+ * promises — none of which one `create` per row inside a `Promise.all` could
53
+ * offer, since `Promise.all` does not cancel its siblings and every request was
54
+ * its own transaction.
55
+ */
56
+ async function writeBatch(accessor: CollectionAccessor,
57
+ batch: Partial<Entity<any>>[],
58
+ bulkUnsupported: { current: boolean },
59
+ written: { count: number }): Promise<void> {
60
+
61
+ if (accessor.createMany && !bulkUnsupported.current) {
62
+ try {
63
+ await accessor.createMany(batch.map(toBulkRow), { upsert: true });
64
+ written.count = batch.length;
65
+ return;
66
+ } catch (e) {
67
+ if (!isBulkUnsupported(e)) throw e;
68
+ // A data source without bulk writes: remember it, so the failed
69
+ // round trip is not paid again on every later batch.
70
+ bulkUnsupported.current = true;
71
+ }
72
+ }
73
+
74
+ // Sequential, not `Promise.all`: a rejected write does not cancel its
75
+ // siblings, so a parallel batch commits an indeterminate subset of itself
76
+ // and nobody can say which rows landed.
77
+ for (const entity of batch) {
78
+ try {
79
+ await accessor.create(entity.values ?? {}, entity.id);
80
+ } catch (e) {
81
+ // Without `/bulk` there is no upsert, so the preview's promise is
82
+ // honoured the only way left: a row that collided on an id the user
83
+ // supplied is overwritten. A row with no id had nothing to collide
84
+ // on, and every other failure is still a failure.
85
+ if (entity.id === undefined || !isConflict(e)) throw e;
86
+ await accessor.update(entity.id, entity.values ?? {});
87
+ }
88
+ written.count++;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Write imported entities in batches, reporting what was committed.
94
+ *
95
+ * Resumable on purpose: a failure at row 4 000 leaves rows 1–3 999 in the table
96
+ * whatever this does, so the only useful thing to report is where to carry on
97
+ * from. `offset` is that point — the Retry button passes back the `committed`
98
+ * count of the error it is retrying, instead of starting from zero and
99
+ * conflicting on every row that already exists.
100
+ *
101
+ * @throws {ImportSaveError} naming the rows that were not written.
102
+ */
103
+ export async function saveImportedEntities(dataClient: RebaseData,
104
+ path: string,
105
+ data: Partial<Entity<any>>[],
106
+ {
107
+ offset = 0,
108
+ batchSize = IMPORT_BATCH_SIZE,
109
+ onBatchCommitted = () => undefined,
110
+ bulkUnsupported = { current: false }
111
+ }: {
112
+ offset?: number;
113
+ batchSize?: number;
114
+ onBatchCommitted?: (rowsWritten: number) => void;
115
+ bulkUnsupported?: { current: boolean };
116
+ } = {}): Promise<void> {
117
+
118
+ console.debug("Saving imported data", offset, batchSize);
119
+
120
+ const accessor = dataClient.collection(path);
121
+
122
+ for (let start = offset; start < data.length; start += batchSize) {
123
+ const batch = data.slice(start, start + batchSize);
124
+ const written = { count: 0 };
125
+ try {
126
+ await writeBatch(accessor, batch, bulkUnsupported, written);
127
+ } catch (e) {
128
+ throw Object.assign(e as Error, {
129
+ committed: start + written.count,
130
+ failedFrom: start + written.count,
131
+ failedTo: start + batch.length
132
+ }) as ImportSaveError;
133
+ }
134
+ onBatchCommitted(start + batch.length);
135
+ }
136
+
137
+ onBatchCommitted(data.length);
138
+ }
@@ -1,10 +1,27 @@
1
+ import { isPrototypePollutingKey, pathTraversesPrototype } from "@rebasepro/utils";
2
+
1
3
  /**
2
4
  * Take an object with keys of type `address.street`, `address.city` and
3
5
  * convert it to an object with nested objects like `{ address: { street: ..., city: ... } }`
6
+ *
7
+ * Keys here are the **header row of an uploaded file**, so they are attacker
8
+ * data. `res["__proto__"] = …` is the prototype setter rather than an own
9
+ * property, so a column named `__proto__.polluted` walked out of the accumulator
10
+ * and wrote onto `Object.prototype` for the life of the tab — and
11
+ * `constructor.prototype.x` threw a `TypeError` that surfaced as an unreadable
12
+ * file. Such a column is refused, not sanitised, the way `setIn` settled it
13
+ * (`docs/bug-classes.md` class 22).
14
+ *
4
15
  * @param flatObj
5
16
  */
6
17
  export function unflattenObject(flatObj: { [key: string]: any }) {
7
18
  return Object.keys(flatObj).reduce((nestedObj, key) => {
19
+ // The string form, so `toPath` rewrites `__proto__[0]` to a `__proto__`
20
+ // segment too — the bracket branch below writes through the same setter.
21
+ if (pathTraversesPrototype(key)) {
22
+ console.warn(`Skipping column "${key}": a header may not reach the prototype chain`);
23
+ return nestedObj;
24
+ }
8
25
  let currentObj = nestedObj;
9
26
  const keyParts = key.split(".");
10
27
  keyParts.forEach((keyPart, i) => {
@@ -37,6 +54,12 @@ export function unflattenObject(flatObj: { [key: string]: any }) {
37
54
 
38
55
  export function mapJsonParse(obj: Record<string, any>) {
39
56
  return Object.keys(obj).reduce((acc: Record<string, any>, key) => {
57
+ // Same header row, same setter: `acc["__proto__"] = value` replaces the
58
+ // accumulator's prototype instead of adding a column.
59
+ if (isPrototypePollutingKey(key)) {
60
+ console.warn(`Skipping column "${key}": a header may not reach the prototype chain`);
61
+ return acc;
62
+ }
40
63
  try {
41
64
  acc[key] = JSON.parse(obj[key]);
42
65
  } catch (e) {
@@ -157,6 +157,7 @@ export const ImageBubble = forwardRef<HTMLDivElement, ImageBubbleProps>(
157
157
  >
158
158
  <TextField
159
159
  size={"small"}
160
+ aria-label={t("alt_text")}
160
161
  placeholder={t("alt_text")}
161
162
  value={alt}
162
163
  onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@@ -166,6 +167,7 @@ export const ImageBubble = forwardRef<HTMLDivElement, ImageBubbleProps>(
166
167
  />
167
168
  <TextField
168
169
  size={"small"}
170
+ aria-label={t("title")}
169
171
  placeholder={t("title")}
170
172
  value={title}
171
173
  onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
@@ -90,6 +90,7 @@ export const LinkSelector = ({
90
90
  <TextField
91
91
  inputRef={inputRef}
92
92
  autoFocus={open}
93
+ aria-label={t("editor_paste_or_type_link")}
93
94
  placeholder={t("editor_paste_or_type_link")}
94
95
  defaultValue={href}
95
96
  size="small"
@@ -9,18 +9,18 @@ import { deepEqual as equal } from "fast-equals";
9
9
  import { ErrorBoundary } from "@rebasepro/ui";
10
10
  import { AlignLeftIcon, useDebouncedCallback } from "@rebasepro/ui";
11
11
  import { getDefaultValuesFor } from "@rebasepro/common";
12
- import { isReadOnly } from "@rebasepro/app";
12
+ import { isDisabled, isReadOnly } from "@rebasepro/app";
13
13
 
14
14
  import { getFormFieldKeys, resolveFormLayout } from "@rebasepro/app";
15
15
  import type { ResolvedFormField } from "@rebasepro/app";
16
- import { Alert, Button, cls, Dialog, DialogActions, DialogContent, DialogTitle, iconSize, paperMixin, Typography } from "@rebasepro/ui";
16
+ import { Alert, Button, cls, defaultBorderMixin, Dialog, DialogActions, DialogContent, DialogTitle, paperMixin, Typography } from "@rebasepro/ui";
17
17
  import { Formex, FormexController, useCreateFormex } from "@rebasepro/forms";
18
18
 
19
- import { FieldBlock, isSelfLabellingProperty } from "./components/FieldBlock";
20
- import { FormRail } from "./components/FormRail";
19
+ import { FieldBlock, isSelfLabellingProperty, LABEL_ICON_SIZE, spanClass } from "./components/FieldBlock";
20
+ import { AdditionalFieldValue } from "../components/AdditionalFieldValue";
21
+ import { FormRail, RecordMeta } from "./components/FormRail";
21
22
  import { FormSections } from "./components/FormSections";
22
23
  import { useRailVisible } from "./components/useRailVisible";
23
- import { LabelWithIconAndTooltip } from "./components/LabelWithIconAndTooltip";
24
24
  import { PropertyFieldBinding } from "./PropertyFieldBinding";
25
25
  import { flattenKeys } from "@rebasepro/app";
26
26
  import { ErrorFocus } from "./components/ErrorFocus";
@@ -173,15 +173,30 @@ export function EntityForm<M extends Record<string, unknown>>({
173
173
  });
174
174
  };
175
175
 
176
- const [initialValues, initialDirty] = useMemo(() => {
177
- const initialValues = initialDirtyValues ? mergeDeep(baseInitialValues, initialDirtyValues) : baseInitialValues;
178
- const initialDirty = Boolean(initialDirtyValues) && initialDirtyValues && Object.keys(initialDirtyValues).length > 0;
179
- return [initialValues, initialDirty];
180
- }, [baseInitialValues, initialDirtyValues]);
176
+ /**
177
+ * What the form opens showing, when that is not what is stored — an edit
178
+ * carried over from the side panel's "open full screen", or a draft
179
+ * restored from the local-changes backup.
180
+ *
181
+ * The merge is deliberately *not* folded into the baseline. `mergeDeep`
182
+ * used to produce a single `initialValues` that served as both, with a
183
+ * `initialDirty: true` bolted on beside it to say what the baseline could
184
+ * no longer show — and that flag survived exactly one render, because the
185
+ * record's data arriving replaced the baseline and the form recomputed
186
+ * dirty as false against a baseline that already contained the edit.
187
+ *
188
+ * Read once, at mount: this is a handoff, and re-reading it every render
189
+ * tied the form to a cache that is cleared as soon as it has been consumed.
190
+ */
191
+ const initialModifiedValues = useRef(
192
+ initialDirtyValues && Object.keys(initialDirtyValues).length > 0
193
+ ? mergeDeep(baseInitialValues, initialDirtyValues)
194
+ : undefined
195
+ ).current;
181
196
 
182
197
  const internalFormex = useCreateFormex<M>({
183
- initialValues: initialValues as M,
184
- initialDirty,
198
+ initialValues: baseInitialValues as M,
199
+ initialModifiedValues: initialModifiedValues as M | undefined,
185
200
  debugId: `EntityForm:${path}/${entityIdProp}`,
186
201
  initialTouched: initialDirtyValues ?
187
202
  flattenKeys(initialDirtyValues!)
@@ -193,7 +208,9 @@ export function EntityForm<M extends Record<string, unknown>>({
193
208
  onSubmit,
194
209
  onReset: () => {
195
210
  onResetProp?.();
196
- onValuesModified?.(false, initialValues as M);
211
+ // The stored values — a reset returns the form to them, and by now
212
+ // that is the baseline whether or not it was at mount.
213
+ onValuesModified?.(false, baseInitialValues as M);
197
214
  },
198
215
  onValuesChangeDeferred: onValuesChangeDeferredProp,
199
216
  validation: async (values): Promise<Record<string, string>> => {
@@ -420,7 +437,7 @@ export function EntityForm<M extends Record<string, unknown>>({
420
437
  disabledProp
421
438
  || (!autoSave && formex.isSubmitting)
422
439
  || isReadOnly(property)
423
- || Boolean(property.admin?.disabled)
440
+ || isDisabled(property)
424
441
  || (!isNew && "isId" in property && Boolean(property.isId))
425
442
  || (isNew && isIdAndAuto)
426
443
  );
@@ -440,25 +457,26 @@ export function EntityForm<M extends Record<string, unknown>>({
440
457
  const child = AdditionalFieldBuilder
441
458
  ? <AdditionalFieldBuilder entity={entity} context={additionalFieldContext}/>
442
459
  : <div className={"w-full"}>
443
- {additionalField.value?.({
444
- entity,
445
- context: additionalFieldContext
446
- })?.toString()}
460
+ <AdditionalFieldValue
461
+ field={additionalField}
462
+ entity={entity}
463
+ context={additionalFieldContext}/>
447
464
  </div>;
448
465
 
449
466
  return (
467
+ // Through FieldBlock like every other cell, so a computed
468
+ // column is labelled at the same size, with the same gap to its
469
+ // box, as the property next to it on the grid.
450
470
  <div key={`additional_${field.key}`}
451
471
  className={spanClass(field.span)}>
452
- <LabelWithIconAndTooltip
453
- propertyKey={field.key}
454
- icon={<AlignLeftIcon size={iconSize.small}/>}
455
- title={additionalField.name}
456
- className={"text-text-secondary dark:text-text-secondary-dark"}/>
457
- <div className={cls(paperMixin, "w-full min-h-14 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
458
- <ErrorBoundary>
472
+ <FieldBlock propertyKey={field.key}
473
+ showLabel={true}
474
+ label={additionalField.name}
475
+ icon={<AlignLeftIcon size={LABEL_ICON_SIZE}/>}>
476
+ <div className={cls(paperMixin, "w-full min-h-12 p-4 md:p-6 overflow-x-auto no-scrollbar")}>
459
477
  {child}
460
- </ErrorBoundary>
461
- </div>
478
+ </div>
479
+ </FieldBlock>
462
480
  </div>
463
481
  );
464
482
  }
@@ -537,12 +555,25 @@ export function EntityForm<M extends Record<string, unknown>>({
537
555
  }
538
556
  ];
539
557
 
540
- return <FormSections
541
- sections={sections}
542
- collapsed={collapsedSections}
543
- onToggle={toggleSection}
544
- errorKeys={errorKeys}
545
- renderField={renderField}/>;
558
+ return <>
559
+ <FormSections
560
+ sections={sections}
561
+ collapsed={collapsedSections}
562
+ onToggle={toggleSection}
563
+ errorKeys={errorKeys}
564
+ renderField={renderField}/>
565
+
566
+ {/* Folded back with the rail's fields, and for the same reason. The
567
+ rail renders only where it has been measured to fit, so on the
568
+ split pane, the side panel and the dialog this block was dropped
569
+ outright and the record's id was nowhere on screen but the
570
+ truncated chip in the bar. */}
571
+ {!showRail && layout.showRecordMeta && status === "existing" && entity && (
572
+ <div className={cls("mt-8 pt-5 border-t max-w-sm", defaultBorderMixin)}>
573
+ <RecordMeta entity={entity as Entity<Record<string, unknown>>}/>
574
+ </div>
575
+ )}
576
+ </>;
546
577
  };
547
578
 
548
579
  const formRef = useRef<HTMLDivElement>(null);
@@ -666,7 +697,16 @@ export function EntityForm<M extends Record<string, unknown>>({
666
697
  <div
667
698
  id={`form_${path}`}
668
699
  className={cls(
669
- "@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
700
+ // Two rules widen the column, and they answer to
701
+ // different things on purpose. `@7xl/form` measures
702
+ // the form itself: full screen on a laptop is ~1400px
703
+ // of form, which after the 304px rail leaves room for
704
+ // the wider column with real gutters either side — and
705
+ // it stays 768 in the split pane and the side panel,
706
+ // which never get that wide. `2xl` is the older
707
+ // viewport rule, kept so a panel on a large monitor
708
+ // does not get *narrower* than it is today.
709
+ "@container/col w-full max-w-3xl @7xl/form:max-w-4xl 2xl:max-w-4xl flex flex-col",
670
710
  openEntityMode === "dialog"
671
711
  ? "pt-5 pb-8 px-6 sm:px-8"
672
712
  : "pt-6 pb-12 px-5 sm:px-8"
@@ -706,25 +746,6 @@ export function EntityForm<M extends Record<string, unknown>>({
706
746
  );
707
747
  }
708
748
 
709
- /**
710
- * Grid placement for a span.
711
- *
712
- * Written out as literal class strings rather than composed, because Tailwind
713
- * scans source text: a template literal would produce classes that exist at
714
- * runtime and were never generated into the stylesheet.
715
- *
716
- * Below the column breakpoint every field takes the single available column, so
717
- * the side panel, the dialog and mobile all get a plain stack.
718
- */
719
- function spanClass(span: 1 | 2 | 3 | 4): string {
720
- switch (span) {
721
- case 1: return "@2xl/col:col-span-1";
722
- case 2: return "@2xl/col:col-span-2";
723
- case 3: return "@2xl/col:col-span-3";
724
- case 4: return "@2xl/col:col-span-4";
725
- }
726
- }
727
-
728
749
  function useOnAutoSave<M extends Record<string, unknown>>(autoSave: undefined | boolean, formex: FormexController<M>, lastSavedValues: React.MutableRefObject<EntityValues<M> | undefined>, save: (values: EntityValues<M>) => Promise<Entity<M> | void>) {
729
750
  useEffect(() => {
730
751
  if (!autoSave) return;
@@ -35,8 +35,8 @@ import { EntityForm } from "./EntityForm";
35
35
  import {
36
36
  extractTouchedValues,
37
37
  removeEmptyContainers,
38
- getChanges,
39
38
  getInitialEntityValues,
39
+ getUnappliedLocalChanges,
40
40
  } from "./form_utils";
41
41
  import { mergeDeep } from "@rebasepro/utils";
42
42
 
@@ -127,22 +127,35 @@ export function EntityFormBinding<M extends Record<string, unknown>>({
127
127
 
128
128
  const [localChangesCleared, setLocalChangesCleared] = useState<boolean>(false);
129
129
 
130
+ // `initialDirtyValues` is deliberately *not* merged in here any more. This
131
+ // value is the form's baseline, and an edit folded into the baseline is
132
+ // indistinguishable from saved data — which is how an edit carried over
133
+ // from the side panel arrived reading "Saved". {@link EntityForm} applies
134
+ // it as an overlay on the values instead, so the difference survives.
135
+ //
136
+ // The auto-applied local-changes backup is still folded in, unchanged, and
137
+ // has the same shape of problem: a restored draft opens undirty and cannot
138
+ // be saved until a field is touched. Left as it is rather than changed
139
+ // blind, since nothing here exercises that path.
130
140
  const computedInitialValues = useMemo(() => {
131
- const withLocalChanges = autoApplyLocalChanges && localChangesDataRaw
141
+ return autoApplyLocalChanges && localChangesDataRaw
132
142
  ? mergeDeep(baseInitialValues, localChangesDataRaw as Partial<M>)
133
143
  : baseInitialValues;
134
- return initialDirtyValues ? mergeDeep(withLocalChanges, initialDirtyValues) : withLocalChanges;
135
- }, [autoApplyLocalChanges, localChangesDataRaw, baseInitialValues, initialDirtyValues]);
144
+ }, [autoApplyLocalChanges, localChangesDataRaw, baseInitialValues]);
136
145
 
137
146
  const localChangesData = useMemo(() => {
138
147
  if (!localChangesDataRaw) return undefined;
139
- const changes = getChanges(localChangesDataRaw, computedInitialValues);
140
- const cleaned = removeEmptyContainers(changes);
141
- if (cleaned && typeof cleaned === "object" && Object.keys(cleaned).length === 0) {
142
- return undefined;
143
- }
144
- return cleaned;
145
- }, [localChangesDataRaw, computedInitialValues]);
148
+ // What the form opens showing: the baseline plus any edit handed over by
149
+ // the layout this one replaced. See {@link getUnappliedLocalChanges} for
150
+ // why the banner is measured against that and not against the baseline.
151
+ const openingValues = initialDirtyValues
152
+ ? mergeDeep(computedInitialValues, initialDirtyValues)
153
+ : computedInitialValues;
154
+ return getUnappliedLocalChanges(
155
+ localChangesDataRaw as Partial<M>,
156
+ openingValues as Partial<M>
157
+ );
158
+ }, [localChangesDataRaw, computedInitialValues, initialDirtyValues]);
146
159
 
147
160
  const hasLocalChanges = !localChangesCleared && !!localChangesData && Object.keys(localChangesData as object).length > 0;
148
161
 
@@ -14,7 +14,7 @@ import { Field, FormexFieldProps, getIn } from "@rebasepro/forms";
14
14
  import { ReadOnlyFieldBinding } from "./field_bindings/ReadOnlyFieldBinding";
15
15
 
16
16
  import { isPropertyBuilder, resolveProperty } from "@rebasepro/common";
17
- import { isHidden, isReadOnly } from "@rebasepro/app";
17
+ import { isDisabled, isHidden, isReadOnly } from "@rebasepro/app";
18
18
  import { useAuthController, useCustomizationController } from "@rebasepro/app";
19
19
  import { Typography } from "@rebasepro/ui";
20
20
  import { getFieldConfig, getFieldId } from "../components/field_configs";
@@ -113,7 +113,7 @@ function PropertyFieldBindingInternal<M extends Record<string, unknown> = Record
113
113
  }) as Property | null;
114
114
 
115
115
  const readOnly = resolvedProperty ? isReadOnly(resolvedProperty) : true;
116
- const disabled = disabledProp || readOnly || Boolean(resolvedProperty?.admin?.disabled) || context.disabled;
116
+ const disabled = disabledProp || readOnly || (resolvedProperty ? isDisabled(resolvedProperty) : false) || context.disabled;
117
117
 
118
118
  if (resolvedProperty === null || isHidden(resolvedProperty)) {
119
119
  return <></>;