@rebasepro/admin 0.10.0 → 0.10.1-canary.14e53ae

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 (38) hide show
  1. package/dist/{CollectionEditorDialog-BOlKGGg7.js → CollectionEditorDialog-BqVYCTmU.js} +3 -3
  2. package/dist/{CollectionEditorDialog-BOlKGGg7.js.map → CollectionEditorDialog-BqVYCTmU.js.map} +1 -1
  3. package/dist/{PropertyEditView-i51tFOfM.js → PropertyEditView-CIX85COE.js} +2 -2
  4. package/dist/{PropertyEditView-i51tFOfM.js.map → PropertyEditView-CIX85COE.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-Dh4W53PU.js → RouterCollectionsStudioView-_1RsFWS0.js} +4 -4
  6. package/dist/{RouterCollectionsStudioView-Dh4W53PU.js.map → RouterCollectionsStudioView-_1RsFWS0.js.map} +1 -1
  7. package/dist/collection_editor_ui.js +4 -4
  8. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +2 -0
  9. package/dist/{export-BM6HPujv.js → export-CIi-Zrsn.js} +2 -2
  10. package/dist/{export-BM6HPujv.js.map → export-CIi-Zrsn.js.map} +1 -1
  11. package/dist/{history-CtIPJxvQ.js → history-BojXI3uS.js} +2 -2
  12. package/dist/{history-CtIPJxvQ.js.map → history-BojXI3uS.js.map} +1 -1
  13. package/dist/hooks/index.d.ts +1 -0
  14. package/dist/hooks/useResolvedUsers.d.ts +8 -0
  15. package/dist/{import-DzQnT03v.js → import-SVoKfekq.js} +2 -2
  16. package/dist/{import-DzQnT03v.js.map → import-SVoKfekq.js.map} +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +7 -7
  19. package/dist/preview/components/UserPreview.d.ts +3 -2
  20. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  21. package/dist/util/previews.d.ts +18 -0
  22. package/dist/{util-XFwQ9FEc.js → util-aJuPuy_1.js} +198 -65
  23. package/dist/util-aJuPuy_1.js.map +1 -0
  24. package/package.json +8 -8
  25. package/src/components/CollectionViewBinding/usePreviewSlots.ts +41 -10
  26. package/src/components/DetailViewBinding.tsx +12 -5
  27. package/src/components/EntityPreviewBinding.tsx +2 -2
  28. package/src/components/SideDialogs.tsx +1 -1
  29. package/src/form/EntityForm.tsx +12 -5
  30. package/src/hooks/index.ts +1 -0
  31. package/src/hooks/useResolvedUsers.ts +143 -0
  32. package/src/index.ts +2 -1
  33. package/src/preview/components/ReferencePreview.tsx +2 -2
  34. package/src/preview/components/RelationPreview.tsx +2 -2
  35. package/src/preview/components/UserPreview.tsx +13 -4
  36. package/src/preview/property_previews/StringPropertyPreview.tsx +15 -9
  37. package/src/util/previews.ts +35 -46
  38. package/dist/util-XFwQ9FEc.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/admin",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.14e53ae",
5
5
  "description": "Rebase CMS — content management views, forms, and routing",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -83,13 +83,13 @@
83
83
  "react-dropzone": "^15.0.0",
84
84
  "react-use-measure": "^2.1.7",
85
85
  "zod": "^4.4.3",
86
- "@rebasepro/common": "0.10.0",
87
- "@rebasepro/app": "0.10.0",
88
- "@rebasepro/forms": "0.10.0",
89
- "@rebasepro/inference": "0.10.0",
90
- "@rebasepro/types": "0.10.0",
91
- "@rebasepro/ui": "0.10.0",
92
- "@rebasepro/utils": "0.10.0"
86
+ "@rebasepro/common": "0.10.1-canary.14e53ae",
87
+ "@rebasepro/forms": "0.10.1-canary.14e53ae",
88
+ "@rebasepro/types": "0.10.1-canary.14e53ae",
89
+ "@rebasepro/app": "0.10.1-canary.14e53ae",
90
+ "@rebasepro/inference": "0.10.1-canary.14e53ae",
91
+ "@rebasepro/utils": "0.10.1-canary.14e53ae",
92
+ "@rebasepro/ui": "0.10.1-canary.14e53ae"
93
93
  },
