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 } } : {}),
},
});