@rebasepro/admin 0.13.0 → 0.13.1-canary.g1822133

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 (210) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CSheud_E.js} +19 -9
  2. package/dist/CollectionEditorDialog-CSheud_E.js.map +1 -0
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CWZlC1gq.js} +5 -5
  4. package/dist/PropertyEditView-CWZlC1gq.js.map +1 -0
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.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/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/ClearFilterSortButton.d.ts +7 -1
  11. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  12. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  13. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  14. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +101 -0
  15. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  16. package/dist/components/CollectionViewBinding/SortButton.d.ts +7 -1
  17. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  18. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  19. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  20. package/dist/components/DetailViewBinding.d.ts +15 -0
  21. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  22. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  23. package/dist/components/EditViewBinding.d.ts +30 -1
  24. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  25. package/dist/components/EntityIdentityBar.d.ts +43 -1
  26. package/dist/components/EntityViewBinding.d.ts +60 -6
  27. package/dist/components/RebaseLayout.d.ts +1 -1
  28. package/dist/components/RebaseShell.d.ts +1 -1
  29. package/dist/components/RelationSelector.d.ts +21 -1
  30. package/dist/components/UserSelector.d.ts +8 -1
  31. package/dist/components/app/Scaffold.d.ts +9 -3
  32. package/dist/components/index.d.ts +2 -0
  33. package/dist/data_export/export/export.d.ts +7 -0
  34. package/dist/data_export/export/fetch_export_data.d.ts +48 -0
  35. package/dist/data_export/export/index.d.ts +1 -0
  36. package/dist/data_import/utils/csv.d.ts +30 -0
  37. package/dist/data_import/utils/index.d.ts +2 -0
  38. package/dist/data_import/utils/save_entities.d.ts +38 -0
  39. package/dist/data_import/utils/transforms.d.ts +9 -0
  40. package/dist/editor.js +3 -0
  41. package/dist/editor.js.map +1 -1
  42. package/dist/{export-J79rMoNj.js → export-L53WektO.js} +175 -39
  43. package/dist/export-L53WektO.js.map +1 -0
  44. package/dist/form/components/FieldBlock.d.ts +58 -4
  45. package/dist/form/components/FormRail.d.ts +13 -0
  46. package/dist/form/components/FormSections.d.ts +15 -1
  47. package/dist/form/components/useRailVisible.d.ts +5 -4
  48. package/dist/form/form_utils.d.ts +38 -1
  49. package/dist/{history-D7L_JlLO.js → history-D5SKygpJ.js} +4 -4
  50. package/dist/{history-D7L_JlLO.js.map → history-D5SKygpJ.js.map} +1 -1
  51. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  52. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  53. package/dist/{import-BvYl--U4.js → import-C_4edkoD.js} +6 -5
  54. package/dist/import-C_4edkoD.js.map +1 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +237 -123
  57. package/dist/index.js.map +1 -1
  58. package/dist/preview/compact_text.d.ts +23 -0
  59. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  60. package/dist/preview/components/date_fns_locales.d.ts +9 -0
  61. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  62. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  63. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  64. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  65. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  66. package/dist/routes/RebaseRoute.d.ts +1 -1
  67. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  68. package/dist/util/number_format.d.ts +14 -0
  69. package/dist/util/previews.d.ts +9 -2
  70. package/dist/util/view_constants.d.ts +24 -0
  71. package/dist/util/view_mode.d.ts +18 -0
  72. package/dist/{util-GiwPgxnL.js → util-C-D5yD2n.js} +4161 -1809
  73. package/dist/util-C-D5yD2n.js.map +1 -0
  74. package/package.json +10 -9
  75. package/src/collection_editor/serializable_types.ts +1 -0
  76. package/src/collection_editor/ui/collection_editor/AICollectionGeneratorPopover.tsx +8 -3
  77. package/src/collection_editor/ui/collection_editor/CollectionDetailsForm.tsx +5 -2
  78. package/src/collection_editor/ui/collection_editor/CollectionJsonImportDialog.tsx +1 -0
  79. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +9 -8
  80. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  81. package/src/collection_editor/ui/collection_editor/GeneralSettingsForm.tsx +5 -2
  82. package/src/collection_editor/ui/collection_editor/properties/MapPropertyField.tsx +1 -1
  83. package/src/collection_editor/ui/collection_editor/properties/advanced/AdvancedPropertyValidation.tsx +5 -2
  84. package/src/collection_editor/ui/collection_editor/properties/conditions/ConditionsEditor.tsx +1 -0
  85. package/src/collection_editor/useJsonCollectionsConfigController.tsx +92 -84
  86. package/src/collection_editor/useLocalCollectionsConfigController.tsx +10 -3
  87. package/src/components/AdditionalFieldValue.tsx +68 -0
  88. package/src/components/ClearFilterSortButton.tsx +16 -7
  89. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  90. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  91. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  92. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  93. package/src/components/CollectionTableBinding/table_bindings.tsx +7 -1
  94. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  95. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +13 -7
  96. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  97. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
  98. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  99. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +52 -12
  100. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +9 -0
  101. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
  102. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  103. package/src/components/CollectionViewBinding/SearchHighlight.tsx +311 -0
  104. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  105. package/src/components/CollectionViewBinding/SortButton.tsx +16 -4
  106. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  107. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  108. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  109. package/src/components/CollectionViewBinding/ViewModeToggle.tsx +10 -5
  110. package/src/components/CollectionViewBinding/useBoardDataController.tsx +23 -6
  111. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  112. package/src/components/CollectionViewBinding/utils.ts +7 -4
  113. package/src/components/DefaultDrawer.tsx +26 -23
  114. package/src/components/DetailViewBinding.tsx +109 -62
  115. package/src/components/DrawerNavigationGroup.tsx +16 -39
  116. package/src/components/DrawerNavigationItem.tsx +56 -16
  117. package/src/components/EditViewBinding.tsx +233 -50
  118. package/src/components/EntityDisplayHeader.tsx +170 -0
  119. package/src/components/EntityIdentityBar.tsx +163 -23
  120. package/src/components/EntityInspector.tsx +24 -6
  121. package/src/components/EntityPreviewBinding.tsx +58 -18
  122. package/src/components/EntityViewBinding.tsx +344 -41
  123. package/src/components/InlineEntityPreview.tsx +10 -10
  124. package/src/components/RebaseLayout.tsx +2 -2
  125. package/src/components/RebaseShell.tsx +2 -2
  126. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  127. package/src/components/RelationSelector.tsx +76 -15
  128. package/src/components/SearchIconsView.tsx +14 -33
  129. package/src/components/SelectableTable/filters/StringNumberFilterField.tsx +1 -0
  130. package/src/components/SideDialogs.tsx +15 -3
  131. package/src/components/SidePanelBinding.tsx +122 -66
  132. package/src/components/UserSelector.tsx +67 -13
  133. package/src/components/app/Scaffold.tsx +112 -10
  134. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  135. package/src/components/index.ts +3 -0
  136. package/src/data_export/export/ExportCollectionAction.tsx +101 -48
  137. package/src/data_export/export/export.ts +63 -16
  138. package/src/data_export/export/fetch_export_data.ts +90 -0
  139. package/src/data_export/export/index.ts +1 -0
  140. package/src/data_import/components/DataNewPropertiesMapping.tsx +20 -3
  141. package/src/data_import/components/ImportNewPropertyFieldPreview.tsx +1 -0
  142. package/src/data_import/components/ImportSaveInProgress.tsx +35 -36
  143. package/src/data_import/import/ImportCollectionAction.tsx +13 -2
  144. package/src/data_import/utils/csv.ts +153 -0
  145. package/src/data_import/utils/data.ts +16 -2
  146. package/src/data_import/utils/file_to_json.ts +77 -6
  147. package/src/data_import/utils/index.ts +2 -0
  148. package/src/data_import/utils/save_entities.ts +138 -0
  149. package/src/data_import/utils/transforms.ts +23 -0
  150. package/src/editor/components/image-bubble.tsx +2 -0
  151. package/src/editor/selectors/link-selector.tsx +1 -0
  152. package/src/form/EntityForm.tsx +74 -53
  153. package/src/form/EntityFormBinding.tsx +24 -11
  154. package/src/form/PropertyFieldBinding.tsx +2 -2
  155. package/src/form/components/FieldBlock.tsx +80 -10
  156. package/src/form/components/FormRail.tsx +19 -2
  157. package/src/form/components/FormSections.tsx +32 -5
  158. package/src/form/components/StorageUploadProgress.tsx +9 -3
  159. package/src/form/components/useRailVisible.tsx +5 -4
  160. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  161. package/src/form/field_bindings/BinaryFieldBinding.tsx +11 -5
  162. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  163. package/src/form/field_bindings/DateTimeFieldBinding.tsx +4 -0
  164. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  165. package/src/form/field_bindings/KeyValueFieldBinding.tsx +3 -0
  166. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  167. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  168. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  169. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  170. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  171. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  172. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  173. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  174. package/src/form/field_bindings/TextFieldBinding.tsx +18 -0
  175. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  176. package/src/form/field_bindings/VectorFieldBinding.tsx +7 -4
  177. package/src/form/form_utils.ts +78 -0
  178. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  179. package/src/hooks/useEntityDisplay.tsx +262 -0
  180. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  181. package/src/index.ts +9 -1
  182. package/src/preview/PropertyPreview.tsx +25 -5
  183. package/src/preview/compact_text.ts +68 -0
  184. package/src/preview/components/BooleanPreview.tsx +5 -2
  185. package/src/preview/components/DatePreview.tsx +15 -2
  186. package/src/preview/components/ReferencePreview.tsx +5 -8
  187. package/src/preview/components/RelationPreview.tsx +8 -7
  188. package/src/preview/components/StorageThumbnail.tsx +5 -2
  189. package/src/preview/components/date_fns_locales.ts +125 -0
  190. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  191. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  192. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  193. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  194. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  195. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  196. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  197. package/src/routes/RebaseRoute.tsx +62 -4
  198. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  199. package/src/util/navigation_utils.ts +4 -2
  200. package/src/util/number_format.ts +46 -0
  201. package/src/util/previews.ts +8 -55
  202. package/src/util/property_utils.tsx +6 -1
  203. package/src/util/view_constants.ts +27 -0
  204. package/src/util/view_mode.ts +36 -0
  205. package/dist/CollectionEditorDialog-CjXkWZ64.js.map +0 -1
  206. package/dist/PropertyEditView-W8I3YV89.js.map +0 -1
  207. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  208. package/dist/export-J79rMoNj.js.map +0 -1
  209. package/dist/import-BvYl--U4.js.map +0 -1
  210. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -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 {
@@ -58,7 +57,14 @@ export interface RelationSelectorProps {
58
57
 
59
58
  onValueChange?: (updatedValue: EntityRelation | EntityRelation[] | undefined) => void;
60
59
  placeholder?: React.ReactNode;
61
- size?: "small" | "medium";
60
+ /**
61
+ * `large` is the form's size and is on the shared 28/32/40/48 control
62
+ * scale, so a relation picker lines up with the text field beside it.
63
+ *
64
+ * `small` and `medium` predate that scale (42 and 56) and are kept as they
65
+ * are because the table cells and the filter row are built around them.
66
+ */
67
+ size?: "small" | "medium" | "large";
62
68
  useChips?: boolean;
63
69
  disabled?: boolean;
64
70
  error?: boolean; // kept for backwards compatibility (could be used for styling later)
@@ -84,7 +90,20 @@ export interface RelationSelectorProps {
84
90
  pageSize?: number;
85
91
  emptyPlaceholder?: string;
86
92
  searchPlaceholder?: string;
93
+ /** Shown in the open list when a search matched nothing. */
87
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;
88
107
  loadingText?: string;
89
108
  }
90
109
 
@@ -108,7 +127,9 @@ export const RelationSelector = React.forwardRef<
108
127
  pageSize,
109
128
  emptyPlaceholder,
110
129
  searchPlaceholder = "Search...",
111
- noResultsText = "No relations found.",
130
+ noResultsText = "No matches.",
131
+ emptyText = "Select…",
132
+ emptyCollectionText,
112
133
  loadingText = "Loading..."
113
134
  },
114
135
  ref
@@ -117,10 +138,16 @@ export const RelationSelector = React.forwardRef<
117
138
  const collection = relation.target();
118
139
  const dataClient = useData();
119
140
  const sidePanelController = useSidePanel();
141
+ const contextPortalContainer = usePortalContainer();
120
142
  const multiple = multipleOverride ?? relationCardinality(relation) === "many";
121
143
 
122
144
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
123
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);
124
151
  const [selectedItems, setSelectedItems] = useState<RelationItem[]>([]);
