@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
@@ -6,6 +6,7 @@ import { useTranslation } from "@rebasepro/app";
6
6
  import {
7
7
  Button,
8
8
  CheckIcon,
9
+ ChevronDownIcon,
9
10
  cls,
10
11
  CodeIcon,
11
12
  CopyIcon,
@@ -21,7 +22,8 @@ import {
21
22
  MoreVerticalIcon,
22
23
  Separator,
23
24
  Tooltip,
24
- Typography
25
+ Typography,
26
+ XIcon
25
27
  } from "@rebasepro/ui";
26
28
 
27
29
  export interface EntityIdentityBarProps {
@@ -55,6 +57,31 @@ export interface EntityIdentityBarProps {
55
57
  */
56
58
  onSaveAndClose?: () => void;
57
59
 
60
+ /**
61
+ * Where {@link onSaveAndClose} is offered.
62
+ *
63
+ * `"button"` — its own filled button, and Save demotes to text. For the
64
+ * overlays, where you opened the record to do one thing to it and closing is
65
+ * the dominant intent.
66
+ *
67
+ * `"menu"` — a `▾` welded to the Save button. For the split, where the list
68
+ * is right there and `j`/`k` walk from record to record: there, saving and
69
+ * *staying* is the common case, so it keeps the filled button and dismissing
70
+ * is one step further in. The gesture still exists, so muscle memory carried
71
+ * over from the side panel finds it.
72
+ */
73
+ saveAndClosePlacement?: "button" | "menu";
74
+
75
+ /**
76
+ * Dismiss this record, from the ✕ at the bar's trailing edge.
77
+ *
78
+ * Fenced off from Save by a separator: it is chrome, not the last item in
79
+ * the action row. The panel that supplies it decides what dismissing means —
80
+ * in the split it is a navigation, which the layout's unsaved-changes
81
+ * blocker already guards, so the ✕ cannot drop an edit without asking.
82
+ */
83
+ onClose?: () => void;
84
+
58
85
  onBack?: () => void;
59
86
  onInspect?: () => void;
60
87
  /** Opens the inspector straight on its history tab. */
@@ -114,6 +141,8 @@ export function EntityIdentityBar({
114
141
  saveDisabled,
115
142
  hasErrors,
116
143
  onSaveAndClose,
144
+ saveAndClosePlacement = "button",
145
+ onClose,
117
146
  onBack,
118
147
  onInspect,
119
148
  onViewHistory,
@@ -135,6 +164,15 @@ export function EntityIdentityBar({
135
164
  const hasMenu = Boolean(recordActions) || Boolean(onInspect) || Boolean(onViewHistory)
136
165
  || Boolean(externalLink);
137
166
 
167
+ // The two shapes "save and close" takes, so the Save button next to it knows
168
+ // whether it is still the primary action.
169
+ const saveAndCloseButton = Boolean(onSaveAndClose) && saveAndClosePlacement === "button";
170
+ const saveAndCloseMenu = Boolean(onSaveAndClose) && saveAndClosePlacement === "menu";
171
+
172
+ const saveTooltip = hasErrors
173
+ ? (t("fix_errors_before_saving") ?? "Fix highlighted errors before saving")
174
+ : undefined;
175
+
138
176
  return (
139
177
  <div className={cls(
140
178
  "h-[52px] shrink-0 flex items-center gap-2 pl-1.5 pr-2 border-b",
@@ -190,24 +228,66 @@ export function EntityIdentityBar({
190
228
  )}
191
229
 
192
230
  {onSave && (
193
- <Tooltip title={hasErrors
194
- ? (t("fix_errors_before_saving") ?? "Fix highlighted errors before saving")
195
- : undefined}>
196
- <LoadingButton
197
- // Where there is something to close, closing is the
198
- // dominant intent and takes the filled treatment.
199
- variant={onSaveAndClose ? "text" : "filled"}
200
- color={"primary"}
201
- size={"small"}
202
- loading={saving && !onSaveAndClose}
203
- disabled={saveDisabled}
204
- onClick={onSave}>
205
- {saveLabel}
206
- </LoadingButton>
207
- </Tooltip>
231
+ // `rounded-lg overflow-hidden`: the two halves of the split
232
+ // button are square inside and the group carries the radius, so
233
+ // there is one control with a seam rather than two buttons that
234
+ // happen to touch. Their borders match their own fill, so the
235
+ // seam has to be drawn — see the `▾` half for where and in what.
236
+ <div className={"flex items-stretch rounded-lg overflow-hidden"}>
237
+ <Tooltip title={saveTooltip}>
238
+ <LoadingButton
239
+ // Where a separate close button carries it, closing
240
+ // is the dominant intent and takes the filled
241
+ // treatment. Under the `▾` the record is staying
242
+ // open, so Save keeps it.
243
+ variant={saveAndCloseButton ? "text" : "filled"}
244
+ color={"primary"}
245
+ size={"small"}
246
+ loading={saving && !saveAndCloseButton}
247
+ disabled={saveDisabled}
248
+ onClick={onSave}
249
+ className={saveAndCloseMenu ? "rounded-none" : undefined}>
250
+ {saveLabel}
251
+ </LoadingButton>
252
+ </Tooltip>
253
+
254
+ {saveAndCloseMenu && <>
255
+ <Menu align={"end"}
256
+ trigger={
257
+ <Button variant={"filled"}
258
+ color={"primary"}
259
+ size={"small"}
260
+ disabled={saveDisabled}
261
+ aria-label={closeLabel}
262
+ // The seam is this half's own left border,
263
+ // tinted from `currentColor` — the ink the
264
+ // label is drawn in — so it holds on any
265
+ // button colour and dims with the control
266
+ // rather than needing a case per state. It
267
+ // was a `bg-white/25` span between the
268
+ // halves: a bright hairline scratched over a
269
+ // saturated blue, and, being a sibling of
270
+ // the buttons rather than part of one, it
271
+ // stayed at full strength while `disabled`
272
+ // dropped both halves to `opacity-40` — a
273
+ // rule brighter than the button it bisected.
274
+ // Inline because it has to beat the
275
+ // variant's `border-<color>` on one edge.
276
+ style={{ borderLeftColor: "color-mix(in oklab, currentColor 15%, transparent)" }}
277
+ className={"rounded-none px-1.5"}>
278
+ <ChevronDownIcon size={iconSize.smallest}/>
279
+ </Button>
280
+ }>
281
+ <MenuItem onClick={onSaveAndClose}>
282
+ <CheckIcon size={iconSize.smallest}/>
283
+ {closeLabel}
284
+ </MenuItem>
285
+ </Menu>
286
+ </>}
287
+ </div>
208
288
  )}
209
289
 
210
- {onSaveAndClose && (
290
+ {saveAndCloseButton && (
211
291
  <LoadingButton variant={"filled"}
212
292
  color={"primary"}
213
293
  size={"small"}
@@ -251,6 +331,18 @@ export function EntityIdentityBar({
251
331
  )}
252
332
 
253
333
  {trailing}
334
+
335
+ {/* Last, and behind a rule. A ✕ that sits flush against Save reads
336
+ as the next item in the action row, and the two adjacent controls
337
+ are then "commit this edit" and "abandon it". */}
338
+ {onClose && <>
339
+ <Separator orientation={"vertical"} className={"h-5 mx-1 shrink-0"}/>
340
+ <Tooltip title={`${t("close")} · Esc`}>
341
+ <IconButton size={"small"} onClick={onClose} aria-label={t("close")}>
342
+ <XIcon size={iconSize.smallest}/>
343
+ </IconButton>
344
+ </Tooltip>
345
+ </>}
254
346
  </div>
255
347
  );
256
348
  }
@@ -65,16 +65,34 @@ export function EntityInspector({
65
65
  const { t } = useTranslation();
66
66
  const open = tab !== null;
67
67
 
68
+ // Escape closes the inspector, and only the inspector.
69
+ //
70
+ // The split view holds its own Escape listener for as long as a record is
71
+ // selected, and closing the record panel is a navigation — so an Escape
72
+ // meant for this panel was taking the whole record with it. On `window` in
73
+ // the bubble phase, as this listener used to be, `stopPropagation` cannot
74
+ // prevent that: it governs an event's travel *between* elements and says
75
+ // nothing about the other listeners on the element it is called from. Both
76
+ // ran, and the split's ran first, because it registered first — this one
77
+ // only exists while the inspector is open, which is always later.
78
+ //
79
+ // Capture on `document` is the idiom that holds regardless of mount order:
80
+ // it runs on the way down, before anything bubbles back to `window`, and
81
+ // there `stopPropagation` is enough. It is what the relation and user
82
+ // selectors already use to own the key while their popovers are open.
83
+ // Pinned in escape_key_ownership.test.tsx; see docs/bug-classes.md.
68
84
  useEffect(() => {
69
85
  if (!open) return;
70
86
  const onKeyDown = (e: KeyboardEvent) => {
71
- if (e.key === "Escape") {
72
- e.stopPropagation();
73
- onClose();
74
- }
87
+ if (e.key !== "Escape") return;
88
+ // A Radix overlay opened from inside the inspector is above it and
89
+ // owns the key first — the same rule the split view applies.
90
+ if (document.querySelector('[role="dialog"][data-state="open"]')) return;
91
+ e.stopPropagation();
92
+ onClose();
75
93
  };
76
- window.addEventListener("keydown", onKeyDown);
77
- return () => window.removeEventListener("keydown", onKeyDown);
94
+ document.addEventListener("keydown", onKeyDown, true);
95
+ return () => document.removeEventListener("keydown", onKeyDown, true);
78
96
  }, [open, onClose]);
79
97
 
80
98
  if (!open) return null;
@@ -346,14 +346,16 @@ export function EntityViewBinding<M extends Record<string, unknown>>(
346
346
  }
347
347
 
348
348
  return (
349
- <div ref={containerRef} className={cls("flex-1 flex flex-row w-full min-h-0", className)}>
349
+ <div ref={containerRef} className={cls("@container/form flex-1 flex flex-row w-full min-h-0", className)}>
350
350
 
351
351
  {/* `items-start`: without it the cross-axis default stretches the
352
352
  column to the scroller's height and its bottom padding lands at
353
353
  the bottom of the viewport instead of after the last field. */}
354
354
  <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
355
355
  <div className={cls(
356
- "@container/col w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
356
+ // Same widths as the editable form (see EntityForm) — toggling
357
+ // between read-only and edit must not move the column.
358
+ "@container/col w-full max-w-3xl @7xl/form:max-w-4xl 2xl:max-w-4xl flex flex-col",
357
359
  openEntityMode === "dialog"
358
360
  ? "pt-5 pb-8 px-6 sm:px-8"
359
361
  : "pt-6 pb-12 px-5 sm:px-8"
@@ -14,7 +14,8 @@ import {
14
14
  useData,
15
15
  useDataTableController,
16
16
  useLargeLayout,
17
- usePermissions
17
+ usePermissions,
18
+ useTranslation
18
19
  } from "@rebasepro/app";
19
20
  import { ErrorView } from "@rebasepro/app";
20
21
  import { Button, DialogActions, PlusIcon, Typography } from "@rebasepro/ui";
@@ -347,6 +348,7 @@ function EntitySelectionDialogActions({
347
348
  const { canCreate } = usePermissions();
348
349
 
349
350
  const largeLayout = useLargeLayout();
351
+ const { t } = useTranslation();
350
352
 
351
353
  const onClick: MouseEventHandler<HTMLButtonElement> | undefined = onNewClick
352
354
  ? (e) => {
@@ -359,7 +361,7 @@ function EntitySelectionDialogActions({
359
361
  ? <Button
360
362
  onClick={onClick}
361
363
  startIcon={<PlusIcon/>}>
362
- Add {collection.singularName ?? collection.name}
364
+ {t("add_specific", { name: collection.singularName ?? collection.name })}
363
365
  </Button>
364
366
  : <Button
365
367
  onClick={onClick}>
@@ -18,7 +18,7 @@ import {
18
18
  SearchIcon,
19
19
  Separator,
20
20
  Tooltip,
21
- useInjectStyles,
21
+ usePortalContainer,
22
22
  XIcon
23
23
  } from "@rebasepro/ui";
24
24
  import * as React from "react";
@@ -29,7 +29,6 @@ import { EntityPreviewBindingData } from "./EntityPreviewBinding";
29
29
  import { useData, useRelationSelector } from "@rebasepro/app";
30
30
  import { useSidePanel } from "../hooks/useSidePanel";
31
31
  import { normalizeToEntityRelation } from "@rebasepro/common";
32
- import { EmptyValue } from "../preview";
33
32
 
34
33
  /** Whether an authored relation yields many rows. Derived from its kind. */
35
34
  function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
@@ -91,7 +90,20 @@ export interface RelationSelectorProps {
91
90
  pageSize?: number;
92
91
  emptyPlaceholder?: string;
93
92
  searchPlaceholder?: string;
93
+ /** Shown in the open list when a search matched nothing. */
94
94
  noResultsText?: string;
95
+ /**
96
+ * Shown on the closed trigger when nothing is selected. Says only that the
97
+ * field is empty — see the note by `resolvedPlaceholder` for why it cannot
98
+ * say whether there is anything to pick.
99
+ */
100
+ emptyText?: string;
101
+ /**
102
+ * Shown in the open list when the target collection turned out to hold no
103
+ * rows at all, as opposed to a search simply not matching. Defaults to a
104
+ * sentence naming the target collection.
105
+ */
106
+ emptyCollectionText?: string;
95
107
  loadingText?: string;
96
108
  }
97
109
 
@@ -115,7 +127,9 @@ export const RelationSelector = React.forwardRef<
115
127
  pageSize,
116
128
  emptyPlaceholder,
117
129
  searchPlaceholder = "Search...",
118
- noResultsText = "No relations found.",
130
+ noResultsText = "No matches.",
131
+ emptyText = "Select…",
132
+ emptyCollectionText,
119
133
  loadingText = "Loading..."
120
134
  },
121
135
  ref
@@ -124,10 +138,16 @@ export const RelationSelector = React.forwardRef<
124
138
  const collection = relation.target();
125
139
  const dataClient = useData();
126
140
  const sidePanelController = useSidePanel();
141
+ const contextPortalContainer = usePortalContainer();
127
142
  const multiple = multipleOverride ?? relationCardinality(relation) === "many";
128
143
 
129
144
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
130
145
  const isPopoverOpenRef = useRef(false);
146
+ // The list is only worth fetching once the picker has been opened. A
147
+ // table cell mounts one of these per row, and the selected value is
148
+ // resolved by its own effect below, not from the list. Latches on the
149
+ // first open so a closed popover keeps its results.
150
+ const [listRequested, setListRequested] = useState(false);
131
151
  const [selectedItems, setSelectedItems] = useState<RelationItem[]>([]);
132
152
  const [isLoadingSelectedItems, setIsLoadingSelectedItems] = useState(false);
133
153
  const [searchString, setSearchString] = useState<string>("");
@@ -150,7 +170,8 @@ export const RelationSelector = React.forwardRef<
150
170
  path: getCollectionDataPath(collection),
151
171
  collection,
152
172
  fixedFilter,
153
- pageSize
173
+ pageSize,
174
+ enabled: listRequested
154
175
  });
155
176
 
156
177
  const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -417,6 +438,7 @@ relation } as RelationItem;
417
438
  if (next) {
418
439
  // Capture current selection so we can pin those items to the top of the list
419
440
  pinnedIdsRef.current = new Set(selectedItems.map(i => String(i.id)));
441
+ setListRequested(true);
420
442
  setIsPopoverOpen(true);
421
443
  isPopoverOpenRef.current = true;
422
444
  }
@@ -455,18 +477,35 @@ relation } as RelationItem;
455
477
  };
456
478
  }, [isPopoverOpen]);
457
479
 
458
- useInjectStyles("RelationSelector", " [cmdk-group] { max-height: 40vh; overflow-y: auto; } ");
459
-
460
480
  const closePopover = useCallback(() => {
461
481
  setIsPopoverOpen(false);
462
482
  isPopoverOpenRef.current = false;
463
483
  pinnedIdsRef.current = null;
464
484
  }, []);
465
485
 
466
- const resolvedPlaceholder = placeholder || emptyPlaceholder || <EmptyValue className={"ml-2"}/>;
467
-
468
- // Use Sheet portal container if available, otherwise document.body
469
- const portalContainer = (typeof document !== "undefined" ? document.body : undefined);
486
+ // Text, not `EmptyValue`. That component draws a small grey rounded bar
487
+ // with no text in it, which is the same shape the `Skeleton` component
488
+ // uses while data is in flight — so a relation you simply had not filled
489
+ // in was indistinguishable from one that was still loading, and an empty
490
+ // Author or Tags field read as permanently stuck. It also left the trigger
491
+ // button with no accessible name, since the bar contains no text at all.
492
+ //
493
+ // This says "not filled in" rather than "nothing to choose from": which of
494
+ // those it is cannot be known here. The list is deliberately not fetched
495
+ // until the picker is first opened (`enabled: listRequested`, guarded by
496
+ // relation_cell_picker_gating.test.tsx, because a table renders one of
497
+ // these per row), so before that `availableItems` is empty because nobody
498
+ // asked, not because the target collection is. The genuine "nothing to
499
+ // pick yet" state is reported inside the popover below, where the fetch
500
+ // has actually happened.
501
+ const resolvedPlaceholder = placeholder || emptyPlaceholder || emptyText;
502
+
503
+ // Whatever dialog, side dialog or sheet we are inside offers as its
504
+ // portal host, falling back to `document.body`. This is not cosmetic:
505
+ // those are modal, and a modal locks scrolling everywhere outside its
506
+ // own content — a list portaled to the body opens and then refuses to
507
+ // scroll, because every wheel event over it is cancelled.
508
+ const portalContainer = (contextPortalContainer ?? (typeof document !== "undefined" ? document.body : undefined)) ?? undefined;
470
509
 
471
510
  return (
472
511
  <>
@@ -481,6 +520,10 @@ relation } as RelationItem;
481
520
  disabled={disabled}
482
521
  onClick={() => {
483
522
  if (disabled) return;
523
+ // Unconditional: the toggle can only close a
524
+ // popover that was opened, which requested the
525
+ // list already.
526
+ setListRequested(true);
484
527
  setIsPopoverOpen(o => {
485
528
  const next = !o;
486
529
  isPopoverOpenRef.current = next;
@@ -663,10 +706,17 @@ relation } as RelationItem;
663
706
  className="ml-2 text-sm text-text-secondary dark:text-text-secondary-dark">{loadingText}</span>
664
707
  </div>
665
708
  )}
709
+ {/* Two different nothings, which used to share one
710
+ message ("No relations found.") even though the
711
+ fix for each is the opposite: clear the search, or
712
+ go and create the first row. With the fetch done,
713
+ the search string is what tells them apart. */}
666
714
  {!isLoading && availableItems.length === 0 && (
667
715
  <CommandPrimitive.Empty
668
716
  className="px-4 py-6 text-center text-text-secondary dark:text-text-secondary-dark">
669
- {noResultsText}
717
+ {searchString
718
+ ? noResultsText
719
+ : (emptyCollectionText ?? `No ${collection.name ?? "entries"} yet.`)}
670
720
  </CommandPrimitive.Empty>
671
721
  )}
672
722
  <CommandPrimitive.Group>
@@ -4,9 +4,8 @@ import {
4
4
  coolIconKeys,
5
5
  debounce,
6
6
  IconButton,
7
- iconKeys,
8
7
  iconSize,
9
- lucideIcons,
8
+ LucideIconByName,
10
9
  SearchBar,
11
10
  Tooltip
12
11
  } from "@rebasepro/ui";
@@ -15,20 +14,6 @@ import { useTranslation } from "@rebasepro/app";
15
14
 
16
15
  const UPDATE_SEARCH_INDEX_WAIT_MS = 220;
17
16
 
18
-
19
- function toPascalCase(str: string): string {
20
- return str.split(/[-_]/).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join("");
21
- }
22
-
23
- function resolveIcon(iconKey: string): React.ComponentType<{ size: number }> | null {
24
- const allIcons = lucideIcons as Record<string, React.ComponentType<{ size: number }>>;
25
- let icon = allIcons[iconKey];
26
- if (!icon) {
27
- icon = allIcons[toPascalCase(iconKey)];
28
- }
29
- return icon ?? null;
30
- }
31
-
32
17
  export interface SearchIconsProps {
33
18
  selectedIcon?: string;
34
19
  onIconSelected?: (icon: string) => void;
@@ -74,23 +59,19 @@ export function SearchIconsView({
74
59
  />
75
60
 
76
61
  <div className={"flex max-w-full flex-wrap mt-4"}>
77
- {icons.map((icon: string) => {
78
- const LucideIcon = resolveIcon(icon);
79
- if (!LucideIcon) return null;
80
- return (
81
- <Tooltip title={icon} key={icon}
82
- asChild={true}>
83
- <IconButton
84
- shape={"square"}
85
- toggled={selectedIcon === icon}
86
- onClick={onIconSelected ? () => onIconSelected(icon) : undefined}
87
- className="box-content m-1"
88
- >
89
- <LucideIcon size={iconSize.medium}/>
90
- </IconButton>
91
- </Tooltip>
92
- );
93
- })}
62
+ {icons.map((icon: string) => (
63
+ <Tooltip title={icon} key={icon}
64
+ asChild={true}>
65
+ <IconButton
66
+ shape={"square"}
67
+ toggled={selectedIcon === icon}
68
+ onClick={onIconSelected ? () => onIconSelected(icon) : undefined}
69
+ className="box-content m-1"
70
+ >
71
+ <LucideIconByName name={icon} size={iconSize.medium}/>
72
+ </IconButton>
73
+ </Tooltip>
74
+ ))}
94
75
  </div>
95
76
  </>
96
77
  );
@@ -190,6 +190,7 @@ export function StringNumberFilterField({
190
190
 
191
191
  {!enumValues && <TextField
192
192
  size={"medium"}
193
+ aria-label={`${title ?? name} filter value`}
193
194
  type={type === "number" ? "number" : undefined}
194
195
  value={internalValue !== undefined && internalValue != null ? String(internalValue) : ""}
195
196
  disabled={isNullOperation}
@@ -9,7 +9,7 @@ import { EditViewBinding } from "./EditViewBinding";
9
9
  import { DetailViewBinding } from "./DetailViewBinding";
10
10
  import { useSideDialogContext } from "./SideDialogs";
11
11
  import { useNavigate } from "react-router";
12
- import { saveEntityToMemoryCache, useComponentOverride } from "@rebasepro/app";
12
+ import { useComponentOverride } from "@rebasepro/app";
13
13
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
14
14
  import { useSidePanel } from "../hooks/useSidePanel";
15
15
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
@@ -76,12 +76,33 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
76
76
  close(true);
77
77
  }
78
78
 
79
+ /**
80
+ * The panel's response to a save, and the one place it decides where the
81
+ * panel goes next.
82
+ *
83
+ * Exactly **one** panel navigation is raised here. These branches used to
84
+ * run independently — a `replace` onto the saved record's address *and* a
85
+ * close — and because the last navigation is the one that survives, which
86
+ * of them the user got was settled by statement order rather than by
87
+ * intent. Closing wins by construction now: moving a panel to a new address
88
+ * it is about to leave is work whose only effect is to fight the close.
89
+ *
90
+ * `props.onUpdate` runs last for the same reason. It belongs to whoever
91
+ * opened the panel and it is free to navigate — the reference picker's
92
+ * selects the record and closes the picker — so the opener's intent should
93
+ * be the final word, not something this panel overwrites on its way out.
94
+ */
79
95
  const onUpdate = (params: OnUpdateParams) => {
80
- if (props.onUpdate) {
81
- props.onUpdate(params);
82
- }
83
96
  setEditInDialog(false);
84
- if (params.status !== "existing") {
97
+
98
+ // Either "save and close" was clicked, or the opener asked for it when
99
+ // it opened the panel.
100
+ const closeRequested = sideDialogsController.pendingClose || props.closeOnSave;
101
+
102
+ if (closeRequested) {
103
+ sideDialogsController.setPendingClose(false);
104
+ closeAfterSave();
105
+ } else if (params.status !== "existing") {
85
106
  // A newly created entity replaces the panel with its own address,
86
107
  // which already leaves the edit view behind.
87
108
  sidePanelController.replace({
@@ -95,11 +116,7 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
95
116
  closeEditView();
96
117
  }
97
118
 
98
- if (sideDialogsController.pendingClose) {
99
- sideDialogsController.setPendingClose(false);
100
- closeAfterSave();
101
- }
102
-
119
+ props.onUpdate?.(params);
103
120
  }
104
121
 
105
122
  const parentCollectionSlugs = useMemo(() => {
@@ -290,25 +307,19 @@ entityId }
290
307
  onSaved={onUpdate}
291
308
  navigateBack={closeEditView}
292
309
  barActionsStart={barActionsStart}
293
- barActions={({
294
- status,
295
- values,
296
- dirty
297
- }) => windowControls(
310
+ barActions={({ carryEdit }) => windowControls(
298
311
  allowFullScreen && <IconButton
299
312
  className="self-center"
300
313
  size={"small"}
301
314
  onClick={() => {
302
- // Only an edit in progress is worth carrying across.
303
- // A record loaded from this cache opens dirty, so
304
- // stashing a clean one made the full-screen view
305
- // announce unsaved changes to someone who had only
306
- // looked at it. A new record always carries: there
307
- // is nothing on the server to fall back to.
308
- if (dirty || status === "new" || status === "copy") {
309
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
310
- saveEntityToMemoryCache(key, values);
311
- }
315
+ // The form's own handoff, rather than a second
316
+ // copy of the rules for it here: only an edit in
317
+ // progress is worth carrying (a clean record
318
+ // stashed anyway arrived announcing unsaved
319
+ // changes to someone who had only looked at it),
320
+ // and only the fields that were edited (carrying
321
+ // the whole record marked every field touched).
322
+ carryEdit?.();
312
323
  setBlocked(false);
313
324
  setBlockedNavigationMessage(undefined);
314
325
  if (entityId) {