Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lib-user: Refactor group stats data export #6753

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
74 changes: 17 additions & 57 deletions packages/lib-user/src/components/Contributors/Contributors.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Loader, SpacedText } from '@zooniverse/react-components'
import { Box, Layer } from 'grommet'
import { Box } from 'grommet'
import { arrayOf, bool, shape, string } from 'prop-types'
import { useState } from 'react'
import { useTranslation } from '../../translations/i18n.js'

import { fetchPanoptesUsers } from '../../utils'
import { useTranslation } from '../../translations/i18n.js'

import {
usePanoptesProjects,
Expand All @@ -20,7 +19,7 @@ import {
} from '@components/shared'

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

const STATS_ENDPOINT = '/classifications/user_groups'
const CONTRIBUTORS_PER_PAGE = 40
Expand All @@ -31,10 +30,11 @@ function Contributors({
group,
membership
}) {
const { t } = useTranslation()
const [exportLoading, setExportLoading] = useState(false)
const [showExport, setShowExport] = useState(false)
const [page, setPage] = useState(1)


const { t } = useTranslation()

const showContributors = adminMode
|| membership?.roles.includes('group_admin')
|| (membership?.roles.includes('group_member') && group?.stats_visibility === 'private_show_agg_and_ind')
Expand Down Expand Up @@ -98,36 +98,6 @@ function Contributors({
})
}

const loadingExportMessage = t('Contributors.generating')

async function handleGenerateExport() {
setExportLoading(true)

const allUsersQuery = {
id: memberIdsPerStats?.join(','),
page_size: 100
}

const allUsers = await fetchPanoptesUsers(allUsersQuery)

const { filename, dataExportUrl } = await generateExport({
group,
projects,
stats,
users: allUsers
})

// Create an anchor element and trigger download
const link = document.createElement('a')
link.href = dataExportUrl
link.setAttribute('download', filename)
document.body.appendChild(link) // Append to the document
link.click() // Programmatically click the link to trigger the download
document.body.removeChild(link) // Clean up

setExportLoading(false)
}

function handlePageChange({ page }) {
setPage(page)
}
Expand All @@ -138,25 +108,15 @@ function Contributors({

return (
<>
{exportLoading ? (
<Layer>
<Box
align='center'
gap='small'
height='medium'
justify='center'
width='medium'
>
<SpacedText>
{loadingExportMessage}
</SpacedText>
<Loader
loadingMessage={loadingExportMessage}
/>
</Box>
</Layer>
) : null
}
{showExport && (
<ExportStats
group={group}
handleShowExport={setShowExport}
memberIdsPerStats={memberIdsPerStats}
projects={projects}
stats={stats}
/>
)}
<Layout
primaryHeaderItem={
<HeaderLink
Expand All @@ -171,7 +131,7 @@ function Contributors({
linkProps={{
as: 'button',
disabled: disableStatsExport,
onClick: handleGenerateExport
onClick: () => setShowExport(true)
}}
title={t('Contributors.title')}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import asyncStates from '@zooniverse/async-states'
import { Loader } from '@zooniverse/react-components'
import { Box, Button, Layer, Text, Anchor } from 'grommet'
import { Alert, Download } from 'grommet-icons'
import { func, number, string, shape } from 'prop-types'

import { useTranslation } from '../../../../translations/i18n.js'

function ExportBox({ children }) {
return (
<Box
align='center'
gap='small'
height='medium'
justify='center'
pad='medium'
width={{ min: 'medium' }}
>
{children}
</Box>
)
}

const DEFAULT_DOWNLOAD_URL = {
url: '',
filename: ''
}
const DEFAULT_HANDLER = () => true

function ExportStats({
csvSizeEstimate = '',
downloadUrl = DEFAULT_DOWNLOAD_URL,
errorMessage = '',
exportProgress = 0,
exportStatus = asyncStates.initialized,
memberCount = 0,
onClose = DEFAULT_HANDLER,
onConfirm = DEFAULT_HANDLER,
onRetry = DEFAULT_HANDLER
}) {
const { t } = useTranslation()
const loadingExportMessage = t('Contributors.ExportStats.generating')

// loading state
if (exportStatus === asyncStates.loading) {
return (
<Layer>
<ExportBox>
<Text>
{loadingExportMessage}
</Text>
<Loader loadingMessage={loadingExportMessage} />
<Text>
{t('Contributors.ExportStats.progress', {
progress: Math.round(exportProgress)
})}
</Text>
</ExportBox>
</Layer>
)
}

// error state
if (exportStatus === asyncStates.error) {
return (
<Layer>
<ExportBox>
<Box
align='center'
gap='small'
>
<Alert
color='status-error'
size='large'
/>
<Text
textAlign='center'
weight='bold'
color='status-error'
>
{t('Contributors.ExportStats.error')}
</Text>
<Text textAlign='center'>
{errorMessage || t('Contributors.ExportStats.errorMessage')}
</Text>
</Box>
<Box
direction='row'
gap='medium'
margin={{ top: 'medium' }}
>
<Button
label={t('Contributors.ExportStats.close')}
onClick={onClose}
/>
<Button
label={t('Contributors.ExportStats.retry')}
onClick={onRetry}
primary
/>
</Box>
</ExportBox>
</Layer>
)
}

// success state
if (exportStatus === asyncStates.success) {
return (
<Layer>
<ExportBox>
<Text
textAlign='center'
weight='bold'
>
{t('Contributors.ExportStats.complete')}
</Text>
<Text textAlign='center'>
{t('Contributors.ExportStats.download')}
</Text>
<Box
align='center'
gap='small'
>
<Anchor
href={downloadUrl.url}
download={downloadUrl.filename}
icon={<Download />}
label={
<Text>
{downloadUrl.filename}
</Text>
}
primary
target='_blank'
/>
</Box>
<Button
label={t('Contributors.ExportStats.close')}
onClick={onClose}
margin={{ top: 'medium' }}
/>
</ExportBox>
</Layer>
)
}

return (
<Layer>
<ExportBox>
<Text textAlign='center'>
{t('Contributors.ExportStats.confirmMessage', {
memberCount: memberCount.toLocaleString()
})}
</Text>
<Text>
{t('Contributors.ExportStats.fileSize', {
fileSize: csvSizeEstimate
})}
</Text>
<Box
direction='row'
gap='medium'
margin={{ top: 'medium' }}
>
<Button
label={t('Contributors.ExportStats.cancel')}
onClick={onClose}
/>
<Button
label={t('Contributors.ExportStats.confirm')}
onClick={onConfirm}
primary
/>
</Box>
</ExportBox>
</Layer>
)
}

ExportStats.propTypes = {
csvSizeEstimate: string,
downloadUrl: shape({
url: string,
filename: string
}),
errorMessage: string,
exportProgress: number,
exportStatus: string,
memberCount: number,
onClose: func,
onConfirm: func,
onRetry: func
}

export default ExportStats
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { composeStory } from '@storybook/react'
import { render, screen } from '@testing-library/react'

import Meta, { ConfirmLargeExport, ConfirmSmallExport, Loading, Error, Success } from './ExportStats.stories.js'

describe('components > Contributors > ExportStats', function () {
describe('ConfirmLargeExport', function () {
const ConfirmLargeExportStory = composeStory(ConfirmLargeExport, Meta)
let memberCount, fileSize

before(function () {
render(<ConfirmLargeExportStory />)
memberCount = screen.getByText('Generate CSV of group stats for 5,000 members?')
fileSize = screen.getByText('Approximately 3.5 MB.')
})

it('should show the correct member count', function () {
expect(memberCount).to.be.ok()
})

it('should show the correct file size estimate', function () {
expect(fileSize).to.be.ok()
})
})

describe('ConfirmSmallExport', function () {
const ConfirmSmallExportStory = composeStory(ConfirmSmallExport, Meta)
let memberCount, fileSize

before(function () {
render(<ConfirmSmallExportStory />)
memberCount = screen.getByText('Generate CSV of group stats for 400 members?')
fileSize = screen.getByText('Approximately 750 KB.')
})

it('should show the correct member count', function () {
expect(memberCount).to.be.ok()
})

it('should show the correct file size estimate', function () {
expect(fileSize).to.be.ok()
})
})

describe('Loading', function () {
const LoadingStory = composeStory(Loading, Meta)
let loadingMessage, progress

before(function () {
render(<LoadingStory />)
loadingMessage = screen.getByText('Generating stats export...')
progress = screen.getByText('Progress: 65%')
})

it('should show the loading message', function () {
expect(loadingMessage).to.be.ok()
})

it('should show the progress', function () {
expect(progress).to.be.ok()
})
})

describe('Error', function () {
const ErrorStory = composeStory(Error, Meta)

it('should show the error message', function () {
render(<ErrorStory />)
const errorMessage = screen.getByText('Network error: Failed to fetch user data')
expect(errorMessage).to.be.ok()
})
})

describe('Success', function () {
const SuccessStory = composeStory(Success, Meta)

it('should show the download link', function () {
render(<SuccessStory />)
const downloadLink = screen.getByText('TestGroup1234_data_export_2025-01-01T101010.csv')
expect(downloadLink).to.be.ok()
})
})
})
Loading