125
152
  const [isLoadingSelectedItems, setIsLoadingSelectedItems] = useState(false);
126
153
  const [searchString, setSearchString] = useState<string>("");
@@ -143,7 +170,8 @@ export const RelationSelector = React.forwardRef<
143
170
  path: getCollectionDataPath(collection),
144
171
  collection,
145
172
  fixedFilter,
146
- pageSize
173
+ pageSize,
174
+ enabled: listRequested
147
175
  });
148
176
 
149
177
  const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -410,6 +438,7 @@ relation } as RelationItem;
410
438
  if (next) {
411
439
  // Capture current selection so we can pin those items to the top of the list
412
440
  pinnedIdsRef.current = new Set(selectedItems.map(i => String(i.id)));
441
+ setListRequested(true);
413
442
  setIsPopoverOpen(true);
414
443
  isPopoverOpenRef.current = true;
415
444
  }
@@ -448,18 +477,35 @@ relation } as RelationItem;
448
477
  };
449
478
  }, [isPopoverOpen]);
450
479
 
451
- useInjectStyles("RelationSelector", " [cmdk-group] { max-height: 40vh; overflow-y: auto; } ");
452
-
453
480
  const closePopover = useCallback(() => {
454
481
  setIsPopoverOpen(false);
455
482
  isPopoverOpenRef.current = false;
456
483
  pinnedIdsRef.current = null;
457
484
  }, []);
