@rebasepro/admin 0.13.0 → 0.13.1-canary.g18cfeb7

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 (143) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CUFXevVg.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CUFXevVg.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DDUC7giL.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-DDUC7giL.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-vzS848Gf.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-vzS848Gf.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/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  12. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  13. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  14. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  15. package/dist/components/DetailViewBinding.d.ts +15 -0
  16. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  17. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  18. package/dist/components/EditViewBinding.d.ts +30 -1
  19. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  20. package/dist/components/EntityIdentityBar.d.ts +43 -1
  21. package/dist/components/EntityViewBinding.d.ts +60 -6
  22. package/dist/components/RebaseLayout.d.ts +1 -1
  23. package/dist/components/RebaseShell.d.ts +1 -1
  24. package/dist/components/RelationSelector.d.ts +8 -1
  25. package/dist/components/UserSelector.d.ts +8 -1
  26. package/dist/components/app/Scaffold.d.ts +9 -3
  27. package/dist/components/index.d.ts +2 -0
  28. package/dist/{export-J79rMoNj.js → export-5bl2EoSs.js} +2 -2
  29. package/dist/{export-J79rMoNj.js.map → export-5bl2EoSs.js.map} +1 -1
  30. package/dist/form/components/FieldBlock.d.ts +58 -4
  31. package/dist/form/components/FormRail.d.ts +13 -0
  32. package/dist/form/components/FormSections.d.ts +15 -1
  33. package/dist/form/components/useRailVisible.d.ts +5 -4
  34. package/dist/form/form_utils.d.ts +38 -1
  35. package/dist/{history-D7L_JlLO.js → history-BuKKAhoY.js} +4 -4
  36. package/dist/{history-D7L_JlLO.js.map → history-BuKKAhoY.js.map} +1 -1
  37. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  38. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  39. package/dist/{import-BvYl--U4.js → import-BhdWs34H.js} +3 -3
  40. package/dist/{import-BvYl--U4.js.map → import-BhdWs34H.js.map} +1 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +152 -57
  43. package/dist/index.js.map +1 -1
  44. package/dist/preview/compact_text.d.ts +23 -0
  45. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  46. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  47. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  48. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  49. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  51. package/dist/routes/RebaseRoute.d.ts +1 -1
  52. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  53. package/dist/util/number_format.d.ts +14 -0
  54. package/dist/util/previews.d.ts +9 -2
  55. package/dist/{util-GiwPgxnL.js → util-DNG7iK0w.js} +3756 -2300
  56. package/dist/util-DNG7iK0w.js.map +1 -0
  57. package/package.json +9 -9
  58. package/src/collection_editor/serializable_types.ts +1 -0
  59. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  60. package/src/components/AdditionalFieldValue.tsx +68 -0
  61. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  62. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  63. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  64. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  65. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  66. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +723 -348
  67. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  68. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
  69. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
  70. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  71. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  72. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  73. package/src/components/CollectionViewBinding/SplitListView.tsx +33 -2
  74. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  75. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  76. package/src/components/CollectionViewBinding/utils.ts +7 -4
  77. package/src/components/DefaultDrawer.tsx +19 -23
  78. package/src/components/DetailViewBinding.tsx +93 -56
  79. package/src/components/DrawerNavigationGroup.tsx +16 -39
  80. package/src/components/DrawerNavigationItem.tsx +44 -16
  81. package/src/components/EditViewBinding.tsx +215 -42
  82. package/src/components/EntityDisplayHeader.tsx +170 -0
  83. package/src/components/EntityIdentityBar.tsx +163 -23
  84. package/src/components/EntityInspector.tsx +24 -6
  85. package/src/components/EntityPreviewBinding.tsx +58 -18
  86. package/src/components/EntityViewBinding.tsx +342 -41
  87. package/src/components/InlineEntityPreview.tsx +10 -10
  88. package/src/components/RebaseLayout.tsx +2 -2
  89. package/src/components/RebaseShell.tsx +2 -2
  90. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  91. package/src/components/RelationSelector.tsx +15 -4
  92. package/src/components/SideDialogs.tsx +15 -3
  93. package/src/components/SidePanelBinding.tsx +122 -66
  94. package/src/components/UserSelector.tsx +15 -4
  95. package/src/components/app/Scaffold.tsx +112 -10
  96. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  97. package/src/components/index.ts +3 -0
  98. package/src/data_import/utils/data.ts +9 -1
  99. package/src/form/EntityForm.tsx +64 -52
  100. package/src/form/EntityFormBinding.tsx +24 -11
  101. package/src/form/PropertyFieldBinding.tsx +2 -2
  102. package/src/form/components/FieldBlock.tsx +80 -10
  103. package/src/form/components/FormRail.tsx +19 -2
  104. package/src/form/components/FormSections.tsx +32 -5
  105. package/src/form/components/StorageUploadProgress.tsx +9 -3
  106. package/src/form/components/useRailVisible.tsx +5 -4
  107. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  108. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  109. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  110. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  111. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  112. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  113. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  114. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
  115. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  116. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  117. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  118. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  119. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  120. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  121. package/src/form/form_utils.ts +78 -0
  122. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  123. package/src/hooks/useEntityDisplay.tsx +262 -0
  124. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  125. package/src/index.ts +9 -1
  126. package/src/preview/PropertyPreview.tsx +25 -5
  127. package/src/preview/compact_text.ts +68 -0
  128. package/src/preview/components/BooleanPreview.tsx +5 -2
  129. package/src/preview/components/StorageThumbnail.tsx +5 -2
  130. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  131. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  132. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  133. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  134. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  135. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  136. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  137. package/src/routes/RebaseRoute.tsx +62 -4
  138. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  139. package/src/util/number_format.ts +46 -0
  140. package/src/util/previews.ts +8 -55
  141. package/src/util/property_utils.tsx +6 -1
  142. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  143. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -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);
