Skip to content

Commit

Permalink
lib-user: Add Contributors CSV data export (#6113)
Browse files Browse the repository at this point in the history
* Add initial ProjectStats component

* Add initial MemberStats component

* Add initial Contributors component

* Add Contributors boxShadows

* Refactor GroupContainer for GroupStats and Contributors

* Create ContributorsContainer, Contributors, and refactor ContributorsList

* Add conditional for ProjectStats displayName with private project

* Add ContributorsList tests, related refactors

* Remove data export from user stats page

* Refactor ContentLink, usePanoptesUser, and stats.mock per data export

* Add CSV data export to Contributors

* Add Contributors data export tests

* Fix order of hooks issue

* Refactor tests per rebase

* Refactor ContributorsList with privateProjectIndex

* Sanitize group name for filename

* Refactor tests per mock data changes

* Add convertStatsSecondsToHours util

* Refactor components with convertStatsSecondsToHours

* Remove unnecessary TitledStat NaN conditional and story

* Fix missing groupId in TopContributors
  • Loading branch information
mcbouslog committed Jun 12, 2024
1 parent b4935ba commit 48007fa
Show file tree
Hide file tree
Showing 21 changed files with 313 additions and 46 deletions.
23 changes: 22 additions & 1 deletion packages/lib-user/src/components/Contributors/Contributors.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { arrayOf, bool, shape, string } from 'prop-types'
import { useState } from 'react'

import {
usePanoptesProjects,
Expand All @@ -13,6 +14,7 @@ import {
} from '@components/shared'

import ContributorsList from './components/ContributorsList'
import { generateExport } from './helpers/generateExport'

const STATS_ENDPOINT = '/classifications/user_groups'

Expand All @@ -22,12 +24,14 @@ function Contributors({
group,
membership
}) {
const [dataExportUrl, setDataExportUrl] = useState('')
const [filename, setFilename] = useState('')

const showContributors = adminMode
|| membership?.roles.includes('group_admin')
|| (membership?.roles.includes('group_member') && group?.stats_visibility === 'private_show_agg_and_ind')
|| (membership?.roles.includes('group_member') && group?.stats_visibility === 'public_agg_show_ind_if_member')
|| group?.stats_visibility === 'public_show_all'
if (!showContributors) return (<div>Not authorized</div>)

// fetch stats
const statsQuery = {
Expand Down Expand Up @@ -80,6 +84,8 @@ function Contributors({
})
}

if (!showContributors) return (<div>Not authorized</div>)

return (
<Layout
primaryHeaderItem={
Expand All @@ -91,6 +97,21 @@ function Contributors({
}
>
<ContentBox
linkLabel='Export all stats'
linkProps={{
href: dataExportUrl,
download: filename,
onClick: () => {
generateExport({
group,
handleFileName: setFilename,
handleDataExportUrl: setDataExportUrl,
projects,
stats,
users
})
}
}}
title='Full Group Stats'
>
{contributors.length > 0 ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { Box } from 'grommet'
import { arrayOf, number, shape, string } from 'prop-types'

import { convertStatsSecondsToHours } from '@utils'

import MemberStats from '../MemberStats'
import ProjectStats from '../ProjectStats'

function ContributorsList({
contributors = [],
projects = []
}) {
let privateProjectIndex = 1

return (
<Box
as='ol'
>
{contributors.map((contributor, index) => {
const totalHoursSpent = contributor.session_time >= 0 ? contributor.session_time / 3600 : 0
const totalHoursSpent = convertStatsSecondsToHours(contributor.session_time)

return (
<Box
Expand Down Expand Up @@ -50,8 +54,8 @@ function ContributorsList({
>
{contributor.project_contributions.map(statsProject => {
const project = projects.find(project => project.id === statsProject.project_id.toString())
const projectDisplayName = project?.display_name || 'Private Project'
const projectHoursSpent = statsProject.session_time >= 0 ? statsProject.session_time / 3600 : 0
const projectDisplayName = project?.display_name || `Private Project ${privateProjectIndex++}`
const projectHoursSpent = convertStatsSecondsToHours(statsProject.session_time)

return (
<ProjectStats
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { composeStory } from '@storybook/react'
import { render, screen, within } from '@testing-library/react'

import { convertStatsSecondsToHours } from '@utils'

import { USERS } from '../../../../../test/mocks/panoptes'
import { group_member_stats_breakdown } from '../../../../../test/mocks/stats.mock'

Expand Down Expand Up @@ -39,7 +41,7 @@ describe('components > Contributors > ContributorsList', function () {
})

it('should show the user\'s session time', function () {
const sessionTime = within(contributorItem).getByText(Math.round((group_member_stats_breakdown[0].session_time / 3600)).toLocaleString())
const sessionTime = within(contributorItem).getByText(Math.round(convertStatsSecondsToHours(group_member_stats_breakdown[0].session_time)).toLocaleString())
expect(sessionTime).to.be.ok()
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { arrayOf, func, number, shape, string } from 'prop-types'

import { getExportData } from './getExportData'

const DEFAULT_HANDLER = () => true

export function generateExport({
group,
handleFileName = DEFAULT_HANDLER,
handleDataExportUrl = DEFAULT_HANDLER,
projects,
stats,
users
}) {
const data = getExportData({ projects, stats, users })

let str = ''

data.forEach((row) => {
str += row.map((col) => JSON.stringify(col)).join(',').concat('\n')
})

// The following regexp sanitizes the group name by removing all non-alphanumeric characters (i.e. emojis, spaces, punctuation, etc.)
let sanitizedGroupName = group.display_name.replace(/[^a-zA-Z0-9]/g, '')

let newFilename = `${sanitizedGroupName}.data_export.${Date.now()}.csv`
handleFileName(newFilename)

let file = new File([str], newFilename, { type: 'text/csv' })
let newDataExportUrl = URL.createObjectURL(file)
handleDataExportUrl(newDataExportUrl)
}

generateExport.propTypes = {
group: shape({
display_name: string
}),
handleFileName: func,
handleDataExportUrl: func,
projects: arrayOf(shape({
display_name: string,
id: string
})),
stats: shape({
group_member_stats_breakdown: arrayOf(shape({
user_id: number,
count: number,
session_time: number,
project_contributions: arrayOf(shape({
project_id: number,
count: number,
session_time: number
}))
}))
}),
users: arrayOf(shape({
id: string,
display_name: string,
login: string
}))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import sinon from 'sinon'

import { PROJECTS, USER_GROUPS, USER, GROUP_MEMBER_USER, GROUP_ADMIN_USER } from '../../../../test/mocks/panoptes'
import { group_member_stats_breakdown } from '../../../../test/mocks/stats.mock'

import { generateExport } from './generateExport'

describe('Contributors > generateExport', function () {
let setFilename
let projects
let stats
let users

before(function () {
setFilename = sinon.stub()
projects = PROJECTS
stats = {
group_member_stats_breakdown
}
users = [USER, GROUP_MEMBER_USER, GROUP_ADMIN_USER]
})

after(function () {
setFilename.resetHistory()
})

it('should call setFilename with the correct filename', function () {
generateExport({
group: USER_GROUPS[0],
handleFileName: setFilename,
projects,
stats,
users
})

expect(setFilename).to.be.calledWithMatch(/TestGroup.data_export.\d+.csv/)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { arrayOf, number, shape, string } from 'prop-types'

import { convertStatsSecondsToHours } from '@utils'

export function getExportData ({
stats,
projects,
users
}) {
// create a list of all project names
let privateProjectIndex = 1
const projectNames = [...new Set(
stats.group_member_stats_breakdown.flatMap(member => {
return member.project_contributions.map(statsProject => {
const project = projects.find(project => project.id === statsProject.project_id.toString())
return project?.display_name || `Private Project ${privateProjectIndex++}`
})
}))]

// create header row
const data = [
[
'Display Name', 'Username', 'Total Classifications', 'Total Hours',
...projectNames.flatMap(projectName => [`${projectName} Classifications`, `${projectName} Hours`])
]
]

// iterate over each member and create a row for each member's stats
for (const member of stats.group_member_stats_breakdown) {
const user = users.find(user => user.id === member.user_id.toString())
if (!user) continue

const memberHours = convertStatsSecondsToHours(member.session_time)

const row = [
user.display_name,
user.login,
member.count,
memberHours
]

// iterate over each project and add the member stats for that project or 0 if the member has no stats for that project
for (const projectName of projectNames) {
const projectStats = member.project_contributions
.find(statsProject => {
const project = projects.find(project => project.id === statsProject.project_id.toString())
return project?.display_name === projectName
})
row.push(projectStats?.count || 0)
row.push(convertStatsSecondsToHours(projectStats?.session_time))
}
data.push(row)
}

return data
}

getExportData.propTypes = {
projects: arrayOf(shape({
display_name: string,
id: string
})),
stats: shape({
group_member_stats_breakdown: arrayOf(shape({
user_id: number,
count: number,
session_time: number,
project_contributions: arrayOf(shape({
project_id: number,
count: number,
session_time: number
}))
}))
}),
users: arrayOf(shape({
id: string,
display_name: string,
login: string
}))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { PROJECTS, USER, GROUP_MEMBER_USER, GROUP_ADMIN_USER } from '../../../../test/mocks/panoptes'
import { group_member_stats_breakdown } from '../../../../test/mocks/stats.mock'

import { getExportData } from './getExportData'

describe('Contributors > getExportData', function () {
let projects
let stats
let users

before(function () {
projects = PROJECTS
stats = {
group_member_stats_breakdown
}
users = [USER, GROUP_MEMBER_USER, GROUP_ADMIN_USER]
})

it('should return an array of arrays with the correct data', function () {
const projectNames = [
`Notes from Nature - Capturing California's Flowers`,
'NEST QUEST GO: EASTERN BLUEBIRDS',
'Corresponding with Quakers',
'Wildwatch Kenya is a short project name compared to the longest live project at 80',
'Planet Hunters TESS'
]
const data = getExportData({ projects, stats, users })

expect(data).to.eql([
[
'Display Name', 'Username', 'Total Classifications', 'Total Hours',
...projectNames.flatMap(projectName => [`${projectName} Classifications`, `${projectName} Hours`])
],
[
'Test User 1', 'TestUser', 13425, 65, 121, 6, 93, 10, 45, 16, 36, 19, 0, 0
],
[
'Student User 1', 'StudentUser', 9574, 96, 0, 0, 56, 10, 45, 16, 23, 19, 0, 0
],
[
'Teacher User 1', 'TeacherUser', 648, 127, 0, 0, 0, 0, 45, 16, 3, 19, 56, 13
]
])
})
})
1 change: 1 addition & 0 deletions packages/lib-user/src/components/GroupStats/GroupStats.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ function GroupStats({
gap='30px'
>
<TopContributors
groupId={group?.id}
stats={stats}
topContributors={topContributors}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { string } from 'prop-types'

import { useStats } from '@hooks'
import { convertStatsSecondsToHours } from '@utils'

import GroupCard from './GroupCard'

const STATS_ENDPOINT = '/classifications/user_groups'
Expand All @@ -19,7 +21,7 @@ function GroupCardContainer({

const { total_count, time_spent, active_users, project_contributions } = data || {}

const hoursSpent = time_spent >= 0 ? time_spent / 3600 : 0
const hoursSpent = convertStatsSecondsToHours(time_spent)

return (
<GroupCard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function ContentLink({
dark: 'light-4',
light: 'dark-5'
}}
download={link.download || false}
forwardedAs={link.as || 'a'}
href={link.href}
label={
Expand All @@ -33,6 +34,7 @@ function ContentLink({
{link.text}
</SpacedText>
}
onClick={link.onClick}
/>
)
}
Expand Down
Loading

0 comments on commit 48007fa

Please sign in to comment.