@rebasepro/admin 0.13.0 → 0.13.1-canary.g249daa1

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 (154) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-DgfCYDmF.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-DgfCYDmF.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-CCrruunW.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-CCrruunW.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-C_EwDSi1.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-C_EwDSi1.js.map +1 -0
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/EntityCardBinding.d.ts +3 -1
  12. package/dist/components/CollectionViewBinding/SearchExplanation.d.ts +48 -0
  13. package/dist/components/CollectionViewBinding/SearchHighlight.d.ts +92 -0
  14. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  15. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  16. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  17. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  18. package/dist/components/DetailViewBinding.d.ts +15 -0
  19. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  20. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  21. package/dist/components/EditViewBinding.d.ts +30 -1
  22. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  23. package/dist/components/EntityIdentityBar.d.ts +43 -1
  24. package/dist/components/EntityViewBinding.d.ts +60 -6
  25. package/dist/components/RebaseLayout.d.ts +1 -1
  26. package/dist/components/RebaseShell.d.ts +1 -1
  27. package/dist/components/RelationSelector.d.ts +8 -1
  28. package/dist/components/UserSelector.d.ts +8 -1
  29. package/dist/components/app/Scaffold.d.ts +9 -3
  30. package/dist/components/index.d.ts +2 -0
  31. package/dist/{export-J79rMoNj.js → export-CV-qQfxK.js} +2 -2
  32. package/dist/{export-J79rMoNj.js.map → export-CV-qQfxK.js.map} +1 -1
  33. package/dist/form/components/FieldBlock.d.ts +58 -4
  34. package/dist/form/components/FormRail.d.ts +13 -0
  35. package/dist/form/components/FormSections.d.ts +15 -1
  36. package/dist/form/components/useRailVisible.d.ts +5 -4
  37. package/dist/form/form_utils.d.ts +38 -1
  38. package/dist/{history-D7L_JlLO.js → history-By7QT3V0.js} +4 -4
  39. package/dist/{history-D7L_JlLO.js.map → history-By7QT3V0.js.map} +1 -1
  40. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  41. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  42. package/dist/{import-BvYl--U4.js → import-CYbvYa_x.js} +3 -3
  43. package/dist/{import-BvYl--U4.js.map → import-CYbvYa_x.js.map} +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +152 -57
  46. package/dist/index.js.map +1 -1
  47. package/dist/preview/compact_text.d.ts +23 -0
  48. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  49. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  51. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  52. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  53. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  54. package/dist/routes/RebaseRoute.d.ts +1 -1
  55. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  56. package/dist/util/number_format.d.ts +14 -0
  57. package/dist/util/previews.d.ts +9 -2
  58. package/dist/util/view_constants.d.ts +24 -0
  59. package/dist/util/view_mode.d.ts +18 -0
  60. package/dist/{util-GiwPgxnL.js → util-B8hurkxt.js} +3569 -1692
  61. package/dist/util-B8hurkxt.js.map +1 -0
  62. package/package.json +9 -9
  63. package/src/collection_editor/serializable_types.ts +1 -0
  64. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  65. package/src/components/AdditionalFieldValue.tsx +68 -0
  66. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  67. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  68. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  69. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  70. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  71. package/src/components/CollectionViewBinding/CollectionCardViewBinding.tsx +2 -1
  72. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +822 -354
  73. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  74. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +51 -12
  75. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +51 -39
  76. package/src/components/CollectionViewBinding/SearchExplanation.tsx +106 -0
  77. package/src/components/CollectionViewBinding/SearchHighlight.tsx +285 -0
  78. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  79. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  80. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  81. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  82. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  83. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  84. package/src/components/CollectionViewBinding/utils.ts +7 -4
  85. package/src/components/DefaultDrawer.tsx +19 -23
  86. package/src/components/DetailViewBinding.tsx +109 -62
  87. package/src/components/DrawerNavigationGroup.tsx +16 -39
  88. package/src/components/DrawerNavigationItem.tsx +44 -16
  89. package/src/components/EditViewBinding.tsx +233 -50
  90. package/src/components/EntityDisplayHeader.tsx +170 -0
  91. package/src/components/EntityIdentityBar.tsx +163 -23
  92. package/src/components/EntityInspector.tsx +24 -6
  93. package/src/components/EntityPreviewBinding.tsx +58 -18
  94. package/src/components/EntityViewBinding.tsx +344 -41
  95. package/src/components/InlineEntityPreview.tsx +10 -10
  96. package/src/components/RebaseLayout.tsx +2 -2
  97. package/src/components/RebaseShell.tsx +2 -2
  98. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  99. package/src/components/RelationSelector.tsx +23 -9
  100. package/src/components/SideDialogs.tsx +15 -3
  101. package/src/components/SidePanelBinding.tsx +122 -66
  102. package/src/components/UserSelector.tsx +20 -8
  103. package/src/components/app/Scaffold.tsx +112 -10
  104. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  105. package/src/components/index.ts +3 -0
  106. package/src/data_import/utils/data.ts +9 -1
  107. package/src/form/EntityForm.tsx +74 -53
  108. package/src/form/EntityFormBinding.tsx +24 -11
  109. package/src/form/PropertyFieldBinding.tsx +2 -2
  110. package/src/form/components/FieldBlock.tsx +80 -10
  111. package/src/form/components/FormRail.tsx +19 -2
  112. package/src/form/components/FormSections.tsx +32 -5
  113. package/src/form/components/StorageUploadProgress.tsx +9 -3
  114. package/src/form/components/useRailVisible.tsx +5 -4
  115. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  116. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  117. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  118. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  119. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  120. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  121. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  122. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  123. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  124. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  125. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  126. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  127. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  128. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  129. package/src/form/form_utils.ts +78 -0
  130. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  131. package/src/hooks/useEntityDisplay.tsx +262 -0
  132. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  133. package/src/index.ts +9 -1
  134. package/src/preview/PropertyPreview.tsx +25 -5
  135. package/src/preview/compact_text.ts +68 -0
  136. package/src/preview/components/BooleanPreview.tsx +5 -2
  137. package/src/preview/components/StorageThumbnail.tsx +5 -2
  138. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  139. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  140. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  141. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  142. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  143. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  144. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  145. package/src/routes/RebaseRoute.tsx +62 -4
  146. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  147. package/src/util/navigation_utils.ts +4 -2
  148. package/src/util/number_format.ts +46 -0
  149. package/src/util/previews.ts +8 -55
  150. package/src/util/property_utils.tsx +6 -1
  151. package/src/util/view_constants.ts +27 -0
  152. package/src/util/view_mode.ts +36 -0
  153. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  154. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -0,0 +1,262 @@
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(() => {
187
+ // Unreachable, and kept only so it stays that way: `resolve` records a
188
+ // failure as "nothing" and returns a RESOLVED promise, which is why
189
+ // the warning that used to live here could never run — see the note on
190
+ // `EntityDisplayCache.resolve`. The cache reports the failure itself
191
+ // now, once per key. This exists so that if `resolve` ever starts
192
+ // rejecting, it surfaces as a caught no-op rather than an unhandled
193
+ // rejection during render.
194
+ });
195
+ }, [cache, canResolve, key, resolver, entity, context, role]);
196
+
197
+ /* ---- the answer ------------------------------------------------------ */
198
+
199
+ // A resolver that produced nothing falls back to the derived value rather
200
+ // than to blank: it said this record has no computed answer for the role,
201
+ // not that the role should be empty.
202
+ const computed = canResolve && resolved !== null ? resolved as T | undefined : undefined;
203
+ const value = computed ?? derived;
204
+
205
+ return useMemo(() => ({
206
+ value,
207
+ loading,
208
+ source: collection && propertyKey && value === derived && derived !== undefined
209
+ ? {
210
+ key: propertyKey,
211
+ property: getPropertyInPath(collection.properties, propertyKey),
212
+ value: rawValue
213
+ }
214
+ : undefined
215
+ }), [value, loading, propertyKey, derived, collection, rawValue]);
216
+ }
217
+
218
+ /**
219
+ * Every display role of a record at once — what a list row, a card or a board
220
+ * card needs, in one call rather than six.
221
+ *
222
+ * The role list is iterated from {@link ENTITY_DISPLAY_ROLES} so the hook order
223
+ * is fixed and adding a role does not mean editing every caller.
224
+ */
225
+ export function useEntityDisplayValues<M extends Record<string, unknown>>(
226
+ params: UseEntityDisplayParams<M>
227
+ ): Record<EntityDisplayRole, ResolvedDisplayValue> & { loading: boolean } {
228
+
229
+ // Not a loop over `useEntityDisplay`: the number of hooks a component calls
230
+ // has to be fixed at compile time, and a role added to the array later would
231
+ // silently change it. Written out, it is checked by the return type.
232
+ const title = useEntityDisplay<string, M>("title", params);
233
+ const subtitle = useEntityDisplay<string, M>("subtitle", params);
234
+ const image = useEntityDisplay<string, M>("image", params);
235
+ const status = useEntityDisplay<string, M>("status", params);
236
+ const date = useEntityDisplay<Date | string | number, M>("date", params);
237
+ const tags = useEntityDisplay<string[] | string, M>("tags", params);
238
+
239
+ return useMemo(() => ({
240
+ title,
241
+ subtitle,
242
+ image,
243
+ status,
244
+ date,
245
+ tags,
246
+ loading: title.loading || subtitle.loading || image.loading
247
+ || status.loading || date.loading || tags.loading
248
+ }), [title, subtitle, image, status, date, tags]);
249
+ }
250
+
251
+ /**
252
+ * What the record is called — the one role asked for often enough on its own to
253
+ * deserve a name.
254
+ */
255
+ export function useEntityTitle<M extends Record<string, unknown>>(
256
+ params: UseEntityDisplayParams<M>
257
+ ): ResolvedDisplayValue<string> {
258
+ return useEntityDisplay<string, M>("title", params);
259
+ }
260
+
261
+ /** Re-exported so a caller can iterate roles without reaching into types. */
262
+ 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
  }
