@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
@@ -0,0 +1,258 @@
1
+ import type { AdminCollection, EntityDisplayRole } from "@rebasepro/admin-types";
2
+ import type { Entity, Property } from "@rebasepro/types";
3
+ import { ENTITY_DISPLAY_ROLES } from "@rebasepro/admin-types";
4
+ import { useEffect, useMemo, useSyncExternalStore } from "react";
5
+ import {
6
+ EntityDisplayCache,
7
+ entityDisplayKey,
8
+ getDisplayPropertyKey,
9
+ getDisplayResolver,
10
+ getPropertyInPath,
11
+ useRebaseContext
12
+ } from "@rebasepro/app";
13
+ import { getValueInPath } from "@rebasepro/utils";
14
+
15
+ import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
16
+ import { getUserLabel, useResolvedUser } from "./useResolvedUsers";
17
+
18
+ /**
19
+ * What fills one display role for one record.
20
+ *
21
+ * `value` is `undefined` rather than a placeholder when nothing resolved,
22
+ * because the substitute differs by surface and each one knows its own better
23
+ * than this hook does: a page heading wants the singular collection name, a link
24
+ * wants the id, a draft wants "New customer". A single baked-in fallback is
25
+ * exactly what produced three different ones scattered across the consumers.
26
+ */
27
+ export interface ResolvedDisplayValue<T = unknown> {
28
+ /** What goes in the slot. `undefined` when the record has nothing for it. */
29
+ value: T | undefined;
30
+ /**
31
+ * A resolver is in flight, and `value` currently holds the derived answer or
32
+ * nothing. Surfaces that can show a skeleton may; none has to.
33
+ */
34
+ loading: boolean;
35
+ /**
36
+ * Set only when the value came from a property of this collection. Carries
37
+ * what a rich renderer needs — a status shows as its enum's coloured chip,
38
+ * an image as a storage thumbnail — which a bare resolved string cannot
39
+ * describe.
40
+ */
41
+ source?: {
42
+ key: string;
43
+ property: Property | undefined;
44
+ value: unknown;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * One shared store, so every surface agrees on a record and resolves it once.
50
+ *
51
+ * Module-level rather than a provider: two app instances on one page share a
52
+ * cache keyed by collection path and record id, which *is* the same record.
53
+ * Nothing here is user-specific — a resolver that reads per-user data must
54
+ * invalidate on sign-out, which {@link useEntityDisplayCache} exposes.
55
+ */
56
+ let sharedCache: EntityDisplayCache | undefined;
57
+
58
+ /**
59
+ * The store behind {@link useEntityDisplay}, for invalidating after a write.
60
+ *
61
+ * Built on first use rather than at module scope. Importing a module should not
62
+ * construct anything: it runs during SSR where there is nothing to cache, and it
63
+ * made this module unimportable from any test that stubs `@rebasepro/app` —
64
+ * which several do, and which is a legitimate thing for them to do.
65
+ */
66
+ export function useEntityDisplayCache(): EntityDisplayCache {
67
+ if (!sharedCache) sharedCache = new EntityDisplayCache();
68
+ return sharedCache;
69
+ }
70
+
71
+ export interface UseEntityDisplayParams<M extends Record<string, unknown>> {
72
+ /**
73
+ * Optional because several callers look the collection up in the registry by
74
+ * path and may not find it — a relation into a collection the panel does not
75
+ * register. A hook that could not be called until that succeeded would have
76
+ * to sit below an early return, which is not where hooks go.
77
+ */
78
+ collection?: AdminCollection<M>;
79
+ /**
80
+ * The record. A resolver is handed the whole entity — its id and path are
81
+ * how it reaches anything the record does not carry — so a computed value is
82
+ * only available once there is an entity to compute it from.
83
+ */
84
+ entity?: Entity<M>;
85
+ /**
86
+ * Values to read a declared path from when they are newer than the entity:
87
+ * the live form values while editing. Falls back to `entity.values`.
88
+ */
89
+ values?: Record<string, unknown>;
90
+ /** Set false to skip resolver work entirely — a draft has nothing to show. */
91
+ enabled?: boolean;
92
+ }
93
+
94
+ /**
95
+ * What fills one display role of one record.
96
+ *
97
+ * This is the only implementation. Before it there were seven for the title
98
+ * alone — the identity bar, the detail header, inline previews, reference cards,
99
+ * list slots, relation chips and the headless view — each re-deriving it from
100
+ * `collection.properties` and disagreeing about the answer.
101
+ *
102
+ * Resolution order, per role:
103
+ *
104
+ * 1. `admin.display[role]` as a **resolver** — awaited, cached per record and
105
+ * role, with the derived value showing meanwhile so nothing flickers empty.
106
+ * 2. `admin.display[role]` as a **property path** (or, for the title, the
107
+ * deprecated `titleProperty`).
108
+ * 3. The **derived** answer: for the title, the best-ranked property carrying a
109
+ * readable value; for the rest, today's per-role heuristics.
110
+ *
111
+ * A user-picker title resolves to the person behind the id, as it always did —
112
+ * that path is now here rather than in two of the seven.
113
+ */
114
+ export function useEntityDisplay<T = unknown, M extends Record<string, unknown> = Record<string, unknown>>(
115
+ role: EntityDisplayRole,
116
+ {
117
+ collection,
118
+ entity,
119
+ values: valuesProp,
120
+ enabled = true
121
+ }: UseEntityDisplayParams<M>
122
+ ): ResolvedDisplayValue<T> {
123
+
124
+ const context = useRebaseContext();
125
+ const cache = useEntityDisplayCache();
126
+
127
+ const values = valuesProp ?? entity?.values;
128
+
129
+ /* ---- the declared or derived key ------------------------------------- */
130
+
131
+ // The title is the one role with a ranking behind it: several candidates,
132
+ // best first, skipping the ones that hold nothing for *this* record. The
133
+ // others are a single declared or derived key.
134
+ const declaredKey = collection ? getDisplayPropertyKey(collection, role) : undefined;
135
+ const propertyKey = collection && role === "title" && !declaredKey
136
+ ? getEntityTitlePropertyKeyForEntity(collection, values, entity?.id)
137
+ : declaredKey;
138
+
139
+ const rawValue = values && propertyKey ? getValueInPath(values, propertyKey) : undefined;
140
+
141
+ // A user picker stores an auth id and renders the person: resolved like a
142
+ // relation, and hooked unconditionally so the hook order never changes.
143
+ const titleUser = useResolvedUser(
144
+ collection && role === "title" && isUserSelectProperty(collection, propertyKey) && typeof rawValue === "string"
145
+ ? rawValue
146
+ : undefined
147
+ );
148
+
149
+ const derived = useMemo<T | undefined>(() => {
150
+ if (titleUser) return getUserLabel(titleUser) as T;
151
+ if (rawValue === undefined || rawValue === null) return undefined;
152
+ // Only text roles get flattened to a string; a date stays a date and an
153
+ // image stays whatever the storage property holds, so the renderer can
154
+ // still format them.
155
+ if (role === "title" || role === "subtitle" || role === "status") {
156
+ return (resolveTitleToString(rawValue) || undefined) as T | undefined;
157
+ }
158
+ return rawValue as T;
159
+ }, [titleUser, rawValue, role]);
160
+
161
+ /* ---- the computed answer --------------------------------------------- */
162
+
163
+ const resolver = collection ? getDisplayResolver(collection, role) : undefined;
164
+ const canResolve = Boolean(enabled && resolver && entity);
165
+ const key = entity ? entityDisplayKey(entity.path, entity.id, role) : undefined;
166
+
167
+ const resolved = useSyncExternalStore<unknown>(
168
+ // Subscribing unconditionally keeps the hook order stable. The store
169
+ // only notifies when an entry changes, so a collection with no resolver
170
+ // never re-renders from it.
171
+ cb => cache.subscribe(cb),
172
+ () => (canResolve && key ? cache.peek(key) : undefined),
173
+ // Server render: nothing is resolved, so every slot starts at its
174
+ // fallback and hydration does not disagree with the markup.
175
+ () => undefined
176
+ );
177
+
178
+ const loading = Boolean(canResolve && key && resolved === undefined);
179
+
180
+ useEffect(() => {
181
+ if (!canResolve || !key || !resolver || !entity) return;
182
+ if (cache.peek(key) !== undefined) return;
183
+ cache.resolve(key, () => resolver({
184
+ entity,
185
+ context
186
+ })).catch((error: unknown) => {
187
+ // `resolve` has already recorded the failure as "nothing"; this is
188
+ // the one line that says why, once, rather than per render.
189
+ console.warn(`[rebase] Could not resolve ${role} for ${key}:`, error);
190
+ });
191
+ }, [cache, canResolve, key, resolver, entity, context, role]);
192
+
193
+ /* ---- the answer ------------------------------------------------------ */
194
+
195
+ // A resolver that produced nothing falls back to the derived value rather
196
+ // than to blank: it said this record has no computed answer for the role,
197
+ // not that the role should be empty.
198
+ const computed = canResolve && resolved !== null ? resolved as T | undefined : undefined;
199
+ const value = computed ?? derived;
200
+
201
+ return useMemo(() => ({
202
+ value,
203
+ loading,
204
+ source: collection && propertyKey && value === derived && derived !== undefined
205
+ ? {
206
+ key: propertyKey,
207
+ property: getPropertyInPath(collection.properties, propertyKey),
208
+ value: rawValue
209
+ }
210
+ : undefined
211
+ }), [value, loading, propertyKey, derived, collection, rawValue]);
212
+ }
213
+
214
+ /**
215
+ * Every display role of a record at once — what a list row, a card or a board
216
+ * card needs, in one call rather than six.
217
+ *
218
+ * The role list is iterated from {@link ENTITY_DISPLAY_ROLES} so the hook order
219
+ * is fixed and adding a role does not mean editing every caller.
220
+ */
221
+ export function useEntityDisplayValues<M extends Record<string, unknown>>(
222
+ params: UseEntityDisplayParams<M>
223
+ ): Record<EntityDisplayRole, ResolvedDisplayValue> & { loading: boolean } {
224
+
225
+ // Not a loop over `useEntityDisplay`: the number of hooks a component calls
226
+ // has to be fixed at compile time, and a role added to the array later would
227
+ // silently change it. Written out, it is checked by the return type.
228
+ const title = useEntityDisplay<string, M>("title", params);
229
+ const subtitle = useEntityDisplay<string, M>("subtitle", params);
230
+ const image = useEntityDisplay<string, M>("image", params);
231
+ const status = useEntityDisplay<string, M>("status", params);
232
+ const date = useEntityDisplay<Date | string | number, M>("date", params);
233
+ const tags = useEntityDisplay<string[] | string, M>("tags", params);
234
+
235
+ return useMemo(() => ({
236
+ title,
237
+ subtitle,
238
+ image,
239
+ status,
240
+ date,
241
+ tags,
242
+ loading: title.loading || subtitle.loading || image.loading
243
+ || status.loading || date.loading || tags.loading
244
+ }), [title, subtitle, image, status, date, tags]);
245
+ }
246
+
247
+ /**
248
+ * What the record is called — the one role asked for often enough on its own to
249
+ * deserve a name.
250
+ */
251
+ export function useEntityTitle<M extends Record<string, unknown>>(
252
+ params: UseEntityDisplayParams<M>
253
+ ): ResolvedDisplayValue<string> {
254
+ return useEntityDisplay<string, M>("title", params);
255
+ }
256
+
257
+ /** Re-exported so a caller can iterate roles without reaching into types. */
258
+ export { ENTITY_DISPLAY_ROLES };
@@ -1,60 +1,49 @@
1
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";
2
+ import type { Entity, EntityStatus } from "@rebasepro/types";
3
+ import { useEntityTitle } from "./useEntityDisplay";
6
4
 
7
5
  export interface UseEntityDisplayTitleParams<M extends Record<string, unknown>> {
8
6
  collection: AdminCollection<M>;
7
+ /** The record. Needed for a computed title; see {@link useEntityTitle}. */
8
+ entity?: Entity<M>;
9
+ /** Live form values, when they are ahead of the entity. */
9
10
  values?: Record<string, unknown>;
10
- entityId?: string | number;
11
11
  status: EntityStatus;
12
12
  }
13
13
 
14
14
  /**
15
- * The human name for a record, for the identity bar and the breadcrumb.
15
+ * The heading for a record: the identity bar and the breadcrumb.
16
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.
17
+ * Everything about *what a record is called* lives in {@link useEntityTitle}.
18
+ * What is left here is the heading's own two rules, which are presentation, not
19
+ * identity:
20
+ *
21
+ * The guard on `status` is the first. The title resolver returns the first
22
+ * candidate carrying a value, and on a brand new entity the only values are the
23
+ * property defaults — so creating a blog post opened a page headed **draft**,
24
+ * the default of its `status` enum. A record that does not exist yet has no
25
+ * name; it has an intent.
26
+ *
27
+ * The fallback is the second. A page needs a heading even when the record has
28
+ * nothing readable in it, and the honest one is what kind of thing it is.
22
29
  */
23
30
  export function useEntityDisplayTitle<M extends Record<string, unknown>>({
24
31
  collection,
32
+ entity,
25
33
  values,
26
- entityId,
27
34
  status
28
35
  }: UseEntityDisplayTitleParams<M>): string {
29
36
 
30
37
  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
38
  const singular = collection.singularName ?? collection.name;
45
39
 
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
- }
40
+ const { value: title } = useEntityTitle<M>({
41
+ collection,
42
+ entity,
43
+ values,
44
+ enabled: !isNew
45
+ });
58
46
 
59
- return singular;
47
+ if (isNew) return `New ${singular.toLowerCase()}`;
48
+ return title ?? singular;
60
49
  }
@@ -270,7 +270,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
270
270
 
271
271
  } else if (property.type === "boolean") {
272
272
  if (typeof value === "boolean") {
273
- content = <BooleanPreview value={value} size={size} property={property}/>;
273
+ content = <BooleanPreview value={value} size={size} property={property} hideLabel={props.hideLabel}/>;
274
274
  } else {
275
275
  content = buildWrongValueType(propertyKey, property.type, value);
276
276
  }
@@ -8,18 +8,21 @@ import { PreviewSize } from "../../types/components/PropertyPreviewProps";
8
8
  export function BooleanPreview({
9
9
  value,
10
10
  size,
11
- property
11
+ property,
12
+ hideLabel
12
13
  }: {
13
14
  value: boolean,
14
15
  size: PreviewSize,
15
16
  property: Property,
17
+ /** The caller already shows the name — see `PropertyPreviewProps.hideLabel`. */
18
+ hideLabel?: boolean,
16
19
  }): React.ReactElement {
17
20
  return <div className={"flex flex-row gap-2 items-center"}>
18
21
  <Checkbox checked={value}
19
22
  padding={false}
20
23
  size={size}
21
24
  color={"secondary"}/>
22
- {property.name && <span
25
+ {!hideLabel && property.name && <span
23
26
  className={cls("text-text-secondary dark:text-text-secondary-dark", size === "small" ? "text-sm" : "")}>{property.name}</span>}
24
27
  </div>;
25
28
  }
@@ -6,10 +6,10 @@ import { DetailViewBinding } from "../components/DetailViewBinding";
6
6
  import { useCallback, useEffect, useRef, useState } from "react";
7
7
  import { useNavigate } from "react-router";
8
8
  import { CollectionViewBinding } from "../components/CollectionViewBinding/CollectionViewBinding";
9
- import { NotFoundPage, useUserConfigurationPersistence, useComponentOverride, useNavigationBlocker } from "@rebasepro/app";
9
+ import { ErrorView, NotFoundPage, useUserConfigurationPersistence, useComponentOverride, useNavigationBlocker } from "@rebasepro/app";
10
10
  import { resolveOpenEntityMode, resolveViewMode } from "../util/view_mode";
11
11
  import { UnsavedChangesDialog } from "@rebasepro/app";
12
- import { CircularProgressCenter } from "@rebasepro/ui";
12
+ import { CenteredView, CircularProgressCenter } from "@rebasepro/ui";
13
13
  import { NavigationViewCollectionInternal, NavigationViewEntityCustomInternal, NavigationViewInternal } from "@rebasepro/app";
14
14
  import { getNavigationEntriesFromPath } from "@rebasepro/app";
15
15
  import { toArray } from "@rebasepro/utils";
@@ -94,7 +94,7 @@ export function RebaseRoute() {
94
94
  if (!collectionRegistry.initialised) {
95
95
  return <CircularProgressCenter/>;
96
96
  }
97
- return null;
97
+ return <UnresolvedCollectionView path={navigationEntries[0].slug}/>;
98
98
  }
99
99
  return <ResolvedCollectionView
100
100
  key={`collection_view_${collection.slug}`}
@@ -117,7 +117,7 @@ export function RebaseRoute() {
117
117
  if (!collectionRegistry.initialised) {
118
118
  return <CircularProgressCenter/>;
119
119
  }
120
- return null;
120
+ return <UnresolvedCollectionView path={firstEntry.slug}/>;
121
121
  }
122
122
  return <ResolvedCollectionView
123
123
  key={`collection_view_${collection.slug}`}
@@ -233,6 +233,7 @@ function EntityFullScreenRoute({
233
233
  const urlController = useUrlController();
234
234
  const navigate = useNavigate();
235
235
  const location = useLocation();
236
+ const userConfigPersistence = useUserConfigurationPersistence();
236
237
 
237
238
  // defaultValues may be carried via location.state when openNewDocument() is called
238
239
  // for full-screen mode. We read it once on mount — after that, the form owns its state.
@@ -340,6 +341,30 @@ function EntityFullScreenRoute({
340
341
  // Determine if this is a detail-view-first collection showing the view page
341
342
  const isDetailMode = collection.defaultEntityAction === "view" && !isNew && !isCopy && entityId && !isEditRoute;
342
343
 
344
+ // A record is full screen either because its collection has no list to show
345
+ // beside it, or because the user folded that list away. Only the second is
346
+ // reversible, and it is reversible by dropping `#full` alone: this is the
347
+ // same URL the split route reads, so the same conditions decide it.
348
+ const splitListAvailable = !isNew && !isCopy && entityId !== undefined &&
349
+ navigationEntries[0]?.type === "collection" &&
350
+ (navigationEntries.length === 2 || navigationEntries.length === 3) &&
351
+ resolveOpenEntityMode({
352
+ collection,
353
+ viewMode: resolveViewMode({
354
+ collection,
355
+ search: location.search,
356
+ savedViewMode: userConfigPersistence?.getCollectionConfig(collection.slug)?.defaultViewMode as ViewMode | undefined
357
+ })
358
+ }) === "split";
359
+
360
+ const showList = splitListAvailable
361
+ ? () => navigate({
362
+ pathname,
363
+ search: location.search,
364
+ hash: ""
365
+ })
366
+ : undefined;
367
+
343
368
  if (isDetailMode) {
344
369
  return <>
345
370
  <DetailViewBinding
@@ -349,6 +374,7 @@ function EntityFullScreenRoute({
349
374
  layout={"full_screen"}
350
375
  path={collectionPath}
351
376
  selectedTab={selectedTab ?? undefined}
377
+ onShowList={showList}
352
378
  onEditClick={() => {
353
379
  const editUrl = urlController.buildUrlCollectionPath(`${collectionPath}/${entityId}`) + "/edit";
354
380
  navigate(editUrl + hash);
@@ -377,6 +403,7 @@ function EntityFullScreenRoute({
377
403
  path={collectionPath}
378
404
  copy={isCopy}
379
405
  selectedTab={selectedTab ?? undefined}
406
+ onShowList={showList}
380
407
  defaultValues={isNew ? defaultValues : undefined}
381
408
  onValuesModified={(modified) => blocked.current = modified}
382
409
  navigateBack={() => {
@@ -415,3 +442,34 @@ function EntityFullScreenRoute({
415
442
  body={"You have unsaved changes in this entity."}/>
416
443
  </>;
417
444
  }
445
+
446
+ /**
447
+ * Shown when a URL names a collection the registry cannot resolve.
448
+ *
449
+ * This used to be `return null`, which renders an empty pane inside the app
450
+ * chrome: the sidebar, the nav highlight and the breadcrumb all still work,
451
+ * because those read the collections array directly, while the view itself is
452
+ * blank. Nothing is thrown, and the only trace is a `console.debug` — so the
453
+ * panel looks like it lost its data rather than like it failed to find the
454
+ * collection, and there is no string to search for.
455
+ *
456
+ * A slug containing slashes went unresolvable this way and cost an afternoon to
457
+ * find. The lookup is fixed; this is the part that made it expensive.
458
+ */
459
+ function UnresolvedCollectionView({ path }: { path: string }) {
460
+ // Warn, not debug: this is a route that renders nothing useful, and it
461
+ // should be visible at the console's default level.
462
+ useEffect(() => {
463
+ console.warn(
464
+ `[rebase] No collection is registered for "${path}", so this route has nothing to render. ` +
465
+ "Check that the collection is in the collections array and that its slug matches the URL."
466
+ );
467
+ }, [path]);
468
+
469
+ return <CenteredView>
470
+ <ErrorView
471
+ title={"Collection not found"}
472
+ error={`Nothing is registered for "${path}".`}
473
+ tooltip={"The URL names a collection the panel does not know about. It may have been renamed or removed from the collections array, or the slug may not match the path."}/>
474
+ </CenteredView>;
475
+ }
@@ -61,4 +61,12 @@ export interface PropertyPreviewProps<P extends Property | Property, CustomProps
61
61
  */
62
62
  textOnly?: boolean;
63
63
 
64
+ /**
65
+ * The caller has already labelled this value, so the preview must not
66
+ * repeat the property name. A boolean is the one that does: it renders its
67
+ * name beside the checkbox, which reads as a caption in a table cell and as
68
+ * a stutter under a field label — "VIP" over a checkbox saying "VIP".
69
+ */
70
+ hideLabel?: boolean;
71
+
64
72
  }