diff --git a/.circleci/config.yml b/.circleci/config.yml index e8c6abf304..c376f45636 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -560,7 +560,7 @@ parameters: type: string dev_git_branch: # change to feature branch to test deployment description: "Name of github branch that will deploy to dev" - default: "al-ttahub-3329-fix-event-view" + default: "al-ttahub-3402-connect-be-overview" type: string sandbox_git_branch: # change to feature branch to test deployment default: "mb/TTAHUB-3483/checkbox-to-activity-reports" diff --git a/frontend/package.json b/frontend/package.json index e54b30297c..4bb47f5d4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -209,13 +209,15 @@ "/src/pages/RegionalDashboard/constants.js", "/src/pages/makecolors.js", "/src/NetworkContext.js", - "/src/hooks/helpers.js" + "/src/hooks/helpers.js", + "/src/testHelpers.js", + "/src/pages/activityReports/testHelpers.js" ], "coverageThreshold": { "global": { "statements": 90, - "functions": 89, - "branches": 89, + "functions": 90, + "branches": 90, "lines": 90 } } diff --git a/frontend/src/Constants.js b/frontend/src/Constants.js index b0682cea10..20e2370a58 100644 --- a/frontend/src/Constants.js +++ b/frontend/src/Constants.js @@ -160,6 +160,9 @@ export const ESCAPE_KEY_CODE = 27; export const GOALS_PER_PAGE = 10; export const TOPICS_PER_PAGE = 10; export const COURSES_PER_PAGE = 10; +export const RECIPIENTS_WITH_NO_TTA_PER_PAGE = 10; +export const RECIPIENTS_WITH_OHS_STANDARD_FEI_GOAL_PER_PAGE = 10; +export const RECIPIENTS_WITH_CLASS_SCORES_AND_GOALS_GOAL_PER_PAGE = 10; // In Internet Explorer (tested on release 9 and 11) and Firefox 36 and earlier // the Esc key returns "Esc" instead of "Escape". @@ -176,3 +179,24 @@ export const LOCAL_STORAGE_ADDITIONAL_DATA_KEY = (id) => `ar-additional-data-${i export const LOCAL_STORAGE_EDITABLE_KEY = (id) => `ar-can-edit-${id}-${LOCAL_STORAGE_CACHE_NUMBER}`; export const SESSION_STORAGE_IMPERSONATION_KEY = `auth-impersonation-id-${LOCAL_STORAGE_CACHE_NUMBER}`; export const REGIONAL_RESOURCE_DASHBOARD_FILTER_KEY = 'regional-resources-dashboard-filters'; + +export const SUPPORT_LINK = 'https://app.smartsheetgov.com/b/form/f0b4725683f04f349a939bd2e3f5425a'; +export const mustBeQuarterHalfOrWhole = (value) => { + if (value % 0.25 !== 0) { + return 'Duration must be rounded to the nearest quarter hour'; + } + return true; +}; + +export const parseCheckboxEvent = (event) => { + const { target: { checked = null, value = null } = {} } = event; + return { + checked, + value, + }; +}; + +export const arrayExistsAndHasLength = (array) => array && Array.isArray(array) && array.length > 0; + +export const NOOP = () => {}; +export const EMPTY_ARRAY = []; diff --git a/frontend/src/Routes.js b/frontend/src/Routes.js index 210f3e751f..318ad90de3 100644 --- a/frontend/src/Routes.js +++ b/frontend/src/Routes.js @@ -41,6 +41,9 @@ import SessionForm from './pages/SessionForm'; import ViewTrainingReport from './pages/ViewTrainingReport'; import QADashboard from './pages/QADashboard'; import SomethingWentWrong from './components/SomethingWentWrong'; +import RecipientsWithNoTta from './pages/QADashboard/RecipientsWithNoTta'; +import RecipientsWithClassScoresAndGoals from './pages/QADashboard/RecipientsWithClassScoresAndGoals'; +import RecipientsWithOhsStandardFeiGoal from './pages/QADashboard/RecipientsWithOhsStandardFeiGoal'; export default function Routes({ alert, @@ -157,6 +160,33 @@ export default function Routes({ )} /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> { + describe('NOOP', () => { + it('returns undefined', () => { + expect(NOOP()).toBeUndefined(); + }); + }); + + describe('arrayExistsAndHasLength', () => { + it('returns true if array exists and has length', () => { + expect(arrayExistsAndHasLength([1])).toBeTruthy(); + }); + + it('returns false if array does not exist', () => { + expect(arrayExistsAndHasLength(null)).toBeFalsy(); + }); + + it('returns false if array is not an array', () => { + expect(arrayExistsAndHasLength('string')).toBeFalsy(); + }); + + it('returns false if array has no length', () => { + expect(arrayExistsAndHasLength([])).toBeFalsy(); + }); + }); + + describe('parseCheckboxEvent', () => { + it('returns checked and value from event', () => { + const event = { + target: { + checked: true, + value: 'shoes', + }, + }; + expect(parseCheckboxEvent(event)).toEqual({ + checked: true, + value: 'shoes', + }); + }); + + it('returns null checked and value from event', () => { + const event = { + target: {}, + }; + expect(parseCheckboxEvent(event)).toEqual({ + checked: null, + value: null, + }); + }); + }); +}); diff --git a/frontend/src/__tests__/permissions.js b/frontend/src/__tests__/permissions.js index 3ca508f131..238a1fc6c5 100644 --- a/frontend/src/__tests__/permissions.js +++ b/frontend/src/__tests__/permissions.js @@ -10,6 +10,7 @@ import isAdmin, { canChangeGoalStatus, canEditOrCreateGoals, hasTrainingReportWritePermissions, + canEditOrCreateSessionReports, } from '../permissions'; describe('permissions', () => { @@ -415,4 +416,41 @@ describe('permissions', () => { expect(hasTrainingReportWritePermissions(user)).toBeFalsy(); }); }); + + describe('canEditOrCreateSessionReports', () => { + it('returns true if the user is an admin', () => { + const user = { + permissions: [ + { + scopeId: SCOPE_IDS.ADMIN, + }, + ], + }; + expect(canEditOrCreateSessionReports(user, 1)).toBeTruthy(); + }); + + it('returns true if the user has read_write_training_reports', () => { + const user = { + permissions: [ + { + scopeId: SCOPE_IDS.READ_WRITE_TRAINING_REPORTS, + regionId: 1, + }, + ], + }; + expect(canEditOrCreateSessionReports(user, 1)).toBeTruthy(); + }); + + it('returns false otherwise', () => { + const user = { + permissions: [ + { + scopeId: SCOPE_IDS.READ_REPORTS, + regionId: 1, + }, + ], + }; + expect(canEditOrCreateSessionReports(user, 1)).toBeFalsy(); + }); + }); }); diff --git a/frontend/src/__tests__/utils.js b/frontend/src/__tests__/utils.js index 9e43d158a6..12e7fd93d0 100644 --- a/frontend/src/__tests__/utils.js +++ b/frontend/src/__tests__/utils.js @@ -61,6 +61,32 @@ describe('filtersToQueryString', () => { const str = filtersToQueryString(filters); expect(str).toBe(`region.in[]=14&startDate.win=${encodeURIComponent('2021/11/13-2021/12/13')}`); }); + + it('handles region, second param', () => { + const filters = [ + { + id: '07bc65ed-a4ce-410f-b7be-f685bc8921ed', + topic: 'startDate', + condition: 'is within', + query: '2021/11/13-2021/12/13', + }, + ]; + const str = filtersToQueryString(filters, '14'); + expect(str).toBe(`startDate.win=${encodeURIComponent('2021/11/13-2021/12/13')}®ion.in[]=14`); + }); + + it('handles oddball region', () => { + const filters = [ + { + id: '07bc65ed-a4ce-410f-b7be-f685bc8921ed', + topic: 'startDate', + condition: 'is within', + query: '2021/11/13-2021/12/13', + }, + ]; + const str = filtersToQueryString(filters, 'YOLO'); + expect(str).toBe(`startDate.win=${encodeURIComponent('2021/11/13-2021/12/13')}`); + }); }); describe('formatDateRange', () => { diff --git a/frontend/src/components/ActivityReportsTable/ColumnHeader.js b/frontend/src/components/ActivityReportsTable/ColumnHeader.js index d833b53762..5c82788777 100644 --- a/frontend/src/components/ActivityReportsTable/ColumnHeader.js +++ b/frontend/src/components/ActivityReportsTable/ColumnHeader.js @@ -2,11 +2,16 @@ import React from 'react'; import PropTypes from 'prop-types'; +export const getClassNamesFor = (n, sortBy, sortDirection) => (sortBy === n ? sortDirection : ''); + function ColumnHeader({ - displayName, name, sortBy, sortDirection, onUpdateSort, + displayName, + name, + sortBy, + sortDirection, + onUpdateSort, }) { - const getClassNamesFor = (n) => (sortBy === n ? sortDirection : ''); - const sortClassName = getClassNamesFor(name); + const sortClassName = getClassNamesFor(name, sortBy, sortDirection); let fullAriaSort; switch (sortClassName) { case 'asc': diff --git a/frontend/src/components/ActivityReportsTable/ReportsTable.js b/frontend/src/components/ActivityReportsTable/ReportsTable.js index 067742dbfa..57b8d39a6a 100644 --- a/frontend/src/components/ActivityReportsTable/ReportsTable.js +++ b/frontend/src/components/ActivityReportsTable/ReportsTable.js @@ -7,7 +7,7 @@ import { import Container from '../Container'; import TableHeader from '../TableHeader'; import ReportRow from './ReportRow'; -import { REPORTS_PER_PAGE } from '../../Constants'; +import { parseCheckboxEvent, REPORTS_PER_PAGE } from '../../Constants'; import './ReportsTable.css'; export default function ReportsTable({ @@ -51,7 +51,7 @@ export default function ReportsTable({ // The all-reports checkbox can select/deselect all visible reports const toggleSelectAll = (event) => { - const { target: { checked = null } = {} } = event; + const { checked } = parseCheckboxEvent(event); if (checked === true) { setReportCheckboxes(makeReportCheckboxes(reports, true)); @@ -63,7 +63,7 @@ export default function ReportsTable({ }; const handleReportSelect = (event) => { - const { target: { checked = null, value = null } = {} } = event; + const { checked, value } = parseCheckboxEvent(event); if (checked === true) { setReportCheckboxes({ ...reportCheckboxes, [value]: true }); } else { diff --git a/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js b/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js index 001d729068..ff02d0137a 100644 --- a/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js +++ b/frontend/src/components/ActivityReportsTable/__tests__/ColumnHeader.js @@ -4,9 +4,23 @@ import { render, screen, act, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import ColumnHeader from '../ColumnHeader'; +import ColumnHeader, { getClassNamesFor } from '../ColumnHeader'; describe('ActivityReportsTable ColumnHeader', () => { + describe('getClassNamesFor', () => { + it('returns empty string when sortBy does not match', () => { + expect(getClassNamesFor('shoes', 'hats', 'asc')).toBe(''); + }); + + it('returns asc when sortBy matches and sortDirection is asc', () => { + expect(getClassNamesFor('shoes', 'shoes', 'asc')).toBe('asc'); + }); + + it('returns desc when sortBy matches and sortDirection is desc', () => { + expect(getClassNamesFor('shoes', 'shoes', 'desc')).toBe('desc'); + }); + }); + const renderColumnHeader = (onUpdateSort = jest.fn(), sortDirection = 'asc') => { const name = 'fanciest shoes'; render( diff --git a/frontend/src/components/ClassScoreBadge.js b/frontend/src/components/ClassScoreBadge.js new file mode 100644 index 0000000000..6e8597941c --- /dev/null +++ b/frontend/src/components/ClassScoreBadge.js @@ -0,0 +1,52 @@ +import React from 'react'; +import moment from 'moment'; +import './ClassScoreBadge.scss'; + +const BadgeAbove = (fontSize) => ( + + Above all thresholds + +); + +const BadgeBelowQuality = (fontSize) => ( + + Below quality + +); + +const BadgeBelowCompetitive = (fontSize) => ( + + Below competitive + +); + +export function getScoreBadge(key, score, received, size) { + const fontSize = size || 'font-sans-2xs'; + if (key === 'ES' || key === 'CO') { + if (score >= 6) return BadgeAbove(fontSize); + if (score < 5) return BadgeBelowCompetitive(fontSize); + return BadgeBelowQuality(fontSize); + } + + if (key === 'IS') { + if (score >= 3) return BadgeAbove(fontSize); + + // IS is slightly more complicated. + // See TTAHUB-2097 for details. + const dt = moment(received, 'MM/DD/YYYY'); + + if (dt.isAfter('2025-08-01')) { + if (score < 2.5) return BadgeBelowCompetitive(fontSize); + return BadgeBelowQuality(fontSize); + } + + if (dt.isAfter('2020-11-09') && dt.isBefore('2025-07-31')) { + if (score < 2.3) return BadgeBelowCompetitive(fontSize); + return BadgeBelowQuality(fontSize); + } + } + + return null; +} + +export default getScoreBadge; diff --git a/frontend/src/components/ClassScoreBadge.scss b/frontend/src/components/ClassScoreBadge.scss new file mode 100644 index 0000000000..28f06b6905 --- /dev/null +++ b/frontend/src/components/ClassScoreBadge.scss @@ -0,0 +1,21 @@ +@use '../colors.scss' as *; + +%badge { + background-color: $success-darkest; + border-radius: 12px; + padding: 4px 12px; + } + + .ttahub-badge--success { + @extend %badge; + } + + .ttahub-badge--warning { + background-color: $warning; + @extend %badge; + } + + .ttahub-badge--error { + background-color: $error; + @extend %badge; + } diff --git a/frontend/src/components/ContentFromFeedByTag.js b/frontend/src/components/ContentFromFeedByTag.js index 24838f713b..507d298e9b 100644 --- a/frontend/src/components/ContentFromFeedByTag.js +++ b/frontend/src/components/ContentFromFeedByTag.js @@ -60,7 +60,6 @@ export default function ContentFromFeedByTag({ }, [tagName, contentSelector]); const classNames = `${className} ttahub-single-feed-item--by-tag ${contentSelector ? 'ttahub-single-feed-item--by-tag--with-selector' : ''}`; - return (
diff --git a/frontend/src/components/ContextMenu.js b/frontend/src/components/ContextMenu.js index d958bfee29..8affb56358 100644 --- a/frontend/src/components/ContextMenu.js +++ b/frontend/src/components/ContextMenu.js @@ -43,6 +43,7 @@ ContextMenu.propTypes = { menuItems: PropTypes.arrayOf(PropTypes.shape({ label: PropTypes.string, onClick: PropTypes.func, + id: PropTypes.string, })).isRequired, backgroundColor: PropTypes.string, left: PropTypes.bool, diff --git a/frontend/src/components/DataRow.js b/frontend/src/components/DataRow.js new file mode 100644 index 0000000000..2ff13341d0 --- /dev/null +++ b/frontend/src/components/DataRow.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Grid } from '@trussworks/react-uswds'; + +export default function DataRow({ label, value }) { + return ( + + + {label} + + + {value} + + + ); +} + +DataRow.propTypes = { + label: PropTypes.string.isRequired, + value: PropTypes.node.isRequired, +}; diff --git a/frontend/src/components/DrawerTriggerButton.css b/frontend/src/components/DrawerTriggerButton.css index c6e267f923..74ed3045f8 100644 --- a/frontend/src/components/DrawerTriggerButton.css +++ b/frontend/src/components/DrawerTriggerButton.css @@ -1,3 +1,4 @@ .usa-button.usa-button__drawer-trigger { margin-top: 0; + margin-left: 0; } \ No newline at end of file diff --git a/frontend/src/components/DrawerTriggerButton.js b/frontend/src/components/DrawerTriggerButton.js index 7021a83f5c..398edf3353 100644 --- a/frontend/src/components/DrawerTriggerButton.js +++ b/frontend/src/components/DrawerTriggerButton.js @@ -5,12 +5,12 @@ import './DrawerTriggerButton.css'; export default function DrawerTriggerButton({ drawerTriggerRef, children, - + customClass, }) { return (
diff --git a/frontend/src/components/GoalCards/SessionObjectiveCard.css b/frontend/src/components/GoalCards/SessionObjectiveCard.css deleted file mode 100644 index 6ebf2a9fb7..0000000000 --- a/frontend/src/components/GoalCards/SessionObjectiveCard.css +++ /dev/null @@ -1,9 +0,0 @@ -/** the 64em comes from the USDWS "desktop" breakpoint */ -@media(min-width: 64em) { - .ttahub-goal-card__objective-list--session-objective:not([hidden]) { - display: grid; - grid-template-columns: 130px 1fr; - column-gap: 1.5rem; - row-gap: 0.5rem; - } -} diff --git a/frontend/src/components/GoalCards/SessionObjectiveCard.js b/frontend/src/components/GoalCards/SessionObjectiveCard.js deleted file mode 100644 index 97382bfdf6..0000000000 --- a/frontend/src/components/GoalCards/SessionObjectiveCard.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Link } from 'react-router-dom'; -import './SessionObjectiveCard.css'; - -function SessionObjectiveCard({ - objective, - objectivesExpanded, -}) { - const { - title, - trainingReportId, - grantNumbers, - endDate, - topics, - sessionName, - } = objective; - - const trainingReportUrl = `/training-report/view/${trainingReportId.substring(trainingReportId.lastIndexOf('-') + 1)}`; - - return ( - - ); -} - -SessionObjectiveCard.propTypes = { - objective: PropTypes.shape({ - type: PropTypes.string, - title: PropTypes.string, - trainingReportId: PropTypes.string, - sessionName: PropTypes.string, - grantNumbers: PropTypes.arrayOf(PropTypes.string), - endDate: PropTypes.string, - topics: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - objectivesExpanded: PropTypes.bool.isRequired, -}; -export default SessionObjectiveCard; diff --git a/frontend/src/components/HeaderUserMenu.js b/frontend/src/components/HeaderUserMenu.js index f31810bf3f..4f34561255 100644 --- a/frontend/src/components/HeaderUserMenu.js +++ b/frontend/src/components/HeaderUserMenu.js @@ -12,7 +12,7 @@ import UserContext from '../UserContext'; import isAdmin from '../permissions'; import colors from '../colors'; import Pill from './Pill'; -import { SESSION_STORAGE_IMPERSONATION_KEY } from '../Constants'; +import { SESSION_STORAGE_IMPERSONATION_KEY, SUPPORT_LINK } from '../Constants'; import { storageAvailable } from '../hooks/helpers'; function UserMenuNav({ items }) { @@ -77,7 +77,7 @@ function HeaderUserMenu({ areThereUnreadNotifications, setAreThereUnreadNotifica { key: 4, label: 'Contact support', - to: 'https://app.smartsheetgov.com/b/form/f0b4725683f04f349a939bd2e3f5425a', + to: SUPPORT_LINK, external: true, }, { key: 5, space: true }, diff --git a/frontend/src/components/MediaCaptureButton.js b/frontend/src/components/MediaCaptureButton.js index 0c7aa97070..5ee8a18409 100644 --- a/frontend/src/components/MediaCaptureButton.js +++ b/frontend/src/components/MediaCaptureButton.js @@ -1,36 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import html2canvas from 'html2canvas'; import { Button } from '@trussworks/react-uswds'; +import useMediaCapture from '../hooks/useMediaCapture'; export default function MediaCaptureButton({ reference, className, buttonText, id, title, }) { - const capture = async () => { - try { - // capture the element, setting the width and height - // we just calculated, and setting the background to white - // and then converting it to a data url - // and triggering a download - const canvas = await html2canvas(reference.current, { - onclone: (_document, element) => { - // set the first child to be white (we can always make this configurable later) - element.firstChild.classList.add('bg-white'); - - // make sure we get the entire element and nothing is cut off - element.classList.remove('overflow-x-scroll'); - }, - }); - const base64image = canvas.toDataURL('image/png'); - const a = document.createElement('a'); - a.href = base64image; - a.setAttribute('download', `${title}.png`); - a.click(); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - } - }; + const capture = useMediaCapture(reference, title); return ( diff --git a/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js b/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js index 7a5af745b5..44ff632c38 100644 --- a/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js +++ b/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js @@ -65,7 +65,7 @@ describe('Recipient Record - TTA History', () => { it('renders the TTA History page appropriately', async () => { act(() => renderTTAHistory()); - const overview = document.querySelector('.smart-hub--dashboard-overview'); + const overview = document.querySelector('.smart-hub--dashboard-overview-container'); expect(overview).toBeTruthy(); }); diff --git a/frontend/src/pages/RecipientRecord/pages/components/ClassReview.js b/frontend/src/pages/RecipientRecord/pages/components/ClassReview.js index 2a998d6115..6cd34a7515 100644 --- a/frontend/src/pages/RecipientRecord/pages/components/ClassReview.js +++ b/frontend/src/pages/RecipientRecord/pages/components/ClassReview.js @@ -3,31 +3,12 @@ import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Link } from '@trussworks/react-uswds'; import PropTypes from 'prop-types'; -import moment from 'moment'; import Container from '../../../../components/Container'; import Drawer from '../../../../components/Drawer'; import { getClassScores } from '../../../../fetchers/monitoring'; -import './ClassReview.scss'; import { useGrantData } from '../GrantDataContext'; import ContentFromFeedByTag from '../../../../components/ContentFromFeedByTag'; - -const BadgeAbove = () => ( - - Above all thresholds - -); - -const BadgeBelowQuality = () => ( - - Below quality - -); - -const BadgeBelowCompetitive = () => ( - - Below competitive - -); +import { getScoreBadge } from '../../../../components/ClassScoreBadge'; const ClassReview = ({ grantNumber, recipientId, regionId }) => { const { updateGrantClassData } = useGrantData(); @@ -43,34 +24,6 @@ const ClassReview = ({ grantNumber, recipientId, regionId }) => { fetchScores(); }, [grantNumber, recipientId, regionId, updateGrantClassData]); - const getScoreBadge = (key, score, received) => { - if (key === 'ES' || key === 'CO') { - if (score >= 6) return BadgeAbove(); - if (score < 5) return BadgeBelowCompetitive(); - return BadgeBelowQuality(); - } - - if (key === 'IS') { - if (score >= 3) return BadgeAbove(); - - // IS is slightly more complicated. - // See TTAHUB-2097 for details. - const dt = moment(received, 'MM/DD/YYYY'); - - if (dt.isAfter('2025-08-01')) { - if (score < 2.5) return BadgeBelowCompetitive(); - return BadgeBelowQuality(); - } - - if (dt.isAfter('2020-11-09') && dt.isBefore('2025-07-31')) { - if (score < 2.3) return BadgeBelowCompetitive(); - return BadgeBelowQuality(); - } - } - - return null; - }; - if (!scores || Object.keys(scores).length === 0) return null; return ( @@ -138,7 +91,9 @@ const ClassReview = ({ grantNumber, recipientId, regionId }) => {

Emotional support

- {getScoreBadge('ES', scores.ES, scores.received)} + { + getScoreBadge('ES', scores.ES, scores.received) + }

{scores.ES} @@ -153,7 +108,9 @@ const ClassReview = ({ grantNumber, recipientId, regionId }) => {

Classroom organization

- {getScoreBadge('CO', scores.CO, scores.received)} + { + getScoreBadge('CO', scores.CO, scores.received) + }

{scores.CO} @@ -168,7 +125,9 @@ const ClassReview = ({ grantNumber, recipientId, regionId }) => {

Instructional support

- {getScoreBadge('IS', scores.IS, scores.received)} + { + getScoreBadge('IS', scores.IS, scores.received) + }

{scores.IS} diff --git a/frontend/src/pages/RecipientRecord/pages/components/ClassReview.scss b/frontend/src/pages/RecipientRecord/pages/components/ClassReview.scss index 7673166e7c..b351eef831 100644 --- a/frontend/src/pages/RecipientRecord/pages/components/ClassReview.scss +++ b/frontend/src/pages/RecipientRecord/pages/components/ClassReview.scss @@ -1,25 +1,5 @@ @use '../../../../colors.scss' as *; -%badge { - background-color: $success-darkest; - border-radius: 12px; - padding: 4px 12px; -} - -.ttahub-badge--success { - @extend %badge; -} - -.ttahub-badge--warning { - background-color: $warning; - @extend %badge; -} - -.ttahub-badge--error { - background-color: $error; - @extend %badge; -} - .ttahub-class-feed-article .ttahub-feed-article { margin-bottom: 12px; padding-bottom: 0; diff --git a/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js b/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js index d508ff0395..85ef0ca0e0 100644 --- a/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js +++ b/frontend/src/pages/RegionalDashboard/components/__tests__/TrainingReportDashboard.js @@ -42,7 +42,7 @@ describe('Training report Dashboard page', () => { expect(fetchMock.calls(hoursOfTrainingUrl)).toHaveLength(1); expect(fetchMock.calls(sessionsByTopicUrl)).toHaveLength(1); - expect(document.querySelector('.smart-hub--dashboard-overview')).toBeTruthy(); + expect(document.querySelector('.smart-hub--dashboard-overview-container')).toBeTruthy(); expect(screen.getByText('Reasons in Training Reports')).toBeInTheDocument(); expect(screen.getByText('Hours of training by National Center')).toBeInTheDocument(); diff --git a/frontend/src/pages/RegionalDashboard/constants.js b/frontend/src/pages/RegionalDashboard/constants.js index 0bde585a61..8e86b62ea5 100644 --- a/frontend/src/pages/RegionalDashboard/constants.js +++ b/frontend/src/pages/RegionalDashboard/constants.js @@ -19,6 +19,7 @@ import { reportTextFilter, deliveryMethodFilter, activityReportGoalResponseFilter, + regionFilter, grantStatusFilter, } from '../../components/filter/activityReportFilters'; import { goalNameFilter } from '../../components/filter/goalFilters'; @@ -39,6 +40,7 @@ const DASHBOARD_FILTER_CONFIG = [ reasonsFilter, recipientFilter, reportIdFilter, + regionFilter, reportTextFilter, singleOrMultiRecipientsFilter, specialistRoleFilter, @@ -46,6 +48,7 @@ const DASHBOARD_FILTER_CONFIG = [ targetPopulationsFilter, topicsFilter, ttaTypeFilter, + regionFilter, grantStatusFilter, ]; diff --git a/frontend/src/pages/RegionalDashboard/index.js b/frontend/src/pages/RegionalDashboard/index.js index 6fff09baa8..bdbae79b71 100644 --- a/frontend/src/pages/RegionalDashboard/index.js +++ b/frontend/src/pages/RegionalDashboard/index.js @@ -4,14 +4,14 @@ import React, { useState, } from 'react'; import ReactRouterPropTypes from 'react-router-prop-types'; -import { Grid } from '@trussworks/react-uswds'; import FilterPanel from '../../components/filter/FilterPanel'; +import FilterPanelContainer from '../../components/filter/FilterPanelContainer'; import { hasApproveActivityReport } from '../../permissions'; import UserContext from '../../UserContext'; import { DASHBOARD_FILTER_CONFIG } from './constants'; import RegionPermissionModal from '../../components/RegionPermissionModal'; import { showFilterWithMyRegions } from '../regionHelpers'; -import { regionFilter, specialistNameFilter } from '../../components/filter/activityReportFilters'; +import { specialistNameFilter } from '../../components/filter/activityReportFilters'; import FeatureFlag from '../../components/FeatureFlag'; import useFilters from '../../hooks/useFilters'; import './index.css'; @@ -67,20 +67,22 @@ export default function RegionalDashboard({ match }) { regions, defaultRegion, allRegionsFilters, + userHasOnlyOneRegion, // filter functionality filters, setFilters, onApplyFilters, onRemoveFilter, + filterConfig, } = useFilters( user, filterKey, true, + [], + DASHBOARD_FILTER_CONFIG, ); - const userHasOnlyOneRegion = useMemo(() => regions.length === 1, [regions]); - const { h1Text, showFilters, @@ -88,19 +90,15 @@ export default function RegionalDashboard({ match }) { } = pageConfig(userHasOnlyOneRegion, defaultRegion)[reportType] || pageConfig(userHasOnlyOneRegion, defaultRegion).default; const filtersToUse = useMemo(() => { - const filterConfig = [...DASHBOARD_FILTER_CONFIG]; - - if (!userHasOnlyOneRegion) { - filterConfig.push(regionFilter); - } + const config = [...filterConfig]; // If user has approve activity report permission add 'Specialist name' filter. if (hasApproveActivityReport(user)) { - filterConfig.push(specialistNameFilter); - filterConfig.sort((a, b) => a.display.localeCompare(b.display)); + config.push(specialistNameFilter); + config.sort((a, b) => a.display.localeCompare(b.display)); } - return filterConfig; - }, [user, userHasOnlyOneRegion]); + return config; + }, [filterConfig, user]); return (

@@ -118,7 +116,7 @@ export default function RegionalDashboard({ match }) { {h1Text} {showFilters && ( - + - + )} { let heading = await screen.findByText(/region 1 goal dashboard/i); expect(heading).toBeVisible(); - const removeDate = await screen.findByRole('button', { name: /this button removes the filter: date started is within/i }); + const removeDate = await screen.findByRole('button', { name: /this button removes the filter: date started \(ar\) is within/i }); act(() => userEvent.click(removeDate)); heading = await screen.findByText(/region 1 goal dashboard/i); diff --git a/frontend/src/pages/RegionalGoalDashboard/index.js b/frontend/src/pages/RegionalGoalDashboard/index.js index 74c44b463a..120fad9d2b 100644 --- a/frontend/src/pages/RegionalGoalDashboard/index.js +++ b/frontend/src/pages/RegionalGoalDashboard/index.js @@ -16,6 +16,7 @@ import GoalsPercentage from '../../widgets/RegionalGoalDashboard/GoalsPercentage import GoalStatusChart from '../../widgets/RegionalGoalDashboard/GoalStatusChart'; import TotalHrsAndRecipientGraphWidget from '../../widgets/TotalHrsAndRecipientGraph'; import TopicsTable from '../../widgets/RegionalGoalDashboard/TopicsTable'; +import FilterPanelContainer from '../../components/filter/FilterPanelContainer'; const defaultDate = formatDateRange({ forDateTime: true, @@ -121,7 +122,7 @@ export default function RegionalGoalDashboard() { {' '} Goal Dashboard - + - + { it('handles errors by displaying an error message', async () => { // Page Load. fetchMock.get(`${resourcesUrl}?${allRegions}`, 500, { overwriteRoutes: true }); - fetchMock.post(reportPostUrl, reportResponse); + fetchMock.post(reportPostUrl, 500); const user = { homeRegionId: 14, diff --git a/frontend/src/pages/ResourcesDashboard/index.js b/frontend/src/pages/ResourcesDashboard/index.js index 1a7941f80e..e2db3ca434 100644 --- a/frontend/src/pages/ResourcesDashboard/index.js +++ b/frontend/src/pages/ResourcesDashboard/index.js @@ -2,9 +2,7 @@ /* eslint-disable no-console */ import React, { useContext, - useMemo, useState, - useCallback, useEffect, } from 'react'; import moment from 'moment'; @@ -13,15 +11,12 @@ import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import { Grid, Alert } from '@trussworks/react-uswds'; import useDeepCompareEffect from 'use-deep-compare-effect'; +import useFilters from '../../hooks/useFilters'; import FilterPanel from '../../components/filter/FilterPanel'; -import { allRegionsUserHasPermissionTo } from '../../permissions'; -import { buildDefaultRegionFilters, showFilterWithMyRegions } from '../regionHelpers'; -import useSessionFiltersAndReflectInUrl from '../../hooks/useSessionFiltersAndReflectInUrl'; -import AriaLiveContext from '../../AriaLiveContext'; import ResourcesDashboardOverview from '../../widgets/ResourcesDashboardOverview'; import ResourceUse from '../../widgets/ResourceUse'; -import { expandFilters, filtersToQueryString, formatDateRange } from '../../utils'; -import './index.scss'; +import { showFilterWithMyRegions } from '../regionHelpers'; +import { filtersToQueryString, formatDateRange } from '../../utils'; import { fetchFlatResourceData } from '../../fetchers/Resources'; import { downloadReports, @@ -35,18 +30,24 @@ import RegionPermissionModal from '../../components/RegionPermissionModal'; import ResourcesAssociatedWithTopics from '../../widgets/ResourcesAssociatedWithTopics'; import ReportsTable from '../../components/ActivityReportsTable/ReportsTable'; import useSessionSort from '../../hooks/useSessionSort'; +import './index.scss'; const defaultDate = formatDateRange({ forDateTime: true, string: `2022/07/01-${moment().format('YYYY/MM/DD')}`, withSpaces: false, }); + +const additionalDefaultFilters = [ + { + id: uuidv4(), + topic: 'startDate', + condition: 'is within', + query: defaultDate, + }, +]; export default function ResourcesDashboard() { const { user } = useContext(UserContext); - const ariaLiveContext = useContext(AriaLiveContext); - const regions = allRegionsUserHasPermissionTo(user); - const defaultRegion = user.homeRegionId || regions[0] || 0; - const allRegionsFilters = useMemo(() => buildDefaultRegionFilters(regions), [regions]); const [isLoading, setIsLoading] = useState(false); const [areReportsLoading, setAreReportsLoading] = useState(false); const [resourcesData, setResourcesData] = useState({}); @@ -56,9 +57,6 @@ export default function ResourcesDashboard() { count: 0, rows: [], }); - const hasCentralOffice = useMemo(() => ( - user && user.homeRegionId && user.homeRegionId === 14 - ), [user]); const [activityReportSortConfig, setActivityReportSortConfig] = useSessionSort({ sortBy: 'updatedAt', @@ -72,88 +70,26 @@ export default function ResourcesDashboard() { (activePage - 1) * REPORTS_PER_PAGE, ); - const getFiltersWithAllRegions = () => { - const filtersWithAllRegions = [...allRegionsFilters]; - return filtersWithAllRegions; - }; - const centralOfficeWithAllRegionFilters = getFiltersWithAllRegions(); + const { + // from useUserDefaultRegionFilters + regions, + // defaultRegion, + allRegionsFilters, - const defaultFilters = useMemo(() => { - if (hasCentralOffice) { - return [...centralOfficeWithAllRegionFilters, - { - id: uuidv4(), - topic: 'startDate', - condition: 'is within', - query: defaultDate, - }]; - } - - return [ - { - id: uuidv4(), - topic: 'region', - condition: 'is', - query: defaultRegion, - }, - { - id: uuidv4(), - topic: 'startDate', - condition: 'is within', - query: defaultDate, - }, - ]; - }, [defaultRegion, hasCentralOffice, centralOfficeWithAllRegionFilters]); - - const [filters, setFiltersInHook] = useSessionFiltersAndReflectInUrl( + // filter functionality + filters, + setFilters, + onApplyFilters, + onRemoveFilter, + filterConfig, + } = useFilters( + user, REGIONAL_RESOURCE_DASHBOARD_FILTER_KEY, - defaultFilters, + true, + additionalDefaultFilters, + RESOURCES_DASHBOARD_FILTER_CONFIG, ); - const setFilters = useCallback((newFilters) => { - setFiltersInHook(newFilters); - setResetPagination(true); - setActivityReportOffset(0); - setActivityReportSortConfig({ - ...activityReportSortConfig, - activePage: 1, - }); - }, [activityReportSortConfig, setActivityReportSortConfig, setFiltersInHook]); - - // Remove Filters. - const onRemoveFilter = (id, addBackDefaultRegions) => { - const newFilters = [...filters]; - const index = newFilters.findIndex((item) => item.id === id); - if (index !== -1) { - newFilters.splice(index, 1); - if (addBackDefaultRegions) { - // We always want the regions to appear in the URL. - setFilters([...allRegionsFilters, ...newFilters]); - } else { - setFilters(newFilters); - } - } - }; - - // Apply filters. - const onApplyFilters = (newFilters, addBackDefaultRegions) => { - if (addBackDefaultRegions) { - // We always want the regions to appear in the URL. - setFilters([ - ...allRegionsFilters, - ...newFilters, - ]); - } else { - setFilters([ - ...newFilters, - ]); - } - - ariaLiveContext.announce(`${newFilters.length} filter${newFilters.length !== 1 ? 's' : ''} applied to topics with resources `); - }; - - const filtersToApply = useMemo(() => expandFilters(filters), [filters]); - const { reportIds } = resourcesData; useEffect(() => { @@ -192,7 +128,7 @@ export default function ResourcesDashboard() { async function fetcHResourcesData() { setIsLoading(true); // Filters passed also contains region. - const filterQuery = filtersToQueryString(filtersToApply); + const filterQuery = filtersToQueryString(filters); try { const data = await fetchFlatResourceData( filterQuery, @@ -208,7 +144,7 @@ export default function ResourcesDashboard() { // Call resources fetch. fetcHResourcesData(); }, [ - filtersToApply, + filters, ]); const handleDownloadReports = async (setIsDownloading, setDownloadError, url, buttonRef) => { @@ -295,21 +231,13 @@ export default function ResourcesDashboard() { filters={filters} onApplyFilters={onApplyFilters} onRemoveFilter={onRemoveFilter} - filterConfig={RESOURCES_DASHBOARD_FILTER_CONFIG} + filterConfig={filterConfig} allUserRegions={regions} /> { required: 'Enter duration', valueAsNumber: true, validate: { - mustBeQuarterHalfOrWhole: (value) => { - if (value % 0.25 !== 0) { - return 'Duration must be rounded to the nearest quarter hour'; - } - return true; - }, + mustBeQuarterHalfOrWhole, }, min: { value: 0.25, message: 'Duration must be greater than 0 hours' }, max: { value: 99, message: 'Duration must be less than or equal to 99 hours' }, diff --git a/frontend/src/pages/SessionForm/pages/supportingAttachments.js b/frontend/src/pages/SessionForm/pages/supportingAttachments.js index 8f9d6c57a9..c1ef9503fc 100644 --- a/frontend/src/pages/SessionForm/pages/supportingAttachments.js +++ b/frontend/src/pages/SessionForm/pages/supportingAttachments.js @@ -1,89 +1,14 @@ -import React, { - useEffect, - useRef, - useState, -} from 'react'; +import React from 'react'; import { TRAINING_REPORT_STATUSES } from '@ttahub/common'; -import { - ErrorMessage, - Fieldset, - FormGroup, - Label, - Button, -} from '@trussworks/react-uswds'; -import PropTypes from 'prop-types'; -import { Helmet } from 'react-helmet'; -import { Controller, useFormContext } from 'react-hook-form'; -import ReportFileUploader from '../../../components/FileUploader/ReportFileUploader'; +import { Button } from '@trussworks/react-uswds'; import { deleteSessionSupportingAttachment } from '../../../fetchers/File'; import { pageComplete, supportingAttachmentsVisitedField } from '../constants'; +import SupportingAttachmentsSessionOrCommunication from '../../../components/SupportAttachmentsSessionOrCommunication'; const path = 'supporting-attachments'; const position = 3; const fields = [supportingAttachmentsVisitedField]; -const SupportingAttachments = ({ reportId }) => { - const [fileError, setFileError] = useState(); - const { watch, register, setValue } = useFormContext(); - const visitedRef = useRef(false); - const pageVisited = watch(supportingAttachmentsVisitedField); - - useEffect(() => { - /* - Track if we have visited this page yet in order to mark page as 'complete'. - We use a ref and a hook-form entry called 'visitedField' to track this requirement. - */ - if (!pageVisited && !visitedRef.current) { - visitedRef.current = true; - setValue(supportingAttachmentsVisitedField, true); - } - }, [pageVisited, setValue]); - - return ( - <> - - Supporting Attachments - - -
- -
- - - Example: .doc, .pdf, .txt, .csv (max size 30 MB) - { fileError && ({fileError})} - ( - - )} - /> - -
- - ); -}; - -SupportingAttachments.propTypes = { - reportId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, -}; - const ReviewSection = () => <>; export const isPageComplete = (hookForm) => pageComplete(hookForm, fields); @@ -107,7 +32,12 @@ export default { Alert, ) => (
- +
diff --git a/frontend/src/pages/TrainingReportForm/pages/__tests__/eventSummary.js b/frontend/src/pages/TrainingReportForm/pages/__tests__/eventSummary.js index dd2fe9555e..5c16428a6c 100644 --- a/frontend/src/pages/TrainingReportForm/pages/__tests__/eventSummary.js +++ b/frontend/src/pages/TrainingReportForm/pages/__tests__/eventSummary.js @@ -1,3 +1,4 @@ +/* eslint-disable react/prop-types */ /* eslint-disable react/jsx-props-no-spreading */ import React from 'react'; import { @@ -43,7 +44,13 @@ describe('eventSummary', () => { }, }; - const RenderEventSummary = (user = defaultUser) => { + const RenderEventSummary = ({ + user = defaultUser, + creators = [ + { id: 1, name: 'IST 1', nameWithNationalCenters: 'IST 1' }, + { id: 2, name: 'IST 2', nameWithNationalCenters: 'IST 2' }, + ], + }) => { const hookForm = useForm({ mode: 'onBlur', defaultValues: defaultFormValues, @@ -63,10 +70,7 @@ describe('eventSummary', () => { nameWithNationalCenters: 'Tedwina User', }, ], - creators: [ - { id: 1, name: 'IST 1' }, - { id: 2, name: 'IST 2' }, - ], + creators, }, }; @@ -75,7 +79,7 @@ describe('eventSummary', () => { return ( - + { weAreAutoSaving={false} datePickerKey="key" Alert={() => <>} + showSubmitModal={jest.fn()} /> @@ -155,7 +160,10 @@ describe('eventSummary', () => { expect(await screen.findByRole('textbox', { name: /event name required/i })).toBeInTheDocument(); // Event creator. - expect(await screen.findByTestId('creator-select')).toBeInTheDocument(); + const creator = await screen.findByLabelText(/Event creator/i); + expect(creator).toBeInTheDocument(); + + await selectEvent.select(creator, ['IST 2']); // Event Organizer. expect(await screen.findByRole('combobox', { name: /event organizer/i })).toBeInTheDocument(); @@ -199,5 +207,21 @@ describe('eventSummary', () => { // Nine additional read only fields. expect(screen.queryAllByTestId('read-only-label').length).toBe(9); }); + it('handles null creators', async () => { + const adminUser = { + ...defaultUser, + permissions: [ + { regionId: 1, scopeId: ADMIN }, + ], + }; + act(() => { + render(); + }); + + const creator = await screen.findByLabelText(/Event creator/i); + + // Event creator. + expect(creator).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/pages/TrainingReportForm/pages/eventSummary.js b/frontend/src/pages/TrainingReportForm/pages/eventSummary.js index fbd5dabc41..a7c83cf1ca 100644 --- a/frontend/src/pages/TrainingReportForm/pages/eventSummary.js +++ b/frontend/src/pages/TrainingReportForm/pages/eventSummary.js @@ -215,42 +215,28 @@ const EventSummary = ({ defaultValue="" />
+
- ( - + +
+ ); +} + +LegendControl.propTypes = { + label: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + selected: PropTypes.bool.isRequired, + setSelected: PropTypes.func.isRequired, + shape: PropTypes.string.isRequired, +}; diff --git a/frontend/src/widgets/LegendControl.scss b/frontend/src/widgets/LegendControl.scss new file mode 100644 index 0000000000..85f79bee58 --- /dev/null +++ b/frontend/src/widgets/LegendControl.scss @@ -0,0 +1,80 @@ +@use "../colors.scss" as *; + +.ttahub-legend-control label::after { + height: 3px; + content: " "; + width: 80px; + display: inline-block; + margin-left: 10px; + margin-bottom: 2px; +} + +.ttahub-legend-control.circle label::after { + background-color: $ttahub-blue; /* Technical assistance */ +} + +.ttahub-legend-control.triangle label::after { + border-top: 3px dashed $ttahub-orange; /* training */ + height: 3px; + background-color: #ffffff00; +} + +.ttahub-legend-control.square label::after { + border-top: 3px dashed $ttahub-medium-deep-teal; /* both */ + height: 3px; + background-color: #ffffff00; +} + +.ttahub-legend-control.usa-checkbox::after { + content: ""; + height: 9px; + position: absolute; + right: 4.2rem; + top: 7px; + width: 9px; +} + +/* both */ +.ttahub-legend-control.usa-checkbox.square::after { + background: $ttahub-medium-deep-teal; +} + +/* technical assistance */ +.ttahub-legend-control.usa-checkbox.circle::after { + background: $ttahub-blue; + border-radius: 50%; + height: 12px; + width: 12px; +} + +/* training */ +.ttahub-legend-control.usa-checkbox.triangle::after { + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-bottom: 10px solid $ttahub-orange; + height: 0; + width: 0; + right: 4rem; + z-index: 1; +} + +/* this adds a white line around the triangle to make it stand out a bit more*/ +.ttahub-legend-control.usa-checkbox.triangle::before { + content: ""; + border-left: 11px solid transparent; + border-right: 11px solid transparent; + border-bottom: 11px solid white; + height: 0; + width: 0; + position: absolute; + right: calc(4rem - 1px); + top: 6px; + z-index: 1; +} + +@media (max-width: 1606px) { + .ttahub-legend-control { + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } +} diff --git a/frontend/src/widgets/LegendControlFieldset.js b/frontend/src/widgets/LegendControlFieldset.js new file mode 100644 index 0000000000..c627770d85 --- /dev/null +++ b/frontend/src/widgets/LegendControlFieldset.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Fieldset } from '@trussworks/react-uswds'; + +export default function LegendControlFieldset({ + children, + legend, +}) { + return ( +
+ {legend} + {children} +
+ ); +} + +LegendControlFieldset.propTypes = { + children: PropTypes.node.isRequired, + legend: PropTypes.string.isRequired, +}; diff --git a/frontend/src/widgets/LineGraph.js b/frontend/src/widgets/LineGraph.js new file mode 100644 index 0000000000..37c4708f74 --- /dev/null +++ b/frontend/src/widgets/LineGraph.js @@ -0,0 +1,329 @@ +import React, { + useEffect, + useRef, + useState, +} from 'react'; +import PropTypes from 'prop-types'; +import Plotly from 'plotly.js-basic-dist'; +import { DECIMAL_BASE } from '@ttahub/common'; +import colors from '../colors'; +import LegendControl from './LegendControl'; +import LegendControlFieldset from './LegendControlFieldset'; +import HorizontalTableWidget from './HorizontalTableWidget'; +import { arrayExistsAndHasLength } from '../Constants'; +import NoResultsFound from '../components/NoResultsFound'; + +const HOVER_TEMPLATE = '(%{x}, %{y})'; + +export default function LineGraph({ + data, + hideYAxis, + xAxisTitle, + yAxisTitle, + legendConfig, + tableConfig, + widgetRef, + showTabularData, +}) { + // the state for the legend and which traces are visible + const [legends, setLegends] = useState(legendConfig); + + // the dom el for drawing the chart + const lines = useRef(); + + const hasData = data && data.length && data.some((d) => d.x.length > 0); + + useEffect(() => { + if (!lines || showTabularData || !arrayExistsAndHasLength(data) || !hasData) { + return; + } + + const xTickStep = (() => { + const value = data[0].x.length; + let divisor = value; + if (value > 12) { + divisor = 6; + } + + if (value > 24) { + divisor = 4; + } + + return parseInt(value / divisor, DECIMAL_BASE); + })(); + + const layout = { + height: 320, + hoverlabel: { + bgcolor: '#fff', + bordercolor: '#fff', + font: { + color: '#fff', + }, + }, + font: { + color: colors.smartHubTextInk, + }, + margin: { + l: 50, + t: 0, + r: 0, + b: 68, + }, + showlegend: false, + xaxis: { + showgrid: false, + hovermode: 'closest', + autotick: false, + ticks: 'outside', + tick0: 0, + dtick: xTickStep, + ticklen: 5, + tickwidth: 1, + tickcolor: '#000', + title: { + text: xAxisTitle, + standoff: 40, + font: { + family: 'Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif', + size: 18, + color: colors.smartHubTextInk, + }, + }, + }, + yaxis: { + automargin: true, + rangemode: 'tozero', + tickwidth: 1, + tickcolor: 'transparent', + tickformat: (n) => { + // if not a whole number, round to 1 decimal place + if (n % 1 !== 0) { + return '.1f'; + } + return ','; + }, + title: { + standoff: 20, + text: yAxisTitle, + font: { + family: 'Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif', + size: 18, + color: colors.smartHubTextInk, + }, + }, + }, + }; + + if (hideYAxis) { + layout.yaxis = { + ...layout.yaxis, + showline: false, + autotick: true, + ticks: '', + showticklabels: false, + }; + } + + // these are ordered from left to right how they appear in the checkboxes/legend + const traces = [ + { + // Technical Assistance + type: 'scatter', + mode: 'lines+markers', + x: data[1].x, + y: data[1].y, + hovertemplate: HOVER_TEMPLATE, + line: { + dash: 'solid', + width: 3, + color: colors.ttahubBlue, + }, + marker: { + size: 12, + }, + hoverlabel: { + font: { color: '#ffffff', size: '16' }, + bgcolor: colors.textInk, + }, + }, + // Both + { + type: 'scatter', + mode: 'lines+markers', + x: data[2].x, + y: data[2].y, + hovertemplate: HOVER_TEMPLATE, + line: { + dash: 'longdash', + width: 3, + color: colors.ttahubMediumDeepTeal, + }, + marker: { + symbol: 'square', + size: 12, + }, + hoverlabel: { + font: { color: '#ffffff', size: '16' }, + bgcolor: colors.textInk, + }, + }, + { + // Training + type: 'scatter', + mode: 'lines+markers', + x: data[0].x, + y: data[0].y, + hovertemplate: HOVER_TEMPLATE, + line: { + dash: 'dash', + width: 3, + color: colors.ttahubOrange, + }, + marker: { + size: 14, + symbol: 'triangle-up', + }, + hoverlabel: { + font: { color: '#ffffff', size: '16' }, + bgcolor: colors.textInk, + }, + }, + ]; + + const tracesToDraw = legends.map((legend, index) => (legend.selected ? traces[index] : null)) + .filter((trace) => Boolean(trace)); + // draw the plot + Plotly.newPlot(lines.current, tracesToDraw, layout, { displayModeBar: false, hovermode: 'none', responsive: true }); + }, [data, hideYAxis, legends, showTabularData, xAxisTitle, yAxisTitle, hasData]); + + if (!hasData) { + return ; + } + + return ( +
+ { showTabularData + ? ( + + ) + : ( +
+ + {legends.map((legend) => ( + { + const updatedLegends = legends.map((l) => { + if (l.id === legend.id) { + return { ...l, selected }; + } + return l; + }); + setLegends(updatedLegends); + }} + shape={legend.shape} + /> + ))} + +
+
+ )} +
+ + ); +} + +LineGraph.propTypes = { + data: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + x: PropTypes.arrayOf(PropTypes.string), + y: PropTypes.arrayOf(PropTypes.number), + month: PropTypes.string, + }), + ), + hideYAxis: PropTypes.bool, + xAxisTitle: PropTypes.string, + yAxisTitle: PropTypes.string, + legendConfig: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + selected: PropTypes.bool.isRequired, + shape: PropTypes.oneOf(['circle', 'triangle', 'square']).isRequired, + })), + tableConfig: PropTypes.shape({ + headings: PropTypes.arrayOf(PropTypes.string).isRequired, + firstHeading: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + requestSort: PropTypes.func, + sortConfig: PropTypes.shape({ + sortBy: PropTypes.string, + direction: PropTypes.string, + activePage: PropTypes.number, + }), + caption: PropTypes.string.isRequired, + enableCheckboxes: PropTypes.bool.isRequired, + enableSorting: PropTypes.bool.isRequired, + showTotalColumn: PropTypes.bool.isRequired, + checkboxes: PropTypes.shape({}), + setCheckboxes: PropTypes.func, + footer: PropTypes.shape({ + data: PropTypes.arrayOf(PropTypes.string), + showFooter: PropTypes.bool.isRequired, + }), + data: PropTypes.arrayOf(PropTypes.shape({ + heading: PropTypes.string.isRequired, + data: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + title: PropTypes.string.isRequired, + })).isRequired, + })).isRequired, + }).isRequired, + // eslint-disable-next-line react/forbid-prop-types + widgetRef: PropTypes.object.isRequired, + showTabularData: PropTypes.bool.isRequired, +}; + +LineGraph.defaultProps = { + xAxisTitle: '', + yAxisTitle: '', + hideYAxis: false, + data: null, + legendConfig: [ + { + label: 'Technical Assistance', + id: 'show-ta-checkbox', + selected: true, + shape: 'circle', + }, + { + label: 'Training', + id: 'show-training-checkbox', + selected: true, + shape: 'triangle', + }, + { + label: 'Both', + id: 'show-both-checkbox', + selected: true, + shape: 'square', + }, + ], +}; diff --git a/frontend/src/widgets/OverviewWidgetField.js b/frontend/src/widgets/OverviewWidgetField.js new file mode 100644 index 0000000000..3da48a5b10 --- /dev/null +++ b/frontend/src/widgets/OverviewWidgetField.js @@ -0,0 +1,112 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Grid } from '@trussworks/react-uswds'; +import { Link } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import FiltersNotApplicable from '../components/FiltersNotApplicable'; +import DrawerTriggerButton from '../components/DrawerTriggerButton'; +import Drawer from '../components/Drawer'; +import ContentFromFeedByTag from '../components/ContentFromFeedByTag'; + +import './OverviewWidgetField.scss'; + +import Tooltip from '../components/Tooltip'; + +export function OverviewWidgetField({ + label1, + label2, + route, + data, + icon, + iconColor, + backgroundColor, + showTooltip, + tooltipText, + filterApplicable, + iconSize, + showNoResults, +}) { + const drawerTriggerRef = useRef(null); + const noData = data === '0%'; + return ( + + + + + + + +
+ {showNoResults && noData ? ( + <> + No results + + Get help using filters + + + + + + ) : ( + <> + {data} + {!filterApplicable ? : null} + + )} +
+ + {showTooltip ? ( + + ) : ( + {label1} + )} + {label2} + {route && (!showNoResults || !noData) && ( + + {route.label} + + )} +
+
+ ); +} + +OverviewWidgetField.propTypes = { + label1: PropTypes.string.isRequired, + label2: PropTypes.string, + data: PropTypes.string.isRequired, + icon: PropTypes.shape({ + prefix: PropTypes.string, + iconName: PropTypes.string, + // eslint-disable-next-line react/forbid-prop-types + icon: PropTypes.array, + }).isRequired, + iconColor: PropTypes.string.isRequired, + backgroundColor: PropTypes.string.isRequired, + tooltipText: PropTypes.string, + showTooltip: PropTypes.bool, + route: PropTypes.shape({ + to: PropTypes.string, + label: PropTypes.string, + }), + filterApplicable: PropTypes.bool, + iconSize: PropTypes.string, + showNoResults: PropTypes.bool, +}; + +OverviewWidgetField.defaultProps = { + tooltipText: '', + showTooltip: false, + label2: '', + route: null, + filterApplicable: true, + iconSize: 'sm', + showNoResults: false, +}; + +export default OverviewWidgetField; diff --git a/frontend/src/widgets/OverviewWidgetField.scss b/frontend/src/widgets/OverviewWidgetField.scss new file mode 100644 index 0000000000..68b55e9527 --- /dev/null +++ b/frontend/src/widgets/OverviewWidgetField.scss @@ -0,0 +1,22 @@ +@media( min-width: 1024px ){ + .smart-hub--dashboard-overview-widget-field { + margin: 0; + } +} + +.smart-hub--dashboard-overview-widget-field-icon { + border-radius: 50%; + } + + .smart-hub--dashboard-overview-widget-field-icon__background-sm { + height: 3em; + width: 3em; +} + +.smart-hub--dashboard-overview-widget-field .smart-hub--ellipsis { + max-width: 200px; +} + +.smart-hub--dashboard-overview-widget-field .smart-hub--no-results-found { + margin: auto; +} \ No newline at end of file diff --git a/frontend/src/widgets/PercentageActivityReportByRole.js b/frontend/src/widgets/PercentageActivityReportByRole.js new file mode 100644 index 0000000000..2da2dc6082 --- /dev/null +++ b/frontend/src/widgets/PercentageActivityReportByRole.js @@ -0,0 +1,198 @@ +import React, { + useRef, useEffect, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import WidgetContainer from '../components/WidgetContainer'; +import HorizontalTableWidget from './HorizontalTableWidget'; +import useMediaCapture from '../hooks/useMediaCapture'; +import useWidgetSorting from '../hooks/useWidgetSorting'; +import useWidgetExport from '../hooks/useWidgetExport'; +import useWidgetMenuItems from '../hooks/useWidgetMenuItems'; +import { EMPTY_ARRAY } from '../Constants'; +import VBarGraph from './VBarGraph'; + +const FIRST_COLUMN = 'Specialist role'; + +const TABLE_HEADINGS = [ + 'Number of activity reports', + 'Percentage of activity reports', +]; + +const DEFAULT_SORT_CONFIG = { + sortBy: 'Specialist_role', + direction: 'asc', + activePage: 1, +}; + +export default function PercentageActivityReportByRole({ data }) { + const widgetRef = useRef(null); + const capture = useMediaCapture(widgetRef, 'Percentage of activity reports by role'); + const [showTabularData, setShowTabularData] = useState(false); + const [checkboxes, setCheckboxes] = useState({}); + const [displayFilteredReports, setDisplayFilteredReports] = useState(0); + + // we have to store this is in state, despite + // it being a prop, because of other dependencies + // in the react graph + const [trace, setTrace] = useState([]); + const [tabularData, setTabularData] = useState([]); + const [totals, setTotals] = useState({ + totalNumberOfReports: 0, + totalPercentage: 100, + }); + + const [showFiltersNotApplicable, setShowFiltersNotApplicable] = useState(false); + + const { + requestSort, + sortConfig, + } = useWidgetSorting( + 'qa-dashboard-percentage-ars-by-role', // localStorageKey + DEFAULT_SORT_CONFIG, // defaultSortConfig + tabularData, // dataToUse + setTabularData, // setDataToUse + ['Specialist_role'], // stringColumns + EMPTY_ARRAY, // dateColumns + EMPTY_ARRAY, // keyColumns + ); + + const { exportRows } = useWidgetExport( + tabularData, + TABLE_HEADINGS, + checkboxes, + FIRST_COLUMN, + 'PercentageARSByRole', + ); + + // records is an array of objects + // and the other fields need to be converted to camelCase + useEffect(() => { + if (!data) { + setTabularData([]); + setTrace([]); + setTotals({ + totalNumberOfReports: 0, + totalPercentage: 100, + }); + return; + } + + // take the API data + // and transform it into the format + // that the LineGraph component expects + // (an object for each trace) + // and the table (an array of objects in the format defined by proptypes) + const { + records, + filteredReports, + showDashboardFiltersNotApplicable: showFiltersNotApplicableProp, + } = data; + + const totalPercentage = records.reduce((acc, record) => acc + record.percentage, 0); + const totalNumberOfReports = records.reduce((acc, record) => acc + record.role_count, 0); + + const tableData = []; + const traceData = []; + + (records || []).forEach((dataset, index) => { + traceData.push({ + name: dataset.role_name, + count: dataset.percentage, + }); + + tableData.push({ + heading: dataset.role_name, + id: `${dataset.role_name}-${index + 1}`, + data: [ + { + value: dataset.role_count, + title: 'Number of activity reports', + sortKey: 'Number_of_activity_reports', + }, + { + value: `${String(dataset.percentage)}%`, + title: 'Percentage of activity reports', + sortKey: 'Percentage_of_activity_reports', + }, + ], + }); + }); + setShowFiltersNotApplicable(showFiltersNotApplicableProp); + setDisplayFilteredReports(filteredReports); + setTrace(traceData); + setTabularData(tableData); + setTotals({ + totalNumberOfReports, + totalPercentage, + }); + }, [data]); + // end use effect + + const menuItems = useWidgetMenuItems( + showTabularData, + setShowTabularData, + capture, + checkboxes, + exportRows, + ); + + return ( +
+ + + {showTabularData ? ( + + ) : ( + + )} + +
+ ); +} + +PercentageActivityReportByRole.propTypes = { + data: PropTypes.shape({ + showDashboardFiltersNotApplicable: PropTypes.bool, + totalNumberOfReports: PropTypes.number, + totalPercentage: PropTypes.number, + filteredReports: PropTypes.number, + records: PropTypes.arrayOf(PropTypes.shape({ + role_name: PropTypes.string, + role_count: PropTypes.number, + percentage: PropTypes.number, + })), + }).isRequired, +}; diff --git a/frontend/src/widgets/QaDetailsDrawer.scss b/frontend/src/widgets/QaDetailsDrawer.scss new file mode 100644 index 0000000000..66dc2f4347 --- /dev/null +++ b/frontend/src/widgets/QaDetailsDrawer.scss @@ -0,0 +1,8 @@ + +/* h2 font weight was being applied */ +.smart-hub--qa-details--title-drawer { + font-weight: 400; + font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif; + font-size: 1.06rem; + line-height: 1.5; +} diff --git a/frontend/src/widgets/QualityAssuranceDashboardOverview.js b/frontend/src/widgets/QualityAssuranceDashboardOverview.js new file mode 100644 index 0000000000..3bc6016d5c --- /dev/null +++ b/frontend/src/widgets/QualityAssuranceDashboardOverview.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + faPersonChalkboard, + faBus, + faUser, +} from '@fortawesome/free-solid-svg-icons'; +import colors from '../colors'; + +import { DashboardOverviewContainer } from './DashboardOverviewContainer'; + +const createOverviewFieldArray = (data) => ([ + { + key: 'recipients-with-no-tta', + icon: faUser, + showTooltip: false, + label1: 'Recipients with no TTA', + iconColor: colors.ttahubBlue, + backgroundColor: colors.ttahubBlueLight, + data: data && data.recipientsWithNoTTA ? `${data.recipientsWithNoTTA.pct}%` : '0%', + route: 'qa-dashboard/recipients-with-no-tta', + filterApplicable: data.recipientsWithNoTTA.filterApplicable, + showNoResults: true, + }, + { + icon: faBus, + showTooltip: false, + label1: 'Recipients with OHS standard FEI goal', + iconColor: colors.ttahubOrange, + backgroundColor: colors.ttahubOrangeLight, + data: data && data.recipientsWithOhsStandardFeiGoals + ? `${data.recipientsWithOhsStandardFeiGoals.pct}%` + : '0%', + route: 'qa-dashboard/recipients-with-ohs-standard-fei-goal', + filterApplicable: data.recipientsWithOhsStandardFeiGoals.filterApplicable, + showNoResults: true, + }, + { + key: 'recipients-with-ohs-standard-class-goals', + icon: faPersonChalkboard, + showTooltip: false, + label1: 'Recipients with OHS standard CLASS goal', + iconColor: colors.success, + backgroundColor: colors.ttahubDeepTealLight, + data: data && data.recipientsWithOhsStandardClass + ? `${data.recipientsWithOhsStandardClass.pct}%` + : '0%', + route: 'qa-dashboard/recipients-with-class-scores-and-goals', + filterApplicable: data.recipientsWithOhsStandardClass.filterApplicable, + showNoResults: true, + }, +]); + +export function QualityAssuranceDashboardOverview({ + data, loading, +}) { + if (!data) { + return null; + } + const DASHBOARD_FIELDS = createOverviewFieldArray(data); + return ( + + ); +} + +QualityAssuranceDashboardOverview.propTypes = { + data: PropTypes.shape({ + recipientsWithNoTTA: PropTypes.shape({ + pct: PropTypes.number, + }), + recipientsWithOhsStandardFeiGoals: PropTypes.shape({ + pct: PropTypes.number, + }), + recipientsWithOhsStandardClass: PropTypes.shape({ + pct: PropTypes.number, + }), + }), + loading: PropTypes.bool, +}; + +QualityAssuranceDashboardOverview.defaultProps = { + data: { + recipientsWithNoTTA: { + pct: 0, + filterApplicable: false, + }, + recipientsWithOhsStandardFeiGoals: { + pct: 0, + filterApplicable: false, + }, + recipientsWithOhsStandardClass: { + pct: 0, + filterApplicable: false, + }, + }, + loading: false, +}; + +export default QualityAssuranceDashboardOverview; diff --git a/frontend/src/widgets/RecipientsWithClassScoresAndGoalsWidget.js b/frontend/src/widgets/RecipientsWithClassScoresAndGoalsWidget.js new file mode 100644 index 0000000000..7ec6bec9c6 --- /dev/null +++ b/frontend/src/widgets/RecipientsWithClassScoresAndGoalsWidget.js @@ -0,0 +1,354 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { DECIMAL_BASE } from '@ttahub/common'; +import { + Dropdown, + Checkbox, + Label, + Button, +} from '@trussworks/react-uswds'; +import { v4 as uuidv4 } from 'uuid'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTimesCircle } from '@fortawesome/free-solid-svg-icons'; +import colors from '../colors'; +import { RECIPIENTS_WITH_CLASS_SCORES_AND_GOALS_GOAL_PER_PAGE } from '../Constants'; +import WidgetContainer from '../components/WidgetContainer'; +import useWidgetPaging from '../hooks/useWidgetPaging'; +import DrawerTriggerButton from '../components/DrawerTriggerButton'; +import Drawer from '../components/Drawer'; +import ContentFromFeedByTag from '../components/ContentFromFeedByTag'; +import RecipientCard from '../pages/QADashboard/Components/RecipientCard'; +import './QaDetailsDrawer.scss'; + +function RecipientsWithClassScoresAndGoalsWidget({ + data, + parentLoading, +}) { + const { widgetData, pageData } = data; + const titleDrawerRef = useRef(null); + const subtitleRef = useRef(null); + const [loading, setLoading] = useState(false); + const [allRecipientsData, setAllRecipientsData] = useState([]); + const [recipientsDataToDisplay, setRecipientsDataToDisplay] = useState([]); + const [selectedRecipientCheckBoxes, setSelectedRecipientCheckBoxes] = useState({}); + const [allRecipientsChecked, setAllRecipientsChecked] = useState(false); + const [resetPagination, setResetPagination] = useState(false); + const [perPage, setPerPage] = useState([RECIPIENTS_WITH_CLASS_SCORES_AND_GOALS_GOAL_PER_PAGE]); + + const defaultSort = { + sortBy: 'name', + direction: 'asc', + activePage: 1, + }; + + const { + handlePageChange, + requestSort, + exportRows, + sortConfig, + setSortConfig, + } = useWidgetPaging( + ['lastArStartDate', 'emotionalSupport', 'classroomOrganization', 'instructionalSupport', 'reportReceivedDate'], + 'recipientsWithClassScoresAndGoals', + defaultSort, + RECIPIENTS_WITH_CLASS_SCORES_AND_GOALS_GOAL_PER_PAGE, + allRecipientsData, // data to use. + setAllRecipientsData, + resetPagination, + setResetPagination, + loading, + selectedRecipientCheckBoxes, + 'recipientsWithClassScoresAndGoals', + setRecipientsDataToDisplay, + ['name'], + ['lastARStartDate', 'reportDeliveryDate'], + 'recipientsWithClassScoresAndGoals.csv', + 'dataForExport', + ); + + const perPageChange = (e) => { + const perPageValue = parseInt(e.target.value, DECIMAL_BASE); + setSortConfig({ + ...sortConfig, + activePage: 1, + offset: 0, + }); + + // Use splice to get the new data to display. + setRecipientsDataToDisplay(allRecipientsData.slice(0, perPageValue)); + setPerPage(perPageValue); + }; + + const setSortBy = (e) => { + const [sortBy, direction] = e.target.value.split('-'); + requestSort(sortBy, direction); + }; + + const getSubtitleWithPct = () => { + const totalRecipients = widgetData ? widgetData.total : 0; + const grants = widgetData ? widgetData['grants with class'] : 0; + const pct = widgetData ? widgetData['% recipients with class'] : 0; + const recipoientsWithClass = widgetData ? widgetData['recipients with class'] : 0; + return `${recipoientsWithClass} of ${totalRecipients} (${pct}%) recipients (${grants} grants)`; + }; + + const makeRecipientCheckboxes = (goalsArr, checked) => ( + goalsArr.reduce((obj, g) => ({ ...obj, [g.id]: checked }), {}) + ); + + const selectAllRecipientsCheckboxSelect = (event) => { + const { target: { checked = null } = {} } = event; + // Preserve checked recipients on other pages. + const thisPagesRecipientIds = allRecipientsData.map((g) => g.id); + const preservedCheckboxes = Object.keys(selectedRecipientCheckBoxes).reduce((obj, key) => { + if (!thisPagesRecipientIds.includes(parseInt(key, DECIMAL_BASE))) { + return { ...obj, [key]: selectedRecipientCheckBoxes[key] }; + } + return { ...obj }; + }, {}); + + if (checked === true) { + setSelectedRecipientCheckBoxes( + { + ...makeRecipientCheckboxes(allRecipientsData, true), ...preservedCheckboxes, + }, + ); + } else { + setSelectedRecipientCheckBoxes({ + ...makeRecipientCheckboxes(allRecipientsData, false), ...preservedCheckboxes, + }); + } + }; + + useEffect(() => { + try { + // Set local data. + setLoading(true); + setAllRecipientsData(pageData || []); + } finally { + setLoading(false); + } + }, [pageData]); + + useEffect(() => { + const recipientIds = allRecipientsData.map((g) => g.id); + const countOfCheckedOnThisPage = recipientIds.filter( + (id) => selectedRecipientCheckBoxes[id], + ).length; + if (allRecipientsData.length === countOfCheckedOnThisPage) { + setAllRecipientsChecked(true); + } else { + setAllRecipientsChecked(false); + } + }, [selectedRecipientCheckBoxes, allRecipientsData]); + + const handleRecipientCheckboxSelect = (event) => { + const { target: { checked = null, value = null } = {} } = event; + if (checked === true) { + setSelectedRecipientCheckBoxes({ ...selectedRecipientCheckBoxes, [value]: true }); + } else { + setSelectedRecipientCheckBoxes({ ...selectedRecipientCheckBoxes, [value]: false }); + } + }; + + const handleExportRows = () => { + const selectedRecipientIds = Object.keys( + selectedRecipientCheckBoxes, + ).filter((key) => selectedRecipientCheckBoxes[key]); + if (selectedRecipientIds.length > 0) { + exportRows('selected'); + } else { + exportRows('all'); + } + }; + + const selectedRecipientCheckBoxesCount = Object.keys(selectedRecipientCheckBoxes).filter( + (key) => selectedRecipientCheckBoxes[key], + ).length; + return ( + + + OHS standard CLASS® goals + + + + + + )} + SubtitleDrawer={( + <> +
+ + How are thresholds met? + + + + +
+

+ {getSubtitleWithPct()} +

+ + )} + className="padding-3" + displayPaginationBoxOutline + showHeaderBorder={false} + widgetContainerTitleClass="padding-top-2" + titleDrawerCss="smart-hub--qa-details--title-drawer" + > +
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + + + + + + +
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + + + + +
+
+
+
+ + {selectedRecipientCheckBoxesCount > 0 + && ( + + + {selectedRecipientCheckBoxesCount} + {' '} + selected + {' '} + + + + )} + { + selectedRecipientCheckBoxesCount > 0 && ( + + ) + } +
+ {recipientsDataToDisplay.map((r, index) => ( + + ))} +
+
+
+ ); +} + +RecipientsWithClassScoresAndGoalsWidget.propTypes = { + data: PropTypes.shape({ + widgetData: PropTypes.shape({ + total: PropTypes.number, + '% recipients with class': PropTypes.number, + 'recipients with class': PropTypes.number, + 'grants with class': PropTypes.number, + }), + pageData: PropTypes.oneOfType([ + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + lastArStartDate: PropTypes.string, + lastArEndDate: PropTypes.string, + emotionalSupport: PropTypes.number, + classroomOrganization: PropTypes.number, + instructionalSupport: PropTypes.number, + reportReceivedDate: PropTypes.string, + goals: PropTypes.arrayOf(PropTypes.shape({ + goalNumber: PropTypes.string, + status: PropTypes.string, + creator: PropTypes.string, + collaborator: PropTypes.string, + })), + }), + PropTypes.shape({}), + ]), + }), + parentLoading: PropTypes.bool.isRequired, +}; + +RecipientsWithClassScoresAndGoalsWidget.defaultProps = { + data: { headers: [], RecipientsWithOhsStandardFeiGoal: [] }, +}; + +export default RecipientsWithClassScoresAndGoalsWidget; diff --git a/frontend/src/widgets/RecipientsWithNoTtaWidget.js b/frontend/src/widgets/RecipientsWithNoTtaWidget.js new file mode 100644 index 0000000000..1c1ce8b955 --- /dev/null +++ b/frontend/src/widgets/RecipientsWithNoTtaWidget.js @@ -0,0 +1,149 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { RECIPIENTS_WITH_NO_TTA_PER_PAGE } from '../Constants'; +import HorizontalTableWidget from './HorizontalTableWidget'; +import WidgetContainer from '../components/WidgetContainer'; +import useWidgetPaging from '../hooks/useWidgetPaging'; + +const defaultSortConfig = { + sortBy: 'Days_Since_Last_TTA', + direction: 'desc', + activePage: 1, +}; + +function RecipientsWithNoTtaWidget({ + data, + loading, + resetPagination, + setResetPagination, +}) { + const { pageData, widgetData } = data; + const [allRecipientData, setAllRecipientData] = useState([]); + const [recipientCount, setRecipientCount] = useState(0); + const [localLoading, setLocalLoading] = useState(false); + const [recipientsPerPage, setRecipientsPerPage] = useState([]); + const [checkBoxes, setCheckBoxes] = useState({}); + + const { + offset, + activePage, + handlePageChange, + requestSort, + exportRows, + sortConfig, + } = useWidgetPaging( + pageData ? pageData.headers : [], + 'recipientsWithNoTta', + defaultSortConfig, + RECIPIENTS_WITH_NO_TTA_PER_PAGE, + allRecipientData, // data to use. + setAllRecipientData, + resetPagination, + setResetPagination, + loading, + checkBoxes, + 'RecipientsWithNoTta', + setRecipientsPerPage, + ['Recipient'], + ['Date_of_Last_TTA'], + 'recipientsWithNoTta.csv', + ); + + useEffect(() => { + try { + // Set local data. + setLocalLoading(true); + setAllRecipientData(pageData ? pageData.RecipientsWithNoTta : []); // TODO: Put this back. + setRecipientCount(pageData ? pageData.RecipientsWithNoTta.length : 0); + } finally { + setLocalLoading(false); + } + }, [pageData, widgetData]); + + const getSubtitleWithPct = () => { + const totalRecipients = widgetData ? widgetData.total : 0; + const recipientsWithoutTTA = widgetData ? widgetData['recipients without tta'] : 0; + const pct = widgetData ? widgetData['% recipients without tta'] : 0; + return `${recipientsWithoutTTA} of ${totalRecipients} (${pct}%) recipients`; + }; + + const menuItems = [ + { + label: 'Export selected rows', + onClick: () => { + exportRows('selected'); + }, + }, + { + label: 'Export table', + onClick: () => { + exportRows('all'); + }, + }, + ]; + + return ( + + + + ); +} + +RecipientsWithNoTtaWidget.propTypes = { + data: PropTypes.shape({ + pageData: PropTypes.oneOfType([ + PropTypes.shape({ + headers: PropTypes.arrayOf(PropTypes.string), + RecipientsWithNoTta: PropTypes.arrayOf( + PropTypes.shape({ + recipient: PropTypes.string, + dateOfLastTta: PropTypes.date, + daysSinceLastTta: PropTypes.number, + }), + ), + }), + PropTypes.shape({}), + ]), + widgetData: PropTypes.shape({ + 'recipients without tta': PropTypes.number, + total: PropTypes.number, + '% recipients without tta': PropTypes.number, + }), + }), + resetPagination: PropTypes.bool, + setResetPagination: PropTypes.func, + loading: PropTypes.bool.isRequired, +}; + +RecipientsWithNoTtaWidget.defaultProps = { + data: { headers: [], RecipientsWithNoTta: [] }, + resetPagination: false, + setResetPagination: () => {}, +}; + +export default RecipientsWithNoTtaWidget; diff --git a/frontend/src/widgets/RecipientsWithOhsStandardFeiGoalWidget.js b/frontend/src/widgets/RecipientsWithOhsStandardFeiGoalWidget.js new file mode 100644 index 0000000000..d83bd23f23 --- /dev/null +++ b/frontend/src/widgets/RecipientsWithOhsStandardFeiGoalWidget.js @@ -0,0 +1,197 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { RECIPIENTS_WITH_OHS_STANDARD_FEI_GOAL_PER_PAGE } from '../Constants'; +import HorizontalTableWidget from './HorizontalTableWidget'; +import WidgetContainer from '../components/WidgetContainer'; +import useWidgetPaging from '../hooks/useWidgetPaging'; +import DrawerTriggerButton from '../components/DrawerTriggerButton'; +import Drawer from '../components/Drawer'; +import ContentFromFeedByTag from '../components/ContentFromFeedByTag'; + +function RecipientsWithOhsStandardFeiGoalWidget({ + data, + loading, + resetPagination, + setResetPagination, +}) { + const { pageData, widgetData } = data; + const defaultSortConfig = { + sortBy: 'Goal_status', + direction: 'desc', + activePage: 1, + }; + + const [recipientDataToUse, setRecipientDataToUse] = useState([]); + const [recipientCount, setRecipientCount] = useState(0); + const [localLoading, setLocalLoading] = useState(false); + const [recipientsPerPage, setRecipientsPerPage] = useState([]); + const [checkBoxes, setCheckBoxes] = useState({}); + + const titleDrawerRef = useRef(null); + const subtitleDrawerLinkRef = useRef(null); + + const { + offset, + activePage, + handlePageChange, + requestSort, + exportRows, + sortConfig, + } = useWidgetPaging( + pageData ? pageData.headers : [], + 'recipientsWithOhsStandardFeiGoal', + defaultSortConfig, + RECIPIENTS_WITH_OHS_STANDARD_FEI_GOAL_PER_PAGE, + recipientDataToUse, // Data to use. + setRecipientDataToUse, + resetPagination, + setResetPagination, + loading, + checkBoxes, + 'RecipientsWithOhsStandardFeiGoal', + setRecipientsPerPage, + ['Recipient', 'Goal_number', 'Goal_status', 'Root_cause'], + ['Goal_created_on'], + 'recipientsWithOhsStandardFeiGoal.csv', + null, + ['Goal_status'], + ); + + useEffect(() => { + try { + // Set local data. + setLocalLoading(true); + const recipientToUse = pageData ? pageData.RecipientsWithOhsStandardFeiGoal : []; + setRecipientDataToUse(recipientToUse); + setRecipientCount(recipientToUse.length); + } finally { + setLocalLoading(false); + } + }, [pageData, widgetData]); + + const getSubtitleWithPct = () => { + const totalRecipients = widgetData ? widgetData.total : 0; + const pct = widgetData ? widgetData['% recipients with fei'] : 0; + const recipientsWithFei = widgetData ? widgetData['recipients with fei'] : 0; + const numberOfGrants = widgetData ? widgetData['grants with fei'] : 0; + return `${recipientsWithFei} of ${totalRecipients} (${pct}%) recipients (${numberOfGrants} grants)`; + }; + + const menuItems = [ + { + label: 'Export selected rows', + onClick: () => { + exportRows('selected'); + }, + }, + { + label: 'Export table', + onClick: () => { + exportRows('all'); + }, + }, + ]; + + return ( + <> + + + OHS standard FEI goal + + + + + + )} + SubtitleDrawer={( +
+ + Learn about root causes + + + + +
+ )} + enableCheckboxes + exportRows={exportRows} + > + +
+ + ); +} + +RecipientsWithOhsStandardFeiGoalWidget.propTypes = { + data: PropTypes.shape({ + pageData: PropTypes.oneOfType([ + PropTypes.shape({ + headers: PropTypes.arrayOf(PropTypes.string), + RecipientsWithOhsStandardFeiGoal: PropTypes.arrayOf( + PropTypes.shape({ + recipientName: PropTypes.string, + goalNumber: PropTypes.number, + goalStatus: PropTypes.string, + rootCause: PropTypes.string, + createdAt: PropTypes.string, + }), + ), + }), + PropTypes.shape({}), + ]), + widgetData: PropTypes.shape({ + 'recipients with fei': PropTypes.number, + total: PropTypes.number, + '% recipients with fei': PropTypes.number, + 'grants with fei': PropTypes.number, + }), + }), + resetPagination: PropTypes.bool, + setResetPagination: PropTypes.func, + loading: PropTypes.bool.isRequired, +}; + +RecipientsWithOhsStandardFeiGoalWidget.defaultProps = { + data: { headers: [], RecipientsWithOhsStandardFeiGoal: [] }, + resetPagination: false, + setResetPagination: () => {}, +}; + +export default RecipientsWithOhsStandardFeiGoalWidget; diff --git a/frontend/src/widgets/RegionalGoalDashboard/__tests__/GoalStatusChart.js b/frontend/src/widgets/RegionalGoalDashboard/__tests__/GoalStatusChart.js new file mode 100644 index 0000000000..b7e46d6ff7 --- /dev/null +++ b/frontend/src/widgets/RegionalGoalDashboard/__tests__/GoalStatusChart.js @@ -0,0 +1,24 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import fetchMock from 'fetch-mock'; +import { render, screen, act } from '@testing-library/react'; +import GoalStatusChart from '../GoalStatusChart'; + +describe('GoalStatusChart', () => { + it('renders', async () => { + fetchMock.get('/api/widgets/goalsByStatus?', { + total: 3, + 'Not Started': 1, + 'In Progress': 1, + Closed: 1, + 'Ceased/Suspended': 0, + }); + + act(() => { + render(); + }); + + expect(await screen.findByText('Number of goals by status')).toBeInTheDocument(); + expect(fetchMock.called()).toBe(true); + }); +}); diff --git a/frontend/src/widgets/ResourcesDashboardOverview.css b/frontend/src/widgets/ResourcesDashboardOverview.css deleted file mode 100644 index 03d27f5acb..0000000000 --- a/frontend/src/widgets/ResourcesDashboardOverview.css +++ /dev/null @@ -1,26 +0,0 @@ -@media( min-width: 640px){ - .smart-hub--resources-dashboard-overview { - column-gap: 1rem; - row-gap:.5rem; - } -} - -@media( min-width: 1024px ){ - .smart-hub--resources-dashboard-overview { - gap: 1em; - } - .smart-hub--resources-dashboard-overview-field { - margin: 0; - } -} - -.smart-hub--resources-dashboard-overview-field-icon-background { - border-radius: 50%; - height: 3em; - width: 3em; -} - - -.smart-hub--resources-dashboard-overview-field .smart-hub--ellipsis { - max-width: 200px; -} diff --git a/frontend/src/widgets/ResourcesDashboardOverview.js b/frontend/src/widgets/ResourcesDashboardOverview.js index 5d0bbead26..a4516a5897 100644 --- a/frontend/src/widgets/ResourcesDashboardOverview.js +++ b/frontend/src/widgets/ResourcesDashboardOverview.js @@ -1,8 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Grid } from '@trussworks/react-uswds'; -import { Link } from 'react-router-dom'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faLink, faCube, @@ -10,166 +7,73 @@ import { faUserFriends, faFolder, } from '@fortawesome/free-solid-svg-icons'; -import './ResourcesDashboardOverview.css'; - -import Loader from '../components/Loader'; -import Tooltip from '../components/Tooltip'; +import { DashboardOverviewContainer } from './DashboardOverviewContainer'; import colors from '../colors'; -export function Field({ - label1, - label2, - route, - data, - icon, - iconColor, - backgroundColor, - showTooltip, - tooltipText, -}) { - return ( - - - - - - - - {data} - {showTooltip ? ( - - ) : ( - {label1} - )} - {label2} - {route && ( - - {route.label} - - )} - - - ); -} - -Field.propTypes = { - label1: PropTypes.string.isRequired, - label2: PropTypes.string, - data: PropTypes.string.isRequired, - icon: PropTypes.shape({ - prefix: PropTypes.string, - iconName: PropTypes.string, - // eslint-disable-next-line react/forbid-prop-types - icon: PropTypes.array, - }).isRequired, - iconColor: PropTypes.string.isRequired, - backgroundColor: PropTypes.string.isRequired, - tooltipText: PropTypes.string, - showTooltip: PropTypes.bool, - route: PropTypes.shape({ - to: PropTypes.string, - label: PropTypes.string, - }), -}; - -Field.defaultProps = { - tooltipText: '', - showTooltip: false, - label2: '', - route: null, -}; -const DASHBOARD_FIELDS = { - 'Reports with resources': { - render: (data, showTooltip) => ( - - ), +const createOverviewFieldArray = (data) => ([ + { + key: 'report-resources', + icon: faLink, + showTooltip: true, + label1: 'Reports with resources', + label2: `${data.report.numResources} of ${data.report.num}`, + iconColor: colors.success, + backgroundColor: colors.ttahubDeepTealLight, + tooltipText: "AR's that cite at least one resource", + data: data.report.percentResources, }, - 'ECLKC Resources': { - render: (data, showTooltip) => ( - - ), + { + key: 'eclkc-resources', + icon: faCube, + showTooltip: true, + label1: 'ECLKC resources', + label2: `${data.resource.numEclkc} of ${data.resource.num}`, + iconColor: colors.ttahubBlue, + backgroundColor: colors.ttahubBlueLight, + tooltipText: 'Percentage of all cited resources that are from ECLKC', + data: data.resource.percentEclkc, }, - 'Recipients reached': { - render: (data, showTooltip) => ( - - ), + { + key: 'recipient-reached', + icon: faUser, + showTooltip: true, + label1: 'Recipients reached', + iconColor: colors.ttahubMagenta, + backgroundColor: colors.ttahubMagentaLight, + tooltipText: 'Total recipients of ARs that cite at least one resource', + data: data.recipient.numResources, }, - 'Participants reached': { - render: (data, showTooltip) => ( - - ), + { + key: 'participants-reached', + icon: faUserFriends, + showTooltip: true, + label1: 'Participants reached', + iconColor: colors.ttahubOrange, + backgroundColor: colors.ttahubOrangeLight, + tooltipText: 'Total participants of ARs that cite at least one resource', + data: data.participant.numParticipants, }, - 'Reports citing iPD courses': { - render: (data) => ( - - ), + { + key: 'reports-citing-ipd-courses', + icon: faFolder, + showTooltip: false, + label1: 'Reports citing iPD courses', + iconColor: colors.baseDark, + backgroundColor: colors.baseLightest, + tooltipText: 'Total participants of ARs that cite at least one resource', + data: data.ipdCourses.percentReports, + route: 'ipd-courses', }, -}; +]); export function ResourcesDashboardOverviewWidget({ - data, loading, fields, showTooltips, + data, loading, }) { return ( - - - { fields.map((field) => DASHBOARD_FIELDS[field].render(data, showTooltips, field)) } - + ); } @@ -194,8 +98,6 @@ ResourcesDashboardOverviewWidget.propTypes = { }), loading: PropTypes.bool, - fields: PropTypes.arrayOf(PropTypes.string), - showTooltips: PropTypes.bool, }; ResourcesDashboardOverviewWidget.defaultProps = { @@ -221,14 +123,6 @@ ResourcesDashboardOverviewWidget.defaultProps = { }, }, loading: false, - showTooltips: false, - fields: [ - 'Reports with resources', - 'ECLKC Resources', - 'Recipients reached', - 'Participants reached', - 'Reports citing iPD courses', - ], }; export default ResourcesDashboardOverviewWidget; diff --git a/frontend/src/widgets/RootCauseFeiGoals.css b/frontend/src/widgets/RootCauseFeiGoals.css new file mode 100644 index 0000000000..d64b8beca9 --- /dev/null +++ b/frontend/src/widgets/RootCauseFeiGoals.css @@ -0,0 +1,9 @@ +.tta-qa-dashboard-percentage-ars-by-role { + height: 500px; +} + +@media (min-width: 1070px) { + .tta-qa-dashboard-percentage-ars-by-role { + height: 475px; + } +} diff --git a/frontend/src/widgets/RootCauseFeiGoals.js b/frontend/src/widgets/RootCauseFeiGoals.js new file mode 100644 index 0000000000..70637e9317 --- /dev/null +++ b/frontend/src/widgets/RootCauseFeiGoals.js @@ -0,0 +1,202 @@ +import React, { + useRef, + useEffect, + useState, +} from 'react'; +import PropTypes from 'prop-types'; +import WidgetContainer from '../components/WidgetContainer'; +import HorizontalTableWidget from './HorizontalTableWidget'; +import useMediaCapture from '../hooks/useMediaCapture'; +import useWidgetSorting from '../hooks/useWidgetSorting'; +import useWidgetExport from '../hooks/useWidgetExport'; +import useWidgetMenuItems from '../hooks/useWidgetMenuItems'; +import { EMPTY_ARRAY } from '../Constants'; +import BarGraph from './BarGraph'; +import './RootCauseFeiGoals.css'; + +const FIRST_COLUMN = 'Root cause'; + +const TABLE_HEADINGS = [ + 'Number', + 'Percentage', +]; + +const DEFAULT_SORT_CONFIG = { + sortBy: 'Root_cause', + direction: 'asc', + activePage: 1, +}; + +export default function RootCauseFeiGoals({ data }) { + const widgetRef = useRef(null); + const capture = useMediaCapture(widgetRef, 'RootCauseOnFeiGoals'); + const [showTabularData, setShowTabularData] = useState(false); + const [checkboxes, setCheckboxes] = useState({}); + + // we have to store this is in state, despite + // it being a prop, because of other dependencies + // in the react graph + const [trace, setTrace] = useState([]); + const [tabularData, setTabularData] = useState([]); + const [totals, setTotals] = useState({ + totalNumberOfGoals: 0, + totalNumberOfRootCauses: 0, + }); + const [showFiltersNotApplicable, setShowFiltersNotApplicable] = useState(false); + + const { + requestSort, + sortConfig, + } = useWidgetSorting( + 'qa-dashboard-percentage-ars-by-role', // localStorageKey + DEFAULT_SORT_CONFIG, // defaultSortConfig + tabularData, // dataToUse + setTabularData, // setDataToUse + ['Root_cause'], // stringColumns + EMPTY_ARRAY, // dateColumns + EMPTY_ARRAY, // keyColumns + ); + + const { exportRows } = useWidgetExport( + tabularData, + TABLE_HEADINGS, + checkboxes, + FIRST_COLUMN, + 'PercentageARSByRole', + ); + + // records is an array of objects + // and the other fields need to be converted to camelCase + useEffect(() => { + if (!data) { + setTabularData([]); + setTrace([]); + setTotals({ + totalNumberOfGoals: 0, + totalNumberOfRootCauses: 0, + }); + return; + } + // take the API data + // and transform it into the format + // that the LineGraph component expects + // (an object for each trace) + // and the table (an array of objects in the format defined by proptypes) + const { + records, + totalNumberOfGoals, + totalNumberOfRootCauses, + showDashboardFiltersNotApplicable: showDashboardFiltersNotApplicableProp, + } = data; + + const tableData = []; + const traceData = []; + + (records || []).forEach((dataset, index) => { + traceData.push({ + category: dataset.rootCause, + count: dataset.percentage, + }); + + tableData.push({ + heading: dataset.rootCause, + id: `${dataset.rootCause} - ${index + 1}`, + data: [ + { + value: dataset.response_count, + title: 'Root cause', + sortKey: 'Root_cause', + }, + { + value: `${String(dataset.percentage)}%`, + title: 'Number', + sortKey: 'Number', + }, + ], + }); + }); + + // Sort traceData by rootCause in descending order + traceData.sort((a, b) => b.category.localeCompare(a.category)); + setShowFiltersNotApplicable(showDashboardFiltersNotApplicableProp); + setTrace(traceData); + setTabularData(tableData); + setTotals({ + totalNumberOfGoals, + totalNumberOfRootCauses, + }); + }, [data]); + // end use effect + + const menuItems = useWidgetMenuItems( + showTabularData, + setShowTabularData, + capture, + checkboxes, + exportRows, + ); + + return ( +
+ + {showTabularData ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} + +RootCauseFeiGoals.propTypes = { + data: PropTypes.shape({ + totalNumberOfGoals: PropTypes.number, + totalNumberOfRootCauses: PropTypes.number, + showDashboardFiltersNotApplicable: PropTypes.bool, + records: PropTypes.arrayOf(PropTypes.shape({ + rootCause: PropTypes.string, + response_count: PropTypes.number, + percentage: PropTypes.number, + })), + }).isRequired, +}; diff --git a/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js b/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js index 250bd127e6..1ed19484f1 100644 --- a/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js +++ b/frontend/src/widgets/TRHoursOfTrainingByNationalCenter.js @@ -1,4 +1,89 @@ +import React, { useRef, useState } from 'react'; +import PropTypes from 'prop-types'; import VBarGraph from './VBarGraph'; import withWidgetData from './withWidgetData'; +import WidgetContainer from '../components/WidgetContainer'; +import HorizontalTableWidget from './HorizontalTableWidget'; +import useMediaCapture from '../hooks/useMediaCapture'; +import { NOOP } from '../Constants'; -export default withWidgetData(VBarGraph, 'trHoursOfTrainingByNationalCenter'); +const FIRST_HEADING = 'National Center'; +const HEADINGS = ['Hours']; + +const TRHoursWidget = ({ + data, +}) => { + const widgetRef = useRef(null); + const [showTabularData, setShowTabularData] = useState(false); + const capture = useMediaCapture(widgetRef, 'Total TTA hours'); + + const menuItems = [{ + label: showTabularData ? 'Display graph' : 'Display table', + onClick: () => setShowTabularData(!showTabularData), + }]; + + if (!showTabularData) { + menuItems.push({ + label: 'Save screenshot', + onClick: capture, + }); + } + + const tabularData = data.map((row, index) => ({ + heading: row.name, + id: index + 1, + data: [ + { + value: row.count, + title: 'Hours', + sortKey: 'Hours', + }, + ], + })); + + return ( + + {showTabularData ? ( +
+ +
+ ) : ( + + )} +
+ ); +}; + +TRHoursWidget.propTypes = { + data: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + count: PropTypes.number, + })), +}; + +TRHoursWidget.defaultProps = { + data: [], +}; + +export default withWidgetData(TRHoursWidget, 'trHoursOfTrainingByNationalCenter'); diff --git a/frontend/src/widgets/TotalHrsAndRecipientGraph.js b/frontend/src/widgets/TotalHrsAndRecipientGraph.js index 0230d2c5be..0f5bb3e9f4 100644 --- a/frontend/src/widgets/TotalHrsAndRecipientGraph.js +++ b/frontend/src/widgets/TotalHrsAndRecipientGraph.js @@ -1,273 +1,97 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import Plotly from 'plotly.js-basic-dist'; -import { Grid } from '@trussworks/react-uswds'; -import { DECIMAL_BASE } from '@ttahub/common'; import withWidgetData from './withWidgetData'; -import AccessibleWidgetData from './AccessibleWidgetData'; -import MediaCaptureButton from '../components/MediaCaptureButton'; -import Container from '../components/Container'; -import colors from '../colors'; -import './TotalHrsAndRecipientGraph.scss'; +import LineGraph from './LineGraph'; +import WidgetContainer from '../components/WidgetContainer'; +import useMediaCapture from '../hooks/useMediaCapture'; +import { arrayExistsAndHasLength, NOOP } from '../Constants'; -const HOVER_TEMPLATE = '(%{x}, %{y})'; +export function TotalHrsAndRecipientGraph({ data, hideYAxis }) { + const widgetRef = useRef(null); + const capture = useMediaCapture(widgetRef, 'Total TTA hours'); + const [showTabularData, setShowTabularData] = useState(false); -export function TotalHrsAndRecipientGraph({ data, loading, hideYAxis }) { - // the state for which lines to show - const [showTA, setShowTA] = useState(true); - const [showTraining, setShowTraining] = useState(true); - const [showBoth, setShowBoth] = useState(true); - - // the dom el for drawing the chart - const lines = useRef(); - - // the dom el for the widget - const widget = useRef(); - - const [showAccessibleData, setShowAccessibleData] = useState(false); const [columnHeadings, setColumnHeadings] = useState([]); const [tableRows, setTableRows] = useState([]); useEffect(() => { - if (!lines || !data || !Array.isArray(data) || showAccessibleData) { + if (!arrayExistsAndHasLength(data)) { return; } - /* - Data: The below is a breakdown of the Traces widget data array. - data[0]: Hours of Training - data[1]: Hours of Technical Assistance - data[2]: Hours of Both - */ - - // these are ordered from left to right how they appear in the checkboxes/legend - const traces = [ - { - // Technical Assistance - type: 'scatter', - mode: 'lines+markers', - x: data[1].x, - y: data[1].y, - hovertemplate: HOVER_TEMPLATE, - line: { - dash: 'solid', - width: 3, - // color: '#2e4a62', - color: colors.ttahubBlue, - }, - marker: { - size: 12, - }, - hoverlabel: { - font: { color: '#ffffff', size: '16' }, - bgcolor: colors.textInk, - }, - }, - { - // Training - type: 'scatter', - mode: 'lines+markers', - x: data[0].x, - y: data[0].y, - hovertemplate: HOVER_TEMPLATE, - line: { - dash: 'dash', - width: 3, - color: colors.ttahubOrange, - }, - marker: { - size: 14, - symbol: 'triangle-up', - }, - hoverlabel: { - font: { color: '#ffffff', size: '16' }, - bgcolor: colors.textInk, - }, - }, - - // Both - { - type: 'scatter', - mode: 'lines+markers', - x: data[2].x, - y: data[2].y, - hovertemplate: HOVER_TEMPLATE, - line: { - dash: 'longdash', - width: 3, - color: colors.ttahubMediumDeepTeal, - }, - marker: { - symbol: 'square', - size: 12, - }, - hoverlabel: { - font: { color: '#ffffff', size: '16' }, - bgcolor: colors.textInk, - }, - }]; - - const xTickStep = (() => { - const value = data[0].x.length; - let divisor = value; - if (value > 12) { - divisor = 6; - } - - if (value > 24) { - divisor = 4; - } - - return parseInt(value / divisor, DECIMAL_BASE); - } - )(); - - // Specify Chart Layout. - const layout = { - height: 320, - hoverlabel: { - bgcolor: '#fff', - bordercolor: '#fff', - font: { - color: '#fff', - }, - }, - font: { - color: colors.smartHubTextInk, - }, - margin: { - l: 50, - t: 0, - r: 0, - b: 68, - }, - showlegend: false, - xaxis: { - showgrid: false, - hovermode: 'closest', - autotick: false, - ticks: 'outside', - tick0: 0, - dtick: xTickStep, - ticklen: 5, - tickwidth: 1, - tickcolor: '#000', - title: { - text: 'Date range', - standoff: 40, - font: { - family: 'Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif', - size: 18, - color: colors.smartHubTextInk, - }, - }, - }, - yaxis: { - automargin: true, - rangemode: 'tozero', - tickwidth: 1, - tickcolor: 'transparent', - tickformat: (n) => { - // if not a whole number, round to 1 decimal place - if (n % 1 !== 0) { - return '.1f'; - } - return ','; - }, - title: { - standoff: 20, - text: 'Number of hours', - font: { - family: 'Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif', - size: 18, - color: colors.smartHubTextInk, - }, - }, - }, - }; - - if (hideYAxis) { - layout.yaxis = { - ...layout.yaxis, - showline: false, - autotick: true, - ticks: '', - showticklabels: false, - }; - } - - // showTA, showTraining, showBoth - // if false, then its a null for me dude - // and then away it goes - // these are ordered in the same order as the legend - const tracesToDraw = [showTA, showTraining, showBoth] - .map((trace, index) => (trace ? traces[index] : null)) - .filter((trace) => trace !== null); - - // draw the plot - Plotly.newPlot(lines.current, tracesToDraw, layout, { displayModeBar: false, hovermode: 'none', responsive: true }); - }, [data, hideYAxis, showAccessibleData, showBoth, showTA, showTraining]); - - useEffect(() => { - if (!lines || !data || !Array.isArray(data) || !showAccessibleData) { - return; - } - - const headings = ['TTA Provided', ...data[0].x.map((x, index) => { + const headings = data[0].x.map((x, index) => { if (data[0].month[index]) { return `${data[0].month[index]} ${x}`; } return x; - })]; + }); const rows = data.map((row) => ({ heading: row.name, - data: row.y.map((y) => `${(Math.round(y * 10) / 10).toString()}`), + data: row.y.map((y) => ({ + title: row.name, + value: `${(Math.round(y * 10) / 10).toString()}`, + })), })); setColumnHeadings(headings); setTableRows(rows); - }, [data, showAccessibleData]); - - function toggleType() { - setShowAccessibleData(!showAccessibleData); + }, [data]); + + const menuItems = [{ + label: showTabularData ? 'Display graph' : 'Display table', + onClick: () => setShowTabularData(!showTabularData), + }]; + + if (!showTabularData) { + menuItems.push({ + label: 'Save screenshot', + onClick: capture, + id: 'rd-save-screenshot', + }); } return ( - -
- -

Total TTA hours

- - { !showAccessibleData && } - - -
- - { showAccessibleData - ? - : ( -
-
- Toggle individual lines by checking or unchecking a legend item. - - - -
-
-
- )} -
- + + + ); } @@ -281,7 +105,6 @@ TotalHrsAndRecipientGraph.propTypes = { }), ), PropTypes.shape({}), ]), - loading: PropTypes.bool.isRequired, hideYAxis: PropTypes.bool, }; @@ -289,57 +112,15 @@ TotalHrsAndRecipientGraph.defaultProps = { hideYAxis: false, data: [ { - name: 'Hours of Training', x: [], y: [], month: '', + name: 'Training', x: [], y: [], month: '', }, { - name: 'Hours of Technical Assistance', x: [], y: [], month: '', + name: 'Technical Assistance', x: [], y: [], month: '', }, { - name: 'Hours of Both', x: [], y: [], month: '', + name: 'Both', x: [], y: [], month: '', }, ], }; -/** - * the legend control for the graph (input, span, line) - * @param {props} object - * @returns A jsx element - */ -export function LegendControl({ - label, id, selected, setSelected, shape, -}) { - function handleChange() { - setSelected(!selected); - } - - return ( -
- - -
- ); -} - -LegendControl.propTypes = { - label: PropTypes.string.isRequired, - id: PropTypes.string.isRequired, - selected: PropTypes.bool.isRequired, - setSelected: PropTypes.func.isRequired, - shape: PropTypes.string.isRequired, -}; - export default withWidgetData(TotalHrsAndRecipientGraph, 'totalHrsAndRecipientGraph'); diff --git a/frontend/src/widgets/TotalHrsAndRecipientGraph.scss b/frontend/src/widgets/TotalHrsAndRecipientGraph.scss deleted file mode 100644 index e095121af3..0000000000 --- a/frontend/src/widgets/TotalHrsAndRecipientGraph.scss +++ /dev/null @@ -1,102 +0,0 @@ -@use '../colors.scss' as *; - -@media(min-width: 900px){ - .ttahub-total-hours-container .ttahub--show-accessible-data-button { - flex: 1; - text-align: right; - } -} - -.ttahub--total-hrs-recipient-graph-legend label { - font-size: 16px; - padding-right: 32px; -} - -.ttahub-total-hours-container { - height: calc( 100% - 1.5rem); -} - -.ttahub--total-hrs-recipient-graph-legend label::after { - height: 3px; - content: " "; - width: 80px; - display: inline-block; - margin-left: 10px; - margin-bottom: 2px; -} - -.ttahub--total-hrs-recipient-graph-legend div:nth-child(2) label::after { - background-color: $ttahub-blue; /* Technical assistance */ -} - -.ttahub--total-hrs-recipient-graph-legend div:nth-child(3) label::after { - border-top: 3px dashed $ttahub-orange; /* training */ - height: 3px; - background-color: #ffffff00; -} - -.ttahub--total-hrs-recipient-graph-legend div:nth-child(4) label::after { - border-top: 3px dashed $ttahub-medium-deep-teal; /* both */ - height: 3px; - background-color: #ffffff00; -} - -.ttahub--total-hrs-recipient-graph-legend .usa-checkbox::after { - content: ''; - height: 9px; - position: absolute; - right: 4.2rem; - top: 7px; - width: 9px; -} - -/* both */ -.ttahub--total-hrs-recipient-graph-legend .usa-checkbox.square::after { - background: $ttahub-medium-deep-teal; -} - -/* technical assistance */ -.ttahub--total-hrs-recipient-graph-legend .usa-checkbox.circle::after { - background: $ttahub-blue; - border-radius: 50%; - height: 12px; - width: 12px; -} - -/* training */ -.ttahub--total-hrs-recipient-graph-legend .usa-checkbox.triangle::after { - border-left: 10px solid transparent; - border-right: 10px solid transparent; - border-bottom: 10px solid $ttahub-orange; - height: 0; - width: 0; - right: 4rem; - z-index: 1; -} - -/* this adds a white line around the triangle to make it stand out a bit more*/ -.ttahub--total-hrs-recipient-graph-legend .usa-checkbox.triangle::before { - content: ''; - border-left: 11px solid transparent; - border-right: 11px solid transparent; - border-bottom: 11px solid white; - height: 0; - width: 0; - position: absolute; - right: calc(4rem - 1px); - top: 6px; - z-index: 1; -} - -@media(max-width: 1606px) { - .ttahub-legend-control { - margin-top: .5rem; - margin-bottom: .5rem; - } -} - -@media(max-width: 1500px){ - .ttahub--total-hrs-recipient-graph-apply-filters-container { - width: 100%; - } -} diff --git a/frontend/src/widgets/VBarGraph.js b/frontend/src/widgets/VBarGraph.js index 352412ddcb..10dda4835e 100644 --- a/frontend/src/widgets/VBarGraph.js +++ b/frontend/src/widgets/VBarGraph.js @@ -1,17 +1,12 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } 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 NoResultsFound from '../components/NoResultsFound'; import './VBarGraph.css'; -import DisplayTableToggle from '../components/DisplayTableToggleButton'; const Plot = createPlotlyComponent(Plotly); @@ -19,16 +14,11 @@ function VBarGraph({ data, yAxisLabel, xAxisLabel, - title, - subtitle, - loading, - loadingLabel, + widgetRef, + widthOffset, }) { const [plot, updatePlot] = useState({}); - const bars = useRef(null); - const [showAccessibleData, updateShowAccessibleData] = useState(false); - - const size = useSize(bars); + const size = useSize(data.length > 0 ? widgetRef : null); useEffect(() => { if (!data || !Array.isArray(data) || !size) { @@ -56,7 +46,7 @@ function VBarGraph({ const layout = { bargap: 0.5, height: 350, - width: size.width - 40, + width: (size.width - widthOffset), hoverlabel: { bgcolor: '#000', bordercolor: '#000', @@ -102,65 +92,24 @@ function VBarGraph({ responsive: true, displayModeBar: false, hovermode: 'none', }, }); - }, [data, xAxisLabel, size, yAxisLabel]); + }, [data, xAxisLabel, size, yAxisLabel, widthOffset]); - const tableData = data.map((row) => ({ - data: [ - row.name, - parseFloat(row.count).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }), - ], - })); + if (!data || data.length === 0) { + return ( +
+ +
+ ); + } return ( - - -
-
- - {title} - -

{subtitle}

-
- {!showAccessibleData - ? ( - - ) - : null} - -
- -
- { showAccessibleData - ? ( - - ) - : ( - <> -
- - -
- - )} -
- +
+ +
); } @@ -173,18 +122,13 @@ VBarGraph.propTypes = { ), yAxisLabel: PropTypes.string.isRequired, xAxisLabel: PropTypes.string.isRequired, - title: PropTypes.string, - subtitle: PropTypes.string, - loading: PropTypes.bool, - loadingLabel: PropTypes.string, + widgetRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }).isRequired, + widthOffset: PropTypes.number, }; VBarGraph.defaultProps = { data: [], - title: 'Vertical Bar Graph', - subtitle: '', - loading: false, - loadingLabel: 'Vertical Bar Graph Loading', + widthOffset: 40, }; export default VBarGraph; diff --git a/frontend/src/widgets/__tests__/BarGraph.js b/frontend/src/widgets/__tests__/BarGraph.js index 82d7712ce0..2645fedcb1 100644 --- a/frontend/src/widgets/__tests__/BarGraph.js +++ b/frontend/src/widgets/__tests__/BarGraph.js @@ -1,9 +1,10 @@ import '@testing-library/jest-dom'; -import React from 'react'; +import React, { createRef } from 'react'; import { render, waitFor, act, + screen, } from '@testing-library/react'; import BarGraph from '../BarGraph'; @@ -20,13 +21,17 @@ const TEST_DATA = [{ count: 0, }]; -const renderBarGraph = async () => { +const renderBarGraph = (data = TEST_DATA) => { act(() => { - render(); + render(); }); }; describe('Bar Graph', () => { + it('handles null data', () => { + renderBarGraph(null); + expect(document.querySelector('svg')).toBe(null); + }); it('is shown', async () => { renderBarGraph(); @@ -40,4 +45,14 @@ describe('Bar Graph', () => { // eslint-disable-next-line no-underscore-dangle expect(point2.__data__.text).toBe('0'); }); + + it('shows no results found', async () => { + renderBarGraph([]); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /no results found\./i })).toBeDefined(); + expect(screen.getByText('Try removing or changing the selected filters.')).toBeDefined(); + expect(screen.getByText('Get help using filters')).toBeDefined(); + }); + }); }); diff --git a/frontend/src/widgets/__tests__/DashboardOverview.js b/frontend/src/widgets/__tests__/DashboardOverview.js index 0cd08ecaf2..9ea6abac9d 100644 --- a/frontend/src/widgets/__tests__/DashboardOverview.js +++ b/frontend/src/widgets/__tests__/DashboardOverview.js @@ -21,9 +21,18 @@ const baseFields = [ 'In person activities', ]; -const renderDashboardOverview = (props) => { - const fields = props.fields || baseFields; - render(); +const renderDashboardOverview = ({ + fields = baseFields, + data = baseData, + loading = false, + showTooltips = false, +}) => { + render(); }; describe('Dashboard Overview Widget', () => { @@ -74,4 +83,9 @@ describe('Dashboard Overview Widget', () => { renderDashboardOverview({ data, fields }); expect(screen.getByText(/2 recipients/i)).toBeInTheDocument(); }); + + it('shows tooltips', async () => { + renderDashboardOverview({ showTooltips: true }); + expect(screen.getAllByTestId('tooltip')).toHaveLength(baseFields.length); + }); }); diff --git a/frontend/src/widgets/__tests__/HorizontalTableWidget.js b/frontend/src/widgets/__tests__/HorizontalTableWidget.js index edf5d59119..45a643fbad 100644 --- a/frontend/src/widgets/__tests__/HorizontalTableWidget.js +++ b/frontend/src/widgets/__tests__/HorizontalTableWidget.js @@ -1,11 +1,15 @@ import '@testing-library/jest-dom'; +import { Router } from 'react-router'; import React from 'react'; import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; import HorizontalTableWidget from '../HorizontalTableWidget'; +const history = createMemoryHistory(); + const renderHorizontalTableWidget = ( headers = [], data = [], @@ -15,17 +19,21 @@ const renderHorizontalTableWidget = ( sortConfig = {}, requestSort = () => {}, enableCheckboxes = false, + showTotalColumn = true, ) => render( - , + + + , ); describe('Horizontal Table Widget', () => { @@ -102,6 +110,37 @@ describe('Horizontal Table Widget', () => { expect(container.querySelector('.fa-arrow-up-right-from-square')).toBeInTheDocument(); }); + it('correctly renders link when isInternalLink is true', async () => { + const headers = ['col1', 'col2', 'col3']; + const data = [ + { + heading: 'Row 1 Data', + link: 'internal link 1', + isUrl: true, + isInternalLink: true, + data: [ + { + title: 'col1', + value: '17', + }, + { + title: 'col2', + value: '18', + }, + { + title: 'col3', + value: '19', + }, + ], + }, + ]; + + renderHorizontalTableWidget(headers, data); + // find element a with href of href="/internal link 1". + const url = screen.getByText(/Row 1 Data/i); + expect(url).toHaveAttribute('href', '/internal link 1'); + }); + it('renders with sorting', async () => { const headers = ['col1']; const data = [ @@ -184,6 +223,58 @@ describe('Horizontal Table Widget', () => { expect(sortElement).toHaveClass('sortable asc'); }); + it('properly displays a internal link in the table', async () => { + const headers = ['col1']; + const data = [ + { + heading: 'Row 1 Data', + isUrl: false, + data: [ + { + title: 'col1', + value: 'Test Link', + isUrl: true, + link: 'example.com', + isInternalLink: true, + hideLinkIcon: true, + }, + ], + }, + ]; + + renderHorizontalTableWidget(headers, data, 'First Heading', false, 'Last Heading', {}, {}, false, false); + expect(screen.getByText(/First Heading/i)).toBeInTheDocument(); + expect(screen.getByText(/Row 1 Data/i)).toBeInTheDocument(); + const url = screen.getByText(/Test Link/i); + expect(url).toHaveAttribute('href', '/example.com'); + }); + + it('properly displays a external link in the table', async () => { + const headers = ['col1']; + const data = [ + { + heading: 'Row 1 Data', + isUrl: false, + data: [ + { + title: 'col1', + value: 'Test Link', + isUrl: true, + link: 'http://external.example.com', + isInternalLink: false, + hideLinkIcon: true, + }, + ], + }, + ]; + + renderHorizontalTableWidget(headers, data, 'First Heading', false, 'Last Heading', {}, {}, false, false); + expect(screen.getByText(/First Heading/i)).toBeInTheDocument(); + expect(screen.getByText(/Row 1 Data/i)).toBeInTheDocument(); + const url = screen.getByText(/Test Link/i); + expect(url).toHaveAttribute('href', 'http://external.example.com'); + }); + it('specifies sort col and direction desc', async () => { const requestSort = jest.fn(); const headers = ['col1']; @@ -242,4 +333,68 @@ describe('Horizontal Table Widget', () => { expect(screen.getByText(/Last Heading/i)).toBeInTheDocument(); expect(screen.queryAllByRole('checkbox')).toHaveLength(2); }); + + it('hides the total column when the hideTotal param is passed', async () => { + const headers = ['col1', 'col2', 'col3']; + const data = [ + { + heading: 'Row 1 Data', + isUrl: false, + data: [ + { + title: 'col1', + value: '17', + }, + { + title: 'col2', + value: '18', + }, + { + title: 'col3', + value: '19', + }, + ], + }, + ]; + + renderHorizontalTableWidget(headers, data, 'First Heading', false, 'Last Heading', {}, {}, false, false); + expect(screen.getByText(/First Heading/i)).toBeInTheDocument(); + expect(screen.getByText(/col1/i, { selector: '.usa-sr-only' })).toBeInTheDocument(); + expect(screen.getByText(/col2/i, { selector: '.usa-sr-only' })).toBeInTheDocument(); + expect(screen.getByText(/col3/i, { selector: '.usa-sr-only' })).toBeInTheDocument(); + expect(screen.getByText(/Row 1 Data/i)).toBeInTheDocument(); + expect(screen.getByText(/17/i)).toBeInTheDocument(); + expect(screen.getByText(/18/i)).toBeInTheDocument(); + expect(screen.getByText(/19/i)).toBeInTheDocument(); + expect(screen.queryAllByText(/Last Heading/i).length).toBe(0); + }); + + it('hides the link icon when the hideLinkIcon param is passed', async () => { + const headers = ['col1', 'col2', 'col3']; + const data = [ + { + heading: 'Row 1 Data', + link: 'Row 1 Data', + isUrl: true, + hideLinkIcon: true, + data: [ + { + title: 'col1', + value: '17', + }, + { + title: 'col2', + value: '18', + }, + { + title: 'col3', + value: '19', + }, + ], + }, + ]; + + const { container } = renderHorizontalTableWidget(headers, data, 'First Heading', false, 'Last Heading', {}, {}, false, true); + expect(container.querySelector('.fa-arrow-up-right-from-square')).toBeNull(); + }); }); diff --git a/frontend/src/widgets/__tests__/LineGraph.js b/frontend/src/widgets/__tests__/LineGraph.js new file mode 100644 index 0000000000..2582cc49af --- /dev/null +++ b/frontend/src/widgets/__tests__/LineGraph.js @@ -0,0 +1,674 @@ +import '@testing-library/jest-dom'; +import React, { createRef } from 'react'; +import { + render, + waitFor, + act, + screen, +} from '@testing-library/react'; +import LineGraph from '../LineGraph'; + +const traces = [ + { + x: [ + 'Jan 23', + 'Feb 23', + 'Mar 23', + 'Apr 23', + 'May 23', + 'Jun 23', + 'Jul 23', + 'Aug 23', + 'Sep 23', + 'Oct 23', + 'Nov 23', + 'Dec 23', + ], + y: [ + 80, + 83, + 83, + 77, + 77, + 83, + 84, + 76, + 73, + 82, + 79, + 69, + ], + name: 'In person', + traceOrder: 1, + }, + { + x: [ + 'Jan 23', + 'Feb 23', + 'Mar 23', + 'Apr 23', + 'May 23', + 'Jun 23', + 'Jul 23', + 'Aug 23', + 'Sep 23', + 'Oct 23', + 'Nov 23', + 'Dec 23', + ], + y: [ + 20, + 17, + 16, + 16, + 20, + 13, + 13, + 21, + 26, + 17, + 16, + 29, + ], + name: 'Virtual', + traceOrder: 2, + }, + { + x: [ + 'Jan 23', + 'Feb 23', + 'Mar 23', + 'Apr 23', + 'May 23', + 'Jun 23', + 'Jul 23', + 'Aug 23', + 'Sep 23', + 'Oct 23', + 'Nov 23', + 'Dec 23', + ], + y: [ + 0, + 0, + 1, + 1, + 3, + 4, + 2, + 2, + 0, + 1, + 5, + 2, + ], + name: 'Hybrid', + traceOrder: 3, + }, +]; + +const tableConfig = { + title: 'Delivery method', + caption: 'TTA broken down by delivery method into total hours and percentages', + enableCheckboxes: true, + enableSorting: true, + showTotalColumn: false, + data: [ + { + heading: 'Jan 23', + sortKey: 1, + id: 1, + data: [ + { + value: 818, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 80, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 204, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 20, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 0, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 0, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Feb 23', + sortKey: 2, + id: 2, + data: [ + { + value: 1750, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 83, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 174, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 17, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 0, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 0, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Mar 23', + sortKey: 3, + id: 3, + data: [ + { + value: 742, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 83, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 143, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 16, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 1, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 1, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Apr 23', + sortKey: 4, + id: 4, + data: [ + { + value: 936, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 77, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 255, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 16, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 24, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 1, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'May 23', + sortKey: 5, + id: 5, + data: [ + { + value: 742, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 77, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 191, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 20, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 29, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 3, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Jun 23', + sortKey: 6, + id: 6, + data: [ + { + value: 650, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 83, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 102, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 13, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 31, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 4, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Jul 23', + sortKey: 7, + id: 7, + data: [ + { + value: 827, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 84, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 138, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 13, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 20, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 2, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Aug 23', + sortKey: 8, + id: 8, + data: [ + { + value: 756, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 76, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 206, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 21, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 20, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 2, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Sep 23', + sortKey: 9, + id: 9, + data: [ + { + value: 699, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 73, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 258, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 26, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 0, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 0, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Oct 23', + sortKey: 10, + id: 10, + data: [ + { + value: 855, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 82, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 177, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 17, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 11, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 1, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Nov 23', + sortKey: 11, + id: 11, + data: [ + { + value: 803, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 79, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 290, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 16, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 78, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 5, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + { + heading: 'Dec 23', + sortKey: 12, + id: 12, + data: [ + { + value: 689, + title: "In person (AR's)", + sortKey: "In_person_(AR's)", + }, + { + value: 69, + title: ' In person (Percentage)', + sortKey: 'In_person_(Percentage)', + }, + { + value: 596, + title: "Virtual (AR's)", + sortKey: "Virtual_(AR's)", + }, + { + value: 29, + title: 'Virtual (Percentage)', + sortKey: 'Virtual_(Percentage)', + }, + { + value: 64, + title: "Hybrid (AR's)", + sortKey: "Hybrid_(AR's)", + }, + { + value: 2, + title: 'Hybrid (Percentage)', + sortKey: 'Hybrid_(Percentage)', + }, + ], + }, + ], + sortConfig: { + sortBy: '1', + direction: 'desc', + activePage: 1, + }, + checkboxes: {}, + firstHeading: 'Months', + headings: [ + "In person (AR's)", + 'In person (Percentage)', + "Virtual (AR's)", + 'Virtual (Percentage)', + "Hybrid (AR's)", + 'Hybrid (Percentage)', + ], + footer: { + showFooter: true, + data: [ + '', + 'Total', + '8420', + '73', + '2734', + '24', + '356', + '3', + ], + }, +}; + +describe('LineGraph', () => { + const renderTest = (showTabularData = false, data = traces) => { + act(() => { + render( + , + ); + }); + }; + + it('switches legends', () => { + renderTest(); + + const inPersonCheckbox = document.getElementById('show-in-person-checkbox'); + const hybridCheckbox = document.getElementById('show-hybrid-checkbox'); + const virtualCheckbox = document.getElementById('show-virtual-checkbox'); + expect(inPersonCheckbox).toBeChecked(); + expect(hybridCheckbox).toBeChecked(); + expect(virtualCheckbox).toBeChecked(); + + act(() => { + inPersonCheckbox.click(); + }); + + act(() => { + hybridCheckbox.click(); + }); + + act(() => { + virtualCheckbox.click(); + }); + + expect(inPersonCheckbox).not.toBeChecked(); + expect(hybridCheckbox).not.toBeChecked(); + expect(virtualCheckbox).not.toBeChecked(); + }); + + it('displays tabular data', async () => { + const showTabularData = true; + renderTest(showTabularData); + + await waitFor(() => { + expect(document.querySelector('.smarthub-horizontal-table-widget')).toBeInTheDocument(); + }); + }); + + it('shows no results found', async () => { + renderTest(false, []); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /no results found\./i })).toBeVisible(); + expect(screen.getByText('Try removing or changing the selected filters.')).toBeVisible(); + expect(screen.getByRole('button', { name: /get help using filters/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/widgets/__tests__/QualityAssuranceDashboardOverview.js b/frontend/src/widgets/__tests__/QualityAssuranceDashboardOverview.js new file mode 100644 index 0000000000..14f634645a --- /dev/null +++ b/frontend/src/widgets/__tests__/QualityAssuranceDashboardOverview.js @@ -0,0 +1,80 @@ +/* eslint-disable jest/no-disabled-tests */ +import '@testing-library/jest-dom'; +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { render, screen } from '@testing-library/react'; +import { QualityAssuranceDashboardOverview } from '../QualityAssuranceDashboardOverview'; + +const renderQualityAssuranceDashboardOverview = (props) => { + const history = createMemoryHistory(); + + render( + + + , + ); +}; + +describe('Quality Assurance Dashboard Overview Widget', () => { + it('handles undefined data', async () => { + renderQualityAssuranceDashboardOverview({ data: undefined }); + expect(screen.getByText(/Recipients with no TTA/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients with OHS standard FEI goal/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients with OHS standard CLASS goal/i)).toBeInTheDocument(); + }); + + it('shows the correct data', async () => { + const data = { + recipientsWithNoTTA: { + pct: '11%', + filterApplicable: true, + }, + recipientsWithOhsStandardFeiGoals: { + pct: '22%', + filterApplicable: true, + }, + recipientsWithOhsStandardClass: { + pct: '33.5%', + filterApplicable: false, + }, + }; + + renderQualityAssuranceDashboardOverview({ data }); + + expect(screen.getByText(/Recipients with no TTA/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients with OHS standard FEI goal/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients with OHS standard CLASS goal/i)).toBeInTheDocument(); + + expect(await screen.findByText(/11%/)).toBeVisible(); + expect(await screen.findByText(/22%/)).toBeVisible(); + expect(await screen.findByText(/33.5%/)).toBeVisible(); + expect(await screen.findByText(/One or more of the selected filters cannot be applied to this data./)).toBeVisible(); + }); + + it('shows no results message', async () => { + const data = { + recipientsWithNoTTA: { + pct: '0', + filterApplicable: true, + }, + recipientsWithOhsStandardFeiGoals: { + pct: '0', + filterApplicable: true, + }, + recipientsWithOhsStandardClass: { + pct: '0', + filterApplicable: true, + }, + }; + + renderQualityAssuranceDashboardOverview({ data }); + + expect(screen.getByText(/Recipients with no TTA/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients with OHS standard FEI goal/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients with OHS standard CLASS goal/i)).toBeInTheDocument(); + + expect(screen.queryAllByText(/No results/i).length).toBe(3); + expect(screen.queryAllByText(/display details/i).length).toBe(0); + }); +}); diff --git a/frontend/src/widgets/__tests__/RecipientsWithClassScoresAndGoalsWidget.js b/frontend/src/widgets/__tests__/RecipientsWithClassScoresAndGoalsWidget.js new file mode 100644 index 0000000000..8d19b66d87 --- /dev/null +++ b/frontend/src/widgets/__tests__/RecipientsWithClassScoresAndGoalsWidget.js @@ -0,0 +1,207 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import moment from 'moment'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Router } from 'react-router'; +import { createMemoryHistory } from 'history'; +import RecipientsWithClassScoresAndGoalsWidget from '../RecipientsWithClassScoresAndGoalsWidget'; +import UserContext from '../../UserContext'; + +const recipientData = { + widgetData: { + '% recipients with class': 18.26, + 'grants with class': 346, + 'recipients with class': 283, + total: 1550, + }, + pageData: [ + { + id: 1, + name: 'Action for Boston Community Development, Inc.', + lastARStartDate: '01/02/2021', + emotionalSupport: 6.0430, + classroomOrganization: 5.0430, + instructionalSupport: 4.0430, + reportDeliveryDate: '03/01/2022', + goals: [ + { + goalNumber: 'G-45641', + status: 'In progress', + creator: 'John Doe', + collaborator: 'Jane Doe', + }, + { + goalNumber: 'G-25858', + status: 'Suspended', + creator: 'Bill Smith', + collaborator: 'Bob Jones', + }, + ], + }, + ], +}; + +const renderRecipientsWithClassScoresAndGoalsWidget = (data) => { + const history = createMemoryHistory(); + render( + + + + + , + ); +}; + +describe('Recipients With Class and Scores and Goals Widget', () => { + it('renders correctly without data', async () => { + const emptyData = { + widgetData: { + '% recipients with class': 0, + 'grants with class': 0, + 'recipients with class': 0, + total: 0, + }, + pageData: [], + }; + renderRecipientsWithClassScoresAndGoalsWidget(emptyData); + + expect(screen.getByText(/Recipients with CLASS® scores/i)).toBeInTheDocument(); + expect(screen.getByText(/0-0 of 0/i)).toBeInTheDocument(); + }); + + it('renders correctly with data', async () => { + renderRecipientsWithClassScoresAndGoalsWidget(recipientData); + + expect(screen.getByText(/Recipients with CLASS® scores/i)).toBeInTheDocument(); + expect(screen.getByText(/1-1 of 1/i)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].name)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].lastARStartDate)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].emotionalSupport)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].classroomOrganization)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].instructionalSupport)).toBeInTheDocument(); + expect(screen.getByText('03/01/2022')).toBeInTheDocument(); + + // Expand the goals. + const goalsButton = screen.getByRole('button', { name: /view goals for recipient action for boston community development, inc\./i }); + expect(goalsButton).toBeInTheDocument(); + goalsButton.click(); + + expect(screen.getByText(recipientData.pageData[0].goals[0].goalNumber)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[0].status)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[0].creator)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[0].collaborator)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[1].goalNumber)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[1].status)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[1].creator)).toBeInTheDocument(); + expect(screen.getByText(recipientData.pageData[0].goals[1].collaborator)).toBeInTheDocument(); + }); + + it('updates the page when the per page limit is changed', async () => { + const numberOfRecipients = 15; + const multipleRecipientData = { + widgetData: { + '% recipients with class': 18.26, + 'grants with class': 346, + 'recipients with class': 283, + total: 1550, + }, + pageData: Array.from({ length: numberOfRecipients }, (_, i) => ({ + ...recipientData.pageData[0], + name: `recipient ${i + 1}`, + })), + }; + renderRecipientsWithClassScoresAndGoalsWidget(multipleRecipientData); + + expect(screen.getByText(/Recipients with CLASS® scores/i)).toBeInTheDocument(); + expect(screen.getByText(/1-10 of 15/i)).toBeInTheDocument(); + + // Make sure we see 'recipient 1' but we do NOT see 'recipient 15'. + expect(screen.getByText('recipient 1')).toBeInTheDocument(); + expect(screen.queryByText('recipient 15')).not.toBeInTheDocument(); + + // Click the perPage dropdown and select 25. + const perPageDropdown = screen.getByRole('combobox', { name: /select recipients per page/i }); + userEvent.selectOptions(perPageDropdown, '25'); + expect(screen.getByText(/1-15 of 15/i)).toBeInTheDocument(); + expect(screen.getByText('recipient 1')).toBeInTheDocument(); + expect(screen.getByText('recipient 15')).toBeInTheDocument(); + }); + + it('sorts the recipients by name', async () => { + const numberOfRecipients = 15; + const multipleRecipientData = { + widgetData: { + '% recipients with class': 18.26, + 'grants with class': 346, + 'recipients with class': 283, + total: 1550, + }, + pageData: Array.from({ length: numberOfRecipients }, (_, i) => ({ + ...recipientData.pageData[0], + name: `recipient ${i + 1}`, + })), + }; + + renderRecipientsWithClassScoresAndGoalsWidget(multipleRecipientData); + + expect(screen.getByText(/Recipients with CLASS® scores/i)).toBeInTheDocument(); + expect(screen.getByText(/1-10 of 15/i)).toBeInTheDocument(); + + // Make sure we see 'Apple' but we do NOT see 'Zebra'. + expect(screen.getByText('recipient 1')).toBeInTheDocument(); + expect(screen.queryByText('recipient 15')).not.toBeInTheDocument(); + + // Click the sort button. + const sortButton = screen.getByRole('combobox', { name: /sort by/i }); + userEvent.selectOptions(sortButton, 'name-desc'); + + // Make sure we see 'Zebra' but we do NOT see 'Apple'. + expect(screen.getByText('recipient 15')).toBeInTheDocument(); + expect(screen.queryByText('recipient 1')).not.toBeInTheDocument(); + }); + + it('sorts the recipients by date', async () => { + const numberOfRecipients = 15; + const multipleRecipientData = { + widgetData: { + '% recipients with class': 18.26, + 'grants with class': 346, + 'recipients with class': 283, + total: 1550, + }, + pageData: Array.from({ length: numberOfRecipients }, (_, i) => ({ + ...recipientData.pageData[0], + name: `recipient ${i + 1}`, + // Make the date of last TTA increment by 1 day for each recipient. + lastARStartDate: moment(recipientData.pageData[0].lastARStartDate).add(i, 'days').format('MM/DD/YYYY'), + })), + }; + + renderRecipientsWithClassScoresAndGoalsWidget(multipleRecipientData); + + expect(screen.getByText(/Recipients with CLASS® scores/i)).toBeInTheDocument(); + expect(screen.getByText(/1-10 of 15/i)).toBeInTheDocument(); + + // Make sure we see 'Apple' but we do NOT see 'Zebra'. + expect(screen.getByText('recipient 1')).toBeInTheDocument(); + expect(screen.queryByText('recipient 15')).not.toBeInTheDocument(); + + // Click the sort button. + const sortButton = screen.getByRole('combobox', { name: /sort by/i }); + userEvent.selectOptions(sortButton, 'lastARStartDate-desc'); + + // Make sure we see 'Zebra' but we do NOT see 'Apple'. + expect(screen.getByText('recipient 15')).toBeInTheDocument(); + expect(screen.queryByText('recipient 1')).not.toBeInTheDocument(); + + // Click the sort button. + userEvent.selectOptions(sortButton, 'lastARStartDate-asc'); + + expect(screen.getByText('recipient 1')).toBeInTheDocument(); + expect(screen.queryByText('recipient 15')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/__tests__/RecipientsWithNoTtaWidget.js b/frontend/src/widgets/__tests__/RecipientsWithNoTtaWidget.js new file mode 100644 index 0000000000..e78cf27625 --- /dev/null +++ b/frontend/src/widgets/__tests__/RecipientsWithNoTtaWidget.js @@ -0,0 +1,90 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import RecipientsWithNoTtaWidget from '../RecipientsWithNoTtaWidget'; + +const rendersRecipientsWithNoTta = (data) => { + render( {}} + perPageNumber={10} + />); +}; + +describe('Recipients with no tta Widget', () => { + it('renders correctly with null data', async () => { + rendersRecipientsWithNoTta({}); + expect(screen.getByText(/recipients with no tta/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients without Activity Reports or Training Reports for more than 90 days./i)).toBeInTheDocument(); + }); + it('renders correctly without data', async () => { + const emptyData = { + headers: ['Recipient', 'Date of Last TTA', 'Days Since Last TTA'], + RecipientsWithNoTta: [], + }; + rendersRecipientsWithNoTta(emptyData); + expect(screen.getByText(/recipients with no tta/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients without Activity Reports or Training Reports for more than 90 days./i)).toBeInTheDocument(); + }); + + it('renders correctly with data', async () => { + const data = { + widgetData: { + total: 1460, + 'recipients without tta': 794, + '% recipients without tta': 54.38, + }, + pageData: { + headers: ['Date of Last TTA', 'Days Since Last TTA'], + RecipientsWithNoTta: [ + { + heading: 'Test Recipient 1', + name: 'Test Recipient 1', + recipient: 'Test Recipient 1', + isUrl: true, + hideLinkIcon: true, + link: '/recipient-tta-records/376/region/1/profile', + data: [{ + title: 'Date of Last TTA', + value: '2021-09-01', + }, + { + title: 'Days Since Last TTA', + value: '90', + }], + }, + { + heading: 'Test Recipient 2', + name: 'Test Recipient 2', + recipient: 'Test Recipient 2', + isUrl: true, + hideLinkIcon: true, + link: '/recipient-tta-records/376/region/1/profile', + data: [{ + title: 'Date of Last TTA', + value: '2021-09-02', + }, + { + title: 'Days Since Last TTA', + value: '91', + }], + }, + ], + }, + }; + rendersRecipientsWithNoTta(data); + + expect(screen.getByText(/recipients with no tta/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipients without Activity Reports or Training Reports for more than 90 days./i)).toBeInTheDocument(); + expect(screen.getByText(/Recipient 1/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipient 2/i)).toBeInTheDocument(); + + expect(screen.getByText(/2021-09-01/i)).toBeInTheDocument(); + expect(screen.getByText(/2021-09-02/i)).toBeInTheDocument(); + + expect(screen.getByRole('cell', { name: /90/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /91/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/__tests__/RecipientsWithOhsStandardFeiGoalWidget.js b/frontend/src/widgets/__tests__/RecipientsWithOhsStandardFeiGoalWidget.js new file mode 100644 index 0000000000..388c89f790 --- /dev/null +++ b/frontend/src/widgets/__tests__/RecipientsWithOhsStandardFeiGoalWidget.js @@ -0,0 +1,148 @@ +import '@testing-library/jest-dom'; +import moment from 'moment'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import RecipientsWithOhsStandardFeiGoalWidget from '../RecipientsWithOhsStandardFeiGoalWidget'; +import UserContext from '../../UserContext'; + +const renderRecipientsWithOhsStandardFeiGoalWidget = (data) => { + render( + + {}} + perPageNumber={10} + /> + , + ); +}; + +describe('Recipients with ohs standard fei goal widget', () => { + it('renders correctly with null data', async () => { + renderRecipientsWithOhsStandardFeiGoalWidget({}); + expect(screen.getByText(/recipients with/i)).toBeInTheDocument(); + expect(screen.getByText(/Root causes were identified through self-reported data/i)).toBeInTheDocument(); + }); + it('renders correctly without data', async () => { + const emptyData = { + headers: ['Recipient', 'Date of Last TTA', 'Days Since Last TTA'], + RecipientsWithNoTta: [], + }; + renderRecipientsWithOhsStandardFeiGoalWidget(emptyData); + expect(screen.getByText(/recipients with/i)).toBeInTheDocument(); + expect(screen.getByText(/Root causes were identified through self-reported data./i)).toBeInTheDocument(); + }); + + it('renders correctly with data', async () => { + const data = { + widgetData: { + total: 1550, + '% recipients with fei': 55.35, + 'grants with fei': 1093, + 'recipients with fei': 858, + }, + pageData: { + headers: ['Goal created on', 'Goal number', 'Goal status', 'Root cause'], + RecipientsWithOhsStandardFeiGoal: [ + { + heading: 'Recipient 1', + name: 'Test Recipient 1', + recipient: 'Test Recipient 1', + isUrl: true, + hideLinkIcon: true, + link: '/recipient-tta-records/376/region/1/profile', + data: [{ + title: 'Goal_created_on', + value: moment('2021-09-01').format('MM/DD/YYYY'), + }, + { + title: 'Goal_number', + value: 'G-20628', + }, + { + title: 'Goal_status', + value: 'In progress', + }, + { + title: 'Root_cause', + value: 'Community Partnership, Workforce', + }, + ], + }, + { + heading: 'Test Recipient 2', + name: 'Test Recipient 2', + recipient: 'Test Recipient 2', + isUrl: true, + hideLinkIcon: true, + link: '/recipient-tta-records/376/region/1/profile', + data: [{ + title: 'Goal_created_on', + value: moment('2021-09-02').format('MM/DD/YYYY'), + }, + { + title: 'Goal_number', + value: 'G-359813', + }, + { + title: 'Goal_status', + value: 'Not started', + }, + { + title: 'Root_cause', + value: 'Testing', + }], + }, + { + heading: 'Test Recipient 3', + name: 'Test Recipient 3', + recipient: 'Test Recipient 3', + isUrl: true, + hideLinkIcon: true, + link: '/recipient-tta-records/376/region/1/profile', + data: [{ + title: 'Goal_created_on', + value: moment('2021-09-03').format('MM/DD/YYYY'), + }, + { + title: 'Goal_number', + value: 'G-457825', + }, + { + title: 'Goal_status', + value: 'Unavailable', + }, + { + title: 'Root_cause', + value: 'Facilities', + }], + }], + }, + }; + renderRecipientsWithOhsStandardFeiGoalWidget(data); + + expect(screen.getByText(/recipients with/i)).toBeInTheDocument(); + expect(screen.getByText(/Root causes were identified through self-reported data./i)).toBeInTheDocument(); + expect(screen.getByText(/Recipient 1/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipient 2/i)).toBeInTheDocument(); + expect(screen.getByText(/Recipient 3/i)).toBeInTheDocument(); + + expect(screen.getByText('09/01/2021')).toBeInTheDocument(); + expect(screen.getByText('09/02/2021')).toBeInTheDocument(); + expect(screen.getByText('09/03/2021')).toBeInTheDocument(); + + expect(screen.getByText(/G-20628/i)).toBeInTheDocument(); + expect(screen.getByText(/G-359813/i)).toBeInTheDocument(); + expect(screen.getByText(/G-457825/i)).toBeInTheDocument(); + + expect(screen.getByText(/In progress/i)).toBeInTheDocument(); + expect(screen.getByText(/Not started/i)).toBeInTheDocument(); + expect(screen.getByText(/Unavailable/i)).toBeInTheDocument(); + + expect(screen.getByText(/Community Partnership, Workforce/i)).toBeInTheDocument(); + expect(screen.getByText(/Testing/i)).toBeInTheDocument(); + expect(screen.getByText(/Facilities/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/__tests__/ResourceUseSparklineGraph.js b/frontend/src/widgets/__tests__/ResourceUseSparklineGraph.js index 77c6eecefe..50b6a6e29e 100644 --- a/frontend/src/widgets/__tests__/ResourceUseSparklineGraph.js +++ b/frontend/src/widgets/__tests__/ResourceUseSparklineGraph.js @@ -24,7 +24,7 @@ const testData = { value: '19', }, { - title: 'total', + title: 'Total', value: '20', }, ], @@ -71,7 +71,7 @@ const testData = { value: '19', }, { - title: 'total', + title: 'Total', value: '20', }, ], @@ -116,7 +116,7 @@ const testData = { value: '27', }, { - title: 'total', + title: 'Total', value: '28', }, ], diff --git a/frontend/src/widgets/__tests__/RootCauseFeiGoals.js b/frontend/src/widgets/__tests__/RootCauseFeiGoals.js new file mode 100644 index 0000000000..3589592bdc --- /dev/null +++ b/frontend/src/widgets/__tests__/RootCauseFeiGoals.js @@ -0,0 +1,67 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { + render, + screen, + act, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import RootCauseFeiGoals from '../RootCauseFeiGoals'; + +const ROOT_CAUSE_FEI_GOALS_DATA = { + totalNumberOfGoals: 11510, + totalNumberOfRootCauses: 21637, + records: [ + { + rootCause: 'Community Partnerships', + response_count: 2532, + percentage: 22, + }, + { + rootCause: 'Facilities', + response_count: 2186, + percentage: 19, + }, + { + rootCause: 'Family Circumstances', + response_count: 2762, + percentage: 24, + }, + { + rootCause: 'Other ECE Care Options', + response_count: 3683, + percentage: 32, + }, + { + rootCause: 'Unavailable', + response_count: 115, + percentage: 1, + }, + { + rootCause: 'Workforce', + response_count: 10359, + percentage: 90, + }, + ], +}; + +describe('RootCauseFeiGoals', () => { + it('should switch to tabular data', async () => { + render(); + + const button = await screen.findByRole('button', { name: /open actions/i }); + + act(() => { + userEvent.click(button); + }); + + const tabularDataButton = await screen.findByRole('button', { name: /display table/i }); + + act(() => { + userEvent.click(tabularDataButton); + }); + + const table = await screen.findByRole('table'); + expect(table).toBeVisible(); + }); +}); diff --git a/frontend/src/widgets/__tests__/TopicFrequencyGraph.js b/frontend/src/widgets/__tests__/TopicFrequencyGraph.js index 48760732a7..a01c2bfcb3 100644 --- a/frontend/src/widgets/__tests__/TopicFrequencyGraph.js +++ b/frontend/src/widgets/__tests__/TopicFrequencyGraph.js @@ -152,11 +152,6 @@ describe('Topic & Frequency Graph Widget', () => { expect(await screen.findByText('Number of Activity Reports by Topic')).toBeInTheDocument(); }); - it('handles loading', async () => { - renderArGraphOverview({ loading: true }); - expect(await screen.findByText('Loading')).toBeInTheDocument(); - }); - it('the sort control works', async () => { renderArGraphOverview({ data: [...TEST_DATA] }); const button = screen.getByRole('button', { name: /change topic graph order/i }); @@ -181,24 +176,4 @@ describe('Topic & Frequency Graph Widget', () => { // eslint-disable-next-line no-underscore-dangle 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 as table/i }); - act(() => userEvent.click(button)); - - const firstRowHeader = await screen.findByRole('cell', { - name: /community and self-assessment/i, - }); - expect(firstRowHeader).toBeInTheDocument(); - - 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 as graph/i }); - act(() => userEvent.click(viewGraph)); - - expect(firstRowHeader).not.toBeInTheDocument(); - expect(firstTableCell).not.toBeInTheDocument(); - }); }); diff --git a/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js b/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js index 7420cb5c12..5054f47762 100644 --- a/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js +++ b/frontend/src/widgets/__tests__/TotalHrsAndRecipientGraph.js @@ -8,7 +8,8 @@ import { screen, act, } from '@testing-library/react'; -import { TotalHrsAndRecipientGraph, LegendControl } from '../TotalHrsAndRecipientGraph'; +import LegendControl from '../LegendControl'; +import { TotalHrsAndRecipientGraph } from '../TotalHrsAndRecipientGraph'; const TEST_DATA_MONTHS = [ { @@ -76,11 +77,6 @@ describe('Total Hrs And Recipient Graph Widget', () => { expect(await screen.findByText(/Total TTA Hours/i)).toBeInTheDocument(); }); - it('handles loading', async () => { - renderTotalHrsAndRecipientGraph({ loading: true }); - expect(await screen.findByText('Loading')).toBeInTheDocument(); - }); - it('handles checkbox clicks', async () => { const setSelected = jest.fn(); render(); @@ -89,42 +85,6 @@ describe('Total Hrs And Recipient Graph Widget', () => { expect(setSelected).toHaveBeenCalled(); }); - it('displays table data correctly', async () => { - renderTotalHrsAndRecipientGraph({ data: TEST_DATA_DAYS }); - const button = screen.getByRole('button', { name: 'display total training and technical assistance hours as table' }); - fireEvent.click(button); - const jan1 = screen.getByRole('columnheader', { name: /jan 1/i }); - const feb4 = screen.getByRole('columnheader', { name: /feb 4/i }); - expect(jan1).toBeInTheDocument(); - expect(feb4).toBeInTheDocument(); - }); - - it('handles switching contexts', async () => { - renderTotalHrsAndRecipientGraph({ data: TEST_DATA_MONTHS }); - const button = screen.getByRole('button', { name: 'display total training and technical assistance hours as table' }); - fireEvent.click(button); - const table = screen.getByRole('table', { name: /total tta hours by date and type/i }); - - const randomRowHeader = screen.getByRole('rowheader', { name: /recipient rec tta/i }); - expect(randomRowHeader).toBeInTheDocument(); - - const randomColumnHeader = screen.getByRole('columnheader', { name: /apr/i }); - expect(randomColumnHeader).toBeInTheDocument(); - - const cells = []; - - for (let index = 2; index < 10; index++) { - cells.push(screen.getByRole('cell', { name: `${index.toString()}` })); - } - - expect(screen.getByRole('cell', { name: '11.2' })).toBeInTheDocument(); - cells.forEach((cell) => expect(cell).toBeInTheDocument()); - - expect(table).toBeInTheDocument(); - fireEvent.click(button); - expect(table).not.toBeInTheDocument(); - }); - it('expertly handles large datasets', async () => { const largeDataSet = [{ name: 'Hours of Training', x: ['Sep-20', 'Oct-20', 'Nov-20', 'Dec-20', 'Jan-21', 'Feb-21', 'Mar-21', 'Apr-21', 'May-21', 'Jun-21', 'Jul-21', 'Aug-21', 'Sep-21', 'Oct-21', 'Nov-21', 'Dec-21', 'Jan-22', 'Feb-22', 'Mar-22', 'Apr-22', 'May-22', 'Jun-22', 'Jul-22', 'Aug-22', 'Sep-22'], y: [87.5, 209, 406.50000000000006, 439.4, 499.40000000000003, 493.6, 443.5, 555, 527.5, 428.5, 295, 493.5, 533.5, 680.5, 694, 278, 440, 611, 761.5, 534, 495.5, 551, 338.5, 772, 211], month: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false], diff --git a/frontend/src/widgets/__tests__/VBarGraph.js b/frontend/src/widgets/__tests__/VBarGraph.js index c6039b8c55..75bf99794b 100644 --- a/frontend/src/widgets/__tests__/VBarGraph.js +++ b/frontend/src/widgets/__tests__/VBarGraph.js @@ -1,12 +1,11 @@ import '@testing-library/jest-dom'; -import React from 'react'; +import React, { createRef } 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 = [{ @@ -22,15 +21,15 @@ const TEST_DATA = [{ count: 0, }]; -const renderBarGraph = async () => { +const renderBarGraph = async (props) => { act(() => { - render(); + render(); }); }; describe('VBar Graph', () => { it('is shown', async () => { - renderBarGraph(); + renderBarGraph({ data: TEST_DATA }); await waitFor(() => expect(document.querySelector('svg')).not.toBe(null)); @@ -43,19 +42,13 @@ describe('VBar Graph', () => { expect(point2.__data__.text).toBe('one'); }); - it('toggles table view', async () => { - act(() => { - renderBarGraph(); - }); - - await waitFor(() => expect(document.querySelector('svg')).not.toBe(null)); + it('shows no results found', async () => { + renderBarGraph({ data: [] }); - const button = await screen.findByRole('button', { name: /as table/i }); - act(() => { - userEvent.click(button); + await waitFor(() => { + expect(screen.getByText(/no results found/i)).toBeVisible(); + expect(screen.getByText('Try removing or changing the selected filters.')).toBeVisible(); + expect(screen.getByText('Get help using filters')).toBeVisible(); }); - - const table = document.querySelector('table'); - expect(table).not.toBeNull(); }); }); diff --git a/src/queries/api/dashboards/qa/class.sql b/src/queries/api/dashboards/qa/class.sql index e08b3bb791..cb993a60f2 100644 --- a/src/queries/api/dashboards/qa/class.sql +++ b/src/queries/api/dashboards/qa/class.sql @@ -56,6 +56,12 @@ JSON: { "type": "number", "nullable": false, "description": "Total number of recipients." + }, + { + "columnName": "grants with fei", + "type": "number", + "nullable": false, + "description": "Number of grants with a FEI goal." } ] }, @@ -82,6 +88,12 @@ JSON: { "nullable": true, "description": "Grant number associated with the recipient." }, + { + "columnName": "region id", + "type": "number", + "nullable": true, + "description": "Region number associated with the recipient's grant." + }, { "columnName": "goalId", "type": "number", @@ -129,6 +141,18 @@ JSON: { "type": "date", "nullable": true, "description": "Date when the monitoring report was delivered." + }, + { + "columnName": "creator", + "type": "string", + "nullable": true, + "description": "User who created the goal" + }, + { + "columnName": "colaborators", + "type": "string[]", + "nullable": true, + "description": "Users who collaborated on the goal" } ] }, @@ -250,6 +274,27 @@ JSON: { "display": "Creation Date", "description": "Filter based on the date range of creation", "supportsExclusion": true + }, + { + "name": "status", + "type": "string[]", + "display": "Goal Status", + "description": "Filter based on goal status", + "supportsExclusion": true + }, + { + "name": "group", + "type": "integer[]", + "display": "Group", + "description": "Filter based on group membership.", + "supportsExclusion": true + }, + { + "name": "currentUserId", + "type": "integer[]", + "display": "Current User", + "description": "Filter based on the current user ID.", + "supportsExclusion": true } ] } @@ -268,6 +313,7 @@ DECLARE domain_classroom_organization_filter TEXT := NULLIF(current_setting('ssdi.domainClassroomOrganization', true), ''); domain_instructional_support_filter TEXT := NULLIF(current_setting('ssdi.domainInstructionalSupport', true), ''); create_date_filter TEXT := NULLIF(current_setting('ssdi.createDate', true), ''); + goal_status_filter TEXT := NULLIF(current_setting('ssdi.status', true), ''); -- Declare `.not` variables recipient_not_filter BOOLEAN := COALESCE(current_setting('ssdi.recipient.not', true), 'false') = 'true'; @@ -281,6 +327,7 @@ DECLARE domain_classroom_organization_not_filter BOOLEAN := COALESCE(current_setting('ssdi.domainClassroomOrganization.not', true), 'false') = 'true'; domain_instructional_support_not_filter BOOLEAN := COALESCE(current_setting('ssdi.domainInstructionalSupport.not', true), 'false') = 'true'; create_date_not_filter BOOLEAN := COALESCE(current_setting('ssdi.createDate.not', true), 'false') = 'true'; + goal_status_not_filter BOOLEAN := COALESCE(current_setting('ssdi.status.not', true), 'false') = 'true'; BEGIN --------------------------------------------------------------------------------------------------- @@ -420,7 +467,7 @@ BEGIN AND ( group_filter IS NULL OR ( - COALESCE(group_filter, '[]')::jsonb @> to_jsonb(g.name) != group_not_filter + COALESCE(group_filter, '[]')::jsonb @> to_jsonb(g.id::text) != group_not_filter ) ) LEFT JOIN "GroupCollaborators" gc @@ -467,6 +514,7 @@ BEGIN domain_classroom_organization_filter IS NOT NULL OR domain_instructional_support_filter IS NOT NULL THEN + RAISE WARNING 'domain_classroom_organization_filter: %', domain_classroom_organization_filter; WITH applied_filtered_grants AS ( SELECT @@ -490,16 +538,16 @@ BEGIN COALESCE( domain_emotional_support_filter, domain_classroom_organization_filter, - NULLIF(current_setting('ssdi.domainInstructionalSupport', true), '') + domain_instructional_support_filter ) IS NOT NULL - AND (ARRAY_AGG(DISTINCT mrs.id))[0] IS NOT NULL + AND (ARRAY_AGG(DISTINCT mrs.id))[1] IS NOT NULL ) -- If all domain filters are NULL, then mrs.id can be anything OR ( COALESCE( domain_emotional_support_filter, domain_classroom_organization_filter, - NULLIF(current_setting('ssdi.domainInstructionalSupport', true), '') + domain_instructional_support_filter ) IS NULL ) ) @@ -514,8 +562,8 @@ BEGIN ) AS json_values WHERE json_values.value = ( CASE - WHEN (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[0] >= 6 THEN 'Above all thresholds' - WHEN (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[0] < 5 THEN 'Below competitive' + WHEN (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[1] >= 6 THEN 'Above all thresholds' + WHEN (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[1] < 5 THEN 'Below competitive' ELSE 'Below quality' END ) @@ -534,8 +582,8 @@ BEGIN ) AS json_values WHERE json_values.value = ( CASE - WHEN (ARRAY_AGG(mcs."classroomOrganization" ORDER BY mcs."reportDeliveryDate" DESC))[0] >= 6 THEN 'Above all thresholds' - WHEN (ARRAY_AGG(mcs."classroomOrganization" ORDER BY mcs."reportDeliveryDate" DESC))[0] < 5 THEN 'Below competitive' + WHEN (ARRAY_AGG(mcs."classroomOrganization" ORDER BY mcs."reportDeliveryDate" DESC))[1] >= 6 THEN 'Above all thresholds' + WHEN (ARRAY_AGG(mcs."classroomOrganization" ORDER BY mcs."reportDeliveryDate" DESC))[1] < 5 THEN 'Below competitive' ELSE 'Below quality' END ) @@ -555,12 +603,12 @@ BEGIN WHERE json_values.value = ( CASE -- Get the max reportDeliveryDate for the instructionalSupport domain - WHEN (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[0] >= 3 THEN 'Above all thresholds' + WHEN (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[1] >= 3 THEN 'Above all thresholds' WHEN (MAX(mcs."reportDeliveryDate") >= '2025-08-01' - AND (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[0] < 2.5) + AND (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[1] < 2.5) THEN 'Below competitive' WHEN (MAX(mcs."reportDeliveryDate") BETWEEN '2020-11-09' AND '2025-07-31' - AND (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[0] < 2.3) + AND (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mcs."reportDeliveryDate" DESC))[1] < 2.3) THEN 'Below competitive' ELSE 'Below quality' END @@ -618,7 +666,8 @@ BEGIN -- Step 2.2 If grant filters active, delete from filtered_goals for any goals filtered, delete from filtered_grants using filtered_goals IF - create_date_filter IS NOT NULL + create_date_filter IS NOT NULL OR + goal_status_filter IS NOT NULL THEN WITH applied_filtered_goals AS ( @@ -646,6 +695,16 @@ BEGIN ) != create_date_not_filter ) ) + -- Filter for status if ssdi.status is defined + AND ( + goal_status_filter IS NULL + OR ( + LOWER(g.status) IN ( + SELECT LOWER(value) + FROM json_array_elements_text(COALESCE(goal_status_filter, '[]')::json) AS value + ) != goal_status_not_filter + ) + ) ), applied_filtered_out_goals AS ( SELECT @@ -698,8 +757,9 @@ WITH with_class AS ( SELECT r.id, - COUNT(DISTINCT g.id) FILTER (WHERE COALESCE(g."goalTemplateId",0) = 18172) > 0 has_class, - COUNT(DISTINCT mcs.id) > 0 has_scores + COUNT(DISTINCT fg.id) FILTER (WHERE COALESCE(g."goalTemplateId",0) = 18172) > 0 has_class, + COUNT(DISTINCT mcs.id) > 0 has_scores, + COUNT(DISTINCT gr.id) FILTER (WHERE COALESCE(g."goalTemplateId",0) = 18172 AND fg.id IS NOT NULL AND mcs.id IS NOT NULL) grant_count FROM "Recipients" r JOIN "Grants" gr ON r.id = gr."recipientId" @@ -707,6 +767,8 @@ WITH ON gr.id = fgr.id LEFT JOIN "Goals" g ON gr.id = g."grantId" + LEFT JOIN filtered_goals fg + ON g.id = fg.id LEFT JOIN "MonitoringReviewGrantees" mrg ON gr.number = mrg."grantNumber" LEFT JOIN "MonitoringReviews" mr @@ -717,29 +779,36 @@ WITH LEFT JOIN "MonitoringClassSummaries" mcs ON mr."reviewId" = mcs."reviewId" WHERE gr.status = 'Active' + AND g."deletedAt" IS NULL + AND (mrs.id IS NULL OR mrs.name = 'Complete') + AND g."mapsToParentGoalId" IS NULL GROUP BY 1 ), with_class_widget AS ( SELECT - (((COUNT(DISTINCT wc.id) FILTER (WHERE has_class)::decimal/ - COUNT(DISTINCT wc.id)))*100)::decimal(5,2) "% recipients with class", - COUNT(DISTINCT wc.id) FILTER (WHERE wc.has_class) "recipients with class", - COUNT(DISTINCT wc.id) total + (COALESCE(COUNT(DISTINCT wc.id) FILTER (WHERE wc.has_class AND wc.has_scores)::decimal/ + NULLIF(COUNT(DISTINCT wc.id), 0), 0)*100)::decimal(5,2) "% recipients with class", + COUNT(DISTINCT wc.id) FILTER (WHERE wc.has_class AND wc.has_scores) "recipients with class", + COUNT(DISTINCT wc.id) total, + SUM(grant_count) "grants with class" FROM with_class wc ), with_class_page AS ( SELECT - r.id "recipientId", - r.name "recipientName", - gr.number "grantNumber", - (ARRAY_AGG(g.id ORDER BY g.id DESC))[1] "goalId", - (ARRAY_AGG(g."createdAt" ORDER BY g.id DESC))[1] "goalCreatedAt", - (ARRAY_AGG(g.status ORDER BY g.id DESC))[1] "goalStatus", - (ARRAY_AGG(a."startDate" ORDER BY a."startDate" DESC))[1] "lastARStartDate", - (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL))[1] "emotionalSupport", - (ARRAY_AGG(mcs."classroomOrganization" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL))[1] "classroomOrganization", - (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL))[1] "instructionalSupport", - (ARRAY_AGG(mr."reportDeliveryDate" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL))[1] "reportDeliveryDate" + r.id "recipientId", + r.name "recipientName", + gr.number "grantNumber", + gr."regionId", + (ARRAY_AGG(g.id ORDER BY g.id DESC) FILTER (WHERE fg.id IS NOT NULL))[1] "goalId", + (ARRAY_AGG(g."createdAt" ORDER BY g.id DESC) FILTER (WHERE fg.id IS NOT NULL))[1] "goalCreatedAt", + (ARRAY_AGG(g.status ORDER BY g.id DESC) FILTER (WHERE fg.id IS NOT NULL))[1] "goalStatus", + (ARRAY_AGG(a."startDate" ORDER BY a."startDate" DESC) FILTER (WHERE fg.id IS NOT NULL))[1] "lastARStartDate", + (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL AND fg.id IS NOT NULL))[1] "emotionalSupport", + (ARRAY_AGG(mcs."classroomOrganization" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL AND fg.id IS NOT NULL))[1] "classroomOrganization", + (ARRAY_AGG(mcs."instructionalSupport" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL AND fg.id IS NOT NULL))[1] "instructionalSupport", + (ARRAY_AGG(mr."reportDeliveryDate" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL AND fg.id IS NOT NULL))[1] "reportDeliveryDate", + (ARRAY_AGG(DISTINCT u.name || ', ' || COALESCE(ur.agg_roles, 'No Roles')) FILTER (WHERE ct.name = 'Creator' AND fg.id IS NOT NULL))[1] "creator", + (ARRAY_AGG(DISTINCT u.name || ', ' || COALESCE(ur.agg_roles, 'No Roles')) FILTER (WHERE ct.name = 'Collaborator' AND fg.id IS NOT NULL)) "collaborators" FROM with_class wc JOIN "Recipients" r ON wc.id = r.id @@ -752,6 +821,25 @@ WITH ON gr.id = g."grantId" AND has_class AND g."goalTemplateId" = 18172 + LEFT JOIN filtered_goals fg + ON g.id = fg.id + LEFT JOIN "GoalCollaborators" gc + ON g.id = gc."goalId" + LEFT JOIN "CollaboratorTypes" ct + ON gc."collaboratorTypeId" = ct.id + AND ct.name IN ('Creator', 'Collaborator') + LEFT JOIN "ValidFor" vf + ON ct."validForId" = vf.id + AND vf.name = 'Goals' + LEFT JOIN "Users" u + ON gc."userId" = u.id + LEFT JOIN LATERAL ( + SELECT ur."userId", STRING_AGG(r.name, ', ') AS agg_roles + FROM "UserRoles" ur + JOIN "Roles" r ON ur."roleId" = r.id + WHERE ur."userId" = u.id + GROUP BY ur."userId" + ) ur ON u.id = ur."userId" LEFT JOIN "ActivityReportGoals" arg ON g.id = arg."goalId" LEFT JOIN "ActivityReports" a @@ -770,8 +858,12 @@ WITH AND (has_class OR has_scores) AND (g.id IS NOT NULL OR mcs.id IS NOT NULL) AND (mrs.id IS NULL OR mrs.name = 'Complete') - GROUP BY 1,2,3 - ORDER BY 1,3 + AND (mcs.id IS NOT NULL) + AND g."deletedAt" IS NULL + AND g."mapsToParentGoalId" IS NULL + GROUP BY 1, 2, 3, 4 + HAVING (ARRAY_AGG(mcs."emotionalSupport" ORDER BY mr."reportDeliveryDate" DESC) FILTER (WHERE mr.id IS NOT NULL AND fg.id IS NOT NULL))[1] IS NOT NULL + ORDER BY 1, 3 ), -- CTE for fetching active filters using NULLIF() to handle empty strings @@ -780,13 +872,14 @@ WITH CASE WHEN NULLIF(current_setting('ssdi.recipients', true), '') IS NOT NULL THEN 'recipients' END, CASE WHEN NULLIF(current_setting('ssdi.grantNumbers', true), '') IS NOT NULL THEN 'grantNumbers' END, CASE WHEN NULLIF(current_setting('ssdi.stateCode', true), '') IS NOT NULL THEN 'stateCode' END, - CASE WHEN NULLIF(current_setting('ssdi.regionIds', true), '') IS NOT NULL THEN 'regionIds' END, + CASE WHEN NULLIF(current_setting('ssdi.region', true), '') IS NOT NULL THEN 'region' END, CASE WHEN NULLIF(current_setting('ssdi.group', true), '') IS NOT NULL THEN 'group' END, CASE WHEN NULLIF(current_setting('ssdi.currentUserId', true), '') IS NOT NULL THEN 'currentUserId' END, CASE WHEN NULLIF(current_setting('ssdi.domainEmotionalSupport', true), '') IS NOT NULL THEN 'domainEmotionalSupport' END, CASE WHEN NULLIF(current_setting('ssdi.domainClassroomOrganization', true), '') IS NOT NULL THEN 'domainClassroomOrganization' END, CASE WHEN NULLIF(current_setting('ssdi.domainInstructionalSupport', true), '') IS NOT NULL THEN 'domainInstructionalSupport' END, - CASE WHEN NULLIF(current_setting('ssdi.createDate', true), '') IS NOT NULL THEN 'createDate' END + CASE WHEN NULLIF(current_setting('ssdi.createDate', true), '') IS NOT NULL THEN 'createDate' END, + CASE WHEN NULLIF(current_setting('ssdi.status', true), '') IS NOT NULL THEN 'status' END ], NULL) AS active_filters ), @@ -797,7 +890,8 @@ WITH JSONB_AGG(JSONB_BUILD_OBJECT( '% recipients with class', "% recipients with class", 'recipients with class', "recipients with class", - 'total', total + 'total', total, + 'grants with class', "grants with class" )) AS data, af.active_filters -- Use precomputed active_filters FROM with_class_widget @@ -813,6 +907,7 @@ WITH 'recipientId', "recipientId", 'recipientName', "recipientName", 'grantNumber', "grantNumber", + 'region id', "regionId", 'goalId', "goalId", 'goalCreatedAt', "goalCreatedAt", 'goalStatus', "goalStatus", @@ -820,13 +915,25 @@ WITH 'emotionalSupport', "emotionalSupport", 'classroomOrganization', "classroomOrganization", 'instructionalSupport', "instructionalSupport", - 'reportDeliveryDate', "reportDeliveryDate" + 'reportDeliveryDate', "reportDeliveryDate", + 'creator', "creator", + 'collaborators', "collaborators" )) AS data, af.active_filters -- Use precomputed active_filters FROM with_class_page CROSS JOIN active_filters_array af GROUP BY af.active_filters + UNION + + SELECT + 'with_class_page' data_set, + 0 records, + '[]'::JSONB, + af.active_filters -- Use precomputed active_filters + FROM active_filters_array af + GROUP BY af.active_filters + UNION SELECT @@ -842,7 +949,11 @@ WITH GROUP BY af.active_filters ) -SELECT * +SELECT + data_set, + MAX(records) records, + JSONB_AGG(data ORDER BY records DESC) -> 0 data, + active_filters FROM datasets -- Filter for datasets if ssdi.dataSetSelection is defined WHERE 1 = 1 @@ -851,4 +962,5 @@ AND ( OR ( COALESCE(NULLIF(current_setting('ssdi.dataSetSelection', true), ''), '[]')::jsonb @> to_jsonb("data_set")::jsonb ) -); +) +GROUP BY 1,4; diff --git a/src/queries/api/dashboards/qa/dashboard.sql b/src/queries/api/dashboards/qa/dashboard.sql index c6df917bc4..c5ea3b2a6d 100644 --- a/src/queries/api/dashboards/qa/dashboard.sql +++ b/src/queries/api/dashboards/qa/dashboard.sql @@ -108,6 +108,19 @@ JSON: { } ] }, + { + "name": "activity_widget", + "defaultName": "Activity Widget", + "description": "Number of activity reports matching filters", + "schema": [ + { + "columnName": "filtered_reports", + "type": "number", + "nullable": false, + "description": "The number of reports that match the filters." + } + ] + }, { "name": "process_log", "defaultName": "Process Log", @@ -157,6 +170,20 @@ JSON: { "column": "number" } } + }, + { + "name": "status", + "type": "string[]", + "display": "Goal status", + "description": "Filter based on the goal status.", + "supportsExclusion": true, + "supportsFuzzyMatch": true, + "options": { + "query": { + "sqlQuery": "SELECT status FROM \"Goals\"", + "column": "status" + } + } }, { "name": "programType", @@ -195,7 +222,7 @@ JSON: { }, { "name": "group", - "type": "string[]", + "type": "integer[]", "display": "Group", "description": "Filter based on group membership.", "supportsExclusion": true @@ -220,6 +247,85 @@ JSON: { "display": "Activity Report Goal Response", "description": "Filter based on goal field responses in activity reports.", "supportsExclusion": true + }, + { + "name": "startDate", + "type": "date[]", + "display": "Start Date", + "description": "Filter based on the start date of the activity reports.", + "supportsExclusion": true + }, + { + "name": "endDate", + "type": "date[]", + "display": "End Date", + "description": "Filter based on the end date of the activity reports.", + "supportsExclusion": true + }, + { + "name": "reportId", + "type": "string[]", + "display": "Report Ids", + "description": "Filter based on the report ids.", + "supportsExclusion": true, + "supportsFuzzyMatch": true + }, + { + "name": "targetPopulations", + "type": "string[]", + "display": "Target populations", + "description": "Filter based on the selected target populations.", + "supportsExclusion": true + }, + { + "name": "topic", + "type": "string[]", + "display": "Topics", + "description": "Filter based on the selected topics.", + "supportsExclusion": true + }, + { + "name": "ttaType", + "type": "string[]", + "display": "TTA type", + "description": "Filter based on the selected TTA type.", + "supportsExclusion": true + }, + { + "name": "reportText", + "type": "string[]", + "display": "Report text", + "description": "Filter based on any of the free-form text fields on a report.", + "supportsExclusion": true, + "supportsFuzzyMatch": true + }, + { + "name": "role", + "type": "string[]", + "display": "Specialist role", + "description": "Filter based on the selected Specialist role.", + "supportsExclusion": true + }, + { + "name": "reason", + "type": "string[]", + "display": "Reasons", + "description": "Filter based on the selected reasons.", + "supportsExclusion": true + }, + { + "name": "goalName", + "type": "string[]", + "display": "Goal Text", + "description": "Filter based on the text of the goal.", + "supportsExclusion": true, + "supportsFuzzyMatch": true + }, + { + "name": "singleOrMultiRecipients", + "type": "string[]", + "display": "Single or multiple recipients", + "description": "Filter based on the number of recipients." } ] } @@ -237,6 +343,7 @@ DECLARE goal_name_filter TEXT := NULLIF(current_setting('ssdi.goalName', true), ''); create_date_filter TEXT := NULLIF(current_setting('ssdi.createDate', true), ''); activity_report_goal_response_filter TEXT := NULLIF(current_setting('ssdi.activityReportGoalResponse', true), ''); + report_id_filter TEXT := NULLIF(current_setting('ssdi.reportId', true), ''); start_date_filter TEXT := NULLIF(current_setting('ssdi.startDate', true), ''); end_date_filter TEXT := NULLIF(current_setting('ssdi.endDate', true), ''); reason_filter TEXT := NULLIF(current_setting('ssdi.reason', true), ''); @@ -258,6 +365,7 @@ DECLARE goal_name_not_filter BOOLEAN := COALESCE(current_setting('ssdi.goalName.not', true), 'false') = 'true'; create_date_not_filter BOOLEAN := COALESCE(current_setting('ssdi.createDate.not', true), 'false') = 'true'; activity_report_goal_response_not_filter BOOLEAN := COALESCE(current_setting('ssdi.activityReportGoalResponse.not', true), 'false') = 'true'; + report_id_not_filter BOOLEAN := COALESCE(current_setting('ssdi.reportId.not', true), 'false') = 'true'; start_date_not_filter BOOLEAN := COALESCE(current_setting('ssdi.startDate.not', true), 'false') = 'true'; end_date_not_filter BOOLEAN := COALESCE(current_setting('ssdi.endDate.not', true), 'false') = 'true'; reason_not_filter BOOLEAN := COALESCE(current_setting('ssdi.reason.not', true), 'false') = 'true'; @@ -406,7 +514,7 @@ BEGIN AND ( group_filter IS NULL OR ( - COALESCE(group_filter, '[]')::jsonb @> to_jsonb(g.name) != group_not_filter + COALESCE(group_filter, '[]')::jsonb @> to_jsonb(g.id) != group_not_filter ) ) LEFT JOIN "GroupCollaborators" gc @@ -612,6 +720,7 @@ BEGIN --------------------------------------------------------------------------------------------------- -- Step 3.2: If activity reports filters (set 1), delete from filtered_activity_reports for any activity reports filtered, delete from filtered_goals using filterd_activity_reports, delete from filtered_grants using filtered_goals IF + report_id_filter IS NOT NULL OR start_date_filter IS NOT NULL OR end_date_filter IS NOT NULL OR reason_filter IS NOT NULL OR @@ -626,6 +735,18 @@ BEGIN JOIN "ActivityReports" a ON fa.id = a.id WHERE a."calculatedStatus" = 'approved' + -- Filter for reportId if ssdi.reportId is defined + AND ( + report_id_filter IS NULL + OR ( + EXISTS ( + SELECT 1 + FROM json_array_elements_text(COALESCE(report_text_filter, '[]')::json) AS value + WHERE CONCAT('R', LPAD(a."regionId"::text, 2, '0'), '-AR-', a.id) ~* value::text + OR COALESCE(a."legacyId",'') ~* value::text + ) != report_text_not_filter + ) + ) -- Filter for startDate dates between two values if ssdi.startDate is defined AND ( start_date_filter IS NULL @@ -658,7 +779,7 @@ BEGIN AND ( reason_filter IS NULL OR ( - (a."reason"::string[] && ARRAY( + (a."reason"::TEXT[] && ARRAY( SELECT value::text FROM json_array_elements_text(COALESCE(reason_filter, '[]')::json) )) != reason_not_filter @@ -668,7 +789,7 @@ BEGIN AND ( target_populations_filter IS NULL OR ( - (a."targetPopulations"::string[] && ARRAY( + (a."targetPopulations"::TEXT[] && ARRAY( SELECT value::text FROM json_array_elements_text(COALESCE(target_populations_filter, '[]')::json) )) != target_populations_not_filter @@ -679,11 +800,11 @@ BEGIN tta_type_filter IS NULL OR ( ( - a."ttaType"::string[] @> ARRAY( + a."ttaType"::TEXT[] @> ARRAY( SELECT value::text FROM json_array_elements_text(COALESCE(tta_type_filter, '[]')::json) ) - AND a."ttaType"::string[] <@ ARRAY( + AND a."ttaType"::TEXT[] <@ ARRAY( SELECT value::text FROM json_array_elements_text(COALESCE(tta_type_filter, '[]')::json) ) @@ -768,13 +889,13 @@ BEGIN WITH applied_filtered_activity_reports AS ( SELECT DISTINCT - a.id + a.id "activityReportId" FROM filtered_activity_reports fa JOIN "ActivityReports" a ON fa.id = a.id JOIN "ActivityReportGoals" arg ON a.id = arg."activityReportId" - JOIN filterd_goals fg + JOIN filtered_goals fg ON arg."goalId" = fg.id JOIN "ActivityReportObjectives" aro ON a.id = aro."activityReportId" @@ -883,7 +1004,7 @@ BEGIN WITH applied_filtered_activity_reports AS ( SELECT DISTINCT - a.id + a.id "activityReportId" FROM filtered_activity_reports fa JOIN "ActivityReports" a ON fa.id = a.id @@ -983,7 +1104,7 @@ BEGIN WITH applied_filtered_activity_reports AS ( SELECT DISTINCT - a.id + a.id "activityReportId" FROM filtered_activity_reports fa JOIN "ActivityReports" a ON fa.id = a.id @@ -1083,15 +1204,46 @@ BEGIN END $$; --------------------------------------------------------------------------------------------------- WITH + active_filters_array AS ( + SELECT array_remove(ARRAY[ + CASE WHEN NULLIF(current_setting('ssdi.recipients', true), '') IS NOT NULL THEN 'recipients' END, + CASE WHEN NULLIF(current_setting('ssdi.programType', true), '') IS NOT NULL THEN 'programType' END, + CASE WHEN NULLIF(current_setting('ssdi.grantNumber', true), '') IS NOT NULL THEN 'grantNumber' END, + CASE WHEN NULLIF(current_setting('ssdi.stateCode', true), '') IS NOT NULL THEN 'stateCode' END, + CASE WHEN NULLIF(current_setting('ssdi.region', true), '') IS NOT NULL THEN 'region' END, + CASE WHEN NULLIF(current_setting('ssdi.group', true), '') IS NOT NULL THEN 'group' END, + CASE WHEN NULLIF(current_setting('ssdi.goalName', true), '') IS NOT NULL THEN 'goalName' END, + CASE WHEN NULLIF(current_setting('ssdi.createDate', true), '') IS NOT NULL THEN 'createDate' END, + CASE WHEN NULLIF(current_setting('ssdi.activityReportGoalResponse', true), '') IS NOT NULL THEN 'activityReportGoalResponse' END, + CASE WHEN NULLIF(current_setting('ssdi.reportId', true), '') IS NOT NULL THEN 'reportId' END, + CASE WHEN NULLIF(current_setting('ssdi.startDate', true), '') IS NOT NULL THEN 'startDate' END, + CASE WHEN NULLIF(current_setting('ssdi.endDate', true), '') IS NOT NULL THEN 'endDate' END, + CASE WHEN NULLIF(current_setting('ssdi.reason', true), '') IS NOT NULL THEN 'reason' END, + CASE WHEN NULLIF(current_setting('ssdi.targetPopulations', true), '') IS NOT NULL THEN 'targetPopulations' END, + CASE WHEN NULLIF(current_setting('ssdi.ttaType', true), '') IS NOT NULL THEN 'ttaType' END, + CASE WHEN NULLIF(current_setting('ssdi.reportText', true), '') IS NOT NULL THEN 'reportText' END, + CASE WHEN NULLIF(current_setting('ssdi.topic', true), '') IS NOT NULL THEN 'topic' END, + CASE WHEN NULLIF(current_setting('ssdi.singleOrMultiRecipients', true), '') IS NOT NULL THEN 'singleOrMultiRecipients' END, + CASE WHEN NULLIF(current_setting('ssdi.role', true), '') IS NOT NULL THEN 'role' END + ], NULL) AS active_filters +), + activity_widget AS ( + SELECT + COUNT(DISTINCT a.id) filtered_reports + FROM "ActivityReports" a + JOIN filtered_activity_reports far + ON a.id = far.id + WHERE a."calculatedStatus" = 'approved' + ), delivery_method_graph_values AS ( SELECT DATE_TRUNC('month', "startDate")::DATE::TEXT AS month, COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('In person', 'in-person', 'In-person')) AS in_person_count, COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('Virtual', 'virtual')) AS virtual_count, COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('Hybrid', 'hybrid')) AS hybrid_count, - ((COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('In person', 'in-person', 'In-person')) * 100.0) / COUNT(DISTINCT a.id))::decimal(5,2) AS in_person_percentage, - ((COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('Virtual', 'virtual')) * 100.0) / COUNT(DISTINCT a.id))::decimal(5,2) AS virtual_percentage, - ((COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('Hybrid', 'hybrid')) * 100.0) / COUNT(DISTINCT a.id))::decimal(5,2) AS hybrid_percentage + (COALESCE((COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('In person', 'in-person', 'In-person')) * 100.0) / NULLIF(COUNT(DISTINCT a.id),0),0))::decimal(5,2) AS in_person_percentage, + (COALESCE((COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('Virtual', 'virtual')) * 100.0) / NULLIF(COUNT(DISTINCT a.id),0),0))::decimal(5,2) AS virtual_percentage, + (COALESCE((COUNT(DISTINCT a.id) FILTER (WHERE a."deliveryMethod" IN ('Hybrid', 'hybrid')) * 100.0) / NULLIF(COUNT(DISTINCT a.id),0),0))::decimal(5,2) AS hybrid_percentage FROM "ActivityReports" a JOIN filtered_activity_reports far ON a.id = far.id @@ -1106,9 +1258,9 @@ WITH SUM(in_person_count) in_person_count, SUM(virtual_count) virtual_count, SUM(hybrid_count) hybrid_count, - ((SUM(in_person_count) * 100.0) / SUM(in_person_count + virtual_count + hybrid_count))::decimal(5,2) AS in_person_percentage, - ((SUM(virtual_count) * 100.0) / SUM(in_person_count + virtual_count + hybrid_count))::decimal(5,2) AS virtual_percentage, - ((SUM(hybrid_count) * 100.0) / SUM(in_person_count + virtual_count + hybrid_count))::decimal(5,2) AS hybrid_percentage + (COALESCE((SUM(in_person_count) * 100.0) / NULLIF(SUM(in_person_count + virtual_count + hybrid_count),0),0))::decimal(5,2) AS in_person_percentage, + (COALESCE((SUM(virtual_count) * 100.0) / NULLIF(SUM(in_person_count + virtual_count + hybrid_count),0),0))::decimal(5,2) AS virtual_percentage, + (COALESCE((SUM(hybrid_count) * 100.0) / NULLIF(SUM(in_person_count + virtual_count + hybrid_count),0),0))::decimal(5,2) AS hybrid_percentage FROM delivery_method_graph_values ), delivery_method_graph AS ( @@ -1122,7 +1274,7 @@ WITH SELECT COALESCE(r.name, a."creatorRole"::text) AS role_name, COUNT(*) AS role_count, - ((COUNT(*) * 100.0) / SUM(COUNT(*)) OVER ())::decimal(5,2) AS percentage + (COALESCE((COUNT(*) * 100.0) / NULLIF(SUM(COUNT(*)) OVER (), 0), 0))::decimal(5,2) AS percentage FROM "ActivityReports" a JOIN filtered_activity_reports far ON a.id = far.id @@ -1134,6 +1286,19 @@ WITH ORDER BY 1 DESC ), datasets AS ( + SELECT + 'activity_widget' data_set, + COUNT(*) records, + JSONB_AGG(JSONB_BUILD_OBJECT( + 'filtered_reports', filtered_reports + )) data, + af.active_filters + FROM activity_widget + CROSS JOIN active_filters_array af + GROUP BY af.active_filters + + UNION + SELECT 'delivery_method_graph' data_set, COUNT(*) records, @@ -1145,9 +1310,24 @@ WITH 'in_person_percentage', in_person_percentage, 'virtual_percentage', virtual_percentage, 'hybrid_percentage', hybrid_percentage - )) data + )) data, + af.active_filters FROM delivery_method_graph + CROSS JOIN active_filters_array af + GROUP BY af.active_filters + UNION + + SELECT + 'delivery_method_graph' data_set, + 0 records, + '[]'::JSONB, + af.active_filters -- Use precomputed active_filters + FROM active_filters_array af + GROUP BY af.active_filters + + UNION + SELECT 'role_graph' data_set, COUNT(*) records, @@ -1155,20 +1335,43 @@ WITH 'role_name', role_name, 'role_count', role_count, 'percentage', percentage - )) data + )) data, + af.active_filters FROM role_graph + CROSS JOIN active_filters_array af + GROUP BY af.active_filters + UNION + + SELECT + 'role_graph' data_set, + 0 records, + '[]'::JSONB, + af.active_filters -- Use precomputed active_filters + FROM active_filters_array af + GROUP BY af.active_filters + + UNION + SELECT 'process_log' data_set, COUNT(*) records, JSONB_AGG(JSONB_BUILD_OBJECT( 'action', action, 'record_cnt', record_cnt - )) data + )) data, + af.active_filters FROM process_log + CROSS JOIN active_filters_array af + GROUP BY af.active_filters ) - SELECT * - FROM datasets + +SELECT + data_set, + MAX(records) records, + JSONB_AGG(data ORDER BY records DESC) -> 0 data, + active_filters +FROM datasets -- Filter for datasets if ssdi.dataSetSelection is defined WHERE 1 = 1 AND ( @@ -1176,4 +1379,5 @@ AND ( OR ( COALESCE(NULLIF(current_setting('ssdi.dataSetSelection', true), ''), '[]')::jsonb @> to_jsonb("data_set")::jsonb ) -); +) +GROUP BY 1,4; diff --git a/src/queries/api/dashboards/qa/fei.sql b/src/queries/api/dashboards/qa/fei.sql index 8d908d7714..c0c6ca014d 100644 --- a/src/queries/api/dashboards/qa/fei.sql +++ b/src/queries/api/dashboards/qa/fei.sql @@ -56,6 +56,12 @@ JSON: { "type": "number", "nullable": false, "description": "Total number of recipients." + }, + { + "columnName": "grants with fei", + "type": "number", + "nullable": false, + "description": "Number of grants with a FEI goal." } ] }, @@ -82,6 +88,12 @@ JSON: { "nullable": true, "description": "Grant number associated with the recipient." }, + { + "columnName": "region id", + "type": "number", + "nullable": true, + "description": "Region number associated with the recipient's grant." + }, { "columnName": "goalId", "type": "number", @@ -208,7 +220,7 @@ JSON: { }, { "name": "group", - "type": "string[]", + "type": "integer[]", "display": "Group", "description": "Filter based on group membership", "supportsExclusion": true @@ -233,6 +245,13 @@ JSON: { "display": "Activity Report Goal Response", "description": "Filter based on goal field responses in activity reports", "supportsExclusion": true + }, + { + "name": "status", + "type": "string[]", + "display": "Goal Status", + "description": "Filter based on goal status", + "supportsExclusion": true } ] } @@ -249,6 +268,7 @@ DECLARE current_user_id_filter TEXT := NULLIF(current_setting('ssdi.currentUserId', true), ''); create_date_filter TEXT := NULLIF(current_setting('ssdi.createDate', true), ''); activity_report_goal_response_filter TEXT := NULLIF(current_setting('ssdi.activityReportGoalResponse', true), ''); + goal_status_filter TEXT := NULLIF(current_setting('ssdi.status', true), ''); -- Declare `.not` variables recipient_not_filter BOOLEAN := COALESCE(current_setting('ssdi.recipient.not', true), 'false') = 'true'; @@ -260,6 +280,7 @@ DECLARE current_user_id_not_filter BOOLEAN := COALESCE(current_setting('ssdi.currentUserId.not', true), 'false') = 'true'; create_date_not_filter BOOLEAN := COALESCE(current_setting('ssdi.createDate.not', true), 'false') = 'true'; activity_report_goal_response_not_filter BOOLEAN := COALESCE(current_setting('ssdi.activityReportGoalResponse.not', true), 'false') = 'true'; + goal_status_not_filter BOOLEAN := COALESCE(current_setting('ssdi.status.not', true), 'false') = 'true'; BEGIN --------------------------------------------------------------------------------------------------- @@ -399,7 +420,7 @@ BEGIN AND ( group_filter IS NULL OR ( - COALESCE(group_filter, '[]')::jsonb @> to_jsonb(g.name) != group_not_filter + COALESCE(group_filter, '[]')::jsonb @> to_jsonb(g.id) != group_not_filter ) ) LEFT JOIN "GroupCollaborators" gc @@ -465,6 +486,7 @@ BEGIN -- Step 2.2 If grant filters active, delete from filtered_goals for any goals filtered, delete from filtered_grants using filtered_goals IF create_date_filter IS NOT NULL OR + goal_status_filter IS NOT NULL OR activity_report_goal_response_filter IS NOT NULL THEN WITH @@ -488,6 +510,16 @@ BEGIN ) != create_date_not_filter ) ) + -- Filter for status if ssdi.status is defined + AND ( + goal_status_filter IS NULL + OR ( + g.status IN ( + SELECT value + FROM json_array_elements_text(COALESCE(goal_status_filter, '[]')::json) AS value + ) != goal_status_not_filter + ) + ) LEFT JOIN "GoalFieldResponses" gfr ON g.id = gfr."goalId" AND g."goalTemplateId" = 19017 @@ -554,7 +586,8 @@ WITH with_fei AS ( SELECT r.id, - COUNT(DISTINCT fg.id) FILTER (WHERE COALESCE(g."goalTemplateId",0) = 19017) > 0 has_fei + COUNT(DISTINCT fg.id) FILTER (WHERE COALESCE(g."goalTemplateId",0) = 19017) > 0 has_fei, + COUNT(DISTINCT gr.id) FILTER (WHERE COALESCE(g."goalTemplateId",0) = 19017 AND fg.id IS NOT NULL) grant_count FROM "Recipients" r JOIN "Grants" gr ON r.id = gr."recipientId" @@ -565,14 +598,17 @@ WITH LEFT JOIN filtered_goals fg ON g.id = fg.id WHERE gr.status = 'Active' + AND g."deletedAt" IS NULL + AND g."mapsToParentGoalId" IS NULL GROUP BY 1 ), with_fei_widget AS ( SELECT - (((COUNT(DISTINCT wf.id) FILTER (WHERE has_fei)::decimal/ - COUNT(DISTINCT wf.id)))*100)::decimal(5,2) "% recipients with fei", + (COALESCE((COUNT(DISTINCT wf.id) FILTER (WHERE has_fei)::decimal/ + NULLIF(COUNT(DISTINCT wf.id),0)),0)*100)::decimal(5,2) "% recipients with fei", COUNT(DISTINCT wf.id) FILTER (WHERE wf.has_fei) "recipients with fei", - COUNT(DISTINCT wf.id) total + COUNT(DISTINCT wf.id) total, + COALESCE(SUM(grant_count),0) "grants with fei" FROM with_fei wf ), @@ -596,6 +632,7 @@ WITH r.id "recipientId", r.name "recipientName", gr.number "grantNumber", + gr."regionId", g.id "goalId", g."createdAt", g.status "goalStatus", @@ -615,12 +652,15 @@ WITH ON g.id = fg.id LEFT JOIN "GoalFieldResponses" gfr ON g.id = gfr."goalId" + WHERE 1 = 1 + AND g."deletedAt" IS NULL + AND g."mapsToParentGoalId" IS NULL ), with_fei_graph AS ( SELECT wfpr.response, COUNT(*) AS response_count, - ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 0)::decimal(5,2) AS percentage + ROUND(COALESCE(COUNT(*) * 100.0 / NULLIF(SUM(COUNT(*)) OVER (),0),0), 0)::decimal(5,2) AS percentage FROM with_fei_page wfp CROSS JOIN UNNEST(wfp."rootCause") wfpr(response) GROUP BY 1 @@ -632,7 +672,8 @@ WITH JSONB_AGG(JSONB_BUILD_OBJECT( '% recipients with fei', "% recipients with fei", 'recipients with fei', "recipients with fei", - 'total', total + 'total', total, + 'grants with fei', "grants with fei" )) AS data, af.active_filters FROM with_fei_widget @@ -648,6 +689,7 @@ WITH 'recipientId', "recipientId", 'recipientName', "recipientName", 'grantNumber', "grantNumber", + 'region id', "regionId", 'goalId', "goalId", 'createdAt', "createdAt", 'goalStatus', "goalStatus", @@ -658,6 +700,16 @@ WITH CROSS JOIN active_filters_array af GROUP BY af.active_filters + UNION + + SELECT + 'with_fei_page' data_set, + 0 records, + '[]'::JSONB, + af.active_filters -- Use precomputed active_filters + FROM active_filters_array af + GROUP BY af.active_filters + UNION SELECT @@ -673,6 +725,16 @@ WITH CROSS JOIN active_filters_array af GROUP BY af.active_filters + UNION + + SELECT + 'with_fei_graph' data_set, + 0 records, + '[]'::JSONB, + af.active_filters -- Use precomputed active_filters + FROM active_filters_array af + GROUP BY af.active_filters + UNION SELECT @@ -688,7 +750,11 @@ WITH GROUP BY af.active_filters ) -SELECT * +SELECT + data_set, + MAX(records) records, + JSONB_AGG(data ORDER BY records DESC) -> 0 data, + active_filters FROM datasets -- Filter for datasets if ssdi.dataSetSelection is defined WHERE 1 = 1 @@ -697,4 +763,5 @@ AND ( OR ( COALESCE(NULLIF(current_setting('ssdi.dataSetSelection', true), ''), '[]')::jsonb @> to_jsonb("data_set")::jsonb ) -); +) +GROUP BY 1,4; diff --git a/src/queries/api/dashboards/qa/no-tta.sql b/src/queries/api/dashboards/qa/no-tta.sql index 8e4b146cf1..645dd009ba 100644 --- a/src/queries/api/dashboards/qa/no-tta.sql +++ b/src/queries/api/dashboards/qa/no-tta.sql @@ -76,6 +76,12 @@ JSON: { "nullable": true, "description": "Name of the recipient." }, + { + "columnName": "region id", + "type": "number", + "nullable": true, + "description": "Region number associated with the recipient's grant." + }, { "columnName": "last tta", "type": "date", @@ -423,9 +429,9 @@ WITH active_filters_array AS ( SELECT array_remove(ARRAY[ CASE WHEN NULLIF(current_setting('ssdi.recipients', true), '') IS NOT NULL THEN 'recipients' END, CASE WHEN NULLIF(current_setting('ssdi.programType', true), '') IS NOT NULL THEN 'programType' END, - CASE WHEN NULLIF(current_setting('ssdi.grantNumbers', true), '') IS NOT NULL THEN 'grantNumbers' END, + CASE WHEN NULLIF(current_setting('ssdi.grantNumber', true), '') IS NOT NULL THEN 'grantNumber' END, CASE WHEN NULLIF(current_setting('ssdi.stateCode', true), '') IS NOT NULL THEN 'stateCode' END, - CASE WHEN NULLIF(current_setting('ssdi.regionIds', true), '') IS NOT NULL THEN 'regionIds' END, + CASE WHEN NULLIF(current_setting('ssdi.region', true), '') IS NOT NULL THEN 'region' END, CASE WHEN NULLIF(current_setting('ssdi.startDate', true), '') IS NOT NULL THEN 'startDate' END, CASE WHEN NULLIF(current_setting('ssdi.endDate', true), '') IS NOT NULL THEN 'endDate' END ], NULL) AS active_filters @@ -454,14 +460,17 @@ no_tta AS ( ), no_tta_widget AS ( SELECT - (((COUNT(*) FILTER (WHERE NOT has_tta))::decimal/COUNT(*))*100)::decimal(5,2) "% recipients without tta", + (COALESCE((COUNT(*) FILTER (WHERE NOT has_tta))::decimal/NULLIF(COUNT(*),0),0)*100)::decimal(5,2) "% recipients without tta", COUNT(*) FILTER (WHERE not has_tta ) "recipients without tta", COUNT(*) total FROM no_tta ), no_tta_page AS ( - SELECT r.id, r.name, - (array_agg(a."endDate" ORDER BY a."endDate" DESC))[1] last_tta, + SELECT + r.id, + r.name, + gr."regionId", + ((array_agg(a."endDate" ORDER BY a."endDate" DESC))[1])::timestamp last_tta, now()::date - ((array_agg(a."endDate" ORDER BY a."endDate" DESC))[1])::date days_since_last_tta FROM no_tta nt JOIN "Recipients" r ON nt.id = r.id @@ -471,7 +480,7 @@ no_tta_page AS ( LEFT JOIN "ActivityReports" a ON ar."activityReportId" = a.id AND a."calculatedStatus" = 'approved' WHERE gr.status = 'Active' - GROUP BY 1,2 + GROUP BY 1,2,3 ), datasets AS ( SELECT 'no_tta_widget' data_set, COUNT(*) records, @@ -479,26 +488,54 @@ datasets AS ( '% recipients without tta', "% recipients without tta", 'recipients without tta', "recipients without tta", 'total', total - )) data + )) data, + af.active_filters -- Use precomputed active_filters FROM no_tta_widget + CROSS JOIN active_filters_array af + GROUP BY af.active_filters + UNION + SELECT 'no_tta_page' data_set, COUNT(*) records, JSONB_AGG(JSONB_BUILD_OBJECT( 'recipient id', id, 'recipient name', name, + 'region id', "regionId", 'last tta', last_tta, 'days since last tta', days_since_last_tta - )) data + )) data, + af.active_filters -- Use precomputed active_filters FROM no_tta_page + CROSS JOIN active_filters_array af + GROUP BY af.active_filters + + UNION + + SELECT + 'no_tta_page' data_set, + 0 records, + '[]'::JSONB, + af.active_filters -- Use precomputed active_filters + FROM active_filters_array af + GROUP BY af.active_filters + UNION SELECT 'process_log' data_set, COUNT(*) records, JSONB_AGG(JSONB_BUILD_OBJECT( 'action', action, 'record_cnt', record_cnt - )) data + )) data, + af.active_filters -- Use precomputed active_filters FROM process_log + CROSS JOIN active_filters_array af + GROUP BY af.active_filters ) -SELECT * + +SELECT + data_set, + MAX(records) records, + JSONB_AGG(data ORDER BY records DESC) -> 0 data, + active_filters FROM datasets -- Filter for datasets if ssdi.dataSetSelection is defined WHERE 1 = 1 @@ -507,4 +544,5 @@ AND ( OR ( COALESCE(NULLIF(current_setting('ssdi.dataSetSelection', true), ''), '[]')::jsonb @> to_jsonb("data_set")::jsonb ) -); +) +GROUP BY 1,4; diff --git a/src/routes/ssdi/handlers.ts b/src/routes/ssdi/handlers.ts index 7a68f242ef..d7ac4aa7ef 100644 --- a/src/routes/ssdi/handlers.ts +++ b/src/routes/ssdi/handlers.ts @@ -180,6 +180,7 @@ const runQuery = async (req: Request, res: Response) => { filters, filterValues, ); + if (errors?.invalidFilters?.length || errors?.invalidTypes?.length) { res.status(400).json({ errors }); return; diff --git a/src/services/ssdi.test.js b/src/services/ssdi.test.js index 8f5386171c..3ee132ba74 100644 --- a/src/services/ssdi.test.js +++ b/src/services/ssdi.test.js @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { QueryTypes } from 'sequelize'; import db from '../models'; import { cache, @@ -761,7 +762,7 @@ describe('ssdi', () => { describe('validateType', () => { it('should validate integer[] type', () => { expect(validateType('integer[]', [1, 2, 3])).toBe(true); - expect(validateType('integer[]', [1, '2', 3])).toBe(false); + expect(validateType('integer[]', [1, '2', 3])).toBe(true); }); it('should validate date[] type', () => { @@ -787,6 +788,8 @@ describe('ssdi', () => { describe('preprocessAndValidateFilters', () => { const filters = { flag1: { type: 'integer[]', name: 'flag1', description: 'Flag description' }, + dateFlag: { type: 'date[]', name: 'dateFlag', description: 'Date array flag' }, + stringFlag: { type: 'string[]', name: 'stringFlag', description: 'String flag' }, }; it('should preprocess and validate filters correctly', () => { @@ -798,10 +801,91 @@ describe('ssdi', () => { }); it('should return errors for invalid filters and types', () => { - const input = { flag1: [1, '2', 3], invalidFlag: [1, 2, 3] }; + const input = { flag1: [1, 'two', 3], invalidFlag: [1, 2, 3] }; const { result, errors } = preprocessAndValidateFilters(filters, input); expect(errors.invalidFilters).toEqual(['Invalid filter: invalidFlag']); - expect(errors.invalidTypes).toEqual(['Invalid type for filter flag1: expected integer[]']); + expect(errors.invalidTypes).toEqual(['Invalid type for filter flag1: expected integer[] received 1,two,3']); + }); + + it('should preprocess date array filters with separator correctly', () => { + const input = { 'dateFlag.in': ['2023/01/01', '2023/02/01-2023/03/01'] }; + const { result, errors } = preprocessAndValidateFilters(filters, input); + + expect(result).toEqual({ dateFlag: ['2023/01/01', '2023/02/01', '2023/03/01'] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); + }); + + it('should handle non-array date flag input and split it with a dash', () => { + const input = { 'dateFlag.in': '2023/01/01-2023/02/01' }; + const { result, errors } = preprocessAndValidateFilters(filters, input); + expect(result).toEqual({ dateFlag: ['2023/01/01', '2023/02/01'] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); + }); + + it('should split string values with comma for .in suffix', () => { + const input = { 'stringFlag.in': 'value1,value2,value3' }; + const { result, errors } = preprocessAndValidateFilters(filters, input); + expect(result).toEqual({ stringFlag: ['value1', 'value2', 'value3'] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); + }); + + it('should return default values for missing filters', () => { + const filtersWithDefaults = { + flag1: { + type: 'integer[]', + name: 'flag1', + description: 'Flag description', + defaultValues: [1, 2, 3], + }, + }; + const input = {}; + const { result, errors } = preprocessAndValidateFilters(filtersWithDefaults, input); + expect(result).toEqual({ flag1: [1, 2, 3] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); + }); + + it('should handle filters with .nin suffix and split them by comma', () => { + const input = { 'flag1.nin': '1,2,3' }; + const { result, errors } = preprocessAndValidateFilters(filters, input); + expect(result).toEqual({ 'flag1.not': [1, 2, 3] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); + }); + + it('should handle array input with .in suffix correctly', () => { + const input = { 'flag1.in': [1, 2, '3,4'] }; + const { result, errors } = preprocessAndValidateFilters(filters, input); + expect(result).toEqual({ flag1: [1, 2, 3, 4] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); + }); + + it('should add empty array as value if an invalid type is received', () => { + const input = { flag1: 'not an array' }; + const { result, errors } = preprocessAndValidateFilters(filters, input); + expect(result).toEqual({ flag1: ['not an array'] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(1); + }); + + it('should correctly handle cases where a filter is missing but has a default value', () => { + const filtersWithDefaults = { + flag2: { + type: 'string', + name: 'flag2', + description: 'Flag 2 description', + defaultValues: ['default'], + }, + }; + const input = {}; + const { result, errors } = preprocessAndValidateFilters(filtersWithDefaults, input); + expect(result).toEqual({ flag2: ['default'] }); + expect(errors.invalidFilters.length).toBe(0); + expect(errors.invalidTypes.length).toBe(0); }); }); @@ -827,9 +911,116 @@ describe('ssdi', () => { }); describe('executeQuery', () => { - it('should throw an error if the query is not a string', async () => { - await expect(executeQuery(123)).rejects - .toThrow('The "paths[1]" argument must be of type string. Received type number (123)'); + const mockQuery = jest.fn(); + let pathResolveSpy; + let fsReadFileSpy; + let fsStatSpy; + + beforeEach(() => { + jest.clearAllMocks(); + db.sequelize.query = mockQuery; + cache.clear(); + }); + + afterEach(() => { + // Restore the original implementations after each test + if (pathResolveSpy) pathResolveSpy.mockRestore(); + if (fsReadFileSpy) fsReadFileSpy.mockRestore(); + if (fsStatSpy) fsStatSpy.mockRestore(); + }); + + it('should throw an error if the file path is not a string', async () => { + await expect(executeQuery(123)).rejects.toThrow( + 'The "paths[1]" argument must be of type string. Received type number (123)', + ); + }); + + it('should resolve the file path correctly using safeResolvePath', async () => { + const mockResolvedPath = '/app/src/queries/resolved/path/to/test.sql'; + // Spy on path.resolve to mock it only for this test + pathResolveSpy = jest.spyOn(path, 'resolve').mockReturnValue(mockResolvedPath); + + const mockStat = { mtime: new Date() }; + fs.promises.stat.mockResolvedValue(mockStat); + fs.promises.readFile.mockResolvedValue('/* JSON: { "name": "test", "filters": [] } */\nSELECT * FROM test;'); + + await executeQuery('test.sql'); + + expect(path.resolve).toHaveBeenCalledWith(expect.anything(), 'test.sql'); + const cachedFile = cache.get(mockResolvedPath); + expect(cachedFile).toBeDefined(); + expect(cachedFile?.query).toEqual('SELECT * FROM test;'); + }); + + it('should cache the query after reading the file', async () => { + const mockResolvedPath = '/app/src/queries/resolved/path/to/test.sql'; + pathResolveSpy = jest.spyOn(path, 'resolve').mockReturnValue(mockResolvedPath); + + const mockStat = { mtime: new Date() }; + fs.promises.stat.mockResolvedValue(mockStat); + fs.promises.readFile.mockResolvedValue('/* JSON: { "name": "test", "filters": [] } */\nSELECT * FROM test;'); + + await executeQuery('test.sql'); + + expect(cache.has(mockResolvedPath)).toBe(true); + expect(cache.get(mockResolvedPath)?.query).toEqual('SELECT * FROM test;'); + }); + + it('should throw an error if the JSON header cannot be read from the file', async () => { + const mockResolvedPath = '/app/src/queries/resolved/path/to/invalid.sql'; + pathResolveSpy = jest.spyOn(path, 'resolve').mockReturnValue(mockResolvedPath); + + const mockStat = { mtime: new Date() }; + fs.promises.stat.mockResolvedValue(mockStat); + fs.promises.readFile.mockResolvedValue('INVALID CONTENT'); + + await expect(executeQuery('invalid.sql')).rejects.toThrow( + `Unable to read and parse the JSON header from file: ${mockResolvedPath}`, + ); + }); + + it('should execute the query and return the result', async () => { + const mockResolvedPath = '/app/src/queries/resolved/path/to/valid.sql'; + pathResolveSpy = jest.spyOn(path, 'resolve').mockReturnValue(mockResolvedPath); + + const mockStat = { mtime: new Date() }; + fs.promises.stat.mockResolvedValue(mockStat); + fs.promises.readFile.mockResolvedValue('/* JSON: { "name": "test", "filters": [] } */\nSELECT * FROM test;'); + mockQuery.mockResolvedValue([{ id: 1, name: 'Test' }]); + + const result = await executeQuery('valid.sql'); + + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM test;', { type: QueryTypes.SELECT }); + expect(result).toEqual([{ id: 1, name: 'Test' }]); + }); + + it('should throw an error if the query execution fails', async () => { + const mockResolvedPath = '/app/src/queries/resolved/path/to/valid.sql'; + pathResolveSpy = jest.spyOn(path, 'resolve').mockReturnValue(mockResolvedPath); + + const mockStat = { mtime: new Date() }; + fs.promises.stat.mockResolvedValue(mockStat); + fs.promises.readFile.mockResolvedValue('/* JSON: { "name": "test", "filters": [] } */\nSELECT * FROM test;'); + mockQuery.mockRejectedValue(new Error('Database error')); + + await expect(executeQuery('valid.sql')).rejects.toThrow('Query failed: Database error'); + }); + + it('should use the cached result if the file has been cached', async () => { + const mockResolvedPath = '/app/src/queries/resolved/path/to/cached.sql'; + pathResolveSpy = jest.spyOn(path, 'resolve').mockReturnValue(mockResolvedPath); + + cache.set(mockResolvedPath, { + jsonHeader: { name: 'test', filters: [] }, + query: 'SELECT * FROM cached;', + modificationTime: new Date(), + }); + mockQuery.mockResolvedValue([{ id: 2, name: 'Cached' }]); + + const result = await executeQuery('cached.sql'); + + expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM cached;', { type: QueryTypes.SELECT }); + expect(result).toEqual([{ id: 2, name: 'Cached' }]); }); }); }); diff --git a/src/services/ssdi.ts b/src/services/ssdi.ts index 3df3ca89d6..bab32f2b81 100644 --- a/src/services/ssdi.ts +++ b/src/services/ssdi.ts @@ -565,7 +565,17 @@ const readFiltersFromFile = async ( const validateType = (expectedType: FilterType, value: any): boolean => { switch (expectedType) { case FilterType.IntegerArray: - return Array.isArray(value) && value.every((v) => Number.isInteger(v)); + return value.every((v, i, arr) => { + if (Number.isInteger(v)) { + return true; + } + if (!Number.isNaN(Number(v))) { + // eslint-disable-next-line no-param-reassign + arr[i] = Number(v); // Convert string to number + return true; + } + return false; + }); case FilterType.DateArray: return Array.isArray(value) && value.every((v) => !Number.isNaN(Date.parse(v))); case FilterType.StringArray: @@ -590,16 +600,37 @@ const preprocessAndValidateFilters = (filters: Filters, input: Record { const suffix = Object.keys(suffixMapping).find((s) => key.endsWith(s)); let newKey = key; + const baseKey = newKey.split('.')[0]; let newValue = input[key]; if (suffix) { const mappedSuffix = suffixMapping[suffix]; newKey = key.replace(suffix, mappedSuffix ?? ''); - if ((suffix === '.win' || suffix === '.in') - && filters[newKey]?.type === FilterType.DateArray - && !Array.isArray(newValue)) { - newValue = newValue.split('-'); + const isDateArrayFilter = (suf, filterType) => (suf === '.win' || suf === '.in') + && filterType === FilterType.DateArray; + + const isArrayWithSeparator = (arr, separator) => Array.isArray(arr) + && arr.some((item) => typeof item === 'string' && item.includes(separator)); + + const splitValue = (value, separator) => (Array.isArray(value) + ? value.flatMap((item) => (typeof item === 'string' + ? item.split(separator) + : item)) + : value.split(separator)); + + if (isDateArrayFilter(suffix, filters[baseKey]?.type)) { + if (!Array.isArray(newValue)) { + newValue = splitValue(newValue, '-'); + } else if (isArrayWithSeparator(newValue, '-')) { + newValue = splitValue(newValue, '-'); + } + } else if (suffix === '.in' || suffix === '.nin') { + if (!Array.isArray(newValue) && newValue.includes(',')) { + newValue = splitValue(newValue, ','); + } else if (isArrayWithSeparator(newValue, ',')) { + newValue = splitValue(newValue, ','); + } } } @@ -607,12 +638,12 @@ const preprocessAndValidateFilters = (filters: Filters, input: Record => { } try { - const result = await db.sequelize.query(query, { type: QueryTypes.SELECT }); + const result = await db.sequelize.query( + query, + { + type: QueryTypes.SELECT, + }, + ); return result; } catch (error) { throw new Error(`Query failed: ${error.message}`); diff --git a/tests/e2e/recipient-record.spec.ts b/tests/e2e/recipient-record.spec.ts index c79ea51f1d..87d6035b49 100644 --- a/tests/e2e/recipient-record.spec.ts +++ b/tests/e2e/recipient-record.spec.ts @@ -11,7 +11,7 @@ test.describe('Recipient record', () => { await page.getByRole('link', { name: 'TTA History' }).click(); // remove a filter - await page.getByRole('button', { name: /This button removes the filter: Date started is within/i }).click(); + await page.getByRole('button', { name: /This button removes the filter: Date started \(ar\) is within/i }).click(); // goals and objectives, add a new goal await page.getByRole('link', { name: 'RTTAPA' }).click(); diff --git a/tests/e2e/regional-dashboard.spec.ts b/tests/e2e/regional-dashboard.spec.ts index 8f659d994f..2044950041 100644 --- a/tests/e2e/regional-dashboard.spec.ts +++ b/tests/e2e/regional-dashboard.spec.ts @@ -29,8 +29,10 @@ test('Regional Dashboard', async ({ page }) => { await page.getByRole('button', { name: 'This button removes the filter: State or territory contains RI' }).click(); // switch the total training graph's display type back and forth - await page.getByRole('button', { name: 'display total training and technical assistance hours as table' }).click(); - await page.getByRole('button', { name: 'display total training and technical assistance hours as graph' }).click(); + await page.getByRole('button', { name: 'Open Actions for Total TTA hours' }).click(); + await page.getByRole('button', { name: 'Display table' }).click(); + await page.getByRole('button', { name: 'Open Actions for Total TTA hours' }).click(); + await page.getByRole('button', { name: 'Display graph' }).click(); // toggle all the legend items off await page.locator('label').filter({ hasText: 'Training' }).click(); @@ -38,6 +40,8 @@ test('Regional Dashboard', async ({ page }) => { await page.getByText('Technical Assistance').click(); // print a screenshot of the TTA hours graph + await page.getByRole('button', { name: 'Open Actions for Total TTA hours' }).click(); + await Promise.all([ page.waitForEvent('download'), page.locator('#rd-save-screenshot').click()