package/src/index.ts CHANGED
@@ -44,6 +44,12 @@ export {
44
44
  Drawer,
45
45
  DefaultDrawer,
46
46
  DrawerFooterActions,
47
+ // The two rows the `Shell.DrawerNavigation*` overrides replace. Public so an
48
+ // app can wrap the stock ones — pass different props, add a context — instead of
49
+ // reimplementing a nav row and drifting from the framework's hover, active and
50
+ // tooltip behaviour on the next release.
51
+ DrawerNavigationItem,
52
+ DrawerNavigationGroup,
47
53
  AdminModeSyncer,
48
54
  // ContentHomePage is lazy-loaded — not re-exported here
49
55
  RebaseAdmin,
@@ -60,7 +66,9 @@ export type {
60
66
  EntityViewBindingProps,
61
67
  SelectionProps,
62
68
  SelectableTableProps,
63
- CollectionPanelProps
69
+ CollectionPanelProps,
70
+ DrawerNavigationGroupProps,
71
+ DrawerNavigationItemProps
64
72
  } from "./components";
65
73
 
66
74
  export * from "./hooks";
@@ -4,7 +4,7 @@ import { deepEqual as equal } from "fast-equals"
4
4
 
5
5
  import { EntityReference, EntityRelation } from "@rebasepro/types";
6
6
  import type { PropertyPreviewProps } from "../types/components/PropertyPreviewProps";
7
- import { resolveProperty, normalizeToEntityRelation } from "@rebasepro/common";
7
+ import { resolveProperty, normalizeToEntityRelation, getRelationTargetPath } from "@rebasepro/common";
8
8
  import { useAuthController, useCustomizationController, resolveComponentRef } from "@rebasepro/app";
9
9
  import { EmptyValue } from "./components/EmptyValue";
10
10
  import { UrlComponentPreview } from "./components/UrlComponentPreview";
@@ -27,6 +27,7 @@ import { ErrorView } from "@rebasepro/app";
27
27
  import { RelationPreview } from "./components/RelationPreview";
28
28
  import { InlineEntityListPreview } from "./components/InlineEntityListPreview";
29
29
  import { useIsNestedEntityPreview } from "../components/EntityPreviewNesting";
30
+ import { markdownToPlainText } from "./compact_text";
30
31
  import { UserPreview } from "./components/UserPreview";
31
32
 
32
33
  /**
@@ -50,6 +51,13 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
50
51
  fill
51
52
  } = props;
52
53
 
54
+ // A preview rendered inside an entity preview fills one line of a card and
55
+ // has no room to grow — see {@link PropertyPreviewProps.compact}. The card
56
+ // is the thing that knows this, and it says so by opening a nesting
57
+ // boundary, so the depth is the signal rather than a prop every caller
58
+ // between here and there would have to forward.
59
+ const compact = props.compact ?? (nested || Boolean(props.textOnly));
60
+
53
61
  const property = resolveProperty({
54
62
  propertyKey,
55
63
  property: inputProperty,
@@ -103,7 +111,9 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
103
111
  fill={fill}
104
112
  previewType={stringProperty.admin?.urlPreview}/>;
105
113
  } else if (stringProperty.admin?.markdown) {
106
- content = <Markdown source={value} size={"small"}/>;
114
+ content = compact
115
+ ? <span className={"text-sm"}>{markdownToPlainText(value)}</span>
116
+ : <Markdown source={value} size={"small"}/>;
107
117
  } else if (stringProperty.userSelect) {
108
118
  content = <UserPreview
109
119
  value={value}
@@ -113,6 +123,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
113
123
  />;
114
124
  } else {
115
125
  content = <StringPropertyPreview {...props}
126
+ compact={compact}
116
127
  property={stringProperty}
117
128
  value={value}/>;
118
129
  }
@@ -129,6 +140,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
129
140
  if (arrayProperty.of) {
130
141
  if (Array.isArray(arrayProperty.of)) {
131
142
  content = <ArrayPropertyPreview {...props}
143
+ compact={compact}
132
144
  value={value}
133
145
  property={arrayProperty}/>;
134
146
  } else if (arrayProperty.of.type === "reference") {
@@ -153,6 +165,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
153
165
  } else {
154
166
  content = <ArrayOfStringsPreview
155
167
  {...props}
168
+ compact={compact}
156
169
  property={property as ArrayProperty}
157
170
  value={value as string[]}/>;
158
171
  }
@@ -163,11 +176,13 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
163
176
  property={property as ArrayProperty}/>;
164
177
  } else {
165
178
  content = <ArrayPropertyPreview {...props}
179
+ compact={compact}
166
180
  value={value}
167
181
  property={property as ArrayProperty}/>;
168
182
  }
169
183
  } else if (arrayProperty.oneOf) {
170
184
  content = <ArrayOneOfPreview {...props}
185
+ compact={compact}
171
186
  value={value}
172
187
  property={property as ArrayProperty}/>;
173
188
  }
@@ -178,6 +193,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
178
193
  if (typeof value === "object") {
179
194
  content =
180
195
  <MapPropertyPreview {...props}
196
+ compact={compact}
181
197
  value={value as Record<string, any>}
182
198
  property={property as MapProperty}/>;
183
199
  } else {
@@ -218,12 +234,16 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
218
234
  }
219
235
 
220
236
  } else if (property.type === "relation") {
237
+ // A relation column arrives either hydrated or as the bare foreign key,
238
+ // depending on the fetch path. The declared target resolves the latter;
239
+ // see `normalizeToEntityRelation`.
240
+ const relationTargetPath = getRelationTargetPath(property);
221
241
  if (!value) {
222
242
  content = <EmptyValue/>;
223
243
  } else if (Array.isArray(value)) {
224
244
  // Many-cardinality relation: value is an array of EntityRelation (or plain objects)
225
245
  const relations = (value as unknown[])
226
- .map(item => normalizeToEntityRelation(item, "relation"))
246
+ .map(item => normalizeToEntityRelation(item, "relation", relationTargetPath))
227
247
  .filter(Boolean) as EntityRelation[];
228
248
  const renderRelation = (entityRelation: EntityRelation, index: number) => <RelationPreview
229
249
  key={`preview_rel_${propertyKey}_${index}`}
@@ -252,7 +272,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
252
272
  );
253
273
  } else {
254
274
  // Single-cardinality relation
255
- const relationValue = normalizeToEntityRelation(value, "relation");
275
+ const relationValue = normalizeToEntityRelation(value, "relation", relationTargetPath);
256
276
  if (relationValue) {
257
277
  content = <RelationPreview
258
278
  disabled={!property.relation}
@@ -270,7 +290,7 @@ export const PropertyPreview = React.memo(function PropertyPreview<P extends Pro
270
290
 
271
291
  } else if (property.type === "boolean") {
272
292
  if (typeof value === "boolean") {
273
- content = <BooleanPreview value={value} size={size} property={property}/>;
293
+ content = <BooleanPreview value={value} size={size} property={property} hideLabel={props.hideLabel}/>;
274
294
  } else {
275
295
  content = buildWrongValueType(propertyKey, property.type, value);
276
296
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Turning block content into the one line a compact preview has room for.
3
+ *
4
+ * A preview slot inside a card is a single line that cannot grow. Rendering
5
+ * Markdown there does not produce a small version of the document — it
6
+ * produces the whole document, headings and lists included, inside a 44px box.
7
+ * These helpers take the opening line instead, which is what a person reads
8
+ * off a card anyway.
9
+ */
10
+
11
+ /** Longer than any card line; the cap only keeps the DOM node small. */
12
+ const DEFAULT_MAX_LENGTH = 280;
13
+
14
+ const FENCED_CODE = /```[\s\S]*?```|~~~[\s\S]*?~~~/g;
15
+ const HTML_TAG = /<[^>]*>/g;
16
+ const IMAGE = /!\[([^\]]*)\]\([^)]*\)/g;
17
+ const LINK = /\[([^\]]*)\]\([^)]*\)/g;
18
+ const REFERENCE_LINK = /\[([^\]]*)\]\[[^\]]*\]/g;
19
+ const HEADING = /^\s{0,3}#{1,6}\s+/gm;
20
+ const BLOCKQUOTE = /^\s{0,3}>\s?/gm;
21
+ const LIST_MARKER = /^\s*(?:[-*+]|\d+[.)])\s+/gm;
22
+ const THEMATIC_BREAK = /^\s{0,3}(?:[-*_]\s*){3,}$/gm;
23
+ const TABLE_PIPE = /[|]/g;
24
+ const EMPHASIS = /(\*\*|__|\*|_|~~|`)/g;
25
+
26
+ /**
27
+ * The readable text of a Markdown document, as one line.
28
+ *
29
+ * Deliberately a lexical strip rather than a parse: this runs once per preview
30
+ * cell in a table that can hold hundreds, and the result is truncated to a line
31
+ * regardless, so the cost of being exactly right is not worth paying. Anything
32
+ * it fails to strip degrades to a stray `*`, never to a broken layout.
33
+ */
34
+ export function markdownToPlainText(source: string, maxLength: number = DEFAULT_MAX_LENGTH): string {
35
+ if (!source) return "";
36
+
37
+ const text = source
38
+ .replace(FENCED_CODE, " ")
39
+ .replace(IMAGE, "$1")
40
+ .replace(LINK, "$1")
41
+ .replace(REFERENCE_LINK, "$1")
42
+ .replace(THEMATIC_BREAK, " ")
43
+ .replace(HEADING, "")
44
+ .replace(BLOCKQUOTE, "")
45
+ .replace(LIST_MARKER, "")
46
+ .replace(HTML_TAG, "")
47
+ .replace(TABLE_PIPE, " ")
48
+ .replace(EMPHASIS, "");
49
+
50
+ return collapseToSingleLine(text, maxLength);
51
+ }
52
+
53
+ /**
54
+ * Whitespace — including the newlines that would otherwise let a `truncate`
55
+ * container grow — collapsed to single spaces, capped to `maxLength`.
56
+ */
57
+ export function collapseToSingleLine(source: string, maxLength: number = DEFAULT_MAX_LENGTH): string {
58
+ if (!source) return "";
59
+ const collapsed = source.replace(/\s+/g, " ").trim();
60
+ if (collapsed.length <= maxLength) return collapsed;
61
+ // Cut at a word boundary when there is one nearby, so the line does not end
62
+ // mid-word for the sake of ten characters.
63
+ const cut = collapsed.slice(0, maxLength);
64
+ // The cut already landed between words: keep the last one whole.
65
+ if (collapsed[maxLength] === " ") return cut.trimEnd() + "…";
66
+ const lastSpace = cut.lastIndexOf(" ");
67
+ return (lastSpace > maxLength * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
68
+ }
@@ -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
  }
@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
2
2
 
3
3
  import { renderSkeletonImageThumbnail } from "../property_previews/SkeletonPropertyComponent";
4
4
  import { UrlComponentPreview } from "./UrlComponentPreview";
5
- import { ErrorView, useStorageSource, useStorageSources } from "@rebasepro/app";
5
+ import { ErrorView, useStorageSource, useStorageSources, useTranslation } from "@rebasepro/app";
6
6
  import { resolveStorageSource } from "@rebasepro/common";
7
7
  import { DownloadConfig, FileType } from "@rebasepro/types";
8
8
  import type { PreviewSize } from "../../types/components/PropertyPreviewProps";
@@ -42,6 +42,7 @@ export function StorageThumbnailInternal({
42
42
  storageSourceKey
43
43
  }: StorageThumbnailProps) {
44
44
 
45
+ const { t } = useTranslation();
45
46
  const [error, setError] = React.useState<Error | undefined>(undefined);
46
47
  const defaultStorage = useStorageSource();
47
48
  const storageSources = useStorageSources();
@@ -85,7 +86,9 @@ export function StorageThumbnailInternal({
85
86
  : (filetype?.startsWith("audio") ? "audio" : "file"));
86
87
 
87
88
  if (downloadConfig?.fileNotFound)
88
- return <ErrorView error={"File not found"}></ErrorView>
89
+ // `file_not_found` is translated into seven locales and this rendered
90
+ // the English literal, so a German panel said "File not found".
91
+ return <ErrorView error={t("file_not_found")}></ErrorView>
89
92
 
90
93
  return downloadConfig?.url
91
94
  ? <UrlComponentPreview previewType={previewType}
@@ -1,7 +1,7 @@
1
1
  import type { ArrayProperty, RelationProperty } from "@rebasepro/types";
2
2
  import { EntityRelation } from "@rebasepro/types";
3
3
  import type { PropertyPreviewProps } from "../../types/components/PropertyPreviewProps";
4
- import { normalizeToEntityRelation } from "@rebasepro/common";
4
+ import { normalizeToEntityRelation, getRelationTargetPath } from "@rebasepro/common";
5
5
  import { RelationPreview } from "../components/RelationPreview";
6
6
  import { useIsNestedEntityPreview } from "../../components/EntityPreviewNesting";
7
7
  import { InlineEntityListPreview } from "../components/InlineEntityListPreview";
@@ -27,11 +27,14 @@ export function ArrayOfRelationsPreview({
27
27
  throw Error("Picked wrong preview component ArrayOfRelationsPreview");
28
28
 
29
29
  const ofProperty = property.of as RelationProperty;
30
+ // Elements arrive hydrated or as bare foreign keys depending on the fetch
31
+ // path; the declared target resolves the latter rather than dropping them.
32
+ const relationTargetPath = getRelationTargetPath(ofProperty);
30
33
 
31
34
  // See ArrayOfReferencesPreview: nested lists stay on one wrapping line.
32
35
  if (nested || textOnly) {
33
36
  const relations = ((value ?? []) as unknown[])
34
- .map(relation => normalizeToEntityRelation(relation))
37
+ .map(relation => normalizeToEntityRelation(relation, "relation", relationTargetPath))
35
38
  .filter((relation): relation is EntityRelation => Boolean(relation));
36
39
  return <InlineEntityListPreview
37
40
  items={relations}
@@ -51,7 +54,7 @@ export function ArrayOfRelationsPreview({
51
54
  <div className="flex flex-col w-full gap-0.5">
52
55
  {value ?
53
56
  (value as unknown[]).map((relation: unknown, index: number) => {
54
- const entityRelation = normalizeToEntityRelation(relation);
57
+ const entityRelation = normalizeToEntityRelation(relation, "relation", relationTargetPath);
55
58
 
56
59
  if (!entityRelation) return null;
57
60