@rebasepro/admin 0.13.0 → 0.13.1-canary.g3660bd5

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 (103) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-MODRemil.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-MODRemil.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-Bt-VM0zQ.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-Bt-VM0zQ.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-D54P9o8O.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-D54P9o8O.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  9. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  10. package/dist/components/CollectionViewBinding/SlotValue.d.ts +37 -0
  11. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +5 -3
  12. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  13. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +29 -4
  14. package/dist/components/DetailViewBinding.d.ts +6 -0
  15. package/dist/components/DrawerNavigationGroup.d.ts +5 -4
  16. package/dist/components/DrawerNavigationItem.d.ts +4 -2
  17. package/dist/components/EditViewBinding.d.ts +7 -1
  18. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  19. package/dist/components/EntityIdentityBar.d.ts +20 -1
  20. package/dist/components/EntityViewBinding.d.ts +60 -6
  21. package/dist/components/RebaseLayout.d.ts +1 -1
  22. package/dist/components/RebaseShell.d.ts +1 -1
  23. package/dist/components/app/Scaffold.d.ts +9 -3
  24. package/dist/components/index.d.ts +2 -0
  25. package/dist/{export-J79rMoNj.js → export-Bl1V7lKO.js} +2 -2
  26. package/dist/{export-J79rMoNj.js.map → export-Bl1V7lKO.js.map} +1 -1
  27. package/dist/form/components/FieldBlock.d.ts +47 -4
  28. package/dist/form/components/FormRail.d.ts +13 -0
  29. package/dist/form/components/FormSections.d.ts +15 -1
  30. package/dist/form/components/useRailVisible.d.ts +5 -4
  31. package/dist/{history-D7L_JlLO.js → history-DN_dzFAF.js} +4 -4
  32. package/dist/{history-D7L_JlLO.js.map → history-DN_dzFAF.js.map} +1 -1
  33. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  34. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  35. package/dist/{import-BvYl--U4.js → import-BJXrHdFv.js} +3 -3
  36. package/dist/{import-BvYl--U4.js.map → import-BJXrHdFv.js.map} +1 -1
  37. package/dist/index.js +149 -51
  38. package/dist/index.js.map +1 -1
  39. package/dist/preview/compact_text.d.ts +23 -0
  40. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  41. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  42. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  43. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  44. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  45. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  46. package/dist/routes/RebaseRoute.d.ts +1 -1
  47. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  48. package/dist/util/number_format.d.ts +14 -0
  49. package/dist/util/previews.d.ts +9 -2
  50. package/dist/{util-GiwPgxnL.js → util-EXfeGzbH.js} +8142 -7353
  51. package/dist/util-EXfeGzbH.js.map +1 -0
  52. package/package.json +9 -9
  53. package/src/components/AdditionalFieldValue.tsx +68 -0
  54. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +5 -6
  55. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +9 -27
  56. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +11 -24
  57. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +26 -6
  58. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -33
  59. package/src/components/CollectionViewBinding/SlotValue.tsx +60 -0
  60. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +7 -5
  61. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  62. package/src/components/CollectionViewBinding/SplitListView.tsx +14 -1
  63. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  64. package/src/components/CollectionViewBinding/usePreviewSlots.ts +115 -48
  65. package/src/components/DefaultDrawer.tsx +19 -23
  66. package/src/components/DetailViewBinding.tsx +74 -54
  67. package/src/components/DrawerNavigationGroup.tsx +4 -7
  68. package/src/components/DrawerNavigationItem.tsx +23 -4
  69. package/src/components/EditViewBinding.tsx +54 -33
  70. package/src/components/EntityDisplayHeader.tsx +170 -0
  71. package/src/components/EntityIdentityBar.tsx +49 -5
  72. package/src/components/EntityPreviewBinding.tsx +54 -17
  73. package/src/components/EntityViewBinding.tsx +341 -41
  74. package/src/components/InlineEntityPreview.tsx +10 -10
  75. package/src/components/RebaseLayout.tsx +2 -2
  76. package/src/components/RebaseShell.tsx +2 -2
  77. package/src/components/app/Scaffold.tsx +112 -10
  78. package/src/components/index.ts +3 -0
  79. package/src/form/EntityForm.tsx +28 -33
  80. package/src/form/components/FieldBlock.tsx +65 -8
  81. package/src/form/components/FormRail.tsx +19 -2
  82. package/src/form/components/FormSections.tsx +32 -5
  83. package/src/form/components/StorageUploadProgress.tsx +9 -3
  84. package/src/form/components/useRailVisible.tsx +5 -4
  85. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +7 -1
  86. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  87. package/src/hooks/useEntityDisplay.tsx +262 -0
  88. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  89. package/src/preview/PropertyPreview.tsx +18 -2
  90. package/src/preview/compact_text.ts +68 -0
  91. package/src/preview/components/BooleanPreview.tsx +5 -2
  92. package/src/preview/components/StorageThumbnail.tsx +5 -2
  93. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  94. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  95. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  96. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  97. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  98. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  99. package/src/routes/RebaseRoute.tsx +62 -4
  100. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  101. package/src/util/number_format.ts +46 -0
  102. package/src/util/previews.ts +8 -55
  103. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -45,6 +45,7 @@ import type { OnUpdateParams } from "../types/components/EntityFormProps";
