@rebasepro/admin 0.12.1-canary.gf5f1d39 → 0.13.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-CjXkWZ64.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-CjXkWZ64.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-W8I3YV89.js} +36 -16
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-W8I3YV89.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-BsLTNoqX.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-BsLTNoqX.js.map} +1 -1
  8. package/dist/collection_editor/serializable_types.d.ts +4 -1
  9. package/dist/collection_editor/ui/collection_editor/CollectionRelationsTab.d.ts +1 -0
  10. package/dist/collection_editor/useLocalCollectionsConfigController.d.ts +8 -0
  11. package/dist/collection_editor_ui.js +5 -5
  12. package/dist/collection_editor_ui.js.map +1 -1
  13. package/dist/components/CollectionTableBinding/column_utils.d.ts +17 -0
  14. package/dist/components/CollectionViewBinding/CollectionViewStartActions.d.ts +7 -2
  15. package/dist/components/CollectionViewBinding/SortButton.d.ts +24 -0
  16. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
  17. package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
  18. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
  19. package/dist/components/DetailViewBinding.d.ts +5 -0
  20. package/dist/components/EditFormActions.d.ts +12 -1
  21. package/dist/components/EditViewBinding.d.ts +6 -1
  22. package/dist/components/EntityIdentityBar.d.ts +53 -0
  23. package/dist/components/EntityInspector.d.ts +35 -0
  24. package/dist/components/EntityPreviewNesting.d.ts +25 -0
  25. package/dist/components/EntityViewBinding.d.ts +9 -0
  26. package/dist/components/InlineEntityPreview.d.ts +47 -0
  27. package/dist/components/history/EntityHistoryView.d.ts +4 -1
  28. package/dist/components/index.d.ts +2 -0
  29. package/dist/editor.js +18 -10
  30. package/dist/editor.js.map +1 -1
  31. package/dist/{export-BzIpUdSK.js → export-J79rMoNj.js} +2 -2
  32. package/dist/{export-BzIpUdSK.js.map → export-J79rMoNj.js.map} +1 -1
  33. package/dist/form/EntityForm.d.ts +1 -1
  34. package/dist/form/EntityFormActions.d.ts +11 -1
  35. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  36. package/dist/form/components/FieldBlock.d.ts +30 -0
  37. package/dist/form/components/FormRail.d.ts +18 -0
  38. package/dist/form/components/FormSections.d.ts +16 -0
  39. package/dist/form/components/index.d.ts +3 -2
  40. package/dist/form/components/useRailVisible.d.ts +24 -0
  41. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  42. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  43. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  44. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  45. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  46. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  47. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  48. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  49. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  50. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  51. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  52. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  53. package/dist/{history-g9688IaK.js → history-D7L_JlLO.js} +35 -33
  54. package/dist/history-D7L_JlLO.js.map +1 -0
  55. package/dist/hooks/navigation/utils.d.ts +16 -0
  56. package/dist/hooks/useBuildSidePanel.d.ts +14 -0
  57. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
  58. package/dist/hooks/useHistory.d.ts +7 -0
  59. package/dist/hooks/useRecordActions.d.ts +17 -0
  60. package/dist/{import-BjUG-cIY.js → import-BvYl--U4.js} +2 -2
  61. package/dist/{import-BjUG-cIY.js.map → import-BvYl--U4.js.map} +1 -1
  62. package/dist/index.js +106 -35
  63. package/dist/index.js.map +1 -1
  64. package/dist/markdown-CklalUUk.js.map +1 -1
  65. package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
  66. package/dist/preview/index.d.ts +1 -0
  67. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  68. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  69. package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
  70. package/dist/types/components/EntityFormProps.d.ts +0 -5
  71. package/dist/types/fields.d.ts +24 -0
  72. package/dist/{util-B5fJm1FA.js → util-GiwPgxnL.js} +2106 -1117
  73. package/dist/util-GiwPgxnL.js.map +1 -0
  74. package/package.json +32 -34
  75. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  76. package/src/collection_editor/serializable_types.ts +4 -3
  77. package/src/collection_editor/serializable_utils.ts +51 -17
  78. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  79. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  80. package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
  81. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  82. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  83. package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
  84. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  85. package/src/collection_editor/validateCollectionJson.ts +3 -3
  86. package/src/components/AdminModeSyncer.tsx +1 -1
  87. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
  88. package/src/components/CollectionTableBinding/column_utils.tsx +59 -2
  89. package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
  90. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
  91. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
  92. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +9 -2
  93. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +47 -12
  94. package/src/components/CollectionViewBinding/SortButton.tsx +189 -0
  95. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
  96. package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
  97. package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
  98. package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
  99. package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
  100. package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
  101. package/src/components/DefaultAppBar.tsx +1 -1
  102. package/src/components/DefaultDrawer.tsx +1 -1
  103. package/src/components/DetailViewBinding.tsx +112 -148
  104. package/src/components/DrawerNavigationItem.tsx +1 -1
  105. package/src/components/EditFormActions.tsx +54 -129
  106. package/src/components/EditViewBinding.tsx +202 -99
  107. package/src/components/EntityIdentityBar.tsx +288 -0
  108. package/src/components/EntityInspector.tsx +137 -0
  109. package/src/components/EntityPreviewBinding.tsx +6 -2
  110. package/src/components/EntityPreviewNesting.tsx +50 -0
  111. package/src/components/EntityViewBinding.tsx +35 -32
  112. package/src/components/HomePage/FavouritesView.tsx +1 -1
  113. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  114. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  115. package/src/components/InlineEntityPreview.tsx +149 -0
  116. package/src/components/RebaseLayout.tsx +1 -1
  117. package/src/components/RebaseNavigation.tsx +45 -5
  118. package/src/components/RebaseRouteDefs.tsx +1 -1
  119. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  120. package/src/components/SidePanelBinding.tsx +19 -13
  121. package/src/components/UserSelector.tsx +12 -6
  122. package/src/components/common/default_entity_actions.tsx +4 -3
  123. package/src/components/history/EntityHistoryEntry.tsx +28 -7
  124. package/src/components/history/EntityHistoryView.tsx +29 -24
  125. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  126. package/src/components/index.ts +2 -0
  127. package/src/data_import/utils/get_import_inference_type.ts +6 -0
  128. package/src/form/EntityForm.tsx +267 -179
  129. package/src/form/EntityFormActions.tsx +50 -99
  130. package/src/form/EntityFormBinding.tsx +4 -2
  131. package/src/form/PropertyFieldBinding.tsx +4 -0
  132. package/src/form/components/FieldBlock.tsx +119 -0
  133. package/src/form/components/FormRail.tsx +125 -0
  134. package/src/form/components/FormSections.tsx +118 -0
  135. package/src/form/components/index.tsx +3 -2
  136. package/src/form/components/useRailVisible.tsx +46 -0
  137. package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
  138. package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
  139. package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
  140. package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
  141. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  142. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  143. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  144. package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
  145. package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
  146. package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
  147. package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
  148. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
  149. package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
  150. package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
  151. package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
  152. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  153. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  154. package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
  155. package/src/hooks/navigation/utils.ts +20 -0
  156. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  157. package/src/hooks/useBuildSidePanel.tsx +22 -61
  158. package/src/hooks/useEntityDisplayTitle.tsx +60 -0
  159. package/src/hooks/useHistory.ts +20 -10
  160. package/src/hooks/useRecordActions.tsx +69 -0
  161. package/src/hooks/useResolvedUsers.ts +10 -5
  162. package/src/preview/PropertyPreview.tsx +48 -22
  163. package/src/preview/components/InlineEntityListPreview.tsx +42 -0
  164. package/src/preview/components/ReferencePreview.tsx +34 -14
  165. package/src/preview/components/RelationPreview.tsx +34 -14
  166. package/src/preview/index.ts +1 -0
  167. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
  168. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
  169. package/src/routes/RebaseRoute.tsx +2 -2
  170. package/src/types/components/EntityFormActionsProps.tsx +0 -1
  171. package/src/types/components/EntityFormProps.tsx +0 -6
  172. package/src/types/fields.tsx +27 -0
  173. package/dist/form/components/FormEntry.d.ts +0 -6
  174. package/dist/form/components/FormLayout.d.ts +0 -5
  175. package/dist/history-g9688IaK.js.map +0 -1
  176. package/dist/util-B5fJm1FA.js.map +0 -1
  177. package/src/form/components/FormEntry.tsx +0 -22
  178. package/src/form/components/FormLayout.tsx +0 -16
