@rebasepro/admin 0.13.0 → 0.13.1-canary.gcd6689e

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 (60) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-CZZZpYOz.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CZZZpYOz.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CeoHSmUg.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-CeoHSmUg.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-CMKFWM2J.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js.map → RouterCollectionsStudioView-CMKFWM2J.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/SlotValue.d.ts +29 -0
  10. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +5 -3
  11. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  12. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +29 -4
  13. package/dist/components/DetailViewBinding.d.ts +6 -0
  14. package/dist/components/EditViewBinding.d.ts +7 -1
  15. package/dist/components/EntityIdentityBar.d.ts +10 -1
  16. package/dist/components/EntityViewBinding.d.ts +41 -6
  17. package/dist/components/app/Scaffold.d.ts +4 -3
  18. package/dist/{export-J79rMoNj.js → export-l19WevKo.js} +2 -2
  19. package/dist/{export-J79rMoNj.js.map → export-l19WevKo.js.map} +1 -1
  20. package/dist/form/components/FieldBlock.d.ts +16 -0
  21. package/dist/{history-D7L_JlLO.js → history-JL1ZmSKD.js} +4 -4
  22. package/dist/{history-D7L_JlLO.js.map → history-JL1ZmSKD.js.map} +1 -1
  23. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  24. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  25. package/dist/{import-BvYl--U4.js → import-DNrKrFQV.js} +3 -3
  26. package/dist/{import-BvYl--U4.js.map → import-DNrKrFQV.js.map} +1 -1
  27. package/dist/index.js +79 -15
  28. package/dist/index.js.map +1 -1
  29. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  30. package/dist/routes/RebaseRoute.d.ts +1 -1
  31. package/dist/types/components/PropertyPreviewProps.d.ts +7 -0
  32. package/dist/{util-GiwPgxnL.js → util-pQ3YwBve.js} +1022 -641
  33. package/dist/util-pQ3YwBve.js.map +1 -0
  34. package/package.json +9 -9
  35. package/src/components/AdditionalFieldValue.tsx +68 -0
  36. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +5 -6
  37. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +9 -27
  38. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +11 -24
  39. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +10 -33
  40. package/src/components/CollectionViewBinding/SlotValue.tsx +52 -0
  41. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +7 -5
  42. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  43. package/src/components/CollectionViewBinding/usePreviewSlots.ts +115 -48
  44. package/src/components/DetailViewBinding.tsx +71 -54
  45. package/src/components/EditViewBinding.tsx +51 -33
  46. package/src/components/EntityIdentityBar.tsx +26 -4
  47. package/src/components/EntityPreviewBinding.tsx +24 -12
  48. package/src/components/EntityViewBinding.tsx +237 -39
  49. package/src/components/InlineEntityPreview.tsx +10 -10
  50. package/src/components/app/Scaffold.tsx +48 -7
  51. package/src/form/EntityForm.tsx +6 -24
  52. package/src/form/components/FieldBlock.tsx +24 -0
  53. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  54. package/src/hooks/useEntityDisplay.tsx +258 -0
  55. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  56. package/src/preview/PropertyPreview.tsx +1 -1
  57. package/src/preview/components/BooleanPreview.tsx +5 -2
  58. package/src/routes/RebaseRoute.tsx +62 -4
  59. package/src/types/components/PropertyPreviewProps.tsx +8 -0
  60. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -3,12 +3,14 @@ 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 { getDisplayPropertyKey } from "@rebasepro/app";
