Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Homepage: Build avatar and profile banner into user dashboard #6117

Merged
merged 8 commits into from
Jun 7, 2024
3 changes: 2 additions & 1 deletion packages/app-root/src/components/HomePageContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Box } from 'grommet'
import { PanoptesAuthContext } from '../contexts'
import { CommunityContainer, DefaultHome } from '@zooniverse/content'
import { Loader } from '@zooniverse/react-components'
import { UserHome } from '@zooniverse/user'

export default function HomePageContainer({
dailyZooPosts = [],
Expand All @@ -20,7 +21,7 @@ export default function HomePageContainer({
</Box>
) : (
<Box height={{ min: '100vh' }}>
{user?.login ? <p>Signed-in</p> : <DefaultHome />}
{user?.login ? <UserHome authUser={user} /> : <DefaultHome />}
</Box>
)}
<CommunityContainer
Expand Down
20 changes: 20 additions & 0 deletions packages/lib-user/src/components/UserHome/UserHome.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { shape, string } from 'prop-types'

import { Layout } from '@components/shared'
import DashboardContainer from './components/Dashboard/DashboardContainer.js'

function UserHome({ authUser }) {
return (
<Layout>
<DashboardContainer authUser={authUser}/>
</Layout>
)
}

export default UserHome

UserHome.propTypes = {
authUser: shape({
id: string
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Box, Image } from 'grommet'
import styled from 'styled-components'
import { shape, string } from 'prop-types'

const Relative = styled(Box)`
position: relative;
`

const StyledAvatar = styled(Image)`
width: 128px;
height: 128px;
mcbouslog marked this conversation as resolved.
Show resolved Hide resolved
object-fit: cover;
border-radius: 50%;
border: solid white 5px;
position: absolute;
top: 206px;
`

export default function Dashboard({ authUser, profileBannerSrc = '' }) {
return (
<Relative
fill
align='center'
height={{ min: '270px', max: '270px' }}
background={{ image: `url(${profileBannerSrc})`, color: 'neutral-1' }}
>
<StyledAvatar
alt='User avatar'
src={
authUser.avatar_src
? authUser.avatar_src
: 'https://www.zooniverse.org/assets/simple-avatar.png'
}
/>
</Relative>
)
}

Dashboard.propTypes = {
authUser: shape({
id: string
}),
profileBannerSrc: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { GROUP_ADMIN_USER, USER } from '../../../../../test/mocks/panoptes/users.js'
import Dashboard from './Dashboard.js'

export default {
title: 'Components / UserHome / Dashboard',
component: Dashboard
}

export const Default = {
args: {
authUser: USER,
profileBannerSrc: 'https://panoptes-uploads.zooniverse.org/user_profile_header/9da9fd16-46c1-4d84-a272-83ac19fb32c3.jpeg'
}
}

export const NoAvatarOrBanner = {
args: {
authUser: GROUP_ADMIN_USER,
profileBannerSrc: ''
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { panoptes } from '@zooniverse/panoptes-js'
import auth from 'panoptes-client/lib/auth'
import useSWR from 'swr'

import Dashboard from './Dashboard'

const fetchProfileBanner = async ({ authUser }) => {
const token = await auth.checkBearerToken()
const authorization = `Bearer ${token}`
const query = { include: 'profile_header', user_id: authUser.id }

try {
const { body } = await panoptes.get('/users', query, { authorization })
const profileBannerSrc = body.linked.profile_headers[0].src
return profileBannerSrc
} catch (error) {
console.error(error)
return null
}
}

const SWROptions = {
revalidateIfStale: true,
revalidateOnMount: true,
revalidateOnFocus: true,
revalidateOnReconnect: true,
refreshInterval: 0
}

export default function DashboardContainer({ authUser }) {
const key = { authUser }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If authUser is empty or null, you should set the key to null. That tells SWR to skip the request.

Otherwise, your code here might inadvertently run the SWR hook when it shouldn't, and cache an unexpected value for this key in the SWR cache.

When you pass a key to useSWR, it looks up that key in its global cache and immediately returns the stored result (which is why keys must be unique.) If the stored result is stale, it revalidates the cache by fetching a new result from the remote source. Hence 'stale while revalidate.'

Copy link
Contributor

@eatyourgreens eatyourgreens Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also a warning that if you've used { authUser } as the key for any other queries, elsewhere in your code, useSWR(key, fetcher, options) will returned the cached values for those queries. That can lead to some weird bugs.

You could avoid that here by passing the query object as the key. I think that's unique to this specific API request. I think that when I was writing SWR hooks for translations, I used the API endpoint and query as the key for each request, but I don't remember the details. That was where I learnt that reusing a key leads to bugs!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DashboardContainer is in UserHome, and UserHome only renders if authUser.login exists. I don't expect authUser to ever be null in this component.

UserHome is conditionally rendered in app-root here. Do you suggest handling the homepage differently?

{user?.login ? <UserHome authUser={user}/> : <DefaultHome />}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you know that authuser will always be defined, then this should be fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Good catch with the need for a unique key too, so I'll definitely address that here!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that unique key caught me out here, which is why I added the endpoint.

const defaultKey = {
user: storedUser,
endpoint: '/me'
}

Copy link
Contributor

@eatyourgreens eatyourgreens Jun 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to this specific API request, but it can also be a good idea to invalidate the cache when an auth token is refreshed, like this example for user project preferences, which includes authorization in the key:

const key = { endpoint, projectID, userID, authorization }

I think there might be some discussion of that in the SWR docs for query keys. The idea is that you don't want to accidentally show stale private data to another user, eg. on a shared browser, so always clear the cache when the auth header expires and is refreshed.

https://swr.vercel.app/docs/arguments#multiple-arguments

const { data: profileBannerSrc } = useSWR(key, fetchProfileBanner, SWROptions)
mcbouslog marked this conversation as resolved.
Show resolved Hide resolved

return <Dashboard authUser={authUser} profileBannerSrc={profileBannerSrc} />
}
1 change: 1 addition & 0 deletions packages/lib-user/src/components/UserHome/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './UserHome.js'
1 change: 1 addition & 0 deletions packages/lib-user/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export { default as UserStats } from './components/UserStats'
export { default as MyGroups } from './components/MyGroups'
export { default as GroupStats } from './components/GroupStats'
export { default as UserHome } from './components/UserHome'

// components/shared
export { default as Avatar } from './components/shared/Avatar'
Expand Down