@rebasepro/admin 0.12.0 → 0.12.1-canary.g009ed95

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 (172) hide show
  1. package/README.md +1 -1
  2. package/dist/{CollectionEditorDialog-Dpc75wIZ.js → CollectionEditorDialog-B-iVE9y3.js} +75 -62
  3. package/dist/{CollectionEditorDialog-Dpc75wIZ.js.map → CollectionEditorDialog-B-iVE9y3.js.map} +1 -1
  4. package/dist/{PropertyEditView-DH3XZC2x.js → PropertyEditView-DoLEH_bQ.js} +36 -16
  5. package/dist/{PropertyEditView-DH3XZC2x.js.map → PropertyEditView-DoLEH_bQ.js.map} +1 -1
  6. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js → RouterCollectionsStudioView-C3669Ezm.js} +5 -5
  7. package/dist/{RouterCollectionsStudioView-BZLq4MGp.js.map → RouterCollectionsStudioView-C3669Ezm.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/CollectionViewBinding/SplitListCloseButton.d.ts +10 -0
  14. package/dist/components/CollectionViewBinding/SplitViewContext.d.ts +23 -0
  15. package/dist/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.d.ts +26 -0
  16. package/dist/components/DetailViewBinding.d.ts +5 -0
  17. package/dist/components/EditFormActions.d.ts +12 -1
  18. package/dist/components/EditViewBinding.d.ts +6 -1
  19. package/dist/components/EntityIdentityBar.d.ts +53 -0
  20. package/dist/components/EntityInspector.d.ts +29 -0
  21. package/dist/components/EntityPreviewNesting.d.ts +25 -0
  22. package/dist/components/EntityViewBinding.d.ts +9 -0
  23. package/dist/components/InlineEntityPreview.d.ts +47 -0
  24. package/dist/components/index.d.ts +2 -0
  25. package/dist/editor.js +18 -10
  26. package/dist/editor.js.map +1 -1
  27. package/dist/{export-BzIpUdSK.js → export-B8DIn-u8.js} +2 -2
  28. package/dist/{export-BzIpUdSK.js.map → export-B8DIn-u8.js.map} +1 -1
  29. package/dist/form/EntityForm.d.ts +1 -1
  30. package/dist/form/EntityFormActions.d.ts +11 -1
  31. package/dist/form/PropertyFieldBinding.d.ts +1 -1
  32. package/dist/form/components/FieldBlock.d.ts +30 -0
  33. package/dist/form/components/FormRail.d.ts +18 -0
  34. package/dist/form/components/FormSections.d.ts +16 -0
  35. package/dist/form/components/index.d.ts +3 -2
  36. package/dist/form/components/useRailVisible.d.ts +24 -0
  37. package/dist/form/field_bindings/BinaryFieldBinding.d.ts +1 -1
  38. package/dist/form/field_bindings/DateTimeFieldBinding.d.ts +1 -1
  39. package/dist/form/field_bindings/GeopointFieldBinding.d.ts +1 -1
  40. package/dist/form/field_bindings/MarkdownEditorFieldBinding.d.ts +1 -1
  41. package/dist/form/field_bindings/MultiSelectFieldBinding.d.ts +1 -1
  42. package/dist/form/field_bindings/ReadOnlyFieldBinding.d.ts +1 -1
  43. package/dist/form/field_bindings/SelectFieldBinding.d.ts +1 -1
  44. package/dist/form/field_bindings/StorageUploadFieldBinding.d.ts +1 -1
  45. package/dist/form/field_bindings/SwitchFieldBinding.d.ts +1 -1
  46. package/dist/form/field_bindings/TextFieldBinding.d.ts +1 -1
  47. package/dist/form/field_bindings/UserSelectFieldBinding.d.ts +1 -1
  48. package/dist/form/field_bindings/VectorFieldBinding.d.ts +1 -1
  49. package/dist/{history-g9688IaK.js → history-ClwDEtqN.js} +32 -31
  50. package/dist/history-ClwDEtqN.js.map +1 -0
  51. package/dist/hooks/navigation/utils.d.ts +16 -0
  52. package/dist/hooks/useBuildSidePanel.d.ts +14 -0
  53. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -0
  54. package/dist/hooks/useRecordActions.d.ts +17 -0
  55. package/dist/{import-BjUG-cIY.js → import-Cwzf4RW5.js} +2 -2
  56. package/dist/{import-BjUG-cIY.js.map → import-Cwzf4RW5.js.map} +1 -1
  57. package/dist/index.js +106 -35
  58. package/dist/index.js.map +1 -1
  59. package/dist/markdown-CklalUUk.js.map +1 -1
  60. package/dist/preview/components/InlineEntityListPreview.d.ts +15 -0
  61. package/dist/preview/index.d.ts +1 -0
  62. package/dist/preview/property_previews/ArrayOfReferencesPreview.d.ts +1 -1
  63. package/dist/preview/property_previews/ArrayOfRelationsPreview.d.ts +1 -1
  64. package/dist/types/components/EntityFormActionsProps.d.ts +0 -1
  65. package/dist/types/components/EntityFormProps.d.ts +0 -5
  66. package/dist/types/fields.d.ts +24 -0
  67. package/dist/{util-B5fJm1FA.js → util-DSh3HRzK.js} +1974 -1130
  68. package/dist/util-DSh3HRzK.js.map +1 -0
  69. package/package.json +32 -34
  70. package/src/collection_editor/ConfigControllerProvider.tsx +1 -1
  71. package/src/collection_editor/serializable_types.ts +4 -3
  72. package/src/collection_editor/serializable_utils.ts +51 -17
  73. package/src/collection_editor/ui/collection_editor/CollectionEditorDialog.tsx +65 -22
  74. package/src/collection_editor/ui/collection_editor/CollectionRLSTab.tsx +38 -30
  75. package/src/collection_editor/ui/collection_editor/CollectionRelationsTab.tsx +3 -1
  76. package/src/collection_editor/ui/collection_editor/RouterCollectionStudioView.tsx +1 -1
  77. package/src/collection_editor/ui/collection_editor/RouterCollectionsStudioView.tsx +1 -1
  78. package/src/collection_editor/ui/collection_editor/properties/RelationPropertyField.tsx +16 -0
  79. package/src/collection_editor/useLocalCollectionsConfigController.tsx +127 -15
  80. package/src/collection_editor/validateCollectionJson.ts +3 -3
  81. package/src/components/AdminModeSyncer.tsx +1 -1
  82. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +10 -2
  83. package/src/components/CollectionTableBinding/column_utils.tsx +26 -2
  84. package/src/components/CollectionTableBinding/table_bindings.tsx +2 -1
  85. package/src/components/CollectionViewBinding/CollectionBoardViewBinding.tsx +23 -16
  86. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +7 -1
  87. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +8 -2
  88. package/src/components/CollectionViewBinding/CollectionViewStartActions.tsx +22 -11
  89. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +33 -0
  90. package/src/components/CollectionViewBinding/SplitListView.tsx +18 -3
  91. package/src/components/CollectionViewBinding/SplitViewContext.tsx +34 -0
  92. package/src/components/CollectionViewBinding/hooks/useKanbanDragAndDrop.ts +48 -9
  93. package/src/components/CollectionViewBinding/useBoardDataController.tsx +80 -25
  94. package/src/components/CollectionViewBinding/usePreviewSlots.ts +18 -6
  95. package/src/components/DefaultAppBar.tsx +1 -1
  96. package/src/components/DefaultDrawer.tsx +1 -1
  97. package/src/components/DetailViewBinding.tsx +100 -143
  98. package/src/components/DrawerNavigationItem.tsx +1 -1
  99. package/src/components/EditFormActions.tsx +54 -129
  100. package/src/components/EditViewBinding.tsx +182 -93
  101. package/src/components/EntityIdentityBar.tsx +288 -0
  102. package/src/components/EntityInspector.tsx +132 -0
  103. package/src/components/EntityPreviewBinding.tsx +6 -2
  104. package/src/components/EntityPreviewNesting.tsx +50 -0
  105. package/src/components/EntityViewBinding.tsx +35 -32
  106. package/src/components/HomePage/FavouritesView.tsx +1 -1
  107. package/src/components/HomePage/NavigationCardBinding.tsx +1 -1
  108. package/src/components/HomePage/SmallNavigationCard.tsx +1 -1
  109. package/src/components/InlineEntityPreview.tsx +149 -0
  110. package/src/components/RebaseLayout.tsx +1 -1
  111. package/src/components/RebaseNavigation.tsx +45 -5
  112. package/src/components/RebaseRouteDefs.tsx +1 -1
  113. package/src/components/SelectableTable/SelectableTable.tsx +2 -2
  114. package/src/components/SidePanelBinding.tsx +19 -13
  115. package/src/components/UserSelector.tsx +12 -6
  116. package/src/components/common/default_entity_actions.tsx +4 -3
  117. package/src/components/history/EntityHistoryEntry.tsx +28 -7
  118. package/src/components/history/EntityHistoryView.tsx +20 -20
  119. package/src/components/history/LastEditedByIndicator.tsx +4 -3
  120. package/src/components/index.ts +2 -0
  121. package/src/data_import/utils/get_import_inference_type.ts +6 -0
  122. package/src/form/EntityForm.tsx +267 -179
  123. package/src/form/EntityFormActions.tsx +50 -99
  124. package/src/form/EntityFormBinding.tsx +4 -2
  125. package/src/form/PropertyFieldBinding.tsx +4 -0
  126. package/src/form/components/FieldBlock.tsx +119 -0
  127. package/src/form/components/FormRail.tsx +125 -0
  128. package/src/form/components/FormSections.tsx +118 -0
  129. package/src/form/components/index.tsx +3 -2
  130. package/src/form/components/useRailVisible.tsx +46 -0
  131. package/src/form/field_bindings/BinaryFieldBinding.tsx +3 -2
  132. package/src/form/field_bindings/DateTimeFieldBinding.tsx +3 -2
  133. package/src/form/field_bindings/GeopointFieldBinding.tsx +3 -2
  134. package/src/form/field_bindings/MapFieldBinding.tsx +1 -2
  135. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +3 -2
  136. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +2 -1
  137. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +2 -1
  138. package/src/form/field_bindings/ReferenceFieldBinding.tsx +2 -1
  139. package/src/form/field_bindings/RelationFieldBinding.tsx +6 -4
  140. package/src/form/field_bindings/RepeatFieldBinding.tsx +4 -1
  141. package/src/form/field_bindings/SelectFieldBinding.tsx +3 -2
  142. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +31 -8
  143. package/src/form/field_bindings/SwitchFieldBinding.tsx +7 -3
  144. package/src/form/field_bindings/TextFieldBinding.tsx +18 -16
  145. package/src/form/field_bindings/UserSelectFieldBinding.tsx +3 -2
  146. package/src/form/field_bindings/VectorFieldBinding.tsx +2 -1
  147. package/src/hooks/navigation/useBuildUrlController.tsx +1 -1
  148. package/src/hooks/navigation/useTopLevelNavigation.ts +18 -7
  149. package/src/hooks/navigation/utils.ts +20 -0
  150. package/src/hooks/useBuildSideDialogsController.tsx +2 -2
  151. package/src/hooks/useBuildSidePanel.tsx +22 -61
  152. package/src/hooks/useEntityDisplayTitle.tsx +60 -0
  153. package/src/hooks/useHistory.ts +6 -5
  154. package/src/hooks/useRecordActions.tsx +69 -0
  155. package/src/hooks/useResolvedUsers.ts +10 -5
  156. package/src/preview/PropertyPreview.tsx +48 -22
  157. package/src/preview/components/InlineEntityListPreview.tsx +42 -0
  158. package/src/preview/components/ReferencePreview.tsx +34 -14
  159. package/src/preview/components/RelationPreview.tsx +34 -14
  160. package/src/preview/index.ts +1 -0
  161. package/src/preview/property_previews/ArrayOfReferencesPreview.tsx +24 -1
  162. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +25 -1
  163. package/src/routes/RebaseRoute.tsx +2 -2
  164. package/src/types/components/EntityFormActionsProps.tsx +0 -1
  165. package/src/types/components/EntityFormProps.tsx +0 -6
  166. package/src/types/fields.tsx +27 -0
  167. package/dist/form/components/FormEntry.d.ts +0 -6
  168. package/dist/form/components/FormLayout.d.ts +0 -5
  169. package/dist/history-g9688IaK.js.map +0 -1
  170. package/dist/util-B5fJm1FA.js.map +0 -1
  171. package/src/form/components/FormEntry.tsx +0 -22
  172. package/src/form/components/FormLayout.tsx +0 -16
@@ -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,
@@ -8,6 +8,9 @@ import { PluginProviderStack, resolveComponentRef, useComponentOverride, Collect
8
8
 
9
9
  import { CollectionViewBinding } from "./CollectionViewBinding/CollectionViewBinding";
10
10
  import { EntityViewBinding } from "./EntityViewBinding";
11
+ import { EntityIdentityBar } from "./EntityIdentityBar";
12
+ import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
13
+ import { EntityInspector, InspectorTab } from "./EntityInspector";
11
14
  import { CircularProgressCenter, iconSize } from "@rebasepro/ui";
12
15
  import {
13
16
  Alert,
@@ -41,13 +44,11 @@ import {
41
44
  } from "@rebasepro/app";
42
45
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
43
46
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
44
- import { useNavigate } from "react-router-dom";
47
+ import { useNavigate } from "react-router";
45
48
  import { getValueInPath } from "@rebasepro/utils";
46
49
  import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
47
50
  import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
48
- import { JsonPreviewBinding } from "../components/JsonPreviewBinding";
49
51
 
50
- const EntityHistoryView = lazy(() => import("../components/history").then(m => ({ default: m.EntityHistoryView })));
51
52
 
52
53
  import { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../util/view_constants";
53
54
 
@@ -76,6 +77,11 @@ export interface DetailViewBindingProps<M extends Record<string, unknown> = Reco
76
77
  onEditClick?: () => void;
77
78
  layout?: "side_panel" | "full_screen" | "split" | "dialog";
78
79
  barActions?: (params: BarActionsParams) => React.ReactNode;
80
+ /**
81
+ * Controls the owning panel puts at the identity bar's leading edge, ahead
82
+ * of the breadcrumb — the side panel's close button.
83
+ */
84
+ barActionsStart?: React.ReactNode;
79
85
  }
80
86
 
81
87
  export function DetailViewBinding<M extends Record<string, unknown>>({
@@ -134,7 +140,8 @@ function DetailViewBindingInner<M extends Record<string, unknown>>({
134
140
  entity,
135
141
  dataLoading,
136
142
  layout = "full_screen",
137
- barActions
143
+ barActions,
144
+ barActionsStart
138
145
  }: DetailViewBindingProps<M> & {
139
146
  entity?: Entity<M>,
140
147
  dataLoading: boolean,
@@ -189,7 +196,18 @@ entityId }
189
196
  const customViewsCount = customViews?.length ?? 0;
190
197
  const includeJsonView = collection.includeJsonView === undefined ? true : collection.includeJsonView;
191
198
  const includeHistoryView = Boolean(collection.history);
192
- const hasAdditionalViews = customViewsCount > 0 || subcollectionsCount > 0 || includeJsonView || includeHistoryView;
199
+ // Same split as the edit view: the strip is for destinations, the inspector
200
+ // for the developer tools that used to sit in front of them.
201
+ const hasAdditionalViews = customViewsCount > 0 || subcollectionsCount > 0;
202
+
203
+ const [inspectorTab, setInspectorTab] = useState<InspectorTab | null>(null);
204
+
205
+ const legacyInspectorTab: InspectorTab | undefined = selectedTab === JSON_TAB_VALUE
206
+ ? "json"
207
+ : selectedTab === HISTORY_TAB_VALUE ? "history" : undefined;
208
+ useEffect(() => {
209
+ if (legacyInspectorTab) setInspectorTab(legacyInspectorTab);
210
+ }, [legacyInspectorTab]);
193
211
 
194
212
  const {
195
213
  resolvedEntityViews
@@ -198,13 +216,11 @@ entityId }
198
216
  const validTabValues = useMemo(() => {
199
217
  const set = new Set<string>([
200
218
  MAIN_TAB_VALUE,
201
- ...(includeJsonView ? [JSON_TAB_VALUE] : []),
202
- ...(includeHistoryView ? [HISTORY_TAB_VALUE] : []),
203
219
  ...resolvedEntityViews.map(v => v.key),
204
220
  ...subcollections.map(s => s.slug)
205
221
  ]);
206
222
  return set;
207
- }, [includeJsonView, includeHistoryView, resolvedEntityViews, subcollections]);
223
+ }, [resolvedEntityViews, subcollections]);
208
224
 
209
225
  const activeTab = validTabValues.has(selectedTab) ? selectedTab : MAIN_TAB_VALUE;
210
226
 
@@ -321,35 +337,6 @@ entityId }
321
337
 
322
338
  const globalLoading = dataLoading && !usedEntity;
323
339
 
324
- // JSON view
325
- const jsonTabMounted = mountedTabsRef.current.has(JSON_TAB_VALUE);
326
- const jsonView = (activeTab === JSON_TAB_VALUE || jsonTabMounted) ? <div
327
- className={cls("relative flex-1 h-full overflow-auto w-full",
328
- { "hidden": activeTab !== JSON_TAB_VALUE })}
329
- key={"json_view"}
330
- role="tabpanel">
331
- <ErrorBoundary>
332
- <JsonPreviewBinding values={usedEntity?.values ?? {}} />
333
- </ErrorBoundary>
334
- </div> : null;
335
-
336
- // History view
337
- const historyView = includeHistoryView && activeTab === HISTORY_TAB_VALUE ? <div
338
- className={"relative flex-1 h-full overflow-auto w-full"}
339
- key={"history_view"}
340
- role="tabpanel">
341
- <ErrorBoundary>
342
- <Suspense fallback={<CircularProgressCenter />}>
343
- <EntityHistoryView
344
- collection={collection}
345
- entity={usedEntity}
346
- formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
347
- modifiedValues={usedEntity?.values}
348
- />
349
- </Suspense>
350
- </ErrorBoundary>
351
- </div> : null;
352
-
353
340
  // Subcollection views
354
341
  const subCollectionsViews = childViews && childViews.map(({ collection: subcollection }) => {
355
342
  const subcollectionId = subcollection.slug;
@@ -490,7 +477,9 @@ entityId }
490
477
 
491
478
  const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews || layout === "side_panel" || layout === "dialog" || Boolean(backToCollectionButton);
492
479
 
493
- const fullScreenButton = !barActions && (layout === "side_panel" || layout === "split" || layout === "dialog") && entityId ? (
480
+ const fullScreenButton = !barActions && // Not in split: the list panel beside it carries the close control,
481
+ // and an expand button there competes with it.
482
+ (layout === "side_panel" || layout === "dialog") && entityId ? (
494
483
  <Tooltip title={"Open full screen"}>
495
484
  <IconButton
496
485
  size="small"
@@ -516,131 +505,99 @@ entityId }
516
505
  </Button>
517
506
  ) : null;
518
507
 
519
- // Main content view with title and properties
508
+ // Main content view. The title, the `path/id` chip and the empty band under
509
+ // them have moved into the identity bar, and the `w-80 2xl:w-96` rail that
510
+ // held a single Edit button is gone — the bar holds it now.
520
511
  const mainView = <div
521
512
  className={cls(
522
513
  "flex-1 flex flex-row w-full overflow-y-auto justify-center",
523
514
  !mainViewVisible ? "hidden" : ""
524
515
  )}>
525
- <div
526
- className={cls("relative flex flex-row max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-6xl w-full h-fit")}>
527
- <div className={cls(
528
- "flex flex-col w-full",
529
- layout === "dialog"
530
- ? "pt-4 pb-12 px-6 sm:px-8"
531
- : "pt-12 pb-16 px-4 sm:px-8 md:px-10"
532
- )}>
533
- {/* Title and entity path */}
534
- <div className={"w-full flex flex-col items-start my-4 lg:my-6"}>
535
- <div className="flex items-center justify-between w-full">
536
- <Typography
537
- className={cls("my-4 grow line-clamp-1", collection.hideIdFromForm ? "mb-6" : "")}
538
- variant={"h4"}>
539
- {title}
540
- </Typography>
541
- {editButton}
542
- </div>
543
-
544
- <Alert color={"base"} outerClassName={"w-full"} size={"small"}>
545
- <code
546
- className={"text-xs select-all text-text-secondary dark:text-text-secondary-dark"}>
547
- {usedEntity?.path ?? path}/{entityId}
548
- </code>
549
- </Alert>
550
- </div>
551
-
552
- {/* Property detail display */}
553
- <div className="mt-12 flex flex-col gap-8">
554
- {propertyDetailView()}
555
- </div>
556
-
557
- <div className="h-16" />
558
- </div>
559
-
560
- {/* Side action bar for large screens */}
561
- {canEdit && onEditClick && layout === "full_screen" && <div
562
- className={cls(
563
- "overflow-auto h-full hidden @6xl:flex flex-col gap-2 w-80 2xl:w-96 px-4 py-16 sticky top-0 border-l",
564
- defaultBorderMixin
565
- )}>
566
- <Button
567
- fullWidth={true}
568
- variant="filled"
569
- color="primary"
570
- startIcon={<PencilIcon size={iconSize.small} />}
571
- onClick={onEditClick}>
572
- {t("edit_entity")}
573
- </Button>
574
- </div>}
516
+ <div className={cls(
517
+ "w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
518
+ layout === "dialog"
519
+ ? "pt-5 pb-12 px-6 sm:px-8"
520
+ : "pt-6 pb-16 px-5 sm:px-8"
521
+ )}>
522
+ {propertyDetailView()}
575
523
  </div>
576
524
  </div>;
577
525
 
578
526
  let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-800">
579
527
 
580
- {shouldShowTopBar && <div
581
- className={cls("h-[52px] items-center flex overflow-hidden w-full border-b pl-2 pr-2 flex bg-surface-50 dark:bg-surface-900", defaultBorderMixin)}>
582
-
583
- {backToCollectionButton}
584
-
585
- {fullScreenButton}
586
-
587
- {barActions?.({
588
- path,
589
- entityId,
590
- values: usedEntity?.values ?? {},
591
- status: "existing"
592
- })}
593
-
594
- {pluginActionsTop}
595
-
596
- {hasAdditionalViews && <div className={"flex-1 flex justify-end min-w-0 shrink-0"}>
597
- <Tabs
598
- className={"!w-fit max-w-full"}
599
- value={activeTab}
600
- onValueChange={(value) => {
601
- onSideTabClick(value);
602
- }}>
603
-
604
- {includeJsonView && <Tab
605
- disabled={!hasAdditionalViews}
606
- value={JSON_TAB_VALUE}
607
- className={"text-sm"}>
608
- <CodeIcon size={iconSize.smallest} />
609
- </Tab>}
610
-
611
- {includeHistoryView && <Tab
612
- disabled={!hasAdditionalViews}
613
- value={HISTORY_TAB_VALUE}
614
- className={"text-sm"}>
615
- <HistoryIcon size={iconSize.smallest} />
616
- </Tab>}
617
-
618
- <Tab
619
- disabled={!hasAdditionalViews}
620
- value={MAIN_TAB_VALUE}
621
- className={"text-sm min-w-[90px]"}>
622
- <span className="flex items-center gap-1.5">
623
- {getIcon(collection.icon, undefined, undefined, "smallest")}
624
- {collection.singularName ?? collection.name}
625
- </span>
626
- </Tab>
528
+ <EntityIdentityBar
529
+ collection={collection as AdminCollection}
530
+ title={title}
531
+ entityId={entityId}
532
+ status={"existing"}
533
+ dirty={false}
534
+ saving={false}
535
+ onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
536
+ onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
537
+ leading={<>
538
+ {barActionsStart}
539
+ {/* Split view: closing the list is opening this record
540
+ full screen, and the control for it belongs at this
541
+ bar's leading edge. */}
542
+ {layout === "split" && <SplitListCloseButton/>}
543
+ </>}
544
+ trailing={<>
545
+ {editButton}
546
+ {pluginActionsTop}
547
+ {backToCollectionButton}
548
+ {fullScreenButton}
549
+ {barActions?.({
550
+ path,
551
+ entityId,
552
+ values: usedEntity?.values ?? {},
553
+ status: "existing"
554
+ })}
555
+ </>}
556
+ />
627
557
 
628
- {customViewTabsStart}
629
- {customViewTabsEnd}
630
- {subcollectionTabs}
631
- </Tabs>
632
- </div>}
558
+ {hasAdditionalViews && <div className={cls(
559
+ "h-10 shrink-0 flex items-stretch border-b px-2 min-w-0",
560
+ defaultBorderMixin
561
+ )}>
562
+ <Tabs
563
+ className={"!w-full"}
564
+ innerClassName={"h-full"}
565
+ variant={"boxy"}
566
+ value={activeTab}
567
+ onValueChange={(value) => {
568
+ onSideTabClick(value);
569
+ }}>
570
+
571
+ <Tab value={MAIN_TAB_VALUE}>
572
+ <span className="flex items-center gap-1.5">
573
+ {getIcon(collection.icon, undefined, undefined, "smallest")}
574
+ {collection.singularName ?? collection.name}
575
+ </span>
576
+ </Tab>
577
+
578
+ {customViewTabsStart}
579
+ {customViewTabsEnd}
580
+ {subcollectionTabs}
581
+ </Tabs>
633
582
  </div>}
634
583
 
635
584
  {globalLoading
636
585
  ? <DetailViewSkeleton collection={collection} />
637
586
  : mainView}
638
587
 
639
- {jsonView}
640
- {historyView}
641
588
  {customViewsView}
642
589
  {subCollectionsViews}
643
590
 
591
+ <EntityInspector
592
+ tab={inspectorTab}
593
+ onTabChange={setInspectorTab}
594
+ onClose={() => setInspectorTab(null)}
595
+ collection={collection as AdminCollection}
596
+ entity={usedEntity as Entity<Record<string, unknown>> | undefined}
597
+ formContext={readOnlyFormContext as FormContext<Record<string, unknown>> | undefined}
598
+ values={usedEntity?.values}
599
+ includeHistory={includeHistoryView}/>
600
+
644
601
  </div>;
645
602
 
646
603
  if (plugins && plugins.length > 0) {
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
 
3
- import { NavLink } from "react-router-dom";
3
+ import { NavLink } from "react-router";
4
4
  import { cls, Tooltip } from "@rebasepro/ui";
5
5
 
6
6
  export function DrawerNavigationItem({