6
7
  import { getLeadingRelationTitleKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/app";
7
8
  import { getEntityFromCache } from "@rebasepro/app";
8
9
  import { getEntityPreviewKeys } from "../../util/previews";
9
10
  import { getValueInPath } from "@rebasepro/utils";
10
11
  import type { AuthController } from "@rebasepro/admin-types";
11
12
  import { ChipColorScheme, CHIP_COLORS, CHIP_SEED_KEYS } from "@rebasepro/ui";
13
+ import { useEntityDisplay } from "../../hooks/useEntityDisplay";
12
14
 
13
15
  /** Whether an authored relation yields many rows. Derived from its kind. */
14
16
  function relationCardinality(relation: { kind?: string; cardinality?: string } | undefined): "one" | "many" | undefined {
@@ -23,12 +25,18 @@ function relationCardinality(relation: { kind?: string; cardinality?: string } |
23
25
  // ── Slot types ────────────────────────────────────────────────────────
24
26
 
25
27
  /**
26
- * A resolved "slot" containing the property definition, key, and the
27
- * concrete value extracted from a entity.
28
+ * A resolved "slot": what fills one display role for one record.
29
+ *
30
+ * `property` and `propertyKey` are set when the value was read off the record,
31
+ * and absent when a `display` resolver computed it — a computed title has no
32
+ * column behind it, so there is nothing to render it *as*. Renderers must go
33
+ * through {@link SlotValue}, which makes that choice once: a property renders
34
+ * with its own preview (an enum keeps its coloured chip, a date its format), a
35
+ * computed value renders as text.
28
36
  */
29
37
  export interface PreviewSlot {
30
- property: Property;
31
- propertyKey: string;
38
+ property?: Property;
39
+ propertyKey?: string;
32
40
  value: unknown;
33
41
  }
34
42
 
@@ -129,10 +137,19 @@ export function resolveCollectionSlotKeys(
129
137
  ? [leadingRelationKey, ...rankedCandidates.filter(key => key !== leadingRelationKey)]
130
138
  : rankedCandidates;
131
139
  const titleKey = titleKeyCandidates[0];
140
+ // `getEntityImagePreviewPropertyKey` prefers `display.image` itself, so
141
+ // every caller of it — this, the reference card, the board — agrees.
132
142
  const imageKey = getEntityImagePreviewPropertyKey(collection);
133
143
 
134
- // Status: first string-enum that isn't the title
135
- let statusKey: string | undefined;
144
+ // Status: declared, else the first string-enum that isn't the title.
145
+ //
146
+ // Everything below this line is inference — "the first enum", "a key that
147
+ // looks like a timestamp", "the longest remaining string". It is a good
148
+ // guess and it is only a guess, so a collection that states the role wins
149
+ // outright and the ladder is skipped. That is the whole point of
150
+ // `admin.display`: the heuristics stay for the collections that never say
151
+ // anything, and stop overruling the ones that do.
152
+ let statusKey: string | undefined = getDisplayPropertyKey(collection, "status");
136
153
 
137
154
  // 1. Explicitly defined in previewProperties
138
155
  if (!statusKey && collection.previewProperties) {
@@ -169,13 +186,15 @@ export function resolveCollectionSlotKeys(
169
186
  }
170
187
  }
171
188
 
172
- // Date: prefer well-known timestamp fields, fallback to any date
173
- let dateKey: string | undefined;
189
+ // Date: declared, else a well-known timestamp field, else any date
190
+ let dateKey: string | undefined = getDisplayPropertyKey(collection, "date");
174
191
  const dateCandidates = ["updated_at", "updatedAt", "modified_at", "modifiedAt", "created_at", "createdAt"];
175
- for (const candidate of dateCandidates) {
176
- if (collection.properties[candidate]) {
177
- dateKey = candidate;
178
- break;
192
+ if (!dateKey) {
193
+ for (const candidate of dateCandidates) {
194
+ if (collection.properties[candidate]) {
195
+ dateKey = candidate;
196
+ break;
197
+ }
179
198
  }
180
199
  }
181
200
  if (!dateKey) {
@@ -222,7 +241,8 @@ export function resolveCollectionSlotKeys(
222
241
  const scoreB = propB?.type === "string" ? (propB.admin?.multiline ? 2 : 1) : 0;
223
242
  return scoreB - scoreA;
224
243
  });
225
- const subtitleKey = sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined;
244
+ const subtitleKey = getDisplayPropertyKey(collection, "subtitle")
245
+ ?? (sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined);
226
246
 
227
247
  return { titleKey,
228
248
  titleKeyCandidates,
@@ -506,43 +526,26 @@ function resolveRelationDisplayName(
506
526
  }
507
527
 
508
528
  // Helper: extract display name from entity values using the target collection
529
+ //
530
+ // This used to carry a ranking of its own — priority keys `name/title/label/
531
+ // displayName`, then the first visible non-id string — which is a *seventh*
532
+ // answer to "what is this record called", and one that disagreed with the
533
+ // shared one: it read `titleProperty` with a flat lookup (so a dotted path
534
+ // was ignored), never saw `display.title`, and ranked by key name where the
535
+ // shared ranking excludes identifiers structurally. A chip and the record it
536
+ // points at could therefore be labelled differently on the same screen.
537
+ //
538
+ // What is left here is the part that is genuinely local: a relation carries
539
+ // *eagerly loaded* values, not an entity, so a resolver cannot run against
540
+ // it — a computed title falls back to the ranked property, which is the same
541
+ // thing the list row shows while its own resolver is in flight.
509
542
  const extractDisplayName = (values: Record<string, unknown>): string | undefined => {
510
543
  if (targetCollection) {
511
- const targetTitleKey = targetCollection.titleProperty as string | undefined;
512
- if (targetTitleKey && values[targetTitleKey] !== undefined) {
513
- return String(values[targetTitleKey]);
514
- }
515
-
516
- // Helper to check if a property is hidden/internal
517
- const isHiddenProp = (p: Property): boolean => {
518
- if (p.admin?.hideFromCollection) return true;
519
- if (typeof p.admin?.disabled === "object" && p.admin.disabled.hidden) return true;
520
- return false;
521
- };
522
-
523
- // Helper to check if a property is a visible, non-id string
524
- const isDisplayCandidate = (p: Property): boolean => {
525
- return p.type === "string" && !p.admin?.multiline && !p.admin?.markdown && !p.storage
526
- && !("isId" in p && p.isId) && !isHiddenProp(p);
527
- };
528
-
529
- // Prioritize common title-like fields: name, title, label, displayName
530
- const priorityKeys = ["name", "title", "label", "displayName"];
531
- for (const pk of priorityKeys) {
532
- const p = targetCollection.properties[pk] as Property | undefined;
533
- if (p && isDisplayCandidate(p) && values[pk] !== undefined && values[pk] !== null) {
534
- return String(values[pk]);
535
- }
536
- }
537
-
538
- // Fallback: find first visible, non-id string property in target
539
- for (const [k, p] of Object.entries(targetCollection.properties)) {
540
- const tp = p as Property;
541
- if (isDisplayCandidate(tp)) {
542
- if (values[k] !== undefined && values[k] !== null) {
543
- return String(values[k]);
544
- }
545
- }
544
+ for (const key of getTitlePropertyCandidates(targetCollection)) {
545
+ const value = getValueInPath(values, key);
546
+ if (value === undefined || value === null || value === "") continue;
547
+ if (typeof value === "string" && looksLikeIdentifierValue(value)) continue;
548
+ return String(value);
546
549
  }
547
550
  }
548
551
  // Generic fallback: walk entity values for any short string.
@@ -582,3 +585,67 @@ function resolveRelationDisplayName(
582
585
  // 3. Final fallback: show the ID
583
586
  return id !== undefined ? String(id) : undefined;
584
587
  }
588
+
589
+ /**
590
+ * The slots for one record, with `admin.display` resolvers applied.
591
+ *
592
+ * {@link resolveEntitySlots} answers the question from values already in hand —
593
+ * which is everything a slot filled by a property needs, and nothing a slot
594
+ * filled by a *resolver* can use. A computed title may have to be fetched, so it
595
+ * arrives after the first render; this hook is where the two meet.
596
+ *
597
+ * Until a resolver lands, the derived slot shows. That is deliberate: a list of
598
+ * fifty rows must not blank its titles for a beat while fifty promises settle,
599
+ * and the derived answer — the best-ranked property carrying a readable value —
600
+ * is a reasonable thing to be looking at meanwhile. When the resolved value
601
+ * arrives it replaces the slot outright, property and all, because there is no
602
+ * property behind a computed value (see {@link PreviewSlot}).
603
+ *
604
+ * Every role is hooked unconditionally, so the hook order is fixed regardless of
605
+ * what a given collection declares.
606
+ */
607
+ export function useEntitySlots(
608
+ entity: Entity<Record<string, unknown>>,
609
+ collection: AdminCollection<Record<string, unknown>>,
610
+ slotKeys: CollectionSlotKeys
611
+ ): EntityPreviewSlots {
612
+
613
+ const derived = useMemo(
614
+ () => resolveEntitySlots(entity, collection, slotKeys),
615
+ [entity, collection, slotKeys]
616
+ );
617
+
618
+ const params = { collection,
619
+ entity };
620
+ const title = useEntityDisplay<string>("title", params);
621
+ const subtitle = useEntityDisplay<string>("subtitle", params);
622
+ const image = useEntityDisplay<string>("image", params);
623
+ const status = useEntityDisplay<string>("status", params);
624
+ const date = useEntityDisplay<Date | string | number>("date", params);
625
+
626
+ return useMemo(() => {
627
+ // `source` is set only when the value came from a property, so its
628
+ // absence is exactly the "this was computed" signal — no second flag to
629
+ // keep in step with the first.
630
+ const computed = (
631
+ resolved: { value: unknown, source?: unknown }
632
+ ): PreviewSlot | undefined =>
633
+ resolved.value !== undefined && resolved.source === undefined
634
+ ? { value: resolved.value }
635
+ : undefined;
636
+
637
+ const computedDate = computed(date);
638
+
639
+ return {
640
+ ...derived,
641
+ title: computed(title) ?? derived.title,
642
+ subtitle: computed(subtitle) ?? derived.subtitle,
643
+ image: computed(image) ?? derived.image,
644
+ status: computed(status) ?? derived.status,
645
+ date: computedDate
646
+ ? { ...computedDate,
647
+ formatted: formatDateValue(computedDate.value) }
648
+ : derived.date
649
+ };
650
+ }, [derived, title, subtitle, image, status, date]);
651
+ }
@@ -10,11 +10,11 @@ import { CollectionViewBinding } from "./CollectionViewBinding/CollectionViewBin
10
10
  import { EntityViewBinding } from "./EntityViewBinding";
11
11
  import { EntityIdentityBar } from "./EntityIdentityBar";
12
12
  import { SplitListCloseButton } from "./CollectionViewBinding/SplitListCloseButton";
13
+ import { SplitListShowButton } from "./CollectionViewBinding/SplitListShowButton";
13
14
  import { EntityInspector, InspectorTab } from "./EntityInspector";
14
15
  import { CircularProgressCenter, iconSize } from "@rebasepro/ui";
15
16
  import {
16
17
  Alert,
17
- ArrowLeftIcon,
18
18
  Button,
19
19
  CenteredView,
20
20
  cls,
@@ -34,6 +34,7 @@ import { ErrorBoundary } from "@rebasepro/ui";
34
34
  import { ErrorView, createFormexStub, usePermissions, useTranslation, getIcon } from "@rebasepro/app";
35
35
 
36
36
  import { removeInitialAndTrailingSlashes, resolveDefaultSelectedView } from "@rebasepro/app";
37
+ import { withViewMode } from "../util/view_mode";
37
38
  import { resolvedSelectedEntityView } from "../util/resolutions";
38
39
  import {
39
40
  useCustomizationController,
@@ -45,9 +46,7 @@ import {
45
46
  import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
46
47
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
47
48
  import { useNavigate } from "react-router";
48
- import { getValueInPath } from "@rebasepro/utils";
49
- import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
50
- import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
49
+ import { useEntityDisplayTitle } from "../hooks/useEntityDisplayTitle";
51
50
 
52
51
 
53
52
  import { MAIN_TAB_VALUE, JSON_TAB_VALUE, HISTORY_TAB_VALUE } from "../util/view_constants";
@@ -82,6 +81,12 @@ export interface DetailViewBindingProps<M extends Record<string, unknown> = Reco
82
81
  * of the breadcrumb — the side panel's close button.
83
82
  */
84
83
  barActionsStart?: React.ReactNode;
84
+ /**
85
+ * Full screen only: bring the list back beside this record. Set when the
86
+ * record was opened out of a split collection, so folding the list away is
87
+ * reversible; it takes the place of the back arrow when present.
88
+ */
89
+ onShowList?: () => void;
85
90
  }
86
91
 
87
92
  export function DetailViewBinding<M extends Record<string, unknown>>({
@@ -141,7 +146,8 @@ function DetailViewBindingInner<M extends Record<string, unknown>>({
141
146
  dataLoading,
142
147
  layout = "full_screen",
143
148
  barActions,
144
- barActionsStart
149
+ barActionsStart,
150
+ onShowList
145
151
  }: DetailViewBindingProps<M> & {
146
152
  entity?: Entity<M>,
147
153
  dataLoading: boolean,
@@ -283,18 +289,13 @@ entityId }
283
289
  const formViewConfig = (collection as AdminCollection<M> & { formView?: FormViewConfig<M> }).formView;
284
290
  const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
285
291
 
286
- // Title resolution
287
- const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, usedEntity?.values, usedEntity?.id);
288
- const rawTitle = usedEntity?.values && titlePropertyKey ? getValueInPath(usedEntity.values, titlePropertyKey) : undefined;
289
- // A user picker stores an id: resolve it to the person, like a relation.
290
- const titleUser = useResolvedUser(isUserSelectProperty(collection, titlePropertyKey) && typeof rawTitle === "string"
291
- ? rawTitle
292
- : undefined);
293
- const title = titleUser
294
- ? getUserLabel(titleUser)
295
- : (rawTitle !== undefined && rawTitle !== null
296
- ? resolveTitleToString(rawTitle)
297
- : (collection.singularName ?? collection.name));
292
+ // The heading, resolved the one way — this used to be a copy of
293
+ // `useEntityDisplayTitle`'s body, and the two had already drifted.
294
+ const title = useEntityDisplayTitle({
295
+ collection,
296
+ entity: usedEntity,
297
+ status: "existing"
298
+ });
298
299
 
299
300
  // Non-action custom views
300
301
  const nonActionCustomViews = useMemo(() =>
@@ -380,20 +381,31 @@ entityId }
380
381
  }, [onTabChange, path, entityId, collection]);
381
382
 
382
383
  const propertyDetailView = () => {
383
- // formView.Builder replaces the default property display
384
+ // formView.Builder replaces the default property display. It gets the
385
+ // centred column the record used to be given here; the default display
386
+ // now lays itself out, rail included.
384
387
  if (FormViewBuilder && usedEntity) {
385
- return <ErrorBoundary>
386
- <Suspense fallback={<CircularProgressCenter />}>
387
- <FormViewBuilder
388
- collection={collection}
389
- parentCollectionSlugs={parentCollectionSlugs}
390
- parentEntityIds={parentEntityIds}
391
- entity={usedEntity}
392
- modifiedValues={usedEntity?.values}
393
- formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
394
- />
395
- </Suspense>
396
- </ErrorBoundary>;
388
+ return <div className={"flex-1 min-w-0 overflow-y-auto flex justify-center items-start"}>
389
+ <div className={cls(
390
+ "w-full max-w-3xl 2xl:max-w-4xl flex flex-col",
391
+ layout === "dialog"
392
+ ? "pt-5 pb-12 px-6 sm:px-8"
393
+ : "pt-6 pb-16 px-5 sm:px-8"
394
+ )}>
395
+ <ErrorBoundary>
396
+ <Suspense fallback={<CircularProgressCenter />}>
397
+ <FormViewBuilder
398
+ collection={collection}
399
+ parentCollectionSlugs={parentCollectionSlugs}
400
+ parentEntityIds={parentEntityIds}
401
+ entity={usedEntity}
402
+ modifiedValues={usedEntity?.values}
403
+ formContext={readOnlyFormContext as FormContext<Record<string, unknown>>}
404
+ />
405
+ </Suspense>
406
+ </ErrorBoundary>
407
+ </div>
408
+ </div>;
397
409
  }
398
410
 
399
411
  return (
@@ -402,6 +414,9 @@ entityId }
402
414
  entity={usedEntity}
403
415
  collection={collection}
404
416
  path={path}
417
+ asPage
418
+ openEntityMode={layout}
419
+ formContext={readOnlyFormContext}
405
420
  />}
406
421
  </>
407
422
  );
@@ -458,24 +473,27 @@ entityId }
458
473
  );
459
474
  });
460
475
 
476
+ // The breadcrumb's destination, and the back arrow's when it renders. Both
477
+ // keep the view mode: a collection reached from one of its own records must
478
+ // come back as the user left it.
479
+ const collectionUrl = withViewMode(urlController.buildUrlCollectionPath(path));
480
+
461
481
  // Full screen is the one layout with no way out of its own: a side panel and a
462
482
  // dialog can be dismissed, and a split keeps the list beside it, but opening an
463
483
  // entity full screen replaces the collection entirely and left browser Back as
464
484
  // the only route back to it — which is not an affordance the page shows, and is
465
485
  // wrong anyway once the user has moved between tabs inside the entity.
466
- const backToCollectionButton = layout === "full_screen" ? (
467
- <Tooltip title={t("back")}>
468
- <IconButton
469
- size="small"
470
- aria-label={t("back")}
471
- onClick={() => navigate(urlController.buildUrlCollectionPath(path))}
472
- >
473
- <ArrowLeftIcon size={iconSize.smallest} />
474
- </IconButton>
475
- </Tooltip>
476
- ) : null;
477
-
478
- const shouldShowTopBar = Boolean(barActions) || hasAdditionalViews || layout === "side_panel" || layout === "dialog" || Boolean(backToCollectionButton);
486
+ //
487
+ // Unless the list can be unfolded back beside the record: that chevron
488
+ // leads to the same collection while keeping the record open, so it stands
489
+ // alone rather than beside an arrow going almost the same place.
490
+ //
491
+ // It goes to the bar's leading edge — where a back control reads as back,
492
+ // and where the edit view has always put the same arrow — rather than to
493
+ // the trailing edge among the record's own actions.
494
+ const backToCollection = layout === "full_screen" && !onShowList
495
+ ? () => navigate(collectionUrl)
496
+ : undefined;
479
497
 
480
498
  const fullScreenButton = !barActions && // Not in split: the list panel beside it carries the close control,
481
499
  // and an expand button there competes with it.
@@ -508,43 +526,42 @@ entityId }
508
526
  // Main content view. The title, the `path/id` chip and the empty band under
509
527
  // them have moved into the identity bar, and the `w-80 2xl:w-96` rail that
510
528
  // held a single Edit button is gone — the bar holds it now.
529
+ //
530
+ // The scroll now lives on the record's own column rather than on this row,
531
+ // so the metadata rail beside it stays put while the record scrolls — the
532
+ // same arrangement the form uses.
511
533
  const mainView = <div
512
534
  className={cls(
513
- "flex-1 flex flex-row w-full overflow-y-auto justify-center",
535
+ "flex-1 flex flex-row w-full min-h-0",
514
536
  !mainViewVisible ? "hidden" : ""
515
537
  )}>
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()}
523
- </div>
538
+ {propertyDetailView()}
524
539
  </div>;
525
540
 
526
541
  let result = <div className="relative flex flex-col h-full w-full bg-white dark:bg-surface-800">
527
542
 
528
543
  <EntityIdentityBar
529
544
  collection={collection as AdminCollection}
545
+ collectionUrl={layout === "full_screen" || layout === "split" ? collectionUrl : undefined}
530
546
  title={title}
531
547
  entityId={entityId}
532
548
  status={"existing"}
533
549
  dirty={false}
534
550
  saving={false}
551
+ onBack={backToCollection}
535
552
  onInspect={includeJsonView ? () => setInspectorTab("json") : undefined}
536
553
  onViewHistory={includeHistoryView ? () => setInspectorTab("history") : undefined}
537
554
  leading={<>
538
555
  {barActionsStart}
539
556
  {/* Split view: closing the list is opening this record
540
557
  full screen, and the control for it belongs at this
541
- bar's leading edge. */}
558
+ bar's leading edge. Full screen carries its mirror. */}
542
559
  {layout === "split" && <SplitListCloseButton/>}
560
+ {layout === "full_screen" && onShowList && <SplitListShowButton onClick={onShowList}/>}
543
561
  </>}
544
562
  trailing={<>
545
563
  {editButton}
546
564
  {pluginActionsTop}
547
- {backToCollectionButton}
548
565
  {fullScreenButton}
549
566
  {barActions?.({
550
567
  path,
@@ -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 ? () => {
@@ -717,8 +734,9 @@ parentEntityIds,
717
734
  {barActionsStart}
718
735
  {/* Split view: closing the list is opening this record
719
736
  full screen, and the control for it belongs at this
720
- bar's leading edge. */}
737
+ bar's leading edge. Full screen carries its mirror. */}
721
738
  {layout === "split" && <SplitListCloseButton/>}
739
+ {layout === "full_screen" && onShowList && <SplitListShowButton onClick={onShowList}/>}
722
740
  </>}
723
741
  trailing={<>
724
742
  {pluginActionsTop}
@@ -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,
@@ -25,6 +26,15 @@ import {
25
26
  export interface EntityIdentityBarProps {
26
27
  /** Plural collection name, shown as the breadcrumb ahead of the title. */
27
28
  collection: AdminCollection;
29
+ /**
30
+ * Where the breadcrumb points. Without it the collection name is inert text
31
+ * that still reads as a breadcrumb — worse than no breadcrumb at all, and
32
+ * the only way back once a layout drops its back arrow.
33
+ *
34
+ * Left unset by the overlays (side panel, dialog), where the collection is
35
+ * already underneath and navigating would dismiss the record instead.
36
+ */
37
+ collectionUrl?: string;
28
38
  /** Resolved record title, already guarded against showing a field default. */
29
39
  title: string;
30
40
  entityId?: string | number;
@@ -81,6 +91,7 @@ export interface EntityIdentityBarProps {
81
91
  */
82
92
  export function EntityIdentityBar({
83
93
  collection,
94
+ collectionUrl,
84
95
  title,
85
96
  entityId,
86
97
  status,
@@ -126,10 +137,21 @@ export function EntityIdentityBar({
126
137
  </Tooltip>
127
138
  )}
128
139
 
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>
140
+ {collectionUrl
141
+ ? <Link to={collectionUrl}
142
+ className={"visited:text-inherit dark:visited:text-inherit hidden sm:block"}>
143
+ <Typography variant={"caption"}
144
+ className={cls(
145
+ "text-text-disabled dark:text-text-disabled-dark whitespace-nowrap",
146
+ "hover:text-text-primary dark:hover:text-text-primary-dark transition-colors"
147
+ )}>
148
+ {collection.name}&nbsp;/
149
+ </Typography>
150
+ </Link>
151
+ : <Typography variant={"caption"}
152
+ className={"text-text-disabled dark:text-text-disabled-dark whitespace-nowrap hidden sm:block"}>
153
+ {collection.name}&nbsp;/
154
+ </Typography>}
133
155
 
134
156
  <span className={"font-headers font-semibold text-[15px] tracking-tight truncate min-w-0"}
135
157
  title={title}>