458
485
 
459
- const resolvedPlaceholder = placeholder || emptyPlaceholder || <EmptyValue className={"ml-2"}/>;
460
-
461
- // Use Sheet portal container if available, otherwise document.body
462
- 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;
463
509
 
464
510
  return (
465
511
  <>
@@ -474,6 +520,10 @@ relation } as RelationItem;
474
520
  disabled={disabled}
475
521
  onClick={() => {
476
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);
477
527
  setIsPopoverOpen(o => {
478
528
  const next = !o;
479
529
  isPopoverOpenRef.current = next;
@@ -488,9 +538,13 @@ relation } as RelationItem;
488
538
  className={cls(
489
539
  {
490
540
  "min-h-[42px] py-1 px-2": size === "small",
491
- "min-h-[56px] py-2 px-4": size === "medium"
541
+ "min-h-[56px] py-2 px-4": size === "medium",
542
+ "min-h-[48px] py-1 px-4": size === "large"
492
543
  },
493
- "w-full select-none rounded-md text-sm relative flex items-center",
544
+ // `rounded-lg` like every other field box —
545
+ // `rounded-md` made this the one control in a
546
+ // row with a different corner.
547
+ "w-full select-none rounded-lg text-sm relative flex items-center",
494
548
  invisible ? fieldBackgroundInvisibleMixin : fieldBackgroundMixin,
495
549
  disabled ? fieldBackgroundDisabledMixin : fieldBackgroundHoverMixin,
496
550
  className
@@ -580,7 +634,7 @@ relation } as RelationItem;
580
634
  </Tooltip>
581
635
  )}
582
636
  <ChevronDownIcon
583
- size={size === "medium" ? iconSize.medium : iconSize.small}
637
+ size={size === "small" ? iconSize.small : iconSize.medium}
584
638
  className={cls("transition", isPopoverOpen ? "rotate-180" : "")}
585
639
  />
586
640
  </div>
@@ -652,10 +706,17 @@ relation } as RelationItem;
652
706
  className="ml-2 text-sm text-text-secondary dark:text-text-secondary-dark">{loadingText}</span>