@@ -706,25 +737,6 @@ export function EntityForm<M extends Record<string, unknown>>({
706
737
  );
707
738
  }
708
739
 
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
740
  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
741
  useEffect(() => {
730
742
  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 <></>;
@@ -1,9 +1,33 @@
1
1
  import type { Property } from "@rebasepro/types";
2
+ import type { PropertySpan } from "@rebasepro/admin-types";
2
3
  import React from "react";
3
4
  import { cls, ErrorBoundary, Typography } from "@rebasepro/ui";
4
5
  import { getIconForProperty } from "../../util/property_utils";
5
6
  import { PropertyIdCopyTooltip } from "../../components/PropertyIdCopyTooltip";
6
7
 
8
+ /**
9
+ * Grid placement for a span.
10
+ *
11
+ * Written out as literal class strings rather than composed, because Tailwind
12
+ * scans source text: a template literal would produce classes that exist at
13
+ * runtime and were never generated into the stylesheet.
14
+ *
15
+ * Below the column breakpoint every field takes the single available column, so
16
+ * the side panel, the dialog and mobile all get a plain stack.
17
+ *
18
+ * Lives here rather than in the form, because the read-only rendering of a
19
+ * record places its fields on the same grid — a record that changes shape when
20
+ * you press Edit is the bug this whole layout exists to avoid.
21
+ */
22
+ export function spanClass(span: PropertySpan): string {
23
+ switch (span) {
24
+ case 1: return "@2xl/col:col-span-1";
25
+ case 2: return "@2xl/col:col-span-2";
26
+ case 3: return "@2xl/col:col-span-3";
27
+ case 4: return "@2xl/col:col-span-4";
28
+ }
29
+ }
30
+
7
31
  /**
8
32
  * Does this property's editor supply its own heading?
9
33
  *
@@ -46,28 +70,71 @@ export function isSelfLabellingProperty(property: Property | undefined): boolean
46
70
  }
47
71
  }
48
72
 
73
+ /**
74
+ * The type icon in a field label, in px.
75
+ *
76
+ * Matched to the label's 13px rather than taken from `iconSize.smallest` (16),
77
+ * which is sized for buttons. Exported so the surfaces that draw a label
78
+ * themselves — the additional-field cards, the rail — can stay in step.
79
+ */
80
+ export const LABEL_ICON_SIZE = 14;
81
+
49
82
  export interface FieldBlockProps {
50
83
  propertyKey: string;
51
84
  property?: Property;
52
85
  /** Rendered by the form rather than by the field, unless self-labelling. */
53
86
  showLabel: boolean;
87
+ /**
88
+ * Which of the two renderings this block frames.
89
+ *
90
+ * The **label is identical in both** — same size, same weight, same colour,
91
+ * same type icon, same required marker. A field is the same field whether or
92
+ * not you are editing it, and a record whose labels restyle themselves under
93
+ * Edit reads as two different screens.
94
+ *
95
+ * What `"read"` drops is the *description*, which is not part of the label:
96
+ * it is input help. "Sum of all line items before tax and shipping" under a
97
+ * number that is visibly a sum tells a reader nothing, and a line of it
98
+ * under every field is what a record has instead of room.
99
+ */
100
+ mode?: "edit" | "read";
101
+ /**
102
+ * Label text, when there is no property to take it from.
103
+ *
104
+ * For additional fields, which are columns a collection computes rather
105
+ * than stores. They go on the same grid as the properties and so have to
106
+ * carry the same label — drawing their own is how they ended up a size out
107
+ * and a couple of pixels off from the field beside them.
108
+ */
109
+ label?: string;
110
+ /** Type icon, when there is no property to derive one from. */
111
+ icon?: React.ReactNode;
54
112
  children: React.ReactNode;
55
113
  }
56
114
 
57
115
  /**
58
116
  * One field's worth of the form grid: label, control, description.
59
117
  *
60
- * The label carries a small type icon so a number reads differently from a
61
- * relation at a glance, and the description sits under the control where it
62
- * annotates what you just filled in.
118
+ * The same block frames the read-only rendering of a record, so a field sits in
119
+ * the same place, at the same width, under the same label whether or not you are
120
+ * editing it see {@link EntityViewBinding}.
121
+ *
122
+ * The label is one size down from the control it names (13px against the body's
123
+ * 14) with its icon matched to it at 14px. Labels are scaffolding: you read them
124
+ * to find the value, then read the value. Setting them at body size made a
125
+ * column of records scan as a column of field names with data attached.
63
126
  */
64
127
  export function FieldBlock({
65
128
  propertyKey,
66
129
  property,
67
130
  showLabel,
131
+ mode = "edit",
132
+ label,
133
+ icon,
68
134
  children
69
135
  }: FieldBlockProps) {
70
136
 
137
+ const reading = mode === "read";
71
138
  const description = property?.description?.trim();
72
139
  const required = Boolean(property?.validation?.required);
73
140
 
@@ -82,18 +149,21 @@ export function FieldBlock({
82
149
  {showLabel && (
83
150
  <PropertyIdCopyTooltip propertyKey={propertyKey}>
84
151
  <div className={cls(
85
- "flex items-center gap-1.5 text-sm font-medium leading-tight mb-1.5",
86
- "text-text-secondary dark:text-text-secondary-dark"
152
+ "flex items-center gap-1.5 font-medium leading-tight mb-1.5",
153
+ "text-[13px] text-text-secondary dark:text-text-secondary-dark"
87
154
  )}>
88
155
  {/* Small and quiet: enough to tell a number from a
89
156
  relation at a glance, not enough to compete with the
90
- field name. */}
91
- {property && (
157
+ field name. Matched to the label's 13px rather than
158
+ left at the 16px `smallest` token, which put the
159
+ heaviest mark on the row on the least important
160
+ thing in it. */}
161
+ {(property || icon) && (
92
162
  <span className={"shrink-0 text-text-disabled dark:text-text-disabled-dark"}>
93
- {getIconForProperty(property, "smallest")}
163
+ {property ? getIconForProperty(property, LABEL_ICON_SIZE) : icon}
94
164
  </span>
95
165
  )}
96
- <span className={"truncate"}>{property?.name ?? propertyKey}</span>
166
+ <span className={"truncate"}>{property?.name ?? label ?? propertyKey}</span>
97
167
  {required && <span className={"text-red-500 dark:text-red-500 -ml-1"}>*</span>}
98
168
  </div>
99
169
  </PropertyIdCopyTooltip>
@@ -107,7 +177,7 @@ export function FieldBlock({
107
177
 
108
178
  {/* Under the control, where it reads as a note about the thing you
109
179
  just filled in rather than a subtitle of the next label. */}
110
- {showLabel && description && (
180
+ {showLabel && description && !reading && (
111
181
  <Typography variant={"caption"}
112
182
  color={"disabled"}
113
183
  className={"mt-1.5 ml-0.5 leading-snug"}>
@@ -32,7 +32,14 @@ export function FormRail({
32
32
  // No responsive hiding here. The caller decides whether the rail is
33
33
  // shown, by measuring; a CSS `hidden` would leave the fields the
34
34
  // resolver moved in here unreachable rather than folding them back.
35
- "flex flex-col gap-6 shrink-0 w-72 border-l overflow-y-auto",
35
+ //
36
+ // `w-76` (304px), between the old 288 and a full `w-80`. The rail
37
+ // holds real controls — a status select, a date picker — plus a
38
+ // record block whose id is a 36-character UUID, so 288 left the
39
+ // select tighter than the identical select in the column. 320 took
40
+ // more than it gave back: past about 300px the extra goes to the
41
+ // gap beside a chip, not to anything that needed it.
42
+ "flex flex-col gap-6 shrink-0 w-76 border-l overflow-y-auto",
36
43
  "px-5 py-6 bg-surface-50 dark:bg-surface-900",
37
44
  defaultBorderMixin
38
45
  )}>
@@ -47,7 +54,17 @@ export function FormRail({
47
54
  );
48
55
  }
49
56
 
50
- function RecordMeta({ entity }: { entity: Entity<Record<string, unknown>> }) {
57
+ /**
58
+ * The record's id and audit timestamps.
59
+ *
60
+ * Exported because the rail is not the only place it renders. The rail is shown
61
+ * only when it has been *measured* to fit, and every layout narrower than that —
62
+ * the split pane, the side panel, the dialog — folds its contents back into the
63
+ * column. The fields folded back but this block did not, so on three of the four
64
+ * layouts a record simply had no id on screen: the only copy of it was the
65
+ * truncated `e166a394…b422` chip in the identity bar.
66
+ */
67
+ export function RecordMeta({ entity }: { entity: Entity<Record<string, unknown>> }) {
51
68
 
52
69
  const createdAt = pickDate(entity.values, ["created_at", "createdAt", "created_on"]);
53
70
  const updatedAt = pickDate(entity.values, ["updated_at", "updatedAt", "modified_at"]);
@@ -14,6 +14,20 @@ export interface FormSectionsProps {
14
14
  */
15
15
  errorKeys: ReadonlySet<string>;
16
16
  renderField: (field: ResolvedFormField) => React.ReactNode;
17
+ /**
18
+ * Which rendering this is. `"read"` opens the gaps up rather than closing
19
+ * them: a control is a bordered 40px box that separates itself from the next
20
+ * one, and a value is bare text that does not. The form can also afford
21
+ * tighter rows because a description usually sits between them; with that
22
+ * gone, the same numbers ran a record's values together into one block.
23
+ */
24
+ mode?: "edit" | "read";
25
+ /**
26
+ * Renders one field as a summary row, for a section that asked for
27
+ * `readVariant: "summary"`. Only the read view supplies it; without it such
28
+ * a section falls back to the grid rather than to nothing.
29
+ */
30
+ renderSummaryField?: (field: ResolvedFormField, index: number, total: number) => React.ReactNode;
17
31
  }
18
32
 
19
33
  export function FormSections({
@@ -21,15 +35,22 @@ export function FormSections({
21
35
  collapsed,
22
36
  onToggle,
23
37
  errorKeys,
24
- renderField
38
+ renderField,
39
+ mode = "edit",
40
+ renderSummaryField
25
41
  }: FormSectionsProps) {
26
42
 
43
+ const reading = mode === "read";
44
+
27
45
  return (
28
- <div className={"flex flex-col gap-8"}>
46
+ <div className={cls("flex flex-col", reading ? "gap-10" : "gap-8")}>
29
47
  {sections.map((section) => {
30
48
 
31
49
  const hasError = section.fields.some(f => errorKeys.has(f.key));
32
50
  const isCollapsed = section.collapsible && collapsed.has(section.key) && !hasError;
51
+ const summary = reading
52
+ && section.readVariant === "summary"
53
+ && Boolean(renderSummaryField);
33
54
 
34
55
  return (
35
56
  <section key={section.key} className={"min-w-0"}>
@@ -43,15 +64,21 @@ export function FormSections({
43
64
  onToggle={() => onToggle(section.key)}/>
44
65
  )}
45
66
 
46
- {!isCollapsed && (
67
+ {!isCollapsed && (summary
68
+ ? <div id={`form_section_${section.key}`}
69
+ className={"flex flex-col min-w-0 @2xl:max-w-md @2xl:ml-auto"}>
70
+ {section.fields.map((field, index) =>
71
+ renderSummaryField!(field, index, section.fields.length))}
72
+ </div>
47
73
  // A container query, not a media query: the form is
48
74
  // rendered at four very different widths (full
49
75
  // screen, side panel, split pane, dialog) inside the
50
76
  // same viewport, so spans have to answer to the
51
77
  // column they are in, not to the window.
52
- <div id={`form_section_${section.key}`}
78
+ : <div id={`form_section_${section.key}`}
53
79
  className={cls(
54
- "grid gap-x-4 gap-y-5 min-w-0",
80
+ "grid min-w-0",
81
+ reading ? "gap-x-8 gap-y-7" : "gap-x-4 gap-y-5",
55
82
  "grid-cols-1 @2xl:grid-cols-4"
56
83
  )}>
57
84
  {section.fields.map(renderField)}
@@ -87,9 +87,15 @@ export function StorageUploadProgress({
87
87
  }, [entry.file, entry.fileName, upload]);
88
88
 
89
89
  if (simple) {
90
- return <div className={`w-${imageSize} h-${imageSize}`}>
91
-
92
- {loading && <Skeleton className={`w-${imageSize} h-${imageSize}`}/>}
90
+ // `imageSize` is a pixel count, so it has to be sized inline: `w-${n}`
91
+ // is neither a real Tailwind scale value nor a class the JIT can see.
92
+ return <div style={{
93
+ width: imageSize,
94
+ height: imageSize
95
+ }}>
96
+
97
+ {loading && <Skeleton width={imageSize}
98
+ height={imageSize}/>}
93
99
 
94
100
  </div>
95
101
  }
@@ -3,10 +3,11 @@ import React, { useLayoutEffect, useState } from "react";
3
3
  /**
4
4
  * Below this the form is a single column and the rail is not shown.
5
5
  *
6
- * Roughly the 288px rail plus a content column still wide enough to hold a
7
- * four-up row. Above it the rail is worth having even in the split view; below
8
- * it a narrow split pane, the side panel, the dialog, a phone its fields
9
- * fold back into the column.
6
+ * Roughly the 320px rail plus a content column still wide enough to hold a
7
+ * four-up row at the threshold that leaves ~680px, which is four columns of
8
+ * ~145px once the gutters are taken out. Above it the rail is worth having even
9
+ * in the split view; below it — a narrow split pane, the side panel, the dialog,
10
+ * a phone — its fields fold back into the column.
10
11
  */
11
12
  export const RAIL_MIN_WIDTH = 1000;
12
13
 
@@ -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,
@@ -86,14 +86,19 @@ export function BinaryFieldBinding({
86
86
  const localError = malformed ? "Not valid base64" : undefined;
87
87
 
88
88
  return (
89
- <div className="flex flex-col gap-2 w-full mt-2">
90
- <div className="flex items-center justify-between mb-1">
91
- {!hideLabel && <LabelWithIcon
89
+ <div className="flex flex-col gap-2 w-full">
90
+ {/* The row goes away with the label rather than emptying out. Left
91
+ unconditional it still contributed its `mb-1` and the column's
92
+ `gap-2`, which — on top of the `mt-2` that used to sit on the
93
+ wrapper — pushed this control 20px below the one next to it
94
+ while the two labels stayed level. */}
95
+ {!hideLabel && <div className="flex items-center justify-between mb-1">
96
+ <LabelWithIcon
92
97
  icon={getIconForProperty(property, "small")}
93
98
  required={property.validation?.required}
94
99
  title={property.name ?? propertyKey}
95
- />}
96
- </div>
100
+ />
101
+ </div>}
97
102
 
98
103
  <PropertyIdCopyTooltip propertyKey={propertyKey}>
99
104
  <div className="w-full">
@@ -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,