@@ -0,0 +1,189 @@
1
+ import type { Properties } from "@rebasepro/types";
2
+
3
+ import React, { useCallback, useMemo, useState } from "react";
4
+ import { EntityTableController } from "@rebasepro/admin-types";
5
+ import {
6
+ ArrowDownIcon,
7
+ ArrowUpDownIcon,
8
+ ArrowUpIcon,
9
+ Button,
10
+ cls,
11
+ iconSize,
12
+ IconButton,
13
+ Popover,
14
+ Typography
15
+ } from "@rebasepro/ui";
16
+ import { useTranslation } from "@rebasepro/app";
17
+ import { getSortablePropertyOptions } from "../CollectionTableBinding/column_utils";
18
+ import { getIconForProperty } from "../../util/property_utils";
19
+
20
+ export type SortButtonProps<M extends Record<string, unknown>> = {
21
+ tableController: EntityTableController<M>;
22
+ /**
23
+ * Resolved properties of the collection. The sortable ones among them are
24
+ * the options offered.
25
+ */
26
+ properties: Properties;
27
+ };
28
+
29
+ /**
30
+ * Toolbar control that orders the collection by one property.
31
+ *
32
+ * The table orders by clicking a column header, which leaves every other view
33
+ * mode — list, cards, and either of them inside a split view — with no way to
34
+ * order at all, even though the controller has always carried `sortBy` and the
35
+ * query has always honoured it. This binds a control to it.
36
+ *
37
+ * Clicking the active property flips its direction, so ascending → descending
38
+ * is one click rather than a trip through a direction selector; clicking a
39
+ * different one starts it ascending. The popover stays open through both.
40
+ */
41
+ export function SortButton<M extends Record<string, unknown>>({
42
+ tableController,
43
+ properties
44
+ }: SortButtonProps<M>) {
45
+
46
+ const { t } = useTranslation();
47
+ const [open, setOpen] = useState(false);
48
+
49
+ const options = useMemo(() => getSortablePropertyOptions(properties), [properties]);
50
+
51
+ const {
52
+ sortBy,
53
+ setSortBy,
54
+ setItemCount,
55
+ pageSize,
56
+ filterValues,
57
+ checkFilterCombination
58
+ } = tableController;
59
+
60
+ const activeKey = sortBy?.[0];
61
+ const activeDirection = sortBy?.[1];
62
+
63
+ const applySort = useCallback((next?: [string, "asc" | "desc"]) => {
64
+ setSortBy?.(next);
65
+ // Re-ordering a partially loaded collection is the same event as
66
+ // clicking a table header: the rows already fetched are no longer the
67
+ // ones the query answers with, so pagination starts over rather than
68
+ // holding on to a page of the previous order (`SelectableTable` resets
69
+ // it for the same reason).
70
+ if (pageSize !== undefined)
71
+ setItemCount?.(pageSize);
72
+ }, [setSortBy, setItemCount, pageSize]);
73
+
74
+ const onOptionClick = useCallback((key: string) => {
75
+ if (key === activeKey) {
76
+ applySort([key, activeDirection === "asc" ? "desc" : "asc"]);
77
+ } else {
78
+ applySort([key, "asc"]);
79
+ }
80
+ }, [activeKey, activeDirection, applySort]);
81
+
82
+ if (!setSortBy || options.length === 0) {
83
+ return null;
84
+ }
85
+
86
+ const activeOption = options.find(option => option.key === activeKey);
87
+ const label = activeOption
88
+ ? `${t("sort_by")}: ${activeOption.title}`
89
+ : t("sort");
90
+
91
+ // Icon only, at every width. The arrow says which way the order runs; which
92
+ // property it runs on is in the popover, and in the tooltip for anyone who
93
+ // wants it without opening one.
94
+ //
95
+ // No `onClick` here and no `Tooltip` wrapper: the popover is controlled in
96
+ // this component, and Radix's trigger — which reaches the button through
97
+ // `asChild` — is what toggles it. A handler of our own would toggle it a
98
+ // second time in the same click and the popover would never open; a
99
+ // `Tooltip` in between would swallow the trigger's ref.
100
+ const trigger = (
101
+ <IconButton
102
+ size={"small"}
103
+ aria-label={label}
104
+ title={label}
105
+ className={cls(activeOption && "text-primary")}
106
+ >
107
+ {activeDirection === "desc"
108
+ ? <ArrowDownIcon size={iconSize.smallest}/>
109
+ : activeDirection === "asc"
110
+ ? <ArrowUpIcon size={iconSize.smallest}/>
111
+ : <ArrowUpDownIcon size={iconSize.smallest}/>}
112
+ </IconButton>
113
+ );
114
+
115
+ return (
116
+ <Popover
117
+ open={open}
118
+ onOpenChange={setOpen}
119
+ modal={false}
120
+ side="bottom"
121
+ align="start"
122
+ trigger={trigger}
123
+ >
124
+ <div className="py-2 min-w-[240px] flex flex-col">
125
+ <Typography
126
+ variant="caption"
127
+ color="secondary"
128
+ className="font-medium uppercase tracking-wider px-3 pb-1"
129
+ >
130
+ {t("sort_by")}
131
+ </Typography>
132
+
133
+ <div className="max-h-[320px] overflow-y-auto flex flex-col">
134
+ {options.map(({ key, title, property }) => {
135
+ const active = key === activeKey;
136
+ // A driver may refuse a sort next to the filter that is
137
+ // already applied. Offering it would turn the click
138
+ // into a failed request, so it is disabled with the
139
+ // same authority the table headers ask.
140
+ const disabled = checkFilterCombination
141
+ ? !checkFilterCombination(filterValues ?? {}, [key, active && activeDirection === "asc" ? "desc" : "asc"])
142
+ : false;
143
+ return (
144
+ <button
145
+ key={key}
146
+ type="button"
147
+ role="menuitemradio"
148
+ aria-checked={active}
149
+ disabled={disabled}
150
+ onClick={() => onOptionClick(key)}
151
+ className={cls(
152
+ "flex items-center gap-2 px-3 py-1.5 text-sm text-left w-full",
153
+ disabled
154
+ ? "opacity-50 cursor-not-allowed"
155
+ : "cursor-pointer hover:bg-surface-accent-100 dark:hover:bg-surface-accent-900",
156
+ active && "text-primary"
157
+ )}
158
+ >
159
+ <span className="flex-shrink-0 flex items-center">
160
+ {getIconForProperty(property, "smallest")}
161
+ </span>
162
+ <span className="flex-grow truncate">{title}</span>
163
+ {active && (
164
+ <span className="flex-shrink-0 flex items-center">
165
+ {activeDirection === "desc"
166
+ ? <ArrowDownIcon size={iconSize.smallest}/>
167
+ : <ArrowUpIcon size={iconSize.smallest}/>}
168
+ </span>
169
+ )}
170
+ </button>
171
+ );
172
+ })}
173
+ </div>
174
+
175
+ <div className="px-3 pt-2">
176
+ <Button
177
+ variant="text"
178
+ size="small"
179
+ className="w-full"
180
+ disabled={!activeOption}
181
+ onClick={() => applySort(undefined)}
182
+ >
183
+ {t("clear_sort")}
184
+ </Button>
185
+ </div>
186
+ </div>
187
+ </Popover>
188
+ );
189
+ }
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+
3
+ import { IconButton, iconSize, Tooltip, XIcon } from "@rebasepro/ui";
4
+ import { useTranslation } from "@rebasepro/app";
5
+
6
+ import { useSplitView } from "./SplitViewContext";
7
+
8
+ /**
9
+ * Closes the list of a split view, leaving the open record on its own.
10
+ *
11
+ * It leads the record's own app bar, where the breadcrumb starts — this is the
12
+ * entity pane's old "Open full screen" button, moved to that edge and wearing an
13
+ * X: closing the list and opening the record full screen are the same thing, so
14
+ * there is one control and one route (`#full`) rather than two.
15
+ */
16
+ export function SplitListCloseButton() {
17
+
18
+ const { t } = useTranslation();
19
+ const splitView = useSplitView();
20
+
21
+ if (!splitView?.detailOpen) return null;
22
+
23
+ return (
24
+ <Tooltip title={t("hide_list")}>
25
+ <IconButton
26
+ size="small"
27
+ onClick={splitView.openFullScreen}
28
+ aria-label={t("hide_list")}>
29
+ <XIcon size={iconSize.smallest}/>
30
+ </IconButton>
31
+ </Tooltip>
32
+ );
33
+ }
@@ -11,10 +11,11 @@ import {
11
11
  } from "@rebasepro/ui";