45
45
  import { EditFormActions } from "./EditFormActions";
46
46
  import { EntityIdentityBar } from "./EntityIdentityBar";
47
47
  import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
48
+ import { SplitListShowButton } from "./CollectionViewBinding/SplitListShowButton";
48
49
  import { useRecordActions } from "../hooks/useRecordActions";
49
50
  import { useSideDialogContext } from "./SideDialogs";
50
51
  import { useAdminContext } from "../hooks/useAdminContext";
@@ -96,6 +97,12 @@ export interface EditViewBindingProps<M extends Record<string, unknown> = Record
96
97
  * of the breadcrumb — the side panel's close button.
97
98
  */
98
99
  barActionsStart?: React.ReactNode;
100
+ /**
101
+ * Full screen only: bring the list back beside this record. Set when the
102
+ * record was opened out of a split collection, so folding the list away is
103
+ * reversible; it takes the place of the back arrow when present.
104
+ */
105
+ onShowList?: () => void;
99
106
  formProps?: Partial<EntityFormBindingProps<M>>,
100
107
  /**
101
108
  * Pre-populate the form with these values when creating a new entity.
@@ -192,6 +199,7 @@ export function EditViewBindingInner<M extends Record<string, unknown>>({
192
199
  layout = "side_panel",
193
200
  barActions,
194
201
  barActionsStart,
202
+ onShowList,
195
203
  status,
196
204
  setStatus,
197
205
  formProps,
@@ -405,8 +413,8 @@ parentEntityIds,
405
413
 
406
414
  const displayTitle = useEntityDisplayTitle({
407
415
  collection,
416
+ entity: usedEntity,
408
417
  values: (formContext?.values ?? usedEntity?.values) as Record<string, unknown> | undefined,
409
- entityId,
410
418
  status
411
419
  });
412
420
 
@@ -524,35 +532,39 @@ parentEntityIds,
524
532
  const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
525
533
  const formViewIncludeActions = formViewConfig?.includeActions !== false;
526
534
 
527
- const entityReadOnlyView = !canEdit && entity ? <div
528
- className={cls("flex-1 flex flex-row w-full overflow-y-auto justify-center", (canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : "")}>
529
- <div
530
- className={cls("relative flex flex-col max-w-4xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-6xl w-full h-fit")}>
531
- <Typography className={"mt-16 mb-8 mx-8"} variant={"h4"}>
532
- {collection.singularName ?? collection.name}
533
- </Typography>
534
- {FormViewBuilder && readOnlyFormContext ? (
535
- <ErrorBoundary>
536
- <Suspense fallback={<CircularProgressCenter />}>
537
- <FormViewBuilder
538
- collection={collection}
539
- parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}
540
- entity={usedEntity}
541
- modifiedValues={usedEntity?.values}
542
- formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
543
- />
544
- </Suspense>
545
- </ErrorBoundary>
546
- ) : (
547
- <EntityViewBinding
548
- className={"px-8 h-full overflow-auto"}
549
- entity={entity}
550
- path={path}
551
- collection={collection} />
552
- )}
553
- <div className="h-16" />
554
- </div>
555
- </div> : null;
535
+ // Without edit permission the record is rendered read-only — the same
536
+ // sections, spans and rail as the form, so losing the permission changes the
537
+ // controls and nothing else. The `h4` that used to head this repeated the
538
+ // collection name the identity bar above it already carries.
539
+ const entityReadOnlyView = !canEdit && entity ? (
540
+ FormViewBuilder && readOnlyFormContext
541
+ ? <div className={cls(
542
+ "flex-1 flex flex-row w-full overflow-y-auto justify-center",
543
+ (canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : ""
544
+ )}>
545
+ <div className={"w-full max-w-3xl 2xl:max-w-4xl flex flex-col pt-6 pb-16 px-5 sm:px-8"}>
546
+ <ErrorBoundary>
547
+ <Suspense fallback={<CircularProgressCenter />}>
548
+ <FormViewBuilder
549
+ collection={collection}
550
+ parentCollectionSlugs={parentCollectionSlugs} parentEntityIds={parentEntityIds}
551
+ entity={usedEntity}
552
+ modifiedValues={usedEntity?.values}
553
+ formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
554
+ />
555
+ </Suspense>
556
+ </ErrorBoundary>
557
+ </div>
558
+ </div>
559
+ : <EntityViewBinding
560
+ className={cls((canEdit || !mainViewVisible || selectedSecondaryForm) ? "hidden" : "")}
561
+ entity={entity}
562
+ path={path}
563
+ collection={collection}
564
+ asPage
565
+ openEntityMode={layout}
566
+ formContext={readOnlyFormContext}/>
567
+ ) : null;
556
568
 
557
569
  const entityView = FormViewBuilder ? (
558
570
  // formView.Builder replaces the default form
@@ -687,6 +699,9 @@ parentEntityIds,
687
699
 
688
700
  <EntityIdentityBar
689
701
  collection={collection as AdminCollection}
702
+ collectionUrl={layout === "full_screen" || layout === "split"
703
+ ? withViewMode(urlController.buildUrlCollectionPath(path))
704
+ : undefined}
690
705
  title={displayTitle}
691
706
  entityId={status === "existing" ? entityId : undefined}
692
707
  status={status}
@@ -696,8 +711,10 @@ parentEntityIds,
696
711
  saveDisabled={!canEdit || saveDisabled}
697
712
  // Full screen replaces the collection entirely, leaving browser Back
698
713
  // as the only route to it. The detail view has carried this arrow
699
- // for that reason; the edit view is reached the same way.
700
- onBack={layout === "full_screen"
714
+ // for that reason; the edit view is reached the same way. Where the
715
+ // list can be unfolded back beside the record, that chevron reaches
716
+ // the same collection and the arrow beside it is noise.
717
+ onBack={layout === "full_screen" && !onShowList
701
718
  ? () => navigate(withViewMode(urlController.buildUrlCollectionPath(path)))
702
719
  : undefined}
703
720
  onSave={canEdit && formContext ? () => {
@@ -711,14 +728,18 @@ parentEntityIds,
711
728
  onDiscard={canEdit && formContext ? () => formContext.formex.resetForm() : undefined}
712
729
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
713
730
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
731
+ externalLink={usedEntity
732
+ ? customizationController?.entityLinkBuilder?.({ entity: usedEntity })
733
+ : undefined}
714
734
  recordActions={recordActionItems}
715
735
  pluginActions={formPluginActions}
716
736
  leading={<>
717
737
  {barActionsStart}
718
738
  {/* Split view: closing the list is opening this record
719
739
  full screen, and the control for it belongs at this
720
- bar's leading edge. */}
740
+ bar's leading edge. Full screen carries its mirror. */}
721
741
  {layout === "split" && <SplitListCloseButton/>}
