@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/dist/{CollectionEditorDialog-7b-kSLno.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-C-31uvpG.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BJylABq3.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/ClearFilterSortButton.d.ts +7 -1
  10. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  11. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  12. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  13. package/dist/components/CollectionViewBinding/SlotValue.d.ts +26 -0
  14. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  15. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -6
  16. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +1 -1
  17. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +26 -1
  18. package/dist/components/DetailViewBinding.d.ts +7 -0
  19. package/dist/components/DrawerNavigationGroup.d.ts +4 -2
  20. package/dist/components/DrawerNavigationItem.d.ts +13 -5
  21. package/dist/components/EditViewBinding.d.ts +22 -7
  22. package/dist/components/EntityIdentityBar.d.ts +24 -1
  23. package/dist/components/RelationSelector.d.ts +13 -0
  24. package/dist/data_export/export/export.d.ts +7 -0
  25. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  26. package/dist/data_export/export/index.d.ts +1 -0
  27. package/dist/data_import/utils/csv.d.ts +30 -0
  28. package/dist/data_import/utils/index.d.ts +2 -0
  29. package/dist/data_import/utils/save_entities.d.ts +38 -0
  30. package/dist/data_import/utils/transforms.d.ts +9 -0
  31. package/dist/editor.js +3 -0
  32. package/dist/editor.js.map +1 -1
  33. package/dist/{export-C-XG3aRh.js → export-L53WektO.js} +175 -39
  34. package/dist/export-L53WektO.js.map +1 -0
  35. package/dist/form/form_utils.d.ts +38 -1
  36. package/dist/{history-DfMuvPfr.js → history-D5SKygpJ.js} +2 -2
  37. package/dist/{history-DfMuvPfr.js.map → history-D5SKygpJ.js.map} +1 -1
  38. package/dist/{import-B0GyllB0.js → import-C_4edkoD.js} +5 -4
  39. package/dist/import-C_4edkoD.js.map +1 -0
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +96 -80
  42. package/dist/index.js.map +1 -1
  43. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  44. package/dist/util/view_constants.d.ts +24 -0
  45. package/dist/util/view_mode.d.ts +18 -0
  46. package/dist/{util-DHNXodBo.js → util-C-D5yD2n.js} +2173 -673
  47. package/dist/util-C-D5yD2n.js.map +1 -0
  48. package/package.json +10 -9
  49. package/src/collection_editor/serializable_types.ts +1 -0
  50. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  51. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  52. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  53. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  54. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  55. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  56. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  57. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  58. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  59. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  60. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  61. package/src/components/ClearFilterSortButton.tsx +16 -7
  62. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +2 -2
  63. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  64. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  65. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  66. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  67. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +18 -1
  68. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  69. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  70. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +816 -335
  71. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  72. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
  73. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  74. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +45 -10
  75. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  76. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  77. package/src/components/CollectionViewBinding/SlotValue.tsx +96 -0
  78. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  79. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -9
  80. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +3 -3
  81. package/src/components/CollectionViewBinding/SplitListView.tsx +36 -9
  82. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  83. package/src/components/CollectionViewBinding/useBoardDataController.tsx +11 -3
  84. package/src/components/CollectionViewBinding/usePreviewSlots.ts +76 -24
  85. package/src/components/CollectionViewBinding/utils.ts +7 -4
  86. package/src/components/DefaultDrawer.tsx +7 -0
  87. package/src/components/DetailViewBinding.tsx +33 -9
  88. package/src/components/DrawerNavigationGroup.tsx +12 -32
  89. package/src/components/DrawerNavigationItem.tsx +33 -12
  90. package/src/components/EditViewBinding.tsx +175 -28
  91. package/src/components/EntityIdentityBar.tsx +109 -17
  92. package/src/components/EntityInspector.tsx +24 -6
  93. package/src/components/EntityViewBinding.tsx +4 -2
  94. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  95. package/src/components/RelationSelector.tsx +61 -11
  96. package/src/components/SearchIconsView.tsx +14 -33
  97. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  98. package/src/components/SidePanelBinding.tsx +36 -25
  99. package/src/components/UserSelector.tsx +52 -9
  100. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  101. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  102. package/src/data_export/export/export.ts +63 -16
  103. package/src/data_export/export/fetch_export_data.ts +90 -0
  104. package/src/data_export/export/index.ts +1 -0
  105. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  106. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  107. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  108. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  109. package/src/data_import/utils/csv.ts +153 -0
  110. package/src/data_import/utils/data.ts +16 -2
  111. package/src/data_import/utils/file_to_json.ts +77 -6
  112. package/src/data_import/utils/index.ts +2 -0
  113. package/src/data_import/utils/save_entities.ts +138 -0
  114. package/src/data_import/utils/transforms.ts +23 -0
  115. package/src/editor/components/image-bubble.tsx +2 -0
  116. package/src/editor/selectors/link-selector.tsx +1 -0
  117. package/src/form/EntityForm.tsx +12 -3
  118. package/src/form/EntityFormBinding.tsx +8 -12
  119. package/src/form/PropertyFieldBinding.tsx +2 -2
  120. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  121. package/src/form/field_bindings/BinaryFieldBinding.tsx +1 -0
  122. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  123. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  124. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  125. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  126. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +15 -2
  127. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  128. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  129. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  130. package/src/form/field_bindings/VectorFieldBinding.tsx +1 -0
  131. package/src/form/form_utils.ts +78 -0
  132. package/src/index.ts +9 -1
  133. package/src/preview/PropertyPreview.tsx +7 -3
  134. package/src/preview/components/DatePreview.tsx +15 -2
  135. package/src/preview/components/ReferencePreview.tsx +5 -8
  136. package/src/preview/components/RelationPreview.tsx +8 -7
  137. package/src/preview/components/date_fns_locales.ts +125 -0
  138. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  139. package/src/util/navigation_utils.ts +4 -2
  140. package/src/util/property_utils.tsx +6 -1
  141. package/src/util/view_constants.ts +27 -0
  142. package/src/util/view_mode.ts +36 -0
  143. package/dist/CollectionEditorDialog-7b-kSLno.js.map +0 -1
  144. package/dist/PropertyEditView-C-31uvpG.js.map +0 -1
  145. package/dist/RouterCollectionsStudioView-BJylABq3.js.map +0 -1
  146. package/dist/export-C-XG3aRh.js.map +0 -1
  147. package/dist/import-B0GyllB0.js.map +0 -1
  148. package/dist/util-DHNXodBo.js.map +0 -1
@@ -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,7 +9,7 @@ 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";
@@ -437,7 +437,7 @@ export function EntityForm<M extends Record<string, unknown>>({
437
437
  disabledProp
438
438
  || (!autoSave && formex.isSubmitting)
439
439
  || isReadOnly(property)
440
- || Boolean(property.admin?.disabled)
440
+ || isDisabled(property)
441
441
  || (!isNew && "isId" in property && Boolean(property.isId))
442
442
  || (isNew && isIdAndAuto)
443
443
  );
@@ -697,7 +697,16 @@ export function EntityForm<M extends Record<string, unknown>>({
697
697
  <div
698
698
  id={`form_${path}`}
699
699
  className={cls(
700
- "@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",
701
710
  openEntityMode === "dialog"
702
711
  ? "pt-5 pb-8 px-6 sm:px-8"
703
712
  : "pt-6 pb-12 px-5 sm:px-8"
@@ -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
 
@@ -145,20 +145,16 @@ export function EntityFormBinding<M extends Record<string, unknown>>({
145
145
 
146
146
  const localChangesData = useMemo(() => {
147
147
  if (!localChangesDataRaw) return undefined;
148
- // Against what the form *opens showing*, not against the baseline. The
149
- // banner exists to offer a draft the form is not already displaying;
150
- // an edit carried over from the side panel is in both the backup and
151
- // the form, so measuring it against the baseline alone offered to
152
- // restore the values already on screen.
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.
153
151
  const openingValues = initialDirtyValues
154
152
  ? mergeDeep(computedInitialValues, initialDirtyValues)
155
153
  : computedInitialValues;
156
- const changes = getChanges(localChangesDataRaw, openingValues);
157
- const cleaned = removeEmptyContainers(changes);
158
- if (cleaned && typeof cleaned === "object" && Object.keys(cleaned).length === 0) {
159
- return undefined;
160
- }
161
- return cleaned;
154
+ return getUnappliedLocalChanges(
155
+ localChangesDataRaw as Partial<M>,
156
+ openingValues as Partial<M>
157
+ );
162
158
  }, [localChangesDataRaw, computedInitialValues, initialDirtyValues]);
163
159
 
164
160
  const hasLocalChanges = !localChangesCleared && !!localChangesData && Object.keys(localChangesData as object).length > 0;
@@ -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 <></>;
@@ -7,7 +7,7 @@ import { LabelWithIconAndTooltip } from "../components/LabelWithIconAndTooltip";
7
7
  import { PropertyFieldBinding } from "../PropertyFieldBinding";
8
8
  import { ExpandablePanel, Typography } from "@rebasepro/ui";
9
9
  import { getArrayResolvedProperties } from "@rebasepro/common";
10
- import { isReadOnly } from "@rebasepro/app";
10
+ import { isDisabled, isReadOnly } from "@rebasepro/app";
11
11
  import { getIconForProperty } from "../../util/property_utils";
12
12
  import { useClearRestoreValue } from "../useClearRestoreValue";
13
13
  import { useAuthController } from "@rebasepro/app";
@@ -65,7 +65,7 @@ export function ArrayCustomShapedFieldBinding({
65
65
  </>);
66
66
 
67
67
  const body = (resolvedProperties ?? []).map((childProperty, index) => {
68
- const thisDisabled = isReadOnly(childProperty) || Boolean(childProperty.admin?.disabled);
68
+ const thisDisabled = isReadOnly(childProperty) || isDisabled(childProperty);
69
69
  const fieldProps = {
70
70
  propertyKey: `${propertyKey}[${index}]`,
71
71
  disabled: disabled || thisDisabled,
@@ -139,6 +139,7 @@ export function BinaryFieldBinding({
139
139
  <div className={cls("flex flex-col gap-2 p-4 rounded-xl border", defaultBorderMixin)}>
140
140
  <TextField
141
141
  size={size}
142
+ aria-label={`${property.name ?? propertyKey} base64 value`}
142
143
  value={text}
143
144
  onChange={onChange}
144
145
  autoFocus={autoFocus ?? true}
@@ -13,7 +13,7 @@ import { EnumValuesChip } from "../../preview";
13
13
  import { DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, getDefaultValueFor } from "@rebasepro/common";
14
14
  import { getIconForProperty } from "../../util/property_utils";
15
15
  import { cls, ExpandablePanel, paperMixin, Select, SelectItem, Typography } from "@rebasepro/ui";
16
- import { useTranslation } from "@rebasepro/app";
16
+ import { isDisabled, useTranslation } from "@rebasepro/app";
17
17
  import { useClearRestoreValue } from "../useClearRestoreValue";
18
18
  import { ArrayContainer, ArrayEntryParams } from "../../components/ArrayContainer";
19
19
  import { mergeDeep } from "@rebasepro/utils";
@@ -94,8 +94,8 @@ export function BlockFieldBinding({
94
94
  addLabel={property.name ? t("add_to_field", { fieldName: property.name }) : t("add_entry")}
95
95
  buildEntry={buildEntry}
96
96
  onInternalIdAdded={setLastAddedId}
97
- disabled={isSubmitting || Boolean(property.admin?.disabled)}
98
- canAddElements={!property.admin?.disabled}
97
+ disabled={isSubmitting || isDisabled(property)}
98
+ canAddElements={!isDisabled(property)}
99
99
  onValueChange={(value) => setFieldValue(propertyKey, value)}
100
100
  newDefaultEntry={{
101
101
  [property.oneOf!.typeField ?? DEFAULT_ONE_OF_TYPE]: firstOneOfKey,
@@ -60,6 +60,10 @@ export function DateTimeFieldBinding({
60
60
  required={property.validation?.required}
61
61
  className={showError ? "text-red-500 dark:text-red-500" : "text-text-secondary dark:text-text-secondary-dark"}
62
62
  title={property.name ?? propertyKey}/>}
63
+ // The entity form sets `hideLabel` for every field FieldBlock
64
+ // labels on its behalf, and FieldBlock's label is a <span> that
65
+ // points at nothing — so without this the control is nameless.
66
+ aria-label={hideLabel ? (property.name ?? propertyKey) : undefined}
63
67
  />
64
68
  </PropertyIdCopyTooltip>
65
69
 
@@ -269,6 +269,7 @@ function MapKeyValueRow<T extends Record<string, unknown>>({
269
269
  if (type === "string" || type === "number") {
270
270
  return <TextField
271
271
  key={type}
272
+ aria-label={fieldKey ? `${t("value")}: ${fieldKey}` : t("value")}
272
273
  placeholder={"value"}
273
274
  value={entryValue as string | number | undefined}
274
275
  type={type === "number" ? "number" : "text"}
@@ -390,6 +391,7 @@ function MapKeyValueRow<T extends Record<string, unknown>>({
390
391
  <div className="w-[300px] max-w-[30%]">
391
392
  <TextField
392
393
  value={fieldKey}
394
+ aria-label={t("key")}
393
395
  placeholder={"key"}
394
396
  disabled={disabled || (entryValue !== undefined && entryValue !== null && entryValue !== "")}
395
397
  size={"medium"}
@@ -459,6 +461,7 @@ function ArrayKeyValueRow<T>({
459
461
  function buildInput(entryValue: unknown, type: DataType) {
460
462
  if (type === "string" || type === "number") {
461
463
  return <TextField value={entryValue as string | number | undefined}
464
+ aria-label={`Value ${index + 1}`}
462
465
  type={type === "number" ? "number" : "text"}
463
466
  size={"medium"}
464
467
  onChange={(event) => {
@@ -6,7 +6,7 @@ import { useTranslation } from "@rebasepro/app";
6
6
 
7
7
  import { ErrorBoundary } from "@rebasepro/ui";
8
8
  import { getIconForProperty } from "../../util/property_utils";
9
- import { isHidden, isReadOnly } from "@rebasepro/app";
9
+ import { isDisabled, isHidden, isReadOnly } from "@rebasepro/app";
10
10
  import { FieldHelperText } from "../components/FieldHelperText";
11
11
  import { LabelWithIconAndTooltip } from "../components/LabelWithIconAndTooltip";
12
12
  import { PropertyFieldBinding } from "../PropertyFieldBinding";
@@ -50,7 +50,7 @@ export function MapFieldBinding({
50
50
  {Object.entries(mapProperties)
51
51
  .filter(([_, property]) => !isHidden(property))
52
52
  .map(([entryKey, childProperty], index) => {
53
- const thisDisabled = isReadOnly(childProperty) || Boolean(childProperty.admin?.disabled);
53
+ const thisDisabled = isReadOnly(childProperty) || isDisabled(childProperty);
54
54
  const fieldBindingProps: PropertyFieldBindingProps<Record<string, unknown>> = {
55
55
  propertyKey: `${propertyKey}.${entryKey}`,
56
56
  disabled: disabled || thisDisabled,
@@ -45,7 +45,7 @@ export function ReadOnlyFieldBinding({
45
45
  icon={getIconForProperty(property, "small")}
46
46
  required={property.validation?.required}
47
47
  title={property.name ?? propertyKey}
48
- className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>
48
+ className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3"}/>
49
49
  }
50
50
 
51
51
  <div
@@ -59,13 +59,26 @@ export function ReadOnlyFieldBinding({
59
59
  // so a row holding one of each had its two boxes'
60
60
  // baselines and bottom edges disagree — the field looked
61
61
  // misaligned rather than uneditable.
62
- : "rounded-lg border border-surface-200 dark:border-surface-700 px-4 md:px-6 min-h-12 opacity-80"
62
+ // `px-3` is what `TextField` gives its input, so a
63
+ // read-only value starts on the same vertical line as
64
+ // the text beside it. It was `px-4 md:px-6`, which in a
65
+ // one-column span spent a sixth of the box on empty
66
+ // margin and pushed short values towards the middle.
67
+ : "rounded-lg border border-surface-200 dark:border-surface-700 px-3 min-h-12 opacity-80"
63
68
  )}>
64
69
 
65
70
  <ErrorBoundary>
71
+ {/* The field is labelled either just above by this
72
+ component, or by the `FieldBlock` that asked for
73
+ `hideLabel` — never by the preview. `BooleanPreview`
74
+ prints the property name beside its checkbox, so a
75
+ read-only boolean read as "Procesado por LLM" over a
76
+ checkbox saying "Procesado por LLM", wrapped over two
77
+ lines in a one-column span. */}
66
78
  <PropertyPreview propertyKey={propertyKey}
67
79
  value={value}
68
80
  property={property}
81
+ hideLabel={hideLabel || !minimalistView}
69
82
  size={"medium"}/>
70
83
  </ErrorBoundary>
71
84
 
@@ -11,7 +11,7 @@ import { getIconForProperty } from "../../util/property_utils";
11
11
  import { PropertyFieldBinding } from "../PropertyFieldBinding";
12
12
  import { ExpandablePanel, Typography } from "@rebasepro/ui";
13
13
  import { useClearRestoreValue } from "../useClearRestoreValue";
14
- import { useAuthController, useTranslation } from "@rebasepro/app";
14
+ import { isDisabled, useAuthController, useTranslation } from "@rebasepro/app";
15
15
  import { mergeDeep } from "@rebasepro/utils";
16
16
 
17
17
  /**
@@ -96,14 +96,14 @@ export function RepeatFieldBinding({
96
96
  </ErrorBoundary>;
97
97
  };
98
98
 
99
- const canAddElements = !property.admin?.disabled && !isSubmitting && !disabled && (property.admin?.canAddElements || property.admin?.canAddElements === undefined);
99
+ const canAddElements = !isDisabled(property) && !isSubmitting && !disabled && (property.admin?.canAddElements || property.admin?.canAddElements === undefined);
100
100
  const sortable = property.admin?.sortable === undefined ? true : property.admin.sortable;
101
101
  const arrayContainer = <ArrayContainer droppableId={propertyKey}
102
102
  addLabel={property.name ? t("add_to_field", { fieldName: property.name }) : t("add_entry")}
103
103
  value={(value as unknown[]) ?? []}
104
104
  buildEntry={buildEntry}
105
105
  onInternalIdAdded={setLastAddedId}
106
- disabled={isSubmitting || Boolean(property.admin?.disabled)}
106
+ disabled={isSubmitting || isDisabled(property)}
107
107
  canAddElements={canAddElements}
108
108
  sortable={sortable}
109
109
  newDefaultEntry={getDefaultValueFor(property.of)}
@@ -8,7 +8,7 @@ import { useDropzone } from "react-dropzone";
8
8
  import { FieldHelperText } from "../components/FieldHelperText";
9
9
  import { LabelWithIconAndTooltip } from "../components/LabelWithIconAndTooltip";
10
10
 
11
- import { isReadOnly } from "@rebasepro/app";
11
+ import { isDisabled, isReadOnly } from "@rebasepro/app";
12
12
  import { getIconForProperty } from "../../util/property_utils";
13
13
  import { useAuthController, useSnackbarController, useStorageSource, useTranslation } from "@rebasepro/app";
14
14
  import { StorageSourceContext } from "@rebasepro/app";
@@ -74,7 +74,7 @@ export function StorageUploadFieldBinding({
74
74
  const authController = useAuthController();
75
75
 
76
76
  const storageSource = useStorageSource();
77
- const disabled = isReadOnly(property) || !!property.admin?.disabled || isSubmitting || context.disabled;
77
+ const disabled = isReadOnly(property) || isDisabled(property) || isSubmitting || context.disabled;
78
78
 
79
79
  const {
80
80
  internalValue,
@@ -97,6 +97,22 @@ export function TextFieldBinding<T extends string | number>({
97
97
  required={property.validation?.required || property.isId === true}
98
98
  title={property.name ?? propertyKey}/>
99
99
  );
100
+
101
+ // The control needs a name of its own in two cases, and between them they
102
+ // cover every text field in an entity form:
103
+ //
104
+ // - `hideLabel`, which the form sets for anything FieldBlock labels for it
105
+ // (`hideLabel: !selfLabelling`). FieldBlock draws the name in a <span>
106
+ // inside a <div> — not a <label>, and pointing at nothing — so suppressing
107
+ // the field's own label left the input with no accessible name at all and
108
+ // a screen reader fell back to reading its value back to you.
109
+ // - the multiline branch below, which never associated anything: its label
110
+ // is a decorative absolutely-positioned <div>, so it is unnamed whether or
111
+ // not the form also drew one.
112
+ //
113
+ // The single-line TextField is already handled by the kit when a label is
114
+ // present — it wires `aria-labelledby` — so it only needs this as a fallback.
115
+ const accessibleName = property.name ?? propertyKey;
100
116
  return (<>
101
117
  <PropertyIdCopyTooltip propertyKey={propertyKey}>
102
118
  {isMultiline ? (
@@ -115,6 +131,7 @@ export function TextFieldBinding<T extends string | number>({
115
131
  onChange={onChange}
116
132
  autoFocus={autoFocus}
117
133
  disabled={disabled}
134
+ aria-label={accessibleName}
118
135
  className={cls(
119
136
  "rounded-md resize-none w-full outline-none text-base bg-transparent min-h-[64px] px-3",
120
137
  label ? "pt-8 pb-2" : "py-2",
@@ -138,6 +155,7 @@ export function TextFieldBinding<T extends string | number>({
138
155
  onChange={onChange}
139
156
  autoFocus={autoFocus}
140
157
  label={label}
158
+ aria-label={label ? undefined : accessibleName}
141
159
  type={inputType}
142
160
  disabled={disabled}
143
161
  endAdornment={
@@ -166,6 +166,7 @@ export function VectorFieldBinding({
166
166
  <div className={cls("flex flex-col gap-2 p-4 rounded-xl border bg-surface-50/20 dark:bg-surface-800/10", defaultBorderMixin)}>
167
167
  <TextField
168
168
  size={size}
169
+ aria-label={`${property.name ?? propertyKey} vector values`}
169
170
  value={textValue}
170
171
  onChange={onChange}
171
172
  autoFocus={true}
@@ -114,6 +114,84 @@ export function getChanges<T extends object>(source: Partial<T>, comparison: Par
114
114
  return changes;
115
115
  }
116
116
 
117
+ /**
118
+ * What the local-changes backup still has to offer, given what the form is
119
+ * already showing — the answer the "unsaved local changes" banner is asking for.
120
+ * `undefined` means there is nothing left to apply, and no banner.
121
+ *
122
+ * Measured against what the form *opens showing*, not against the stored
123
+ * record. The banner exists to offer a draft the form is not already displaying,
124
+ * and an edit carried across a change of layout is in both the backup and the
125
+ * form: measuring against the stored values alone offered to restore the values
126
+ * already on screen, over an edit the user had never left.
127
+ */
128
+ export function getUnappliedLocalChanges<M extends Record<string, unknown>>(
129
+ backupValues: Partial<M>,
130
+ openingValues: Partial<M>
131
+ ): Partial<M> | undefined {
132
+ const changes = getChanges(backupValues, openingValues);
133
+ const cleaned = removeEmptyContainers(changes);
134
+ if (!cleaned || typeof cleaned !== "object" || Object.keys(cleaned).length === 0) {
135
+ return undefined;
136
+ }
137
+ return cleaned as Partial<M>;
138
+ }
139
+
140
+ /**
141
+ * What travels when the same record changes layout — the split's "hide list",
142
+ * full screen's "show list", the side panel's "open full screen". One mounted
143
+ * form is replaced by another showing the same record, so the edit in progress
144
+ * has to be handed over; left to the local-changes backup alone, the new form
145
+ * met it as a draft from a closed tab and raised the "unsaved local changes"
146
+ * banner over changes the user had made a second earlier and never left.
147
+ *
148
+ * Returns `undefined` when there is nothing to carry, which is its own answer: a
149
+ * record nobody edited must open in the next layout exactly as clean as it was.
150
+ *
151
+ * Only what was edited here travels, keyed by `touched` — the same subset the
152
+ * backup stores. Carrying the whole record instead marks every field touched in
153
+ * the receiving form, which lights up every empty required field as an error and
154
+ * writes the entire record back out as a "local change".
155
+ */
156
+ export function getEditHandoffValues<M extends Record<string, unknown>>({
157
+ status,
158
+ dirty,
159
+ values,
160
+ touched,
161
+ storedValues
162
+ }: {
163
+ status: EntityStatus;
164
+ /** Whether `values` differ from what is stored. */
165
+ dirty: boolean;
166
+ values: Partial<M>;
167
+ touched: Record<string, boolean>;
168
+ /** The stored record, for the fallback below. Absent for a new one. */
169
+ storedValues?: Partial<M>;
170
+ }): Partial<M> | undefined {
171
+
172
+ if (!values) return undefined;
173
+
174
+ // A record with nothing stored behind it carries whole: there is no
175
+ // baseline to fall back to, so anything not carried is simply lost.
176
+ if (status === "new" || status === "copy") {
177
+ return Object.keys(values).length > 0 ? values : undefined;
178
+ }
179
+
180
+ if (!dirty) return undefined;
181
+
182
+ const touchedValues = removeEmptyContainers(extractTouchedValues(values, touched ?? {})) as Partial<M> | undefined;
183
+ if (touchedValues && Object.keys(touchedValues).length > 0) {
184
+ return touchedValues;
185
+ }
186
+
187
+ // Dirty with nothing touched — a value set programmatically, by a plugin or
188
+ // a custom field that writes through `setFieldValue` without the blur that
189
+ // marks it. Diffing against the stored record still finds it, and dropping
190
+ // it here would hand over a form that opens clean over an edit.
191
+ const changes = getChanges(values, storedValues ?? {});
192
+ return Object.keys(changes).length > 0 ? changes : undefined;
193
+ }
194
+
117
195
  export function getInitialEntityValues<M extends Record<string, unknown>>(
118
196
  authController: AuthController,
119
197
  collection: AdminCollection,
package/src/index.ts CHANGED
@@ -44,6 +44,12 @@ export {
44
44
  Drawer,
45
45
  DefaultDrawer,
46
46
  DrawerFooterActions,
47
+ // The two rows the `Shell.DrawerNavigation*` overrides replace. Public so an
48
+ // app can wrap the stock ones — pass different props, add a context — instead of
49
+ // reimplementing a nav row and drifting from the framework's hover, active and
50
+ // tooltip behaviour on the next release.
51
+ DrawerNavigationItem,
52
+ DrawerNavigationGroup,
47
53
  AdminModeSyncer,
48
54
  // ContentHomePage is lazy-loaded — not re-exported here
49
55
  RebaseAdmin,
@@ -60,7 +66,9 @@ export type {
60
66
  EntityViewBindingProps,
61
67
  SelectionProps,
62
68
  SelectableTableProps,
63
- CollectionPanelProps
69
+ CollectionPanelProps,
70
+ DrawerNavigationGroupProps,
71
+ DrawerNavigationItemProps
64
72
  } from "./components";
65
73
 
66
74
  export * from "./hooks";