653
707
  </div>
654
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. */}
655
714
  {!isLoading && availableItems.length === 0 && (
656
715
  <CommandPrimitive.Empty
657
716
  className="px-4 py-6 text-center text-text-secondary dark:text-text-secondary-dark">
658
- {noResultsText}
717
+ {searchString
718
+ ? noResultsText
719
+ : (emptyCollectionText ?? `No ${collection.name ?? "entries"} yet.`)}
659
720
  </CommandPrimitive.Empty>
660
721
  )}
661
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}
@@ -91,7 +91,17 @@ function SideDialogView({
91
91
  const [blocked, setBlocked] = useState(false);
92
92
  const [blockedNavigationMessage, setBlockedNavigationMessage] = useState<React.ReactNode | undefined>();
93
93
 
94
- const [pendingClose, setPendingClose] = useState(false);
94
+ // Held in a ref, read through a getter, because of *when* it is written and
95
+ // read. "Save and close" raises it and submits the form in one click
96
+ // handler, and the callback that acts on it — the form's `onSaved` — was
97
+ // captured before React could re-render with the new value. As state the
98
+ // flag was therefore always read one save late: the first "save and close"
99
+ // saved and left the panel open, and the *next* save closed it.
100
+ // Nothing renders from it, so a ref loses nothing.
101
+ const pendingCloseRef = React.useRef(false);
102
+ const setPendingClose = useCallback((value: boolean) => {
103
+ pendingCloseRef.current = value;
104
+ }, []);
95
105
 
96
106
  const widthRef = React.useRef<string | undefined>(panel?.width);
97
107
  const width = widthRef.current;
@@ -142,9 +152,11 @@ function SideDialogView({
142
152
  setBlockedNavigationMessage,
143
153
  width,
144
154
  close: onCloseRequest,
145
- pendingClose,
155
+ get pendingClose() {
156
+ return pendingCloseRef.current;
157
+ },
146
158
  setPendingClose
147
- }), [blocked, setBlockedNavigationMessage, width, onCloseRequest, pendingClose]);
159
+ }), [blocked, setBlockedNavigationMessage, width, onCloseRequest, setPendingClose]);
148
160
 
