Skip to content

Commit

Permalink
Classifier: refactor UPP data-fetching with hooks
Browse files Browse the repository at this point in the history
Remove data-fetching from the classifier's UPP store and move it into a `useProjectPreferences` hook instead. Preferences should be fetched when the logged-in user changes. UPP updates are still handled by the store, so the store is updated whenever fresh preferences are received from Panoptes.
  • Loading branch information
eatyourgreens committed Apr 13, 2022
1 parent 4ccf7ed commit 3ba1f61
Show file tree
Hide file tree
Showing 10 changed files with 98 additions and 273 deletions.
21 changes: 20 additions & 1 deletion packages/lib-classifier/src/components/Classifier/Classifier.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import asyncStates from '@zooniverse/async-states'
import { Provider } from 'mobx-react'
import PropTypes from 'prop-types'
import React, { useEffect } from 'react'
import zooTheme from '@zooniverse/grommet-theme'
import '../../translations/i18n'
import i18n from 'i18next'

import { usePanoptesUser, useProjectRoles } from '@hooks'
import { usePanoptesUser, useProjectPreferences, useProjectRoles } from '@hooks'
import Layout from './components/Layout'
import ModalTutorial from './components/ModalTutorial'

Expand All @@ -24,6 +25,24 @@ export default function Classifier({

const user = usePanoptesUser()
const projectRoles = useProjectRoles(project?.id, user?.id)
const upp = useProjectPreferences(project?.id, user?.id)

const uppLoading = upp === undefined
const { userProjectPreferences } = classifierStore
// are we replacing a stored UPP?
if (uppLoading && userProjectPreferences.loadingState === asyncStates.success) {
console.log('resetting stale user data')
userProjectPreferences.reset()
}
// store a new UPP
if (userProjectPreferences.loadingState !== asyncStates.success) {
if (upp === null) {
userProjectPreferences.clear()
}
if (upp?.id) {
userProjectPreferences.setUPP(upp)
}
}

const canPreviewWorkflows = projectRoles.indexOf('owner') > -1 ||
projectRoles.indexOf('collaborator') > -1 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { GraphQLClient } from 'graphql-request'
import { Paragraph } from 'grommet'
import { Provider } from 'mobx-react'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import React, { useEffect } from 'react'
import '../../translations/i18n'
import {
env,
Expand Down Expand Up @@ -61,7 +61,6 @@ export default function ClassifierContainer({
subjectSetID,
workflowID
}) {
const [loaded, setLoaded] = useState(false)
const storeEnvironment = { authClient, client }

const workflowSnapshot = useWorkflowSnapshot(workflowID)
Expand Down Expand Up @@ -98,26 +97,17 @@ export default function ClassifierContainer({
Otherwise, hydration will overwrite the callbacks with
their defaults.
*/
const { classifications, subjects, userProjectPreferences } = classifierStore
console.log('resetting stale user data')
userProjectPreferences.reset()
const { classifications, subjects } = classifierStore
console.log('setting classifier event callbacks')
classifications.setOnComplete(onCompleteClassification)
subjects.setOnReset(onSubjectReset)
classifierStore.setOnAddToCollection(onAddToCollection)
classifierStore.setOnSubjectChange(onSubjectChange)
classifierStore.setOnToggleFavourite(onToggleFavourite)
setLoaded(true)
}, [])

useEffect(function onAuthChange() {
if (loaded) {
classifierStore.userProjectPreferences.checkForUser()
}
}, [loaded, authClient])

try {
if (loaded) {
if (classifierStore) {

return (
<Provider classifierStore={classifierStore}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,10 @@ describe('ModalTutorial', function () {
expect(tutorialTitle).to.be.null()
})

it('should not show the tutorial if it has been seen before', async function () {
it('should not show the tutorial if it has been seen before', function () {
const store = mockStore()
const tutorialSnapshot = TutorialFactory.build({ steps })
store.tutorials.setTutorials([tutorialSnapshot])
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
store.userProjectPreferences.setHeaders({
Expand All @@ -65,11 +64,10 @@ describe('ModalTutorial', function () {
expect(tutorialTitle).to.be.null()
})

it('should show the tutorial if it hasn\'t been seen before', async function () {
it('should show the tutorial if it hasn\'t been seen before', function () {
const store = mockStore()
const tutorialSnapshot = TutorialFactory.build({ steps })
store.tutorials.setTutorials([tutorialSnapshot])
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
store.userProjectPreferences.setHeaders({
Expand All @@ -96,7 +94,6 @@ describe('ModalTutorial', function () {
store = mockStore()
const tutorialSnapshot = TutorialFactory.build({ steps })
store.tutorials.setTutorials([tutorialSnapshot])
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
store.userProjectPreferences.setHeaders({
Expand Down
7 changes: 7 additions & 0 deletions packages/lib-classifier/src/hooks/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ Get the logged-in user, or null if no one is logged in.
const user = usePanoptesUser()
```

## useProjectPreferences

Get project preferences for a user and project, or null if there's no one logged in.
```js
const upp = useProjectPreferences(project.id, user.id)
```

## useProjectRoles

Get the logged-in user's project roles, as an array of strings, or an empty array if no one is logged in.
Expand Down
1 change: 1 addition & 0 deletions packages/lib-classifier/src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { default as useHydratedStore } from './useHydratedStore'
export { default as usePanoptesAuth } from './usePanoptesAuth'
export { default as usePanoptesUser } from './usePanoptesUser'
export { default as useProjectPreferences } from './useProjectPreferences'
export { default as useProjectRoles } from './useProjectRoles'
export { default as useStores } from './useStores'
export { default as useWorkflowSnapshot } from './useWorkflowSnapshot'
53 changes: 53 additions & 0 deletions packages/lib-classifier/src/hooks/useProjectPreferences.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import useSWR from 'swr'
import { panoptes } from '@zooniverse/panoptes-js'

import { usePanoptesAuth } from './'

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

async function createProjectPreferences({ endpoint, project_id, authorization }) {
const data = {
links: { project: project_id },
preferences: {}
}
const { body } = await panoptes.post(endpoint, { project_preferences: data }, { authorization })
const [projectPreferences] = body.project_preferences
return projectPreferences
}

async function fetchProjectPreferences({ endpoint, project_id, user_id, authorization }) {
const { body } = await panoptes.get(endpoint, { project_id, user_id }, { authorization })
const [projectPreferences] = body.project_preferences
return projectPreferences
}

async function fetchOrCreateProjectPreferences({ endpoint, project_id, user_id, authorization }) {
// auth is undefined while loading
if (authorization === undefined) {
return undefined
}
// logged-in
if (authorization) {
const projectPreferences = await fetchProjectPreferences({ endpoint, project_id, user_id, authorization })
if (projectPreferences) {
return projectPreferences
} else {
return await createProjectPreferences({ endpoint, project_id, authorization })
}
}
// not logged-in
return null
}

export default function useProjectPreferences(project_id, user_id) {
const authorization = usePanoptesAuth(user_id)
const endpoint = '/project_preferences'
const { data } = useSWR({ endpoint, project_id, user_id, authorization }, fetchOrCreateProjectPreferences, SWRoptions)
return data
}
7 changes: 4 additions & 3 deletions packages/lib-classifier/src/hooks/useProjectRoles.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ const SWRoptions = {
refreshInterval: 0
}

async function fetchProjectRoles({ project_id, user_id, authorization }) {
async function fetchProjectRoles({ endpoint, project_id, user_id, authorization }) {
if (authorization) {
const { body } = await panoptes.get(`/project_roles`, { project_id, user_id }, { authorization })
const { body } = await panoptes.get(endpoint, { project_id, user_id }, { authorization })
const [projectRoles] = body.project_roles
return projectRoles.roles
}
Expand All @@ -22,6 +22,7 @@ async function fetchProjectRoles({ project_id, user_id, authorization }) {

export default function useProjectRoles(project_id, user_id) {
const authorization = usePanoptesAuth(user_id)
const { data } = useSWR({ project_id, user_id, authorization }, fetchProjectRoles, SWRoptions)
const endpoint = '/project_roles'
const { data } = useSWR({ endpoint, project_id, user_id, authorization }, fetchProjectRoles, SWRoptions)
return data ?? []
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,19 @@ describe('Model > Tutorial', function () {
})

it('should be false while UPP loads', function () {
store.userProjectPreferences.fetchUPP({ id: 'test' })
const tutorial = store.tutorials.active
expect(tutorial.hasNotBeenSeen).to.be.false()
})

it('should be true for anonymous users', async function () {
let tutorial = store.tutorials.active
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
store.userProjectPreferences.clear()
tutorial = store.tutorials.active
expect(tutorial.hasNotBeenSeen).to.be.true()
})

it('should be true after the user has loaded', async function () {
let tutorial = store.tutorials.active
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
tutorial = store.tutorials.active
Expand All @@ -44,7 +42,6 @@ describe('Model > Tutorial', function () {

it('should be false after a user has seen the tutorial', async function () {
let tutorial = store.tutorials.active
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
store.userProjectPreferences.setHeaders({
Expand All @@ -63,7 +60,6 @@ describe('Model > Tutorial', function () {
store = mockStore()
const tutorialSnapshot = TutorialFactory.build()
store.tutorials.setTutorials([tutorialSnapshot])
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
store.userProjectPreferences.setHeaders({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,85 +16,9 @@ const UserProjectPreferencesStore = types

// TODO: move some of these req into panoptes.js helpers
.actions(self => {
function afterAttach () {
createProjectObserver()
}

function createProjectObserver () {
const projectDisposer = autorun(() => {
const validProjectReference = isValidReference(() => getRoot(self).projects.active)

if (validProjectReference) {
self.reset()
self.checkForUser()
}
}, { name: 'UPPStore Project Observer autorun' })
addDisposer(self, projectDisposer)
}

function * createUPP (authorization) {
const { type } = self
const client = getRoot(self).client.panoptes
const project = getRoot(self).projects.active
const data = {
links: { project: project.id },
preferences: {}
}
self.loadingState = asyncStates.posting
try {
const response = yield client.post(`/${type}`, { [type]: data }, { authorization })
self.headers = response.headers
return response.body[type][0]
} catch (error) {
console.error(error)
self.loadingState = asyncStates.error
return null
}
}

function * checkForUser () {
const { authClient } = getRoot(self)

try {
const user = yield authClient.checkCurrent()

if (user) {
self.fetchUPP(user)
} else {
self.reset()
self.loadingState = asyncStates.success
}
} catch (error) {
console.error(error)
self.loadingState = asyncStates.error
}
}

function * fetchUPP (user) {
let resource
const { type } = self
const { authClient } = getRoot(self)
const client = getRoot(self).client.panoptes
const project = getRoot(self).projects.active

self.loadingState = asyncStates.loading
try {
const authorization = yield getBearerToken(authClient)
const response = yield client.get(`/${type}`, { project_id: project.id, user_id: user.id }, { authorization })
if (response.body[type][0]) {
// We don't store the headers from this get response because it's by query params
// and not for a specific resource, so the etag won't be usable for the later PUT request
resource = response.body[type][0]
} else {
resource = yield self.createUPP(authorization)
}

self.loadingState = asyncStates.success
if (resource) self.setUPP(resource)
} catch (error) {
console.error(error)
self.loadingState = asyncStates.error
}
function clear() {
self.resources.clear()
self.loadingState = asyncStates.success
}

function * fetchUPPById (id = '') {
Expand Down Expand Up @@ -181,13 +105,11 @@ const UserProjectPreferencesStore = types
function setUPP (userProjectPreferences) {
self.setResources([userProjectPreferences])
self.setActive(userProjectPreferences.id)
self.loadingState = asyncStates.success
}

return {
afterAttach,
checkForUser: flow(checkForUser),
createUPP: flow(createUPP),
fetchUPP: flow(fetchUPP),
clear,
fetchUPPById: flow(fetchUPPById),
putUPP: flow(putUPP),
setHeaders,
Expand Down
Loading

0 comments on commit 3ba1f61

Please sign in to comment.