12
12
  import { useLargeLayout, UnsavedChangesDialog, useNavigationBlocker } from "@rebasepro/app";
13
13
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
14
- import { useNavigate, useLocation } from "react-router-dom";
14
+ import { useNavigate, useLocation } from "react-router";
15
15
  import { useUrlController } from "../../hooks/navigation/contexts/UrlContext";
16
16
  import { ErrorBoundary } from "@rebasepro/ui";
17
17
  import { withViewMode } from "../../util/view_mode";
18
+ import { SplitViewProvider } from "./SplitViewContext";
18
19
 
19
20
  export type SplitListViewProps<M extends Record<string, unknown> = Record<string, unknown>> = {
20
21
  collection: AdminCollection<M>;
@@ -408,8 +409,22 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
408
409
  </div>
409
410
  ) : <></>;
410
411
 
412
+ // Closing the list is the full-screen entity route: the same `#full` the
413
+ // entity pane's expand button used, so there is one way to give the record
414
+ // the whole window and one way back out of it.
415
+ const openFullScreen = useCallback(() => {
416
+ if (selectedEntityId === undefined) return;
417
+ const suffix = isEditMode ? "/edit" : "";
418
+ navigate(`${buildUrl(`${path}/${selectedEntityId}${suffix}`)}#full`);
419
+ }, [navigate, buildUrl, path, selectedEntityId, isEditMode]);
420
+
421
+ const splitViewController = useMemo(() => ({
422
+ detailOpen: selectedEntityId !== undefined,
423
+ openFullScreen
424
+ }), [selectedEntityId, openFullScreen]);
425
+
411
426
  return (
412
- <>
427
+ <SplitViewProvider value={splitViewController}>
413
428
  <ResizablePanels
414
429
  stacked={!largeLayout}
415
430
  firstPanel={listPanel}
@@ -428,6 +443,6 @@ export function SplitListView<M extends Record<string, unknown> = Record<string,
428
443
  }}