149
161
  const additionalProps = panel?.additional as SidePanelBindingProps | undefined;
150
162
  const isDialogMode = additionalProps?.collection?.openEntityMode === "dialog";
@@ -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";
@@ -49,21 +49,60 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
49
49
  const sideDialogsController = useSideDialogContext();
50
50
  const urlController = useUrlController();
51
51
 
52
+ /**
53
+ * The X in the identity bar.
54
+ *
55
+ * Unforced, so it stops at the same unsaved-changes prompt that Escape and
56
+ * a click outside already do. It used to clear `blocked` and force the
57
+ * close, which made the one control actually labelled "close" the only way
58
+ * out of the panel that dropped an edit without asking.
59
+ *
60
+ * `props.onClose` is not called here: the panel carries the same callback
61
+ * (see `propsToSidePanel`) and {@link SideDialogs} fires it once the panel
62
+ * really closes — which is after the prompt, not before it.
63
+ */
52
64
  const onClose = () => {
53
- if (props.onClose) {
54
- props.onClose();
55
- }
65
+ close();
66
+ }
56
67
 
68
+ /**
69
+ * Closing because the record was just saved, which is the one case that has
70
+ * to force. `blocked` is driven by an effect on the form's dirty flag and is
71
+ * still true for a tick after a save; prompting about unsaved changes here
72
+ * would be asking about values that are already on the server.
73
+ */
74
+ const closeAfterSave = () => {
57
75
  setBlocked(false);
58
76
  close(true);
59
77
  }
