diff --git a/colorsjschecksum b/colorsjschecksum index d9761a45bb..15e52b0835 100644 --- a/colorsjschecksum +++ b/colorsjschecksum @@ -1 +1 @@ -69962956f5817a804f1a491395986eb3c612a6feb4d76315f58c5b273cdf25a7 \ No newline at end of file +5dfc9c0a9f719af9ac72655dee86b64b9122ee7cda857e6ac68ae4a3dc76ade6 \ No newline at end of file diff --git a/colorsscsschecksum b/colorsscsschecksum index dd4f157735..5aa17e9d35 100644 --- a/colorsscsschecksum +++ b/colorsscsschecksum @@ -1 +1 @@ -45fe702aa79405030c90def8c4690064d0be4ca316fc297e6b1dbe1117ae95ff \ No newline at end of file +36906d289c12231e597eeb56423d83f37593429766f6d496f2db5f508c7d6ee8 \ No newline at end of file diff --git a/cucumber/features/notFound.feature b/cucumber/features/notFound.feature index 8db1b71901..c6eb900e0a 100644 --- a/cucumber/features/notFound.feature +++ b/cucumber/features/notFound.feature @@ -2,4 +2,4 @@ Feature: Not Found Page Scenario: User is shown a 404 page if route is not found Given I am logged in And I go to an unknown page - Then I see the "Not Found" alert message + Then I see the "404 error" alert message diff --git a/cucumber/features/steps/notFoundSteps.js b/cucumber/features/steps/notFoundSteps.js index 7bce974b2b..17976cb707 100644 --- a/cucumber/features/steps/notFoundSteps.js +++ b/cucumber/features/steps/notFoundSteps.js @@ -12,7 +12,10 @@ Given('I go to an unknown page', async () => { Then('I see the {string} alert message', async (heading) => { const page = scope.context.currentPage; - const value = await page.$eval('.usa-alert__heading', (el) => el.textContent); - + // find a div with the class 'smart-hub--something-went-wrong'.. + const value = await page.evaluate(() => { + const element = document.querySelector('.smart-hub--something-went-wrong'); + return element ? element.innerText : ''; + }); assertTrue(value.includes(heading)); }); diff --git a/frontend/src/App.js b/frontend/src/App.js index 1c94bedc19..a0c4bfb219 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -2,7 +2,9 @@ import React, { useState, useEffect, useMemo } from 'react'; import '@trussworks/react-uswds/lib/uswds.css'; import '@trussworks/react-uswds/lib/index.css'; -import { BrowserRouter, Route, Switch } from 'react-router-dom'; +import { + BrowserRouter, Route, Switch, +} from 'react-router-dom'; import { Helmet } from 'react-helmet'; import { fetchUser, fetchLogout } from './fetchers/Auth'; @@ -10,6 +12,7 @@ import { HTTPError } from './fetchers'; import { getSiteAlerts } from './fetchers/siteAlerts'; import FeatureFlag from './components/FeatureFlag'; import UserContext from './UserContext'; +import SomethingWentWrongContext from './SomethingWentWrongContext'; import SiteNav from './components/SiteNav'; import Header from './components/Header'; @@ -19,7 +22,6 @@ import TrainingReports from './pages/TrainingReports'; import ResourcesDashboard from './pages/ResourcesDashboard'; import CourseDashboard from './pages/CourseDashboard'; import Unauthenticated from './pages/Unauthenticated'; -import NotFound from './pages/NotFound'; import Home from './pages/Home'; import Landing from './pages/Landing'; import ActivityReport from './pages/ActivityReport'; @@ -58,6 +60,7 @@ import SessionForm from './pages/SessionForm'; import ViewTrainingReport from './pages/ViewTrainingReport'; import useGaUserData from './hooks/useGaUserData'; import QADashboard from './pages/QADashboard'; +import SomethingWentWrong from './components/SomethingWentWrong'; const WHATSNEW_NOTIFICATIONS_KEY = 'whatsnew-read-notifications'; @@ -76,6 +79,8 @@ function App() { const [notifications, setNotifications] = useState({ whatsNew: '' }); const [areThereUnreadNotifications, setAreThereUnreadNotifications] = useState(false); + const [errorResponseCode, setErrorResponseCode] = useState(null); + const [showingNotFound, setShowingNotFound] = useState(false); useGaUserData(user); @@ -441,7 +446,7 @@ function App() { ( - + )} /> @@ -456,9 +461,15 @@ function App() { - - - {authenticated && ( + + + + {authenticated && !errorResponseCode && !showingNotFound && ( <> Skip to main content @@ -475,30 +486,37 @@ function App() { /> - )} - - - -
- {!authenticated && (authError === 403 - ? - : ( - - + )} + + + +
+ {!authenticated && (authError === 403 + ? + : ( + + + + ) + )} + {authenticated && errorResponseCode + && ( + + - ) - )} - {authenticated && renderAuthenticatedRoutes()} - - - - - + )} + {authenticated && !errorResponseCode && renderAuthenticatedRoutes()} + + + + + + ); diff --git a/frontend/src/SomethingWentWrongContext.js b/frontend/src/SomethingWentWrongContext.js new file mode 100644 index 0000000000..f00723564f --- /dev/null +++ b/frontend/src/SomethingWentWrongContext.js @@ -0,0 +1,6 @@ +import React from 'react'; + +const SomethingWentWrong = React.createContext({ +}); + +export default SomethingWentWrong; diff --git a/frontend/src/colors.js b/frontend/src/colors.js index 6d74aebfba..2196c2bd01 100644 --- a/frontend/src/colors.js +++ b/frontend/src/colors.js @@ -41,6 +41,7 @@ const colors = { textInk: '#1b1b1b', textLink: '#46789B', textVisited: '#8C39DB', + responseCode: '#71767A', }; module.exports = colors; diff --git a/frontend/src/colors.scss b/frontend/src/colors.scss index 80fc0e2643..fac7c72316 100644 --- a/frontend/src/colors.scss +++ b/frontend/src/colors.scss @@ -39,4 +39,5 @@ $error-dark: #b50909; $blue-vivid-focus: #2491FF; $text-ink: #1b1b1b; $text-link: #46789B; -$text-visited: #8C39DB; \ No newline at end of file +$text-visited: #8C39DB; +$response-code: #71767A; \ No newline at end of file diff --git a/frontend/src/components/DisplayWithPermission.js b/frontend/src/components/DisplayWithPermission.js index a625ff5618..7fabca2e90 100644 --- a/frontend/src/components/DisplayWithPermission.js +++ b/frontend/src/components/DisplayWithPermission.js @@ -1,8 +1,8 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; -import NotFound from '../pages/NotFound'; import UserContext from '../UserContext'; import isAdmin from '../permissions'; +import SomethingWentWrong from './SomethingWentWrong'; export default function DisplayWithPermission({ scopes, renderNotFound, children, @@ -16,7 +16,7 @@ export default function DisplayWithPermission({ if (!admin && !userHasScope) { if (renderNotFound) { - return ; + return ; } return <>; } diff --git a/frontend/src/components/FeatureFlag.js b/frontend/src/components/FeatureFlag.js index 2a89054b4f..6a29671af4 100644 --- a/frontend/src/components/FeatureFlag.js +++ b/frontend/src/components/FeatureFlag.js @@ -1,8 +1,8 @@ import React, { useContext } from 'react'; import PropTypes from 'prop-types'; -import NotFound from '../pages/NotFound'; import UserContext from '../UserContext'; import isAdmin from '../permissions'; +import SomethingWentWrong from './SomethingWentWrong'; export default function FeatureFlag({ flag, renderNotFound, children, @@ -12,7 +12,7 @@ export default function FeatureFlag({ if (!admin && user.flags && !user.flags.includes(flag)) { if (renderNotFound) { - return ; + return ; } return <>; } diff --git a/frontend/src/components/GoalForm/__tests__/index.js b/frontend/src/components/GoalForm/__tests__/index.js index 00e7d7cd55..06808c15c7 100644 --- a/frontend/src/components/GoalForm/__tests__/index.js +++ b/frontend/src/components/GoalForm/__tests__/index.js @@ -6,6 +6,7 @@ import { screen, within, waitFor, + act, } from '@testing-library/react'; import { SCOPE_IDS } from '@ttahub/common'; import selectEvent from 'react-select-event'; @@ -18,6 +19,7 @@ import UserContext from '../../../UserContext'; import { OBJECTIVE_ERROR_MESSAGES } from '../constants'; import { BEFORE_OBJECTIVES_CREATE_GOAL, BEFORE_OBJECTIVES_SELECT_RECIPIENTS } from '../Form'; import AppLoadingContext from '../../../AppLoadingContext'; +import SomethingWentWrongContext from '../../../SomethingWentWrongContext'; const [objectiveTitleError] = OBJECTIVE_ERROR_MESSAGES; @@ -101,31 +103,33 @@ describe('create goal', () => { }], }]; - function renderForm(recipient = defaultRecipient, goalId = 'new') { + function renderForm(recipient = defaultRecipient, goalId = 'new', setErrorResponseCode = jest.fn()) { const history = createMemoryHistory(); render(( - - + + - - - + > + + + + )); } @@ -368,6 +372,16 @@ describe('create goal', () => { expect(alert.textContent).toBe('There was an error saving your goal'); }); + it('correctly calls the setErrorResponseCode function when there is an error', async () => { + const setErrorResponseCode = jest.fn(); + fetchMock.restore(); + fetchMock.get('/api/recipient/1/goals?goalIds=', 500); + await act(async () => { + renderForm(defaultRecipient, '48743', setErrorResponseCode); + await waitFor(() => expect(setErrorResponseCode).toHaveBeenCalledWith(500)); + }); + }); + it('removes goals', async () => { fetchMock.post('/api/goals', postResponse); diff --git a/frontend/src/components/GoalForm/index.js b/frontend/src/components/GoalForm/index.js index 5c5d010496..3f89df0267 100644 --- a/frontend/src/components/GoalForm/index.js +++ b/frontend/src/components/GoalForm/index.js @@ -36,6 +36,7 @@ import AppLoadingContext from '../../AppLoadingContext'; import useUrlParamState from '../../hooks/useUrlParamState'; import UserContext from '../../UserContext'; import VanillaModal from '../VanillaModal'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; const [objectiveTextError] = OBJECTIVE_ERROR_MESSAGES; @@ -112,6 +113,7 @@ export default function GoalForm({ const { isAppLoading, setIsAppLoading, setAppLoadingText } = useContext(AppLoadingContext); const { user } = useContext(UserContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const canView = useMemo(() => user.permissions.filter( (permission) => permission.regionId === parseInt(regionId, DECIMAL_BASE), @@ -134,9 +136,14 @@ export default function GoalForm({ async function fetchGoal() { setFetchAttempted(true); // as to only fetch once try { - const [goal] = await goalsByIdAndRecipient( - ids, recipient.id.toString(), - ); + let goal = null; + try { + [goal] = await goalsByIdAndRecipient( + ids, recipient.id.toString(), + ); + } catch (err) { + setErrorResponseCode(err.status); + } const selectedGoalGrants = goal.grants ? goal.grants : [goal.grant]; @@ -200,6 +207,7 @@ export default function GoalForm({ ids, setAppLoadingText, setIsAppLoading, + setErrorResponseCode, ]); const setObjectiveError = (objectiveIndex, errorText) => { diff --git a/frontend/src/components/SomethingWentWrong.js b/frontend/src/components/SomethingWentWrong.js new file mode 100644 index 0000000000..d579905a6c --- /dev/null +++ b/frontend/src/components/SomethingWentWrong.js @@ -0,0 +1,154 @@ +import React, { useContext } from 'react'; +import { Link, Button } from '@trussworks/react-uswds'; +import { useHistory } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import SomethingWentWrongContext from '../SomethingWentWrongContext'; +import AppLoadingContext from '../AppLoadingContext'; +import './SomethingWentWrong.scss'; + +/* eslint-disable max-len */ + +function SomethingWentWrong({ passedErrorResponseCode }) { + const { + setErrorResponseCode, errorResponseCode, setShowingNotFound, showingNotFound, + } = useContext(SomethingWentWrongContext); + const { setIsAppLoading, isAppLoading } = useContext(AppLoadingContext); + const history = useHistory(); + + // Make sure if something was loading when an error occurred, we stop the loading spinner. + if (isAppLoading) setIsAppLoading(false); + + // Make sure if we are showing not found we hide the NAV. + if (!errorResponseCode && (!showingNotFound && passedErrorResponseCode === 404)) setShowingNotFound(true); + + const supportLink = 'https://app.smartsheetgov.com/b/form/f0b4725683f04f349a939bd2e3f5425a'; + const getSupportLink = () => ( + support + ); + + const onHomeClick = () => { + setErrorResponseCode(null); + setShowingNotFound(false); + history.push('/'); + }; + + const responseCodeMessages = [ + { + codes: [401, 403], + message: '403 error - forbidden', + title: 'Restricted access.', + body: ( +

+ Sorry, but it looks like you're trying to access a restricted area. Here's what you can do: +

    +
  • + Double-check permissions: + {' '} + Ensure you have the proper clearance to access this page +
    + Contact + {' '} + {getSupportLink()} + {' '} + and ask them to check your permissions. +
    +
  • +
  • + Login again: + {' '} + Try logging in again. Maybe that's the missing key. +
  • +
  • + Explore elsewhere: + {' '} + Return to the main area and explore other permitted sections. +
  • +
+ If you believe this is an error or need further assistance, get in touch with + {' '} + {getSupportLink()} + . +

+ ), + }, + { + codes: [404], + message: '404 error', + title: 'Page not found.', + body: ( +

+ Well, this is awkward. It seems like the page you're looking for has taken a detour into the unknown. Here's what you can do: +

    +
  • + Go back to + {' '} + +
  • +
  • + Contact + {' '} + {getSupportLink()} + {' '} + for help +
  • +
+ Thanks for your understanding and patience! +

+ ), + }, + { + codes: [500], + message: null, + title: 'Something went wrong.', + body: ( +

+ Well, this is awkward. It seems like the page you're looking for has taken a detour into the unknown. Here's what you can do: +

    +
  • + Go back to + {' '} + +
  • +
  • + Contact + {' '} + {getSupportLink()} + {' '} + for help +
  • +
+ Thanks for your understanding and patience! +

+ ), + + }, + ]; + + const messageToDisplay = responseCodeMessages.find((msg) => msg.codes.includes(passedErrorResponseCode)) || responseCodeMessages.find((msg) => msg.code === 500); + + return ( +
+ { + messageToDisplay.message && ( +

{messageToDisplay.message}

+ ) + } +

{messageToDisplay.title}

+
+ { + messageToDisplay.body + } +
+
+ ); +} + +SomethingWentWrong.propTypes = { + passedErrorResponseCode: PropTypes.number, +}; + +SomethingWentWrong.defaultProps = { + passedErrorResponseCode: 404, +}; + +export default SomethingWentWrong; diff --git a/frontend/src/components/SomethingWentWrong.scss b/frontend/src/components/SomethingWentWrong.scss new file mode 100644 index 0000000000..af1e4ae6f1 --- /dev/null +++ b/frontend/src/components/SomethingWentWrong.scss @@ -0,0 +1,38 @@ +@use '../colors.scss' as *; + +.smart-hub--something-went-wrong h3 { + color: $response-code; + font-size: 1.5rem; + font-weight: 700; +} + +.smart-hub--something-went-wrong .smart-hub--something-went-wrong-body a, +.smart-hub--something-went-wrong .smart-hub--something-went-wrong-body button + { + color: $text-link; +} + +.smart-hub--something-went-wrong .smart-hub--something-went-wrong-body a:visited { + color: $text-visited; +} + +.smart-hub--something-went-wrong h1 { + font-size: 2.5rem; + font-weight: 700; + font-family: Merriweather, serif; +} + +.smart-hub--something-went-wrong-body { + max-width: 700px; +} + +.smart-hub--something-went-wrong p, +.smart-hub--something-went-wrong ul li button { + color: $base-darkest; + font-size: 1.25rem; + font-weight: 400; +} + +.smart-hub--something-went-wrong li { + margin-bottom: .5rem; +} \ No newline at end of file diff --git a/frontend/src/components/__tests__/DisplayWithPermission.js b/frontend/src/components/__tests__/DisplayWithPermission.js index c8588d03f9..e3863ad3a8 100644 --- a/frontend/src/components/__tests__/DisplayWithPermission.js +++ b/frontend/src/components/__tests__/DisplayWithPermission.js @@ -8,6 +8,7 @@ import { Router } from 'react-router'; import { createMemoryHistory } from 'history'; import DisplayWithPermission from '../DisplayWithPermission'; import UserContext from '../../UserContext'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; const { ADMIN, READ_WRITE_TRAINING_REPORTS, READ_ACTIVITY_REPORTS } = SCOPE_IDS; @@ -18,9 +19,18 @@ describe('display with permissions', () => { render( - -

This is a test

-
+ + + +

This is a test

+
+
, ); @@ -72,6 +82,7 @@ describe('display with permissions', () => { }; const renderNotFound = true; renderDisplayWithPermission([READ_WRITE_TRAINING_REPORTS], user, renderNotFound); - expect(screen.getByRole('link', { name: /home page/i })).toBeVisible(); + expect(screen.getByRole('heading', { name: /404 error/i })).toBeVisible(); + expect(screen.getByRole('heading', { name: /page not found/i })).toBeVisible(); }); }); diff --git a/frontend/src/components/__tests__/FeatureFlag.js b/frontend/src/components/__tests__/FeatureFlag.js index ed8fd84203..44ec216763 100644 --- a/frontend/src/components/__tests__/FeatureFlag.js +++ b/frontend/src/components/__tests__/FeatureFlag.js @@ -8,6 +8,7 @@ import { Router } from 'react-router'; import { createMemoryHistory } from 'history'; import FeatureFlag from '../FeatureFlag'; import UserContext from '../../UserContext'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; const { ADMIN } = SCOPE_IDS; @@ -18,9 +19,17 @@ describe('feature flag', () => { render( - -

This is a test

-
+ + +

This is a test

+
+
, ); @@ -71,6 +80,7 @@ describe('feature flag', () => { }; const renderNotFound = true; renderFeatureFlag(flag, user, renderNotFound); - expect(screen.getByRole('link', { name: /home page/i })).toBeVisible(); + expect(screen.getByRole('heading', { name: /404 error/i })).toBeVisible(); + expect(screen.getByRole('heading', { name: /page not found/i })).toBeVisible(); }); }); diff --git a/frontend/src/components/__tests__/SomethingWentWrong.js b/frontend/src/components/__tests__/SomethingWentWrong.js new file mode 100644 index 0000000000..c3ed69b7ae --- /dev/null +++ b/frontend/src/components/__tests__/SomethingWentWrong.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router'; +import SomethingWentWrong from '../SomethingWentWrong'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; + +const history = createMemoryHistory(); + +const renderSomethingWentWrong = ( + responseCode = 500, +) => render( + + + + + , +); + +describe('SomethingWentWrong component', () => { + // Write a test to pass the response code 401 to the component. + it('renders a 401 error message', async () => { + renderSomethingWentWrong(401); + + expect(screen.getByText('403 error - forbidden')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /restricted access/i })).toBeInTheDocument(); + expect(screen.getByText(/Sorry, but it looks like you're trying to access a restricted area./i)).toBeInTheDocument(); + expect(screen.getByText(/Double-check permissions:/i)).toBeInTheDocument(); + expect(screen.getByText(/Ensure you have the proper clearance to access this page/i)).toBeInTheDocument(); + expect(screen.getByText(/Login again:/i)).toBeInTheDocument(); + expect(screen.getByText(/Try logging in again. Maybe that's the missing key./i)).toBeInTheDocument(); + expect(screen.getByText(/Explore elsewhere:/i)).toBeInTheDocument(); + expect(screen.getByText(/Return to the main area and explore other permitted sections./i)).toBeInTheDocument(); + expect(screen.getByText(/If you believe this is an error or need further/i)).toBeInTheDocument(); + }); + + // Write a test to pass the response code 403 to the component. + it('renders a 403 error message', async () => { + renderSomethingWentWrong(403); + + expect(screen.getByText('403 error - forbidden')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /restricted access/i })).toBeInTheDocument(); + expect(screen.getByText(/Sorry, but it looks like you're trying to access a restricted area./i)).toBeInTheDocument(); + expect(screen.getByText(/Double-check permissions:/i)).toBeInTheDocument(); + expect(screen.getByText(/Ensure you have the proper clearance to access this page/i)).toBeInTheDocument(); + expect(screen.getByText(/Login again:/i)).toBeInTheDocument(); + expect(screen.getByText(/Try logging in again. Maybe that's the missing key./i)).toBeInTheDocument(); + expect(screen.getByText(/Explore elsewhere:/i)).toBeInTheDocument(); + expect(screen.getByText(/Return to the main area and explore other permitted sections./i)).toBeInTheDocument(); + expect(screen.getByText(/If you believe this is an error or need further/i)).toBeInTheDocument(); + }); + + // Write a test to pass the response code 404 to the component. + it('renders a 404 error message', async () => { + renderSomethingWentWrong(404); + + expect(screen.getByText('404 error')).toBeInTheDocument(); + expect(screen.getByText('Page not found.')).toBeInTheDocument(); + expect(screen.getByText(/Well, this is awkward. It seems like the page/i)).toBeInTheDocument(); + expect(screen.getByText(/home/i)).toBeInTheDocument(); + expect(screen.getByText(/support/i)).toBeInTheDocument(); + expect(screen.getByText(/thanks for your understanding and patience/i)).toBeInTheDocument(); + }); + + // Write a test to pass an unknown response code to the component. + it('renders a generic error message', async () => { + renderSomethingWentWrong(); + expect(screen.getByRole('heading', { name: /something went wrong/i })).toBeInTheDocument(); + expect(screen.getByText(/Well, this is awkward. It seems like the page you're looking for has taken a detour into the unknown. Here's what you can do:/i)).toBeInTheDocument(); + expect(screen.getByText(/Thanks for your understanding and patience!/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/AccountManagement/Group.js b/frontend/src/pages/AccountManagement/Group.js index 1bb94dbad6..fa38ba8413 100644 --- a/frontend/src/pages/AccountManagement/Group.js +++ b/frontend/src/pages/AccountManagement/Group.js @@ -4,16 +4,15 @@ import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import { Alert } from '@trussworks/react-uswds'; import colors from '../../colors'; import { fetchGroup } from '../../fetchers/groups'; import AppLoadingContext from '../../AppLoadingContext'; import WidgetCard from '../../components/WidgetCard'; import ReadOnlyField from '../../components/ReadOnlyField'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; export default function Group({ match }) { const { groupId } = match.params; - const [error, setError] = useState(null); const [group, setGroup] = useState({ name: '', @@ -21,6 +20,7 @@ export default function Group({ match }) { }); const { setIsAppLoading } = useContext(AppLoadingContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); useEffect(() => { async function getGroup() { @@ -29,7 +29,7 @@ export default function Group({ match }) { const existingGroupData = await fetchGroup(groupId); setGroup(existingGroupData); } catch (err) { - setError('There was an error fetching your group'); + setErrorResponseCode(err.status); } finally { setIsAppLoading(false); } @@ -39,7 +39,7 @@ export default function Group({ match }) { if (groupId) { getGroup(); } - }, [groupId, setIsAppLoading]); + }, [groupId, setIsAppLoading, setErrorResponseCode]); if (!group) { return null; @@ -85,11 +85,6 @@ export default function Group({ match }) { {group.name}} > - {error ? ( - - {error} - - ) : null} {group && group.creator ? group.creator.name : ''} diff --git a/frontend/src/pages/AccountManagement/MyGroups.js b/frontend/src/pages/AccountManagement/MyGroups.js index 73870a2194..a1dceb8fbc 100644 --- a/frontend/src/pages/AccountManagement/MyGroups.js +++ b/frontend/src/pages/AccountManagement/MyGroups.js @@ -21,6 +21,7 @@ import { import { MyGroupsContext } from '../../components/MyGroupsProvider'; import AppLoadingContext from '../../AppLoadingContext'; import QuestionTooltip from '../../components/GoalForm/QuestionTooltip'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; const mapSelectedRecipients = (grants) => grants.map((grant) => ({ value: grant.id, @@ -73,6 +74,7 @@ export default function MyGroups({ match }) { }, }); const { user } = useContext(UserContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const watchIsPrivate = watch(GROUP_FIELD_NAMES.IS_PRIVATE); const watchShareWithEveryone = watch(GROUP_FIELD_NAMES.SHARE_WITH_EVERYONE); const watchCoOwners = watch(GROUP_FIELD_NAMES.CO_OWNERS); @@ -112,7 +114,7 @@ export default function MyGroups({ match }) { }); } } catch (err) { - setError('There was an error fetching your group'); + setErrorResponseCode(err.status); } finally { setIsAppLoading(false); } @@ -122,7 +124,7 @@ export default function MyGroups({ match }) { if (groupId && usersFetched && recipientsFetched) { getGroup(); } - }, [groupId, setIsAppLoading, reset, usersFetched, recipientsFetched]); + }, [groupId, setIsAppLoading, reset, usersFetched, recipientsFetched, setErrorResponseCode]); const isCreator = !groupId || (groupCreator && user.id === groupCreator.id); diff --git a/frontend/src/pages/AccountManagement/__tests__/Group.js b/frontend/src/pages/AccountManagement/__tests__/Group.js index 5d1663e5ed..9ae5a29627 100644 --- a/frontend/src/pages/AccountManagement/__tests__/Group.js +++ b/frontend/src/pages/AccountManagement/__tests__/Group.js @@ -3,12 +3,14 @@ import { act, render, screen, + waitFor, } from '@testing-library/react'; import fetchMock from 'fetch-mock'; import join from 'url-join'; import { MemoryRouter } from 'react-router'; import Group from '../Group'; import AppLoadingContext from '../../../AppLoadingContext'; +import SomethingWentWrong from '../../../SomethingWentWrongContext'; const endpoint = join('/', 'api', 'groups'); @@ -17,11 +19,13 @@ describe('Group', () => { fetchMock.restore(); }); - const renderGroup = (groupId) => { + const renderGroup = (groupId, setErrorResponseCode = jest.fn()) => { render( - + + + , ); @@ -76,35 +80,36 @@ describe('Group', () => { it('handles null response', async () => { fetchMock.get(join(endpoint, '1'), null); - - act(() => { - renderGroup(1); + const setErrorResponseCode = jest.fn(); + act(async () => { + renderGroup(1, setErrorResponseCode); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalledWith(null); + }); }); - - const error = await screen.findByText('There was an error fetching your group'); - expect(error).toBeInTheDocument(); }); it('handles 404', async () => { fetchMock.get(join(endpoint, '1'), 404); - - act(() => { - renderGroup(1); + const setErrorResponseCode = jest.fn(); + act(async () => { + renderGroup(1, setErrorResponseCode); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalledWith(404); + }); }); - - const error = await screen.findByText('There was an error fetching your group'); - expect(error).toBeInTheDocument(); }); it('handles 500', async () => { fetchMock.get(join(endpoint, '1'), 500); - act(() => { - renderGroup(1); + const setErrorResponseCode = jest.fn(); + await act(async () => { + renderGroup(1, setErrorResponseCode); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalledWith(500); + }); }); - - const error = await screen.findByText('There was an error fetching your group'); - expect(error).toBeInTheDocument(); }); it('handles no group id', async () => { diff --git a/frontend/src/pages/AccountManagement/__tests__/MyGroups.js b/frontend/src/pages/AccountManagement/__tests__/MyGroups.js index c61ea5508c..9516cd8f71 100644 --- a/frontend/src/pages/AccountManagement/__tests__/MyGroups.js +++ b/frontend/src/pages/AccountManagement/__tests__/MyGroups.js @@ -14,6 +14,7 @@ import MyGroups, { GROUP_FIELD_NAMES } from '../MyGroups'; import MyGroupsProvider from '../../../components/MyGroupsProvider'; import AppLoadingContext from '../../../AppLoadingContext'; import UserContext from '../../../UserContext'; +import SomethingWentWrongContext from '../../../SomethingWentWrongContext'; const error = 'This group name already exists, please use a different name'; @@ -22,15 +23,17 @@ const user = { }; describe('MyGroups', () => { - const renderMyGroups = (groupId = null) => { + const renderMyGroups = (groupId = null, setErrorResponseCode = jest.fn()) => { render( - - - - - + + + + + + + , ); @@ -214,13 +217,12 @@ describe('MyGroups', () => { it('handles fetch errors', async () => { fetchMock.get('/api/group/1', 500); - - act(() => { - renderMyGroups(1); - }); - - await waitFor(() => { - expect(screen.getByText(/There was an error fetching your group/i)).toBeInTheDocument(); + const setErrorResponseCode = jest.fn(); + await act(async () => { + renderMyGroups(1, setErrorResponseCode); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalled(); + }); }); }); diff --git a/frontend/src/pages/ActivityReport/__tests__/index.js b/frontend/src/pages/ActivityReport/__tests__/index.js index 16f09e1912..017b0ca7f4 100644 --- a/frontend/src/pages/ActivityReport/__tests__/index.js +++ b/frontend/src/pages/ActivityReport/__tests__/index.js @@ -135,6 +135,15 @@ describe('ActivityReport', () => { }); }); + describe('something went wrong context', () => { + it('ensure we call set the response code on error', async () => { + fetchMock.get('/api/activity-reports/1', 500); + const setErrorResponseCode = jest.fn(); + renderActivityReport('1', 'activity-summary', null, 1, setErrorResponseCode); + await waitFor(() => expect(setErrorResponseCode).toHaveBeenCalledWith(500)); + }); + }); + describe('groups', () => { it('recipients correctly update for groups', async () => { const groupRecipients = { diff --git a/frontend/src/pages/ActivityReport/index.js b/frontend/src/pages/ActivityReport/index.js index e8902bc549..41bded708a 100644 --- a/frontend/src/pages/ActivityReport/index.js +++ b/frontend/src/pages/ActivityReport/index.js @@ -45,6 +45,7 @@ import { import useLocalStorage, { setConnectionActiveWithError } from '../../hooks/useLocalStorage'; import NetworkContext, { isOnlineMode } from '../../NetworkContext'; import UserContext from '../../UserContext'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; const defaultValues = { ECLKCResourcesUsed: [], @@ -202,6 +203,7 @@ function ActivityReport({ const [creatorNameWithRole, updateCreatorRoleWithName] = useState(''); const reportId = useRef(); const { user } = useContext(UserContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const { socket, @@ -256,7 +258,13 @@ function ActivityReport({ reportId.current = activityReportId; if (activityReportId !== 'new') { - const fetchedReport = await getReport(activityReportId); + let fetchedReport; + try { + fetchedReport = await getReport(activityReportId); + } catch (e) { + // If error retrieving the report show the "something went wrong" page. + setErrorResponseCode(e.status); + } report = convertReportToFormData(fetchedReport); } else { report = { diff --git a/frontend/src/pages/ActivityReport/testHelpers.js b/frontend/src/pages/ActivityReport/testHelpers.js index 13c8b2a889..60808ea50e 100644 --- a/frontend/src/pages/ActivityReport/testHelpers.js +++ b/frontend/src/pages/ActivityReport/testHelpers.js @@ -11,6 +11,7 @@ import moment from 'moment'; import ActivityReport from './index'; import UserContext from '../../UserContext'; import AppLoadingContext from '../../AppLoadingContext'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; export const history = createMemoryHistory(); @@ -66,33 +67,37 @@ export const ReportComponent = ({ currentPage = 'activity-summary', showLastUpdatedTime = null, userId = 1, + setErrorResponseCode = jest.fn(), }) => ( - - - - - + + + + + + + ); -export const renderActivityReport = (id, currentPage = 'activity-summary', showLastUpdatedTime = null, userId = 1) => { +export const renderActivityReport = (id, currentPage = 'activity-summary', showLastUpdatedTime = null, userId = 1, setErrorResponseCode = jest.fn()) => { render( , ); }; diff --git a/frontend/src/pages/ApprovedActivityReport/__tests__/index.js b/frontend/src/pages/ApprovedActivityReport/__tests__/index.js index 1bb5e8e6ce..4f3cadcd63 100644 --- a/frontend/src/pages/ApprovedActivityReport/__tests__/index.js +++ b/frontend/src/pages/ApprovedActivityReport/__tests__/index.js @@ -10,6 +10,7 @@ import { } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; +import SomethingWentWrongContext from '../../../SomethingWentWrongContext'; import ApprovedActivityReport from '../index'; @@ -104,7 +105,7 @@ describe('Activity report print and share view', () => { ], }; - function renderApprovedActivityReport(id, passedUser = user) { + function renderApprovedActivityReport(id, passedUser = user, setErrorResponseCode = jest.fn()) { const match = { path: '', url: '', @@ -113,7 +114,11 @@ describe('Activity report print and share view', () => { }, }; - render(); + render( + + + , + ); } afterEach(() => fetchMock.restore()); @@ -147,7 +152,7 @@ describe('Activity report print and share view', () => { }], requester: 'chud', }); - fetchMock.get('/api/activity-reports/5002', 500); + fetchMock.get('/api/activity-reports/5002', { status: 500 }); fetchMock.get('/api/activity-reports/5003', { ...report, @@ -204,7 +209,7 @@ describe('Activity report print and share view', () => { version: null, }); - fetchMock.get('/api/activity-reports/5007', 401); + fetchMock.get('/api/activity-reports/5007', { status: 401 }); }); it('renders an activity report in clean view', async () => { @@ -224,18 +229,22 @@ describe('Activity report print and share view', () => { }); it('handles authorization errors', async () => { - act(() => renderApprovedActivityReport(5007)); + const setErrorResponseCode = jest.fn(); + act(() => renderApprovedActivityReport(5007, user, setErrorResponseCode)); await waitFor(() => { - expect(screen.getByText(/sorry, you are not allowed to view this report/i)).toBeInTheDocument(); + expect(fetchMock.called('/api/activity-reports/5007')).toBeTruthy(); + expect(setErrorResponseCode).toHaveBeenCalledWith(401); }); }); it('handles data errors', async () => { - act(() => renderApprovedActivityReport(5002)); + const setErrorResponseCode = jest.fn(); + act(() => renderApprovedActivityReport(5002, user, setErrorResponseCode)); await waitFor(() => { - expect(screen.getByText(/sorry, something went wrong\./i)).toBeInTheDocument(); + expect(fetchMock.called('/api/activity-reports/5002')).toBeTruthy(); + expect(setErrorResponseCode).toHaveBeenCalledWith(500); }); }); @@ -306,17 +315,13 @@ describe('Activity report print and share view', () => { }); }); - it('renders a version 2 report with goals', async () => { - act(() => renderApprovedActivityReport(5005)); - await waitFor(() => { - expect(screen.getByText(report.author.fullName)).toBeInTheDocument(); - }); - }); - it('handles a malformed url', async () => { - act(() => renderApprovedActivityReport('butter-lover')); - await waitFor(() => { - expect(screen.getByText(/sorry, something went wrong\./i)).toBeInTheDocument(); + const setErrorResponseCode = jest.fn(); + act(async () => { + renderApprovedActivityReport('butter-lover', user, setErrorResponseCode); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalledWith(404); + }); }); }); @@ -338,4 +343,11 @@ describe('Activity report print and share view', () => { global.localStorage = oldLocalStorage; }); + + it('renders a version 2 report with goals', async () => { + act(() => renderApprovedActivityReport(5005)); + await waitFor(() => { + expect(screen.getByText(report.author.fullName)).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/pages/ApprovedActivityReport/index.js b/frontend/src/pages/ApprovedActivityReport/index.js index 4028d78c25..c595242aaf 100644 --- a/frontend/src/pages/ApprovedActivityReport/index.js +++ b/frontend/src/pages/ApprovedActivityReport/index.js @@ -1,4 +1,6 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { + useEffect, useState, useRef, useContext, +} from 'react'; import PropTypes from 'prop-types'; import ReactRouterPropTypes from 'react-router-prop-types'; import { Redirect } from 'react-router-dom'; @@ -16,10 +18,10 @@ import './index.scss'; import ApprovedReportV1 from './components/ApprovedReportV1'; import ApprovedReportV2 from './components/ApprovedReportV2'; import ApprovedReportSpecialButtons from '../../components/ApprovedReportSpecialButtons'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; export default function ApprovedActivityReport({ match, user }) { - const [notAuthorized, setNotAuthorized] = useState(false); - const [somethingWentWrong, setSomethingWentWrong] = useState(false); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const [justUnlocked, updatedJustUnlocked] = useState(false); @@ -75,7 +77,6 @@ export default function ApprovedActivityReport({ match, user }) { useEffect(() => { if (!parseInt(match.params.activityReportId, 10)) { - setSomethingWentWrong(true); return; } @@ -85,54 +86,13 @@ export default function ApprovedActivityReport({ match, user }) { // review and submit table setReport(data); } catch (err) { - if (err && err.status && (err.status >= 400 && err.status < 500)) { - setNotAuthorized(true); - return; - } - - // eslint-disable-next-line no-console - console.log(err); - setSomethingWentWrong(true); + setErrorResponseCode(err.status); } } fetchReport(); - }, [match.params.activityReportId, user]); - - if (notAuthorized) { - return ( - <> - - Not Authorized To View Activity Report - -
-
-

Unauthorized

-

- Sorry, you are not allowed to view this report -

-
-
- - ); - } + }, [match.params.activityReportId, user, setErrorResponseCode]); - if (somethingWentWrong) { - return ( - <> - - Error Displaying Activity Report - -
-
-

- Sorry, something went wrong. -

-
-
- - ); - } const { id: reportId, displayId, diff --git a/frontend/src/pages/RecipientRecord/__tests__/index.js b/frontend/src/pages/RecipientRecord/__tests__/index.js index 46fe31fe71..f017adce5e 100644 --- a/frontend/src/pages/RecipientRecord/__tests__/index.js +++ b/frontend/src/pages/RecipientRecord/__tests__/index.js @@ -10,6 +10,7 @@ import { createMemoryHistory } from 'history'; import RecipientRecord, { PageWithHeading } from '../index'; import { formatDateRange } from '../../../utils'; import UserContext from '../../../UserContext'; +import SomethingWentWrongContext from '../../../SomethingWentWrongContext'; import AppLoadingContext from '../../../AppLoadingContext'; import { GrantDataProvider } from '../pages/GrantDataContext'; @@ -89,7 +90,7 @@ describe('recipient record page', () => { ], }; - function renderRecipientRecord(history = memoryHistory, regionId = '45') { + function renderRecipientRecord(history = memoryHistory, regionId = '45', setErrorResponseCode = jest.fn()) { const match = { path: '', url: '', @@ -101,20 +102,22 @@ describe('recipient record page', () => { render( - - - + + + - - - - + > + + + + + , ); } @@ -192,17 +195,21 @@ describe('recipient record page', () => { it('handles recipient not found', async () => { fetchMock.get('/api/recipient/1/region/45/merge-permissions', { canMergeGoalsForRecipient: false }); fetchMock.get('/api/recipient/1?region.in[]=45', 404); - act(() => renderRecipientRecord()); - const error = await screen.findByText('Recipient record not found'); - expect(error).toBeInTheDocument(); + const setErrorResponseCode = jest.fn(); + await act(async () => renderRecipientRecord(memoryHistory, '45', setErrorResponseCode)); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalledWith(404); + }); }); it('handles fetch error', async () => { fetchMock.get('/api/recipient/1/region/45/merge-permissions', { canMergeGoalsForRecipient: false }); fetchMock.get('/api/recipient/1?region.in[]=45', 500); - act(() => renderRecipientRecord()); - const error = await screen.findByText('There was an error fetching recipient data'); - expect(error).toBeInTheDocument(); + const setErrorResponseCode = jest.fn(); + act(() => renderRecipientRecord(memoryHistory, '45', setErrorResponseCode)); + await waitFor(() => { + expect(setErrorResponseCode).toHaveBeenCalledWith(500); + }); }); it('navigates to the profile page', async () => { @@ -248,7 +255,7 @@ describe('recipient record page', () => { fetchMock.get('/api/goals/12389/recipient/45', mockGoal); fetchMock.get('/api/topic', []); memoryHistory.push('/recipient-tta-records/45/region/1/goals/12389'); - act(() => renderRecipientRecord()); + await act(() => renderRecipientRecord()); await waitFor(() => expect(screen.queryByText(/loading.../)).toBeNull()); await screen.findByText(/TTA Goals for the Mighty Recipient/i); }); @@ -268,9 +275,10 @@ describe('recipient record page', () => { fetchMock.get('/api/recipient/1?region.in[]=45', theMightyRecipient); fetchMock.get('/api/communication-logs/region/1/log/1', 404); memoryHistory.push('/recipient-tta-records/45/region/1/communication/1/view'); - act(() => renderRecipientRecord()); + const setErrorResponseCode = jest.fn(); + act(() => renderRecipientRecord(memoryHistory, '45', setErrorResponseCode)); await waitFor(() => expect(screen.queryByText(/loading.../)).toBeNull()); - await screen.findByText(/There was an error fetching the communication log/i); + await waitFor(() => expect(setErrorResponseCode).toHaveBeenCalledWith(404)); }); it('navigates to the communication log form', async () => { diff --git a/frontend/src/pages/RecipientRecord/index.js b/frontend/src/pages/RecipientRecord/index.js index 4f29bc68ec..03a7f6ac4a 100644 --- a/frontend/src/pages/RecipientRecord/index.js +++ b/frontend/src/pages/RecipientRecord/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import PropTypes from 'prop-types'; import ReactRouterPropTypes from 'react-router-prop-types'; import useDeepCompareEffect from 'use-deep-compare-effect'; @@ -8,7 +8,6 @@ import { Switch, Route } from 'react-router'; import { DECIMAL_BASE } from '@ttahub/common'; import { getMergeGoalPermissions, getRecipient } from '../../fetchers/recipient'; import RecipientTabs from './components/RecipientTabs'; -import { HTTPError } from '../../fetchers'; import './index.scss'; import Profile from './pages/Profile'; import TTAHistory from './pages/TTAHistory'; @@ -23,6 +22,7 @@ import CommunicationLogForm from './pages/CommunicationLogForm'; import ViewCommunicationLog from './pages/ViewCommunicationLog'; import { GrantDataProvider } from './pages/GrantDataContext'; import ViewGoals from './pages/ViewGoals'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; export function PageWithHeading({ children, @@ -34,7 +34,6 @@ export function PageWithHeading({ slug, }) { const headerMargin = backLink.props.children ? 'margin-top-0' : 'margin-top-5'; - return (
@@ -77,6 +76,7 @@ PageWithHeading.defaultProps = { }; export default function RecipientRecord({ match, hasAlerts }) { + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const { recipientId, regionId } = match.params; const [loading, setLoading] = useState(true); @@ -92,7 +92,6 @@ export default function RecipientRecord({ match, hasAlerts }) { recipientName: '', }); - const [error, setError] = useState(); const [canMergeGoals, setCanMergeGoals] = useState(false); useEffect(() => { @@ -124,11 +123,7 @@ export default function RecipientRecord({ match, hasAlerts }) { }); } } catch (e) { - if (e instanceof HTTPError && e.status === 404) { - setError('Recipient record not found'); - } else { - setError('There was an error fetching recipient data'); - } + setErrorResponseCode(e.status); } finally { setLoading(false); } @@ -162,7 +157,6 @@ export default function RecipientRecord({ match, hasAlerts }) { @@ -202,7 +195,6 @@ export default function RecipientRecord({ match, hasAlerts }) { @@ -327,7 +318,6 @@ export default function RecipientRecord({ match, hasAlerts }) { @@ -345,7 +335,6 @@ export default function RecipientRecord({ match, hasAlerts }) { diff --git a/frontend/src/pages/RecipientRecord/pages/Profile.js b/frontend/src/pages/RecipientRecord/pages/Profile.js index 960c453bf8..c3211bcd92 100644 --- a/frontend/src/pages/RecipientRecord/pages/Profile.js +++ b/frontend/src/pages/RecipientRecord/pages/Profile.js @@ -15,7 +15,7 @@ export default function Profile({ regionId, recipientId, }) { - const activeGrants = recipientSummary.grants.filter((grant) => grant.status === 'Active'); + const activeGrants = (recipientSummary.grants || []).filter((grant) => grant.status === 'Active'); const { hasMonitoringData, hasClassData } = useGrantData(); return ( diff --git a/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/__tests__/index.js b/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/__tests__/index.js index 48fa850182..e91554ed39 100644 --- a/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/__tests__/index.js +++ b/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/__tests__/index.js @@ -10,6 +10,7 @@ import UserContext from '../../../../../UserContext'; import AppLoadingContext from '../../../../../AppLoadingContext'; import { NOT_STARTED, COMPLETE } from '../../../../../components/Navigator/constants'; import ViewCommunicationForm from '../index'; +import SomethingWentWrongContext from '../../../../../SomethingWentWrongContext'; const RECIPIENT_ID = 1; const REGION_ID = 1; @@ -26,23 +27,26 @@ describe('ViewCommunicationForm', () => { const renderTest = ( communicationLogId = '1', + setErrorResponseCode = jest.fn(), ) => render( - - - + + + + + , ); @@ -103,13 +107,14 @@ describe('ViewCommunicationForm', () => { it('shows error message', async () => { const url = `${communicationLogUrl}/region/${REGION_ID}/log/1`; + const setErrorResponseCode = jest.fn(); fetchMock.get(url, 500); - - await act(() => waitFor(() => { - renderTest(); - })); - - expect(await screen.findByText(/There was an error fetching the communication log/i)).toBeInTheDocument(); + await act(async () => { + await waitFor(() => { + renderTest('1', setErrorResponseCode); + expect(setErrorResponseCode).toHaveBeenCalledWith(500); + }); + }); }); it('should render the view without edit button', async () => { diff --git a/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/index.js b/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/index.js index a1a93cf2ff..65d972fad9 100644 --- a/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/index.js +++ b/frontend/src/pages/RecipientRecord/pages/ViewCommunicationLog/index.js @@ -2,7 +2,6 @@ import React, { useEffect, useState, useContext } from 'react'; import moment from 'moment'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; -import { Alert } from '@trussworks/react-uswds'; import ReactRouterPropTypes from 'react-router-prop-types'; import { Link } from 'react-router-dom'; import Container from '../../../../components/Container'; @@ -13,6 +12,7 @@ import BackLink from '../../../../components/BackLink'; import UserContext from '../../../../UserContext'; import DisplayNextSteps from './components/DisplayNextSteps'; import LogLine from './components/LogLine'; +import SomethingWentWrongContext from '../../../../SomethingWentWrongContext'; export default function ViewCommunicationLog({ match, recipientName }) { const { @@ -24,9 +24,9 @@ export default function ViewCommunicationLog({ match, recipientName }) { } = match; const { user } = useContext(UserContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const { setIsAppLoading } = useContext(AppLoadingContext); const [log, setLog] = useState(); - const [error, setError] = useState(); const isAuthor = log && log.author && log.author.id === user.id; @@ -37,29 +37,21 @@ export default function ViewCommunicationLog({ match, recipientName }) { const response = await getCommunicationLogById(regionId, communicationLogId); setLog(response); } catch (err) { - setError('There was an error fetching the communication log.'); + setErrorResponseCode(err.status); } finally { setIsAppLoading(false); } } - if (!log && !error) { + if (!log) { fetchLog(); } - }, [communicationLogId, error, log, regionId, setIsAppLoading]); + }, [communicationLogId, log, regionId, setIsAppLoading, setErrorResponseCode]); - if (!log && !error) { + if (!log) { return null; } - if (error) { - return ( - - {error} - - ); - } - return ( <> diff --git a/frontend/src/pages/SessionForm/__tests__/index.js b/frontend/src/pages/SessionForm/__tests__/index.js index 3100c15f04..257053deba 100644 --- a/frontend/src/pages/SessionForm/__tests__/index.js +++ b/frontend/src/pages/SessionForm/__tests__/index.js @@ -13,22 +13,30 @@ import UserContext from '../../../UserContext'; import AppLoadingContext from '../../../AppLoadingContext'; import { COMPLETE, IN_PROGRESS } from '../../../components/Navigator/constants'; import { mockRSSData } from '../../../testHelpers'; +import SomethingWentWrongContext from '../../../SomethingWentWrongContext'; describe('SessionReportForm', () => { const sessionsUrl = join('/', 'api', 'session-reports'); const history = createMemoryHistory(); - const renderSessionForm = (trainingReportId, currentPage, sessionId) => render( + const renderSessionForm = ( + trainingReportId, + currentPage, + sessionId, + setErrorResponseCode = jest.fn, + ) => render( - - - + + + + + , ); @@ -96,21 +104,18 @@ describe('SessionReportForm', () => { expect(screen.getByText(/Training report - Session/i)).toBeInTheDocument(); }); - it('handles an error fetching a session', async () => { + it('sets response error', async () => { const url = join(sessionsUrl, 'id', '1'); fetchMock.get( url, 500, ); - + const setErrorResponseCode = jest.fn(); act(() => { - renderSessionForm('1', 'session-summary', '1'); + renderSessionForm('1', 'session-summary', '1', setErrorResponseCode); }); - await waitFor(() => expect(fetchMock.called(url)).toBe(true)); - - expect(screen.getByText(/Training report - Session/i)).toBeInTheDocument(); - expect(screen.getByText(/Error fetching session/i)).toBeInTheDocument(); + expect(setErrorResponseCode).toHaveBeenCalledWith(500); }); it('saves draft', async () => { diff --git a/frontend/src/pages/SessionForm/index.js b/frontend/src/pages/SessionForm/index.js index 52e322f395..ed0853765d 100644 --- a/frontend/src/pages/SessionForm/index.js +++ b/frontend/src/pages/SessionForm/index.js @@ -21,6 +21,7 @@ import Navigator from '../../components/Navigator'; import BackLink from '../../components/BackLink'; import pages from './pages'; import AppLoadingContext from '../../AppLoadingContext'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; // websocket publish location interval const INTERVAL_DELAY = 10000; // TEN SECONDS @@ -96,6 +97,7 @@ export default function SessionForm({ match }) { const { user } = useContext(UserContext); const { setIsAppLoading } = useContext(AppLoadingContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const { socket, @@ -158,14 +160,14 @@ export default function SessionForm({ match }) { resetFormData(hookForm.reset, session); reportId.current = session.id; } catch (e) { - setError('Error fetching session'); + setErrorResponseCode(e.status); } finally { setReportFetched(true); setDatePickerKey(`f${Date.now().toString()}`); } } fetchSession(); - }, [currentPage, hookForm.reset, reportFetched, sessionId]); + }, [currentPage, hookForm.reset, reportFetched, sessionId, setErrorResponseCode]); // hook to update the page state in the sidebar useHookFormPageState(hookForm, pages, currentPage); diff --git a/frontend/src/pages/TrainingReportForm/__tests__/index.js b/frontend/src/pages/TrainingReportForm/__tests__/index.js index 6e21bc51bd..b5638f4d53 100644 --- a/frontend/src/pages/TrainingReportForm/__tests__/index.js +++ b/frontend/src/pages/TrainingReportForm/__tests__/index.js @@ -11,21 +11,27 @@ import TrainingReportForm from '../index'; import UserContext from '../../../UserContext'; import AppLoadingContext from '../../../AppLoadingContext'; import { COMPLETE } from '../../../components/Navigator/constants'; +import SomethingWentWrong from '../../../SomethingWentWrongContext'; describe('TrainingReportForm', () => { const history = createMemoryHistory(); const sessionsUrl = '/api/session-reports/eventId/1234'; - const renderTrainingReportForm = (trainingReportId, currentPage) => render( + const renderTrainingReportForm = ( + trainingReportId, currentPage, + setErrorResponseCode = jest.fn, + ) => render( - + + + , @@ -65,6 +71,15 @@ describe('TrainingReportForm', () => { expect(screen.getByText(/Training report - Event/i)).toBeInTheDocument(); }); + it('calls setErrorResponseCode when an error occurs', async () => { + fetchMock.get('/api/events/id/1', 500); + const setErrorResponseCode = jest.fn(); + act(() => { + renderTrainingReportForm('1', 'event-summary', setErrorResponseCode); + }); + await waitFor(() => expect(setErrorResponseCode).toHaveBeenCalledWith(500)); + }); + it('redirects to event summary', async () => { fetchMock.get('/api/events/id/1', { id: 1, @@ -145,16 +160,6 @@ describe('TrainingReportForm', () => { expect(fetchMock.called('/api/events/id/123')).toBe(true); }); - it('displays error when event report fails to load', async () => { - fetchMock.get('/api/events/id/123', 500); - act(() => { - renderTrainingReportForm('123', 'event-summary'); - }); - - expect(fetchMock.called('/api/events/id/123')).toBe(true); - expect(await screen.findByText(/error fetching training report/i)).toBeInTheDocument(); - }); - it('displays "no training report id provided" error', async () => { fetchMock.get('/api/events/id/123', { regionId: '1', diff --git a/frontend/src/pages/TrainingReportForm/index.js b/frontend/src/pages/TrainingReportForm/index.js index 763415daa2..9299bdf2df 100644 --- a/frontend/src/pages/TrainingReportForm/index.js +++ b/frontend/src/pages/TrainingReportForm/index.js @@ -25,6 +25,7 @@ import BackLink from '../../components/BackLink'; import pages from './pages'; import AppLoadingContext from '../../AppLoadingContext'; import useHookFormPageState from '../../hooks/useHookFormPageState'; +import SomethingWentWrongContext from '../../SomethingWentWrongContext'; // websocket publish location interval const INTERVAL_DELAY = 10000; // TEN SECONDS @@ -125,6 +126,7 @@ export default function TrainingReportForm({ match }) { const { user } = useContext(UserContext); const { setIsAppLoading, isAppLoading } = useContext(AppLoadingContext); + const { setErrorResponseCode } = useContext(SomethingWentWrongContext); const { socket, @@ -179,14 +181,19 @@ export default function TrainingReportForm({ match }) { resetFormData(hookForm.reset, event); reportId.current = trainingReportId; } catch (e) { - setError('Error fetching training report'); + setErrorResponseCode(e.status); } finally { setReportFetched(true); setDatePickerKey(Date.now().toString()); } } fetchReport(); - }, [currentPage, hookForm.reset, isAppLoading, reportFetched, trainingReportId]); + }, [currentPage, + hookForm.reset, + isAppLoading, + reportFetched, + trainingReportId, + setErrorResponseCode]); useEffect(() => { // set error if no training report id diff --git a/similarity_api/src/requirements.txt b/similarity_api/src/requirements.txt index ec0b0315f2..df4b224127 100644 --- a/similarity_api/src/requirements.txt +++ b/similarity_api/src/requirements.txt @@ -2,7 +2,7 @@ annotated-types==0.5.0 blinker==1.6.2 blis==0.7.10 catalogue==2.0.9 -certifi==2023.7.22 +certifi==2024.7.4 charset-normalizer==3.2.0 click==8.1.6 confection==0.1.1 diff --git a/src/migrations/20240708000000-remove_national_center_ars.js b/src/migrations/20240708000000-remove_national_center_ars.js new file mode 100644 index 0000000000..c4dbc9863e --- /dev/null +++ b/src/migrations/20240708000000-remove_national_center_ars.js @@ -0,0 +1,917 @@ +const { + prepMigration, +} = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + await queryInterface.sequelize.query(` + --------------------------------------------------- + -- NOTE: + -- Files and Resources are most properly managed by + -- maintenance jobs, so this and similar migrations + -- won't delete them directly. Deleting the link + -- records will give the maintenance job the info + -- it needs to perform its housekeeping. + --------------------------------------------------- + -------- Deleting unwanted ARs -------- + -- Create the AR deletion list + -- Remove AR link records: ------------- + -- ActivityRecipients + -- ActivityReportApprovers + -- ActivityReportCollaborators + -- ActivityReportFiles (no need to remove Files) + -- ActivityReportResources (no need to remove Resources) + + -- Create the NextSteps deletion list + -- Remove NextSteps link records: ------------- + -- NextStepResources + -- remove NextSteps ------------- + + -- Create the ARO deletion list + -- Remove ARO link records: ------------- + -- ActivityReportObjectiveFiles + -- ActivityReportObjectiveResources + -- ActivityReportObjectiveTopics + -- ActivityReportObjectiveCourses + -- remove AROs ------------------- + + -- Create the orphaned Objective deletion list + -- Remove Objective link records: ------------- + -- Delete ObjectiveCollaborators + -- remove Objectives ------------- + + -- Create the ARG deletion list + -- Remove ARG link records: ------------- + -- ActivityReportGoalFieldResponses + -- ActivityReportGoalResources + -- remove ARGs ------------------- + + -- Create the orphaned Goal deletions list + -- ( check if isFromSmartsheetTtaPlan, isRttapa) + -- Remove Goal link records: ------------- + -- EventReportPilotGoals + -- GoalFieldResponses + -- GoalResources + -- remove Goals ------------------ + + -- Create the orphaned ObjectiveTemplate deletion list + -- Create the orphaned GoalTemplate deletion list + -- Remove GoalTemplate link records: ------------- + -- GoalTemplateObjectiveTemplates + -- Remove ObjectiveTemplates -------- + -- Remove GoalTemplates ------------- + + -- Remove ARs ----------------------- + + -- Test query + + -- Correct the onApprovedAR and onAR values for the goals + -- and objectives that were not deleted + + ------------------------------------------------------------------------------------------------------------------- + -------- Deleting unwanted ARs -------- + -- Create the AR deletion list + DROP TABLE IF EXISTS ars_to_delete; + CREATE TEMP TABLE ars_to_delete + AS + SELECT id arid + FROM "ActivityReports" + WHERE id IN (24998, 24645, 24297, 24122, 27517, 30829, 29864, 6442, 23057, 23718, 25205, 25792, 25577, 25573, 26478, 26210, 27117, 26918, 28451, 28117, 27669, 29542, 29101, 29024, 30137, 29762, 31201) + AND "regionId" = 10 + ; + + -- Remove AR link records: ------------- + DROP TABLE IF EXISTS deleted_activityrecipients; + CREATE TEMP TABLE deleted_activityrecipients AS + WITH deletes AS ( + DELETE FROM "ActivityRecipients" + USING ars_to_delete + WHERE "activityReportId" = arid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportapprovers; + CREATE TEMP TABLE deleted_activityreportapprovers AS + WITH deletes AS ( + DELETE FROM "ActivityReportApprovers" + USING ars_to_delete + WHERE "activityReportId" = arid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportcollaborators; + CREATE TEMP TABLE deleted_activityreportcollaborators AS + WITH deletes AS ( + DELETE FROM "ActivityReportCollaborators" + USING ars_to_delete + WHERE "activityReportId" = arid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportfiles; + CREATE TEMP TABLE deleted_activityreportfiles AS + WITH deletes AS ( + DELETE FROM "ActivityReportFiles" + USING ars_to_delete + WHERE "activityReportId" = arid + RETURNING + id, + "fileId" fid + ) + SELECT id, fid FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportresources; + CREATE TEMP TABLE deleted_activityreportresources AS + WITH deletes AS ( + DELETE FROM "ActivityReportResources" + USING ars_to_delete + WHERE "activityReportId" = arid + RETURNING + id, + "resourceId" resourceid + ) + SELECT id, resourceid FROM deletes + ; + + + + -- Create the NextSteps deletion list + DROP TABLE IF EXISTS nextsteps_to_delete; + CREATE TEMP TABLE nextsteps_to_delete + AS + SELECT + id nsid + FROM "NextSteps" + JOIN ars_to_delete + ON "activityReportId" = arid + ; + -- Remove NextSteps link records: ------------- + DROP TABLE IF EXISTS deleted_nextstepresources; + CREATE TEMP TABLE deleted_nextstepresources AS + WITH deletes AS ( + DELETE FROM "NextStepResources" + USING nextsteps_to_delete + WHERE "nextStepId" = nsid + RETURNING + id, + "resourceId" resourceid + ) + SELECT id, resourceid FROM deletes + ; + -- remove NextSteps ------------- + DROP TABLE IF EXISTS deleted_nextsteps; + CREATE TEMP TABLE deleted_nextsteps AS + WITH deletes AS ( + DELETE FROM "NextSteps" + USING nextsteps_to_delete + WHERE id = nsid + RETURNING + id + ) + SELECT id FROM deletes + ; + + + -- Create the ARO deletion list + DROP TABLE IF EXISTS aros_to_delete; + CREATE TEMP TABLE aros_to_delete + AS + SELECT + id aroid, + "objectiveId" oid + FROM "ActivityReportObjectives" + JOIN ars_to_delete + ON "activityReportId" = arid + ; + -- Remove ARO link records: ------------- + DROP TABLE IF EXISTS deleted_activityreportobjectivefiles; + CREATE TEMP TABLE deleted_activityreportobjectivefiles AS + WITH deletes AS ( + DELETE FROM "ActivityReportObjectiveFiles" + USING aros_to_delete + WHERE "activityReportObjectiveId" = aroid + RETURNING + id, + "fileId" fid + ) + SELECT id, fid FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportobjectiveresources; + CREATE TEMP TABLE deleted_activityreportobjectiveresources AS + WITH deletes AS ( + DELETE FROM "ActivityReportObjectiveResources" + USING aros_to_delete + WHERE "activityReportObjectiveId" = aroid + RETURNING + id, + "resourceId" resourceid + ) + SELECT id, resourceid FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportobjectivetopics; + CREATE TEMP TABLE deleted_activityreportobjectivetopics AS + WITH deletes AS ( + DELETE FROM "ActivityReportObjectiveTopics" + USING aros_to_delete + WHERE "activityReportObjectiveId" = aroid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportobjectivecourses; + CREATE TEMP TABLE deleted_activityreportobjectivecourses AS + WITH deletes AS ( + DELETE FROM "ActivityReportObjectiveCourses" + USING aros_to_delete + WHERE "activityReportObjectiveId" = aroid + RETURNING + id + ) + SELECT id FROM deletes + ; + -- remove AROs ------------------- + DROP TABLE IF EXISTS deleted_aros; + CREATE TEMP TABLE deleted_aros AS + WITH deletes AS ( + DELETE FROM "ActivityReportObjectives" + USING aros_to_delete + WHERE id = aroid + RETURNING + id, + "objectiveId" oid + ) + SELECT id, oid FROM deletes + ; + + -- Create the orphaned Objective deletion list + DROP TABLE IF EXISTS objectives_to_delete; + CREATE TEMP TABLE objectives_to_delete + AS + SELECT DISTINCT oid + FROM deleted_aros + EXCEPT + SELECT DISTINCT "objectiveId" + FROM "ActivityReportObjectives" + ; + -- Remove Objective link records: ------------- + -- Delete ObjectiveCollaborators + DROP TABLE IF EXISTS deleted_objectivecollaborators; + CREATE TEMP TABLE deleted_objectivecollaborators AS + WITH deletes AS ( + DELETE FROM "ObjectiveCollaborators" + USING objectives_to_delete + WHERE "objectiveId" = oid + RETURNING + id + ) + SELECT id FROM deletes + ; + + -- remove Objectives ------------------- + DROP TABLE IF EXISTS deleted_objectives; + CREATE TEMP TABLE deleted_objectives AS + WITH deletes AS ( + DELETE FROM "Objectives" + USING objectives_to_delete + WHERE id = oid + RETURNING + id, + "goalId" gid, + "objectiveTemplateId" otid + ) + SELECT id, gid, otid FROM deletes + ; + + -- Create the ARG deletion list + DROP TABLE IF EXISTS args_to_delete; + CREATE TEMP TABLE args_to_delete + AS + SELECT DISTINCT + id argid, + "goalId" gid + FROM "ActivityReportGoals" + JOIN ars_to_delete + ON "activityReportId" = arid + ; + -- Remove ARG link records: ------------- + DROP TABLE IF EXISTS deleted_activityreportgoalfieldresponses; + CREATE TEMP TABLE deleted_activityreportgoalfieldresponses AS + WITH deletes AS ( + DELETE FROM "ActivityReportGoalFieldResponses" + USING args_to_delete + WHERE "activityReportGoalId" = argid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_activityreportgoalresources; + CREATE TEMP TABLE deleted_activityreportgoalresources AS + WITH deletes AS ( + DELETE FROM "ActivityReportGoalResources" + USING args_to_delete + WHERE "activityReportGoalId" = argid + RETURNING + id, + "resourceId" resourceid + ) + SELECT id, resourceid FROM deletes + ; + -- remove ARGs ------------------- + DROP TABLE IF EXISTS deleted_args; + CREATE TEMP TABLE deleted_args AS + WITH deletes AS ( + DELETE FROM "ActivityReportGoals" + USING args_to_delete + WHERE id = argid + RETURNING + id, + "goalId" gid + ) + SELECT id, gid FROM deletes + ; + + -- Create the orphaned Goal deletions list + DROP TABLE IF EXISTS goals_to_delete; + CREATE TEMP TABLE goals_to_delete + AS + SELECT DISTINCT gid + FROM deleted_args dargs + JOIN "Goals" g + ON gid = g.id + WHERE (g."isRttapa" IS NULL OR g."isRttapa" != 'Yes') + AND g."isFromSmartsheetTtaPlan" != TRUE + AND g."createdVia" != 'merge' + EXCEPT + SELECT gid + FROM ( + SELECT DISTINCT "goalId" gid + FROM "ActivityReportGoals" + UNION + SELECT DISTINCT "goalId" + FROM "Objectives" + UNION + SELECT DISTINCT "goalId" + FROM "EventReportPilotGoals" + ) keepers + ; + -- Remove Goal link records: ------------- + DROP TABLE IF EXISTS deleted_goalcollaborators; + CREATE TEMP TABLE deleted_goalcollaborators AS + WITH deletes AS ( + DELETE FROM "GoalCollaborators" + USING goals_to_delete + WHERE "goalId" = gid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_goalfieldresponses; + CREATE TEMP TABLE deleted_goalfieldresponses AS + WITH deletes AS ( + DELETE FROM "GoalFieldResponses" + USING goals_to_delete + WHERE "goalId" = gid + RETURNING + id + ) + SELECT id FROM deletes + ; + DROP TABLE IF EXISTS deleted_goalresources; + CREATE TEMP TABLE deleted_goalresources AS + WITH deletes AS ( + DELETE FROM "GoalResources" + USING goals_to_delete + WHERE "goalId" = gid + RETURNING + id, + "resourceId" resourceid + ) + SELECT id, resourceid FROM deletes + ; + DROP TABLE IF EXISTS deleted_goalstatuschanges; + CREATE TEMP TABLE deleted_goalstatuschanges AS + WITH deletes AS ( + DELETE FROM "GoalStatusChanges" + USING goals_to_delete + WHERE "goalId" = gid + RETURNING + id, + "goalId" gid + ) + SELECT id, gid FROM deletes + ; + -- remove Goals ------------------- + DROP TABLE IF EXISTS deleted_goals; + CREATE TEMP TABLE deleted_goals AS + WITH deletes AS ( + DELETE FROM "Goals" + USING goals_to_delete + WHERE id = gid + RETURNING + id, + "goalTemplateId" gtid + ) + SELECT id, gtid FROM deletes + ; + + -- Create the orphaned ObjectiveTemplate deletion list + DROP TABLE IF EXISTS ots_to_delete; + CREATE TEMP TABLE ots_to_delete + AS + SELECT DISTINCT otid + FROM deleted_objectives + EXCEPT + SELECT DISTINCT "objectiveTemplateId" + FROM "Objectives" + ; + + -- Create the orphaned GoalTemplate deletion list + DROP TABLE IF EXISTS gts_to_delete; + CREATE TEMP TABLE gts_to_delete + AS + SELECT DISTINCT gtid + FROM deleted_goals + EXCEPT + SELECT DISTINCT "goalTemplateId" + FROM "Goals" + ; + -- Remove GoalTemplate link records: ------------- + DROP TABLE IF EXISTS deleted_goaltemplateobjectivetemplates; + CREATE TEMP TABLE deleted_goaltemplateobjectivetemplates AS + WITH unified_deletes AS ( + SELECT DISTINCT id gtotid + FROM "GoalTemplateObjectiveTemplates" + JOIN ots_to_delete + ON otid = "objectiveTemplateId" + UNION + SELECT DISTINCT id gtotid + FROM "GoalTemplateObjectiveTemplates" + JOIN gts_to_delete + ON gtid = "goalTemplateId" + ), + deletes AS ( + DELETE FROM "GoalTemplateObjectiveTemplates" + USING unified_deletes + WHERE id = gtotid + RETURNING + id + ) + SELECT id FROM deletes + ; + -- Remove ObjectiveTemplates -------- + DROP TABLE IF EXISTS deleted_objectivetemplates; + CREATE TEMP TABLE deleted_objectivetemplates AS + WITH deletes AS ( + DELETE FROM "ObjectiveTemplates" + USING ots_to_delete + WHERE id = otid + RETURNING + id + ) + SELECT id FROM deletes + ; + -- Remove GoalTemplates ------------- + DROP TABLE IF EXISTS deleted_goaltemplates; + CREATE TEMP TABLE deleted_goaltemplates AS + WITH deletes AS ( + DELETE FROM "GoalTemplates" + USING gts_to_delete + WHERE id = gtid + RETURNING + id + ) + SELECT id FROM deletes + ; + + -- Remove ARs ------------- + DROP TABLE IF EXISTS deleted_ars; + CREATE TEMP TABLE deleted_ars AS + WITH deletes AS ( + DELETE FROM "ActivityReports" + USING ars_to_delete + WHERE id = arid + RETURNING + id + ) + SELECT id FROM deletes + ; + + + -- Stats ---------------------------- + SELECT 1,'ars_to_delete', count(*) FROM ars_to_delete + UNION + SELECT 2,'deleted_activityreportapprovers', count(*) FROM deleted_activityreportapprovers + UNION + SELECT 3,'deleted_activityreportcollaborators', count(*) FROM deleted_activityreportcollaborators + UNION + SELECT 4,'deleted_activityreportfiles', count(*) FROM deleted_activityreportfiles + UNION + SELECT 5,'deleted_activityreportresources', count(*) FROM deleted_activityreportresources + UNION + SELECT 6,'nextsteps_to_delete', count(*) FROM nextsteps_to_delete + UNION + SELECT 7,'deleted_nextstepresources', count(*) FROM deleted_nextstepresources + UNION + SELECT 8,'deleted_nextsteps', count(*) FROM deleted_nextsteps + UNION + SELECT 9,'aros_to_delete', count(*) FROM aros_to_delete + UNION + SELECT 10,'deleted_activityreportobjectivefiles', count(*) FROM deleted_activityreportobjectivefiles + UNION + SELECT 11,'deleted_activityreportobjectiveresources', count(*) FROM deleted_activityreportobjectiveresources + UNION + SELECT 12,'deleted_activityreportobjectivetopics', count(*) FROM deleted_activityreportobjectivetopics + UNION + SELECT 12,'deleted_activityreportobjectivecourses', count(*) FROM deleted_activityreportobjectivetopics + UNION + SELECT 13,'deleted_aros', count(*) FROM deleted_aros + UNION + SELECT 14,'objectives_to_delete', count(*) FROM objectives_to_delete + UNION + SELECT 14,'deleted_objectivecollaborators', count(*) FROM objectives_to_delete + UNION + SELECT 15,'deleted_objectives', count(*) FROM deleted_objectives + UNION + SELECT 16,'args_to_delete', count(*) FROM args_to_delete + UNION + SELECT 17,'deleted_activityreportgoalfieldresponses', count(*) FROM deleted_activityreportgoalfieldresponses + UNION + SELECT 18,'deleted_activityreportgoalresources', count(*) FROM deleted_activityreportgoalresources + UNION + SELECT 19,'deleted_args', count(*) FROM deleted_args + UNION + SELECT 20,'goals_to_delete', count(*) FROM goals_to_delete + UNION + SELECT 21,'deleted_goalcollaborators', count(*) FROM deleted_goalcollaborators + UNION + SELECT 22,'deleted_goalfieldresponses', count(*) FROM deleted_goalfieldresponses + UNION + SELECT 23,'deleted_goalresources', count(*) FROM deleted_goalresources + UNION + SELECT 24,'deleted_goalstatuschanges', count(*) FROM deleted_goalstatuschanges + UNION + SELECT 25,'deleted_goals', count(*) FROM deleted_goals + UNION + SELECT 26,'ots_to_delete', count(*) FROM ots_to_delete + UNION + SELECT 27,'gts_to_delete', count(*) FROM gts_to_delete + UNION + SELECT 28,'deleted_goaltemplateobjectivetemplates', count(*) FROM deleted_goaltemplateobjectivetemplates + UNION + SELECT 29,'deleted_objectivetemplates', count(*) FROM deleted_objectivetemplates + UNION + SELECT 30,'deleted_goaltemplates', count(*) FROM deleted_goaltemplates + UNION + SELECT 31,'deleted_ars', count(*) FROM deleted_ars + ORDER BY 1 + ; + + -- Reset the onApprovedAR and onAR values for the goals and objectives that + -- were not deleted + -- 1. Calculate correct onApprovedAR values for objectives + DROP TABLE IF EXISTS objectives_on_ars; + CREATE TEMP TABLE objectives_on_ars + AS + WITH objectivelist AS ( + SELECT DISTINCT oid FROM aros_to_delete + EXCEPT + SELECT id FROM deleted_objectives + ) + SELECT + o.id oid, + BOOL_OR(ar.id IS NOT NULL AND ar."calculatedStatus" = 'approved') on_approved_ar, + BOOL_OR(ar.id IS NOT NULL) on_ar + FROM objectivelist ol + JOIN "Objectives" o + ON ol.oid = o.id + LEFT JOIN "ActivityReportObjectives" aro + ON o.id = aro."objectiveId" + LEFT JOIN "ActivityReports" ar + ON aro."activityReportId" = ar.id + AND ar."calculatedStatus" != 'deleted' + GROUP BY 1 + ; + -- 2. Calculate correct onApprovedAR values for goals + DROP TABLE IF EXISTS goals_on_ars; + CREATE TEMP TABLE goals_on_ars + AS + WITH goallist AS ( + SELECT DISTINCT gid FROM args_to_delete + EXCEPT + SELECT id FROM deleted_goals + ) + SELECT + g.id gid, + BOOL_OR( + (ar.id IS NOT NULL AND ar."calculatedStatus" = 'approved') + OR + COALESCE(ooaa.on_approved_ar,FALSE) + ) on_approved_ar, + BOOL_OR(ar.id IS NOT NULL OR COALESCE(ooaa.on_ar,FALSE)) on_ar + FROM goallist gl + JOIN "Goals" g + ON g.id = gl.gid + LEFT JOIN "ActivityReportGoals" arg + ON g.id = arg."goalId" + LEFT JOIN "ActivityReports" ar + ON arg."activityReportId" = ar.id + AND ar."calculatedStatus" != 'deleted' + LEFT JOIN "Objectives" o + ON o."goalId" = g.id + LEFT JOIN objectives_on_ars ooaa + ON ooaa.oid = o.id + GROUP BY 1 + ; + -- 3. Calculate onApprovedAR stats for objectives + DROP TABLE IF EXISTS initial_obj_approved_ar_stats; + CREATE TEMP TABLE initial_obj_approved_ar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_approved_ar = "onApprovedAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onApprovedAR" IS NOT NULL AND on_approved_ar != "onApprovedAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_approved_ar AND (NOT "onApprovedAR" OR "onApprovedAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_approved_ar AND ("onApprovedAR" OR "onApprovedAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_objectives + FROM "Objectives" o + JOIN objectives_on_ars + ON o.id = oid + ; + -- 4. Calculate onAR stats for objectives + DROP TABLE IF EXISTS initial_obj_onar_stats; + CREATE TEMP TABLE initial_obj_onar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_ar = "onAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onAR" IS NOT NULL AND on_ar != "onAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_ar AND (NOT "onAR" OR "onAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_ar AND ("onAR" OR "onAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_objectives + FROM "Objectives" o + JOIN objectives_on_ars + ON o.id = oid + ; + -- 5. Calculate onApprovedAR stats for goals + DROP TABLE IF EXISTS initial_goal_approved_ar_stats; + CREATE TEMP TABLE initial_goal_approved_ar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_approved_ar = "onApprovedAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onApprovedAR" IS NOT NULL AND on_approved_ar != "onApprovedAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_approved_ar AND (NOT "onApprovedAR" OR "onApprovedAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_approved_ar AND ("onApprovedAR" OR "onApprovedAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_goals + FROM "Goals" g + JOIN goals_on_ars + ON g.id = gid + ; + -- 6. Calculate onAR stats for goals + DROP TABLE IF EXISTS initial_goal_onar_stats; + CREATE TEMP TABLE initial_goal_onar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_ar = "onAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onAR" IS NOT NULL AND on_ar != "onAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_ar AND (NOT "onAR" OR "onAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_ar AND ("onAR" OR "onAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_goals + FROM "Goals" g + JOIN goals_on_ars + ON g.id = gid + ; + -- 7. Update onApprovedAR values for objectives and save the results + DROP TABLE IF EXISTS corrected_approved_objectives; + CREATE TEMP TABLE corrected_approved_objectives + AS + WITH updater AS ( + UPDATE "Objectives" o + SET "onApprovedAR" = on_approved_ar + FROM objectives_on_ars + WHERE o.id = oid + AND ("onApprovedAR" != on_approved_ar OR "onApprovedAR" IS NULL) + RETURNING + oid, + on_approved_ar + ) SELECT * FROM updater + ; + -- 8. Update onAR values for objectives and save the results + DROP TABLE IF EXISTS corrected_onar_objectives; + CREATE TEMP TABLE corrected_onar_objectives + AS + WITH updater AS ( + UPDATE "Objectives" o + SET "onAR" = on_ar + FROM objectives_on_ars + WHERE o.id = oid + AND ("onAR" != on_ar OR "onAR" IS NULL) + RETURNING + oid, + on_ar + ) SELECT * FROM updater + ; + -- 9. Update onApprovedAR values for goals and save the results + DROP TABLE IF EXISTS corrected_approved_goals; + CREATE TEMP TABLE corrected_approved_goals + AS + WITH updater AS ( + UPDATE "Goals" g + SET "onApprovedAR" = on_approved_ar + FROM goals_on_ars + WHERE g.id = gid + AND ("onApprovedAR" != on_approved_ar OR "onApprovedAR" IS NULL) + RETURNING + gid, + on_approved_ar + ) SELECT * FROM updater + ; + -- 10. Update onAR values for goals and save the results + DROP TABLE IF EXISTS corrected_onar_goals; + CREATE TEMP TABLE corrected_onar_goals + AS + WITH updater AS ( + UPDATE "Goals" g + SET "onAR" = on_ar + FROM goals_on_ars + WHERE g.id = gid + AND ("onAR" != on_ar OR "onAR" IS NULL) + RETURNING + gid, + on_ar + ) SELECT * FROM updater + ; + -- produce stats on what happened + -- 11. Final onApprovedAR stats for objectives + DROP TABLE IF EXISTS final_obj_approved_ar_stats; + CREATE TEMP TABLE final_obj_approved_ar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_approved_ar = "onApprovedAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onApprovedAR" IS NOT NULL AND on_approved_ar != "onApprovedAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_approved_ar AND (NOT "onApprovedAR" OR "onApprovedAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_approved_ar AND ("onApprovedAR" OR "onApprovedAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_objectives + FROM "Objectives" o + JOIN objectives_on_ars + ON o.id = oid + ; + -- 12. Final onAR stats for objectives + DROP TABLE IF EXISTS final_obj_onar_stats; + CREATE TEMP TABLE final_obj_onar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_ar = "onAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onAR" IS NOT NULL AND on_ar != "onAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_ar AND (NOT "onAR" OR "onAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_ar AND ("onAR" OR "onAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_objectives + FROM "Objectives" o + JOIN objectives_on_ars + ON o.id = oid + ; + -- 13. Final onApprovedAR stats for goals + DROP TABLE IF EXISTS final_goal_approved_ar_stats; + CREATE TEMP TABLE final_goal_approved_ar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_approved_ar = "onApprovedAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onApprovedAR" IS NOT NULL AND on_approved_ar != "onApprovedAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_approved_ar AND (NOT "onApprovedAR" OR "onApprovedAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_approved_ar AND ("onApprovedAR" OR "onApprovedAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_goals + FROM "Goals" g + JOIN goals_on_ars + ON g.id = gid + ; + -- 14. Final onAR stats for goals + DROP TABLE IF EXISTS final_goal_onar_stats; + CREATE TEMP TABLE final_goal_onar_stats + AS + SELECT + COUNT(*) FILTER (WHERE on_ar = "onAR" + ) matching_values, + COUNT(*) FILTER (WHERE "onAR" IS NOT NULL AND on_ar != "onAR" + ) incorrect_values, + COUNT(*) FILTER (WHERE on_ar AND (NOT "onAR" OR "onAR" IS NULL) + ) should_be_marked_true_but_isnt, + COUNT(*) FILTER (WHERE NOT on_ar AND ("onAR" OR "onAR" IS NULL) + ) marked_true_but_shouldnt_be, + COUNT(*) total_goals + FROM "Goals" g + JOIN goals_on_ars + ON g.id = gid + ; + -- make a nice little table to see the math + SELECT + 1 AS order, + 'objective onApprovedAR starting stats' description, + matching_values, + incorrect_values, + should_be_marked_true_but_isnt, + marked_true_but_shouldnt_be, + total_objectives total + FROM initial_obj_approved_ar_stats + UNION + SELECT + 2, + 'objective onApprovedAR values changed', + NULL, + NULL, + SUM(CASE WHEN on_approved_ar THEN 1 ELSE 0 END), + SUM(CASE WHEN NOT on_approved_ar THEN 1 ELSE 0 END), + COUNT(*) + FROM corrected_approved_objectives + UNION + SELECT 3,'objective onApprovedAR ending stats', * FROM final_obj_approved_ar_stats + UNION + SELECT 4,'objective onAR starting stats', * FROM initial_obj_onar_stats + UNION + SELECT + 5, + 'objective onAR values changed', + NULL, + NULL, + SUM(CASE WHEN on_ar THEN 1 ELSE 0 END), + SUM(CASE WHEN NOT on_ar THEN 1 ELSE 0 END), + COUNT(*) + FROM corrected_onar_objectives + UNION + SELECT 6,'objective onAR ending stats', * FROM final_obj_onar_stats + UNION + SELECT 7,'goal onApprovedAR starting stats', * FROM initial_goal_approved_ar_stats + UNION + SELECT + 8, + 'goal onApprovedAR values changed', + NULL, + NULL, + SUM(CASE WHEN on_approved_ar THEN 1 ELSE 0 END), + SUM(CASE WHEN NOT on_approved_ar THEN 1 ELSE 0 END), + COUNT(*) + FROM corrected_approved_goals + UNION + SELECT 9,'goal onApprovedAR ending stats', * FROM final_goal_approved_ar_stats + UNION + SELECT 10,'goal onAR starting stats', * FROM initial_goal_onar_stats + UNION + SELECT + 11, + 'goal onAR values changed', + NULL, + NULL, + SUM(CASE WHEN on_ar THEN 1 ELSE 0 END), + SUM(CASE WHEN NOT on_ar THEN 1 ELSE 0 END), + COUNT(*) + FROM corrected_onar_goals + UNION + SELECT 12,'goal onAR ending stats', * FROM final_goal_onar_stats + ORDER BY 1 + ; + + `, { transaction }); + }); + }, + + down: async () => { + }, +}; diff --git a/src/models/hooks/resource.js b/src/models/hooks/resource.js index a7dd8c4d9c..65817805e2 100644 --- a/src/models/hooks/resource.js +++ b/src/models/hooks/resource.js @@ -26,6 +26,8 @@ const afterUpdate = async (sequelize, instance, options) => { }; const afterCreate = async (sequelize, instance, options) => { + // TTAHUB-3102: Temporarily disable the resource scrape job (06/20/2024). + /* if (!instance.title) { // This is to resolve a recursive reference issue: // Service: /services/resourceQueue Imports: /lib/resource @@ -36,6 +38,7 @@ const afterCreate = async (sequelize, instance, options) => { const { addGetResourceMetadataToQueue } = require('../../services/resourceQueue'); addGetResourceMetadataToQueue(instance.id, instance.url); } + */ }; export { diff --git a/src/models/hooks/resource.test.js b/src/models/hooks/resource.test.js deleted file mode 100644 index 6f57981c92..0000000000 --- a/src/models/hooks/resource.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import { - sequelize, - Resource, -} from '..'; -import { addGetResourceMetadataToQueue } from '../../services/resourceQueue'; - -jest.mock('bull'); - -// Mock addGetResourceMetadataToQueue. -jest.mock('../../services/resourceQueue', () => ({ - addGetResourceMetadataToQueue: jest.fn(), -})); - -describe('resource hooks', () => { - afterAll(async () => { - await sequelize.close(); - }); - - describe('afterCreate', () => { - let newResource; - - afterEach(async () => { - // reset mocks. - addGetResourceMetadataToQueue.mockClear(); - }); - afterAll(async () => { - await Resource.destroy({ - where: { id: [newResource.id] }, - force: true, - }); - }); - - it('calls addGetResourceMetadataToQueue when title is missing on create', async () => { - newResource = await Resource.create({ - url: 'https://www.resource-with-title.com', - title: null, - description: 'resource-with-title', - individualHooks: true, - }); - expect(addGetResourceMetadataToQueue).toHaveBeenCalledWith(newResource.id, 'https://www.resource-with-title.com'); - }); - }); -});