@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
@@ -4,11 +4,11 @@ import type { CustomizationController, SidePanelBindingProps, SideDialogPanelPro
4
4
  import { useCallback, useEffect, useRef, useMemo } from "react";
5
5
  import { CollectionRegistryController } from "@rebasepro/types";
6
6
  import { AuthController } from "@rebasepro/admin-types";
7
- import { useLocation } from "react-router-dom";
7
+ import { useLocation } from "react-router";
8
8
  import { NavigationViewInternal } from "@rebasepro/app";
9
9
  import { getNavigationEntriesFromPath, removeInitialAndTrailingSlashes, removeTrailingSlash, resolveDefaultSelectedView } from "@rebasepro/app";
10
10
  import { resolvedSelectedEntityView } from "../util/resolutions";
11
- import { ADDITIONAL_TAB_WIDTH, CONTAINER_FULL_WIDTH, FORM_CONTAINER_WIDTH } from "@rebasepro/app";
11
+ import { CONTAINER_FULL_WIDTH, SIDE_PANEL_DEFAULT_WIDTH } from "@rebasepro/app";
12
12
  import { useCustomizationController, useLargeLayout, useComponentOverride, CollectionScopeProvider } from "@rebasepro/app";
13
13
  import { JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../components/EditViewBinding";
14
14
  import React from "react";
@@ -39,71 +39,32 @@ function ResolvedSidePanelBinding(props: SidePanelBindingProps) {
39
39
  const NEW_URL_HASH = "new_side";
40
40
  const SIDE_URL_HASH = "side";
41
41
 
42
+ /**
43
+ * How wide the side panel opens.
44
+ *
45
+ * One width for the whole panel, whatever tab is showing. It used to depend on
46
+ * the selected tab — the form got one width and a subcollection or custom view
47
+ * got `calc(55vw + 768px)` — so the panel physically resized under the cursor
48
+ * every time you switched tabs. And the "form" width was derived from how
49
+ * deeply nested the collection's *properties* were, which is not a thing anyone
50
+ * can predict from looking at the panel.
51
+ *
52
+ * Now: `width` prop, then the collection's `sideDialogWidth`, then a default
53
+ * sized for the content the panel actually has to hold — the form column plus
54
+ * its metadata rail — capped so it never eats the whole window.
55
+ */
42
56
  export function getEntityViewWidth(props: SidePanelBindingProps<any>, small: boolean, customizationController: CustomizationController): string {
43
57
  if (small) return CONTAINER_FULL_WIDTH;
44
58
 
45
- const {
46
- selectedSecondaryForm
47
- } = resolvedSelectedEntityView(props.collection?.entityViews, customizationController, props.selectedTab);
48
-
49
- const shouldUseSmallLayout = !props.selectedTab || props.selectedTab === "edit" || props.selectedTab === JSON_TAB_VALUE || props.selectedTab === HISTORY_TAB_VALUE || Boolean(selectedSecondaryForm);
50
-
51
- let resolvedWidth: string | undefined;
52
59
  if (props.width) {
53
- resolvedWidth = typeof props.width === "number" ? `${props.width}px` : props.width;
54
- } else if (props.collection?.sideDialogWidth) {
55
- resolvedWidth = typeof props.collection.sideDialogWidth === "number" ? `${props.collection.sideDialogWidth}px` : props.collection.sideDialogWidth;
60
+ return typeof props.width === "number" ? `${props.width}px` : props.width;
56
61
  }
57
-
58
- if (!shouldUseSmallLayout) {
59
- return `calc(${ADDITIONAL_TAB_WIDTH} + ${resolvedWidth ?? FORM_CONTAINER_WIDTH})`
60
- } else {
61
- if (resolvedWidth) {
62
- return resolvedWidth
63
- } else if (!props.collection) {
64
- return FORM_CONTAINER_WIDTH;
65
- } else {
66
- return calculateCollectionDesiredWidth(props.collection);
67
- }
68
- }
69
- }
70
-
71
- const collectionViewWidthCache: { [key: string]: string } = {};
72
-
73
- function calculateCollectionDesiredWidth(collection: AdminCollection<any>): string {
74
- if (collectionViewWidthCache[collection.slug]) {
75
- return collectionViewWidthCache[collection.slug];
76
- }
77
-
78
- let result = FORM_CONTAINER_WIDTH
79
- if (collection?.properties) {
80
- const values = Object.values(collection.properties).map((p: Property) => getNestedPropertiesDepth(p));
81
- const maxDepth = Math.max(...values);
82
- if (maxDepth < 3) {
83
- result = FORM_CONTAINER_WIDTH;
84
- } else {
85
- result = 768 + 32 * (maxDepth - 2) + "px";
86
- }
87
- }
88
- collectionViewWidthCache[collection.slug] = result;
89
- return result;
90
- }
91
-
92
- function getNestedPropertiesDepth(property: Property, accumulator = 0): number {
93
- if (property.type === "map" && property.properties) {
94
- const values = Object.values(property.properties).flatMap((childProperty) => getNestedPropertiesDepth(childProperty as Readonly<Property>, accumulator + 1));
95
- return Math.max(...values);
96
- } else if (property.type === "array" && property.oneOf) {
97
- return accumulator + 3;
98
- } else if (property.type === "array" && property.of) {
99
- if (Array.isArray(property.of)) {
100
- return Math.max(...property.of.map((p) => getNestedPropertiesDepth(p, accumulator + 1)));
101
- } else {
102
- return getNestedPropertiesDepth(property.of, accumulator + 1);
103
- }
104
- } else {
105
- return accumulator + 1;
62
+ if (props.collection?.sideDialogWidth) {
63
+ return typeof props.collection.sideDialogWidth === "number"
64
+ ? `${props.collection.sideDialogWidth}px`
65
+ : props.collection.sideDialogWidth;
106
66
  }
67
+ return SIDE_PANEL_DEFAULT_WIDTH;
107
68
  }
108
69
 
109
70
  export const useBuildSidePanel = (collectionRegistryController: CollectionRegistryController,
@@ -0,0 +1,60 @@
1
+ import type { AdminCollection } from "@rebasepro/admin-types";
2
+ import type { EntityStatus } from "@rebasepro/types";
3
+ import { getValueInPath } from "@rebasepro/utils";
4
+ import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
5
+ import { getUserLabel, useResolvedUser } from "./useResolvedUsers";
6
+
7
+ export interface UseEntityDisplayTitleParams<M extends Record<string, unknown>> {
8
+ collection: AdminCollection<M>;
9
+ values?: Record<string, unknown>;
10
+ entityId?: string | number;
11
+ status: EntityStatus;
12
+ }
13
+
14
+ /**
15
+ * The human name for a record, for the identity bar and the breadcrumb.
16
+ *
17
+ * The guard on `status` is the point. The title resolver walks candidate
18
+ * properties and returns the first non-empty one, and on a brand new entity the
19
+ * only populated values are the property defaults — so creating a blog post
20
+ * opened a page headed **draft**, the default of its `status` enum. A record
21
+ * that does not exist yet has no name; it has an intent.
22
+ */
23
+ export function useEntityDisplayTitle<M extends Record<string, unknown>>({
24
+ collection,
25
+ values,
26
+ entityId,
27
+ status
28
+ }: UseEntityDisplayTitleParams<M>): string {
29
+
30
+ const isNew = status === "new";
31
+
32
+ const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, values, entityId);
33
+ const rawTitle = !isNew && values && titlePropertyKey
34
+ ? getValueInPath(values, titlePropertyKey)
35
+ : undefined;
36
+
37
+ // A user picker stores an id: resolve it to the person, like a relation.
38
+ const titleUser = useResolvedUser(
39
+ isUserSelectProperty(collection, titlePropertyKey) && typeof rawTitle === "string"
40
+ ? rawTitle
41
+ : undefined
42
+ );
43
+
44
+ const singular = collection.singularName ?? collection.name;
45
+
46
+ if (isNew) {
47
+ return `New ${singular.toLowerCase()}`;
48
+ }
49
+
50
+ if (titleUser) {
51
+ return getUserLabel(titleUser);
52
+ }
53
+
54
+ if (rawTitle !== undefined && rawTitle !== null) {
55
+ const resolved = resolveTitleToString(rawTitle);
56
+ if (resolved) return resolved;
57
+ }
58
+
59
+ return singular;
60
+ }
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState, useMemo } from "react";
2
- import { useApiConfig } from "@rebasepro/app";
2
+ import { useApiBase, useApiConfig } from "@rebasepro/app";
3
3
  import type { EntityHistoryEntry } from "@rebasepro/types";
4
4
 
5
5
  /**
@@ -28,9 +28,17 @@ export function useHistory(params: {
28
28
  entityId: string | number | undefined;
29
29
  enabled?: boolean;
30
30
  pageSize?: number;
31
+ /**
32
+ * Bumped by the caller when the record is saved. A save is the one event
33
+ * that adds a revision, and nothing else here would notice it: without
34
+ * this the open panel keeps showing the list it fetched when it opened,
35
+ * so the revision you just created never appears.
36
+ */
37
+ refreshToken?: number;
31
38
  }): UseEntityHistoryResult {
32
- const { slug, entityId, enabled = true, pageSize = 10 } = params;
39
+ const { slug, entityId, enabled = true, pageSize = 10, refreshToken } = params;
33
40
  const apiConfig = useApiConfig();
41
+ const apiBase = useApiBase();
34
42
 
35
43
  const [entries, setEntries] = useState<HistoryEntryData[]>([]);
36
44
  const [total, setTotal] = useState(0);
@@ -40,7 +48,11 @@ export function useHistory(params: {
40
48
  const [offset, setOffset] = useState(0);
41
49
  const [refreshTrigger, setRefreshTrigger] = useState(0);
42
50
 
43
- // Track the current entity identity so we can detect stale responses
51
+ // Track the current entity identity so we can detect stale responses.
52
+ // `refreshToken` is part of the identity on purpose: a save makes the list
53
+ // we are holding a stale page of a longer list, which is the same problem
54
+ // as having moved to a different record.
55
+ const entityKey = `${slug}/${entityId}/${refreshToken ?? 0}`;
44
56
  const currentEntityRef = useRef<string | undefined>(undefined);
45
57
 
46
58
  const fetchEntries = useCallback(async (
@@ -59,13 +71,12 @@ export function useHistory(params: {
59
71
  };
60
72
  if (token) headers["Authorization"] = `Bearer ${token}`;
61
73
 
62
- const url = `${apiConfig.apiUrl}/api/data/${slug}/${entityId}/history?limit=${pageSize}&offset=${currentOffset}`;
74
+ const url = `${apiBase}/data/${slug}/${entityId}/history?limit=${pageSize}&offset=${currentOffset}`;
63
75
  const response = await fetch(url, { headers,
64
76
  signal });
65
77
 
66
78
  // Check if the request was aborted or the entity changed while in-flight
67
79
  if (signal?.aborted) return;
68
- const entityKey = `${slug}/${entityId}`;
69
80
  if (currentEntityRef.current !== entityKey) return;
70
81
 
71
82
  if (!response.ok) {
@@ -90,12 +101,11 @@ signal });
90
101
  } finally {
91
102
  setIsLoading(false);
92
103
  }
93
- }, [apiConfig, slug, entityId, enabled, pageSize]);
104
+ }, [apiConfig, apiBase, slug, entityId, entityKey, enabled, pageSize]);
94
105
 
95
106
  // Single unified effect: reset state when entity changes, fetch when offset changes.
96
107
  // Uses AbortController to cancel stale requests on entity change or unmount.
97
108
  useEffect(() => {
98
- const entityKey = `${slug}/${entityId}`;
99
109
  const isNewEntity = currentEntityRef.current !== entityKey;
100
110
 
101
111
  if (isNewEntity) {
@@ -117,7 +127,7 @@ signal });
117
127
  return () => {
118
128
  abortController.abort();
119
129
  };
120
- }, [fetchEntries, offset, enabled, entityId, slug, refreshTrigger]);
130
+ }, [fetchEntries, offset, enabled, entityId, entityKey, refreshTrigger]);
121
131
 
