diff --git a/src/dataset-builder/CohortEditor.test.ts b/src/dataset-builder/CohortEditor.test.ts index 48386340d0..3ec0f24859 100644 --- a/src/dataset-builder/CohortEditor.test.ts +++ b/src/dataset-builder/CohortEditor.test.ts @@ -546,4 +546,35 @@ describe('CohortEditor', () => { ) ); }); + + it('renders total cohort count', async () => { + // Arrange + showCohortEditor(); + // Assert + expect(await screen.findByText('Total participant count: 0', { exact: false })).toBeTruthy(); + }); + + it('displays cohort demographic visualization', async () => { + showCohortEditor(); + + expect(await screen.findAllByText('Female')).toHaveLength(2); + expect(await screen.findAllByText('Male')).toHaveLength(2); + expect(await screen.findAllByText('Other')).toHaveLength(2); + + expect(await screen.findByText('Female 18-44')).toBeTruthy(); + expect(await screen.findByText('Female 45-64')).toBeTruthy(); + expect(await screen.findByText('Female 65+')).toBeTruthy(); + expect(await screen.findByText('Male 18-44')).toBeTruthy(); + expect(await screen.findByText('Male 45-64')).toBeTruthy(); + expect(await screen.findByText('Male 65+')).toBeTruthy(); + expect(await screen.findByText('Other 18-44')).toBeTruthy(); + expect(await screen.findByText('Other 45-64')).toBeTruthy(); + expect(await screen.findByText('Other 65+')).toBeTruthy(); + + expect(await screen.findByText('Asian')).toBeTruthy(); + expect(await screen.findByText('Black')).toBeTruthy(); + expect(await screen.findByText('Native American')).toBeTruthy(); + expect(await screen.findByText('Pacific Islander')).toBeTruthy(); + expect(await screen.findByText('White')).toBeTruthy(); + }); }); diff --git a/src/dataset-builder/CohortEditor.ts b/src/dataset-builder/CohortEditor.ts index 8b52848bde..eee3714f12 100644 --- a/src/dataset-builder/CohortEditor.ts +++ b/src/dataset-builder/CohortEditor.ts @@ -2,6 +2,7 @@ import { Spinner, useLoadedData } from '@terra-ui-packages/components'; import _ from 'lodash/fp'; import React, { Fragment, useEffect, useRef, useState } from 'react'; import { div, h, h2, h3, strong } from 'react-hyperscript-helpers'; +import Chart from 'src/components/Chart'; import { ButtonOutline, ButtonPrimary, GroupedSelect, Link, Select } from 'src/components/common'; import Slider from 'src/components/common/Slider'; import { icon } from 'src/components/icons'; @@ -17,6 +18,13 @@ import { ProgramDataListCriteria, ProgramDataRangeCriteria, } from 'src/dataset-builder/DatasetBuilderUtils'; +import { + CohortDemographics, + generateCohortAgeData, + generateCohortDemographicData, + generateRandomCohortAgeData, + generateRandomCohortDemographicData, +} from 'src/dataset-builder/TestConstants'; import { DataRepo, SnapshotBuilderCountResponse, @@ -592,48 +600,146 @@ export const CohortEditor: React.FC = (props) => { getNextCriteriaIndex, } = props; const [cohort, setCohort] = useState(originalCohort); + const [snapshotRequestParticipantCount, setSnapshotRequestParticipantCount] = + useLoadedData(); + const defaultCohortDemographicSeries = [ + { name: 'Asian', data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, + { name: 'Black', data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, + { name: 'White', data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, + { name: 'Native American', data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, + { name: 'Pacific Islander', data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }, + ]; + const defaultCohortAgeSeries = [{ data: [0, 0, 0] }]; + const [cohortAges, setCohortAges] = useState(generateCohortAgeData(defaultCohortAgeSeries)); + const [cohortDemographics, setCohortDemographics] = useState( + generateCohortDemographicData(defaultCohortDemographicSeries) + ); + const countStatus = snapshotRequestParticipantCount.status; + function chartOptions(cohortDemographics: CohortDemographics) { + return { + chart: { + spacingLeft: 20, + spacingRight: 30, + height: cohortDemographics.height, + style: { fontFamily: 'inherit' }, + type: 'bar', + }, + legend: { enabled: cohortDemographics.legendEnabled }, + plotOptions: { series: { stacking: 'normal' } }, + series: cohortDemographics.series, + title: { + align: 'left', + style: { fontSize: '16px', fontWeight: 'bold', color: '#333f52' }, + text: cohortDemographics.title, + }, + tooltip: { + followPointer: true, + formatter() { + // @ts-ignore + // eslint-disable-next-line react/no-this-in-sfc + const currCategory = _.find((category) => category.short === this.x, cohortDemographics.categories); + const categoryDescription = currCategory.long || currCategory.short; + if (cohortDemographics.showSeriesName) { + // @ts-ignore + // eslint-disable-next-line react/no-this-in-sfc + return `${categoryDescription}
\u25CF ${this.series.name}
${this.y}`; + } + // @ts-ignore + // eslint-disable-next-line react/no-this-in-sfc + return `${categoryDescription}
${this.y}`; + }, + }, + xAxis: { + categories: _.map('short', cohortDemographics.categories), + crosshair: true, + }, + yAxis: { + crosshair: true, + title: { text: cohortDemographics.yTitle }, + }, + accessibility: { + point: { + descriptionFormatter: (point) => { + return `${point.index + 1}. Category ${point.category}, ${point.series.name}: ${point.y}.`; + }, + }, + }, + exporting: { buttons: { contextButton: { x: -15 } } }, + }; + } const updateCohort = (updateCohort: (Cohort) => Cohort) => setCohort(updateCohort); - return h(Fragment, [ - h(CohortEditorContents, { - updateCohort, - cohort, - snapshotId, - snapshotBuilderSettings, - onStateChange, - getNextCriteriaIndex, - }), - // add div to cover page to footer - div( - { - style: { - display: 'flex', - backgroundColor: editorBackgroundColor, - alignItems: 'end', - flexDirection: 'row-reverse', - padding: wideMargin, + useEffect(() => { + setSnapshotRequestParticipantCount( + withErrorReporting(`Error fetching snapshot builder count for snapshot ${snapshotId}`)(async () => + DataRepo() + .snapshot(snapshotId) + .getSnapshotBuilderCount(createSnapshotBuilderCountRequest([cohort])) + ) + ); + }, [snapshotId, setSnapshotRequestParticipantCount, cohort]); + + useEffect(() => { + countStatus === 'Ready' + ? (setCohortAges(generateRandomCohortAgeData()), setCohortDemographics(generateRandomCohortDemographicData())) + : (setCohortAges(generateCohortAgeData(defaultCohortAgeSeries)), + setCohortDemographics(generateCohortDemographicData(defaultCohortDemographicSeries))); + // @ts-ignore + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [countStatus]); + + return div({ style: { display: 'flex' } }, [ + div([ + h(CohortEditorContents, { + updateCohort, + cohort, + snapshotId, + snapshotBuilderSettings, + onStateChange, + getNextCriteriaIndex, + }), + // add div to cover page to footer + div( + { + style: { + display: 'flex', + backgroundColor: editorBackgroundColor, + alignItems: 'end', + flexDirection: 'row-reverse', + padding: '0 3rem', + }, }, - }, - [ - h( - ButtonPrimary, - { - onClick: () => { - updateCohorts((cohorts) => { - const index = _.findIndex((c) => _.equals(c.name, cohort.name), cohorts); - if (index === -1) { - // Only add to selectedCohorts on creation of new cohort - addSelectedCohort(cohort); - } - return _.set(`[${index === -1 ? cohorts.length : index}]`, cohort, cohorts); - }); - onStateChange(homepageState.new()); + [ + h( + ButtonPrimary, + { + onClick: () => { + updateCohorts((cohorts) => { + const index = _.findIndex((c) => _.equals(c.name, cohort.name), cohorts); + if (index === -1) { + // Only add to selectedCohorts on creation of new cohort + addSelectedCohort(cohort); + } + return _.set(`[${index === -1 ? cohorts.length : index}]`, cohort, cohorts); + }); + onStateChange(homepageState.new()); + }, }, - }, - ['Save cohort'] - ), - ] - ), + ['Save cohort'] + ), + ] + ), + ]), + div({ style: { width: '42rem' } }, [ + h2({ style: { padding: '1rem', display: 'flex', alignItems: 'center', margin: 0, backgroundColor: 'white' } }, [ + 'Total participant count: ', + snapshotRequestParticipantCount.status === 'Ready' + ? formatCount(snapshotRequestParticipantCount.state.result.total) + : h(Spinner, { style: { marginLeft: '1rem' } }), + ]), + h(Chart, { options: chartOptions(cohortAges) }), + h(Chart, { options: chartOptions(cohortDemographics) }), + ]), ]); }; diff --git a/src/dataset-builder/TestConstants.test.ts b/src/dataset-builder/TestConstants.test.ts new file mode 100644 index 0000000000..6b10d1f612 --- /dev/null +++ b/src/dataset-builder/TestConstants.test.ts @@ -0,0 +1,76 @@ +import { forEach } from 'lodash'; +import { + addToSeriesData, + generateDemographicSeries, + generateRandomNumbers, + generateRandomNumbersThatAddUpTo, +} from 'src/dataset-builder/TestConstants'; + +describe('TestConstants', () => { + it('generateRandomNumbers', () => { + const nums = generateRandomNumbers(3, 90); + expect(nums.length).toBe(3); + forEach(nums, (num) => { + expect(num <= 90).toBe(true); + }); + }); + + it('generateRandomNumbersThatAddUpTo', () => { + const nums = generateRandomNumbersThatAddUpTo(100, 3); + expect(nums.length).toBe(3); + expect(nums.reduce((a, b) => a + b, 0)).toBe(100); + }); + + it('addToSeriesData', () => { + const series = [{ data: [1, 2, 3] }, { data: [5, 6, 7] }]; + const data = [4, 8]; + addToSeriesData(series, data); + expect(series[0].data).toEqual([1, 2, 3, 4]); + expect(series[1].data).toEqual([5, 6, 7, 8]); + }); + + it('addToSeriesData error case', () => { + const series = [{ data: [1, 2, 3] }]; + const data = [4, 8]; + expect(() => { + addToSeriesData(series, data); + }).toThrowError('series and data must be the same length'); + }); + + it('generateDemographicsSeries', () => {}); + const series = generateDemographicSeries(); + expect(series.length).toBe(5); + forEach(series, (s) => { + expect(s.data.length).toBe(12); + }); + let female = 0; + let female18 = 0; + let female45 = 0; + let female65 = 0; + let male = 0; + let male18 = 0; + let male45 = 0; + let male65 = 0; + let nonbinary = 0; + let nonbinary18 = 0; + let nonbinary45 = 0; + let nonbinary65 = 0; + forEach(series, (s) => { + female += s.data[0]; + female18 += s.data[1]; + female45 += s.data[2]; + female65 += s.data[3]; + male += s.data[4]; + male18 += s.data[5]; + male45 += s.data[6]; + male65 += s.data[7]; + nonbinary += s.data[8]; + nonbinary18 += s.data[9]; + nonbinary45 += s.data[10]; + nonbinary65 += s.data[11]; + }); + expect(female + male + nonbinary).toBe(100); + expect(female18 + female45 + female65).toBe(female); + expect(male18 + male45 + male65).toBe(male); + expect(nonbinary18 + nonbinary45 + nonbinary65).toBe(nonbinary); +}); diff --git a/src/dataset-builder/TestConstants.ts b/src/dataset-builder/TestConstants.ts index a914bfb8de..12068f5876 100644 --- a/src/dataset-builder/TestConstants.ts +++ b/src/dataset-builder/TestConstants.ts @@ -175,3 +175,128 @@ const dummyConcepts = [ export const dummyGetConceptForId = (id: number): Concept => { return _.find({ id }, dummyConcepts)!; }; + +interface ChartLabel { + short: string; + long?: string; +} +interface ChartSeries { + name?: string; + data: number[]; +} + +export interface CohortDemographics { + categories: ChartLabel[]; + series: ChartSeries[]; + title: string; + yTitle: string; + height: string; + legendEnabled: boolean; + showSeriesName: boolean; +} + +export function generateCohortAgeData(series) { + return { + categories: [ + { short: 'Female' }, + { short: 'Male' }, + { short: 'Other', long: 'Nonbinary, 2 Spirit, Genderqueer, etc.' }, + ], + series, + title: 'Gender identity and current age', + yTitle: 'AVERAGE AGE', + height: '250rem', + legendEnabled: false, + showSeriesName: false, + }; +} + +export function generateRandomCohortAgeSeries() { + return [{ data: generateRandomNumbers(3, 90) }]; +} +export function generateRandomCohortAgeData() { + return generateCohortAgeData(generateRandomCohortAgeSeries()); +} + +export function generateCohortDemographicData(series) { + return { + categories: [ + { short: 'Female' }, + { short: 'Female 18-44' }, + { short: 'Female 45-64' }, + { short: 'Female 65+' }, + { short: 'Male' }, + { short: 'Male 18-44' }, + { short: 'Male 45-64' }, + { short: 'Male 65+' }, + { short: 'Other', long: 'Nonbinary, 2 Spirit, Genderqueer, etc.' }, + { short: 'Other 18-44', long: 'Nonbinary, 2 Spirit, Genderqueer, etc. 18-44' }, + { short: 'Other 45-64', long: 'Nonbinary, 2 Spirit, Genderqueer, etc. 45-64' }, + { short: 'Other 65+', long: 'Nonbinary, 2 Spirit, Genderqueer, etc. 65+' }, + ], + series, + title: 'Gender identity, current age, and race', + yTitle: 'OVERALL PERCENTAGE', + height: '500rem', + legendEnabled: true, + showSeriesName: true, + }; +} + +export function generateRandomCohortDemographicData() { + return generateCohortDemographicData(generateDemographicSeries()); +} + +export function generateRandomNumbers(numNumbers: number, max: number) { + const randomNumbers: number[] = []; + for (let i = 0; i < numNumbers; i++) { + const randomNumber = Math.floor(Math.random() * max) + 1; + randomNumbers.push(randomNumber); + } + return randomNumbers; +} + +export function generateRandomNumbersThatAddUpTo(total: number, numNumbers: number): number[] { + const randomNumbers: number[] = []; + let remaining = total; + for (let i = 0; i < numNumbers - 1; i++) { + const randomNumber = Math.floor(Math.random() * remaining); + remaining -= randomNumber; + randomNumbers.push(randomNumber); + } + randomNumbers.push(remaining); + return _.shuffle(randomNumbers); +} + +// series and data will always be the same length +export function addToSeriesData(series: ChartSeries[], data: number[]) { + if (series.length !== data.length) throw new Error('series and data must be the same length'); + for (let i = 0; i < series.length; i++) { + series[i].data.push(data[i]); + } +} + +export function generateDemographicSeries(): ChartSeries[] { + const series: ChartSeries[] = [ + { name: 'Asian', data: [] }, + { name: 'Black', data: [] }, + { name: 'White', data: [] }, + { name: 'Native American', data: [] }, + { name: 'Pacific Islander', data: [] }, + ]; + // for each of the three gender identity totals, + const genderTotals = generateRandomNumbersThatAddUpTo(100, 3); + for (const genderTotal of genderTotals) { + // generate the race breakdown for the gender totals + const genderTotalRaceBreakDown = generateRandomNumbersThatAddUpTo(genderTotal, 5); + addToSeriesData(series, genderTotalRaceBreakDown); + // get the three age group breakdowns + const ageGroupGenderTotal = generateRandomNumbersThatAddUpTo(genderTotal, 3); + // get race breakdowns for each of the gender age groups + for (const genderAgeGroupTotal of ageGroupGenderTotal) { + const genderAgeGroupRaceBreakdown = generateRandomNumbersThatAddUpTo(genderAgeGroupTotal, 5); + addToSeriesData(series, genderAgeGroupRaceBreakdown); + } + } + return series; +}