Skip to content

Commit 8c36794

Browse files
feat(hook): add getUser hook (#581)
* feat(core): allow a userId option for the user store * feat(core): add getUserState convenience function for single user fetching --------- Co-authored-by: Carolina Gonzalez <[email protected]>
1 parent 214e0fd commit 8c36794

File tree

7 files changed

+754
-17
lines changed

7 files changed

+754
-17
lines changed

apps/kitchensink-react/src/AppRoutes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {DashboardContextRoute} from './routes/DashboardContextRoute'
1717
import {DashboardWorkspacesRoute} from './routes/DashboardWorkspacesRoute'
1818
import ExperimentalResourceClientRoute from './routes/ExperimentalResourceClientRoute'
1919
import {ReleasesRoute} from './routes/releases/ReleasesRoute'
20+
import {UserDetailRoute} from './routes/UserDetailRoute'
2021
import {UsersRoute} from './routes/UsersRoute'
2122

2223
const documentCollectionRoutes = [
@@ -105,6 +106,7 @@ export function AppRoutes(): JSX.Element {
105106
{documentCollectionRoutes.map((route) => (
106107
<Route key={route.path} path={route.path} element={route.element} />
107108
))}
109+
<Route path="users/:userId" element={<UserDetailRoute />} />
108110
<Route path="comlink-demo" element={<ParentApp />} />
109111
<Route path="releases" element={<ReleasesRoute />} />
110112
</Route>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {Avatar, Box, Text} from '@sanity/ui'
2+
import {type JSX, useState} from 'react'
3+
4+
interface FallbackAvatarProps {
5+
src?: string
6+
size: React.ComponentProps<typeof Avatar>['size']
7+
displayName: string
8+
}
9+
10+
function generateInitials(displayName: string): string {
11+
if (!displayName || displayName.trim() === '') {
12+
return '?'
13+
}
14+
15+
// Split by whitespace and filter out empty strings
16+
const words = displayName
17+
.trim()
18+
.split(/\s+/)
19+
.filter((word) => word.length > 0)
20+
21+
if (words.length === 0) {
22+
return '?'
23+
}
24+
25+
// Use Array.from to properly handle Unicode characters, emojis, etc.
26+
const initials = words
27+
.slice(0, 2) // Take only first 2 words
28+
.map((word) => {
29+
const chars = Array.from(word)
30+
return chars.length > 0 ? chars[0] : ''
31+
})
32+
.filter((char) => char !== '') // Remove any empty characters
33+
.join('')
34+
.toUpperCase()
35+
36+
return initials || '?'
37+
}
38+
39+
export function FallbackAvatar({src, size, displayName}: FallbackAvatarProps): JSX.Element {
40+
const [imageError, setImageError] = useState(false)
41+
const initials = generateInitials(displayName)
42+
43+
// Don't try to render if src is missing, empty, or only whitespace, or if image failed to load
44+
if (imageError || !src || src.trim() === '') {
45+
// Create a fallback that matches Avatar's visual style
46+
return (
47+
<Box
48+
style={{
49+
width: '2.25rem',
50+
height: '2.25rem',
51+
borderRadius: '50%',
52+
backgroundColor: '#e1e3e9',
53+
display: 'inline-flex',
54+
alignItems: 'center',
55+
justifyContent: 'center',
56+
flexShrink: 0,
57+
}}
58+
title={displayName}
59+
>
60+
<Text
61+
size={size !== undefined && size >= 2 ? 3 : 2}
62+
weight="semibold"
63+
style={{
64+
color: '#6e7683',
65+
lineHeight: 1,
66+
}}
67+
>
68+
{initials}
69+
</Text>
70+
</Box>
71+
)
72+
}
73+
74+
return <Avatar size={size} src={src} title={displayName} onError={() => setImageError(true)} />
75+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import {useProject, useUser} from '@sanity/sdk-react'
2+
import {Badge, Card, Flex, Grid, Heading, Stack, Text} from '@sanity/ui'
3+
import {type JSX} from 'react'
4+
import {useParams} from 'react-router'
5+
6+
import {FallbackAvatar} from '../components/FallbackAvatar'
7+
import {PageLayout} from '../components/PageLayout'
8+
9+
export function UserDetailRoute(): JSX.Element {
10+
const {userId} = useParams<{userId: string}>()
11+
const {organizationId, id: projectId} = useProject()
12+
13+
const resourceType = organizationId ? 'organization' : 'project'
14+
const resourceId = organizationId || projectId
15+
16+
const {data: user} = useUser({
17+
userId: userId || '',
18+
resourceType,
19+
[resourceType === 'organization' ? 'organizationId' : 'projectId']: resourceId,
20+
})
21+
22+
if (!user) {
23+
return (
24+
<PageLayout title="❓ User Not Found" subtitle="The requested user could not be located">
25+
<Text>
26+
The user with ID &quot;{userId}&quot; was not found in this {resourceType}.
27+
</Text>
28+
</PageLayout>
29+
)
30+
}
31+
32+
return (
33+
<PageLayout title="👤 User Profile" subtitle={user.sanityUserId}>
34+
<Stack space={4}>
35+
{/* Main Profile Card */}
36+
<Card padding={4} radius={3} shadow={1}>
37+
<Stack space={4}>
38+
{/* User Avatar & Basic Info */}
39+
<Flex align="center" gap={4}>
40+
<FallbackAvatar
41+
size={3}
42+
src={user.profile.imageUrl}
43+
displayName={user.profile.displayName}
44+
/>
45+
<Stack space={2}>
46+
<Heading as="h2" size={2}>
47+
{user.profile.displayName}
48+
</Heading>
49+
<Text muted size={1}>
50+
📧 {user.profile.email}
51+
</Text>
52+
<Badge tone="primary" fontSize={1}>
53+
🆔 {user.profile.id}
54+
</Badge>
55+
</Stack>
56+
</Flex>
57+
58+
{/* Profile Details Grid */}
59+
<Grid columns={2} gap={3}>
60+
<Stack space={2}>
61+
<Text weight="semibold" size={1}>
62+
📅 Created
63+
</Text>
64+
<Text size={1} muted>
65+
{new Date(user.profile.createdAt).toLocaleDateString()}
66+
</Text>
67+
</Stack>
68+
69+
{user.profile.updatedAt && (
70+
<Stack space={2}>
71+
<Text weight="semibold" size={1}>
72+
🔄 Last Updated
73+
</Text>
74+
<Text size={1} muted>
75+
{new Date(user.profile.updatedAt).toLocaleDateString()}
76+
</Text>
77+
</Stack>
78+
)}
79+
</Grid>
80+
</Stack>
81+
</Card>
82+
83+
{/* Memberships Section */}
84+
{user.memberships.length > 0 && (
85+
<Card padding={4} radius={3} shadow={1}>
86+
<Stack space={4}>
87+
<Heading as="h3" size={2}>
88+
🔐 Access & Permissions
89+
</Heading>
90+
91+
<Stack space={3}>
92+
{user.memberships.map((membership, index) => (
93+
<Card
94+
key={`${membership.resourceId}-${index}`}
95+
padding={3}
96+
tone="transparent"
97+
border
98+
radius={2}
99+
>
100+
<Grid columns={[1, 2]} gap={3}>
101+
<Stack space={2}>
102+
<Flex align="center" gap={2}>
103+
<Text weight="semibold" size={1}>
104+
{membership.resourceType === 'project' ? '📦' : '🏢'} Resource
105+
</Text>
106+
<Badge tone="default" fontSize={1}>
107+
{membership.resourceType}
108+
</Badge>
109+
</Flex>
110+
<Text size={1} muted>
111+
{membership.resourceId}
112+
</Text>
113+
</Stack>
114+
115+
<Stack space={2}>
116+
<Text weight="semibold" size={1}>
117+
🎭 Roles
118+
</Text>
119+
<Flex gap={1} wrap="wrap">
120+
{membership.roleNames.map((role) => (
121+
<Badge key={role} tone="primary" fontSize={1}>
122+
{role}
123+
</Badge>
124+
))}
125+
</Flex>
126+
</Stack>
127+
128+
{membership.addedAt && (
129+
<Stack space={2}>
130+
<Text weight="semibold" size={1}>
131+
➕ Added
132+
</Text>
133+
<Text size={1} muted>
134+
{new Date(membership.addedAt).toLocaleDateString()}
135+
</Text>
136+
</Stack>
137+
)}
138+
139+
{membership.lastSeenAt && (
140+
<Stack space={2}>
141+
<Text weight="semibold" size={1}>
142+
👁️ Last Seen
143+
</Text>
144+
<Text size={1} muted>
145+
{new Date(membership.lastSeenAt).toLocaleDateString()}
146+
</Text>
147+
</Stack>
148+
)}
149+
</Grid>
150+
</Card>
151+
))}
152+
</Stack>
153+
</Stack>
154+
</Card>
155+
)}
156+
</Stack>
157+
</PageLayout>
158+
)
159+
}
Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
11
import {useUsers} from '@sanity/sdk-react'
2-
import {Avatar, Box, Card, Flex, Heading, Text} from '@sanity/ui'
2+
import {Box, Card, Flex, Text} from '@sanity/ui'
33
import {type JSX} from 'react'
4+
import {Link} from 'react-router'
45

6+
import {FallbackAvatar} from '../components/FallbackAvatar'
7+
import {PageLayout} from '../components/PageLayout'
58
import {LoadMore} from '../DocumentCollection/LoadMore'
69

710
export function UsersRoute(): JSX.Element {
811
const {data, hasMore, isPending, loadMore} = useUsers({batchSize: 10})
912

1013
return (
11-
<Box padding={4}>
12-
<Heading as="h1" size={5}>
13-
Sanity Organization Users
14-
</Heading>
15-
<Box paddingY={5}>
16-
<ol className="DocumentListLayout list-none" style={{gap: 2}}>
17-
{data.map((user) => (
18-
<li key={user.profile.id}>
19-
<Card width="fill" marginBottom={2}>
14+
<PageLayout title="Organization Users" subtitle={`${data.length} users loaded`}>
15+
<ol className="DocumentListLayout list-none" style={{gap: 2}}>
16+
{data.map((user) => (
17+
<li key={user.profile.id}>
18+
<Link to={`/users/${user.profile.id}`} style={{textDecoration: 'none'}}>
19+
<Card width="fill" marginBottom={2} style={{cursor: 'pointer'}} tone="inherit">
2020
<Flex align="center" gap={2} padding={2}>
21-
<Avatar size={2} src={user.profile.imageUrl} />
21+
<FallbackAvatar
22+
size={2}
23+
src={user.profile.imageUrl}
24+
displayName={user.profile.displayName}
25+
/>
2226
<Box paddingY={2}>
2327
<Flex direction="column" gap={1}>
2428
<Text>{user.profile.displayName}</Text>
@@ -27,11 +31,11 @@ export function UsersRoute(): JSX.Element {
2731
</Box>
2832
</Flex>
2933
</Card>
30-
</li>
31-
))}
32-
<LoadMore isPending={isPending} hasMore={hasMore} onLoadMore={loadMore} />
33-
</ol>
34-
</Box>
35-
</Box>
34+
</Link>
35+
</li>
36+
))}
37+
<LoadMore isPending={isPending} hasMore={hasMore} onLoadMore={loadMore} />
38+
</ol>
39+
</PageLayout>
3640
)
3741
}

packages/react/src/_exports/sdk-react.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export {type ProjectWithoutMembers, useProjects} from '../hooks/projects/useProj
6868
export {useQuery} from '../hooks/query/useQuery'
6969
export {useActiveReleases} from '../hooks/releases/useActiveReleases'
7070
export {usePerspective} from '../hooks/releases/usePerspective'
71+
export {type UserResult, useUser} from '../hooks/users/useUser'
7172
export {type UsersResult, useUsers} from '../hooks/users/useUsers'
7273
export {REACT_SDK_VERSION} from '../version'
7374
export {type DatasetsResponse, type SanityProjectMember} from '@sanity/client'

0 commit comments

Comments
 (0)