@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.
- package/dist/{CollectionEditorDialog-BOlKGGg7.js → CollectionEditorDialog-BqVYCTmU.js} +3 -3
- package/dist/{CollectionEditorDialog-BOlKGGg7.js.map → CollectionEditorDialog-BqVYCTmU.js.map} +1 -1
- package/dist/{PropertyEditView-i51tFOfM.js → PropertyEditView-CIX85COE.js} +2 -2
- package/dist/{PropertyEditView-i51tFOfM.js.map → PropertyEditView-CIX85COE.js.map} +1 -1
- package/dist/{RouterCollectionsStudioView-Dh4W53PU.js → RouterCollectionsStudioView-_1RsFWS0.js} +4 -4
- package/dist/{RouterCollectionsStudioView-Dh4W53PU.js.map → RouterCollectionsStudioView-_1RsFWS0.js.map} +1 -1
- package/dist/collection_editor_ui.js +4 -4
- package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +2 -0
- package/dist/{export-BM6HPujv.js → export-CIi-Zrsn.js} +2 -2
- package/dist/{export-BM6HPujv.js.map → export-CIi-Zrsn.js.map} +1 -1
- package/dist/{history-CtIPJxvQ.js → history-BojXI3uS.js} +2 -2
- package/dist/{history-CtIPJxvQ.js.map → history-BojXI3uS.js.map} +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/useResolvedUsers.d.ts +8 -0
- package/dist/{import-DzQnT03v.js → import-SVoKfekq.js} +2 -2
- package/dist/{import-DzQnT03v.js.map → import-SVoKfekq.js.map} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/preview/components/UserPreview.d.ts +3 -2
- package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
- package/dist/util/previews.d.ts +18 -0
- package/dist/{util-XFwQ9FEc.js → util-aJuPuy_1.js} +198 -65
- package/dist/util-aJuPuy_1.js.map +1 -0
- package/package.json +8 -8
- package/src/components/CollectionViewBinding/usePreviewSlots.ts +41 -10
- package/src/components/DetailViewBinding.tsx +12 -5
- package/src/components/EntityPreviewBinding.tsx +2 -2
- package/src/components/SideDialogs.tsx +1 -1
- package/src/form/EntityForm.tsx +12 -5
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useResolvedUsers.ts +143 -0
- package/src/index.ts +2 -1
- package/src/preview/components/ReferencePreview.tsx +2 -2
- package/src/preview/components/RelationPreview.tsx +2 -2
- package/src/preview/components/UserPreview.tsx +13 -4
- package/src/preview/property_previews/StringPropertyPreview.tsx +15 -9
- package/src/util/previews.ts +35 -46
- 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.
|
|
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.
|
|
87
|
-
"@rebasepro/
|
|
88
|
-
"@rebasepro/
|
|
89
|
-
"@rebasepro/
|
|
90
|
-
"@rebasepro/
|
|
91
|
-
"@rebasepro/
|
|
92
|
-
"@rebasepro/
|
|
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 {
|
|
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
|
|
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 (
|
|
310
|
-
const prop = collection.properties[
|
|
311
|
-
const val = getValueInPath(entity.values,
|
|
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:
|
|
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 {
|
|
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 =
|
|
271
|
+
const titlePropertyKey = getEntityTitlePropertyKeyForEntity(collection, usedEntity?.values, usedEntity?.id);
|
|
271
272
|
const rawTitle = usedEntity?.values && titlePropertyKey ? getValueInPath(usedEntity.values, titlePropertyKey) : undefined;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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,
|
|
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 ?
|
|
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-
|
|
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>
|
package/src/form/EntityForm.tsx
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
package/src/hooks/index.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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"}
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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>
|
package/src/util/previews.ts
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
/**
|