742
+ {layout === "full_screen" && onShowList && <SplitListShowButton onClick={onShowList}/>}
722
743
  </>}
723
744
  trailing={<>
724
745
  {pluginActionsTop}
@@ -0,0 +1,170 @@
1
+ import type { AdminCollection } from "@rebasepro/admin-types";
2
+ import type { Entity, Property } from "@rebasepro/types";
3
+ import React from "react";
4
+ import { Chip, cls, Typography } from "@rebasepro/ui";
5
+
6
+ import { useEntityDisplayValues } from "../hooks/useEntityDisplay";
7
+ import { PropertyPreview } from "../preview";
8
+
9
+ /**
10
+ * The roles whose property the read-only view takes out of its grid.
11
+ *
12
+ * A role rendered here and left in the grid prints the value twice, so the
13
+ * header's own rendering and this list have to agree — but the list is *not*
14
+ * simply "every role the header draws". Two are deliberately absent:
15
+ *
16
+ * `title`, because the heading above a record and a labelled field are not the
17
+ * same thing. `Order #` is a column with a name, a description and a format that
18
+ * someone reads the record to find; `ORD-2026-0043` sitting in the identity bar
19
+ * beside the breadcrumb is a label for the page. Removing the field because the
20
+ * page is named after it takes a value out of the record on the grounds that its
21
+ * text appears elsewhere, which is not a reason — a value in the database is
22
+ * shown.
23
+ *
24
+ * `date`, because the rail's record block already carries the timestamps, and a
25
+ * collection whose `date` role points at a business date — an order date, a due
26
+ * date — wants that field where it declared it.
27
+ */
28
+ export const HEADER_DISPLAY_ROLES = ["subtitle", "image", "status", "tags"] as const;
29
+
30
+ export interface EntityDisplayHeaderProps<M extends Record<string, unknown>> {
31
+ entity: Entity<M>;
32
+ collection: AdminCollection<M>;
33
+ /**
34
+ * Render the title. Off when an identity bar above already shows it — which
35
+ * is every full-page use — and on inside a dialog that has no bar.
36
+ */
37
+ showTitle?: boolean;
38
+ className?: string;
39
+ }
40
+
41
+ /**
42
+ * What the record *is*, above the fields that say what it holds.
43
+ *
44
+ * A record's picture, name, state and labels are the things you opened the page
45
+ * to see; on the plain grid they were fields like any other, so an order's
46
+ * status sat in the second cell of the first row at the same size and weight as
47
+ * its shipping cost, and its tags were a wrapped list of grey text.
48
+ *
49
+ * Every role here comes from `admin.display` and nothing is derived except the
50
+ * title, which was already derived for the identity bar. A collection that
51
+ * declares no display block therefore gets no header at all rather than a
52
+ * heuristic guess at which of its enums is a "status" — the panel does not know
53
+ * what these records are, and a wrong guess here is louder than no guess.
54
+ *
55
+ * A role filled by a *property* renders through {@link PropertyPreview}, so a
56
+ * status keeps its enum's colour and an image stays a storage thumbnail. A role
57
+ * filled by a resolver has no property behind it and renders as what it
58
+ * returned.
59
+ */
60
+ export function EntityDisplayHeader<M extends Record<string, unknown>>({
61
+ entity,
62
+ collection,
63
+ showTitle = false,
64
+ className
65
+ }: EntityDisplayHeaderProps<M>) {
66
+
67
+ const display = useEntityDisplayValues<M>({
68
+ collection,
69
+ entity
70
+ });
71
+
72
+ const title = showTitle ? asText(display.title.value) : undefined;
73
+ const subtitle = asText(display.subtitle.value);
74
+ const tags = asTags(display.tags.value);
75
+
76
+ const hasStatus = display.status.value !== undefined;
77
+ const hasImage = display.image.value !== undefined;
78
+
79
+ if (!title && !subtitle && !hasStatus && !hasImage && !tags.length) return null;
80
+
81
+ return (
82
+ <div className={cls("flex items-start gap-4 min-w-0", className)}>
83
+
84
+ {hasImage && (
85
+ <div className={"shrink-0 w-16 h-16 rounded-lg overflow-hidden flex items-center justify-center bg-surface-100 dark:bg-surface-800"}>
86
+ <RoleValue role={display.image} propertyFallback={
87
+ // A resolver's image is documented as a URL; a storage
88
+ // path only resolves when a property is behind it, which
89
+ // is what carries the storage source.
90
+ typeof display.image.value === "string"
91
+ ? <img src={display.image.value}
92
+ alt={""}
93
+ className={"w-full h-full object-cover"}/>
94
+ : null
95
+ }/>
96
+ </div>
97
+ )}
98
+
99
+ <div className={"flex flex-col gap-1.5 min-w-0 flex-1"}>
100
+
101
+ {title && (
102
+ <Typography variant={"h5"} className={"min-w-0 truncate m-0"}>
103
+ {title}
104
+ </Typography>
105
+ )}
106
+
107
+ {subtitle && (
108
+ <Typography variant={"body2"}
109
+ color={"secondary"}
110
+ className={"min-w-0 m-0"}>
111
+ {subtitle}
112
+ </Typography>
113
+ )}
114
+
115
+ {(hasStatus || tags.length > 0) && (
116
+ <div className={"flex flex-wrap items-center gap-1.5"}>
117
+ {hasStatus && (
118
+ <RoleValue role={display.status} propertyFallback={
119
+ <Chip size={"small"}>{asText(display.status.value)}</Chip>
120
+ }/>
121
+ )}
122
+ {tags.map(tag => (
123
+ <Chip key={tag} size={"small"} outlined>{tag}</Chip>
124
+ ))}
125
+ </div>
126
+ )}
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ /**
133
+ * One resolved role, rendered richly when a property is behind it.
134
+ *
135
+ * `source` is set only when the value came from a property of this collection
136
+ * (see `useEntityDisplay`), and it carries what a rich rendering needs. Its
137
+ * absence is exactly the "this was computed" signal, and a computed value is a
138
+ * bare string with no property to describe it — hence the fallback.
139
+ */
140
+ function RoleValue({
141
+ role,
142
+ propertyFallback
143
+ }: {
144
+ role: { value: unknown, source?: { key: string, property: Property | undefined, value: unknown } };
145
+ propertyFallback: React.ReactNode;
146
+ }) {
147
+ const source = role.source;
148
+ if (!source?.property) return <>{propertyFallback}</>;
149
+ return <PropertyPreview propertyKey={source.key}
150
+ value={source.value}
151
+ property={source.property}
152
+ hideLabel
153
+ size={"medium"}/>;
154
+ }
155
+
156
+ /** A role's value as a line of text, or nothing when it holds nothing to read. */
157
+ function asText(value: unknown): string | undefined {
158
+ if (value === undefined || value === null) return undefined;
159
+ const text = String(value).trim();
160
+ return text.length ? text : undefined;
161
+ }
162
+
163
+ /** The `tags` role, which takes a single string as shorthand for one tag. */
164
+ function asTags(value: unknown): string[] {
165
+ if (value === undefined || value === null) return [];
166
+ const list = Array.isArray(value) ? value : [value];
167
+ return list
168
+ .map(entry => asText(entry))
169
+ .filter((entry): entry is string => Boolean(entry));
170
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AdminCollection } from "@rebasepro/admin-types";
2
2
  import type { EntityStatus } from "@rebasepro/types";
3
3
  import React, { useState } from "react";
4
+ import { Link } from "react-router";
4
5
  import { useTranslation } from "@rebasepro/app";
5
6
  import {
6
7
  Button,
@@ -9,6 +10,7 @@ import {
9
10
  CodeIcon,
10
11
  CopyIcon,
11
12
  defaultBorderMixin,
13
+ ExternalLinkIcon,
12
14
  HistoryIcon,
13
15
  IconButton,
14
16
  iconSize,
@@ -25,6 +27,15 @@ import {
25
27
  export interface EntityIdentityBarProps {
26
28
  /** Plural collection name, shown as the breadcrumb ahead of the title. */
27
29
  collection: AdminCollection;
30
+ /**
31
+ * Where the breadcrumb points. Without it the collection name is inert text
32
+ * that still reads as a breadcrumb — worse than no breadcrumb at all, and
33
+ * the only way back once a layout drops its back arrow.
34
+ *
35
+ * Left unset by the overlays (side panel, dialog), where the collection is
36
+ * already underneath and navigating would dismiss the record instead.
37
+ */
38
+ collectionUrl?: string;
28
39
  /** Resolved record title, already guarded against showing a field default. */
29
40
  title: string;
30
41
  entityId?: string | number;
@@ -49,6 +60,17 @@ export interface EntityIdentityBarProps {
49
60
  /** Opens the inspector straight on its history tab. */
50
61
  onViewHistory?: () => void;
51
62
 
63
+ /**
64
+ * Where this record appears on the live site, from `entityLinkBuilder`.
65
+ *
66
+ * Here rather than above the record, where it used to be a single unlabelled
67
+ * icon alone on its own right-aligned row — the only thing between the bar
68
+ * and the first field, and the only control in the view that did not say
69
+ * what it was. It is a thing you do *to* a record, so it belongs with the
70
+ * others.
71
+ */
72
+ externalLink?: string;
73
+
52
74
  /**
53
75
  * Actions performed *on* the record — copy, delete, whatever the collection
54
76
  * adds — as menu items under the overflow button. They live here rather
@@ -81,6 +103,7 @@ export interface EntityIdentityBarProps {
81
103
  */
82
104
  export function EntityIdentityBar({
83
105
  collection,
106
+ collectionUrl,
84
107
  title,
85
108
  entityId,
86
109
  status,
@@ -94,6 +117,7 @@ export function EntityIdentityBar({
94
117
  onBack,
95
118
  onInspect,
96
119
  onViewHistory,
120
+ externalLink,
97
121
  recordActions,
98
122
  pluginActions,
99
123
  trailing,
@@ -108,7 +132,8 @@ export function EntityIdentityBar({
108
132
  const closeLabel = status === "existing"
109
133
  ? t("save_and_close")
110
134
  : status === "copy" ? t("create_copy_and_close") : t("create_and_close");
111
- const hasMenu = Boolean(recordActions) || Boolean(onInspect) || Boolean(onViewHistory);
135
+ const hasMenu = Boolean(recordActions) || Boolean(onInspect) || Boolean(onViewHistory)
136
+ || Boolean(externalLink);
112
137
 
113
138
  return (
114
139
  <div className={cls(
@@ -126,10 +151,21 @@ export function EntityIdentityBar({
126
151
  </Tooltip>
127
152
  )}
128
153
 
129
- <Typography variant={"caption"}
130
- className={"text-text-disabled dark:text-text-disabled-dark whitespace-nowrap hidden sm:block"}>
131
- {collection.name}&nbsp;/
132
- </Typography>
154
+ {collectionUrl
155
+ ? <Link to={collectionUrl}
156
+ className={"visited:text-inherit dark:visited:text-inherit hidden sm:block"}>
157
+ <Typography variant={"caption"}
158
+ className={cls(
159
+ "text-text-disabled dark:text-text-disabled-dark whitespace-nowrap",
160
+ "hover:text-text-primary dark:hover:text-text-primary-dark transition-colors"
161
+ )}>
162
+ {collection.name}&nbsp;/
163
+ </Typography>
164
+ </Link>
165
+ : <Typography variant={"caption"}
166
+ className={"text-text-disabled dark:text-text-disabled-dark whitespace-nowrap hidden sm:block"}>
167
+ {collection.name}&nbsp;/
168
+ </Typography>}
133
169
 
134
170
  <span className={"font-headers font-semibold text-[15px] tracking-tight truncate min-w-0"}
135
171
  title={title}>
@@ -189,6 +225,14 @@ export function EntityIdentityBar({
189
225
  <MoreVerticalIcon size={iconSize.smallest}/>
190
226
  </IconButton>
191
227
  }>
228
+ {externalLink && (
229
+ <MenuItem onClick={() => window.open(externalLink, "_blank", "noopener,noreferrer")}>
230
+ <ExternalLinkIcon size={iconSize.smallest}/>
231
+ {t("open_in_live_site") ?? "Open in the live site"}
232
+ </MenuItem>
233
+ )}
234
+ {externalLink && (recordActions || onInspect || onViewHistory) &&
235
+ <Separator orientation={"horizontal"}/>}
192
236
  {recordActions}
193
237
  {recordActions && (onInspect || onViewHistory) && <Separator orientation={"horizontal"}/>}
194
238
  {onViewHistory && (
@@ -25,7 +25,8 @@ import {
25
25
  import { useAnalyticsController } from "@rebasepro/app";
26
26
  import { IconForView } from "@rebasepro/app";
27
27
  import { getPropertyInPath } from "../util/property_utils";
28
- import { getEntityPreviewKeys, getEntityTitlePropertyKeyForEntity } from "../util/previews";
28
+ import { resolveCollectionSlotKeys } from "./CollectionViewBinding/usePreviewSlots";
29
+ import { useEntityTitle } from "../hooks/useEntityDisplay";
29
30
  import { getValueInPath } from "@rebasepro/utils";
30
31
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
31
32
  import { useSidePanel } from "../hooks/useSidePanel";
@@ -87,10 +88,33 @@ export function EntityPreviewBindingData({
87
88
 
88
89
  const collection = collectionProp ?? collectionRegistryController.getCollection(entity.path);
89
90
 
91
+ // Above the `!collection` return, because hooks are. `useEntityTitle`
92
+ // tolerates a missing collection for exactly this reason.
93
+ const title = useEntityTitle({
94
+ collection,
95
+ entity
96
+ });
97
+
98
+ // What the card says under the title.
99
+ //
100
+ // A stated list wins outright — `previewProperties` is the one place a
101
+ // developer says "show these", and nothing here is allowed to second-guess
102
+ // it. Failing that, the card fills the same slots as every other surface
103
+ // that renders one record in a row: a supporting line and a status. It used
104
+ // to take the first few properties in declaration order instead, which is
105
+ // how an author card ended up rendering an entire Markdown biography.
106
+ const statedKeys = previewKeys ?? (collection?.previewProperties as string[] | undefined);
90
107
  const listProperties = useMemo(() => {
91
108
  if (!collection) return [];
92
- return previewKeys ?? getEntityPreviewKeys(authController, collection, customizationController.propertyConfigs, previewKeys, size === "medium" || size === "large" ? 3 : 2);
93
- }, [previewKeys, collection, size, authController, customizationController.propertyConfigs]);
109
+ if (statedKeys && statedKeys.length > 0) {
110
+ return statedKeys.filter(key => getPropertyInPath(collection.properties, key));
111
+ }
112
+ const slotKeys = resolveCollectionSlotKeys(
113
+ collection as AdminCollection<Record<string, unknown>>,
114
+ authController,
115
+ customizationController.propertyConfigs);
116
+ return [slotKeys.subtitleKey, slotKeys.statusKey].filter(Boolean) as string[];
117
+ }, [statedKeys, collection, authController, customizationController.propertyConfigs]);
94
118
 
95
119
  if (!collection) {
96
120
  return (
@@ -100,7 +124,7 @@ export function EntityPreviewBindingData({
100
124
  );
101
125
  }
102
126
 
103
- const titleProperty = includeTitle ? getEntityTitlePropertyKeyForEntity(collection, entity.values, entity.id) : undefined;
127
+ const titleProperty = includeTitle ? title.source?.key : undefined;
104
128
  const imagePropertyKey = includeImage ? getEntityImagePreviewPropertyKey(collection) : undefined;
105
129
  const imageProperty = imagePropertyKey ? collection.properties[imagePropertyKey] : undefined;
106
130
  const ofProp = imageProperty && "of" in imageProperty ? imageProperty.of : undefined;
@@ -119,7 +143,10 @@ export function EntityPreviewBindingData({
119
143
  // relation among the preview properties collapses to one line instead
120
144
  // of nesting another bordered card with its own id and action button.
121
145
  <NestedEntityPreviewBoundary>
122
- <div className={cls("flex shrink-0", {
146
+ {/* `overflow-hidden`: the image slot is a fixed box, and a thumbnail
147
+ that fails to load renders a "file not found" message sized for a
148
+ full preview — which spilled across the title beside it. */}
149
+ <div className={cls("flex shrink-0 overflow-hidden", {
123
150
  "w-6 h-6 mx-1 my-0.5": size === "small" || size === "smallest",
124
151
  "w-8 h-8 ml-1 mr-2 m-2 self-start": size === "medium",
125
152
  "w-10 h-10 ml-2 mr-2 m-2 self-start": size === "large"
@@ -148,20 +175,24 @@ export function EntityPreviewBindingData({
148
175
  </div>
149
176
  : <Skeleton/>)}
150
177
 
151
- {titleProperty && (
178
+ {/* A title that came from a property keeps that property's own
179
+ rendering — an enum stays its coloured chip, a relation stays
180
+ a link. A computed one is text, because that is all a
181
+ resolver returns. */}
182
+ {includeTitle && (titleProperty || title.value) && (
152
183
  <div
153
184
  className={"truncate my-0.5 text-sm font-medium text-text-primary dark:text-text-primary-dark"}>
154
- {
155
- entity
185
+ {!entity && titleProperty
186
+ ? <SkeletonPropertyComponent
187
+ property={getPropertyInPath(collection.properties, titleProperty) as Property}
188
+ size={"medium"}/>
189
+ : titleProperty
156
190
  ? <PropertyPreview
157
- propertyKey={titleProperty as string}
158
- value={getValueInPath(entity.values, titleProperty) as never}
159
- property={collection.properties[titleProperty as string] as Property}
160
- size={"medium"}/>
161
- : <SkeletonPropertyComponent
162
- property={collection.properties[titleProperty as string] as Property}
191
+ propertyKey={titleProperty}
192
+ value={title.source?.value as never}
193
+ property={getPropertyInPath(collection.properties, titleProperty) as Property}
163
194
  size={"medium"}/>
164
- }
195
+ : title.value}
165
196
  </div>
166
197
  )}
167
198
 
@@ -171,8 +202,14 @@ export function EntityPreviewBindingData({
171
202
 
172
203
  const valueInPath = getValueInPath(entity.values, key);
173
204
  return (
205
+ // `truncate` clamps a line of text; it does nothing to a
206
+ // preview that renders blocks. Custom `admin.Preview`
207
+ // components can render anything at all, so the height
208
+ // is capped here as well — a card that loses the tail of
209
+ // an unusual value beats a card the size of a document.
174
210
  <div key={"ref_prev_" + key}
175
- className={cls("truncate", restProperties.length > 1 ? "my-0.5" : "my-0")}>
211
+ className={cls("truncate min-w-0 max-h-8 overflow-hidden",
212
+ restProperties.length > 1 ? "my-0.5" : "my-0")}>
176
213
  {
177
214
  entity
178
215
  ? <PropertyPreview
@@ -364,7 +401,7 @@ export const EntityPreviewContainer = React.forwardRef<HTMLDivElement, EntityPre
364
401
  "bg-white dark:bg-surface-900",
365
402
  size === "small" ? "min-h-[32px]" : "min-h-[44px]",
366
403
  fullwidth ? "w-full" : "",
367
- "items-center",
404
+ "items-center overflow-hidden",
368
405
  hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
369
406
  size === "small" ? "p-1" : "px-2 py-1",
370
407
  "flex border rounded-lg",