-
Notifications
You must be signed in to change notification settings - Fork 29
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
app-project: Redesign how your stats are displayed on the classify page #6472
base: master
Are you sure you want to change the base?
Conversation
"FinishedForTheDay": { | ||
"buttons": { | ||
"stats": "See the stats" | ||
}, | ||
"text": "Your answers are saved for the research team while you're working. See the project stats and return to the {{projectName}} home page.", | ||
"title": "Finished for the day?", | ||
"ProjectImage": { | ||
"alt": "Image for {{projectName}}" | ||
} | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This component is deleted for good, so its translation keys are deleted throughout.
"todaysCount": "Classifications today", | ||
"totalCount": "Classifications total", | ||
"text": "Keep up the great work!", | ||
"title": "Your {{projectName}} statistics", | ||
"DailyClassificationsChart": { | ||
"title": "{{projectName}} daily classification counts" | ||
} | ||
"allTime": "All Time", | ||
"lastSeven": "Last 7 Days", | ||
"link": "See more", | ||
"title": "Your Stats" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm able to reuse "title"
in all languages, but the rest of the translation keys will be modified and/or deleted in Lokalise post-merge.
/* Only increment stats on the classify page if the subject is not retired or not already seen by current user */ | ||
const incrementStats = yourStats?.increment | ||
/* | ||
Increment user stats on every classification submitted. | ||
Add the recently classified subject to the user's Recents. | ||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ERAS counts all classifications as a +1 regardless of subject status as 'already seen' or 'retired'. Therefore, we want to increment user stats after every submitted classification.
Add the recently classified subject to the user's Recents. | ||
*/ | ||
const projectID = project?.id | ||
const { mutate } = useYourProjectStats({ projectID, userID }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mutate
function is referenced in the ClassifierWrapper component because the useYourProjectStats()
hook must be called in a functional component. mutate
is specific to the SWR key
in useYourProjectStats()
, so it can be used to update the data
returned by the hook on every classification submitted.
describe('with a retired subject', function () { | ||
describe('with user signed in and any subject', function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These ClassifierWrapper tests are modified to "with user signed in and any subject" because ERAS counts all classifications as a +1 regardless of subject status as 'already seen' or 'retired'. Therefore, the behavior of user recents and user stats is the same in each scenario.
I also didn't include a test here for "increments stats on classification" because this spec file is written with implementation patterns and enzyme, and "increments stats on classification" requires looking at actual UI.
it('should include your personal stats', function () { | ||
expect(wrapper.props().yourStats).to.equal(mockStore.user.personalization) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
user.personalization
does not always equal just the "your personal stats" feature. Stats used to be a part of the UserPersonalization store, but that's not the case in this PR. Specs that equate personalization to stats have been removed.
packages/app-project/src/screens/ClassifyPage/components/RecentSubjects/RecentSubjects.js
Outdated
Show resolved
Hide resolved
function Stat({ label = '', value = 0 }) { | ||
return ( | ||
<Box> | ||
<SpacedText textAlign='center'>{label}</SpacedText> | ||
<Text | ||
color={{ light: 'neutral-1', dark: 'accent-1' }} | ||
size='xxlarge' | ||
textAlign='center' | ||
> | ||
{/* Insert commmas where appropriate */} | ||
{value.toLocaleString()} | ||
</Text> | ||
</Box> | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This Stat presentational component is purposely different than the @shared/components/Stat
in app-project. The styling here matches lib-user, and the lack of animation is also intentional.
/** | ||
* This is a relatively simple container for ProjectStats, but data fetching | ||
* and store observing are purposely separated from the presentational component | ||
* styling and logic. Fetching user data requires authorization, making it | ||
* complicated to use a mock library like MSW for useYourProjectStats() hook. | ||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just noting I considered using MSW in order to see YourProjectStatsContainer in Storybook and unit tests, but mocking authorized requests for user stats was too challenging for the return.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's of any use, the SubjectPicker
stories have an example of mocking an authenticated request to Panoptes with MSW.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need a signed-in user to make requests in the SubjectPicker do you? 🤔 ERAS user stats requests require authorization from the signed-in user. User authorization is what I had a difficult time mocking for YourProjectStatsContainer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subject picker makes an auth'ed request to get the already seen and retired status of each subject for your user account. I don't know if this will help here, but in the subject picker you can ignore the auth headers and mock the endpoint directly. This is the auth'ed API query:
front-end-monorepo/packages/app-project/src/shared/components/SubjectPicker/SubjectPicker.stories.js
Lines 41 to 49 in 1862ade
http.get(`${PANOPTES_HOST}/api/subjects/selection`, ({ request }) => { | |
return HttpResponse.json({ | |
subjects: [ | |
{ id: 1, already_seen: false, retired: false }, | |
{ id: 2, already_seen: true, retired: false }, | |
{ id: 3, already_seen: true, retired: true } | |
] | |
}) | |
}), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gotcha, that makes much more sense! In YourProjectStatsContainer I started mocking all the requests in useYourProjecStats()
hook, got to the authorization part, and said 'uh oh' better just separate the presentational component into its own stories. I'm going to leave this PR as is, but definitely want to use MSW more in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TBH, if your container passes the ERAS response directly into the presentation component, then you're already testing with mock API data anyway. The subject picker transforms the API response, so mocking tests that the transformation is working.
@@ -0,0 +1,21 @@ | |||
/* Today in UTC */ | |||
export function getTodayDateString() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the helper functions in this file, I considered importing similar functions from lib-user, but the hooks and helpers in lib-user are specific to components that handle stats counts and hours and multiple date ranges. This app-project YourProjectStats doesn't need all the bells and whistles like lib-user, so I decided to define simplified helper functions here.
@@ -0,0 +1,19 @@ | |||
export default function incrementStats(mutate, projectID, userID) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mutate
is specific to useYourProjectStats()
hook. No API calls are made here. The data
returned by the custom hook is just updated per classification submitted.
// only fetch stats when a userID is available. Don't fetch if no user logged in. | ||
const key = authorization && userID ? { endpoint, projectID, userID, authorization } : null | ||
return useSWR(key, fetchStats, SWROptions) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This useSWR
hook will not run with a user is not signed in.
@@ -56,18 +56,14 @@ describe('Stores > Store', function () { | |||
}) | |||
|
|||
describe('when a user signs out', function () { | |||
it('should reset the user project preferences and stats', function () { | |||
it('should reset the user project preferences and sessionCount', function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sessionCount
was previously tied up with user stats in app-project's store. I've completely separated the two in this PR, so there are lots of spec file changes to accommodate.
Session count is used to display the AuthenticationInvitation component when a user is not signed in. It's also used to refetch a user's assigned workflow id every 5 classifications they submit. No effect on user classification stats.
{ activity_count: 23 } | ||
{ | ||
links: { | ||
user: '1' | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
activity_count
is not used for anything in FEM anymore, so don't mock it anywhere.
@@ -250,7 +250,7 @@ export default function ClassifierContainer({ | |||
subjectID={subjectID} | |||
workflowSnapshot={workflowSnapshot} | |||
/> : | |||
<Paragraph>Loading…</Paragraph> | |||
<Paragraph>Loading the Classifier</Paragraph> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is just to differentiate "loading" messages between app-project loading the Classify page, lib-classifier loading the entire Classifier component, and parts of the Classifier that display messages like "Loading a subject".
Eventually the loading UX of lib-classifier will be enhanced with skeleton components, loading animations, polite live regions, etc. See #6484
<ContentBox title={t('Classify.RecentSubjects.title', { projectName })}> | ||
<Paragraph margin={{ top: 'none' }}> | ||
{t('Classify.RecentSubjects.text')} | ||
</Paragraph> | ||
<ContentBox title={t('Classify.RecentSubjects.title')}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removal of the Paragraph is per designer request, and the projectName
variable was never actually used anywhere in this component. It's removed from the translation dictionaries too.
@@ -89,7 +100,7 @@ function ClassifyPage({ | |||
gap='medium' | |||
pad='medium' | |||
> | |||
<Box as='main' fill='horizontal'> | |||
<Box as='main' height={{ min: '400px'}} width='100%'> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gave the classifier area a min-height so the layout jump is not so jarring once lib-classifier is actually imported and displayed. The Classifier loading UX will eventually be enhanced. See #6484
@seanmiller26 after our design chat today I applied as many suggestions as possible. It was more complicated to change the number of subject cards at certain breakpoints than I anticipated, so I left the two options as 3 or 1. However, I was able to adjust the overall container layout so the stats text is never squished, and was able to reduce the squishing of recent subject cards, too. Here's a video of what the changes look like for now, and I definitely think more enhancements can be made once a universal subject card is available for use throughout FEM. recording.mov |
We talked in a Slack huddle and all the styling looks good! |
auth.checkCurrent() | ||
const token = await auth.checkBearerToken() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is inspired by #6345. auth.checkCurrent()
is required to resolve before auth.checkBearerToken()
can be called. Without this pattern, there was sometimes a race condition where a user's stats displayed as 0 : 0.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You’ll need await auth.checkCurrent()
here, otherwise the token can be empty for a signed-in user account.
front-end-monorepo/packages/lib-react-components/src/hooks/useUnreadMessages.js
Lines 20 to 21 in cb221b9
await auth.checkCurrent() | |
const token = await auth.checkBearerToken() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that I look at this hook again, it's broken. It saves the bearer token in component state, so that it gets reused even after it has expired. This hook won't refresh expired auth tokens. Panoptes refresh tokens are valid for something like 2 weeks, but auth tokens only last for two hours. Your code will work for the first two hours of any browser session, then stop fetching after that.
That's my fault. I think I wrote this before I properly understood how OAuth works.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useAuthToken
, in the user library, will refresh tokens when they expire, or are about to expire. It's a variation on the usePrevious
pattern. It returns the current, valid auth token, while silently refreshing a stale value in the background.
import auth from "panoptes-client/lib/auth" | |
import { useState } from "react" | |
const isBrowser = typeof window !== "undefined" | |
let defaultToken | |
/* | |
See comments in https://github.com/zooniverse/front-end-monorepo/pull/6345 | |
Top-level await in modules has been supported in Node | |
and in all browsers since 2021. However, ES modules are still | |
not supported in the monorepo. An immediately-invoked async | |
function is a workaround when top-level await is not supported. | |
https://v8.dev/features/top-level-await | |
*/ | |
(async function getDefaultToken() { | |
defaultToken = null | |
if (isBrowser) { | |
await auth.checkCurrent() | |
defaultToken = await auth.checkBearerToken() | |
} | |
})() | |
export default function usePanoptesAuthToken() { | |
const [token, setToken] = useState(defaultToken) | |
async function fetchPanoptesAuthToken() { | |
await auth.checkCurrent() | |
const newToken = await auth.checkBearerToken() | |
if (newToken !== token) { | |
setToken(newToken) | |
} | |
} | |
fetchPanoptesAuthToken() | |
return token | |
} |
Ready for review!
Package
app-project
Linked Issue and/or Talk Post
Closes: #6400
Closes: #5964
Closes: #5134
Closes: #853
Describe your changes
VisX
can be removed from app-project. That will be done in a separate PR though in order to leave yarn.lock untouched here.How to Review
/stats
page. For instance, the project number of i-fancy-cats is 335, and the ERAS query for stats on the project's classify page and your personal bar chart at https://www.zooniverse.org/users/goplayoutside3/stats?env=staging&project_id=335 are the same./stats
page, and every count will be accurate.project_preferences
to check for a new assigned workflow id (you can see this in the network tab on any project).Checklist
PR Creator - Please cater the checklist to fit the review needed for your code changes.
PR Reviewer - Use the checklist during your review. Each point should be checkmarked or discussed before PR approval.
General
yarn panic && yarn bootstrap
ordocker-compose up --build
and FEM works as expectedGeneral UX
Example Staging Project: i-fancy-cats
Bug Fix
Refactoring
Post-merge