122
132
  const loadMore = useCallback(() => {
123
133
  if (!isLoading && hasMore && offset + entries.length < total) {
@@ -136,7 +146,7 @@ signal });
136
146
  };
137
147
  if (token) headers["Authorization"] = `Bearer ${token}`;
138
148
 
139
- const url = `${apiConfig.apiUrl}/api/data/${slug}/${entityId}/history/${historyId}/revert`;
149
+ const url = `${apiBase}/data/${slug}/${entityId}/history/${historyId}/revert`;
140
150
  const response = await fetch(url, { method: "POST",
141
151
  headers });
142
152
 
@@ -154,7 +164,7 @@ headers });
154
164
 
155
165
  // Return the reverted entity data so callers can update the form
156
166
  return result.data as Record<string, unknown>;
157
- }, [apiConfig, slug, entityId]);
167
+ }, [apiConfig, apiBase, slug, entityId]);
158
168
 
159
169
  return useMemo(() => ({
160
170
  entries,
@@ -0,0 +1,69 @@
1
+ import type { EntityAction, AdminCollection } from "@rebasepro/admin-types";
2
+ import type { Entity } from "@rebasepro/types";
3
+ import { useMemo } from "react";
4
+ import { useCustomizationController, usePermissions } from "@rebasepro/app";
5
+ import { copyEntityAction, deleteEntityAction, unlinkEntityAction } from "../components/common/default_entity_actions";
6
+ import { mergeEntityActions } from "../util/entity_actions";
7
+ import { resolveEntityAction } from "../util/resolutions";
8
+ import { useChildViewSource } from "./useChildViewSource";
9
+
10
+ export interface UseRecordActionsParams<M extends Record<string, unknown>> {
11
+ collection: AdminCollection<M>;
12
+ path: string;
13
+ entity?: Entity<M>;
14
+ }
15
+
16
+ /**
17
+ * The actions you can perform *on* a record — copy, delete, and whatever the
18
+ * collection adds — resolved against the current user's permissions.
19
+ *
20
+ * Extracted from the form's footer so the identity bar can own them. They are
21
+ * not ways to leave the form, so they never belonged next to Save; putting them
22
+ * in the bar's overflow menu is what let the footer disappear instead of
23
+ * becoming a full-width strip holding two icon buttons.
24
+ */
25
+ export function useRecordActions<M extends Record<string, unknown>>({
26
+ collection,
27
+ path,
28
+ entity
29
+ }: UseRecordActionsParams<M>): EntityAction[] {
30
+
31
+ const { canCreate, canDelete } = usePermissions();
32
+ const customizationController = useCustomizationController();
33
+
34
+ // Rows shared through a junction: the delete action here removes the link,
35
+ // because that is what the server does for this path.
36
+ const childViewSource = useChildViewSource(path);
37
+ const isLinkedChildView = childViewSource?.kind === "relation" && childViewSource.mode === "linked";
38
+
39
+ return useMemo((): EntityAction[] => {
40
+ const customEntityActions = (collection.entityActions ?? [])
41
+ .map(action => resolveEntityAction(action, customizationController.entityActions))
42
+ .filter(Boolean) as EntityAction[];
43
+
44
+ const createEnabled = canCreate(collection, path);
45
+ const deleteEnabled = entity ? canDelete(collection, path, entity) : false;
46
+ const disableActions = collection.disableDefaultActions ?? [];
47
+
48
+ const actions: EntityAction[] = [];
49
+ if (createEnabled && !disableActions.includes("copy"))
50
+ actions.push(copyEntityAction);
51
+ if (deleteEnabled && !disableActions.includes("delete"))
52
+ actions.push(isLinkedChildView ? unlinkEntityAction : deleteEntityAction);
53
+
54
+ const merged = customEntityActions.length
55
+ ? mergeEntityActions(actions, customEntityActions)
56
+ : actions;
57
+
58
+ return merged.filter(a => a.includeInForm === undefined || a.includeInForm);
59
+ }, [
60
+ canCreate,
61
+ canDelete,
62
+ collection,
63
+ path,
64
+ customizationController.entityActions?.length,
65
+ entity,
66
+ collection.disableDefaultActions,
67
+ isLinkedChildView
68
+ ]);
69
+ }
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { User } from "@rebasepro/types";
3
- import { useAuthController, useRebaseClient } from "@rebasepro/app";
3
+ import { apiBaseOf, useAuthController, useRebaseClient } from "@rebasepro/app";
4
4
 
5
5
  /**
6
6
  * Resolves the auth users behind `userSelect` values.
@@ -31,12 +31,14 @@ queue: new Set() };
31
31
  return cache;
32
32
  }
33
33
 
34
- async function fetchUsers(baseUrl: string, ids: string[], token: string | undefined): Promise<Map<string, User | null>> {
34
+ async function fetchUsers(apiBase: string, ids: string[], token: string | undefined): Promise<Map<string, User | null>> {
35
35
  const resolved = new Map<string, User | null>();
36
36
  for (const id of ids) resolved.set(id, null);
37
37
 
38
38
  const params = new URLSearchParams({ ids: ids.join(",") });
39
- const response = await fetch(`${baseUrl}/api/users?${params}`, {
39
+ // Same route as the selector — see the note there; `/api/users` is a 404,
40
+ // so every `userSelect` value rendered as a bare id.
41
+ const response = await fetch(`${apiBase}/admin/users?${params}`, {
40
42
  headers: token ? { Authorization: `Bearer ${token}` } : {}
41
43
  });
42
44
  if (!response.ok) {
@@ -103,9 +105,12 @@ function requestUser(
103
105
  * when it cannot be resolved.
104
106
  */
105
107
  export function useResolvedUser(uid: string | undefined): User | null {
106
- const client = useRebaseClient<{ baseUrl?: string }>();
108
+ const client = useRebaseClient<{ baseUrl?: string, apiPath?: string }>();
107
109
  const { getAuthToken } = useAuthController();
108
- const baseUrl = client?.baseUrl;
110
+ /* The API prefix, not the bare origin: it keys the cache and is what a
111
+ request is built from, and a backend with a non-default `basePath`
112
+ serves neither at `/api`. */
113
+ const baseUrl = apiBaseOf(client);
109
114
 
110
115
  const [user, setUser] = useState<User | null>(() =>
111
116
  (baseUrl && uid) ? getCache(baseUrl).users.get(uid) ?? null : null);
@@ -25,6 +25,8 @@ import { BooleanPreview } from "./components/BooleanPreview";
25
25
  import { NumberPropertyPreview } from "./property_previews/NumberPropertyPreview";
26
26
  import { ErrorView } from "@rebasepro/app";
27
27
  import { RelationPreview } from "./components/RelationPreview";
28
+ import { InlineEntityListPreview } from "./components/InlineEntityListPreview";
29
+ import { useIsNestedEntityPreview } from "../components/EntityPreviewNesting";
28
30
  import { UserPreview } from "./components/UserPreview";
29
31
 
30
32
  /**
@@ -34,6 +36,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
34
36
 
35
37
  const authController = useAuthController();
36
38
  const customizationController = useCustomizationController();
39
+ const nested = useIsNestedEntityPreview();
37
40
 
38
41
  let content: React.ReactNode | any;
39
42
  const {
@@ -181,9 +184,15 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
181
184
  content = buildWrongValueType(propertyKey, property.type, value);
182
185
  }
183
186
  } else if (property.type === "date") {
184
- if (value instanceof Date) {
187
+ // Not every caller comes through the data layer's Date coercion: the
188
+ // revision history renders raw API payloads, where a timestamp is still
189
+ // the string Postgres sent (`2026-06-19 17:20:33.032+02`). Requiring a
190
+ // `Date` instance turned every audit column in every history entry into
191
+ // a red "Unexpected value" box.
192
+ const date = asDate(value);
193
+ if (date) {
185
194
  content = <DatePreview
186
- date={value}
195
+ date={date}
187
196
  mode={property.mode}
188
197
  />;
189
198
  } else {
@@ -213,28 +222,34 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
213
222
  content = <EmptyValue/>;
214
223
  } else if (Array.isArray(value)) {
215
224
  // Many-cardinality relation: value is an array of EntityRelation (or plain objects)
216
- content = (
217
- <div className="flex flex-col w-full gap-0.5">
218
- {(value as unknown[]).map((item: unknown, index: number) => {
219
- const entityRelation = normalizeToEntityRelation(item, "relation");
220
- if (!entityRelation) return null;
221
- return (
225
+ const relations = (value as unknown[])
226
+ .map(item => normalizeToEntityRelation(item, "relation"))
227
+ .filter(Boolean) as EntityRelation[];
228
+ const renderRelation = (entityRelation: EntityRelation, index: number) => <RelationPreview
229
+ key={`preview_rel_${propertyKey}_${index}`}
230
+ disabled={!property.relation}
231
+ previewProperties={property.admin?.previewProperties}
232
+ includeId={property.admin?.includeId}
233
+ includeEntityLink={property.admin?.includeEntityLink}
234
+ size={"small"}
235
+ relation={entityRelation}
236
+ textOnly={props.textOnly}
237
+ />;
238
+
239
+ content = nested || props.textOnly
240
+ // See ArrayOfReferencesPreview: nested lists stay on one line.
241
+ ? <InlineEntityListPreview items={relations}
242
+ renderItem={renderRelation}/>
243
+ : (
244
+ <div className="flex flex-col w-full gap-0.5">
245
+ {relations.map((entityRelation, index) => (
222
246
  <div className="w-full"
223
- key={`preview_rel_${propertyKey}_${index}`}>
224
- <RelationPreview
225
- disabled={!property.relation}
226
- previewProperties={property.admin?.previewProperties}
227
- includeId={property.admin?.includeId}
228
- includeEntityLink={property.admin?.includeEntityLink}
229
- size={"small"}
230
- relation={entityRelation}
231
- textOnly={props.textOnly}
232
- />
247
+ key={`preview_rel_wrapper_${propertyKey}_${index}`}>
248
+ {renderRelation(entityRelation, index)}
233
249
  </div>
234
- );
235
- })}
236
- </div>
237
- );
250
+ ))}
251
+ </div>
252
+ );
238
253
  } else {
239
254
  // Single-cardinality relation
240
255
  const relationValue = normalizeToEntityRelation(value, "relation");
@@ -276,6 +291,17 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
276
291
  : content;
277
292
  }, equal);
278
293
 
294
+ /**
295
+ * A `Date`, or a string/number that unambiguously names one. Returns null for
296
+ * anything else, so an actual type mismatch still reports as one.
297
+ */
298
+ function asDate(value: unknown): Date | null {
299
+ if (value instanceof Date) return isNaN(value.getTime()) ? null : value;
300
+ if (typeof value !== "string" && typeof value !== "number") return null;
301
+ const parsed = new Date(value);
302
+ return isNaN(parsed.getTime()) ? null : parsed;
303
+ }
304
+
279
305
  function buildWrongValueType(name: string | undefined, type: string, value: any) {
280
306
  console.warn(`Unexpected value for property ${name}, of type ${type}`, value);
281
307
  return (
@@ -0,0 +1,42 @@
1
+ import * as React from "react";
2
+
3
+ import { cls } from "@rebasepro/ui";
4
+
5
+ /**
6
+ * How many links a nested list shows before collapsing the rest into a count.
7
+ */
8
+ const MAX_INLINE_ITEMS = 3;
9
+
10
+ export type InlineEntityListPreviewProps<T> = {
11
+ items: T[];
12
+ renderItem: (item: T, index: number) => React.ReactNode;
13
+ max?: number;
14
+ className?: string;
15
+ };
16
+
17
+ /**
18
+ * One wrapping line of inline entity links, with the overflow collapsed into a
19
+ * `+N`. Used for arrays of references or relations that are nested inside
20
+ * another preview, where stacking full-width cards turns a card into a wall.
21
+ *
22
+ * @group Preview components
23
+ */
24
+ export function InlineEntityListPreview<T>({
25
+ items,
26
+ renderItem,
27
+ max = MAX_INLINE_ITEMS,
28
+ className
29
+ }: InlineEntityListPreviewProps<T>) {
30
+
31
+ if (!items || items.length === 0) return null;
32
+
33
+ const shown = items.slice(0, max);
34
+ const overflow = items.length - shown.length;
35
+
36
+ return <span className={cls("inline-flex flex-wrap items-center gap-x-2 gap-y-0.5 min-w-0 max-w-full", className)}>
37
+ {shown.map((item, index) => renderItem(item, index))}
38
+ {overflow > 0 && <span className={"text-text-secondary dark:text-text-secondary-dark shrink-0"}>
39
+ +{overflow}
40
+ </span>}
41
+ </span>;
42
+ }
@@ -8,9 +8,13 @@ import { Skeleton } from "@rebasepro/ui";
8
8
  import { ErrorBoundary } from "@rebasepro/ui";
9
9
  import { ErrorView } from "@rebasepro/app";
10
10
  import { EntityPreviewBinding, EntityPreviewContainer } from "../../components/EntityPreviewBinding";
11
+ import {
12
+ InlineEntityPreview,
13
+ InlineEntityPreviewMissing,
14
+ InlineEntityPreviewSkeleton
15
+ } from "../../components/InlineEntityPreview";
16
+ import { useIsNestedEntityPreview } from "../../components/EntityPreviewNesting";
11
17
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
12
- import { getEntityTitlePropertyKeyForEntity } from "../../util/previews";
13
- import { getValueInPath } from "@rebasepro/utils";
14
18
  import type { AdminCollection } from "@rebasepro/admin-types";
15
19
 
16
20
  export type ReferencePreviewProps = {
@@ -30,9 +34,10 @@ export type ReferencePreviewProps = {
30
34
  */
31
35
  export const ReferencePreview = function ReferencePreview(props: ReferencePreviewProps) {
32
36
  const reference = props.reference;
37
+ const nested = useIsNestedEntityPreview();
33
38
  if (!(typeof reference === "object" && "isEntityReference" in reference && reference.isEntityReference())) {
34
39
  console.warn("Reference preview received value of type", typeof reference);
35
- if (props.textOnly) {
40
+ if (props.textOnly || nested) {
36
41
  return <span>{String(reference)}</span>;
37
42
  }
38
43
  return <EntityPreviewContainer
@@ -62,6 +67,7 @@ function ReferencePreviewInternalInner({
62
67
  collection
63
68
  }: ReferencePreviewProps & { collection?: AdminCollection }) {
64
69
  const ResolvedMissingReference = useComponentOverride("Entity.MissingReference", DefaultMissingReference);
70
+ const nested = useIsNestedEntityPreview();
65
71
 
66
72
  if (!collection) {
67
73
  if (ResolvedMissingReference !== DefaultMissingReference) {
@@ -70,6 +76,10 @@ function ReferencePreviewInternalInner({
70
76
  if (textOnly) {
71
77
  return <span>{reference.path}</span>;
72
78
  }
79
+ if (nested) {
80
+ return <InlineEntityPreviewMissing label={String(reference.id)}
81
+ tooltip={`Collection not found: ${reference.path}`}/>;
82
+ }
73
83
  return <EntityPreviewContainer
74
84
  onClick={onClick}
75
85
  size={size ?? "medium"}>
@@ -130,6 +140,11 @@ function ReferencePreviewExisting<M extends Record<string, unknown> = Record<str
130
140
 
131
141
  const ResolvedEntityPreview = useComponentOverride("EntityPreview", EntityPreviewBinding);
132
142
  const customizationController = useCustomizationController();
143
+ const nested = useIsNestedEntityPreview();
144
+
145
+ // Nested inside another preview, or filling a title slot: one line of text,
146
+ // not a second card. See {@link InlineEntityPreview}.
147
+ const inline = nested || Boolean(textOnly);
133
148
 
134
149
  const {
135
150
  entity,
@@ -157,8 +172,9 @@ function ReferencePreviewExisting<M extends Record<string, unknown> = Record<str
157
172
  tooltip={reference.path}/>;
158
173
  }
159
174
  if (body) {
160
- if (textOnly) {
161
- return <span>{reference.id}</span>;
175
+ if (inline) {
176
+ return <InlineEntityPreviewMissing label={String(reference.id)}
177
+ tooltip={reference.pathWithId}/>;
162
178
  }
163
179
 
164
180
  return (
@@ -171,8 +187,8 @@ function ReferencePreviewExisting<M extends Record<string, unknown> = Record<str
171
187
  }
172
188
 
173
189
  if (dataLoading && !usedEntity) {
174
- if (textOnly) {
175
- return <Skeleton className="inline-block w-20 h-4" />;
190
+ if (inline) {
191
+ return <InlineEntityPreviewSkeleton/>;
176
192
  }
177
193
  return (
178
194
  <EntityPreviewContainer onClick={disabled ? undefined : onClick}
@@ -184,8 +200,9 @@ function ReferencePreviewExisting<M extends Record<string, unknown> = Record<str
184
200
  }
185
201
 
186
202
  if (!usedEntity) {
187
- if (textOnly) {
188
- return <span>{reference.id}</span>;
203
+ if (inline) {
204
+ return <InlineEntityPreviewMissing label={String(reference.id)}
205
+ tooltip={"Entity not found"}/>;
189
206
  }
190
207
  return (
191
208
  <EntityPreviewContainer onClick={disabled ? undefined : onClick}
@@ -196,11 +213,14 @@ function ReferencePreviewExisting<M extends Record<string, unknown> = Record<str
196
213
  );
197
214
  }
198
215
 
199
- if (textOnly) {
200
- const titleProperty = getEntityTitlePropertyKeyForEntity(collection, usedEntity.values, usedEntity.id);
201
- const titleValue = titleProperty ? getValueInPath(usedEntity.values, titleProperty) : undefined;
202
- const displayValue = titleValue !== undefined && titleValue !== null ? String(titleValue) : String(reference.id);
203
- return <span className="truncate">{displayValue}</span>;
216
+ if (inline) {
217
+ return <InlineEntityPreview entity={usedEntity}
218
+ collection={collection}
219
+ disabled={disabled}
220
+ onClick={onClick}
221
+ // In a title slot the row itself is the click target; only a
222
+ // preview nested inside a card opens its own side panel.
223
+ includeEntityLink={!textOnly && includeEntityLink !== false}/>;
204
224
  }
205
225
 
206
226
  return <ResolvedEntityPreview size={size}