@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
@@ -128,27 +128,24 @@ export function GeopointFieldBinding({
128
128
  : undefined;
129
129
 
130
130
  return (
131
- <div className="flex flex-col gap-2 w-full mt-2">
132
- <div className="flex items-center justify-between mb-1">
133
- {!hideLabel && <LabelWithIcon
131
+ <div className="flex flex-col gap-2 w-full">
132
+ {/* Label only, and only when there is one: a row that survives
133
+ `hideLabel` still spends its `mb-1` and the column's `gap-2`, so
134
+ the pair of inputs sat below the control next to them even
135
+ though the two labels were level. */}
136
+ {!hideLabel && <div className="flex items-center justify-between mb-1">
137
+ <LabelWithIcon
134
138
  icon={getIconForProperty(property, "small")}
135
139
  required={property.validation?.required}
136
140
  title={property.name ?? propertyKey}
137
- />}
138
- {!disabled && (latText || lngText) && (
139
- <IconButton
140
- size="small"
141
- onClick={handleClear}
142
- className="text-text-secondary hover:text-red-500"
143
- aria-label="Clear location"
144
- >
145
- <Trash2Icon size={14}/>
146
- </IconButton>
147
- )}
148
- </div>
141
+ />
142
+ </div>}
149
143
 
150
144
  <PropertyIdCopyTooltip propertyKey={propertyKey}>
151
- <div className="flex gap-4 w-full">
145
+ {/* Clear sits beside the inputs rather than above them, so
146
+ entering a location does not grow the field by a row and
147
+ knock it out of line with its neighbour. */}
148
+ <div className="flex gap-4 w-full items-center">
152
149
  <TextField
153
150
  className="flex-1"
154
151
  size={size}
@@ -178,6 +175,16 @@ export function GeopointFieldBinding({
178
175
  placeholder="e.g. 2.1686"
179
176
  inputClassName="font-mono"
180
177
  />
178
+ {!disabled && (latText || lngText) && (
179
+ <IconButton
180
+ size="small"
181
+ onClick={handleClear}
182
+ className="shrink-0 text-text-secondary hover:text-red-500"
183
+ aria-label="Clear location"
184
+ >
185
+ <Trash2Icon size={14}/>
186
+ </IconButton>
187
+ )}
181
188
  </div>
182
189
  </PropertyIdCopyTooltip>
183
190
 
@@ -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,
@@ -216,17 +216,26 @@ export function MarkdownEditorFieldBinding({
216
216
 
217
217
  return (
218
218
  <>
219
- <div className="flex items-center w-full">
220
- {!hideLabel && <LabelWithIconAndTooltip
219
+ {!hideLabel && <div className="flex items-center w-full">
220
+ <LabelWithIconAndTooltip
221
221
  propertyKey={propertyKey}
222
222
  icon={getIconForProperty(property, "small")}
223
223
  required={property.validation?.required}
224
224
  title={property.name ?? propertyKey}
225
- className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>}
226
- <div className="flex-grow"/>
225
+ className={"h-8 text-text-secondary dark:text-text-secondary-dark ml-3.5"}/>
226
+ </div>}
227
+ {/* `relative` so Clear can float inside the box. It used to sit in
228
+ the label row, which meant a clearable field with its label
229
+ lifted out by the form still drew a row of chrome above the box
230
+ and started lower than the field beside it.
231
+ Bottom-right, not top: the editor puts its own mode toggle at
232
+ `top-2 right-2` and the two would sit on each other. */}
233
+ <div
234
+ className={cls("relative rounded-md", fieldBackgroundMixin, disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin)}>
227
235
  {property.admin?.clearable && !disabled && (
228
236
  <IconButton
229
237
  size="small"
238
+ className="absolute bottom-1 right-1 z-10"
230
239
  onClick={(e) => {
231
240
  e.stopPropagation();
232
241
  e.preventDefault();
@@ -236,9 +245,6 @@ export function MarkdownEditorFieldBinding({
236
245
  <XIcon/>
237
246
  </IconButton>
238
247
  )}
239
- </div>
240
- <div
241
- className={cls("rounded-md", fieldBackgroundMixin, disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin)}>
242
248
  {editor}
243
249
  </div>
244
250
  <FieldHelperText includeDescription={includeDescription}
@@ -87,7 +87,13 @@ export function MultiSelectFieldBinding({
87
87
  return (
88
88
  <>
89
89
  <MultiSelect
90
- className={"w-full mt-2"}
90
+ // No top margin: `className` lands on the box itself, not on
91
+ // the label above it, so `mt-2` was 8px of gap between the
92
+ // form's label and this control and none between the label and
93
+ // the text field beside it. A binding renders a control, not a
94
+ // labelled control — the top of what it renders is the top of
95
+ // the box, or it will not line up with its neighbour.
96
+ className={"w-full"}
91
97
  size={size}
92
98
  value={validValue ? value.map((v) => v?.toString()) : []}
93
99
  disabled={disabled}
@@ -53,7 +53,13 @@ export function ReadOnlyFieldBinding({
53
53
  "w-full overflow-x-scroll no-scrollbar flex items-center",
54
54
  skipCardWrapper
55
55
  ? ""
56
- : "rounded-lg border border-surface-200 dark:border-surface-700 px-4 md:px-6 min-h-10 opacity-80"
56
+ // `min-h-12` is `TextField`'s own `large` height, which is
57
+ // what every editable field in the form is. At 40px a
58
+ // read-only field sat 8px short of the input beside it,
59
+ // so a row holding one of each had its two boxes'
60
+ // baselines and bottom edges disagree — the field looked
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"
57
63
  )}>
58
64
 
59
65
  <ErrorBoundary>
@@ -121,7 +121,7 @@ function ReferenceFieldBindingInternal({
121
121
  />}
122
122
 
123
123
  {!refValue && <div className="justify-center text-left">
124
- <EntityPreviewContainer className={cls("px-6 h-16 text-sm font-medium flex items-center gap-6",
124
+ <EntityPreviewContainer className={cls("px-6 h-12 text-sm font-medium flex items-center gap-6",
125
125
  disabled || isSubmitting
126
126
  ? "text-surface-accent-500"
127
127
  : "cursor-pointer text-surface-accent-700 dark:text-surface-accent-300 hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800")}
@@ -72,7 +72,10 @@ function RelationSelectorBinding({
72
72
  const singleValue = normalizedSingle ?? null;
73
73
  const multipleValue = (manyRelation && Array.isArray(value)) ? value : [];
74
74
 
75
- const selectorSize: "small" | "medium" | undefined = size === "large" ? "medium" : size;
75
+ // Passed straight through. Mapping the form's `large` onto the selector's
76
+ // `medium` produced a 56px box next to 48px text fields — the labels lined
77
+ // up and the boxes did not.
78
+ const selectorSize = size;
76
79
 
77
80
  return (
78
81
  <div className="">
@@ -176,7 +179,7 @@ function SingleRelationFieldBinding({
176
179
  />}
177
180
 
178
181
  {!value && <div className="justify-center text-left">
179
- <EntityPreviewContainer className={cls("px-6 h-16 text-sm font-medium flex items-center gap-6",
182
+ <EntityPreviewContainer className={cls("px-6 h-12 text-sm font-medium flex items-center gap-6",
180
183
  disabled || isSubmitting
181
184
  ? "text-surface-accent-500"
182
185
  : "cursor-pointer text-surface-accent-700 dark:text-surface-accent-300 hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800")}
@@ -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,
@@ -34,7 +34,10 @@ export function UserSelectFieldBinding({
34
34
  size = "large"
35
35
  }: UserSelectProps) {
36
36
 
37
- const selectorSize: "small" | "medium" | undefined = size === "large" ? "medium" : size;
37
+ // Passed straight through. Mapping the form's `large` onto the selector's
38
+ // `medium` produced a 56px box next to 48px text fields — the labels lined
39
+ // up and the boxes did not.
40
+ const selectorSize = size;
38
41
 
39
42
  return (
40
43
  <>
@@ -98,11 +98,13 @@ export function VectorFieldBinding({
98
98
  const isPopulated = arrayValue.length > 0;
99
99
 
100
100
  return (
101
- <div className="flex flex-col gap-2 w-full mt-2">
102
- {/* Field Label */}
103
- <div className="flex items-center justify-between mb-1">
101
+ <div className="flex flex-col gap-2 w-full">
102
+ {/* Only when there is a label to put in it — an empty row still
103
+ spends its `mb-1` and the column's `gap-2`, and the control ends
104
+ up below the one beside it under a label at the same height. */}
105
+ {label && <div className="flex items-center justify-between mb-1">
104
106
  {label}
105
- </div>
107
+ </div>}
106
108
 
107
109
  <PropertyIdCopyTooltip propertyKey={propertyKey}>
108
110
  <div className="w-full">
@@ -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,
@@ -41,11 +41,35 @@ export function useBuildCollectionRegistryController(props: {
41
41
  if (!collectionPath) return undefined;
42
42
 
43
43
  let collection: AdminCollection | undefined;
44
- try {
45
- collection = registry.resolvePathToCollections(collectionPath).finalCollection;
46
- } catch (e) {
47
- console.debug(`Could not resolve path to collection: ${collectionPath}`, e);
48
- return undefined;
44
+
45
+ // An exact slug match first, because a slug is allowed to contain
46
+ // slashes and several drivers need it to: a Firestore collection
47
+ // partitioned by locale is declared as `content/de-DE/podcasts`, and
48
+ // that string is its *name*, not a path to walk.
49
+ //
50
+ // Without this, such a slug went straight to `resolvePathToCollections`,
51
+ // which reads any `a/b/c` as collection/entity/subcollection, looked for
52
+ // a root collection called `content`, found none and threw — and the
53
+ // catch below turned that into `undefined`. `RebaseRoute` renders
54
+ // `null` for an unresolved collection, so the whole view came out blank
55
+ // with nothing in the console above `debug`. Thirty-five collections in
56
+ // one app were unreachable that way.
57
+ // `collectionPath`, not `cleanedPath`: an even segment count means the
58
+ // path ends at a record, and the id has already been trimmed off above.
59
+ // Matching the raw path instead would only ever hit for a collection
60
+ // route, leaving `content/de-DE/podcasts/abc123` back on the walker.
61
+ collection = registry.get(collectionPath) as AdminCollection | undefined;
62
+
63
+ if (!collection) {
64
+ try {
65
+ collection = registry.resolvePathToCollections(collectionPath).finalCollection;
66
+ } catch (e) {
67
+ // Warn, not debug. An unresolved collection ends as a route with
68
+ // nothing in it, and at `debug` there was no way to tell that
69
+ // from a collection that legitimately has no rows.
70
+ console.warn(`[rebase] Could not resolve a collection for "${collectionPath}"`, e);
71
+ return undefined;
72
+ }
49
73
  }
50
74
 
51
75
  if (!collection) {
@@ -0,0 +1,262 @@
1
+ import type { AdminCollection, EntityDisplayRole } from "@rebasepro/admin-types";
2
+ import type { Entity, Property } from "@rebasepro/types";
3
+ import { ENTITY_DISPLAY_ROLES } from "@rebasepro/admin-types";
4
+ import { useEffect, useMemo, useSyncExternalStore } from "react";
5
+ import {
6
+ EntityDisplayCache,
7
+ entityDisplayKey,
8
+ getDisplayPropertyKey,
9
+ getDisplayResolver,
10
+ getPropertyInPath,
11
+ useRebaseContext
12
+ } from "@rebasepro/app";
13
+ import { getValueInPath } from "@rebasepro/utils";
14
+
15
+ import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
16
+ import { getUserLabel, useResolvedUser } from "./useResolvedUsers";
17
+
18
+ /**
19
+ * What fills one display role for one record.
20
+ *
21
+ * `value` is `undefined` rather than a placeholder when nothing resolved,
22
+ * because the substitute differs by surface and each one knows its own better
23
+ * than this hook does: a page heading wants the singular collection name, a link
24
+ * wants the id, a draft wants "New customer". A single baked-in fallback is
25
+ * exactly what produced three different ones scattered across the consumers.
26
+ */
27
+ export interface ResolvedDisplayValue<T = unknown> {
28
+ /** What goes in the slot. `undefined` when the record has nothing for it. */
29
+ value: T | undefined;
30
+ /**
31
+ * A resolver is in flight, and `value` currently holds the derived answer or
32
+ * nothing. Surfaces that can show a skeleton may; none has to.
33
+ */
34
+ loading: boolean;
35
+ /**
36
+ * Set only when the value came from a property of this collection. Carries
37
+ * what a rich renderer needs — a status shows as its enum's coloured chip,
38
+ * an image as a storage thumbnail — which a bare resolved string cannot
39
+ * describe.
40
+ */
41
+ source?: {
42
+ key: string;
43
+ property: Property | undefined;
44
+ value: unknown;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * One shared store, so every surface agrees on a record and resolves it once.
50
+ *
51
+ * Module-level rather than a provider: two app instances on one page share a
52
+ * cache keyed by collection path and record id, which *is* the same record.
53
+ * Nothing here is user-specific — a resolver that reads per-user data must
54
+ * invalidate on sign-out, which {@link useEntityDisplayCache} exposes.
55
+ */
56
+ let sharedCache: EntityDisplayCache | undefined;
57
+
58
+ /**
59
+ * The store behind {@link useEntityDisplay}, for invalidating after a write.
60
+ *
61
+ * Built on first use rather than at module scope. Importing a module should not
62
+ * construct anything: it runs during SSR where there is nothing to cache, and it
63
+ * made this module unimportable from any test that stubs `@rebasepro/app` —
64
+ * which several do, and which is a legitimate thing for them to do.
65
+ */
66
+ export function useEntityDisplayCache(): EntityDisplayCache {
67
+ if (!sharedCache) sharedCache = new EntityDisplayCache();
68
+ return sharedCache;
69
+ }
70
+
71
+ export interface UseEntityDisplayParams<M extends Record<string, unknown>> {
72
+ /**
73
+ * Optional because several callers look the collection up in the registry by
74
+ * path and may not find it — a relation into a collection the panel does not
75
+ * register. A hook that could not be called until that succeeded would have
76
+ * to sit below an early return, which is not where hooks go.
77
+ */
78
+ collection?: AdminCollection<M>;
79
+ /**
80
+ * The record. A resolver is handed the whole entity — its id and path are
81
+ * how it reaches anything the record does not carry — so a computed value is
82
+ * only available once there is an entity to compute it from.
83
+ */
84
+ entity?: Entity<M>;
85
+ /**
86
+ * Values to read a declared path from when they are newer than the entity:
87
+ * the live form values while editing. Falls back to `entity.values`.
88
+ */
89
+ values?: Record<string, unknown>;
90
+ /** Set false to skip resolver work entirely — a draft has nothing to show. */
91
+ enabled?: boolean;
92
+ }
93
+
94
+ /**
95
+ * What fills one display role of one record.
96
+ *
97
+ * This is the only implementation. Before it there were seven for the title
98
+ * alone — the identity bar, the detail header, inline previews, reference cards,
99
+ * list slots, relation chips and the headless view — each re-deriving it from
100
+ * `collection.properties` and disagreeing about the answer.
101
+ *
102
+ * Resolution order, per role:
103
+ *
104
+ * 1. `admin.display[role]` as a **resolver** — awaited, cached per record and
105
+ * role, with the derived value showing meanwhile so nothing flickers empty.
106
+ * 2. `admin.display[role]` as a **property path** (or, for the title, the
107
+ * deprecated `titleProperty`).
108
+ * 3. The **derived** answer: for the title, the best-ranked property carrying a
109
+ * readable value; for the rest, today's per-role heuristics.
110
+ *
111
+ * A user-picker title resolves to the person behind the id, as it always did —
112
+ * that path is now here rather than in two of the seven.
113
+ */
114
+ export function useEntityDisplay<T = unknown, M extends Record<string, unknown> = Record<string, unknown>>(
115
+ role: EntityDisplayRole,
116
+ {
117
+ collection,
118
+ entity,
119
+ values: valuesProp,
120
+ enabled = true
121
+ }: UseEntityDisplayParams<M>
122
+ ): ResolvedDisplayValue<T> {
123
+
124
+ const context = useRebaseContext();
125
+ const cache = useEntityDisplayCache();
126
+
127
+ const values = valuesProp ?? entity?.values;
128
+
129
+ /* ---- the declared or derived key ------------------------------------- */
130
+
131
+ // The title is the one role with a ranking behind it: several candidates,
132
+ // best first, skipping the ones that hold nothing for *this* record. The
133
+ // others are a single declared or derived key.
134
+ const declaredKey = collection ? getDisplayPropertyKey(collection, role) : undefined;
135
+ const propertyKey = collection && role === "title" && !declaredKey
136
+ ? getEntityTitlePropertyKeyForEntity(collection, values, entity?.id)
137
+ : declaredKey;
138
+
139
+ const rawValue = values && propertyKey ? getValueInPath(values, propertyKey) : undefined;
140
+
141
+ // A user picker stores an auth id and renders the person: resolved like a
142
+ // relation, and hooked unconditionally so the hook order never changes.
143
+ const titleUser = useResolvedUser(
144
+ collection && role === "title" && isUserSelectProperty(collection, propertyKey) && typeof rawValue === "string"
145
+ ? rawValue
146
+ : undefined
147
+ );
148
+
149
+ const derived = useMemo<T | undefined>(() => {
150
+ if (titleUser) return getUserLabel(titleUser) as T;
151
+ if (rawValue === undefined || rawValue === null) return undefined;
152
+ // Only text roles get flattened to a string; a date stays a date and an
153
+ // image stays whatever the storage property holds, so the renderer can
154
+ // still format them.
155
+ if (role === "title" || role === "subtitle" || role === "status") {
156
+ return (resolveTitleToString(rawValue) || undefined) as T | undefined;
157
+ }
158
+ return rawValue as T;
159
+ }, [titleUser, rawValue, role]);
160
+
161
+ /* ---- the computed answer --------------------------------------------- */
162
+
163
+ const resolver = collection ? getDisplayResolver(collection, role) : undefined;
164
+ const canResolve = Boolean(enabled && resolver && entity);
165
+ const key = entity ? entityDisplayKey(entity.path, entity.id, role) : undefined;
166
+
167
+ const resolved = useSyncExternalStore<unknown>(
168
+ // Subscribing unconditionally keeps the hook order stable. The store
169
+ // only notifies when an entry changes, so a collection with no resolver
170
+ // never re-renders from it.
171
+ cb => cache.subscribe(cb),
172
+ () => (canResolve && key ? cache.peek(key) : undefined),
173
+ // Server render: nothing is resolved, so every slot starts at its
174
+ // fallback and hydration does not disagree with the markup.
175
+ () => undefined
176
+ );
177
+
178
+ const loading = Boolean(canResolve && key && resolved === undefined);
179
+
180
+ useEffect(() => {
181
+ if (!canResolve || !key || !resolver || !entity) return;
182
+ if (cache.peek(key) !== undefined) return;
183
+ cache.resolve(key, () => resolver({
184
+ entity,
185
+ context
186
+ })).catch(() => {
187
+ // Unreachable, and kept only so it stays that way: `resolve` records a
188
+ // failure as "nothing" and returns a RESOLVED promise, which is why
189
+ // the warning that used to live here could never run — see the note on
190
+ // `EntityDisplayCache.resolve`. The cache reports the failure itself
191
+ // now, once per key. This exists so that if `resolve` ever starts
192
+ // rejecting, it surfaces as a caught no-op rather than an unhandled
193
+ // rejection during render.
194
+ });
195
+ }, [cache, canResolve, key, resolver, entity, context, role]);
196
+
197
+ /* ---- the answer ------------------------------------------------------ */
198
+
199
+ // A resolver that produced nothing falls back to the derived value rather
200
+ // than to blank: it said this record has no computed answer for the role,
201
+ // not that the role should be empty.
202
+ const computed = canResolve && resolved !== null ? resolved as T | undefined : undefined;
203
+ const value = computed ?? derived;
204
+
205
+ return useMemo(() => ({
206
+ value,
207
+ loading,
208
+ source: collection && propertyKey && value === derived && derived !== undefined
209
+ ? {
210
+ key: propertyKey,
211
+ property: getPropertyInPath(collection.properties, propertyKey),
212
+ value: rawValue
213
+ }
214
+ : undefined
215
+ }), [value, loading, propertyKey, derived, collection, rawValue]);
216
+ }
217
+
218
+ /**
219
+ * Every display role of a record at once — what a list row, a card or a board
220
+ * card needs, in one call rather than six.
221
+ *
222
+ * The role list is iterated from {@link ENTITY_DISPLAY_ROLES} so the hook order
223
+ * is fixed and adding a role does not mean editing every caller.
224
+ */
225
+ export function useEntityDisplayValues<M extends Record<string, unknown>>(
226
+ params: UseEntityDisplayParams<M>
227
+ ): Record<EntityDisplayRole, ResolvedDisplayValue> & { loading: boolean } {
228
+
229
+ // Not a loop over `useEntityDisplay`: the number of hooks a component calls
230
+ // has to be fixed at compile time, and a role added to the array later would
231
+ // silently change it. Written out, it is checked by the return type.
232
+ const title = useEntityDisplay<string, M>("title", params);
233
+ const subtitle = useEntityDisplay<string, M>("subtitle", params);
234
+ const image = useEntityDisplay<string, M>("image", params);
235
+ const status = useEntityDisplay<string, M>("status", params);
236
+ const date = useEntityDisplay<Date | string | number, M>("date", params);
237
+ const tags = useEntityDisplay<string[] | string, M>("tags", params);
238
+
239
+ return useMemo(() => ({
240
+ title,
241
+ subtitle,
242
+ image,
243
+ status,
244
+ date,
245
+ tags,
246
+ loading: title.loading || subtitle.loading || image.loading
247
+ || status.loading || date.loading || tags.loading
248
+ }), [title, subtitle, image, status, date, tags]);
249
+ }
250
+
251
+ /**
252
+ * What the record is called — the one role asked for often enough on its own to
253
+ * deserve a name.
254
+ */
255
+ export function useEntityTitle<M extends Record<string, unknown>>(
256
+ params: UseEntityDisplayParams<M>
257
+ ): ResolvedDisplayValue<string> {
258
+ return useEntityDisplay<string, M>("title", params);
259
+ }
260
+
261
+ /** Re-exported so a caller can iterate roles without reaching into types. */
262
+ export { ENTITY_DISPLAY_ROLES };