diff --git a/packages/lib-classifier/src/store/SubjectSet/SubjectSet.js b/packages/lib-classifier/src/store/SubjectSet/SubjectSet.js index 1a174953c0..bff3ad1cdd 100644 --- a/packages/lib-classifier/src/store/SubjectSet/SubjectSet.js +++ b/packages/lib-classifier/src/store/SubjectSet/SubjectSet.js @@ -5,7 +5,13 @@ const SubjectSet = types .model('SubjectSet', { display_name: types.string, links: types.frozen({}), + metadata: types.frozen({}), set_member_subjects_count: types.number }) + .views(self => ({ + get isIndexed () { + return self.metadata.indexFields?.length > 0 + } + })) export default types.compose('SubjectSetResource', Resource, SubjectSet) diff --git a/packages/lib-classifier/src/store/SubjectSet/SubjectSet.spec.js b/packages/lib-classifier/src/store/SubjectSet/SubjectSet.spec.js index 40d8c92bc1..2932a8284a 100644 --- a/packages/lib-classifier/src/store/SubjectSet/SubjectSet.spec.js +++ b/packages/lib-classifier/src/store/SubjectSet/SubjectSet.spec.js @@ -23,4 +23,61 @@ describe('Model > SubjectSet', function () { it('should have a subject count', function () { expect(model.set_member_subjects_count).to.equal(19) }) + + describe('isIndexed', function () { + let isIndexed + + describe('without metadata', function () { + before(function () { + const metadataModel = SubjectSet.create({ + id: '1234', + display_name: 'Hello there!', + set_member_subjects_count: 19 + }) + isIndexed = metadataModel.isIndexed + }) + + it('should be false', function () { + expect(isIndexed).to.be.false() + }) + }) + + describe('with metadata', function () { + describe('but no indexed fields', function () { + before(function () { + const metadataModel = SubjectSet.create({ + id: '1234', + display_name: 'Hello there!', + metadata: { + indexFields: '' + }, + set_member_subjects_count: 19 + }) + isIndexed = metadataModel.isIndexed + }) + + it('should be false', function () { + expect(isIndexed).to.be.false() + }) + }) + + describe('and indexed fields', function () { + before(function () { + const metadataModel = SubjectSet.create({ + id: '1234', + display_name: 'Hello there!', + metadata: { + indexFields: 'Date,Creator' + }, + set_member_subjects_count: 19 + }) + isIndexed = metadataModel.isIndexed + }) + + it('should be true', function () { + expect(isIndexed).to.be.true() + }) + }) + }) + }) }) diff --git a/packages/lib-classifier/src/store/SubjectStore.js b/packages/lib-classifier/src/store/SubjectStore.js index 6daad1bacd..4fb75cca70 100644 --- a/packages/lib-classifier/src/store/SubjectStore.js +++ b/packages/lib-classifier/src/store/SubjectStore.js @@ -2,6 +2,7 @@ import asyncStates from '@zooniverse/async-states' import { autorun } from 'mobx' import { addDisposer, addMiddleware, flow, getRoot, isValidReference, onPatch, tryReference, types } from 'mobx-state-tree' import { getBearerToken } from './utils' +import { subjectSelectionStrategy } from './helpers' import { filterByLabel, filters } from '../components/Classifier/components/MetaTools/components/Metadata/components/MetadataModal' import ResourceStore from './ResourceStore' import Subject from './Subject' @@ -56,6 +57,17 @@ const SubjectStore = types } return false + }, + + /** a helper to get the last subject in the queue */ + get last () { + let lastSubject + + if ( self.resources.size > 0 ) { + const activeSubjects = Array.from(self.resources.values()) + lastSubject = activeSubjects[self.resources.size - 1] + } + return lastSubject } })) @@ -152,26 +164,10 @@ const SubjectStore = types const root = getRoot(self) const client = root.client.panoptes const workflow = tryReference(() => root.workflows.active) - let apiUrl = '/subjects/queued' if (workflow) { self.loadingState = asyncStates.loading - const params = { workflow_id: workflow.id } - - if (workflow.grouped) { - params.subject_set_id = workflow.subjectSetId - } - - if (workflow.configuration.subject_viewer === 'subjectGroup') { - apiUrl = '/subjects/grouped' - params.num_rows = workflow.configuration.subject_viewer_config?.grid_rows || 1 - params.num_columns = workflow.configuration.subject_viewer_config?.grid_columns || 1 - } - - if (subjectIDs) { - apiUrl = '/subjects/selection' - params.ids = subjectIDs - } + const { apiUrl, params } = yield subjectSelectionStrategy(workflow, subjectIDs, self.last?.priority) try { const { authClient } = getRoot(self) diff --git a/packages/lib-classifier/src/store/Workflow/Workflow.js b/packages/lib-classifier/src/store/Workflow/Workflow.js index bd05e5692f..f700c5f0f6 100644 --- a/packages/lib-classifier/src/store/Workflow/Workflow.js +++ b/packages/lib-classifier/src/store/Workflow/Workflow.js @@ -44,6 +44,11 @@ const Workflow = types selectedSubjects: undefined })) .views(self => ({ + get hasIndexedSubjects () { + const activeSet = tryReference(() => self.subjectSet) + return self.grouped && !!activeSet?.isIndexed + }, + get subjectSetId () { const activeSet = tryReference(() => self.subjectSet) return activeSet?.id diff --git a/packages/lib-classifier/src/store/Workflow/Workflow.spec.js b/packages/lib-classifier/src/store/Workflow/Workflow.spec.js index b33f04c843..96bdb68890 100644 --- a/packages/lib-classifier/src/store/Workflow/Workflow.spec.js +++ b/packages/lib-classifier/src/store/Workflow/Workflow.spec.js @@ -4,7 +4,7 @@ import sinon from 'sinon' import RootStore from '../RootStore' import Workflow from './Workflow' -import { MultipleChoiceTaskFactory } from '@test/factories' +import { MultipleChoiceTaskFactory, SubjectSetFactory } from '@test/factories' describe('Model > Workflow', function () { it('should exist', function () { @@ -30,6 +30,10 @@ describe('Model > Workflow', function () { it('should not use transcription task', function () { expect(workflow.usesTranscriptionTask).to.be.false() }) + + it('should not use indexed subject selection', function () { + expect(workflow.hasIndexedSubjects).to.be.false() + }) }) describe('workflow steps', function () { @@ -159,6 +163,61 @@ describe('Model > Workflow', function () { }) }) + describe('Views > hasIndexedSubjects', function () { + let indexedSet + let workflow + + beforeEach(function () { + const rootStore = RootStore.create(); + const subjectSets = Factory.buildList('subject_set', 5) + indexedSet = SubjectSetFactory.build({ + metadata: { + indexFields: 'Date,Creator' + } + }) + subjectSets.push(indexedSet) + rootStore.subjectSets.setResources(subjectSets) + const workflowSnapshot = WorkflowFactory.build({ + id: 'workflow1', + display_name: 'A test workflow', + grouped: true, + links: { + subject_sets: subjectSets.map(subjectSet => subjectSet.id) + }, + version: '0.0' + }) + workflow = Workflow.create(workflowSnapshot) + rootStore.workflows.setResources([workflow]) + }) + + describe('with no selected subject set', function () { + + it('should be false', async function () { + expect(workflow.hasIndexedSubjects).to.be.false() + }) + }) + + describe('with a selected subject set', function () { + + describe('without indexed subjects', function () { + it('should be false', async function () { + expect(workflow.subjectSetId).to.be.undefined() + const subjectSetID = workflow.links.subject_sets[1] + await workflow.selectSubjectSet(subjectSetID) + expect(workflow.hasIndexedSubjects).to.be.false() + }) + }) + + describe('with indexed subjects', function () { + it('should be true', async function () { + expect(workflow.subjectSetId).to.be.undefined() + await workflow.selectSubjectSet(indexedSet.id) + expect(workflow.hasIndexedSubjects).to.be.true() + }) + }) + }) + }) + describe('Views > subjectSetId', function () { let rootStore let workflow diff --git a/packages/lib-classifier/src/store/helpers/getIndexedSubjects/getIndexedSubjects.js b/packages/lib-classifier/src/store/helpers/getIndexedSubjects/getIndexedSubjects.js new file mode 100644 index 0000000000..1edc199254 --- /dev/null +++ b/packages/lib-classifier/src/store/helpers/getIndexedSubjects/getIndexedSubjects.js @@ -0,0 +1,10 @@ +export default async function getIndexedSubjects(subjectSetId, priority = -1) { + const subjectSetURL = `https://subject-set-search-api.zooniverse.org/subjects/${subjectSetId}.json` + const query = `priority__gt=${priority}&_sort=priority` + const mode = 'cors' + const response = await fetch(`${subjectSetURL}?${query}`, { mode }) + const { columns, rows } = await response.json() + const subjectIDColumn = columns.indexOf('subject_id') + const subjectIds = rows.map(row => row[subjectIDColumn]).slice(0,10) + return subjectIds.join(',') +} diff --git a/packages/lib-classifier/src/store/helpers/getIndexedSubjects/getIndexedSubjects.spec.js b/packages/lib-classifier/src/store/helpers/getIndexedSubjects/getIndexedSubjects.spec.js new file mode 100644 index 0000000000..094b49b661 --- /dev/null +++ b/packages/lib-classifier/src/store/helpers/getIndexedSubjects/getIndexedSubjects.spec.js @@ -0,0 +1,50 @@ +import nock from 'nock' + +import getIndexedSubjects from './getIndexedSubjects' + +describe('Store > Helpers > getIndexedSubjects', function () { + let subjectIDs + + before(async function () { + nock('https://subject-set-search-api.zooniverse.org') + .get('/subjects/2.json') + .query(query => query.priority__gt === '-1') + .reply(200, { + columns: ['subject_id', 'priority'], + rows: [ + ['12345', '1'], + ['34567', '2'], + ['56789', '3'] + ] + }) + .get('/subjects/2.json') + .query(query => query.priority__gt === '1') + .reply(200, { + columns: ['subject_id', 'priority'], + rows: [ + ['34567', '2'], + ['56789', '3'] + ] + }) + }) + + describe('with a subject priority', function () { + before(async function () { + subjectIDs = await getIndexedSubjects('2', 1) + }) + + it('should return the next subjects from the set', function () { + expect(subjectIDs).to.equal('34567,56789') + }) + }) + + describe('without a subject priority', function () { + before(async function () { + subjectIDs = await getIndexedSubjects('2') + }) + + it('should return the first subjects from the set', function () { + expect(subjectIDs).to.equal('12345,34567,56789') + }) + }) +}) \ No newline at end of file diff --git a/packages/lib-classifier/src/store/helpers/getIndexedSubjects/index.js b/packages/lib-classifier/src/store/helpers/getIndexedSubjects/index.js new file mode 100644 index 0000000000..3b2f4e0e44 --- /dev/null +++ b/packages/lib-classifier/src/store/helpers/getIndexedSubjects/index.js @@ -0,0 +1 @@ +export { default } from './getIndexedSubjects' diff --git a/packages/lib-classifier/src/store/helpers/index.js b/packages/lib-classifier/src/store/helpers/index.js index 602fef255a..a9d6a387e5 100644 --- a/packages/lib-classifier/src/store/helpers/index.js +++ b/packages/lib-classifier/src/store/helpers/index.js @@ -1,2 +1,4 @@ export { default as convertWorkflowToUseSteps } from './convertWorkflowToUseSteps' export { default as createStore } from './createStore' +export { default as getIndexedSubjects } from './getIndexedSubjects' +export { default as subjectSelectionStrategy } from './subjectSelectionStrategy' diff --git a/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/index.js b/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/index.js new file mode 100644 index 0000000000..362d99ec81 --- /dev/null +++ b/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/index.js @@ -0,0 +1 @@ +export { default } from './subjectSelectionStrategy' diff --git a/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/subjectSelectionStrategy.js b/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/subjectSelectionStrategy.js new file mode 100644 index 0000000000..289572ad91 --- /dev/null +++ b/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/subjectSelectionStrategy.js @@ -0,0 +1,76 @@ +import { getIndexedSubjects } from '@store/helpers' + +export default async function subjectSelectionStrategy(workflow, subjectIDs, priority = -1) { + const workflow_id = workflow.id + + /** Fetch specific subjects for any workflow */ + if (subjectIDs) { + const apiUrl = '/subjects/selection' + const ids = subjectIDs.join(',') + const params = { + ids, + workflow_id + } + return { + apiUrl, + params + } + } + + /** Fetch subject groups for workflows that use the subject group viewer */ + if (workflow.configuration.subject_viewer === 'subjectGroup') { + const apiUrl = '/subjects/grouped' + const num_rows = workflow.configuration.subject_viewer_config?.grid_rows || 1 + const num_columns = workflow.configuration.subject_viewer_config?.grid_columns || 1 + const params = { + num_columns, + num_rows, + workflow_id + } + return { + apiUrl, + params + } + } + + /** fetch ordered subjects for indexed subject sets */ + if (workflow.hasIndexedSubjects) { + const apiUrl = '/subjects/selection' + let ids = await getIndexedSubjects(workflow.subjectSetId, priority) + if (ids === '') { + ids = await getIndexedSubjects(workflow.subjectSetId) + } + const params = { + ids, + workflow_id + } + return { + apiUrl, + params + } + } + + /** Random grouped selection for grouped workflows */ + if (workflow.grouped) { + const apiUrl = '/subjects/queued' + const subject_set_id = workflow.subjectSetId + const params = { + subject_set_id, + workflow_id + } + return { + apiUrl, + params + } + } + + /** default random queued selection */ + const apiUrl = '/subjects/queued' + const params = { + workflow_id + } + return { + apiUrl, + params + } +} \ No newline at end of file diff --git a/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/subjectSelectionStrategy.spec.js b/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/subjectSelectionStrategy.spec.js new file mode 100644 index 0000000000..5d98d9c9e6 --- /dev/null +++ b/packages/lib-classifier/src/store/helpers/subjectSelectionStrategy/subjectSelectionStrategy.spec.js @@ -0,0 +1,217 @@ +import nock from 'nock' + +import subjectSelectionStrategy from './subjectSelectionStrategy' +import { WorkflowFactory } from '@test/factories' + +describe('Store > Helpers > subjectSelectionStrategy', function () { + describe('default random selection', function () { + let strategy + + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345' + }) + strategy = await subjectSelectionStrategy(workflow) + }) + + it(`should use the /subjects/queued endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/queued') + }) + + it('should only pass the workflow ID as the query', function () { + expect(strategy.params).to.deep.equal({ + workflow_id: '12345' + }) + }) + }) + + describe('grouped random selection', function () { + let strategy + + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345', + grouped: true, + subjectSetId: '2' + }) + strategy = await subjectSelectionStrategy(workflow) + }) + + it(`should use the /subjects/queued endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/queued') + }) + + it('should query by workflow and subject set', function () { + expect(strategy.params).to.deep.equal({ + subject_set_id: '2', + workflow_id: '12345' + }) + }) + }) + + describe('specific subjects', function () { + let strategy + + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345', + grouped: true, + subjectSetId: '2' + }) + const subjectIDs = ['3456', '4567', '8910'] + strategy = await subjectSelectionStrategy(workflow, subjectIDs) + }) + + it(`should use the /subjects/selection endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/selection') + }) + + it('should query by workflow and subject ID', function () { + expect(strategy.params).to.deep.equal({ + ids: '3456,4567,8910', + workflow_id: '12345' + }) + }) + }) + + describe('subject groups', function () { + let strategy + + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345', + configuration: { + subject_viewer: 'subjectGroup', + subject_viewer_config: { + grid_rows: 3, + grid_columns: 4 + } + } + }) + strategy = await subjectSelectionStrategy(workflow) + }) + + it(`should use the /subjects/grouped endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/grouped') + }) + + it('should query by workflow and group viewer dimensions', function () { + expect(strategy.params).to.deep.equal({ + num_columns: 4, + num_rows: 3, + workflow_id: '12345' + }) + }) + }) + + describe('indexed subject selection', function () { + let strategy + + before(async function () { + nock('https://subject-set-search-api.zooniverse.org') + .persist(true) + .get('/subjects/2.json') + .query(query => query.priority__gt === '-1') + .reply(200, { + columns: ['subject_id', 'priority'], + rows: [ + ['12345', '1'], + ['34567', '2'], + ['56789', '3'] + ] + }) + .get('/subjects/2.json') + .query(query => query.priority__gt === '2') + .reply(200, { + columns: ['subject_id', 'priority'], + rows: [ + ['56789', '3'] + ] + }) + .get('/subjects/2.json') + .query(query => query.priority__gt === '3') + .reply(200, { + columns: ['subject_id', 'priority'], + rows: [] + }) + }) + + after(function () { + nock.cleanAll() + }) + + describe('with a subject priority', function () { + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345', + grouped: true, + prioritized: true, + hasIndexedSubjects: true, + subjectSetId: '2' + }) + let subjectIDs + strategy = await subjectSelectionStrategy(workflow, subjectIDs, '2') + }) + + it(`should use the /subjects/selection endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/selection') + }) + + it('should query by workflow and subjects greater than the specified priority', function () { + expect(strategy.params).to.deep.equal({ + ids: '56789', + workflow_id: '12345' + }) + }) + }) + + describe('at the end of a set', function () { + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345', + grouped: true, + prioritized: true, + hasIndexedSubjects: true, + subjectSetId: '2' + }) + let subjectIDs + strategy = await subjectSelectionStrategy(workflow, subjectIDs, '3') + }) + + it(`should use the /subjects/selection endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/selection') + }) + + it('should query by workflow and the first subjects in the set', function () { + expect(strategy.params).to.deep.equal({ + ids: '12345,34567,56789', + workflow_id: '12345' + }) + }) + }) + + describe('without a subject priority', function () { + before(async function () { + const workflow = WorkflowFactory.build({ + id: '12345', + grouped: true, + prioritized: true, + hasIndexedSubjects: true, + subjectSetId: '2' + }) + strategy = await subjectSelectionStrategy(workflow) + }) + + it(`should use the /subjects/selection endpoint`, function () { + expect(strategy.apiUrl).to.equal('/subjects/selection') + }) + + it('should query by workflow and all subjects', function () { + expect(strategy.params).to.deep.equal({ + ids: '12345,34567,56789', + workflow_id: '12345' + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/lib-classifier/test/factories/SubjectSetFactory.js b/packages/lib-classifier/test/factories/SubjectSetFactory.js index 252a76db0b..e3db6c5d4f 100644 --- a/packages/lib-classifier/test/factories/SubjectSetFactory.js +++ b/packages/lib-classifier/test/factories/SubjectSetFactory.js @@ -3,5 +3,6 @@ import { Factory } from 'rosie' export default Factory.define('subject_set') .sequence('id', (id) => { return id.toString() }) .attr('display_name', 'Hello there!') + .attr('metadata', {}) .attr('set_member_subjects_count', 56) .attr('links', {})