diff --git a/packages/lib-classifier/src/components/Classifier/Classifier.js b/packages/lib-classifier/src/components/Classifier/Classifier.js index 3fdfc111d51..b49556e02a8 100644 --- a/packages/lib-classifier/src/components/Classifier/Classifier.js +++ b/packages/lib-classifier/src/components/Classifier/Classifier.js @@ -1,3 +1,4 @@ +import asyncStates from '@zooniverse/async-states' import { Provider } from 'mobx-react' import PropTypes from 'prop-types' import React, { useEffect } from 'react' @@ -5,7 +6,7 @@ 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' @@ -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 || diff --git a/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js b/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js index 48b941c3beb..5e05b48253e 100644 --- a/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js +++ b/packages/lib-classifier/src/components/Classifier/ClassifierContainer.js @@ -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, @@ -61,7 +61,6 @@ export default function ClassifierContainer({ subjectSetID, workflowID }) { - const [loaded, setLoaded] = useState(false) const storeEnvironment = { authClient, client } const workflowSnapshot = useWorkflowSnapshot(workflowID) @@ -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 ( diff --git a/packages/lib-classifier/src/components/Classifier/components/ModalTutorial/ModalTutorial.spec.js b/packages/lib-classifier/src/components/Classifier/components/ModalTutorial/ModalTutorial.spec.js index ce0deca0118..1cfd579db64 100644 --- a/packages/lib-classifier/src/components/Classifier/components/ModalTutorial/ModalTutorial.spec.js +++ b/packages/lib-classifier/src/components/Classifier/components/ModalTutorial/ModalTutorial.spec.js @@ -40,7 +40,6 @@ describe('ModalTutorial', 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({ diff --git a/packages/lib-classifier/src/hooks/Readme.md b/packages/lib-classifier/src/hooks/Readme.md index ac7e20037f9..7e66e17e0d2 100644 --- a/packages/lib-classifier/src/hooks/Readme.md +++ b/packages/lib-classifier/src/hooks/Readme.md @@ -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. diff --git a/packages/lib-classifier/src/hooks/index.js b/packages/lib-classifier/src/hooks/index.js index a19bca3b077..903ef00a10e 100644 --- a/packages/lib-classifier/src/hooks/index.js +++ b/packages/lib-classifier/src/hooks/index.js @@ -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' diff --git a/packages/lib-classifier/src/hooks/useProjectPreferences.js b/packages/lib-classifier/src/hooks/useProjectPreferences.js new file mode 100644 index 00000000000..509bfea248d --- /dev/null +++ b/packages/lib-classifier/src/hooks/useProjectPreferences.js @@ -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 +} diff --git a/packages/lib-classifier/src/hooks/useProjectRoles.js b/packages/lib-classifier/src/hooks/useProjectRoles.js index 2bd492cab43..15bf45d996c 100644 --- a/packages/lib-classifier/src/hooks/useProjectRoles.js +++ b/packages/lib-classifier/src/hooks/useProjectRoles.js @@ -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 } @@ -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 ?? [] } diff --git a/packages/lib-classifier/src/store/TutorialStore/Tutorial/Tutorial.spec.js b/packages/lib-classifier/src/store/TutorialStore/Tutorial/Tutorial.spec.js index b0655c08b34..c672578bd59 100644 --- a/packages/lib-classifier/src/store/TutorialStore/Tutorial/Tutorial.spec.js +++ b/packages/lib-classifier/src/store/TutorialStore/Tutorial/Tutorial.spec.js @@ -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 @@ -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({ @@ -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({ diff --git a/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.js b/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.js index 5ba5ae1df79..2cdcb85f114 100644 --- a/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.js +++ b/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.js @@ -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 = '') { @@ -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, diff --git a/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.spec.js b/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.spec.js index 2e3c16e2359..3a6abd04a25 100644 --- a/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.spec.js +++ b/packages/lib-classifier/src/store/UserProjectPreferencesStore/UserProjectPreferencesStore.spec.js @@ -88,173 +88,11 @@ describe('Model > UserProjectPreferencesStore', function () { expect(UserProjectPreferencesStore).to.be.an('object') }) - it('should remain in an initialized state if there is no project', function () { + it('should be in an initialized state', function () { rootStore = setupStores(clientStub, authClientStubWithoutUser) expect(rootStore.userProjectPreferences.loadingState).to.equal(asyncStates.initialized) rootStore = null }) - - it('should set project preferences if there is a user and a project', async function () { - rootStore = setupStores(clientStub, authClientStubWithUser) - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - const uppInStore = rootStore.userProjectPreferences.active - expect(uppInStore.toJSON()).to.deep.equal(upp) - rootStore = null - }) - }) - - describe('Actions > checkForUser', function () { - let rootStore - beforeEach(function () { - rootStore = null - }) - - it('should check for a user upon initialization when there is a project', async function () { - rootStore = setupStores(clientStub, authClientStubWithoutUser) - await rootStore.projects.setActive(project.id) - expect(authClientStubWithoutUser.checkCurrent).to.have.been.called() - }) - - it('should set to a successful state if there is no user', async function () { - rootStore = setupStores(clientStub, authClientStubWithoutUser) - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(rootStore.userProjectPreferences.loadingState).to.equal(asyncStates.success) - expect(rootStore.userProjectPreferences.active).to.be.undefined() - }) - - it('should set state to error upon error', async function () { - const errorAuthClientStub = { - checkBearerToken: () => Promise.reject(new Error('testing error handling')), - checkCurrent: () => Promise.reject(new Error('testing error handling')) - } - - rootStore = setupStores(clientStub, errorAuthClientStub) - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(rootStore.userProjectPreferences.loadingState).to.equal(asyncStates.error) - }) - - it.skip('should call fetchUPP if there is a user', async function () { - rootStore = setupStores(clientStub, authClientStubWithUser) - const fetchUPPSpy = sinon.spy(rootStore.userProjectPreferences, 'fetchUPP') - - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(fetchUPPSpy).to.have.been.called() - }) - }) - - describe('Actions > fetchUPP', function () { - let getSpy - let rootStore - before(function () { - getSpy = sinon.spy(clientStub.panoptes, 'get') - }) - - afterEach(function () { - getSpy.resetHistory() - rootStore = null - }) - - after(function () { - getSpy.restore() - }) - - it('should set the loading state to loading then to success upon successful request', async function () { - rootStore = setupStores(clientStub, authClientStubWithUser) - rootStore.projects.setActive(project.id) - expect(rootStore.userProjectPreferences.loadingState).to.equal(asyncStates.initialized) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(rootStore.userProjectPreferences.loadingState).to.equal(asyncStates.success) - }) - - it('should request for the user project preferences', async function () { - rootStore = setupStores(clientStub, authClientStubWithUser) - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(getSpy).to.have.been.calledWith( - '/project_preferences', - { project_id: project.id, user_id: user.id }, - { authorization: 'Bearer 1234' } - ) - }) - - it.skip('should call createUPP action upon successful request and there is not an existing UPP', async function () { - rootStore = setupStores(clientStubWithoutUPP, authClientStubWithUser) - const createUPPSpy = sinon.spy(rootStore.userProjectPreferences, 'createUPP') - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(createUPPSpy).to.have.been.calledOnceWith(`Bearer ${token}`) - createUPPSpy.restore() - }) - - it.skip('should call setUPP action upon successful request and there is an existing UPP', async function () { - rootStore = setupStores(clientStub, authClientStubWithUser) - const setUPPSpy = sinon.spy(rootStore.userProjectPreferences, 'setUPP') - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(setUPPSpy).to.have.been.calledOnceWith(upp) - setUPPSpy.restore() - }) - }) - - describe('Actions > createUPP', function () { - let rootStore - beforeEach(function () { - rootStore = null - }) - - it('should create new user project preferences', async function () { - const postStub = sinon.stub().callsFake(() => Promise.resolve({ body: { project_preferences: [upp] } })) - const panoptesClientStub = { - panoptes: { - get: (url) => { - if (url === `/projects/${project.id}`) return Promise.resolve({ body: { projects: [project] } }) - return Promise.resolve({ body: { - subjects: Factory.buildList('subject', 10), - project_preferences: [], - workflows: [workflow] - } }) - }, - post: postStub - } - } - - rootStore = setupStores(panoptesClientStub, authClientStubWithUser) - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(postStub).to.have.been.calledOnceWith( - '/project_preferences', - { project_preferences: { - links: { project: project.id }, - preferences: {} - } }, - { authorization: `Bearer ${token}` } - ) - }) - - it('should set the loading state to error upon error', async function () { - const panoptesClientStub = { - panoptes: { - get: (url) => { - if (url === `/projects/${project.id}`) return Promise.resolve({ body: { projects: [project] } }) - return Promise.resolve({ body: { - subjects: Factory.buildList('subject', 10), - project_preferences: [], - workflows: [workflow] - } }) - }, - post: () => Promise.reject(new Error('testing error handling')) - } - } - - rootStore = setupStores(panoptesClientStub, authClientStubWithUser) - rootStore.projects.setActive(project.id) - await when(preferencesAreReady(rootStore.userProjectPreferences)) - expect(rootStore.userProjectPreferences.loadingState).to.equal(asyncStates.error) - }) }) describe('Actions > updateUPP', function () { @@ -296,6 +134,7 @@ describe('Model > UserProjectPreferencesStore', function () { rootStore = setupStores(panoptesClientStub, authClientStubWithUser) rootStore.projects.setActive(project.id) + rootStore.userProjectPreferences.setUPP(upp) }) afterEach(function () {