94
94
  "peerDependencies": {
95
95
  "react": ">=19.0.0",
@@ -1,9 +1,9 @@
1
1
  import { useMemo } from "react";
2
2
  import type { CollectionConfig, Property, RelationProperty } from "@rebasepro/types";
3
3
  import type { Entity, PropertyConfig, EntityRelation } from "@rebasepro/types";
4
- import { getEntityImagePreviewPropertyKey } from "@rebasepro/common";
4
+ import { getEntityImagePreviewPropertyKey, getTitlePropertyCandidates, looksLikeIdentifierValue } from "@rebasepro/common";
5
5
  import { getEntityFromCache } from "@rebasepro/app";
6
- import { getEntityTitlePropertyKey, getEntityPreviewKeys } from "../../util/previews";
6
+ import { getEntityPreviewKeys } from "../../util/previews";
7
7
  import { getValueInPath } from "@rebasepro/utils";
8
8
  import type { AuthController } from "@rebasepro/types";
9
9
  import { ChipColorScheme, CHIP_COLORS } from "@rebasepro/ui";
@@ -90,6 +90,8 @@ export interface EntityPreviewSlots {
90
90
  */
91
91
  export interface CollectionSlotKeys {
92
92
  titleKey: string | undefined;
93
+ /** Every property that could act as the title, best first (see `getTitlePropertyCandidates`) */
94
+ titleKeyCandidates: string[];
93
95
  imageKey: string | undefined;
94
96
  subtitleKey: string | undefined;
95
97
  relationKeys: string[];
@@ -106,7 +108,8 @@ export function resolveCollectionSlotKeys(
106
108
  authController: AuthController,
107
109
  propertyConfigs: Record<string, PropertyConfig>
108
110
  ): CollectionSlotKeys {
109
- const titleKey = getEntityTitlePropertyKey(collection, propertyConfigs);
111
+ const titleKeyCandidates = getTitlePropertyCandidates(collection);
112
+ const titleKey = titleKeyCandidates[0];
110
113
  const imageKey = getEntityImagePreviewPropertyKey(collection);
111
114
 
112
115
  // Status: first string-enum that isn't the title
@@ -201,6 +204,7 @@ export function resolveCollectionSlotKeys(
201
204
  const subtitleKey = sortedPreviewKeys.length > 0 ? sortedPreviewKeys[0] : undefined;
202
205
 
203
206
  return { titleKey,
207
+ titleKeyCandidates,
204
208
  imageKey,
205
209
  subtitleKey,
206
210
  relationKeys,
@@ -242,6 +246,30 @@ function resolveImageSlot(
242
246
  };
243
247
  }
244
248
 
249
+ // ── Title resolution (entity-level) ───────────────────────────────────
250
+
251
+ /**
252
+ * Walk the collection's ranked title candidates and return the first one that
253
+ * holds something a person can read for this entity. Candidates are already
254
+ * ordered and free of identifier properties; this only rejects values —
255
+ * empties, and strings that turn out to be an id at runtime.
256
+ */
257
+ function resolveEntityTitleKey(
258
+ entity: Entity<Record<string, unknown>>,
259
+ candidates: string[]
260
+ ): string | undefined {
261
+ for (const key of candidates) {
262
+ const value = getValueInPath(entity.values, key);
263
+ if (value === undefined || value === null || value === "") continue;
264
+ if (typeof value === "string") {
265
+ if (looksLikeIdentifierValue(value)) continue;
266
+ if (value === String(entity.id)) continue;
267
+ }
268
+ return key;
269
+ }
270
+ return candidates[0];
271
+ }
272
+
245
273
  // ── Date formatting ───────────────────────────────────────────────────
246
274
 
247
275
  function formatDateValue(value: unknown): string {
@@ -299,24 +327,27 @@ export function resolveEntitySlots(
299
327
  collection: CollectionConfig<Record<string, unknown>>,
300
328
  slotKeys: CollectionSlotKeys
301
329
  ): EntityPreviewSlots {
302
- const { titleKey, imageKey, subtitleKey, relationKeys, statusKey, dateKey } = slotKeys;
330
+ const { titleKey, titleKeyCandidates, imageKey, subtitleKey, relationKeys, statusKey, dateKey } = slotKeys;
303
331
 
304
332
  // Image
305
333
  const image = imageKey ? resolveImageSlot(collection, imageKey, entity) : undefined;
306
334
 
307
- // Title
335
+ // Title — the best-ranked candidate that carries a readable value for THIS
336
+ // entity, so a row whose name is empty (or whose column happens to hold a
337
+ // UUID) falls through to the next candidate instead of showing an id.
338
+ const resolvedTitleKey = resolveEntityTitleKey(entity, titleKeyCandidates ?? (titleKey ? [titleKey] : []));
308
339
  let title: PreviewSlot | undefined;
309
- if (titleKey) {
310
- const prop = collection.properties[titleKey] as Property | undefined;
311
- const val = getValueInPath(entity.values, titleKey);
340
+ if (resolvedTitleKey) {
341
+ const prop = collection.properties[resolvedTitleKey] as Property | undefined;
342
+ const val = getValueInPath(entity.values, resolvedTitleKey);
312
343
  if (prop) title = { property: prop,
313
- propertyKey: titleKey,
344
+ propertyKey: resolvedTitleKey,
314
345
  value: val };
315
346
  }
316
347
 
317
348
  // Subtitle
318
349
  let subtitle: PreviewSlot | undefined;
319
- if (subtitleKey) {
350
+ if (subtitleKey && subtitleKey !== resolvedTitleKey) {
320
351
  const prop = collection.properties[subtitleKey] as Property | undefined;
321
352
  const val = getValueInPath(entity.values, subtitleKey);
322
353
  if (prop && val !== undefined && val !== null && val !== "") {
@@ -44,7 +44,8 @@ import { useUrlController } from "../hooks/navigation/contexts/UrlContext";
44
44
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
45
45
  import { useNavigate } from "react-router-dom";
46
46
  import { getValueInPath } from "@rebasepro/utils";
47
- import { getEntityTitlePropertyKey, resolveTitleToString } from "../util/previews";
47
+ import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
48
+ import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
48
49
  import { JsonPreviewBinding } from "../components/JsonPreviewBinding";
49
50
 
50
51
  const EntityHistoryView = lazy(() => import("../components/history").then(m => ({ default: m.EntityHistoryView })));
@@ -267,11 +268,17 @@ entityId }
267
268
  const FormViewBuilder = formViewConfig?.Builder ? resolveComponentRef<EntityCustomViewParams>(formViewConfig.Builder as ComponentRef<EntityCustomViewParams>) : null;
268
269
 
269
270
  // Title resolution
270
- const titlePropertyKey = getEntityTitlePropertyKey(collection, customizationController.propertyConfigs);
271
+ const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, usedEntity?.values, usedEntity?.id);
271
272
  const rawTitle = usedEntity?.values && titlePropertyKey ? getValueInPath(usedEntity.values, titlePropertyKey) : undefined;
272
- const title = rawTitle !== undefined && rawTitle !== null
273
- ? resolveTitleToString(rawTitle)
274
- : (collection.singularName ?? collection.name);
273
+ // A user picker stores an id: resolve it to the person, like a relation.
274
+ const titleUser = useResolvedUser(isUserSelectProperty(collection, titlePropertyKey) && typeof rawTitle === "string"
275
+ ? rawTitle
276
+ : undefined);
277
+ const title = titleUser
278
+ ? getUserLabel(titleUser)
279
+ : (rawTitle !== undefined && rawTitle !== null
280
+ ? resolveTitleToString(rawTitle)
281
+ : (collection.singularName ?? collection.name));
275
282
 
276
283
  // Non-action custom views
277
284
  const nonActionCustomViews = useMemo(() =>
@@ -25,7 +25,7 @@ import {
25
25
  import { useAnalyticsController } from "@rebasepro/app";
26
26
  import { IconForView } from "@rebasepro/app";
27
27
  import { getPropertyInPath } from "../util/property_utils";
28
- import { getEntityPreviewKeys, getEntityTitlePropertyKey } from "../util/previews";
28
+ import { getEntityPreviewKeys, getEntityTitlePropertyKeyForEntity } from "../util/previews";
29
29
  import { getValueInPath } from "@rebasepro/utils";
30
30
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
31
31
  import { useSidePanel } from "../hooks/useSidePanel";
@@ -98,7 +98,7 @@ export function EntityPreviewBindingData({
98
98
  );
99
99
  }
100
100
 
101
- const titleProperty = includeTitle ? getEntityTitlePropertyKey(collection, customizationController.propertyConfigs) : undefined;
101
+ const titleProperty = includeTitle ? getEntityTitlePropertyKeyForEntity(collection, entity.values, entity.id) : undefined;
102
102
  const imagePropertyKey = includeImage ? getEntityImagePreviewPropertyKey(collection) : undefined;
103
103
  const imageProperty = imagePropertyKey ? collection.properties[imagePropertyKey] : undefined;
104
104
  const ofProp = imageProperty && "of" in imageProperty ? imageProperty.of : undefined;
@@ -164,7 +164,7 @@ function SideDialogView({
164
164
  scrollable={false}
165
165
  >
166
166
  {panel && (
167
- <div className="flex flex-col h-[75vh] min-h-[500px] min-w-[55vw] max-w-full overflow-hidden">
167
+ <div className="flex flex-col h-[75vh] min-h-[500px] w-full min-w-0 max-w-full overflow-hidden">
168
168
  <ErrorBoundary>
169
169
  {panel.component}
170
170
  </ErrorBoundary>
@@ -26,7 +26,8 @@ import { EntityFormActions } from "./EntityFormActions";
26
26
  import type { EntityFormActionsProps } from "../types/components/EntityFormActionsProps";
27
27
  import { LocalChangesMenu } from "./components/LocalChangesMenu";
28
28
 
29
- import { getEntityTitlePropertyKey, resolveTitleToString } from "../util/previews";
29
+ import { getEntityTitlePropertyKeyForEntity, isUserSelectProperty, resolveTitleToString } from "../util/previews";
30
+ import { getUserLabel, useResolvedUser } from "../hooks/useResolvedUsers";
30
31
  import { getValueInPath, mergeDeep } from "@rebasepro/utils";
31
32
  import {
32
33
  getChanges,
@@ -313,11 +314,17 @@ export function EntityForm<M extends Record<string, unknown>>({
313
314
 
314
315
  const actionsDisabled = disabled || formex.isSubmitting || (status === "existing" && !formex.dirty) || Boolean(disabledProp);
315
316
 
316
- const titlePropertyKey = getEntityTitlePropertyKey(collection, customizationController.propertyConfigs);
317
+ const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, formex.values as Record<string, unknown> | undefined, entityId);
317
318
  const rawTitle = formex.values && titlePropertyKey ? getValueInPath(formex.values, titlePropertyKey) : undefined;
318
- const title = rawTitle !== undefined && rawTitle !== null
319
- ? resolveTitleToString(rawTitle)
320
- : (collection.singularName ?? collection.name);
319
+ // A user picker stores an id: resolve it to the person, like a relation.
320
+ const titleUser = useResolvedUser(isUserSelectProperty(collection, titlePropertyKey) && typeof rawTitle === "string"
321
+ ? rawTitle
322
+ : undefined);
323
+ const title = titleUser
324
+ ? getUserLabel(titleUser)
325
+ : (rawTitle !== undefined && rawTitle !== null
326
+ ? resolveTitleToString(rawTitle)
327
+ : (collection.singularName ?? collection.name));
321
328
 
322
329
  const modified = formex.dirty;
323
330
 
@@ -2,6 +2,7 @@ export * from "./useSidePanel";
2
2
  export * from "./useSelectionDialog";
3
3
  export * from "./useBuildSidePanel";
4
4
  export * from "./useResolvedNavigationFrom";
5
+ export * from "./useResolvedUsers";
5
6
  export * from "./useHistory";
6
7
  export * from "./navigation/contexts";
7
8
  export * from "./navigation/useBuildCollectionRegistryController";
@@ -0,0 +1,143 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { User } from "@rebasepro/types";
3
+ import { useAuthController, useRebaseClient } from "@rebasepro/app";
4
+
5
+ /**
6
+ * Resolves the auth users behind `userSelect` values.
7
+ *
8
+ * A collection view can ask for dozens of ids at once — one per row — so
9
+ * lookups are coalesced into a single `GET /api/users?ids=…` per tick and
10
+ * cached per base URL. Ids that resolve to nothing are remembered too, so a
11
+ * dangling reference is not retried on every render.
12
+ */
13
+
14
+ interface UserCache {
15
+ users: Map<string, User | null>;
16
+ pending: Map<string, Promise<void>>;
17
+ queue: Set<string>;
18
+ flush?: ReturnType<typeof setTimeout>;
19
+ }
20
+
21
+ const caches = new Map<string, UserCache>();
22
+
23
+ function getCache(baseUrl: string): UserCache {
24
+ let cache = caches.get(baseUrl);
25
+ if (!cache) {
26
+ cache = { users: new Map(),
27
+ pending: new Map(),
28
+ queue: new Set() };
29
+ caches.set(baseUrl, cache);
30
+ }
31
+ return cache;
32
+ }
33
+
34
+ async function fetchUsers(baseUrl: string, ids: string[], token: string | undefined): Promise<Map<string, User | null>> {
35
+ const resolved = new Map<string, User | null>();
36
+ for (const id of ids) resolved.set(id, null);
37
+
38
+ const params = new URLSearchParams({ ids: ids.join(",") });
39
+ const response = await fetch(`${baseUrl}/api/users?${params}`, {
40
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
41
+ });
42
+ if (!response.ok) {
43
+ // A non-admin viewer gets a 403 here. Treat it as "unresolvable" rather
44
+ // than an error: callers fall back to showing the raw value.
45
+ return resolved;
46
+ }
47
+ const data = await response.json();
48
+ const rows: Record<string, unknown>[] = data.users ?? data.data ?? [];
49
+ for (const row of rows) {
50
+ const uid = (row.uid as string) ?? (row.id as string);
51
+ if (!uid) continue;
52
+ resolved.set(uid, {
53
+ uid,
54
+ email: (row.email as string) ?? "",
55
+ displayName: (row.displayName as string) ?? (row.display_name as string) ?? null,
56
+ photoURL: (row.photoURL as string) ?? (row.photo_url as string) ?? null,
57
+ providerId: "custom",
58
+ isAnonymous: false,
59
+ roles: (row.roles as string[]) ?? []
60
+ });
61
+ }
62
+ return resolved;
63
+ }
64
+
65
+ function requestUser(
66
+ baseUrl: string,
67
+ uid: string,
68
+ getAuthToken: (() => Promise<string | undefined>) | undefined
69
+ ): Promise<void> {
70
+ const cache = getCache(baseUrl);
71
+ const existing = cache.pending.get(uid);
72
+ if (existing) return existing;
73
+
74
+ cache.queue.add(uid);
75
+
76
+ const promise = new Promise<void>((resolve, reject) => {
77
+ if (cache.flush) clearTimeout(cache.flush);
78
+ cache.flush = setTimeout(async () => {
79
+ const ids = [...cache.queue];
80
+ cache.queue.clear();
81
+ cache.flush = undefined;
82
+ try {
83
+ const token = await getAuthToken?.();
84
+ const resolved = await fetchUsers(baseUrl, ids, token);
85
+ for (const id of ids) {
86
+ cache.users.set(id, resolved.get(id) ?? null);
87
+ cache.pending.delete(id);
88
+ }
89
+ resolve();
90
+ } catch (error) {
91
+ for (const id of ids) cache.pending.delete(id);
92
+ reject(error);
93
+ }
94
+ }, 0);
95
+ });
96
+
97
+ cache.pending.set(uid, promise);
98
+ return promise;
99
+ }
100
+
101
+ /**
102
+ * The auth user behind a `userSelect` value, or `null` while it is loading or
103
+ * when it cannot be resolved.
104
+ */
105
+ export function useResolvedUser(uid: string | undefined): User | null {
106
+ const client = useRebaseClient<{ baseUrl?: string }>();
107
+ const { getAuthToken } = useAuthController();
108
+ const baseUrl = client?.baseUrl;
109
+
110
+ const [user, setUser] = useState<User | null>(() =>
111
+ (baseUrl && uid) ? getCache(baseUrl).users.get(uid) ?? null : null);
112
+
113
+ useEffect(() => {
114
+ if (!baseUrl || !uid) {
115
+ setUser(null);
116
+ return;
117
+ }
118
+ const cache = getCache(baseUrl);
119
+ if (cache.users.has(uid)) {
120
+ setUser(cache.users.get(uid) ?? null);
121
+ return;
122
+ }
123
+
124
+ let active = true;
125
+ requestUser(baseUrl, uid, getAuthToken)
126
+ .then(() => {
127
+ if (active) setUser(getCache(baseUrl).users.get(uid) ?? null);
128
+ })
129
+ .catch(() => {
130
+ if (active) setUser(null);
131
+ });
132
+ return () => {
133
+ active = false;
134
+ };
135
+ }, [baseUrl, uid, getAuthToken]);
136
+
137
+ return user;
138
+ }
139
+
140
+ /** Human-readable label for a resolved user. */
141
+ export function getUserLabel(user: User): string {
142
+ return user.displayName || user.email || user.uid;
143
+ }
package/src/index.ts CHANGED
@@ -89,7 +89,8 @@ export {
89
89
  getDefaultPropertiesOrder,
90
90
  // Preview utilities (moved from @rebasepro/app — property-aware logic belongs in CMS)
91
91
  getEntityPreviewKeys,
92
- getEntityTitlePropertyKey
92
+ getEntityTitlePropertyKey,
93
+ getEntityTitlePropertyKeyForEntity
93
94
  } from "./util";
94
95
 
95
96
  // Headless collection view adapters
@@ -9,7 +9,7 @@ import { ErrorBoundary } from "@rebasepro/ui";
9
9
  import { ErrorView } from "@rebasepro/app";
10
10
  import { EntityPreviewBinding, EntityPreviewContainer } from "../../components/EntityPreviewBinding";
11
11
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
12
- import { getEntityTitlePropertyKey } from "../../util/previews";
12
+ import { getEntityTitlePropertyKeyForEntity } from "../../util/previews";
13
13
  import { getValueInPath } from "@rebasepro/utils";
14
14
 
15
15
  export type ReferencePreviewProps = {
@@ -196,7 +196,7 @@ function ReferencePreviewExisting<M extends Record<string, unknown> = Record<str
196
196
  }
197
197
 
198
198
  if (textOnly) {
199
- const titleProperty = getEntityTitlePropertyKey(collection, customizationController.propertyConfigs);
199
+ const titleProperty = getEntityTitlePropertyKeyForEntity(collection, usedEntity.values, usedEntity.id);
200
200
  const titleValue = titleProperty ? getValueInPath(usedEntity.values, titleProperty) : undefined;
201
201
  const displayValue = titleValue !== undefined && titleValue !== null ? String(titleValue) : String(reference.id);
202
202
  return <span className="truncate">{displayValue}</span>;
@@ -7,7 +7,7 @@ import { useCustomizationController, useFetch, ErrorView, useComponentOverride,
7
7
  import { Skeleton } from "@rebasepro/ui";
8
8
  import { EntityPreviewBinding, EntityPreviewContainer } from "../../components/EntityPreviewBinding";
9
9
  import { useCollectionRegistryController } from "../../hooks/navigation/contexts/CollectionRegistryContext";
10
- import { getEntityTitlePropertyKey } from "../../util/previews";
10
+ import { getEntityTitlePropertyKeyForEntity } from "../../util/previews";
11
11
  import { getValueInPath } from "@rebasepro/utils";
12
12
 
13
13
  export type RelationPreviewProps = {
@@ -228,7 +228,7 @@ function RelationPreviewExisting<M extends Record<string, unknown> = Record<stri
228
228
  }
229
229
 
230
230
  if (textOnly) {
231
- const titleProperty = getEntityTitlePropertyKey(collection, customizationController.propertyConfigs);
231
+ const titleProperty = getEntityTitlePropertyKeyForEntity(collection, usedEntity.values, usedEntity.id);
232
232
  const titleValue = titleProperty ? getValueInPath(usedEntity.values, titleProperty) : undefined;
233
233
  const displayValue = titleValue !== undefined && titleValue !== null ? String(titleValue) : String(relation.id);
234
234
  return <span className="truncate">{displayValue}</span>;
@@ -3,19 +3,28 @@ import type { StringProperty } from "@rebasepro/types";
3
3
  import React from "react";
4
4
  import { EmptyValue } from "./EmptyValue";
5
5
  import { Typography } from "@rebasepro/ui";
6
+ import { getUserLabel, useResolvedUser } from "../../hooks/useResolvedUsers";
6
7
 
7
8
  /**
8
9
  * Preview component for displaying a user reference value.
9
10
  *
10
- * Shows the raw user ID. Collection-based user management means
11
- * user records are fetched through the standard data layer.
11
+ * Resolves the stored auth user id to a name (or email) the same way a
12
+ * relation resolves its target, and falls back to the raw id only while the
13
+ * lookup is in flight or when the user cannot be resolved.
12
14
  *
13
15
  * @group Preview components
14
16
  */
15
17
  export function UserPreview({ value }: PropertyPreviewProps<StringProperty>) {
16
- if (typeof value !== "string" || !value) {
18
+ const uid = typeof value === "string" && value ? value : undefined;
19
+ const user = useResolvedUser(uid);
20
+
21
+ if (!uid) {
17
22
  return <EmptyValue/>;
18
23
  }
19
24
 
20
- return <Typography variant={"caption"} color={"secondary"}>{value}</Typography>;
25
+ return <Typography variant={"caption"}
26
+ color={"secondary"}
27
+ className={"truncate"}>
28
+ {user ? getUserLabel(user) : uid}
29
+ </Typography>;
21
30
  }
@@ -15,7 +15,8 @@ export function StringPropertyPreview({
15
15
  propertyKey,
16
16
  value,
17
17
  property,
18
- size
18
+ size,
19
+ textOnly
19
20
  }: PropertyPreviewProps<StringProperty>): React.ReactElement {
20
21
 
21
22
  const strValue = value as string;
@@ -44,14 +45,19 @@ export function StringPropertyPreview({
44
45
  );
45
46
  } else {
46
47
  if (!strValue) return <></>;
47
- const lines = strValue.split("\n");
48
- return strValue && strValue.includes("\n")
49
- ? <div className={cls("overflow-x-scroll overflow-hidden", size === "small" ? "text-sm" : "")}>
50
- {lines.map((str: any, index: number) =>
51
- <React.Fragment key={`string_preview_${index}`}>
52
- <span>{str}</span>
53
- {index !== lines.length - 1 && <br/>}
54
- </React.Fragment>)}
48
+ // When used as a title/subtitle (textOnly), collapse newlines so the
49
+ // parent `truncate` can clamp it to a single line.
50
+ if (textOnly) {
51
+ const singleLine = strValue.replace(/\s*\n\s*/g, " ");
52
+ return size === "small"
53
+ ? <span className={"text-sm"}>{singleLine}</span>
54
+ : <>{singleLine}</>;
55
+ }
56
+ return strValue.includes("\n")
57
+ ? <div className={cls(
58
+ "whitespace-pre-line line-clamp-3 overflow-hidden",
59
+ size === "small" ? "text-sm" : "")}>
60
+ {strValue}
55
61
  </div>
56
62
  : (size === "small"
57
63
  ? <span className={"text-sm"}>{strValue}</span>
@@ -1,6 +1,6 @@
1
1
  import type { CollectionConfig, Property, PropertyConfig } from "@rebasepro/types";
2
2
  import { AuthController } from "@rebasepro/types";
3
- import { isPropertyBuilder } from "@rebasepro/common";
3
+ import { getTitlePropertyKey, getTitlePropertyKeyForValues, isPropertyBuilder } from "@rebasepro/common";
4
4
  import { isReferenceProperty, isRelationProperty } from "./property_utils";
5
5
 
6
6
  function isHiddenProperty(property: Property | undefined): boolean {
@@ -58,54 +58,43 @@ export function getEntityPreviewKeys(
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * The property that fills the title slot for a collection.
63
+ *
64
+ * Ranking lives in `@rebasepro/common` so every surface (list rows, cards,
65
+ * board cards, detail header, relation chips) agrees on what an entity is
66
+ * called. Identifiers are excluded from the property *schema* — primary keys,
67
+ * foreign keys, UUID columns — not from the key name.
68
+ */
61
69
  export function getEntityTitlePropertyKey<M extends Record<string, unknown>>(collection: CollectionConfig<M>, propertyConfigs: Record<string, PropertyConfig>): string | undefined {
62
- if (collection.titleProperty) {
63
- return collection.titleProperty as string;
64
- }
65
-
66
- const hasExplicitOrder = !!(collection.propertiesOrder as string[] | undefined);
67
- const orderToSearch = (collection.propertiesOrder as string[]) || Object.keys(collection.properties);
68
-
69
- // If explicit propertiesOrder is set, allow the first non-ID relation or string to be the titleKey.
70
- // Storage/image properties are excluded — they are rendered by the dedicated image slot,
71
- // so using one here would duplicate the same image in both the image and title slots.
72
- if (hasExplicitOrder) {
73
- for (const key of orderToSearch) {
74
- const property = collection.properties[key];
75
- if (property && !isPropertyBuilder(property)) {
76
- const prop = property as Property;
77
- const isIdProp = prop && typeof prop === "object" && "isId" in prop && Boolean((prop as { isId?: boolean }).isId);
78
- if (isIdProp || key === "id" || isHiddenProperty(prop)) {
79
- continue;
80
- }
81
- if (prop.type === "relation" || (prop.type === "string" && !isStorageProperty(prop))) {
82
- return key;
83
- }
84
- }
85
- }
86
- }
70
+ return getTitlePropertyKey(collection);
71
+ }
87
72
 
88
- let firstStringCandidate: string | undefined;
73
+ /**
74
+ * Same as {@link getEntityTitlePropertyKey}, but skips candidates that carry
75
+ * no readable value for this particular entity (empty, or an opaque id).
76
+ */
77
+ export function getEntityTitlePropertyKeyForEntity<M extends Record<string, unknown>>(
78
+ collection: CollectionConfig<M>,
79
+ values: Record<string, unknown> | undefined,
80
+ entityId?: string | number
81
+ ): string | undefined {
82
+ return getTitlePropertyKeyForValues(collection, values, entityId);
83
+ }
89
84
 
90
- for (const key of orderToSearch) {
91
- const property = collection.properties[key];
92
- if (property && !isPropertyBuilder(property)) {
93
- const prop = property as Property;
94
- if (isHiddenProperty(prop)) {
95
- continue;
96
- }
97
- if (prop.type === "string" && !prop.ui?.multiline && !prop.ui?.markdown && !prop.storage && !prop.isId) {
98
- if (!firstStringCandidate) {
99
- firstStringCandidate = key;
100
- }
101
- const lowerKey = key.toLowerCase();
102
- if (["name", "title", "label", "displayname", "username"].includes(lowerKey)) {
103
- return key; // Immediate return if it's a strong title candidate
104
- }
105
- }
106
- }
107
- }
108
- return firstStringCandidate;
85
+ /**
86
+ * True when a property key points at a user picker, whose stored value is an
87
+ * auth user id that has to be resolved before it can be displayed.
88
+ */
89
+ export function isUserSelectProperty<M extends Record<string, unknown>>(
90
+ collection: CollectionConfig<M>,
91
+ propertyKey: string | undefined
92
+ ): boolean {
93
+ if (!propertyKey) return false;
94
+ const property = collection.properties[propertyKey];
95
+ if (!property || isPropertyBuilder(property)) return false;
96
+ const prop = property as Property;
97
+ return prop.type === "string" && Boolean(prop.userSelect);
109
98
  }
110
99
 
111
100
  /**