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

Classifier: refactor UPP data-fetching with hooks #2988

Merged
merged 1 commit into from
Aug 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion packages/lib-classifier/src/components/Classifier/Classifier.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import asyncStates from '@zooniverse/async-states'
import { applySnapshot, getSnapshot } from 'mobx-state-tree'
import PropTypes from 'prop-types'
import React, { useEffect } from 'react'
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,7 @@ export default function Classifier({
const user = usePanoptesUser()
const projectRoles = useProjectRoles(project?.id, user?.id)
let workflowVersionChanged = false

if (workflowSnapshot) {
const storedWorkflow = classifierStore.workflows.resources.get(workflowSnapshot.id)
workflowVersionChanged = workflowSnapshot.version !== storedWorkflow?.version
Expand All @@ -35,6 +37,25 @@ export default function Classifier({
workflowSnapshot = storedWorkflow ? { ...getSnapshot(storedWorkflow), ...workflowSnapshot } : workflowSnapshot
}

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 ||
projectRoles.indexOf('tester') > -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, { StrictMode, useEffect, useState } from 'react'
import React, { StrictMode, 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 All @@ -82,26 +81,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 (
<StrictMode>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { render, screen } from '@testing-library/react'
import asyncStates from '@zooniverse/async-states'
import zooTheme from '@zooniverse/grommet-theme'
import { Grommet } from 'grommet'
import { when } from 'mobx'
import { Provider } from 'mobx-react'

import { DrawingTaskFactory, UPPFactory, WorkflowFactory } from '@test/factories'
Expand Down Expand Up @@ -125,7 +124,6 @@ describe('Components > MetaTools', function () {

before(async function () {
const store = mockStore()
await when(() => store.userProjectPreferences.loadingState === asyncStates.success)
const upp = UPPFactory.build()
store.userProjectPreferences.setUPP(upp)
store.userProjectPreferences.setHeaders({
Expand Down Expand Up @@ -176,7 +174,6 @@ describe('Components > MetaTools', function () {

before(async function () {
const store = mockStore()
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 @@ -3,7 +3,6 @@ import { Grommet } from 'grommet'
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { when } from 'mobx'
import { Provider } from 'mobx-react'
import sinon from 'sinon'

Expand Down Expand Up @@ -43,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 @@ -66,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 Down Expand Up @@ -99,7 +96,6 @@ describe('ModalTutorial', function () {
user = userEvent.setup({ delay: null })
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 @@ -41,6 +41,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
Expand Up @@ -3,6 +3,7 @@ export { default as useHydratedStore } from './useHydratedStore'
export { default as usePanoptesAuth } from './usePanoptesAuth'
export { default as usePanoptesTranslations } from './usePanoptesTranslations'
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 = {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 consistent with other hooks using SWR, LGTM

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
Copy link
Contributor

Choose a reason for hiding this comment

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

loadingState is defined in the ResourceStore model

}

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