@rebasepro/admin 0.13.0 → 0.13.1-canary.g06dbe5b
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-CjXkWZ64.js → CollectionEditorDialog-C3DbFNqp.js} +3 -3
- package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-C3DbFNqp.js.map} +1 -1
- package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DFH8rXRN.js} +5 -5
- package/dist/PropertyEditView-DFH8rXRN.js.map +1 -0
- package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-DiUDOu4b.js} +9 -13
- package/dist/RouterCollectionsStudioView-DiUDOu4b.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/AdditionalFieldValue.d.ts +25 -0
- package/dist/components/ClearFilterSortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
- 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 +63 -0
- package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
- package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
- package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
- package/dist/components/DetailViewBinding.d.ts +15 -0
- package/dist/components/DrawerNavigationGroup.d.ts +9 -6
- package/dist/components/DrawerNavigationItem.d.ts +16 -6
- package/dist/components/EditViewBinding.d.ts +30 -1
- package/dist/components/EntityDisplayHeader.d.ts +53 -0
- package/dist/components/EntityIdentityBar.d.ts +43 -1
- package/dist/components/EntityViewBinding.d.ts +60 -6
- package/dist/components/RebaseLayout.d.ts +1 -1
- package/dist/components/RebaseShell.d.ts +1 -1
- package/dist/components/RelationSelector.d.ts +8 -1
- package/dist/components/UserSelector.d.ts +8 -1
- package/dist/components/app/Scaffold.d.ts +9 -3
- package/dist/components/index.d.ts +2 -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/{export-J79rMoNj.js → export-Bd5i6Uvm.js} +175 -39
- package/dist/export-Bd5i6Uvm.js.map +1 -0
- package/dist/form/components/FieldBlock.d.ts +58 -4
- package/dist/form/components/FormRail.d.ts +13 -0
- package/dist/form/components/FormSections.d.ts +15 -1
- package/dist/form/components/useRailVisible.d.ts +5 -4
- package/dist/form/form_utils.d.ts +38 -1
- package/dist/{history-D7L_JlLO.js → history-D9R6LUrH.js} +4 -4
- package/dist/{history-D7L_JlLO.js.map → history-D9R6LUrH.js.map} +1 -1
- package/dist/hooks/useEntityDisplay.d.ts +102 -0
- package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
- package/dist/{import-BvYl--U4.js → import-pt_j-0G3.js} +6 -5
- package/dist/import-pt_j-0G3.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +158 -60
- package/dist/index.js.map +1 -1
- package/dist/preview/compact_text.d.ts +23 -0
- package/dist/preview/components/BooleanPreview.d.ts +3 -1
- package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
- package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
- package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
- package/dist/routes/RebaseRoute.d.ts +1 -1
- package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
- package/dist/util/number_format.d.ts +14 -0
- package/dist/util/previews.d.ts +9 -2
- package/dist/util/view_constants.d.ts +24 -0
- package/dist/util/view_mode.d.ts +18 -0
- package/dist/{util-GiwPgxnL.js → util-nX_-JRuF.js} +3984 -1774
- package/dist/util-nX_-JRuF.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/CollectionsStudioView.tsx +9 -11
- 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/useLocalCollectionsConfigController.tsx +10 -3
- package/src/components/AdditionalFieldValue.tsx +68 -0
- package/src/components/ClearFilterSortButton.tsx +16 -7
- package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
- 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 +26 -27
- package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
- package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
- package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
- package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
- package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +51 -12
- package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
- package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
- package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
- package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
- package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
- package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
- package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
- package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
- package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
- package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
- package/src/components/CollectionViewBinding/useBoardDataController.tsx +23 -6
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
- package/src/components/CollectionViewBinding/utils.ts +7 -4
- package/src/components/DefaultDrawer.tsx +19 -23
- package/src/components/DetailViewBinding.tsx +109 -62
- package/src/components/DrawerNavigationGroup.tsx +16 -39
- package/src/components/DrawerNavigationItem.tsx +44 -16
- package/src/components/EditViewBinding.tsx +233 -50
- package/src/components/EntityDisplayHeader.tsx +170 -0
- package/src/components/EntityIdentityBar.tsx +163 -23
- package/src/components/EntityInspector.tsx +24 -6
- package/src/components/EntityPreviewBinding.tsx +58 -18
- package/src/components/EntityViewBinding.tsx +344 -41
- package/src/components/InlineEntityPreview.tsx +10 -10
- package/src/components/RebaseLayout.tsx +2 -2
- package/src/components/RebaseShell.tsx +2 -2
- package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
- package/src/components/RelationSelector.tsx +35 -10
- package/src/components/SideDialogs.tsx +15 -3
- package/src/components/SidePanelBinding.tsx +122 -66
- package/src/components/UserSelector.tsx +67 -13
- package/src/components/app/Scaffold.tsx +112 -10
- package/src/components/history/LastEditedByIndicator.tsx +7 -12
- package/src/components/index.ts +3 -0
- 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/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 +51 -5
- 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/form/EntityForm.tsx +74 -53
- package/src/form/EntityFormBinding.tsx +24 -11
- package/src/form/PropertyFieldBinding.tsx +2 -2
- package/src/form/components/FieldBlock.tsx +80 -10
- package/src/form/components/FormRail.tsx +19 -2
- package/src/form/components/FormSections.tsx +32 -5
- package/src/form/components/StorageUploadProgress.tsx +9 -3
- package/src/form/components/useRailVisible.tsx +5 -4
- package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
- package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
- package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
- package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
- package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
- package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
- package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
- package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
- package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
- package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
- package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
- package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
- package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
- package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
- package/src/form/form_utils.ts +78 -0
- package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
- package/src/hooks/useEntityDisplay.tsx +262 -0
- package/src/hooks/useEntityDisplayTitle.tsx +27 -38
- package/src/index.ts +9 -1
- package/src/preview/PropertyPreview.tsx +25 -5
- package/src/preview/compact_text.ts +68 -0
- package/src/preview/components/BooleanPreview.tsx +5 -2
- package/src/preview/components/ReferencePreview.tsx +5 -8
- package/src/preview/components/RelationPreview.tsx +8 -7
- package/src/preview/components/StorageThumbnail.tsx +5 -2
- package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
- package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
- package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
- package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
- package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
- package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
- package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
- package/src/routes/RebaseRoute.tsx +62 -4
- package/src/types/components/PropertyPreviewProps.tsx +21 -0
- package/src/util/navigation_utils.ts +4 -2
- package/src/util/number_format.ts +46 -0
- package/src/util/previews.ts +8 -55
- 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/PropertyEditView-W8I3YV89.js.map +0 -1
- package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
- package/dist/export-J79rMoNj.js.map +0 -1
- package/dist/import-BvYl--U4.js.map +0 -1
- package/dist/util-GiwPgxnL.js.map +0 -1
|
@@ -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
|
-
|
|
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") {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ExcelJS from "exceljs";
|
|
2
2
|
import { getWorksheetHeaders } from "./file_headers";
|
|
3
3
|
import { mapJsonParse, unflattenObject } from "./transforms";
|
|
4
|
+
import { parseCsvToObjects } from "./csv";
|
|
5
|
+
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
4
6
|
|
|
5
7
|
// Re-export for backwards compat (tests import from this file)
|
|
6
8
|
export { unflattenObject } from "./transforms";
|
|
@@ -10,9 +12,53 @@ type ConversionResult = {
|
|
|
10
12
|
propertiesOrder: string[]
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Whether this file is delimited text rather than a workbook.
|
|
17
|
+
*
|
|
18
|
+
* Browsers report `text/csv`, `application/csv` or nothing at all for the same
|
|
19
|
+
* `.csv`, so the extension is the reliable half of the test.
|
|
20
|
+
*/
|
|
21
|
+
function isCsvFile(file: File): boolean {
|
|
22
|
+
const name = (file.name ?? "").toLowerCase();
|
|
23
|
+
if (name.endsWith(".csv") || name.endsWith(".tsv")) return true;
|
|
24
|
+
return file.type === "text/csv" || file.type === "application/csv";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Turn the parsed cells of one sheet/file into the entity-shaped rows the import maps. */
|
|
28
|
+
function toImportRows(rows: Array<Record<string, unknown>>): object[] {
|
|
29
|
+
return rows.map(mapJsonParse).map(unflattenObject);
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
export function convertFileToJson(file: File): Promise<ConversionResult> {
|
|
14
33
|
return new Promise((resolve, reject) => {
|
|
15
|
-
if (file
|
|
34
|
+
if (isCsvFile(file)) {
|
|
35
|
+
// Not the ExcelJS branch: `workbook.xlsx.load` is a zip reader, and
|
|
36
|
+
// every CSV — the format this admin exports by default — died on it
|
|
37
|
+
// with "Can't find end of central directory : is this a zip file ?".
|
|
38
|
+
console.debug("Converting CSV file to JSON", file.name);
|
|
39
|
+
const reader = new FileReader();
|
|
40
|
+
reader.onload = function (e) {
|
|
41
|
+
try {
|
|
42
|
+
const { headers, data } = parseCsvToObjects(e.target?.result as string);
|
|
43
|
+
if (headers.length === 0) {
|
|
44
|
+
reject(new Error("The CSV file is empty"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve({
|
|
48
|
+
data: toImportRows(data),
|
|
49
|
+
propertiesOrder: headers
|
|
50
|
+
});
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error("Error parsing CSV file", err);
|
|
53
|
+
reject(err);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
reader.onerror = () => reject(reader.error ?? new Error("Could not read the file"));
|
|
57
|
+
// Explicit UTF-8: the pre-ExcelJS implementation passed
|
|
58
|
+
// `codepage: 65001` for the same reason, and the browser's default
|
|
59
|
+
// guess mangles accented and CJK text.
|
|
60
|
+
reader.readAsText(file, "utf-8");
|
|
61
|
+
} else if (file.type === "application/json") {
|
|
16
62
|
console.debug("Converting JSON file to JSON", file.name);
|
|
17
63
|
const reader = new FileReader();
|
|
18
64
|
reader.onload = function (e) {
|
|
@@ -57,17 +103,17 @@ export function convertFileToJson(file: File): Promise<ConversionResult> {
|
|
|
57
103
|
const obj: Record<string, any> = {};
|
|
58
104
|
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
|
59
105
|
const header = headers[colNumber - 1];
|
|
60
|
-
|
|
106
|
+
// A `__proto__` header would be the prototype setter
|
|
107
|
+
// here rather than a column; refused, as in `csv.ts`.
|
|
108
|
+
if (header && !isPrototypePollutingKey(header)) {
|
|
61
109
|
obj[header] = cell.value;
|
|
62
110
|
}
|
|
63
111
|
});
|
|
64
112
|
parsedData.push(obj);
|
|
65
113
|
});
|
|
66
114
|
|
|
67
|
-
const cleanedData = parsedData.map(mapJsonParse);
|
|
68
|
-
const jsonData = cleanedData.map(unflattenObject);
|
|
69
115
|
resolve({
|
|
70
|
-
data:
|
|
116
|
+
data: toImportRows(parsedData),
|
|
71
117
|
propertiesOrder: headers
|
|
72
118
|
});
|
|
73
119
|
} catch (err) {
|
|
@@ -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) {
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
||
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
<
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
className={"
|
|
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
|
-
</
|
|
461
|
-
</
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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;
|