diff --git a/frontend/src/pages/StandardGoalForm/RestartStandardGoal.js b/frontend/src/pages/StandardGoalForm/RestartStandardGoal.js index e1c1a26c02..c383ed1a49 100644 --- a/frontend/src/pages/StandardGoalForm/RestartStandardGoal.js +++ b/frontend/src/pages/StandardGoalForm/RestartStandardGoal.js @@ -1,21 +1,13 @@ import { GOAL_STATUS } from '@ttahub/common/src/constants'; -import { uniqueId } from 'lodash'; import PropTypes from 'prop-types'; -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router'; import AppLoadingContext from '../../AppLoadingContext'; import { ROUTES } from '../../Constants'; -import { - GOAL_FORM_BUTTON_LABELS, - GOAL_FORM_BUTTON_TYPES, - GOAL_FORM_BUTTON_VARIANTS, -} from '../../components/SharedGoalComponents/constants'; -import GoalFormUpdateOrRestart from '../../components/SharedGoalComponents/GoalFormUpdateOrRestart'; import { HTTPError } from '../../fetchers'; -import { addStandardGoal, getStandardGoal } from '../../fetchers/standardGoals'; +import { getStandardGoal } from '../../fetchers/standardGoals'; import useGoalTemplatePrompts from '../../hooks/useGoalTemplatePrompts'; -import { GOAL_FORM_FIELDS, mapObjectivesAndRootCauses } from './constants'; +import RestartStandardGoalForm from './RestartStandardGoalForm'; export default function RestartStandardGoal({ recipient }) { const { goalTemplateId, regionId, grantId } = useParams(); @@ -29,13 +21,6 @@ export default function RestartStandardGoal({ recipient }) { const [goal, setGoal] = useState(null); const fetchAttempted = useRef(false); - const hookForm = useForm({ - defaultValues: { - [GOAL_FORM_FIELDS.OBJECTIVES]: [], - [GOAL_FORM_FIELDS.ROOT_CAUSES]: null, - }, - }); - const [goalTemplatePrompts] = useGoalTemplatePrompts(goalTemplateId); useEffect(() => { @@ -49,14 +34,6 @@ export default function RestartStandardGoal({ recipient }) { throw new HTTPError('Goal not found', 404); } setGoal(g); - - // We want the user to start fresh with objectives and root causes. - const resetFormData = { - // eslint-disable-next-line max-len - [GOAL_FORM_FIELDS.OBJECTIVES]: [], - }; - - hookForm.reset(resetFormData); } catch (err) { // eslint-disable-next-line no-console console.error(err); @@ -78,62 +55,21 @@ export default function RestartStandardGoal({ recipient }) { fetchAttempted.current = true; fetchStandardGoal(); } - }, [goal, goalTemplateId, goalTemplatePrompts, grantId, history, hookForm, setIsAppLoading]); - - const standardGoalFormButtons = useMemo( - () => [ - { - id: uniqueId('goal-form-button-'), - type: GOAL_FORM_BUTTON_TYPES.SUBMIT, - variant: GOAL_FORM_BUTTON_VARIANTS.PRIMARY, - label: GOAL_FORM_BUTTON_LABELS.RESTART, - }, - { - id: uniqueId('goal-form-button-'), - type: GOAL_FORM_BUTTON_TYPES.LINK, - variant: GOAL_FORM_BUTTON_VARIANTS.OUTLINE, - label: GOAL_FORM_BUTTON_LABELS.CANCEL, - to: backLinkTo, - }, - ], - [backLinkTo] - ); - - const onSubmit = async (data) => { - try { - setIsAppLoading(true); - - // submit to backend - await addStandardGoal({ - goalTemplateId, - grantId, - status: GOAL_STATUS.IN_PROGRESS, - ...mapObjectivesAndRootCauses(data), - }); - - history.push(backLinkTo); - } catch (err) { - // eslint-disable-next-line no-console - console.log(err); - } finally { - setIsAppLoading(false); - } - }; + }, [goal, goalTemplateId, goalTemplatePrompts, grantId, history, setIsAppLoading]); if (!goal) { return null; } return ( - ); } diff --git a/frontend/src/pages/StandardGoalForm/RestartStandardGoalForm.js b/frontend/src/pages/StandardGoalForm/RestartStandardGoalForm.js new file mode 100644 index 0000000000..641ec88f16 --- /dev/null +++ b/frontend/src/pages/StandardGoalForm/RestartStandardGoalForm.js @@ -0,0 +1,121 @@ +import { GOAL_STATUS } from '@ttahub/common/src/constants'; +import { uniqueId } from 'lodash'; +import PropTypes from 'prop-types'; +import React, { useContext, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useHistory } from 'react-router'; +import AppLoadingContext from '../../AppLoadingContext'; +import { + GOAL_FORM_BUTTON_LABELS, + GOAL_FORM_BUTTON_TYPES, + GOAL_FORM_BUTTON_VARIANTS, +} from '../../components/SharedGoalComponents/constants'; +import GoalFormUpdateOrRestart from '../../components/SharedGoalComponents/GoalFormUpdateOrRestart'; +import { addStandardGoal } from '../../fetchers/standardGoals'; +import { GOAL_FORM_FIELDS, mapObjectivesAndRootCauses } from './constants'; + +export default function RestartStandardGoalForm({ + goal, + goalTemplatePrompts, + recipient, + regionId, + goalTemplateId, + grantId, + backLinkTo, +}) { + const history = useHistory(); + const { setIsAppLoading } = useContext(AppLoadingContext); + + const hookForm = useForm({ + defaultValues: { + [GOAL_FORM_FIELDS.OBJECTIVES]: [], + [GOAL_FORM_FIELDS.ROOT_CAUSES]: null, + }, + }); + + const standardGoalFormButtons = useMemo( + () => [ + { + id: uniqueId('goal-form-button-'), + type: GOAL_FORM_BUTTON_TYPES.SUBMIT, + variant: GOAL_FORM_BUTTON_VARIANTS.PRIMARY, + label: GOAL_FORM_BUTTON_LABELS.RESTART, + }, + { + id: uniqueId('goal-form-button-'), + type: GOAL_FORM_BUTTON_TYPES.LINK, + variant: GOAL_FORM_BUTTON_VARIANTS.OUTLINE, + label: GOAL_FORM_BUTTON_LABELS.CANCEL, + to: backLinkTo, + }, + ], + [backLinkTo] + ); + + const onSubmit = async (data) => { + try { + setIsAppLoading(true); + + await addStandardGoal({ + goalTemplateId, + grantId, + status: GOAL_STATUS.IN_PROGRESS, + ...mapObjectivesAndRootCauses(data), + }); + + history.push(backLinkTo); + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + } finally { + setIsAppLoading(false); + } + }; + + return ( + + ); +} + +RestartStandardGoalForm.propTypes = { + goal: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + grant: PropTypes.shape({ + numberWithProgramTypes: PropTypes.string, + }), + objectives: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + title: PropTypes.string, + }) + ), + }).isRequired, + goalTemplatePrompts: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + prompt: PropTypes.string, + }) + ), + recipient: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + }).isRequired, + regionId: PropTypes.string.isRequired, + goalTemplateId: PropTypes.string.isRequired, + grantId: PropTypes.string.isRequired, + backLinkTo: PropTypes.string.isRequired, +}; + +RestartStandardGoalForm.defaultProps = { + goalTemplatePrompts: null, +}; diff --git a/frontend/src/pages/StandardGoalForm/UpdateStandardGoal.js b/frontend/src/pages/StandardGoalForm/UpdateStandardGoal.js index aae1336a80..704bda8641 100644 --- a/frontend/src/pages/StandardGoalForm/UpdateStandardGoal.js +++ b/frontend/src/pages/StandardGoalForm/UpdateStandardGoal.js @@ -1,20 +1,12 @@ -import { uniqueId } from 'lodash'; import PropTypes from 'prop-types'; -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { useHistory, useLocation, useParams } from 'react-router'; import AppLoadingContext from '../../AppLoadingContext'; import { ROUTES } from '../../Constants'; -import { - GOAL_FORM_BUTTON_LABELS, - GOAL_FORM_BUTTON_TYPES, - GOAL_FORM_BUTTON_VARIANTS, -} from '../../components/SharedGoalComponents/constants'; -import GoalFormUpdateOrRestart from '../../components/SharedGoalComponents/GoalFormUpdateOrRestart'; import { HTTPError } from '../../fetchers'; -import { getStandardGoal, updateStandardGoal } from '../../fetchers/standardGoals'; +import { getStandardGoal } from '../../fetchers/standardGoals'; import useGoalTemplatePrompts from '../../hooks/useGoalTemplatePrompts'; -import { GOAL_FORM_FIELDS } from './constants'; +import UpdateStandardGoalForm from './UpdateStandardGoalForm'; export default function UpdateStandardGoal({ recipient }) { const { goalTemplateId, regionId, grantId } = useParams(); @@ -28,13 +20,6 @@ export default function UpdateStandardGoal({ recipient }) { const [goal, setGoal] = useState(null); const fetchAttempted = useRef(false); - const hookForm = useForm({ - defaultValues: { - [GOAL_FORM_FIELDS.OBJECTIVES]: [], - [GOAL_FORM_FIELDS.ROOT_CAUSES]: null, - }, - }); - const [goalTemplatePrompts] = useGoalTemplatePrompts(goalTemplateId); useEffect(() => { @@ -42,27 +27,13 @@ export default function UpdateStandardGoal({ recipient }) { try { setIsAppLoading(true); - // we need to get closed only if we are restarting the goal const g = await getStandardGoal(goalTemplateId, grantId); - setGoal(g); if (!g) { throw new HTTPError('Goal not found', 404); } - const resetFormData = { - // eslint-disable-next-line max-len - [GOAL_FORM_FIELDS.OBJECTIVES]: g.objectives.map((o) => ({ - value: o.title, - objectiveId: o.id, - onAR: o.onAR, - status: o.status, - })), - [GOAL_FORM_FIELDS.ROOT_CAUSES]: g.responses.flatMap((responses) => - responses.response.map((r) => ({ id: r, name: r })) - ), - }; - hookForm.reset(resetFormData); + setGoal(g); } catch (err) { // eslint-disable-next-line no-console console.error(err); @@ -84,64 +55,21 @@ export default function UpdateStandardGoal({ recipient }) { fetchAttempted.current = true; fetchStandardGoal(); } - }, [goal, goalTemplateId, goalTemplatePrompts, grantId, history, hookForm, setIsAppLoading]); - - const standardGoalFormButtons = useMemo( - () => [ - { - id: uniqueId('goal-form-button-'), - type: GOAL_FORM_BUTTON_TYPES.SUBMIT, - variant: GOAL_FORM_BUTTON_VARIANTS.PRIMARY, - label: GOAL_FORM_BUTTON_LABELS.SAVE, - }, - { - id: uniqueId('goal-form-button-'), - type: GOAL_FORM_BUTTON_TYPES.LINK, - variant: GOAL_FORM_BUTTON_VARIANTS.OUTLINE, - label: GOAL_FORM_BUTTON_LABELS.CANCEL, - to: backLinkTo, - }, - ], - [backLinkTo] - ); - - const onSubmit = async (data) => { - try { - setIsAppLoading(true); - - // submit to backend - await updateStandardGoal({ - goalTemplateId, - grantId, - // eslint-disable-next-line max-len - objectives: data.objectives - ? data.objectives.map((o) => ({ title: o.value, id: o.objectiveId })) - : [], - rootCauses: data.rootCauses ? data.rootCauses.map((r) => r.id) : null, - }); - - history.push(backLinkTo); - } catch (err) { - // eslint-disable-next-line no-console - console.log(err); - } finally { - setIsAppLoading(false); - } - }; + }, [goal, goalTemplateId, goalTemplatePrompts, grantId, history, setIsAppLoading]); if (!goal) { return null; } return ( - ); } diff --git a/frontend/src/pages/StandardGoalForm/UpdateStandardGoalForm.js b/frontend/src/pages/StandardGoalForm/UpdateStandardGoalForm.js new file mode 100644 index 0000000000..99c2fe1170 --- /dev/null +++ b/frontend/src/pages/StandardGoalForm/UpdateStandardGoalForm.js @@ -0,0 +1,135 @@ +import { uniqueId } from 'lodash'; +import PropTypes from 'prop-types'; +import React, { useContext, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useHistory } from 'react-router'; +import AppLoadingContext from '../../AppLoadingContext'; +import { + GOAL_FORM_BUTTON_LABELS, + GOAL_FORM_BUTTON_TYPES, + GOAL_FORM_BUTTON_VARIANTS, +} from '../../components/SharedGoalComponents/constants'; +import GoalFormUpdateOrRestart from '../../components/SharedGoalComponents/GoalFormUpdateOrRestart'; +import { updateStandardGoal } from '../../fetchers/standardGoals'; +import { GOAL_FORM_FIELDS } from './constants'; + +export default function UpdateStandardGoalForm({ + goal, + goalTemplatePrompts, + recipient, + regionId, + goalTemplateId, + grantId, + backLinkTo, +}) { + const history = useHistory(); + const { setIsAppLoading } = useContext(AppLoadingContext); + + const hookForm = useForm({ + defaultValues: { + [GOAL_FORM_FIELDS.OBJECTIVES]: goal.objectives.map((o) => ({ + value: o.title, + objectiveId: o.id, + onAR: o.onAR, + status: o.status, + })), + [GOAL_FORM_FIELDS.ROOT_CAUSES]: goal.responses.flatMap((responses) => + responses.response.map((r) => ({ id: r, name: r })) + ), + }, + }); + + const standardGoalFormButtons = useMemo( + () => [ + { + id: uniqueId('goal-form-button-'), + type: GOAL_FORM_BUTTON_TYPES.SUBMIT, + variant: GOAL_FORM_BUTTON_VARIANTS.PRIMARY, + label: GOAL_FORM_BUTTON_LABELS.SAVE, + }, + { + id: uniqueId('goal-form-button-'), + type: GOAL_FORM_BUTTON_TYPES.LINK, + variant: GOAL_FORM_BUTTON_VARIANTS.OUTLINE, + label: GOAL_FORM_BUTTON_LABELS.CANCEL, + to: backLinkTo, + }, + ], + [backLinkTo] + ); + + const onSubmit = async (data) => { + try { + setIsAppLoading(true); + + await updateStandardGoal({ + goalTemplateId, + grantId, + objectives: data.objectives + ? data.objectives.map((o) => ({ title: o.value, id: o.objectiveId })) + : [], + rootCauses: data.rootCauses ? data.rootCauses.map((r) => r.id) : null, + }); + + history.push(backLinkTo); + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + } finally { + setIsAppLoading(false); + } + }; + + return ( + + ); +} + +UpdateStandardGoalForm.propTypes = { + goal: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + grant: PropTypes.shape({ + numberWithProgramTypes: PropTypes.string, + }), + objectives: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + title: PropTypes.string, + onAR: PropTypes.bool, + status: PropTypes.string, + }) + ), + responses: PropTypes.arrayOf( + PropTypes.shape({ + response: PropTypes.arrayOf(PropTypes.string), + }) + ), + }).isRequired, + goalTemplatePrompts: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + prompt: PropTypes.string, + }) + ), + recipient: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + }).isRequired, + regionId: PropTypes.string.isRequired, + goalTemplateId: PropTypes.string.isRequired, + grantId: PropTypes.string.isRequired, + backLinkTo: PropTypes.string.isRequired, +}; + +UpdateStandardGoalForm.defaultProps = { + goalTemplatePrompts: null, +}; diff --git a/frontend/src/pages/StandardGoalForm/__tests__/UpdateStandardGoal.js b/frontend/src/pages/StandardGoalForm/__tests__/UpdateStandardGoal.js index de593e45d3..a41aa23a1a 100644 --- a/frontend/src/pages/StandardGoalForm/__tests__/UpdateStandardGoal.js +++ b/frontend/src/pages/StandardGoalForm/__tests__/UpdateStandardGoal.js @@ -107,6 +107,22 @@ describe('UpdateStandardGoal', () => { expect(screen.getByText('Grant-123')).toBeInTheDocument(); }); + it('populates existing objectives into the form on initial render', async () => { + RenderTest(); + + await waitFor(() => { + expect(fetchMock.called('/api/goal-templates/standard/1/grant/1')).toBe(true); + }); + + expect(await screen.findByRole('button', { name: /Save/i })).toBeInTheDocument(); + + expect(await screen.findByDisplayValue('Objective 1')).toBeInTheDocument(); + + expect(screen.getByText('Objective 2', { selector: 'div' })).toBeInTheDocument(); + + expect(screen.getByText('Objectives used on reports cannot be edited.')).toBeInTheDocument(); + }); + it('redirects to error page when goal is not found', async () => { const history = createMemoryHistory({ initialEntries: [ diff --git a/src/migrations/20260621225954-revert-bad-objective-deletions.js b/src/migrations/20260621225954-revert-bad-objective-deletions.js new file mode 100644 index 0000000000..b54cd69b54 --- /dev/null +++ b/src/migrations/20260621225954-revert-bad-objective-deletions.js @@ -0,0 +1,36 @@ +const { prepMigration } = require('../lib/migration'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, _Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + const sessionSig = __filename; + await prepMigration(queryInterface, transaction, sessionSig); + + await queryInterface.sequelize.query( + ` + UPDATE "Objectives" + SET "deletedAt" = NULL + WHERE id IN ( + SELECT o.id FROM "Objectives" o + INNER JOIN "Goals" g ON o."goalId" = g.id + INNER JOIN "GoalTemplates" gt ON g."goalTemplateId" = gt.id + INNER JOIN "ActivityReportObjectives" aro ON aro."objectiveId" = o.id + INNER JOIN "ActivityReports" ar ON ar.id = aro."activityReportId" + WHERE gt."standard" IS NOT NULL + AND g."createdAt" >= '2025-09-01' + AND g."deletedAt" IS NULL + AND o."deletedAt" IS NOT NULL + AND ar."calculatedStatus" != 'deleted' + ); + + `, + { transaction } + ); + }); + }, + + async down() { + // This cannot be sensibly rolled back + }, +}; diff --git a/src/services/standardGoal.test.js b/src/services/standardGoal.test.js index c9bc92bf03..06a590250e 100644 --- a/src/services/standardGoal.test.js +++ b/src/services/standardGoal.test.js @@ -409,6 +409,7 @@ describe('standardGoal service', () => { objectiveTemplateId: objectiveTemplate.id, goalId: goal.id, status: GOAL_STATUS.NOT_STARTED, + createdVia: 'rtr', }); }); @@ -588,6 +589,17 @@ describe('standardGoal service', () => { }); it('creates a new objective if its not found by id or title and goal id', async () => { + // The preceding test leaves objectiveWithoutId in the DB (it is still returned by goalForRtr + // because onApprovedAR=true, but it is not removed by updateExistingStandardGoal's selective + // delete because it only deletes objectives with createdVia='rtr' and onAR=false). Remove it + // explicitly so this test starts from a clean slate. + if (objectiveWithoutId) { + await Objective.destroy({ + where: { id: objectiveWithoutId.id }, + force: true, + }); + } + // Create the test objecitve here so its not dlelete by other tests in this block. const g = await updateExistingStandardGoal( grant.id, diff --git a/src/services/standardGoals.ts b/src/services/standardGoals.ts index 453ac1e1c3..13a19189fe 100644 --- a/src/services/standardGoals.ts +++ b/src/services/standardGoals.ts @@ -789,12 +789,12 @@ export async function updateExistingStandardGoal( } // Delete any potentially removed objectives (regardless if we have any objectives). + const idsToKeep = updatedObjectives.map((o) => o.id); await Objective.destroy({ where: { goalId: goal.id, - id: { - [Op.notIn]: updatedObjectives.map((o) => o.id), - }, + onAR: false, + ...(idsToKeep.length ? { id: { [Op.notIn]: idsToKeep } } : {}), }, });