Skip to content

Commit

Permalink
Classifier: Refactor ClassifierContainer hooks (#2793)
Browse files Browse the repository at this point in the history
* Refactor useStore and useWorkflowSnapshot
- Add a `hooks` directory to the `Classifier` component. Import the `useStore` and `useWorkflowSnapshot` hooks from that directory.
- Explicitly declare `SWR` options in the the `useWorkflowSnapshot` hook.

* Add useHydratedStore
Move the store hydration and initialisation code into a `useHydratedStore` hook.

* Separate out classifier callbacks from hydration

* Clean up useHydratedStore
Move store clean-up into `ClassifierContainer`. `useHydratedStore` hydrates the store but doesn't try to alter it after loading it.

* Add store.startClassification
`store.startClassification` allows us to start a new classification automatically, when the subject queue advances, or manually, after loading a workflow from Panoptes.

* Add debugging logs to Classifier hooks

* Remove unused i18n

Co-authored-by: Delilah C. <23665803+goplayoutside3@users.noreply.github.com>

* whitespace

Co-authored-by: Delilah C. <23665803+goplayoutside3@users.noreply.github.com>

* Add a hooks Readme

Co-authored-by: Delilah C. <23665803+goplayoutside3@users.noreply.github.com>
  • Loading branch information
eatyourgreens and goplayoutside3 authored Feb 18, 2022
1 parent 51f0731 commit 88bc3b8
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 99 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default function Classifier({
useEffect(function onURLChange() {
const { workflows } = classifierStore
if (workflowID) {
console.log('starting new subject queue', { workflowID, subjectSetID, subjectID })
workflows.selectWorkflow(workflowID, subjectSetID, subjectID)
}
}, [subjectID, subjectSetID, workflowID])
Expand All @@ -51,9 +52,10 @@ export default function Classifier({
if (workflowSnapshot) {
// pass the subjectSetID prop into the store as part of the new workflow data
workflowSnapshot.subjectSet = subjectSetID
console.log('Refreshing workflow snapshot', workflowSnapshot.id)
workflows.setResources([workflowSnapshot])
// TODO: the task area crashes without the following line. Why is that?
subjects.setActiveSubject(subjects.active?.id)
classifierStore.startClassification()
}
}, [workflowVersion])

Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import { GraphQLClient } from 'graphql-request'
import { Paragraph } from 'grommet'
import makeInspectable from 'mobx-devtools-mst'
import { Provider } from 'mobx-react'
import { persist } from 'mst-persist'
import useSWR from 'swr'
import PropTypes from 'prop-types'
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect } from 'react'
import '../../translations/i18n'
import i18n from 'i18next'
import {
env,
panoptes as panoptesClient,
projects as projectsClient,
tutorials as tutorialsClient
} from '@zooniverse/panoptes-js'

import { asyncSessionStorage } from '@helpers'
import { useHydratedStore, useStore, useWorkflowSnapshot } from './hooks'
import { unregisterWorkers } from '../../workers'
import RootStore from '../../store'
import Classifier from './Classifier'

// import { isBackgroundSyncAvailable } from '../../helpers/featureDetection'
Expand Down Expand Up @@ -50,41 +45,6 @@ const client = {
// So we'll unregister the worker for now.
unregisterWorkers('./queue.js')

let store

function initStore({ authClient, client, initialState }) {
if (!store) {
store = RootStore.create(initialState, {
authClient,
client
})
makeInspectable(store)
}
return store
}
/**
useStore hook adapted from
https://github.com/vercel/next.js/blob/5201cdbaeaa72b54badc8f929ddc73c09f414dc4/examples/with-mobx-state-tree/store.js#L49-L52
*/
function useStore({ authClient, client, initialState }) {
const _store = useMemo(() => initStore({ authClient, client, initialState }), [authClient, initialState])
return _store
}

async function fetchWorkflow(workflowID) {
if (workflowID) {
const { body } = await panoptesClient.get(`/workflows/${workflowID}`)
const [ workflowSnapshot ] = body.workflows
return workflowSnapshot
}
return null
}

function useWorkflowSnapshot(workflowID) {
const { data } = useSWR(workflowID, fetchWorkflow)
return data ?? null
}

export default function ClassifierContainer({
authClient,
cachePanoptesData = false,
Expand All @@ -107,50 +67,42 @@ export default function ClassifierContainer({
initialState: {}
})

const [loaded, setLoaded] = useState(false)
const workflowSnapshot = useWorkflowSnapshot(workflowID)

async function onMount() {
if (cachePanoptesData) {
try {
const storageKey = `fem-classifier-${project.id}`
await persist(storageKey, classifierStore, {
storage: asyncSessionStorage,
whitelist: ['fieldGuide', 'projects', 'subjects', 'subjectSets', 'tutorials', 'workflows', 'workflowSteps']
})
console.log('store hydrated from local storage')
const { subjects, workflows } = classifierStore
if (!workflows.active?.prioritized) {
/*
In this case, we delete the saved queue so that
refreshing the classifier will load a new, randomised
subject queue.
*/
subjects.reset()
}
if (subjects.active) {
/*
This is a hack to start a new classification from a snapshot.
*/
subjects.setActiveSubject(subjects.active.id)
}
} catch (error) {
console.log('store snapshot error.')
console.error(error)
const loaded = useHydratedStore(classifierStore, cachePanoptesData, `fem-classifier-${project.id}`)

useEffect(function onLoad() {
/*
This should run after the store is created and hydrated.
Otherwise, hydration will overwrite the callbacks with
their defaults.
*/
if (loaded) {
const { classifications, subjects, workflows } = classifierStore
if (!workflows.active?.prioritized) {
/*
In this case, we delete the saved queue so that
refreshing the classifier will load a new, randomised
subject queue.
*/
console.log('randomising the subject queue.')
subjects.reset()
}
if (subjects.active) {
/*
This is a hack to start a new classification from a snapshot.
*/
console.log('store hydrated with active subject', subjects.active.id)
classifierStore.startClassification()
}
console.log('setting classifier event callbacks')
classifierStore.setOnAddToCollection(onAddToCollection)
classifications.setOnComplete(onCompleteClassification)
classifierStore.setOnSubjectChange(onSubjectChange)
subjects.setOnReset(onSubjectReset)
classifierStore.setOnToggleFavourite(onToggleFavourite)
}
const { classifications, subjects } = classifierStore
classifierStore.setOnAddToCollection(onAddToCollection)
classifications.setOnComplete(onCompleteClassification)
classifierStore.setOnSubjectChange(onSubjectChange)
subjects.setOnReset(onSubjectReset)
classifierStore.setOnToggleFavourite(onToggleFavourite)
setLoaded(true)
}

useEffect(() => {
onMount()
}, [])
}, [loaded])

useEffect(function onAuthChange() {
if (loaded) {
Expand Down
26 changes: 26 additions & 0 deletions packages/lib-classifier/src/components/Classifier/hooks/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Classifier hooks

## useHydratedStore

Hydrate a `mobx-state-tree` store from a stored [snapshot](https://mobx-state-tree.js.org/concepts/snapshots). Wraps the store in [`mst-persist`](https://github.com/agilgur5/mst-persist), which runs `applySnapshot(store, snapshot)` to hydrate the store, and adds an `onSnapshot` handler to keep the stored snapshot updated when the store changes.

Runs asynchronously and returns `true` when hydration is complete. Snapshots are stored in session storage, so that they don't persist across tabs or windows.

```js
const loaded = useHydratedStore(store, enableStorage = false, storageKey)
```

## useStore

Create a `mobx-state-tree` store, using the Panoptes API clients and an optional snapshot. Adapted from [the NextJS example](https://github.com/vercel/next.js/blob/5201cdbaeaa72b54badc8f929ddc73c09f414dc4/examples/with-mobx-state-tree/store.js#L49-L52), which is also used in `app-project`. `initialState` must be a valid store snapshot.

```js
const classifierStore = useStore({ authClient, client, initialState })
````
## useWorkflowSnapshot

A wrapper for [`useSWR`](https://swr.vercel.app/), which fetches a workflow by ID, using the default SWR options. The workflow will refresh on visibility change (eg. waking from sleep), or when the classifier receives focus.

```js
const workflowSnapshot = useWorkflowSnapshot(workflowID)
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as useHydratedStore } from './useHydratedStore'
export { default as useStore } from './useStore'
export { default as useWorkflowSnapshot } from './useWorkflowSnapshot'
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { persist } from 'mst-persist'
import { useEffect, useState } from 'react'

import { asyncSessionStorage } from '@helpers'

async function hydrateStore(storageKey, classifierStore) {
try {
await persist(storageKey, classifierStore, {
storage: asyncSessionStorage,
whitelist: ['fieldGuide', 'projects', 'subjects', 'subjectSets', 'tutorials', 'workflows', 'workflowSteps']
})
console.log('store hydrated from local storage')
} catch (error) {
console.log('store snapshot error.')
console.error(error)
}
}

export default function useHydratedStore(classifierStore, cachePanoptesData = false, storageKey) {
const [loaded, setLoaded] = useState(false)

async function onMount() {
if (cachePanoptesData) {
await hydrateStore(storageKey, classifierStore)
}
setLoaded(true)
}

useEffect(() => {
onMount()
}, [])
return loaded
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import makeInspectable from 'mobx-devtools-mst'
import { useMemo } from 'react'

import RootStore from '@store'

let store

function initStore({ authClient, client, initialState }) {
if (!store) {
store = RootStore.create(initialState, {
authClient,
client
})
makeInspectable(store)
}
return store
}
/**
useStore hook adapted from
https://github.com/vercel/next.js/blob/5201cdbaeaa72b54badc8f929ddc73c09f414dc4/examples/with-mobx-state-tree/store.js#L49-L52
*/
export default function useStore({ authClient, client, initialState }) {
const _store = useMemo(() => initStore({ authClient, client, initialState }), [authClient, initialState])
return _store
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import useSWR from 'swr'

import { panoptes } from '@zooniverse/panoptes-js'

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

async function fetchWorkflow(workflowID) {
if (workflowID) {
const { body } = await panoptes.get(`/workflows/${workflowID}`)
const [ workflowSnapshot ] = body.workflows
return workflowSnapshot
}
return null
}

export default function useWorkflowSnapshot(workflowID) {
const { data } = useSWR(workflowID, fetchWorkflow, SWRoptions)
return data ?? null
}
33 changes: 17 additions & 16 deletions packages/lib-classifier/src/store/RootStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const RootStore = types
function _addMiddleware(call, next, abort) {
if (call.name === 'setActiveSubject') {
const res = next(call)
onSubjectAdvance()
self.startClassification()
return res
}
return next(call)
Expand All @@ -77,20 +77,6 @@ const RootStore = types
}
}

function onSubjectAdvance () {
const { classifications, feedback, projects, subjects, workflows, workflowSteps } = self
const subject = tryReference(() => subjects?.active)
const workflow = tryReference(() => workflows?.active)
const project = tryReference(() => projects?.active)
if (subject && workflow && project) {
workflowSteps.resetSteps()
classifications.reset()
classifications.createClassification(subject, workflow, project)
feedback.onNewSubject()
self.onSubjectChange(getSnapshot(subject))
}
}

// Public actions
function afterCreate () {
addMiddleware(self, _addMiddleware)
Expand All @@ -114,12 +100,27 @@ const RootStore = types
self.onToggleFavourite = callback
}

function startClassification() {
const { classifications, feedback, projects, subjects, workflows, workflowSteps } = self
const subject = tryReference(() => subjects?.active)
const workflow = tryReference(() => workflows?.active)
const project = tryReference(() => projects?.active)
if (subject && workflow && project) {
workflowSteps.resetSteps()
classifications.reset()
classifications.createClassification(subject, workflow, project)
feedback.onNewSubject()
self.onSubjectChange(getSnapshot(subject))
}
}

return {
afterCreate,
setLocale,
setOnAddToCollection,
setOnSubjectChange,
setOnToggleFavourite
setOnToggleFavourite,
startClassification
}
})

Expand Down

0 comments on commit 88bc3b8

Please sign in to comment.