• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zooniverse / front-end-monorepo / 14253674578

03 Apr 2025 10:07PM UTC coverage: 75.651% (+0.1%) from 75.527%
14253674578

push

github

web-flow
lib-user: Refactor group stats data export (#6753)

* Refactor handleGenerateExport with chunks of users

* Add confirm prompt for stats export

* Add estimated progress

* Initial asyncStates refactor

* Separate export generation functions

* Refactor with ExportStats component

* Refactor ExportStats with container, error state, and download link

* Refactor prop types

* Refactor ExportStats translations

* Add ExportStats tests

* Add getAllUsers tests

* Refactor ExportStats success states

* Refactor group stats export with Web Worker

* Update ExportStats generating text

11092 of 16905 branches covered (65.61%)

Branch coverage included in aggregate %.

72 of 72 new or added lines in 4 files covered. (100.0%)

43 existing lines in 5 files now uncovered.

17255 of 20566 relevant lines covered (83.9%)

387.25 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.54
/packages/lib-classifier/src/store/ClassificationStore.js
1
import asyncStates from '@zooniverse/async-states'
1✔
2
import cuid from 'cuid'
1,226!
3
import { snakeCase } from 'lodash'
4
import { flow, getRoot, getSnapshot, isValidReference, tryReference, types } from 'mobx-state-tree'
1✔
5

6
import Classification, { ClassificationMetadata } from './Classification'
1✔
7
import ResourceStore from './ResourceStore'
1✔
8
import {
1✔
9
  ClassificationQueue,
10
  sessionUtils
11
} from './utils'
12
import { subjectsSeenThisSession } from '@helpers'
6✔
13

14
const ClassificationStore = types
1✔
15
  .model('ClassificationStore', {
16
    active: types.safeReference(Classification),
17
    demoMode: types.maybe(types.boolean),
18
    resources: types.map(Classification),
19
    type: types.optional(types.string, 'classifications')
20
  })
21
  .views(self => ({
331✔
22
    annotation (task) {
23
      const validClassificationReference = isValidReference(() => self.active)
×
24
      if (validClassificationReference) {
×
25
        return self.active.annotation(task)
×
26
      }
27
      return null
×
28
    },
29

30
    get currentAnnotations () {
31
      const validClassificationReference = isValidReference(() => self.active)
7✔
32
      if (validClassificationReference) {
7✔
33
        return self.active.annotations
7✔
34
      }
35
      return []
×
36
    },
37

38
    get classificationQueue () {
39
      const client = getRoot(self).client.panoptes
5✔
40
      const { authClient } = getRoot(self)
5✔
41
      return new ClassificationQueue(client, self.onClassificationSaved, authClient)
5✔
42
    }
43
  }))
44
  .volatile(self => {
45
    return {
331✔
46
      onComplete: () => null
2✔
47
    }
48
  })
49
  .actions(self => {
331✔
50
    function createClassification (subject, workflow, project) {
51
      if (!subject || !workflow || !project) {
327!
52
        throw new Error('Cannot create a classification without a subject, workflow, project')
×
53
      }
54

55
      const { locale } = getRoot(self)
327✔
56

57
      const tempID = cuid()
327✔
58
      const newClassification = Classification.create({
327✔
59
        id: tempID, // Generate an id just for serialization in MST. Should be dropped before POST...
60
        links: {
61
          project: project.id,
62
          subjects: [subject.id],
63
          workflow: workflow.id
64
        },
65
        metadata: ClassificationMetadata.create({
66
          classifier_version: '2.0',
67
          source: subject.metadata.intervention ? 'sugar' : 'api',
327!
68
          subjectSelectionState: {
69
            already_seen: subject.already_seen, // Only record Panoptes' setting of this, not session storage
70
            finished_workflow: subject.finished_workflow,
71
            retired: subject.retired,
72
            selected_at: subject.selected_at,
73
            selection_state: subject.selection_state,
74
            user_has_finished_workflow: subject.user_has_finished_workflow
75
          },
76
          userLanguage: locale,
77
          workflowVersion: workflow.version
78
        })
79
      })
80

81
      self.resources.put(newClassification)
327✔
82
      self.setActive(tempID)
327✔
83
      self.loadingState = asyncStates.success
327✔
84
    }
85

86
    function addAnnotation (task, annotationValue) {
87
      const validClassificationReference = isValidReference(() => self.active)
37✔
88

89
      if (validClassificationReference) {
37!
90
        const classification = self.active
37✔
91
        if (classification) {
37✔
92
          return classification.addAnnotation(task, annotationValue)
37✔
93
        }
94
      } else {
95
        if (process.browser) {
×
96
          // TODO: throw an error here?
97
          console.error('No active classification. Cannot add annotation.')
×
98
          return null
×
99
        }
100
      }
101
    }
102

103
    function completeClassification () {
104
      const classification = tryReference(() => self.active)
6✔
105
      const subject = tryReference(() => getRoot(self).subjects.active)
6✔
106

107
      if (classification && subject) {
6!
108
        const subjectDimensions = getSnapshot(getRoot(self).subjectViewer).dimensions
6✔
109

110
        const metadata = {
6✔
111
          finishedAt: (new Date()).toISOString(),
112
          session: sessionUtils.getSessionID(),
113
          subjectDimensions,
114
          viewport: {
115
            width: window.innerWidth,
116
            height: window.innerHeight
117
          }
118
        }
119

120
        const feedback = getRoot(self).feedback
6✔
121
        if (feedback.isValid) {
6✔
122
          metadata.feedback = getSnapshot(feedback.rules)
1✔
123
        }
124

125
        // TODO store intervention metadata if we have a user...
126
        classification.metadata.update(metadata)
6✔
127

128
        classification.completed = true
6✔
129
        // Convert from observables
130
        let classificationToSubmit = classification.toSnapshot()
6✔
131

132
        const convertedMetadata = {}
6✔
133
        Object.entries(classificationToSubmit.metadata).forEach(([key, value]) => {
90✔
134
          convertedMetadata[snakeCase(key)] = value
90✔
135
        })
136
        classificationToSubmit.metadata = convertedMetadata
6✔
137

138
        /*
139
          Subject.alreadySeen is a computed value, so copy it across to a copy of the subject snapshot.
140
          Calling apps will expect subject.already_seen, as per the Panoptes API.
141
        */
142
        const already_seen = subject.alreadySeen
6✔
143
        const subjectData = Object.assign({}, getSnapshot(subject), { already_seen })
6✔
144
        self.onComplete(classificationToSubmit, subjectData)
6✔
145
        self.trackAlreadySeenSubjects(classificationToSubmit.links.workflow, classificationToSubmit.links.subjects)
6✔
146

147
        if (process.browser) {
6!
148
          console.log('Completed classification', classificationToSubmit)
×
149
        }
150

151
        if (self.demoMode) {
6✔
152
          // subject advance is observing for this to know when to advance the queue
153
          self.loadingState = asyncStates.posting
1✔
154
          if (process.browser) console.log('Demo mode enabled. No classification submitted.')
1!
155
          return Promise.resolve(true)
1✔
156
        }
157

158
        return self.submitClassification(classificationToSubmit)
5✔
159
      } else {
160
        if (process.browser) {
×
161
          console.error('No active classification or active subject. Cannot complete classification')
×
162
        }
163
      }
164
    }
165

166
    function onClassificationSaved (savedClassification) {
167
      // handle any processing of classifications that have been saved to Panoptes.
168
    }
169

170
    function trackAlreadySeenSubjects (workflowID, subjectIDs) {
171
      subjectsSeenThisSession.add(workflowID, subjectIDs)
6✔
172
    }
173

174
    function * submitClassification (classification) {
30!
175
      // Service worker isn't working right now, so let's use the fallback queue for all browsers
176
      try {
177
        yield self.classificationQueue.add(classification)
5✔
178
        self.loadingState = asyncStates.posting
10✔
179
      } catch (error) {
UNCOV
180
        console.error(error)
×
181
        self.loadingState = asyncStates.error
5✔
182
      }
183
    }
184

185
    function setOnComplete (onComplete) {
186
      self.onComplete = onComplete
24✔
187
    }
188

189
    function setDemoMode (boolean) {
190
      self.demoMode = boolean
1✔
191
    }
192

193
    return {
331✔
194
      addAnnotation,
195
      completeClassification,
196
      createClassification,
197
      onClassificationSaved,
198
      setDemoMode,
199
      setOnComplete,
200
      submitClassification: flow(submitClassification),
201
      trackAlreadySeenSubjects
202
    }
203
  })
1✔
204

205
export default types.compose('ClassificationResourceStore', ResourceStore, ClassificationStore)
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc