diff --git a/.circleci/config.yml b/.circleci/config.yml index 5cb8e4f772..6e0a432989 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -288,10 +288,10 @@ parameters: type: string dev_git_branch: # change to feature branch to test deployment description: "Name of github branch that will deploy to dev" - default: "mb/TTAHUB-2501/front-end-goal-name-filter" + default: "al/ttahub-2570/flat-resource-sql" type: string sandbox_git_branch: # change to feature branch to test deployment - default: "mb/TTAHUB-2510/add-IST-visit-dropdown" + default: "mb/TTAHUB/frontend-for-tr-dashboard" type: string prod_new_relic_app_id: default: "877570491" @@ -305,7 +305,7 @@ parameters: sandbox_new_relic_app_id: default: "867346799" type: string -jobs: +jobs: build_and_lint: executor: docker-executor steps: @@ -644,7 +644,7 @@ jobs: command: ./bin/ping-server 8080 - run: name: Pull OWASP ZAP docker image - command: docker pull owasp/zap2docker-stable:latest + command: docker pull softwaresecurityproject/zap-stable:latest - run: name: Make reports directory group writeable command: chmod g+w reports @@ -653,7 +653,7 @@ jobs: command: ./bin/run-owasp-scan - store_artifacts: path: reports/owasp_report.html - resource_class: large + resource_class: arm.large deploy: executor: docker-executor steps: diff --git a/bin/ping-server b/bin/ping-server index a1d298087b..d84846fe64 100755 --- a/bin/ping-server +++ b/bin/ping-server @@ -13,5 +13,5 @@ until $(curl --output /dev/null --max-time 10 --silent --head --fail http://loca fi attempt_counter=$(($attempt_counter+1)) - sleep 10 + sleep 30 done diff --git a/bin/run-owasp-scan b/bin/run-owasp-scan index 2e36a8a618..15d076112f 100755 --- a/bin/run-owasp-scan +++ b/bin/run-owasp-scan @@ -24,6 +24,6 @@ docker run \ --rm \ --user zap:$(id -g) \ --network=$network \ - -t owasp/zap2docker-stable:latest zap-baseline.py \ + -t softwaresecurityproject/zap-stable:latest zap-baseline.py \ -t http://server:8080 \ -c zap.conf -I -i -r owasp_report.html diff --git a/docker-compose.override.yml b/docker-compose.override.yml index fbfe4d4da6..2c8bd0e9bd 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -60,7 +60,7 @@ services: volumes: - ".:/app:rw" owasp_zap_backend: - image: owasp/zap2docker-stable:latest + image: softwaresecurityproject/zap-stable:latest platform: linux/arm64 user: zap command: zap-full-scan.py -t http://backend:8080 -c zap.conf -i -r owasp_report.html @@ -70,7 +70,7 @@ services: depends_on: - backend owasp_zap_similarity: - image: owasp/zap2docker-stable:latest + image: softwaresecurityproject/zap-stable:latest platform: linux/arm64 user: zap command: zap-api-scan.py -t http://similarity:8080/openapi.json -f openapi -I -i -r owasp_api_report.html diff --git a/frontend/package.json b/frontend/package.json index d0837972db..79ce687a4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/react-fontawesome": "^0.2.0", "@hookform/error-message": "^0.0.5", + "@react-hook/resize-observer": "^1.2.6", "@trussworks/react-uswds": "4.1.1", "@ttahub/common": "2.0.18", "@use-it/interval": "^1.0.0", diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 61621e0e37..5bf1d6443a 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -1,6 +1,8 @@ @use 'colors.scss' as *; @use './Grid.scss'; +@use './widgets/widgets.scss'; + @font-face { font-family: 'FontAwesome'; src: url('./assets/fa-solid-900.ttf') format('truetype'), url('./assets/fa-solid-900.woff2') format('woff2'); @@ -403,4 +405,9 @@ fill: #1B1B1B; .desktop\:maxw-6 { max-width: 3rem; } -} \ No newline at end of file +} + +.smart-hub--vertical-text { + writing-mode: vertical-lr; + transform: rotate(180deg); +} diff --git a/frontend/src/components/MediaCaptureButton.js b/frontend/src/components/MediaCaptureButton.js index 589677c5fe..0c7aa97070 100644 --- a/frontend/src/components/MediaCaptureButton.js +++ b/frontend/src/components/MediaCaptureButton.js @@ -4,7 +4,7 @@ import html2canvas from 'html2canvas'; import { Button } from '@trussworks/react-uswds'; export default function MediaCaptureButton({ - reference, className, buttonText, id, + reference, className, buttonText, id, title, }) { const capture = async () => { try { @@ -24,7 +24,7 @@ export default function MediaCaptureButton({ const base64image = canvas.toDataURL('image/png'); const a = document.createElement('a'); a.href = base64image; - a.setAttribute('download', ''); + a.setAttribute('download', `${title}.png`); a.click(); } catch (e) { // eslint-disable-next-line no-console @@ -50,6 +50,7 @@ MediaCaptureButton.propTypes = { className: PropTypes.string, buttonText: PropTypes.string, id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, }; MediaCaptureButton.defaultProps = { diff --git a/frontend/src/components/MultiSelect.js b/frontend/src/components/MultiSelect.js index a774d9de27..868d1541ad 100644 --- a/frontend/src/components/MultiSelect.js +++ b/frontend/src/components/MultiSelect.js @@ -20,7 +20,7 @@ through to react-select. If the selected value is not in the options prop the multiselect box will display an empty tag. */ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import Select, { components } from 'react-select'; import Creatable from 'react-select/creatable'; @@ -104,8 +104,10 @@ function MultiSelect({ onCreateOption, placeholderText, components: componentReplacements, + onClick = () => {}, }) { const inputId = `select-${uuidv4()}`; + const selectorRef = useRef(null); /** * unfortunately, given our support for ie11, we can't @@ -158,6 +160,13 @@ function MultiSelect({ } }; + const onKeyDown = (e) => { + if (e.key === 'Enter' || e.key === ' ') { + selectorRef.current.focus(); + onClick(); + } + }; + const Selector = canCreate ? Creatable : Select; return ( @@ -165,35 +174,47 @@ function MultiSelect({ render={({ onChange: controllerOnChange, value, onBlur }) => { const values = value ? getValues(value) : value; return ( - { - if (onItemSelected) { - onItemSelected(event); - } else if (event) { - onChange(event, controllerOnChange); - } else { - controllerOnChange([]); - } - }} - inputId={inputId} - styles={styles(singleRowInput)} - components={{ ...componentReplacements, DropdownIndicator }} - options={options} - isDisabled={disabled} - tabSelectsValue={false} - isClearable={multiSelectOptions.isClearable} - closeMenuOnSelect={multiSelectOptions.closeMenuOnSelect || false} - controlShouldRenderValue={multiSelectOptions.controlShouldRenderValue} - hideSelectedOptions={multiSelectOptions.hideSelectedOptions} - placeholder={placeholderText || ''} - onCreateOption={onCreateOption} - isMulti - required={!!(required)} - /> + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
+
+ { + if (onItemSelected) { + onItemSelected(event); + } else if (event) { + onChange(event, controllerOnChange); + } else { + controllerOnChange([]); + } + }} + inputId={inputId} + styles={styles(singleRowInput)} + components={{ ...componentReplacements, DropdownIndicator }} + options={options} + isDisabled={disabled} + tabSelectsValue={false} + isClearable={multiSelectOptions.isClearable} + closeMenuOnSelect={multiSelectOptions.closeMenuOnSelect || false} + controlShouldRenderValue={multiSelectOptions.controlShouldRenderValue} + hideSelectedOptions={multiSelectOptions.hideSelectedOptions} + placeholder={placeholderText || ''} + onCreateOption={onCreateOption} + isMulti + required={!!(required)} + /> +
+
); }} control={control} @@ -253,6 +274,7 @@ MultiSelect.propTypes = { }), required: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), placeholderText: PropTypes.string, + onClick: PropTypes.func, }; MultiSelect.defaultProps = { @@ -269,6 +291,7 @@ MultiSelect.defaultProps = { onItemSelected: null, onCreateOption: null, placeholderText: null, + onClick: null, }; export default MultiSelect; diff --git a/frontend/src/components/WidgetH2.js b/frontend/src/components/WidgetH2.js new file mode 100644 index 0000000000..c5a25bd391 --- /dev/null +++ b/frontend/src/components/WidgetH2.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default function WidgetH2({ children, classNames }) { + return ( +

+ {children} +

+ ); +} + +WidgetH2.propTypes = { + children: PropTypes.node.isRequired, + classNames: PropTypes.string, +}; + +WidgetH2.defaultProps = { + classNames: '', +}; diff --git a/frontend/src/components/__tests__/MediaCaptureButton.js b/frontend/src/components/__tests__/MediaCaptureButton.js index aff2435fcf..48cddb0d2c 100644 --- a/frontend/src/components/__tests__/MediaCaptureButton.js +++ b/frontend/src/components/__tests__/MediaCaptureButton.js @@ -8,7 +8,7 @@ describe('MediaCaptureButton', () => { const RenderCaptureButton = () => { const widget = useRef(); return ( -
+
); }; it('renders', () => { diff --git a/frontend/src/components/__tests__/MultiSelect.js b/frontend/src/components/__tests__/MultiSelect.js index 292028bdb9..6e5da8a87f 100644 --- a/frontend/src/components/__tests__/MultiSelect.js +++ b/frontend/src/components/__tests__/MultiSelect.js @@ -22,7 +22,7 @@ const customOptions = [ describe('MultiSelect', () => { // eslint-disable-next-line react/prop-types - const TestMultiSelect = ({ onSubmit }) => { + const TestMultiSelect = ({ onSubmit, disabled = false }) => { const { control, handleSubmit } = useForm({ defaultValues: { name: [] }, mode: 'all', @@ -41,6 +41,8 @@ describe('MultiSelect', () => { name="name" options={options} required={false} + onClick={() => {}} + disabled={disabled} /> @@ -159,4 +161,32 @@ describe('MultiSelect', () => { }, ]); }); + + describe('the div wrapper', () => { + it('forwards space to the Selector, expanding the multiselect', async () => { + render(); + const container = screen.getByTestId('name-click-container'); + container.focus(); + await act(async () => { + userEvent.type(container, '{space}'); + }); + expect(await screen.findByText('one')).toBeVisible(); + }); + it('forwards enter to the Selector, giving it focus', async () => { + render( {}} />); + const container = screen.getByTestId('name-click-container'); + container.focus(); + await act(async () => { + userEvent.type(container, '{enter}'); + }); + const selector = container.querySelector('input'); + expect(selector).toHaveFocus(); + }); + it('hides the Selector with aria-hidden when disabled', async () => { + render(); + const container = screen.getByTestId('name-click-container'); + const div = container.querySelector('div'); + expect(div).toHaveAttribute('aria-hidden', 'true'); + }); + }); }); diff --git a/frontend/src/fetchers/Resources.js b/frontend/src/fetchers/Resources.js index ea9b21fe8f..22c186bbf8 100644 --- a/frontend/src/fetchers/Resources.js +++ b/frontend/src/fetchers/Resources.js @@ -11,6 +11,15 @@ export const fetchResourceData = async (query) => { }; }; +export const fetchFlatResourceData = async (query) => { + const res = await get(join('/', 'api', 'resources', 'flat', `?${query}`)); + const data = await res.json(); + + return { + ...data, + }; +}; + export const fetchTopicResources = async (sortBy = 'updatedAt', sortDir = 'desc', offset = 0, limit = TOPICS_PER_PAGE, filters) => { const request = join('/', 'api', 'resources', 'topic-resources', `?sortBy=${sortBy}&sortDir=${sortDir}&offset=${offset}&limit=${limit}${filters ? `&${filters}` : ''}`); const res = await get(request); diff --git a/frontend/src/hooks/useSize.js b/frontend/src/hooks/useSize.js new file mode 100644 index 0000000000..09521708da --- /dev/null +++ b/frontend/src/hooks/useSize.js @@ -0,0 +1,17 @@ +// https://www.npmjs.com/package/@react-hook/resize-observer +import { useState, useLayoutEffect } from 'react'; +import useResizeObserver from '@react-hook/resize-observer'; + +const useSize = (target) => { + const [size, setSize] = useState(); + + useLayoutEffect(() => { + setSize(target.current.getBoundingClientRect()); + }, [target]); + + // Where the magic happens + useResizeObserver(target, (entry) => setSize(entry.contentRect)); + return size; +}; + +export default useSize; diff --git a/frontend/src/pages/ActivityReport/Pages/__tests__/activitySummary.js b/frontend/src/pages/ActivityReport/Pages/__tests__/activitySummary.js index 74e3e06640..a9ae3da6ff 100644 --- a/frontend/src/pages/ActivityReport/Pages/__tests__/activitySummary.js +++ b/frontend/src/pages/ActivityReport/Pages/__tests__/activitySummary.js @@ -66,6 +66,27 @@ describe('activity summary', () => { expect(await screen.findByText('Duration must be less than or equal to 99 hours')).toBeInTheDocument(); }); }); + + describe('activity recipients validation', () => { + it('shows a validation message when clicked and recipient type is not selected', async () => { + render(); + const input = screen.getByTestId('activityRecipients-click-container'); + userEvent.click(input); + expect(await screen.findByText('You must first select who the activity is for')).toBeInTheDocument(); + }); + + it('hides the message when the recipient type is selected', async () => { + const { container } = render(); + const input = screen.getByTestId('activityRecipients-click-container'); + userEvent.click(input); + expect(await screen.findByText('You must first select who the activity is for')).toBeInTheDocument(); + await act(() => { + const recipient = container.querySelector('#category-recipient'); + userEvent.click(recipient); + }); + expect(screen.queryByText('You must first select who the activity is for')).not.toBeInTheDocument(); + }); + }); }); describe('groups', () => { diff --git a/frontend/src/pages/ActivityReport/Pages/activitySummary.js b/frontend/src/pages/ActivityReport/Pages/activitySummary.js index 261cd72ed0..f1e325e576 100644 --- a/frontend/src/pages/ActivityReport/Pages/activitySummary.js +++ b/frontend/src/pages/ActivityReport/Pages/activitySummary.js @@ -55,11 +55,13 @@ const ActivitySummary = ({ setValue, control, getValues, + clearErrors, } = useFormContext(); const [useGroup, setUseGroup] = useState(false); const [showGroupInfo, setShowGroupInfo] = useState(false); const [groupRecipientIds, setGroupRecipientIds] = useState([]); + const [shouldValidateActivityRecipients, setShouldValidateActivityRecipients] = useState(false); const activityRecipientType = watch('activityRecipientType'); const watchFormRecipients = watch('activityRecipients'); @@ -193,6 +195,16 @@ const ActivitySummary = ({ /> ); + useEffect(() => { + if (!shouldValidateActivityRecipients) return; + + if (disableRecipients) { + setValue('activityRecipients', [], { shouldValidate: true }); + } else { + clearErrors('activityRecipients'); + } + }, [disableRecipients, shouldValidateActivityRecipients, setValue, clearErrors]); + const renderRecipients = (marginTop = 2, marginBottom = 0) => (
{!disableRecipients @@ -211,9 +223,10 @@ const ActivitySummary = ({ valueProperty="activityRecipientId" labelProperty="name" simple={false} - required="Select at least one" + required={disableRecipients ? 'You must first select who the activity is for' : 'Select at least one'} options={selectedRecipients} placeholderText={placeholderText} + onClick={() => setShouldValidateActivityRecipients(true)} />
diff --git a/frontend/src/pages/RegionalDashboard/components/TrainingReportDashboard.js b/frontend/src/pages/RegionalDashboard/components/TrainingReportDashboard.js index e6cc485d7a..cfd6362131 100644 --- a/frontend/src/pages/RegionalDashboard/components/TrainingReportDashboard.js +++ b/frontend/src/pages/RegionalDashboard/components/TrainingReportDashboard.js @@ -2,6 +2,9 @@ import React from 'react'; import { Helmet } from 'react-helmet'; import { Grid, GridContainer } from '@trussworks/react-uswds'; import Overview from '../../../widgets/TrainingReportDashboardOverview'; +import TRReasonList from '../../../widgets/TRReasonList'; +import TRHoursOfTrainingByNationalCenter from '../../../widgets/TRHoursOfTrainingByNationalCenter'; +import VTopicFrequency from '../../../widgets/VTopicFrequency'; export default function TrainingReportDashboard() { return ( @@ -16,11 +19,31 @@ export default function TrainingReportDashboard() { loading={false} /> - - + + + + + + + + + - - ); diff --git a/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js b/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js new file mode 100644 index 0000000000..d508ff0395 --- /dev/null +++ b/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js @@ -0,0 +1,51 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, screen, +} from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import TrainingReportDashboard from '../TrainingReportDashboard'; + +describe('Training report Dashboard page', () => { + const hoursOfTrainingUrl = '/api/widgets/trHoursOfTrainingByNationalCenter'; + const reasonListUrl = '/api/widgets/trReasonList'; + const overviewUrl = '/api/widgets/trOverview'; + const sessionsByTopicUrl = '/api/widgets/trSessionsByTopic'; + + beforeEach(async () => { + fetchMock.get(overviewUrl, { + numReports: '0', + totalRecipients: '0', + recipientPercentage: '0%', + numGrants: '0', + numRecipients: '0', + sumDuration: '0', + numParticipants: '0', + numSessions: '0', + }); + fetchMock.get(reasonListUrl, []); + fetchMock.get(hoursOfTrainingUrl, []); + fetchMock.get(sessionsByTopicUrl, []); + }); + + afterEach(() => fetchMock.restore()); + + const renderTest = () => { + render(); + }; + + it('renders and fetches data', async () => { + renderTest(); + + expect(fetchMock.calls(overviewUrl)).toHaveLength(1); + expect(fetchMock.calls(reasonListUrl)).toHaveLength(1); + expect(fetchMock.calls(hoursOfTrainingUrl)).toHaveLength(1); + expect(fetchMock.calls(sessionsByTopicUrl)).toHaveLength(1); + + expect(document.querySelector('.smart-hub--dashboard-overview')).toBeTruthy(); + + expect(screen.getByText('Reasons in Training Reports')).toBeInTheDocument(); + expect(screen.getByText('Hours of training by National Center')).toBeInTheDocument(); + expect(screen.getByText('Number of TR sessions by topic')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/RegionalDashboard/index.css b/frontend/src/pages/RegionalDashboard/index.css index 5dc6a415f2..d4372c3682 100644 --- a/frontend/src/pages/RegionalDashboard/index.css +++ b/frontend/src/pages/RegionalDashboard/index.css @@ -17,5 +17,5 @@ } .ttahub--dashboard-widget-heading { - font-size: 1.25em; + font-size: 1.5em; } diff --git a/frontend/src/pages/ResourcesDashboard/__tests__/index.js b/frontend/src/pages/ResourcesDashboard/__tests__/index.js index 7a5cbc39c0..ed6017c918 100644 --- a/frontend/src/pages/ResourcesDashboard/__tests__/index.js +++ b/frontend/src/pages/ResourcesDashboard/__tests__/index.js @@ -29,7 +29,7 @@ const defaultDate = formatDateRange({ }); const defaultDateParam = `startDate.win=${encodeURIComponent(defaultDate)}`; -const resourcesUrl = join('api', 'resources'); +const resourcesUrl = join('api', 'resources/flat'); const resourcesDefault = { resourcesDashboardOverview: { diff --git a/frontend/src/pages/ResourcesDashboard/index.js b/frontend/src/pages/ResourcesDashboard/index.js index df671e4487..a2d22f0075 100644 --- a/frontend/src/pages/ResourcesDashboard/index.js +++ b/frontend/src/pages/ResourcesDashboard/index.js @@ -1,3 +1,5 @@ +/* eslint-disable no-alert */ +/* eslint-disable no-console */ import React, { useContext, useMemo, @@ -20,7 +22,7 @@ import ResourcesDashboardOverview from '../../widgets/ResourcesDashboardOverview import ResourceUse from '../../widgets/ResourceUse'; import { expandFilters, filtersToQueryString, formatDateRange } from '../../utils'; import './index.scss'; -import { fetchResourceData } from '../../fetchers/Resources'; +import { fetchFlatResourceData } from '../../fetchers/Resources'; import { downloadReports, getReportsViaIdPost, @@ -195,7 +197,7 @@ export default function ResourcesDashboard() { // Filters passed also contains region. const filterQuery = filtersToQueryString(filtersToApply); try { - const data = await fetchResourceData( + const data = await fetchFlatResourceData( filterQuery, ); setResourcesData(data); diff --git a/frontend/src/widgets/DashboardOverview.js b/frontend/src/widgets/DashboardOverview.js index 5cefdde8e1..6d65075926 100644 --- a/frontend/src/widgets/DashboardOverview.js +++ b/frontend/src/widgets/DashboardOverview.js @@ -86,7 +86,7 @@ const DASHBOARD_FIELDS = { icon={faChartColumn} iconColor={colors.success} backgroundColor={colors.successLighter} - label={`across ${data.numReports} training reports`} + label={`across ${data.numReports} Training Reports`} data={`${data.numSessions} sessions`} /> ), diff --git a/frontend/src/widgets/HorizontalTableWidget.js b/frontend/src/widgets/HorizontalTableWidget.js index 12ae204226..6c8e3fc711 100644 --- a/frontend/src/widgets/HorizontalTableWidget.js +++ b/frontend/src/widgets/HorizontalTableWidget.js @@ -49,7 +49,6 @@ export default function HorizontalTableWidget( onClick={() => { requestSort(name); }} - onKeyDown={() => requestSort(name)} className={`usa-button usa-button--unstyled sortable ${sortClassName}`} aria-label={`${displayName}. Activate to sort ${sortClassName === 'asc' ? 'descending' : 'ascending' }`} diff --git a/frontend/src/widgets/ReasonList.js b/frontend/src/widgets/ReasonList.js index 1366056daf..806609343b 100644 --- a/frontend/src/widgets/ReasonList.js +++ b/frontend/src/widgets/ReasonList.js @@ -19,20 +19,22 @@ const renderReasonList = (data) => { return null; }; -function ReasonList({ data, loading }) { +export function ReasonListTable({ + data, loading, title, +}) { return ( ); } -ReasonList.propTypes = { +ReasonListTable.propTypes = { data: PropTypes.oneOfType([ PropTypes.arrayOf( PropTypes.shape({ @@ -42,10 +44,12 @@ ReasonList.propTypes = { ), PropTypes.shape({}), ]), loading: PropTypes.bool.isRequired, + title: PropTypes.string, }; -ReasonList.defaultProps = { +ReasonListTable.defaultProps = { data: [], + title: 'Reasons in Activity Reports', }; -export default withWidgetData(ReasonList, 'reasonList'); +export default withWidgetData(ReasonListTable, 'reasonList'); diff --git a/frontend/src/widgets/ResourcesAssociatedWithTopics.js b/frontend/src/widgets/ResourcesAssociatedWithTopics.js index eb4df4df41..08a1954f71 100644 --- a/frontend/src/widgets/ResourcesAssociatedWithTopics.js +++ b/frontend/src/widgets/ResourcesAssociatedWithTopics.js @@ -95,15 +95,16 @@ function ResourcesAssociatedWithTopics({ // Value sort. const sortValueA = direction === 'asc' ? 1 : -1; - const sortValueB = direction === 'asc' ? -1 : -1; - valuesToSort.sort( - (a, b) => ( - // eslint-disable-next-line no-nested-ternary - (a.sortBy > b.sortBy) ? sortValueA - : ((b.sortBy > a.sortBy) - ? sortValueB : 0) - ), - ); + const sortValueB = direction === 'asc' ? -1 : 1; + valuesToSort.sort((a, b) => { + if (a.sortBy > b.sortBy) { + return sortValueA; + } if (b.sortBy > a.sortBy) { + return sortValueB; + } + return 0; + }); + setTopicUse(valuesToSort); setOffset(0); setSortConfig({ sortBy, direction, activePage: 1 }); diff --git a/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js b/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js new file mode 100644 index 0000000000..250bd127e6 --- /dev/null +++ b/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js @@ -0,0 +1,4 @@ +import VBarGraph from './VBarGraph'; +import withWidgetData from './withWidgetData'; + +export default withWidgetData(VBarGraph, 'trHoursOfTrainingByNationalCenter'); diff --git a/frontend/src/widgets/TRReasonList.js b/frontend/src/widgets/TRReasonList.js new file mode 100644 index 0000000000..9d1ac1a25c --- /dev/null +++ b/frontend/src/widgets/TRReasonList.js @@ -0,0 +1,4 @@ +import { ReasonListTable } from './ReasonList'; +import withWidgetData from './withWidgetData'; + +export default withWidgetData(ReasonListTable, 'trReasonList'); diff --git a/frontend/src/widgets/TableWidget.js b/frontend/src/widgets/TableWidget.js index c219e3ed5e..20e1566fdb 100644 --- a/frontend/src/widgets/TableWidget.js +++ b/frontend/src/widgets/TableWidget.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { Table } from '@trussworks/react-uswds'; import Container from '../components/Container'; import './TableWidget.css'; +import WidgetH2 from '../components/WidgetH2'; export default function TableWidget( { @@ -22,7 +23,9 @@ export default function TableWidget( diff --git a/frontend/src/widgets/TopicFrequencyGraph.js b/frontend/src/widgets/TopicFrequencyGraph.js index e667fea264..c1bbc92268 100644 --- a/frontend/src/widgets/TopicFrequencyGraph.js +++ b/frontend/src/widgets/TopicFrequencyGraph.js @@ -15,48 +15,29 @@ export const SORT_ORDER = { }; export function sortData(data, order, tabular = false) { + // if order === SORT_ORDER.ALPHA, sort alphabetically if (order === SORT_ORDER.ALPHA) { data.sort((a, b) => a.topic.localeCompare(b.topic)); } else { - data.sort((a, b) => b.count - a.count); + // sort by count and then alphabetically + data.sort((a, b) => { + if (a.count === b.count) { + return a.topic.localeCompare(b.topic); + } + return b.count - a.count; + }); } + // the orientation is reversed visually in the table if (!tabular) { data.reverse(); } } -/** - * - * Takes a string, a reason (or topic, if you prefer) - * provided for an activity report and intersperses it with line breaks - * depending on the length - * - * @param {string} topic - * @returns string with line breaks - */ -export function topicsWithLineBreaks(reason) { - const arrayOfTopics = reason.split(' '); - - return arrayOfTopics.reduce((accumulator, currentValue) => { - const lineBreaks = accumulator.match(/
/g); - const allowedLength = lineBreaks ? lineBreaks.length * 6 : 6; - - // we don't want slashes on their own lines - if (currentValue === '/') { - return `${accumulator} ${currentValue}`; - } - - if (accumulator.length > allowedLength) { - return `${accumulator}
${currentValue}`; - } - - return `${accumulator} ${currentValue}`; - }, ''); -} - export function TopicFrequencyGraphWidget({ - data, loading, + data, + loading, + title, }) { // whether to show the data as accessible widget data or not const [showAccessibleData, setShowAccessibleData] = useState(false); @@ -178,7 +159,7 @@ export function TopicFrequencyGraphWidget({ -

Number of Activity Reports by Topic

+

{title}

) : null} @@ -232,7 +214,7 @@ export function TopicFrequencyGraphWidget({ { showAccessibleData - ? + ? : (
) } @@ -251,10 +233,11 @@ TopicFrequencyGraphWidget.propTypes = { ), PropTypes.shape({}), ]), loading: PropTypes.bool.isRequired, + title: PropTypes.string, }; TopicFrequencyGraphWidget.defaultProps = { - + title: 'Number of Activity Reports by Topic', data: [], }; diff --git a/frontend/src/widgets/VBarGraph.css b/frontend/src/widgets/VBarGraph.css new file mode 100644 index 0000000000..442958cadf --- /dev/null +++ b/frontend/src/widgets/VBarGraph.css @@ -0,0 +1,3 @@ +.smarthub-vbar-graph { + height: calc(100% - 1.5em); +} \ No newline at end of file diff --git a/frontend/src/widgets/VBarGraph.js b/frontend/src/widgets/VBarGraph.js new file mode 100644 index 0000000000..33ae9bd12b --- /dev/null +++ b/frontend/src/widgets/VBarGraph.js @@ -0,0 +1,190 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Grid } from '@trussworks/react-uswds'; +// https://github.com/plotly/react-plotly.js/issues/135#issuecomment-501398125 +import Plotly from 'plotly.js-basic-dist'; +import createPlotlyComponent from 'react-plotly.js/factory'; +import colors from '../colors'; +import Container from '../components/Container'; +import AccessibleWidgetData from './AccessibleWidgetData'; +import MediaCaptureButton from '../components/MediaCaptureButton'; +import WidgetH2 from '../components/WidgetH2'; +import useSize from '../hooks/useSize'; +import './VBarGraph.css'; + +const Plot = createPlotlyComponent(Plotly); + +function VBarGraph({ + data, + yAxisLabel, + xAxisLabel, + title, + subtitle, + loading, + loadingLabel, +}) { + const [plot, updatePlot] = useState({}); + const bars = useRef(null); + const [showAccessibleData, updateShowAccessibleData] = useState(false); + // toggle the data table + function toggleAccessibleData() { + updateShowAccessibleData((current) => !current); + } + + const size = useSize(bars); + + useEffect(() => { + if (!data || !Array.isArray(data) || !size) { + return; + } + + const names = []; + const counts = []; + + data.forEach((dataPoint) => { + names.push(dataPoint.name); + counts.push(dataPoint.count); + }); + + const trace = { + type: 'bar', + x: names, + y: counts, + hoverinfo: 'y', + marker: { + color: colors.ttahubMediumBlue, + }, + }; + + const layout = { + bargap: 0.5, + height: 350, + width: size.width - 40, + hoverlabel: { + bgcolor: '#000', + bordercolor: '#000', + font: { + color: '#fff', + size: 16, + }, + }, + font: { + color: '#1b1b1b', + }, + margin: { + l: 80, + pad: 20, + t: 24, + }, + xaxis: { + automargin: true, + autorange: true, + tickangle: 0, + title: { + text: xAxisLabel, + standoff: 40, + }, + standoff: 20, + }, + yaxis: { + tickformat: ',.0d', + autorange: true, + title: { + text: yAxisLabel, + standoff: 20, + }, + }, + hovermode: 'none', + }; + + updatePlot({ + data: [trace], + layout, + config: { + responsive: true, displayModeBar: false, hovermode: 'none', + }, + }); + }, [data, xAxisLabel, size, yAxisLabel]); + + const tableData = data.map((row) => ({ data: [row.name, row.count] })); + + return ( + + +
+
+ + {title} + +

{subtitle}

+
+ {!showAccessibleData + ? ( + + ) + : null} + +
+ +
+ { showAccessibleData + ? ( + + ) + : ( + <> +
+ + +
+ + )} +
+ + ); +} + +VBarGraph.propTypes = { + data: PropTypes.arrayOf( + PropTypes.shape({ + category: PropTypes.string, + count: PropTypes.number, + }), + ), + yAxisLabel: PropTypes.string.isRequired, + xAxisLabel: PropTypes.string.isRequired, + title: PropTypes.string, + subtitle: PropTypes.string, + loading: PropTypes.bool, + loadingLabel: PropTypes.string, +}; + +VBarGraph.defaultProps = { + data: [], + title: 'Vertical Bar Graph', + subtitle: '', + loading: false, + loadingLabel: 'Vertical Bar Graph Loading', +}; + +export default VBarGraph; diff --git a/frontend/src/widgets/VTopicFrequency.js b/frontend/src/widgets/VTopicFrequency.js new file mode 100644 index 0000000000..37c43bed20 --- /dev/null +++ b/frontend/src/widgets/VTopicFrequency.js @@ -0,0 +1,4 @@ +import { TopicFrequencyGraphWidget } from './TopicFrequencyGraph'; +import withWidgetData from './withWidgetData'; + +export default withWidgetData(TopicFrequencyGraphWidget, 'trSessionsByTopic'); diff --git a/frontend/src/widgets/__tests__/BarGraph.js b/frontend/src/widgets/__tests__/BarGraph.js index 1977330f66..82d7712ce0 100644 --- a/frontend/src/widgets/__tests__/BarGraph.js +++ b/frontend/src/widgets/__tests__/BarGraph.js @@ -1,4 +1,3 @@ -/* eslint-disable jest/no-disabled-tests */ import '@testing-library/jest-dom'; import React from 'react'; import { diff --git a/frontend/src/widgets/__tests__/TopicFrequencyGraph.js b/frontend/src/widgets/__tests__/TopicFrequencyGraph.js index 693f8c616a..48760732a7 100644 --- a/frontend/src/widgets/__tests__/TopicFrequencyGraph.js +++ b/frontend/src/widgets/__tests__/TopicFrequencyGraph.js @@ -9,7 +9,6 @@ import { import userEvent from '@testing-library/user-event'; import { TopicFrequencyGraphWidget, - topicsWithLineBreaks, sortData, SORT_ORDER, } from '../TopicFrequencyGraph'; @@ -54,7 +53,7 @@ describe('Topic & Frequency Graph Widget', () => { }); it('correctly sorts data by count', () => { - const data = [...TEST_DATA]; + let data = [...TEST_DATA]; sortData(data, SORT_ORDER.DESC); expect(data).toStrictEqual([ { @@ -73,15 +72,44 @@ describe('Topic & Frequency Graph Widget', () => { topic: 'CLASS: Instructional Support', count: 12, }, + { + topic: 'Fiscal / Budget', + count: 0, + }, { topic: 'Human Resources', count: 0, }, + ].reverse()); + + data = [...TEST_DATA]; + sortData(data, SORT_ORDER.DESC, true); + expect(data).toStrictEqual([ + { + topic: 'Community and Self-Assessment', + count: 155, + }, + { + topic: 'Family Support Services', + count: 53, + }, + { + topic: 'Five-Year Grant', + count: 33, + }, + { + topic: 'CLASS: Instructional Support', + count: 12, + }, { topic: 'Fiscal / Budget', count: 0, }, - ].reverse()); + { + topic: 'Human Resources', + count: 0, + }, + ]); }); it('correctly sorts data alphabetically', () => { @@ -129,11 +157,6 @@ describe('Topic & Frequency Graph Widget', () => { expect(await screen.findByText('Loading')).toBeInTheDocument(); }); - it('correctly inserts line breaks', () => { - const formattedtopic = topicsWithLineBreaks('Equity, Culture & Language'); - expect(formattedtopic).toBe(' Equity,
Culture
&
Language'); - }); - it('the sort control works', async () => { renderArGraphOverview({ data: [...TEST_DATA] }); const button = screen.getByRole('button', { name: /change topic graph order/i }); @@ -142,20 +165,26 @@ describe('Topic & Frequency Graph Widget', () => { act(() => userEvent.click(aZ)); const apply = screen.getByRole('button', { name: 'Apply filters for the Change topic graph order menu' }); - const point1 = document.querySelector('g.ytick'); + // this won't change because we sort count and then alphabetically + // and this is always last in that case + const firstPoint = document.querySelector('g.ytick'); + // eslint-disable-next-line no-underscore-dangle + expect(firstPoint.__data__.text).toBe('Human Resources'); + + const point1 = Array.from(document.querySelectorAll('g.ytick')).pop(); // eslint-disable-next-line no-underscore-dangle - expect(point1.__data__.text).toBe('Fiscal / Budget'); + expect(point1.__data__.text).toBe('Community and Self-Assessment'); act(() => userEvent.click(apply)); - const point2 = document.querySelector('g.ytick'); + const point2 = Array.from(document.querySelectorAll('g.ytick')).pop(); // eslint-disable-next-line no-underscore-dangle - expect(point2.__data__.text).toBe('Human Resources'); + expect(point2.__data__.text).toBe('CLASS: Instructional Support'); }); it('handles switching display contexts', async () => { renderArGraphOverview({ data: [...TEST_DATA] }); - const button = await screen.findByRole('button', { name: 'display number of activity reports by topic data as table' }); + const button = await screen.findByRole('button', { name: /display Number of Activity Reports by Topic as table/i }); act(() => userEvent.click(button)); const firstRowHeader = await screen.findByRole('cell', { @@ -166,7 +195,7 @@ describe('Topic & Frequency Graph Widget', () => { const firstTableCell = await screen.findByRole('cell', { name: /155/i }); expect(firstTableCell).toBeInTheDocument(); - const viewGraph = await screen.findByRole('button', { name: 'display number of activity reports by topic data as graph' }); + const viewGraph = await screen.findByRole('button', { name: /display Number of Activity Reports by Topic as graph/i }); act(() => userEvent.click(viewGraph)); expect(firstRowHeader).not.toBeInTheDocument(); diff --git a/frontend/src/widgets/__tests__/TrainingReportDashboardOverview.js b/frontend/src/widgets/__tests__/TrainingReportDashboardOverview.js index 49fd8121c8..fc4b053226 100644 --- a/frontend/src/widgets/__tests__/TrainingReportDashboardOverview.js +++ b/frontend/src/widgets/__tests__/TrainingReportDashboardOverview.js @@ -21,7 +21,7 @@ describe('TrainingReportDashboardOverview', () => { expect(screen.getAllByText('0%')).toHaveLength(1); expect(screen.getByText('Recipients have at least one active grant click to visually reveal this information')).toBeInTheDocument(); expect(screen.getByText('Grants served')).toBeInTheDocument(); - expect(screen.getByText('across 0 training reports')).toBeInTheDocument(); + expect(screen.getByText('across 0 Training Reports')).toBeInTheDocument(); expect(screen.getByText('Participants')).toBeInTheDocument(); expect(screen.getByText('Hours of TTA')).toBeInTheDocument(); }); @@ -47,7 +47,7 @@ describe('TrainingReportDashboardOverview', () => { expect(screen.getByText('Recipients have at least one active grant click to visually reveal this information')).toBeInTheDocument(); expect(screen.getByText('Grants served')).toBeInTheDocument(); - expect(screen.getByText('across 2 training reports')).toBeInTheDocument(); + expect(screen.getByText('across 2 Training Reports')).toBeInTheDocument(); expect(screen.getByText('Participants')).toBeInTheDocument(); expect(screen.getByText('Hours of TTA')).toBeInTheDocument(); }); diff --git a/frontend/src/widgets/__tests__/VBarGraph.js b/frontend/src/widgets/__tests__/VBarGraph.js new file mode 100644 index 0000000000..c6039b8c55 --- /dev/null +++ b/frontend/src/widgets/__tests__/VBarGraph.js @@ -0,0 +1,61 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, + waitFor, + act, + screen, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import VBarGraph from '../VBarGraph'; + +const TEST_DATA = [{ + name: 'one', + count: 1, +}, +{ + name: 'two / two and a half', + count: 2, +}, +{ + name: 'three is the number than comes after two and with that we think about it', + count: 0, +}]; + +const renderBarGraph = async () => { + act(() => { + render(); + }); +}; + +describe('VBar Graph', () => { + it('is shown', async () => { + renderBarGraph(); + + await waitFor(() => expect(document.querySelector('svg')).not.toBe(null)); + + const point1 = document.querySelector('g.ytick'); + // eslint-disable-next-line no-underscore-dangle + expect(point1.__data__.text).toBe('0'); + + const point2 = document.querySelector('g.xtick'); + // eslint-disable-next-line no-underscore-dangle + expect(point2.__data__.text).toBe('one'); + }); + + it('toggles table view', async () => { + act(() => { + renderBarGraph(); + }); + + await waitFor(() => expect(document.querySelector('svg')).not.toBe(null)); + + const button = await screen.findByRole('button', { name: /as table/i }); + act(() => { + userEvent.click(button); + }); + + const table = document.querySelector('table'); + expect(table).not.toBeNull(); + }); +}); diff --git a/frontend/src/widgets/widgets.scss b/frontend/src/widgets/widgets.scss new file mode 100644 index 0000000000..0b912a9054 --- /dev/null +++ b/frontend/src/widgets/widgets.scss @@ -0,0 +1,17 @@ +.ttahub-widget-heading-grid { + align-items: start; + display: grid; + grid-template-columns: 1fr; + gap: 1em; + width: 100%; +} + +@media(min-width: 1300px) { + .ttahub-widget-heading-grid { + grid-template-columns: repeat(2, 1fr) repeat(2, max-content); + } + + .ttahub-widget-heading-grid--title, .tta-widget-heading-grid--actions { + grid-column: span 2; + } +} \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c155d962dc..c4fb6940b8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1844,6 +1844,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@juggle/resize-observer@^3.3.1": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -2053,6 +2058,25 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@react-hook/latest@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80" + integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg== + +"@react-hook/passive-layout-effect@^1.2.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz#c06dac2d011f36d61259aa1c6df4f0d5e28bc55e" + integrity sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg== + +"@react-hook/resize-observer@^1.2.6": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz#9a8cf4c5abb09becd60d1d65f6bf10eec211e291" + integrity sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA== + dependencies: + "@juggle/resize-observer" "^3.3.1" + "@react-hook/latest" "^1.0.2" + "@react-hook/passive-layout-effect" "^1.2.0" + "@redux-saga/core@^1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@redux-saga/core/-/core-1.2.2.tgz#99b1daac93a42feecd9bab449f452f56f3155fea" diff --git a/src/lib/cache.ts b/src/lib/cache.ts index d62450df90..0f27c3695e 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -9,14 +9,14 @@ interface CacheOptions { /** * * @param {string} key the key to use for the cache - * @param {function} reponseCallback will be called if the cache is empty (must return a string) + * @param {function} responseCallback will be called if the cache is empty (must return a string) * @param {function} outputCallback will be called to format the output, defaults to a passthrough * @param options see the interface above, defaults to 10 minutes * @returns Promise, the cached response or null if there was an error */ export default async function getCachedResponse( key: string, - reponseCallback: () => Promise, + responseCallback: () => Promise, outputCallback: ((foo: string) => string) | JSON['parse'] = (foo: string) => foo, options: CacheOptions = { EX: 600, @@ -65,7 +65,7 @@ export default async function getCachedResponse( // if we do not have a response, we need to call the callback if (!response) { - response = await reponseCallback(); + response = await responseCallback(); // and then, if we have a response and we are connected to redis, we need to set the cache if (response && clientConnected) { try { diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index b57e099e06..35f66e021e 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -769,11 +769,18 @@ export async function getReportsByManyIds(req, res) { try { const userId = await currentUserId(req, res); - const { reportIds } = req.body; + const { + reportIds, offset, sortBy, sortDir, limit, + } = req.body; // this will return a query with region parameters based // on the req user's permissions - const query = await setReadRegions({}, userId); + const query = await setReadRegions({ + offset, + sortBy, + sortDir, + limit, + }, userId); const reportsWithCount = await activityReports(query, false, userId, reportIds); if (!reportsWithCount) { diff --git a/src/routes/resources/handlers.js b/src/routes/resources/handlers.js index 737e9b5b40..2181c2dacf 100644 --- a/src/routes/resources/handlers.js +++ b/src/routes/resources/handlers.js @@ -2,7 +2,7 @@ import filtersToScopes from '../../scopes'; import { currentUserId } from '../../services/currentUser'; import { setReadRegions } from '../../services/accessValidation'; -import { resourceDashboardPhase1 } from '../../services/dashboards/resource'; +import { resourceDashboardPhase1, resourceDashboardFlat } from '../../services/dashboards/resource'; import getCachedResponse from '../../lib/cache'; const RESOURCE_DATA_CACHE_VERSION = 1.5; @@ -29,3 +29,21 @@ export async function getResourcesDashboardData(req, res) { res.json(response); } + +export async function getFlatResourcesDataWithCache(req, res) { + const userId = await currentUserId(req, res); + const query = await setReadRegions(req.query, userId); + const key = `getFlatResourcesDashboardData?v=${RESOURCE_DATA_CACHE_VERSION}&${JSON.stringify(query)}`; + + const response = await getCachedResponse( + key, + async () => { + const scopes = await filtersToScopes(query); + const data = await resourceDashboardFlat(scopes); + return JSON.stringify(data); + }, + JSON.parse, + ); + + res.json(response); +} diff --git a/src/routes/resources/handlers.test.js b/src/routes/resources/handlers.test.js index 4289fce6ad..49b95cb98b 100644 --- a/src/routes/resources/handlers.test.js +++ b/src/routes/resources/handlers.test.js @@ -1,9 +1,10 @@ -import { getResourcesDashboardData } from './handlers'; -import { resourceDashboardPhase1 } from '../../services/dashboards/resource'; +import { getResourcesDashboardData, getFlatResourcesDataWithCache } from './handlers'; +import { resourceDashboardPhase1, resourceDashboardFlat } from '../../services/dashboards/resource'; import { getUserReadRegions } from '../../services/accessValidation'; jest.mock('../../services/dashboards/resource', () => ({ resourceDashboardPhase1: jest.fn(), + resourceDashboardFlat: jest.fn(), })); jest.mock('../../services/accessValidation'); @@ -32,4 +33,29 @@ describe('Resources handler', () => { expect(res.json).toHaveBeenCalledWith(resourcesData); }); }); + describe('getFlatResourcesDataWithCache', () => { + it('should return all dashboard data', async () => { + const responseData = { + overview: {}, + rolledUpResourceUse: {}, + rolledUpTopicUse: {}, + dateHeaders: [], + }; + + resourceDashboardFlat.mockResolvedValue(responseData); + getUserReadRegions.mockResolvedValue([1]); + const req = { + session: { userId: 1 }, + query: { + sortBy: 'id', + direction: 'asc', + limit: 10, + offset: 0, + }, + }; + const res = { json: jest.fn() }; + await getFlatResourcesDataWithCache(req, res); + expect(res.json).toHaveBeenCalledWith(responseData); + }); + }); }); diff --git a/src/routes/resources/index.js b/src/routes/resources/index.js index 0bf0000cf0..d62c10172a 100644 --- a/src/routes/resources/index.js +++ b/src/routes/resources/index.js @@ -1,9 +1,11 @@ import express from 'express'; import { getResourcesDashboardData, + getFlatResourcesDataWithCache, } from './handlers'; import transactionWrapper from '../transactionWrapper'; const router = express.Router(); router.get('/', transactionWrapper(getResourcesDashboardData)); +router.get('/flat', transactionWrapper(getFlatResourcesDataWithCache)); export default router; diff --git a/src/services/dashboards/resource.js b/src/services/dashboards/resource.js index f3827394a0..cb7ec44235 100644 --- a/src/services/dashboards/resource.js +++ b/src/services/dashboards/resource.js @@ -1,6 +1,7 @@ /* eslint-disable max-len */ -import { Sequelize, Op } from 'sequelize'; +import { Sequelize, Op, QueryTypes } from 'sequelize'; import { REPORT_STATUSES } from '@ttahub/common'; +import { v4 as uuidv4 } from 'uuid'; import { ActivityReport, ActivityReportGoal, @@ -330,6 +331,417 @@ const switchToTopicCentric = (input) => { }); }; +async function GenerateFlatTempTables(reportIds, tblNames) { + const flatResourceSql = /* sql */ ` + -- 1.) Create AR temp table. + DROP TABLE IF EXISTS ${tblNames.createdArTempTableName}; + SELECT + id, + "startDate", + "numberOfParticipants", + to_char("startDate", 'Mon-YY') AS "rollUpDate", + "regionId", + "calculatedStatus" + INTO TEMP ${tblNames.createdArTempTableName} + FROM "ActivityReports" ar + WHERE ar."id" IN (${reportIds.map((r) => r.id).join(',')}); + + -- 2.) Create ARO Resources temp table. + DROP TABLE IF EXISTS ${tblNames.createdAroResourcesTempTableName}; + SELECT + DISTINCT + ar.id AS "activityReportId", + aror."resourceId" + INTO TEMP ${tblNames.createdAroResourcesTempTableName} + FROM ${tblNames.createdArTempTableName} ar + JOIN "ActivityReportObjectives" aro + ON ar."id" = aro."activityReportId" + JOIN "ActivityReportObjectiveResources" aror + ON aro.id = aror."activityReportObjectiveId" + WHERE aror."sourceFields" && '{resource}'; + + -- 3.) Create Resources temp table (only what we need). + DROP TABLE IF EXISTS ${tblNames.createdResourcesTempTableName}; + SELECT + DISTINCT + id, + domain, + url, + title + INTO TEMP ${tblNames.createdResourcesTempTableName} + FROM "Resources" + JOIN ${tblNames.createdAroResourcesTempTableName} dr + ON "Resources".id = dr."resourceId"; + + -- 4.) Create ARO Topics temp table. **** Revisit + DROP TABLE IF EXISTS ${tblNames.createdAroTopicsTempTableName}; + SELECT + ar.id AS "activityReportId", + arot."topicId", + count(DISTINCT aro."objectiveId") AS "objectiveCount" + INTO TEMP ${tblNames.createdAroTopicsTempTableName} + FROM ${tblNames.createdArTempTableName} ar + JOIN "ActivityReportObjectives" aro + ON ar."id" = aro."activityReportId" + JOIN "ActivityReportObjectiveResources" aror + ON aro.id = aror."activityReportObjectiveId" + JOIN "ActivityReportObjectiveTopics" arot + ON aro.id = arot."activityReportObjectiveId" + GROUP BY ar.id, arot."topicId"; + + -- 5.) Create Topics temp table (only what we need). + DROP TABLE IF EXISTS ${tblNames.createdTopicsTempTableName}; + SELECT + DISTINCT + id, + name + INTO TEMP ${tblNames.createdTopicsTempTableName} + FROM "Topics" + JOIN ${tblNames.createdAroTopicsTempTableName} dt + ON "Topics".id = dt."topicId"; + + -- 6.) Create Flat Resource temp table. + DROP TABLE IF EXISTS ${tblNames.createdFlatResourceTempTableName}; + SELECT + DISTINCT + ar.id AS "activityReportId", + ar."startDate", + ar."rollUpDate", + arorr.domain, + arorr.title, + arorr.url, + ar."numberOfParticipants" + INTO TEMP ${tblNames.createdFlatResourceTempTableName} + FROM ${tblNames.createdArTempTableName} ar + JOIN ${tblNames.createdAroResourcesTempTableName} aror + ON ar.id = aror."activityReportId" + JOIN ${tblNames.createdResourcesTempTableName} arorr + ON aror."resourceId" = arorr.id; + + -- 7.) Create date headers. + DROP TABLE IF EXISTS ${tblNames.createdFlatResourceHeadersTempTableName}; + SELECT + generate_series( + date_trunc('month', (SELECT MIN("startDate") FROM ${tblNames.createdFlatResourceTempTableName})), + date_trunc('month', (SELECT MAX("startDate") FROM ${tblNames.createdFlatResourceTempTableName})), + interval '1 month' + )::date AS "date" + INTO TEMP ${tblNames.createdFlatResourceHeadersTempTableName}; + `; + + // console.log('\n\n\n---- Flat sql: ', flatResourceSql, '\n\n\n'); + + // Execute the flat table sql. + await sequelize.query( + flatResourceSql, + { + type: QueryTypes.SELECT, + }, + ); +} + +function getResourceUseSql(tblNames) { + const resourceUseSql = /* sql */` + WITH urlvals AS ( + SELECT + url, + title, + "rollUpDate", + count(tf."activityReportId") AS "resourceCount" + FROM ${tblNames.createdFlatResourceTempTableName} tf + GROUP BY url, title, "rollUpDate" + ORDER BY "url", tf."rollUpDate" ASC), + distincturls AS ( + SELECT DISTINCT url, title + FROM ${tblNames.createdFlatResourceTempTableName} + ), + totals AS + ( + SELECT + url, + title, + SUM("resourceCount") AS "totalCount" + FROM urlvals + GROUP BY url, title + ORDER BY SUM("resourceCount") DESC, + url ASC + LIMIT 10 + ) + SELECT + d.url, + d.title, + to_char(s."date", 'Mon-YY') AS "rollUpDate", + s."date", + coalesce(u."resourceCount", 0) AS "resourceCount", + t."totalCount" + FROM distincturls d + CROSS JOIN ${tblNames.createdFlatResourceHeadersTempTableName} s + JOIN totals t + ON d.url = t.url + LEFT JOIN urlvals u + ON d.url = u.url AND to_char(s."date", 'Mon-YY') = u."rollUpDate" + ORDER BY 1,4 ASC; + `; + + return sequelize.query( + resourceUseSql, + { + type: QueryTypes.SELECT, + }, + ); +} + +function getTopicsUseSql(tblNames) { + const topicUseSql = /* sql */` + WITH topicsuse AS ( + SELECT + f."activityReportId", + t.name, + f."rollUpDate", + count(DISTINCT f.url) AS "resourceCount" -- Only count each resource once per ar and topic. + FROM ${tblNames.createdTopicsTempTableName} t + JOIN ${tblNames.createdAroTopicsTempTableName} arot + ON t.id = arot."topicId" + JOIN ${tblNames.createdFlatResourceTempTableName} f + ON arot."activityReportId" = f."activityReportId" + GROUP BY f."activityReportId", t.name, f."rollUpDate" + ORDER BY t.name, f."rollUpDate" ASC + ), + topicsperdate AS + ( + SELECT + "name", + "rollUpDate", + SUM("resourceCount") AS "resourceCount" + FROM topicsuse + GROUP BY "name", "rollUpDate" + ), + totals AS + ( + SELECT + name, + SUM("resourceCount") AS "totalCount" + FROM topicsperdate + GROUP BY name + ORDER BY SUM("resourceCount") DESC + ) + SELECT + d.name, + to_char(s."date", 'Mon-YY') AS "rollUpDate", + s."date", + coalesce(t."resourceCount", 0) AS "resourceCount", + tt."totalCount" + FROM ${tblNames.createdTopicsTempTableName} d + JOIN ${tblNames.createdFlatResourceHeadersTempTableName} s + ON 1=1 + JOIN totals tt + ON d.name = tt.name + LEFT JOIN topicsperdate t + ON d.name = t.name AND to_char(s."date", 'Mon-YY') = t."rollUpDate" + ORDER BY 1, 3 ASC;`; + return sequelize.query( + topicUseSql, + { + type: QueryTypes.SELECT, + }, + ); +} + +function getOverview(tblNames, totalReportCount) { + // - Number of Participants - + const numberOfParticipants = sequelize.query(/* sql */` + WITH ar_participants AS ( + SELECT + f."activityReportId", + f."numberOfParticipants" + FROM ${tblNames.createdFlatResourceTempTableName} f + GROUP BY f."activityReportId", f."numberOfParticipants" + ) + SELECT + SUM("numberOfParticipants") AS participants + FROM ar_participants; + `, { + type: QueryTypes.SELECT, + }); + + const numberOfRecipSql = /* sql */` + SELECT + count(DISTINCT g."recipientId") AS recipients + FROM ${tblNames.createdFlatResourceTempTableName} ar + JOIN "ActivityRecipients" arr + ON ar."activityReportId" = arr."activityReportId" + JOIN "Grants" g + ON arr."grantId" = g.id + `; + + // - Number of Recipients - + const numberOfRecipients = sequelize.query(numberOfRecipSql, { + type: QueryTypes.SELECT, + }); + + // - Reports with Resources Pct - + const pctOfResourcesSql = /* sql */` + SELECT + count(DISTINCT "activityReportId")::decimal AS "reportsWithResourcesCount", + ${totalReportCount}::decimal AS "totalReportsCount", + CASE WHEN ${totalReportCount} = 0 THEN + 0 + ELSE + (count(DISTINCT "activityReportId") / ${totalReportCount}::decimal * 100)::decimal(5,2) + END AS "resourcesPct" + FROM ${tblNames.createdAroResourcesTempTableName}; + `; + const pctOfReportsWithResources = sequelize.query(pctOfResourcesSql, { + type: QueryTypes.SELECT, + }); + + // - Number of Reports with ECLKC Resources Pct - + const pctOfECKLKCResources = sequelize.query(/* sql */` + WITH eclkc AS ( + SELECT + COUNT(DISTINCT url) AS "eclkcCount" + FROM ${tblNames.createdFlatResourceTempTableName} + WHERE domain = 'eclkc.ohs.acf.hhs.gov' + ), allres AS ( + SELECT + COUNT(DISTINCT url) AS "allCount" + FROM ${tblNames.createdFlatResourceTempTableName} + ) + SELECT + e."eclkcCount", + r."allCount", + CASE WHEN + r."allCount" = 0 + THEN 0 + ELSE (e."eclkcCount" / r."allCount"::decimal * 100)::decimal(5,2) + END AS "eclkcPct" + FROM eclkc e + CROSS JOIN allres r; + `, { + type: QueryTypes.SELECT, + }); + + // 5.) Date Headers table. + const dateHeaders = sequelize.query(/* sql */` + SELECT + to_char("date", 'Mon-YY') AS "rollUpDate" + FROM ${tblNames.createdFlatResourceHeadersTempTableName}; + `, { + type: QueryTypes.SELECT, + }); + return { + numberOfParticipants, + numberOfRecipients, + pctOfReportsWithResources, + pctOfECKLKCResources, + dateHeaders, + }; +} + +/* + Create a flat table to calculate the resource data. Use temp tables to ONLY join to the rows we need. + If over time the amount of data increases and slows again we can cache the flat table a set frequency. +*/ +export async function resourceFlatData(scopes) { + // Date to retrieve report data from. + const reportCreatedAtDate = '2022-12-01'; + + // 1.) Get report ids using the scopes. + const reportIds = await ActivityReport.findAll({ + attributes: [ + 'id', + ], + where: { + [Op.and]: [ + scopes.activityReport, + { + calculatedStatus: REPORT_STATUSES.APPROVED, + startDate: { [Op.ne]: null }, + createdAt: { [Op.gt]: reportCreatedAtDate }, + }, + ], + }, + raw: true, + }); + + // Get total number of reports. + const totalReportCount = reportIds.length; + + if (reportIds.length === 0) { + reportIds.push({ id: 0 }); + } + // 2.) Create temp table names. + const uuid = uuidv4().replaceAll('-', '_'); + const createdArTempTableName = `Z_temp_resdb_ar__${uuid}`; + const createdAroResourcesTempTableName = `Z_temp_resdb_aror__${uuid}`; + const createdResourcesTempTableName = `Z_temp_resdb_res__${uuid}`; + const createdAroTopicsTempTableName = `Z_temp_resdb_arot__${uuid}`; + const createdTopicsTempTableName = `Z_temp_resdb_topics__${uuid}`; + const createdFlatResourceHeadersTempTableName = `Z_temp_resdb_headers__${uuid}`; // Date Headers. + const createdFlatResourceTempTableName = `Z_temp_resdb_flat__${uuid}`; // Main Flat Table. + + const tempTableNames = { + createdArTempTableName, + createdAroResourcesTempTableName, + createdResourcesTempTableName, + createdAroTopicsTempTableName, + createdTopicsTempTableName, + createdFlatResourceTempTableName, + createdFlatResourceHeadersTempTableName, + }; + + // 3. Generate the base temp tables (used for all calcs). + await GenerateFlatTempTables(reportIds, tempTableNames); + + // 4.) Calculate the resource data. + + // -- Resource Use -- + let resourceUseResult = getResourceUseSql(tempTableNames); + + // -- Topic Use -- + let topicUseResult = getTopicsUseSql(tempTableNames); + + // -- Overview -- + let { + numberOfParticipants, + numberOfRecipients, + pctOfReportsWithResources, + pctOfECKLKCResources, + dateHeaders, + } = getOverview(tempTableNames, totalReportCount); + + // -- Wait for all results -- + [ + resourceUseResult, + topicUseResult, + numberOfParticipants, + numberOfRecipients, + pctOfReportsWithResources, + pctOfECKLKCResources, + dateHeaders, + ] = await Promise.all( + [ + resourceUseResult, + topicUseResult, + numberOfParticipants, + numberOfRecipients, + pctOfReportsWithResources, + pctOfECKLKCResources, + dateHeaders, + ], + ); + + // 5.) Restructure Overview. + const overView = { + numberOfParticipants, numberOfRecipients, pctOfReportsWithResources, pctOfECKLKCResources, + }; + + // 6.) Return the data. + return { + resourceUseResult, topicUseResult, overView, dateHeaders, reportIds, + }; +} + // collect all resource data from the db filtered via the scopes export async function resourceData(scopes, skipResources = false, skipTopics = false) { // Date to retrieve report data from. @@ -1604,3 +2016,114 @@ export async function resourceDashboard(scopes) { domainList: generateResourceDomainList(data, true), }; } + +export async function rollUpResourceUse(data) { + const rolledUpResourceUse = data.resourceUseResult.reduce((accumulator, resource) => { + const exists = accumulator.find((r) => r.url === resource.url); + if (!exists) { + // Add a property with the resource's URL. + return [ + ...accumulator, + { + heading: resource.url, + url: resource.url, + title: resource.title, + sortBy: resource.title || resource.url, + total: resource.totalCount, + isUrl: true, + data: [{ title: resource.rollUpDate, value: resource.resourceCount }], + }, + ]; + } + + // Add the resource to the accumulator. + exists.data.push({ title: resource.rollUpDate, value: resource.resourceCount }); + return accumulator; + }, []); + + // Loop through the rolled up resources and add a total. + rolledUpResourceUse.forEach((resource) => { + resource.data.push({ title: 'Total', value: resource.total }); + }); + + // Sort by total and name or url. + // rolledUpResourceUse.sort((r1, r2) => r2.total - r1.total || r1.sortBy.localeCompare(r2.sortBy)); + rolledUpResourceUse.sort((r1, r2) => r2.total - r1.total || r1.url.localeCompare(r2.url)); + return rolledUpResourceUse; +} + +export async function rollUpTopicUse(data) { + const rolledUpTopicUse = data.topicUseResult.reduce((accumulator, topic) => { + const exists = accumulator.find((r) => r.name === topic.name); + if (!exists) { + // Add a property with the resource's name. + return [ + ...accumulator, + { + heading: topic.name, + name: topic.name, + total: topic.totalCount, + isUrl: false, + data: [{ title: topic.rollUpDate, value: topic.resourceCount }], + }, + ]; + } + + // Add the resource to the accumulator. + exists.data.push({ title: topic.rollUpDate, value: topic.resourceCount }); + return accumulator; + }, []); + + // Loop through the rolled up resources and add a total. + rolledUpTopicUse.forEach((topic) => { + topic.data.push({ title: 'Total', value: topic.total }); + }); + + // Sort by total then topic name. + rolledUpTopicUse.sort((r1, r2) => r2.total - r1.total || r1.name.localeCompare(r2.name)); + return rolledUpTopicUse; +} + +export function restructureOverview(data) { + return { + report: { + percentResources: `${formatNumber(data.overView.pctOfReportsWithResources[0].resourcesPct, 2)}%`, + numResources: formatNumber(data.overView.pctOfReportsWithResources[0].reportsWithResourcesCount), + num: formatNumber(data.overView.pctOfReportsWithResources[0].totalReportsCount), + }, + participant: { + numParticipants: formatNumber(data.overView.numberOfParticipants[0].participants), + }, + recipient: { + numResources: formatNumber(data.overView.numberOfRecipients[0].recipients), + }, + resource: { + numEclkc: formatNumber(data.overView.pctOfECKLKCResources[0].eclkcCount), + num: formatNumber(data.overView.pctOfECKLKCResources[0].allCount), + percentEclkc: `${formatNumber(data.overView.pctOfECKLKCResources[0].eclkcPct, 2)}%`, + }, + }; +} + +export async function resourceDashboardFlat(scopes) { + // Get flat resource data. + const data = await resourceFlatData(scopes); + + // Restructure overview. + const dashboardOverview = restructureOverview(data); + + // Roll up resources. + const rolledUpResourceUse = await rollUpResourceUse(data); + + // Roll up topics. + const rolledUpTopicUse = await rollUpTopicUse(data); + + // Date headers. + const dateHeadersArray = data.dateHeaders.map((date) => date.rollUpDate); + return { + resourcesDashboardOverview: dashboardOverview, + resourcesUse: { headers: dateHeadersArray, resources: rolledUpResourceUse }, + topicUse: { headers: dateHeadersArray, topics: rolledUpTopicUse }, + reportIds: data.reportIds.map((r) => r.id), + }; +} diff --git a/src/services/dashboards/resourceFlat.test.js b/src/services/dashboards/resourceFlat.test.js new file mode 100644 index 0000000000..89d1ed844c --- /dev/null +++ b/src/services/dashboards/resourceFlat.test.js @@ -0,0 +1,857 @@ +/* eslint-disable jest/no-commented-out-tests */ +/* eslint-disable max-len */ +import { REPORT_STATUSES } from '@ttahub/common'; +import db, { + ActivityReport, + ActivityRecipient, + Topic, + User, + Recipient, + Grant, + NextStep, + Goal, + Objective, + ActivityReportObjective, + ActivityReportObjectiveResource, + ActivityReportObjectiveTopic, +} from '../../models'; +import filtersToScopes from '../../scopes'; +import { + resourceFlatData, + rollUpResourceUse, + rollUpTopicUse, + restructureOverview, +} from './resource'; +import { RESOURCE_DOMAIN } from '../../constants'; +import { processActivityReportObjectiveForResourcesById } from '../resource'; + +const RECIPIENT_ID = 46204400; +const GRANT_ID_ONE = 107843; +const REGION_ID = 14; +const NONECLKC_DOMAIN = 'non.test1.gov'; +const ECLKC_RESOURCE_URL = `https://${RESOURCE_DOMAIN.ECLKC}/test`; +const ECLKC_RESOURCE_URL2 = `https://${RESOURCE_DOMAIN.ECLKC}/test2`; +const NONECLKC_RESOURCE_URL = `https://${NONECLKC_DOMAIN}/a/b/c`; + +const mockUser = { + id: 5426871, + homeRegionId: 1, + name: 'user5426862', + hsesUsername: 'user5426862', + hsesUserId: '5426862', + lastLogin: new Date(), +}; + +const mockRecipient = { + name: 'recipient', + id: RECIPIENT_ID, + uei: 'NNA5N2KHMGN2XX', +}; + +const mockGrant = { + id: GRANT_ID_ONE, + number: `${GRANT_ID_ONE}`, + recipientId: RECIPIENT_ID, + regionId: REGION_ID, + status: 'Active', +}; + +const mockGoal = { + name: 'Goal 1', + status: 'Draft', + endDate: null, + isFromSmartsheetTtaPlan: false, + onApprovedAR: false, + onAR: false, + grantId: GRANT_ID_ONE, + createdVia: 'rtr', +}; + +const reportObject = { + activityRecipientType: 'recipient', + submissionStatus: REPORT_STATUSES.SUBMITTED, + calculatedStatus: REPORT_STATUSES.APPROVED, + userId: mockUser.id, + lastUpdatedById: mockUser.id, + ECLKCResourcesUsed: ['test'], + activityRecipients: [ + { grantId: GRANT_ID_ONE }, + ], + approvingManagerId: 1, + numberOfParticipants: 11, + deliveryMethod: 'method', + duration: 1, + endDate: '2000-01-01T12:00:00Z', + startDate: '2000-01-01T12:00:00Z', + requester: 'requester', + targetPopulations: ['pop'], + reason: ['reason'], + participants: ['participants'], + ttaType: ['technical-assistance'], + version: 2, +}; + +const regionOneReportA = { + ...reportObject, + regionId: REGION_ID, + duration: 1, + startDate: '2021-01-02T12:00:00Z', + endDate: '2021-01-31T12:00:00Z', +}; + +const regionOneReportB = { + ...reportObject, + regionId: REGION_ID, + duration: 2, + startDate: '2021-01-15T12:00:00Z', + endDate: '2021-02-15T12:00:00Z', +}; + +const regionOneReportC = { + ...reportObject, + regionId: REGION_ID, + duration: 3, + startDate: '2021-01-20T12:00:00Z', + endDate: '2021-02-28T12:00:00Z', +}; + +const regionOneReportD = { + ...reportObject, + regionId: REGION_ID, + duration: 3, + startDate: '2021-01-22T12:00:00Z', + endDate: '2021-01-31T12:00:00Z', +}; + +const regionOneDraftReport = { + ...reportObject, + regionId: REGION_ID, + duration: 7, + startDate: '2021-01-02T12:00:00Z', + endDate: '2021-01-31T12:00:00Z', + submissionStatus: REPORT_STATUSES.DRAFT, + calculatedStatus: REPORT_STATUSES.DRAFT, +}; + +let grant; +let goal; +let objective; +let goalTwo; +let goalThree; +let objectiveTwo; +let objectiveThree; +let activityReportOneObjectiveOne; +let activityReportOneObjectiveTwo; +let activityReportOneObjectiveThree; // Topic but no resources. +let activityReportObjectiveTwo; +let activityReportObjectiveThree; +let arIds; + +describe('Resources dashboard', () => { + beforeAll(async () => { + await User.findOrCreate({ where: mockUser, individualHooks: true }); + await Recipient.findOrCreate({ where: mockRecipient, individualHooks: true }); + [grant] = await Grant.findOrCreate({ + where: mockGrant, + validate: true, + individualHooks: true, + }); + [goal] = await Goal.findOrCreate({ where: mockGoal, validate: true, individualHooks: true }); + [goalTwo] = await Goal.findOrCreate({ where: { ...mockGoal, name: 'Goal 2' }, validate: true, individualHooks: true }); + [goalThree] = await Goal.findOrCreate({ where: { ...mockGoal, name: 'Goal 3' }, validate: true, individualHooks: true }); + [objective] = await Objective.findOrCreate({ + where: { + title: 'Objective 1', + goalId: goal.dataValues.id, + status: 'In Progress', + }, + }); + + [objectiveTwo] = await Objective.findOrCreate({ + where: { + title: 'Objective 2', + goalId: goalTwo.dataValues.id, + status: 'In Progress', + }, + }); + + [objectiveThree] = await Objective.findOrCreate({ + where: { + title: 'Objective 3', + goalId: goalThree.dataValues.id, + status: 'In Progress', + }, + }); + + // Get topic ID's. + const { topicId: classOrgTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'CLASS: Classroom Organization' }, + raw: true, + }); + + const { topicId: erseaTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'ERSEA' }, + raw: true, + }); + + const { topicId: coachingTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'Coaching' }, + raw: true, + }); + + const { topicId: facilitiesTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'Facilities' }, + raw: true, + }); + + const { topicId: fiscalBudgetTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'Fiscal / Budget' }, + raw: true, + }); + + const { topicId: nutritionTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'Nutrition' }, + raw: true, + }); + + const { topicId: oralHealthTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'Oral Health' }, + raw: true, + }); + + const { topicId: equityTopicId } = await Topic.findOne({ + attributes: [['id', 'topicId']], + where: { name: 'Equity' }, + raw: true, + }); + + // Report 1 (Mixed Resources). + const reportOne = await ActivityReport.create({ + ...regionOneReportA, + }, { + individualHooks: true, + }); + await ActivityRecipient.findOrCreate({ + where: { activityReportId: reportOne.id, grantId: mockGrant.id }, + }); + + // Report 1 - Activity Report Objective 1 + [activityReportOneObjectiveOne] = await ActivityReportObjective.findOrCreate({ + where: { + activityReportId: reportOne.id, + status: 'Complete', + objectiveId: objective.id, + }, + }); + + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportOneObjectiveOne.id, + topicId: classOrgTopicId, + }, + }); + + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportOneObjectiveOne.id, + topicId: erseaTopicId, + }, + }); + + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportOneObjectiveOne.id, + topicId: coachingTopicId, + }, + }); + + // Report 1 ECLKC Resource 1. + // Report 1 Non-ECLKC Resource 1. + await processActivityReportObjectiveForResourcesById( + activityReportOneObjectiveOne.id, + [ECLKC_RESOURCE_URL, NONECLKC_RESOURCE_URL], + ); + + // Report 1 - Activity Report Objective 2 + [activityReportOneObjectiveTwo] = await ActivityReportObjective.findOrCreate({ + where: { + activityReportId: reportOne.id, + status: 'Complete', + objectiveId: objectiveTwo.id, + }, + }); + + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportOneObjectiveTwo.id, + topicId: coachingTopicId, + }, + }); + + await processActivityReportObjectiveForResourcesById( + activityReportOneObjectiveTwo.id, + [ECLKC_RESOURCE_URL, NONECLKC_RESOURCE_URL], + ); + + // Report 1 - Activity Report Objective 3 (No resources) + // This topic should NOT count as there are no resources. + [activityReportOneObjectiveThree] = await ActivityReportObjective.findOrCreate({ + where: { + activityReportId: reportOne.id, + status: 'Complete', + objectiveId: objectiveThree.id, + }, + }); + + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportOneObjectiveThree.id, + topicId: nutritionTopicId, + }, + }); + + // Report 2 (Only ECLKC). + const reportTwo = await ActivityReport.create({ ...regionOneReportB }); + await ActivityRecipient.create({ activityReportId: reportTwo.id, grantId: mockGrant.id }); + + activityReportObjectiveTwo = await ActivityReportObjective.create({ + activityReportId: reportTwo.id, + status: 'Complete', + objectiveId: objective.id, + }); + + // Report 2 ECLKC Resource 1. + await processActivityReportObjectiveForResourcesById( + activityReportObjectiveTwo.id, + [ECLKC_RESOURCE_URL, ECLKC_RESOURCE_URL2], + ); + + // Report 2 Topic 1. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveTwo.id, + topicId: oralHealthTopicId, + }, + }); + + // Report 3 (Only Non-ECLKC). + const reportThree = await ActivityReport.create({ ...regionOneReportC }); + await ActivityRecipient.create({ activityReportId: reportThree.id, grantId: mockGrant.id }); + + activityReportObjectiveThree = await ActivityReportObjective.create({ + activityReportId: reportThree.id, + status: 'Complete', + objectiveId: objective.id, + }); + + // Report 3 Non-ECLKC Resource 1. + await processActivityReportObjectiveForResourcesById( + activityReportObjectiveThree.id, + [NONECLKC_RESOURCE_URL, ECLKC_RESOURCE_URL2], + ); + + // Report 3 Topic 1. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveThree.id, + topicId: nutritionTopicId, + }, + }); + + // Report 4. + const reportFour = await ActivityReport.create({ ...regionOneReportD }); + await ActivityRecipient.create({ activityReportId: reportFour.id, grantId: mockGrant.id }); + + const activityReportObjectiveForReport4 = await ActivityReportObjective.create({ + activityReportId: reportFour.id, + status: 'Complete', + objectiveId: objective.id, + }); + + // Report 4 Topic 1. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveForReport4.id, + topicId: facilitiesTopicId, + }, + }); + + // Report 4 Topic 2. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveForReport4.id, + topicId: fiscalBudgetTopicId, + }, + }); + + // Report 4 Topic 3. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveForReport4.id, + topicId: erseaTopicId, + }, + }); + + // Report 4 Non-ECLKC Resource 1. + await processActivityReportObjectiveForResourcesById( + activityReportObjectiveForReport4.id, + [ECLKC_RESOURCE_URL2], + ); + + // Report 5 (No resources). + const reportFive = await ActivityReport.create({ ...regionOneReportD }); + await ActivityRecipient.create({ activityReportId: reportFive.id, grantId: mockGrant.id }); + + const activityReportObjectiveForReport5 = await ActivityReportObjective.create({ + activityReportId: reportFive.id, + status: 'Complete', + objectiveId: objective.id, + }); + + // Report 5 Topic 1. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveForReport5.id, + topicId: facilitiesTopicId, + }, + }); + + // Draft Report (Excluded). + const reportDraft = await ActivityReport.create({ ...regionOneDraftReport }); + await ActivityRecipient.create({ activityReportId: reportDraft.id, grantId: mockGrant.id }); + + const activityReportObjectiveDraft = await ActivityReportObjective.create({ + activityReportId: reportDraft.id, + status: 'Complete', + objectiveId: objective.id, + }); + + // Report Draft ECLKC Resource 1. + // Report Draft Non-ECLKC Resource 1. + await processActivityReportObjectiveForResourcesById( + activityReportObjectiveDraft.id, + [ECLKC_RESOURCE_URL, NONECLKC_RESOURCE_URL], + ); + + // Draft Report 5 Topic 1. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveDraft.id, + topicId: equityTopicId, + }, + }); + + // Draft Report 5 Topic 2. + await ActivityReportObjectiveTopic.findOrCreate({ + where: { + activityReportObjectiveId: activityReportObjectiveDraft.id, + topicId: erseaTopicId, + }, + }); + + arIds = [ + reportOne.id, + reportTwo.id, + reportThree.id, + reportFour.id, + reportFive.id, + reportDraft.id, + ]; + }); + + afterAll(async () => { + const reports = await ActivityReport + .findAll({ where: { userId: [mockUser.id] } }); + const ids = reports.map((report) => report.id); + await NextStep.destroy({ where: { activityReportId: ids } }); + await ActivityRecipient.destroy({ where: { activityReportId: ids } }); + + await ActivityReportObjectiveResource.destroy({ + where: { + activityReportObjectiveId: activityReportOneObjectiveOne.id, + }, + }); + await ActivityReportObjectiveTopic.destroy({ + where: { + activityReportObjectiveId: arIds, + }, + }); + + // eslint-disable-next-line max-len + await ActivityReportObjective.destroy({ where: { objectiveId: [objective.id, objectiveTwo.id, objectiveThree.id] } }); + await ActivityReport.destroy({ where: { id: ids } }); + await Objective.destroy({ where: { id: [objective.id, objectiveTwo.id, objectiveThree.id] }, force: true }); + await Goal.destroy({ where: { id: [goal.id, goalTwo.id, goalThree.id] }, force: true }); + await Grant.destroy({ where: { id: GRANT_ID_ONE }, individualHooks: true }); + await User.destroy({ where: { id: [mockUser.id] } }); + await Recipient.destroy({ where: { id: RECIPIENT_ID } }); + await db.sequelize.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('resourceUseFlat', async () => { + const scopes = await filtersToScopes({ 'region.in': [REGION_ID], 'startDate.win': '2021/01/01-2021/01/31' }); + let resourceUseResult; + db.sequelize.transaction(async () => { + ({ resourceUseResult } = await resourceFlatData(scopes)); + + expect(resourceUseResult).toBeDefined(); + expect(resourceUseResult.length).toBe(3); + + expect(resourceUseResult).toStrictEqual([ + { + date: '2021-01-01', + url: 'https://eclkc.ohs.acf.hhs.gov/test', + rollUpDate: 'Jan-21', + title: null, + resourceCount: '2', + totalCount: '2', + }, + { + date: '2021-01-01', + url: 'https://eclkc.ohs.acf.hhs.gov/test2', + rollUpDate: 'Jan-21', + title: null, + resourceCount: '3', + totalCount: '3', + }, + { + date: '2021-01-01', + url: 'https://non.test1.gov/a/b/c', + rollUpDate: 'Jan-21', + title: null, + resourceCount: '2', + totalCount: '2', + }, + ]); + }); + }); + + it('resourceTopicUseFlat', async () => { + const scopes = await filtersToScopes({ 'region.in': [REGION_ID], 'startDate.win': '2021/01/01-2021/01/31' }); + let topicUseResult; + db.sequelize.transaction(async () => { + ({ topicUseResult } = await resourceFlatData(scopes)); + + expect(topicUseResult).toBeDefined(); + + expect(topicUseResult).toStrictEqual([ + { + name: 'CLASS: Classroom Organization', rollUpDate: 'Jan-21', resourceCount: '2', totalCount: '2', date: '2021-01-01', + }, + { + name: 'Coaching', rollUpDate: 'Jan-21', resourceCount: '2', totalCount: '2', date: '2021-01-01', + }, + { + name: 'ERSEA', rollUpDate: 'Jan-21', resourceCount: '3', totalCount: '3', date: '2021-01-01', + }, + { + name: 'Facilities', rollUpDate: 'Jan-21', resourceCount: '1', totalCount: '1', date: '2021-01-01', + }, + { + name: 'Fiscal / Budget', rollUpDate: 'Jan-21', resourceCount: '1', totalCount: '1', date: '2021-01-01', + }, + { + name: 'Nutrition', rollUpDate: 'Jan-21', resourceCount: '2', totalCount: '2', date: '2021-01-01', + }, + { + name: 'Oral Health', rollUpDate: 'Jan-21', resourceCount: '2', totalCount: '2', date: '2021-01-01', + }, + ]); + }); + }); + + it('overviewFlat', async () => { + const scopes = await filtersToScopes({ 'region.in': [REGION_ID], 'startDate.win': '2021/01/01-2021/01/31' }); + let overView; + db.sequelize.transaction(async () => { + ({ overView } = await resourceFlatData(scopes)); + + expect(overView).toBeDefined(); + const { + numberOfParticipants, + numberOfRecipients, + pctOfReportsWithResources, + pctOfECKLKCResources, + } = overView; + + // Number of Participants. + expect(numberOfParticipants).toStrictEqual([{ + participants: '44', + }]); + + // Number of Recipients. + expect(numberOfRecipients).toStrictEqual([{ + recipients: '1', + }]); + + // Percent of Reports with Resources. + expect(pctOfReportsWithResources).toStrictEqual([ + { + reportsWithResourcesCount: '4', + totalReportsCount: '5', + resourcesPct: '80.0000', + }, + ]); + + // Percent of ECLKC reports. + expect(pctOfECKLKCResources).toStrictEqual([ + { + eclkcCount: '2', + allCount: '3', + eclkcPct: '66.6667', + }, + ]); + }); + }); + + it('resourceDateHeadersFlat', async () => { + const scopes = await filtersToScopes({ 'region.in': [REGION_ID], 'startDate.win': '2021/01/01-2021/01/31' }); + let dateHeaders; + db.sequelize.transaction(async () => { + ({ dateHeaders } = await resourceFlatData(scopes)); + expect(dateHeaders).toBeDefined(); + expect(dateHeaders.length).toBe(1); + expect(dateHeaders).toStrictEqual([ + { + rollUpDate: 'Jan-21', + }, + ]); + }); + }); + + it('should roll up resource use results correctly', async () => { + const data = { + resourceUseResult: [ + { + url: 'http://google.com', resourceCount: 1, rollUpDate: 'Jan-21', title: null, totalCount: 10, + }, + { + url: 'http://google.com', resourceCount: 2, rollUpDate: 'Feb-21', title: null, totalCount: 10, + }, + { + url: 'http://google.com', resourceCount: 3, rollUpDate: 'Mar-21', title: null, totalCount: 10, + }, + { + url: 'http://google.com', resourceCount: 4, rollUpDate: 'Apr-21', title: null, totalCount: 10, + }, + { + url: 'http://github.com', resourceCount: 1, rollUpDate: 'Jan-21', title: null, totalCount: 10, + }, + { + url: 'http://github.com', resourceCount: 2, rollUpDate: 'Feb-21', title: null, totalCount: 10, + }, + { + url: 'http://github.com', resourceCount: 3, rollUpDate: 'Mar-21', title: null, totalCount: 10, + }, + { + url: 'http://github.com', resourceCount: 4, rollUpDate: 'Apr-21', title: null, totalCount: 10, + }, + { + url: 'http://yahoo.com', resourceCount: 1, rollUpDate: 'Jan-21', title: null, totalCount: 10, + }, + { + url: 'http://yahoo.com', resourceCount: 2, rollUpDate: 'Feb-21', title: null, totalCount: 10, + }, + { + url: 'http://yahoo.com', resourceCount: 3, rollUpDate: 'Mar-21', title: null, totalCount: 10, + }, + { + url: 'http://yahoo.com', resourceCount: 4, rollUpDate: 'Apr-21', title: null, totalCount: 10, + }, + ], + }; + + const result = await rollUpResourceUse(data); + + expect(result).toEqual([ + { + heading: 'http://github.com', + url: 'http://github.com', + total: 10, + title: null, + sortBy: 'http://github.com', + isUrl: true, + data: [ + { + title: 'Jan-21', value: 1, + }, + { + title: 'Feb-21', value: 2, + }, + { + title: 'Mar-21', value: 3, + }, + { + title: 'Apr-21', value: 4, + }, + { + title: 'Total', value: 10, + }, + ], + }, + { + heading: 'http://google.com', + url: 'http://google.com', + title: null, + sortBy: 'http://google.com', + total: 10, + isUrl: true, + data: [ + { + title: 'Jan-21', value: 1, + }, + { + title: 'Feb-21', value: 2, + }, + { + title: 'Mar-21', value: 3, + }, + { + title: 'Apr-21', value: 4, + }, + { + title: 'Total', value: 10, + }, + ], + }, + { + heading: 'http://yahoo.com', + url: 'http://yahoo.com', + total: 10, + title: null, + sortBy: 'http://yahoo.com', + isUrl: true, + data: [ + { + title: 'Jan-21', value: 1, + }, + { + title: 'Feb-21', value: 2, + }, + { + title: 'Mar-21', value: 3, + }, + { + title: 'Apr-21', value: 4, + }, + { + title: 'Total', value: 10, + }, + ], + }, + ]); + }); + + it('should roll up topic use results correctly', async () => { + const data = { + topicUseResult: [ + { + name: 'CLASS: Classroom Organization', rollUpDate: 'Jan-21', resourceCount: '1', totalCount: '6', + }, + { + name: 'CLASS: Classroom Organization', rollUpDate: 'Feb-21', resourceCount: '2', totalCount: '6', + }, + { + name: 'CLASS: Classroom Organization', rollUpDate: 'Mar-21', resourceCount: '3', totalCount: '6', + }, + { + name: 'ERSEA', rollUpDate: 'Jan-21', resourceCount: '1', totalCount: '6', + }, + { + name: 'ERSEA', rollUpDate: 'Feb-21', resourceCount: '2', totalCount: '6', + }, + { + name: 'ERSEA', rollUpDate: 'Mar-21', resourceCount: '3', totalCount: '6', + }, + ], + }; + + const result = await rollUpTopicUse(data); + + expect(result).toEqual([ + { + heading: 'CLASS: Classroom Organization', + name: 'CLASS: Classroom Organization', + total: '6', + isUrl: false, + data: [ + { + title: 'Jan-21', value: '1', + }, + { + title: 'Feb-21', value: '2', + }, + { + title: 'Mar-21', value: '3', + }, + { + title: 'Total', value: '6', + }, + ], + }, + { + heading: 'ERSEA', + name: 'ERSEA', + total: '6', + isUrl: false, + data: [ + { + title: 'Jan-21', value: '1', + }, + { + title: 'Feb-21', value: '2', + }, + { + title: 'Mar-21', value: '3', + }, + { + title: 'Total', value: '6', + }, + ], + }, + ]); + }); + + it('verify overview restructures correctly', async () => { + const overviewData = { + overView: { + pctOfReportsWithResources: [{ resourcesPct: '80.0000', reportsWithResourcesCount: '4', totalReportsCount: '5' }], + numberOfParticipants: [{ participants: '44' }], + numberOfRecipients: [{ recipients: '1' }], + pctOfECKLKCResources: [{ eclkcCount: '2', allCount: '3', eclkcPct: '66.6667' }], + }, + }; + + const result = restructureOverview(overviewData); + + expect(result).toEqual({ + report: { + percentResources: '80.00%', + numResources: '4', + num: '5', + }, + participant: { + numParticipants: '44', + }, + recipient: { + numResources: '1', + }, + resource: { + numEclkc: '2', + num: '3', + percentEclkc: '66.67%', + }, + }); + }); +}); diff --git a/src/widgets/helpers.js b/src/widgets/helpers.js index 590f95bacb..ffe0b52da8 100644 --- a/src/widgets/helpers.js +++ b/src/widgets/helpers.js @@ -1,12 +1,63 @@ import { Op } from 'sequelize'; -import { REPORT_STATUSES } from '@ttahub/common'; +import { REPORT_STATUSES, TRAINING_REPORT_STATUSES, REASONS } from '@ttahub/common'; import { ActivityReport, Grant, Recipient, + SessionReportPilot, + Topic, sequelize, } from '../models'; +export const getAllTopicsForWidget = async () => Topic.findAll({ + attributes: ['id', 'name', 'deletedAt'], + where: { deletedAt: null }, + order: [['name', 'ASC']], +}); + +export function generateReasonList() { + const reasons = REASONS + .map((reason) => ({ name: reason, count: 0 })) + .sort((a, b) => { + if (a.name < b.name) { + return -1; + } + if (a.name > b.name) { + return 1; + } + return 0; + }); + + return reasons; +} + +export function baseTRScopes(scopes) { + return { + where: { + [Op.and]: [ + { + 'data.status': { + [Op.in]: [ + TRAINING_REPORT_STATUSES.IN_PROGRESS, + TRAINING_REPORT_STATUSES.COMPLETE, + ], + }, + }, + ...scopes.trainingReport, + ], + }, + include: { + model: SessionReportPilot, + as: 'sessionReports', + attributes: ['data', 'eventId'], + where: { + 'data.status': TRAINING_REPORT_STATUSES.COMPLETE, + }, + required: true, + }, + }; +} + export async function getAllRecipientsFiltered(scopes) { return Recipient.findAll({ attributes: [ diff --git a/src/widgets/index.js b/src/widgets/index.js index 015aee1e18..5459f0b09b 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -12,16 +12,19 @@ import goalsByStatus from './regionalGoalDashboard/goalsByStatus'; import goalsPercentage from './regionalGoalDashboard/goalsPercentage'; import topicsByGoalStatus from './regionalGoalDashboard/topicsByGoalStatus'; import trOverview from './trOverview'; +import trReasonList from './trReasonList'; +import trSessionsByTopic from './trSessionsByTopic'; +import trHoursOfTrainingByNationalCenter from './trHoursOfTrainingByNationalCenter'; /* All widgets need to be added to this object */ export default { overview, - trOverview, dashboardOverview, totalHrsAndRecipientGraph, reasonList, + topicFrequencyGraph, targetPopulationTable, frequencyGraph, @@ -30,4 +33,9 @@ export default { goalsByStatus, goalsPercentage, topicsByGoalStatus, + + trOverview, + trReasonList, + trSessionsByTopic, + trHoursOfTrainingByNationalCenter, }; diff --git a/src/widgets/reasonList.js b/src/widgets/reasonList.js index 5ee3442d76..589ee40785 100644 --- a/src/widgets/reasonList.js +++ b/src/widgets/reasonList.js @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; -import { REPORT_STATUSES, REASONS } from '@ttahub/common'; +import { REPORT_STATUSES } from '@ttahub/common'; import { ActivityReport } from '../models'; -import { countBySingleKey } from './helpers'; +import { countBySingleKey, generateReasonList } from './helpers'; export default async function reasonList(scopes) { // Query Database for all Reasons within the scope. @@ -18,17 +18,7 @@ export default async function reasonList(scopes) { raw: true, }); - const reasons = REASONS - .map((reason) => ({ name: reason, count: 0 })) - .sort((a, b) => { - if (a.name < b.name) { - return -1; - } - if (a.name > b.name) { - return 1; - } - return 0; - }); + const reasons = generateReasonList(); return countBySingleKey(res, 'reason', reasons); } diff --git a/src/widgets/topicFrequencyGraph.js b/src/widgets/topicFrequencyGraph.js index 3c0b1642a7..7e7f8b9a2c 100644 --- a/src/widgets/topicFrequencyGraph.js +++ b/src/widgets/topicFrequencyGraph.js @@ -10,6 +10,7 @@ import { sequelize, } from '../models'; import { scopeToWhere } from '../scopes/utils'; +import { getAllTopicsForWidget as getAllTopics } from './helpers'; const getTopicMappings = async () => sequelize.query(` SELECT @@ -22,12 +23,6 @@ WHERE TT."deletedAt" IS NULL OR TT."mapsTo" IS NOT NULL ORDER BY TT."name" `, { type: QueryTypes.SELECT }); -const getAllTopics = async () => Topic.findAll({ - attributes: ['id', 'name', 'deletedAt'], - where: { deletedAt: null }, - order: [['name', 'ASC']], -}); - export async function topicFrequencyGraph(scopes) { const [ topicsAndParticipants, diff --git a/src/widgets/trHoursOfTrainingByNationalCenter.test.js b/src/widgets/trHoursOfTrainingByNationalCenter.test.js new file mode 100644 index 0000000000..a3c41e1cac --- /dev/null +++ b/src/widgets/trHoursOfTrainingByNationalCenter.test.js @@ -0,0 +1,298 @@ +import faker from '@faker-js/faker'; +import { TRAINING_REPORT_STATUSES } from '@ttahub/common'; +import db, { + EventReportPilot, + SessionReportPilot, + Recipient, + NationalCenter, + Grant, + User, +} from '../models'; +import { + createUser, + createGrant, + createRecipient, + createSessionReport, + createTrainingReport, +} from '../testUtils'; +import trHoursOfTrainingByNationalCenter from './trHoursOfTrainingByNationalCenter'; + +// We need to mock this so that we don't try to send emails or otherwise engage the queue +jest.mock('bull'); + +describe('TR hours of training by national center', () => { + let userCreator; + let userPoc; + let userCollaborator; + + let recipient1; + let recipient2; + let recipient3; + let recipient4; + let recipient5; + + let grant1; + let grant2; + let grant3; + let grant4; + let grant5; + + let trainingReport1; + let trainingReport2; + let trainingReport3; + + let nationalCenter1; + let nationalCenter2; + + beforeAll(async () => { + // user/creator + userCreator = await createUser(); + // user/poc + userPoc = await createUser(); + // user/collaborator ID + userCollaborator = await createUser(); + + // recipient 1 + recipient1 = await createRecipient(); + // recipient 2 + recipient2 = await createRecipient(); + // recipient 3 + recipient3 = await createRecipient(); + // recipient 4 + recipient4 = await createRecipient(); + // recipient 5 (only on uncompleted report) + recipient5 = await createRecipient(); + + // grant 1 + grant1 = await createGrant({ recipientId: recipient1.id, regionId: userCreator.homeRegionId }); + // grant 2 + grant2 = await createGrant({ recipientId: recipient2.id, regionId: userCreator.homeRegionId }); + // grant 3 + grant3 = await createGrant({ recipientId: recipient3.id, regionId: userCreator.homeRegionId }); + // grant 4 + grant4 = await createGrant({ recipientId: recipient4.id, regionId: userCreator.homeRegionId }); + // grant 5 (only on uncompleted report) + grant5 = await createGrant({ recipientId: recipient5.id, regionId: userCreator.homeRegionId }); + + nationalCenter1 = await NationalCenter.create({ + name: faker.word.adjective(3), + }); + + nationalCenter2 = await NationalCenter.create({ + name: faker.word.adjective(4), + }); + + // training report 1 + trainingReport1 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + data: { + reasons: [ + 'Monitoring | Area of Concern', + 'Monitoring | Noncompliance', + 'Monitoring | Deficiency', + ], + }, + }); + + // - session report 1 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTrainers: [ + nationalCenter1.name, + `${nationalCenter2.name} ${userCreator.fullName}`, + ], + }, + }); + + // - session report 2 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTrainers: [ + nationalCenter1.name, + nationalCenter2.name, + ], + }, + }); + + // training report 2 + trainingReport2 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + data: { + reasons: [ + 'Monitoring | Area of Concern', + 'Monitoring | Deficiency', + ], + }, + }); + + // - session report 3 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'hybrid', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 12, + numberOfParticipantsInPerson: 13, + numberOfParticipants: 0, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTrainers: [ + `${nationalCenter1.name} ${userCreator.fullName}`, + ], + }, + }); + + // - session report 4 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant2.id }, { value: grant3.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTrainers: [ + nationalCenter2.name, + ], + }, + }); + + // training report 3 (sessions not completed) + trainingReport3 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + }, { individualHooks: false }); + + // - session report 5 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + }, + }); + + // - session report 6 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + }, + }); + + // update TR 1 to complete, the others will be "in progress" as they have sessions + await trainingReport1.update({ + data: { + ...trainingReport1.data, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + }); + + afterAll(async () => { + // delete session reports + await SessionReportPilot.destroy({ + where: { + eventId: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + // delete training reports + await EventReportPilot.destroy({ + where: { + id: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + await db.GrantNumberLink.destroy({ + where: { + grantId: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + force: true, + }); + + // delete grants + await Grant.destroy({ + where: { + id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + }); + + // delete recipients + await Recipient.destroy({ + where: { + id: [recipient1.id, recipient2.id, recipient3.id, recipient4.id, recipient5.id], + }, + }); + + // delete users + await User.destroy({ + where: { + id: [userCreator.id, userPoc.id, userCollaborator.id], + }, + }); + + await NationalCenter.destroy({ + where: { + id: [nationalCenter1.id, nationalCenter2.id], + }, + }); + + await db.sequelize.close(); + }); + + it('filters and calculates hours of training by national center', async () => { + // Confine this to the grants and reports that we created + const scopes = { + grant: [ + { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id] }, + ], + trainingReport: [ + { id: [trainingReport1.id, trainingReport2.id, trainingReport3.id] }, + ], + }; + + // run our function + const data = await trHoursOfTrainingByNationalCenter(scopes); + + const center1 = data.find((d) => nationalCenter1.name === d.name); + expect(center1.count).toBe(3); + + const center2 = data.find((d) => nationalCenter2.name === d.name); + expect(center2.count).toBe(3); + }); +}); diff --git a/src/widgets/trHoursOfTrainingByNationalCenter.ts b/src/widgets/trHoursOfTrainingByNationalCenter.ts new file mode 100644 index 0000000000..7ba8b26bef --- /dev/null +++ b/src/widgets/trHoursOfTrainingByNationalCenter.ts @@ -0,0 +1,68 @@ +import db from '../models'; +import { + baseTRScopes, +} from './helpers'; +import { IScopes } from './types'; + +const { + EventReportPilot: TrainingReport, + NationalCenter, +} = db; + +export default async function trHoursOfTrainingByNationalCenter( + scopes: IScopes, +) { + const [reports, nationalCenters] = await Promise.all([ + TrainingReport.findAll({ + attributes: [ + 'data', + ], + ...baseTRScopes(scopes), + }), + NationalCenter.findAll({ + attributes: [ + 'name', + ], + }), + ]) as [ + { + data: { + eventId: string, + }, + sessionReports: { + data: { + objectiveTrainers: string[], + duration: number, + } + }[] + }[], + { + name: string, + }[], + ]; + + const dataStruct = nationalCenters.map((center: { name: string }) => ({ + name: center.name, + count: 0, + })) as { name: string, count: number }[]; + + const response = reports.reduce((acc, report) => { + const { sessionReports } = report; + sessionReports.forEach((sessionReport) => { + const { objectiveTrainers, duration } = sessionReport.data; + + objectiveTrainers.forEach((trainer) => { + // trainers were originally and are now stored by the national center abbrev. + // but looking at the data, there was a period where they were stored as + // abbrev - user name, so we need to check for that + const center = dataStruct.find((c) => trainer.includes(c.name)); + if (center) { + center.count += duration; + } + }); + }); + return acc; + }, dataStruct); + + return response; +} diff --git a/src/widgets/trOverview.ts b/src/widgets/trOverview.ts index 59b12c9a2f..13c5f8ca85 100644 --- a/src/widgets/trOverview.ts +++ b/src/widgets/trOverview.ts @@ -1,14 +1,14 @@ -import { Op, WhereOptions } from 'sequelize'; -import { TRAINING_REPORT_STATUSES } from '@ttahub/common'; +import { Op } from 'sequelize'; import db from '../models'; import { + baseTRScopes, formatNumber, getAllRecipientsFiltered, } from './helpers'; +import { IScopes } from './types'; const { EventReportPilot: TrainingReport, - SessionReportPilot: SessionReport, Recipient, Grant, } = db; @@ -17,11 +17,6 @@ const { * interface for scopes */ -interface IScopes { - grant: WhereOptions[], - trainingReport: WhereOptions[], -} - /** * Interface for the data returned by the Training Report findAll * we use to calculate the data for the TR Overview widget @@ -78,7 +73,7 @@ interface IWidgetData { * @returns IWidgetData */ export default async function trOverview( - scopes: IScopes = { grant: [], trainingReport: [] }, + scopes: IScopes, ): Promise { // get all recipients, matching how they are filtered in the AR overview const allRecipientsFiltered = await getAllRecipientsFiltered(scopes); @@ -86,28 +81,7 @@ export default async function trOverview( // Get all completed training reports and their session reports const reports = await TrainingReport.findAll({ attributes: ['data', 'id'], - where: { - [Op.and]: [ - { - 'data.status': { - [Op.in]: [ - TRAINING_REPORT_STATUSES.IN_PROGRESS, - TRAINING_REPORT_STATUSES.COMPLETE, - ], - }, - }, - ...scopes.trainingReport, - ], - }, - include: { - model: SessionReport, - as: 'sessionReports', - attributes: ['data', 'eventId'], - where: { - 'data.status': TRAINING_REPORT_STATUSES.COMPLETE, - }, - required: true, - }, + ...baseTRScopes(scopes), }) as ITrainingReportForOverview[]; const data = reports.reduce((acc: IReportData, report) => { diff --git a/src/widgets/trReasonList.ts b/src/widgets/trReasonList.ts new file mode 100644 index 0000000000..a75869b931 --- /dev/null +++ b/src/widgets/trReasonList.ts @@ -0,0 +1,25 @@ +import db from '../models'; +import { baseTRScopes, countBySingleKey, generateReasonList } from './helpers'; +import { IScopes } from './types'; + +const { EventReportPilot: TrainingReport } = db; + +export default async function trReasonList(scopes: IScopes) { + const res = await TrainingReport.findAll({ + attributes: [ + 'data', + 'id', + ], + ...baseTRScopes(scopes), + }) as { + data: { + reasons: string[], + }, + }[]; + + const reasons = generateReasonList(); + + const mapped = res.map((r) => ({ reasons: r.data.reasons })); + + return countBySingleKey(mapped, 'reasons', reasons); +} diff --git a/src/widgets/trReasonlist.test.js b/src/widgets/trReasonlist.test.js new file mode 100644 index 0000000000..976438633a --- /dev/null +++ b/src/widgets/trReasonlist.test.js @@ -0,0 +1,274 @@ +import { TRAINING_REPORT_STATUSES, REASONS } from '@ttahub/common'; +import db, { + EventReportPilot, + SessionReportPilot, + Recipient, + Grant, + User, +} from '../models'; +import { + createUser, + createGrant, + createRecipient, + createSessionReport, + createTrainingReport, +} from '../testUtils'; +import trReasonList from './trReasonList'; + +// We need to mock this so that we don't try to send emails or otherwise engage the queue +jest.mock('bull'); + +describe('TR reason list', () => { + let userCreator; + let userPoc; + let userCollaborator; + + let recipient1; + let recipient2; + let recipient3; + let recipient4; + let recipient5; + + let grant1; + let grant2; + let grant3; + let grant4; + let grant5; + + let trainingReport1; + let trainingReport2; + let trainingReport3; + + beforeAll(async () => { + // user/creator + userCreator = await createUser(); + // user/poc + userPoc = await createUser(); + // user/collaborator ID + userCollaborator = await createUser(); + + // recipient 1 + recipient1 = await createRecipient(); + // recipient 2 + recipient2 = await createRecipient(); + // recipient 3 + recipient3 = await createRecipient(); + // recipient 4 + recipient4 = await createRecipient(); + // recipient 5 (only on uncompleted report) + recipient5 = await createRecipient(); + + // grant 1 + grant1 = await createGrant({ recipientId: recipient1.id, regionId: userCreator.homeRegionId }); + // grant 2 + grant2 = await createGrant({ recipientId: recipient2.id, regionId: userCreator.homeRegionId }); + // grant 3 + grant3 = await createGrant({ recipientId: recipient3.id, regionId: userCreator.homeRegionId }); + // grant 4 + grant4 = await createGrant({ recipientId: recipient4.id, regionId: userCreator.homeRegionId }); + // grant 5 (only on uncompleted report) + grant5 = await createGrant({ recipientId: recipient5.id, regionId: userCreator.homeRegionId }); + + // training report 1 + trainingReport1 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + data: { + reasons: [ + 'Monitoring | Area of Concern', + 'Monitoring | Noncompliance', + 'Monitoring | Deficiency', + ], + }, + }); + + // - session report 1 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + + // - session report 2 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + + // training report 2 + trainingReport2 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + data: { + reasons: [ + 'Monitoring | Area of Concern', + 'Monitoring | Deficiency', + ], + }, + }); + + // - session report 3 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'hybrid', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 12, + numberOfParticipantsInPerson: 13, + numberOfParticipants: 0, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + + // - session report 4 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant2.id }, { value: grant3.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + + // training report 3 (sessions not completed) + trainingReport3 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + }, { individualHooks: false }); + + // - session report 5 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + }, + }); + + // - session report 6 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + }, + }); + + // update TR 1 to complete, the others will be "in progress" as they have sessions + await trainingReport1.update({ + data: { + ...trainingReport1.data, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + }); + + afterAll(async () => { + // delete session reports + await SessionReportPilot.destroy({ + where: { + eventId: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + // delete training reports + await EventReportPilot.destroy({ + where: { + id: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + await db.GrantNumberLink.destroy({ + where: { + grantId: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + force: true, + }); + + // delete grants + await Grant.destroy({ + where: { + id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + }); + + // delete recipients + await Recipient.destroy({ + where: { + id: [recipient1.id, recipient2.id, recipient3.id, recipient4.id, recipient5.id], + }, + }); + + // delete users + await User.destroy({ + where: { + id: [userCreator.id, userPoc.id, userCollaborator.id], + }, + }); + + await db.sequelize.close(); + }); + + it('filters and calculates training report reasons', async () => { + // Confine this to the grants and reports that we created + const scopes = { + grant: [ + { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id] }, + ], + trainingReport: [ + { id: [trainingReport1.id, trainingReport2.id, trainingReport3.id] }, + ], + }; + + // run our function + const data = await trReasonList(scopes); + + expect(data.length).toBe(REASONS.length); + + const areaOfConcern = data.find((reason) => reason.name === 'Monitoring | Area of Concern'); + expect(areaOfConcern.count).toBe(2); + + const noncompliance = data.find((reason) => reason.name === 'Monitoring | Noncompliance'); + expect(noncompliance.count).toBe(1); + + const deficiency = data.find((reason) => reason.name === 'Monitoring | Deficiency'); + expect(deficiency.count).toBe(2); + + const filteredOut = data.filter((reason) => reason.count === 0); + + expect(filteredOut.length).toBe(REASONS.length - 3); + }); +}); diff --git a/src/widgets/trSessionsByTopic.ts b/src/widgets/trSessionsByTopic.ts new file mode 100644 index 0000000000..68cb243bf3 --- /dev/null +++ b/src/widgets/trSessionsByTopic.ts @@ -0,0 +1,60 @@ +import db from '../models'; +import { + baseTRScopes, + getAllTopicsForWidget, +} from './helpers'; +import { IScopes } from './types'; + +const { + EventReportPilot: TrainingReport, +} = db; + +export default async function trSessionByTopic( + scopes: IScopes, +) { + const [reports, topics] = await Promise.all([ + TrainingReport.findAll({ + attributes: [ + 'data', + ], + ...baseTRScopes(scopes), + }), + getAllTopicsForWidget(), + ]) as [ + { + data: { + eventId: string, + }, + sessionReports: { + data: { + objectiveTopics: string[], + } + }[] + }[], + { + name: string, + }[], + ]; + + const dataStruct = topics.map((topic: { name: string }) => ({ + topic: topic.name, + count: 0, + })) as { topic: string, count: number }[]; + + const response = reports.reduce((acc, report) => { + const { sessionReports } = report; + sessionReports.forEach((sessionReport) => { + const { objectiveTopics } = sessionReport.data; + + objectiveTopics.forEach((topic) => { + const d = dataStruct.find((c) => c.topic === topic); + if (d) { + d.count += 1; + } + }); + }); + return acc; + }, dataStruct); + + return response; +} diff --git a/src/widgets/trSessionsByTopics.test.js b/src/widgets/trSessionsByTopics.test.js new file mode 100644 index 0000000000..25a92d2a67 --- /dev/null +++ b/src/widgets/trSessionsByTopics.test.js @@ -0,0 +1,296 @@ +import faker from '@faker-js/faker'; +import { TRAINING_REPORT_STATUSES } from '@ttahub/common'; +import db, { + EventReportPilot, + SessionReportPilot, + Recipient, + Topic, + Grant, + User, +} from '../models'; +import { + createUser, + createGrant, + createRecipient, + createSessionReport, + createTrainingReport, +} from '../testUtils'; +import trSessionsByTopic from './trSessionsByTopic'; + +// We need to mock this so that we don't try to send emails or otherwise engage the queue +jest.mock('bull'); + +describe('TR sessions by topic', () => { + let userCreator; + let userPoc; + let userCollaborator; + + let recipient1; + let recipient2; + let recipient3; + let recipient4; + let recipient5; + + let grant1; + let grant2; + let grant3; + let grant4; + let grant5; + + let trainingReport1; + let trainingReport2; + let trainingReport3; + + let topic1; + let topic2; + + beforeAll(async () => { + // user/creator + userCreator = await createUser(); + // user/poc + userPoc = await createUser(); + // user/collaborator ID + userCollaborator = await createUser(); + + // recipient 1 + recipient1 = await createRecipient(); + // recipient 2 + recipient2 = await createRecipient(); + // recipient 3 + recipient3 = await createRecipient(); + // recipient 4 + recipient4 = await createRecipient(); + // recipient 5 (only on uncompleted report) + recipient5 = await createRecipient(); + + // grant 1 + grant1 = await createGrant({ recipientId: recipient1.id, regionId: userCreator.homeRegionId }); + // grant 2 + grant2 = await createGrant({ recipientId: recipient2.id, regionId: userCreator.homeRegionId }); + // grant 3 + grant3 = await createGrant({ recipientId: recipient3.id, regionId: userCreator.homeRegionId }); + // grant 4 + grant4 = await createGrant({ recipientId: recipient4.id, regionId: userCreator.homeRegionId }); + // grant 5 (only on uncompleted report) + grant5 = await createGrant({ recipientId: recipient5.id, regionId: userCreator.homeRegionId }); + + topic1 = await Topic.create({ + name: faker.word.conjunction(5) + faker.word.adjective(3) + faker.word.noun(4), + }); + + topic2 = await Topic.create({ + name: faker.word.conjunction(3) + faker.word.adjective(4) + faker.word.noun(5), + }); + + // training report 1 + trainingReport1 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + data: { + reasons: [ + 'Monitoring | Area of Concern', + 'Monitoring | Noncompliance', + 'Monitoring | Deficiency', + ], + }, + }); + + // - session report 1 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTopics: [], + }, + }); + + // - session report 2 + await createSessionReport({ + eventId: trainingReport1.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTopics: [ + topic1.name, + ], + }, + }); + + // training report 2 + trainingReport2 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + data: { + reasons: [ + 'Monitoring | Area of Concern', + 'Monitoring | Deficiency', + ], + }, + }); + + // - session report 3 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'hybrid', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 12, + numberOfParticipantsInPerson: 13, + numberOfParticipants: 0, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTopics: [ + topic2.name, + ], + }, + }); + + // - session report 4 + await createSessionReport({ + eventId: trainingReport2.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant2.id }, { value: grant3.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.COMPLETE, + objectiveTopics: [], + }, + }); + + // training report 3 (sessions not completed) + trainingReport3 = await createTrainingReport({ + collaboratorIds: [userCollaborator.id], + pocIds: [userPoc.id], + ownerId: userCreator.id, + }, { individualHooks: false }); + + // - session report 5 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + objectiveTopics: [ + topic1.name, + topic2.name, + ], + }, + }); + + // - session report 6 + await createSessionReport({ + eventId: trainingReport3.id, + data: { + deliveryMethod: 'in-person', + duration: 1, + recipients: [{ value: grant1.id }, { value: grant2.id }], + numberOfParticipantsVirtually: 0, + numberOfParticipantsInPerson: 0, + numberOfParticipants: 25, + status: TRAINING_REPORT_STATUSES.IN_PROGRESS, + }, + }); + + // update TR 1 to complete, the others will be "in progress" as they have sessions + await trainingReport1.update({ + data: { + ...trainingReport1.data, + status: TRAINING_REPORT_STATUSES.COMPLETE, + }, + }); + }); + + afterAll(async () => { + // delete session reports + await SessionReportPilot.destroy({ + where: { + eventId: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + // delete training reports + await EventReportPilot.destroy({ + where: { + id: [trainingReport1.id, trainingReport2.id, trainingReport3.id], + }, + }); + + await db.GrantNumberLink.destroy({ + where: { + grantId: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + force: true, + }); + + // delete grants + await Grant.destroy({ + where: { + id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id], + }, + }); + + // delete recipients + await Recipient.destroy({ + where: { + id: [recipient1.id, recipient2.id, recipient3.id, recipient4.id, recipient5.id], + }, + }); + + // delete users + await User.destroy({ + where: { + id: [userCreator.id, userPoc.id, userCollaborator.id], + }, + }); + + await Topic.destroy({ + where: { + id: [topic1.id, topic2.id], + }, + }); + + await db.sequelize.close(); + }); + + it('filters and calculates sessions by topics', async () => { + // Confine this to the grants and reports that we created + const scopes = { + grant: [ + { id: [grant1.id, grant2.id, grant3.id, grant4.id, grant5.id] }, + ], + trainingReport: [ + { id: [trainingReport1.id, trainingReport2.id, trainingReport3.id] }, + ], + }; + + // run our function + const data = await trSessionsByTopic(scopes); + + const firstTopic = data.find((d) => topic1.name === d.topic); + expect(firstTopic.count).toBe(1); + + const secondTopic = data.find((d) => topic2.name === d.topic); + expect(secondTopic.count).toBe(1); + }); +}); diff --git a/src/widgets/types.ts b/src/widgets/types.ts new file mode 100644 index 0000000000..2a9d70769b --- /dev/null +++ b/src/widgets/types.ts @@ -0,0 +1,6 @@ +import { WhereOptions } from 'sequelize'; + +export interface IScopes { + grant: WhereOptions[], + trainingReport: WhereOptions[], +} diff --git a/tests/e2e/axe.spec.ts b/tests/e2e/axe.spec.ts index aa6af14e32..9005a9ad18 100644 --- a/tests/e2e/axe.spec.ts +++ b/tests/e2e/axe.spec.ts @@ -10,7 +10,7 @@ const axeUrls = [ 'http://localhost:3000/activity-reports/new/review', 'http://localhost:3000/activity-reports/view/9999', 'http://localhost:3000/regional-dashboard', - 'http://localhost:3000/training-reports', + 'http://localhost:3000/training-reports/not-started', 'http://localhost:3000/recipient-tta-records', 'http://localhost:3000/recipient-tta-records/9/region/1/profile', 'http://localhost:3000/recipient-tta-records/9/region/1/tta-history', diff --git a/tests/e2e/regional-dashboard.spec.ts b/tests/e2e/regional-dashboard.spec.ts index e7a3e13e90..b5c8b6d63e 100644 --- a/tests/e2e/regional-dashboard.spec.ts +++ b/tests/e2e/regional-dashboard.spec.ts @@ -48,7 +48,7 @@ test('Regional Dashboard', async ({ page }) => { ]); // view the topics as a table - await page.getByRole('button', { name: 'display number of activity reports by topic data as table' }).click(); + await page.getByRole('button', { name: /display Number of Activity Reports by Topic as table/i }).click(); // change the topics graph order await page.getByRole('button', { name: 'toggle Change topic graph order menu' }).click(); diff --git a/yarn.lock b/yarn.lock index 4941dc3d4f..7314ca851b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10964,9 +10964,9 @@ proto-list@~1.2.1: integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== protobufjs@^7.2.4: - version "7.2.4" - resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.4.tgz#3fc1ec0cdc89dd91aef9ba6037ba07408485c3ae" - integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ== + version "7.2.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.2.6.tgz#4a0ccd79eb292717aacf07530a07e0ed20278215" + integrity sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw== dependencies: "@protobufjs/aspromise" "^1.1.2" "@protobufjs/base64" "^1.1.2"
-

{title}

+ + {title} +