429
444
  handleCancel={() => blocker?.reset?.()}
430
445
  body={<>You have unsaved changes in this <b>{collection.singularName ?? collection.name}</b>.</>}/>
431
- </>
446
+ </SplitViewProvider>
432
447
  );
433
448
  }
@@ -0,0 +1,34 @@
1
+ import React, { useContext } from "react";
2
+
3
+ /**
4
+ * The enclosing master-detail split view, exposed to the list panel so it can
5
+ * carry the control that closes it.
6
+ */
7
+ export type SplitViewController = {
8
+ /** Whether a record is open in the detail pane. */
9
+ detailOpen: boolean;
10
+ /**
11
+ * Close the list and leave the open record on its own, full screen. This is
12
+ * the existing full-screen entity route (`#full`), not a second mechanism.
13
+ */
14
+ openFullScreen: () => void;
15
+ };
16
+
17
+ const SplitViewContext = React.createContext<SplitViewController | undefined>(undefined);
18
+
19
+ /**
20
+ * The enclosing split view, or `undefined` when the component is not rendered
21
+ * inside one (a side panel, a dialog, a full-screen route).
22
+ */
23
+ export function useSplitView(): SplitViewController | undefined {
24
+ return useContext(SplitViewContext);
25
+ }
26
+
27
+ export function SplitViewProvider({
28
+ value,
29
+ children
30
+ }: { value: SplitViewController, children: React.ReactNode }) {
31
+ return <SplitViewContext.Provider value={value}>
32
+ {children}
33
+ </SplitViewContext.Provider>;
34
+ }
@@ -7,6 +7,42 @@ import { BoardItem } from "@rebasepro/ui";
7
7
  import { BoardDataController } from "../useBoardDataController";