60
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
+ */
61
95
  const onUpdate = (params: OnUpdateParams) => {
62
- if (props.onUpdate) {
63
- props.onUpdate(params);
64
- }
65
96
  setEditInDialog(false);
66
- 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") {
67
106
  // A newly created entity replaces the panel with its own address,
68
107
  // which already leaves the edit view behind.
69
108
  sidePanelController.replace({
@@ -77,11 +116,7 @@ export function SidePanelBinding(props: SidePanelBindingProps) {
77
116
  closeEditView();
78
117
  }
79
118
 
80
- if (sideDialogsController.pendingClose) {
81
- sideDialogsController.setPendingClose(false);
82
- onClose();
83
- }
84
-
119
+ props.onUpdate?.(params);
85
120
  }
86
121
 
87
122
  const parentCollectionSlugs = useMemo(() => {
@@ -181,6 +216,43 @@ entityId }
181
216
  return <div className={"w-full"}/>;
182
217
  }
183
218
 
219
+ const closeButton = (
220
+ <IconButton
221
+ className="self-center"
222
+ size={"small"}
223
+ onClick={onClose}>
224
+ <XIcon/>
225
+ </IconButton>
226
+ );
227
+
228
+ /**
229
+ * Where the panel's own two controls sit, which is not the same question in
230
+ * the two layouts that have them.
231
+ *
232
+ * A **side panel** slides in over the list from the right, and its leading
233
+ * edge is the seam between the two. Close belongs on that seam — it points
234
+ * back at the thing still visible behind it — and it is where the eye
235
+ * already is, because the breadcrumb starts there.
236
+ *
237
+ * A **dialog** has no seam and nothing behind it to point at. It is a box
238
+ * centred on a dimmed screen, and every box like it on every platform is
239
+ * dismissed from its top-right corner. Close was sitting at the far left
240
+ * of the bar, a full dialog's width from the expand button it belongs
241
+ * beside, in the one corner that means "back" in a layout with no back.
242
+ *
243
+ * So in a dialog the two travel together at the trailing edge, expand then
244
+ * close: the window controls, after the record's actions and its overflow
245
+ * menu, in the corner a dialog is closed from.
246
+ */
247
+ const barActionsStart = isDialogMode ? undefined : closeButton;
248
+
249
+ const windowControls = (expandButton: React.ReactNode) => (
250
+ <div className="flex gap-1 items-center">
251
+ {expandButton}
252
+ {isDialogMode && closeButton}
253
+ </div>
254
+ );
255
+
184
256
  return (
185
257
  <>
186
258
  <ErrorBoundary>
@@ -193,31 +265,20 @@ entityId }
193
265
  parentCollectionSlugs={parentCollectionSlugs}
194
266
  parentEntityIds={parentEntityIds}
195
267
  onEditClick={openEditView}
196
- barActionsStart={
197
- // At the bar's leading edge, where the breadcrumb
198
- // starts: the corner of the panel it closes.
199
- <IconButton
268
+ barActionsStart={barActionsStart}
269
+ barActions={() => windowControls(
270
+ allowFullScreen && <IconButton
200
271
  className="self-center"
201
272
  size={"small"}
202
- onClick={onClose}>
203
- <XIcon/>
204
- </IconButton>}
205
- barActions={({
206
- status,
207
- values
208
- }) => <div className="flex gap-1">
209
- {allowFullScreen && <IconButton
210
- className="self-center"
211
- size={"small"}
212
- onClick={() => {
213
- if (entityId) {
214
- const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
215
- navigate(fullScreenUrl, { state: null });
216
- }
217
- }}>
218
- <Maximize2Icon/>
219
- </IconButton>}
220
- </div>}
273
+ onClick={() => {
274
+ if (entityId) {
275
+ const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
276
+ navigate(fullScreenUrl, { state: null });
277
+ }
278
+ }}>
279
+ <Maximize2Icon/>
280
+ </IconButton>
281
+ )}
221
282
  onTabChange={({
222
283
  entityId: tabEntityId,
223
284
  selectedTab,
@@ -245,38 +306,33 @@ entityId }
245
306
  onValuesModified={onValuesModified}
246
307
  onSaved={onUpdate}
247
308
  navigateBack={closeEditView}
248
- barActionsStart={
249
- // At the bar's leading edge, where the breadcrumb
250
- // starts: the corner of the panel it closes.
251
- <IconButton
309
+ barActionsStart={barActionsStart}
310
+ barActions={({ carryEdit }) => windowControls(
311
+ allowFullScreen && <IconButton
252
312
  className="self-center"
253
313
  size={"small"}
254
- onClick={onClose}>
255
- <XIcon/>
256
- </IconButton>}
257
- barActions={({
258
- status,
259
- values
260
- }) => <div className="flex gap-1">
261
- {allowFullScreen && <IconButton
262
- className="self-center"
263
- size={"small"}
264
- onClick={() => {
265
- const key = (status === "new" || status === "copy") ? path + "#new" : path + "/" + entityId;
266
- saveEntityToMemoryCache(key, values);
267
- setBlocked(false);
268
- setBlockedNavigationMessage(undefined);
269
- if (entityId) {
270
- const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
271
- navigate(fullScreenUrl, { state: null });
272
- } else {
273
- const fullScreenUrl = urlController.buildUrlCollectionPath(path);
274
- navigate(fullScreenUrl + "#new", { state: null });
275
- }
276
- }}>
277
- <Maximize2Icon/>
278
- </IconButton>}
279
- </div>}
314
+ onClick={() => {
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?.();
323
+ setBlocked(false);
324
+ setBlockedNavigationMessage(undefined);
325
+ if (entityId) {
326
+ const fullScreenUrl = urlController.buildUrlCollectionPath(`${path}/${entityId}`);
327
+ navigate(fullScreenUrl, { state: null });
328
+ } else {
329
+ const fullScreenUrl = urlController.buildUrlCollectionPath(path);
330
+ navigate(fullScreenUrl + "#new", { state: null });
331
+ }
332
+ }}>
333
+ <Maximize2Icon/>
334
+ </IconButton>
335
+ )}
280
336
  onTabChange={({
281
337
  entityId: tabEntityId,
282
338
  selectedTab,