8
8
  import { generateKeyBetween } from "fractional-indexing";
9
9
 
10
+ /**
11
+ * The digits a board order key is built from: base36, lower case only.
12
+ *
13
+ * The library's default alphabet is base62, and its keys only sort correctly
14
+ * under byte ordering. The column is sorted by the *database*, and Postgres'
15
+ * default collation is not byte ordering — under `en_US.UTF-8`, `"aa"` sorts
16
+ * before `"aC"`, so as soon as a board had been dragged around enough to reach
17
+ * the upper-case digits its order stopped matching the keys. One case has no
18
+ * such ambiguity: digits before letters is true of every collation in use.
19
+ *
20
+ * A key written with the old alphabet no longer validates, which is what makes
21
+ * the board offer to initialise the column — one click, and the order is
22
+ * rewritten in a form the database can sort.
23
+ */
24
+ export const ORDER_KEY_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";
25
+
26
+ /**
27
+ * Whether a stored order value is a key `fractional-indexing` can interpolate
28
+ * against.
29
+ *
30
+ * Anything else — a plain `"12"` from a seed or a migration, an empty string —
31
+ * makes `generateKeyBetween` throw, and the fallback below then hands out the
32
+ * same first key to every card, so each drag lands at the bottom of the column
33
+ * on top of the last one that did. Asking the library itself is the only
34
+ * definition of "valid" worth trusting.
35
+ */
36
+ export function isValidOrderKey(value: unknown): value is string {
37
+ if (typeof value !== "string" || value.length === 0) return false;
38
+ try {
39
+ generateKeyBetween(value, null, ORDER_KEY_DIGITS);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
10
46
  export interface UseKanbanDragAndDropParams<M extends Record<string, unknown>> {
11
47
  collection: AdminCollection<M>;
12
48
  fullPath: string;
@@ -63,13 +99,16 @@ export function useKanbanDragAndDrop<M extends Record<string, unknown>>({
63
99
 
64
100
  const movedIndex = targetColumnItems.findIndex(item => item.id === moveInfo.itemId);
65
101
 
66
- // Get the order keys of the neighbours
67
- const prevKey = movedIndex > 0
68
- ? (targetColumnItems[movedIndex - 1].data.values?.[orderProperty] as string | null) ?? null
69
- : null;
70
- const nextKey = movedIndex < targetColumnItems.length - 1
71
- ? (targetColumnItems[movedIndex + 1].data.values?.[orderProperty] as string | null) ?? null
72
- : null;
102
+ // The order keys of the neighbours. A neighbour whose key is not a
103
+ // valid one counts as no neighbour at all: interpolating against it
104
+ // throws, and the whole move then falls back to a key that ignores
105
+ // where the card was actually dropped.
106
+ const keyAt = (index: number): string | null => {
107
+ const value = targetColumnItems[index]?.data.values?.[orderProperty];
108
+ return isValidOrderKey(value) ? value : null;
109
+ };
110
+ const prevKey = movedIndex > 0 ? keyAt(movedIndex - 1) : null;
111
+ const nextKey = movedIndex < targetColumnItems.length - 1 ? keyAt(movedIndex + 1) : null;
73
112
 
74
113
  try {
75
114
  const a = prevKey;
@@ -78,12 +117,12 @@ export function useKanbanDragAndDrop<M extends Record<string, unknown>>({
78
117
  // Handle duplicate or out-of-order keys to prevent fractional-indexing crash
79
118
  b = null;
80
119
  }
81
- const newKey = generateKeyBetween(a, b);
120
+ const newKey = generateKeyBetween(a, b, ORDER_KEY_DIGITS);
82
121
  updatedValues = setIn(updatedValues, orderProperty, newKey) as Record<string, unknown>;
83
122
  } catch (e) {
84
123
  // Fallback: if keys are somehow invalid, generate from scratch
85
124
  console.warn("fractional-indexing error, falling back:", e);
86
- const newKey = generateKeyBetween(null, null);
125
+ const newKey = generateKeyBetween(null, null, ORDER_KEY_DIGITS);
87
126
  updatedValues = setIn(updatedValues, orderProperty, newKey) as Record<string, unknown>;
88
127
  }
89
128
  }
@@ -6,6 +6,13 @@ import type { AdminCollection } from "@rebasepro/admin-types";
6
6
 
7
7
  const DEFAULT_PAGE_SIZE = 20;
8
8
 
9
+ /**
10
+ * How long a column waits for its subscription's first payload before reading
11
+ * once over HTTP instead. Long enough that a healthy socket always wins the
12
+ * race, short enough that a dead one does not hold a blank board.
13
+ */
14
+ const LIVE_FIRST_PAINT_TIMEOUT_MS = 2500;
15
+
9
16
  /**
10
17
  * Shallow equality for entity value records.
11
18
  * Handles primitives and reference equality for nested values.
@@ -392,46 +399,94 @@ values: { ...e.values,
392
399
  });
393
400
  };
394
401
 
402
+ const accessor = currentDataClient.collection(currentResolvedPath);
403
+
404
+ // Eagerly include relations to avoid N+1 fetches.
405
+ const includeParams = getRelationIncludeParams(currentCollection);
406
+
407
+ const fetchOnce = () => accessor.find({
408
+ where: whereFilter,
409
+ limit: itemCount,
410
+ orderBy: orderByParam,
411
+ include: includeParams
412
+ });
413
+
395
414
  const onError = (error: Error) => {
396
415
  // Skip error handling if we're cleaning up
397
416
  if (isCleaningUpRef.current) return;
398
417
 
399
418
  console.error(`Error loading column ${column}:`, error);
400
- setColumnData(prev => ({
401
- ...prev,
402
- [column]: {
403
- ...prev[column],
404
- entities: [],
405
- loading: false,
406
- hasMore: false,
407
- error
408
- }
409
- }));
410
- };
411
419
 
412
- // Set up listener or fetch
413
- const accessor = currentDataClient.collection(currentResolvedPath);
414
-
415
- // Eagerly include relations to avoid N+1 fetches.
416
- const includeParams = getRelationIncludeParams(currentCollection);
420
+ // A live subscription that fails is not the same thing as a column
421
+ // with nothing in it, but that is exactly what this used to render:
422
+ // entities cleared, no error surfaced, and a column reading "No
423
+ // items" above a header still counting eleven of them. Read once
424
+ // over HTTP instead — the board loses its live updates, not its
425
+ // contents — and only report the failure if that fails too.
426
+ fetchOnce()
427
+ .then(res => onUpdate(res.data as Entity<M>[]))
428
+ .catch(() => {
429
+ if (isCleaningUpRef.current) return;
430
+ setColumnData(prev => ({
431
+ ...prev,
432
+ [column]: {
433
+ ...prev[column],
434
+ entities: prev[column]?.entities ?? [],
435
+ loading: false,
436
+ hasMore: false,
437
+ error
438
+ }
439
+ }));
440
+ });
441
+ };
417
442
 
418
443
  if (accessor.listen) {
444
+ let liveDataReceived = false;
419
445
  const unsubscribe = accessor.listen({
420
446
  where: whereFilter,
421
447
  limit: itemCount,
422
448
  orderBy: orderByParam,
423
449
  include: includeParams
424
- }, res => onUpdate(res.data as Entity<M>[]), onError);
425
- unsubscribersRef.current[column] = unsubscribe;
450
+ }, res => {
451
+ liveDataReceived = true;
452
+ onUpdate(res.data as Entity<M>[]);
453
+ }, onError);
454
+
455
+ // A subscription that is never going to answer takes the client's
456
+ // full 30s watchdog to say so, and the board spins the whole time.
457
+ // If the socket has not delivered shortly after mount, read once
458
+ // over HTTP and paint that; the live data still wins whenever it
459
+ // arrives. Costs nothing when realtime is healthy.
460
+ const firstPaintFallback = setTimeout(() => {
461
+ if (liveDataReceived || isCleaningUpRef.current) return;
462
+ fetchOnce()
463
+ .then(res => {
464
+ if (liveDataReceived || isCleaningUpRef.current) return;
465
+ onUpdate(res.data as Entity<M>[]);
466
+ })
467
+ .catch(() => undefined);
468
+ }, LIVE_FIRST_PAINT_TIMEOUT_MS);
469
+
470
+ unsubscribersRef.current[column] = () => {
471
+ clearTimeout(firstPaintFallback);
472
+ unsubscribe?.();
473
+ };
426
474
  } else {
427
- accessor.find({
428
- where: whereFilter,
429
- limit: itemCount,
430
- orderBy: orderByParam,
431
- include: includeParams
432
- })
475
+ fetchOnce()
433
476
  .then(res => onUpdate(res.data as Entity<M>[]))
434
- .catch(onError);
477
+ .catch((error: Error) => {
478
+ if (isCleaningUpRef.current) return;
479
+ setColumnData(prev => ({
480
+ ...prev,
481
+ [column]: {
482
+ ...prev[column],
483
+ entities: prev[column]?.entities ?? [],
484
+ loading: false,
485
+ hasMore: false,
486
+ error
487
+ }
488
+ }));
489
+ });
435
490
  }
436
491
  }, []); // No dependencies - uses refs for all values
437
492
 
@@ -3,12 +3,12 @@ import type { Property, RelationProperty } from "@rebasepro/types";
3
3
  import type { Entity, EntityRelation } from "@rebasepro/types";
4
4
  import type { PropertyConfig, AdminCollection } from "@rebasepro/admin-types";
5
5
  import { getEntityImagePreviewPropertyKey } from "@rebasepro/app";
6
- import { getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
6
+ import { getLeadingRelationTitleKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
7
7
  import { getEntityFromCache } from "@rebasepro/app";
8
8
  import { getEntityPreviewKeys } from "../../util/previews";
9
9
  import { getValueInPath } from "@rebasepro/utils";
10
10
  import type { AuthController } from "@rebasepro/admin-types";
11
- import { ChipColorScheme, CHIP_COLORS } from "@rebasepro/ui";
11
+ import { ChipColorScheme, CHIP_COLORS, CHIP_SEED_KEYS } from "@rebasepro/ui";
12
12
 
13
13
  /** Whether an authored relation yields many rows. Derived from its kind. */
14
14
  function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
@@ -119,7 +119,15 @@ export function resolveCollectionSlotKeys(
119
119
  authController: AuthController,
120
120
  propertyConfigs: Record<string, PropertyConfig>
121
121
  ): CollectionSlotKeys {
122
- const titleKeyCandidates = getTitlePropertyCandidates(collection);
122
+ // One row per entity: when the collection leads with a relation and holds no
123
+ // name of its own, that relation *is* what the row is called. See
124
+ // `getLeadingRelationTitleKey` — the general ranking keeps relations last,
125
+ // which is right for a detail header but leaves a junction row titleless.
126
+ const rankedCandidates = getTitlePropertyCandidates(collection);
127
+ const leadingRelationKey = getLeadingRelationTitleKey(collection);
128
+ const titleKeyCandidates = leadingRelationKey
129
+ ? [leadingRelationKey, ...rankedCandidates.filter(key => key !== leadingRelationKey)]
130
+ : rankedCandidates;
123
131
  const titleKey = titleKeyCandidates[0];
124
132
  const imageKey = getEntityImagePreviewPropertyKey(collection);
125
133
 
@@ -188,7 +196,9 @@ export function resolveCollectionSlotKeys(
188
196
  if (!hasExplicitOrder) {
189
197
  for (const [key, prop] of Object.entries(collection.properties)) {
190
198
  const p = prop as Property;
191
- if (p.type === "relation") {
199
+ // The relation filling the title slot is already the row's headline;
200
+ // repeating it as a chip below says the same thing twice.
201
+ if (p.type === "relation" && key !== titleKey) {
192
202
  relationKeys.push(key);
193
203
  }
194
204
  }
@@ -417,8 +427,10 @@ id: relation.id });
417
427
 
418
428
  if (items.length > 0) {
419
429
  const relIndex = relationKeys.indexOf(relKey);
420
- const chipColors = Object.values(CHIP_COLORS);
421
- const colorScheme = chipColors[relIndex % chipColors.length];
430
+ // Walking the seed keys rather than `Object.values`: the table also
431
+ // holds a bare-hue alias per hue, so raw values would hand the first
432
+ // two relations the same scheme.
433
+ const colorScheme = CHIP_COLORS[CHIP_SEED_KEYS[relIndex % CHIP_SEED_KEYS.length]];
422
434
  relations.push({
423
435
  property: prop,
424
436
  propertyKey: relKey,
@@ -1,7 +1,7 @@
1
1
 
2
2
  import React from "react";
3
3
 
4
- import { Link, useNavigate } from "react-router-dom";
4
+ import { Link, useNavigate } from "react-router";
5
5
  import { RebaseLogo, LanguageToggle } from "@rebasepro/app";
6
6
  import { ErrorBoundary, iconSize } from "@rebasepro/ui";
7
7
  import {
@@ -6,7 +6,7 @@ import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
6
6
  import { useNavigationStateController } from "../hooks/navigation/contexts/NavigationStateContext";
7
7
 
8
8
 
9
- import { Link, useNavigate } from "react-router-dom";
9
+ import { Link, useNavigate } from "react-router";
10
10
  import { AnalyticsEvent } from "@rebasepro/admin-types";
11
11
  import {
12
12
  Avatar,