Skip to content

Commit 1bcd70e

Browse files
authored
Merge pull request #954 from CDLUC3/feature/944/JS-adding-autosave-back-to-answering-questions
Added auto save back to the PlanOverviewQuestionPage component
2 parents b97a8c0 + 02fbee4 commit 1bcd70e

File tree

3 files changed

+171
-5
lines changed

3 files changed

+171
-5
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
### Added
2+
- Added `autosave` back to the `PlanOverviewQuestionpage` [#944]
23
- Hooked up the `Download plan` page and added a `download-narrative` api endpoint [#313]
4+
5+
### Updated
6+
7+
### Fixed
8+
9+
### Removed
10+
11+
### Chore
12+
====================================================================================================================================
13+
## All changes above the line happened after the merge to the main branch on Nov 3, 2025
14+
### Added
315
- Added user's org as a filter for the Plan Create (`projects/9/dmp/create`) page, and updated filter text to include `organization` [#735]
416
- Fixed filtering on the Plan Create (`projects/9/dmp/create`) page so that it takes search term into consideration when used with checked filters [#735]
517
- Moved checkbox filters below search field to make them more noticeable [#735]

app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/page.spec.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,3 +2247,68 @@ describe('Prevent unload when user has unsaved changes', () => {
22472247
});
22482248
});
22492249
});
2250+
2251+
describe('Auto save', () => {
2252+
it('should show saved message after auto-saving changes', async () => {
2253+
HTMLElement.prototype.scrollIntoView = mockScrollIntoView;
2254+
2255+
// Mock window.tinymce
2256+
window.tinymce = {
2257+
init: jest.fn(),
2258+
remove: jest.fn(),
2259+
};
2260+
2261+
mockUseParams.mockReturnValue({ projectId: 1, dmpid: 1, sid: 22, qid: 344 });
2262+
mockUseRouter.mockReturnValue({
2263+
push: jest.fn(),
2264+
});
2265+
2266+
(useMeQuery as jest.Mock).mockReturnValue({
2267+
data: mockMeData,
2268+
loading: false,
2269+
error: undefined
2270+
});
2271+
2272+
2273+
(usePlanQuery as jest.Mock).mockReturnValue({
2274+
data: mockPlanData,
2275+
loading: false,
2276+
error: undefined,
2277+
});
2278+
2279+
(usePublishedQuestionQuery as jest.Mock).mockReturnValue({
2280+
data: mockCheckboxQuestion,
2281+
loading: false,
2282+
error: undefined,
2283+
});
2284+
2285+
(useAnswerByVersionedQuestionIdQuery as jest.Mock).mockReturnValue({
2286+
data: mockCheckboxAnswer,
2287+
loading: false,
2288+
error: undefined,
2289+
});
2290+
2291+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
2292+
await act(async () => {
2293+
render(
2294+
<PlanOverviewQuestionPage />
2295+
);
2296+
});
2297+
2298+
const checkboxGroup = screen.getByTestId('checkbox-group');
2299+
expect(checkboxGroup).toBeInTheDocument();
2300+
const checkboxes = within(checkboxGroup).getAllByRole('checkbox');
2301+
const alexCheckbox = checkboxes.find(
2302+
(checkbox) => (checkbox as HTMLInputElement).value === 'Alex'
2303+
);
2304+
2305+
fireEvent.click(alexCheckbox!);
2306+
2307+
const lastSavedText = screen.getByText('messages.unsavedChanges');
2308+
expect(lastSavedText).toBeInTheDocument();
2309+
// Verify timeout scheduled
2310+
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3000);
2311+
2312+
setTimeoutSpy.mockRestore();
2313+
});
2314+
});

app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/page.tsx

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ const PlanOverviewQuestionPage: React.FC = () => {
186186
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
187187
// Track whether there are unsaved changes
188188
const [hasUnsavedChanges, setHasUnsavedChanges] = useState<boolean>(false);
189+
const [isAutoSaving, setIsAutoSaving] = useState<boolean>(false);
190+
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
191+
const [currentTime, setCurrentTime] = useState(new Date());
192+
const autoSaveTimeoutRef = useRef<NodeJS.Timeout>();
189193

190194
// Localization
191195
const Global = useTranslations('Global');
@@ -366,6 +370,7 @@ const PlanOverviewQuestionPage: React.FC = () => {
366370
...prev,
367371
otherAffiliationName: value
368372
}));
373+
setHasUnsavedChanges(true);
369374
};
370375

371376
// Update the selected radio value when user selects different option
@@ -750,7 +755,11 @@ const PlanOverviewQuestionPage: React.FC = () => {
750755
};
751756

752757
// Call Server Action updateAnswerAction or addAnswerAction to save answer
753-
const addAnswer = async () => {
758+
const addAnswer = async (isAutoSave = false) => {
759+
if (isAutoSave) {
760+
setIsAutoSaving(true);
761+
}
762+
754763
const jsonPayload = getAnswerJson();
755764
// Check is answer already exists. If so, we want to call an update mutation rather than add
756765
const isUpdate = Boolean(answerData?.answerByVersionedQuestionId);
@@ -785,6 +794,11 @@ const PlanOverviewQuestionPage: React.FC = () => {
785794
path: routePath('projects.dmp.versionedQuestion.detail', { projectId, dmpId, versionedSectionId, versionedQuestionId })
786795
}
787796
});
797+
} finally {
798+
if (isAutoSave) {
799+
setIsAutoSaving(false);
800+
setHasUnsavedChanges(false);
801+
}
788802
}
789803
}
790804
return {
@@ -804,6 +818,11 @@ const PlanOverviewQuestionPage: React.FC = () => {
804818
if (isSubmitting) return;
805819
setIsSubmitting(true);
806820

821+
// Clear any pending auto-save
822+
if (autoSaveTimeoutRef.current) {
823+
clearTimeout(autoSaveTimeoutRef.current);
824+
}
825+
807826
const result = await addAnswer();
808827

809828
if (!result.success) {
@@ -825,11 +844,32 @@ const PlanOverviewQuestionPage: React.FC = () => {
825844
// Show user a success message and redirect back to the Section page
826845
showSuccessToast();
827846
router.push(routePath('projects.dmp.versionedSection', { projectId, dmpId, versionedSectionId }))
828-
829847
}
830848
}
831849
};
832850

851+
// Helper function to format the last saved messaging
852+
const getLastSavedText = () => {
853+
854+
if (isAutoSaving) {
855+
return `${Global('buttons.saving')}...`;
856+
}
857+
858+
if (!lastSavedAt) {
859+
return hasUnsavedChanges ? t('messages.unsavedChanges') : '';
860+
}
861+
862+
const diffInMinutes = Math.floor(Math.abs(currentTime.getTime() - lastSavedAt.getTime()) / (1000 * 60));
863+
864+
if (diffInMinutes === 0) {
865+
return t('messages.savedJustNow');
866+
} else if (diffInMinutes === 1) {
867+
return t('messages.lastSavedOneMinuteAgo');
868+
} else {
869+
return t('messages.lastSaves', { minutes: diffInMinutes });
870+
}
871+
};
872+
833873
// Get parsed JSON from question, and set parsed, question and questionType in state
834874
useEffect(() => {
835875
if (selectedQuestion) {
@@ -934,20 +974,65 @@ const PlanOverviewQuestionPage: React.FC = () => {
934974

935975
}, [answerData, questionType]);
936976

937-
// Warn user of unsaved changes if they try to leave the page
977+
978+
// Auto-save logic
938979
useEffect(() => {
980+
if (!hasUnsavedChanges) return;
981+
if (!versionedQuestionId || !versionedSectionId || !question) return;
982+
983+
// Set a timeout to auto-save after 3 seconds of inactivity
984+
autoSaveTimeoutRef.current = setTimeout(async () => {
985+
const { success } = await addAnswer(true);
986+
if (success) {
987+
setLastSavedAt(new Date());
988+
setHasUnsavedChanges(false);
989+
}
990+
}, 3000);
991+
992+
return () => clearTimeout(autoSaveTimeoutRef.current);
993+
}, [formData, versionedQuestionId, versionedSectionId, question, hasUnsavedChanges]);
994+
995+
996+
997+
// Auto-save on window blur and before unload
998+
useEffect(() => {
999+
const handleWindowBlur = () => {
1000+
if (hasUnsavedChanges && !isAutoSaving) {
1001+
if (autoSaveTimeoutRef.current) {
1002+
clearTimeout(autoSaveTimeoutRef.current);
1003+
}
1004+
addAnswer(true);
1005+
}
1006+
};
1007+
9391008
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
9401009
if (hasUnsavedChanges) {
9411010
e.preventDefault();
9421011
e.returnValue = ''; // Required for Chrome/Firefox to show the confirm dialog
9431012
}
9441013
};
9451014

1015+
window.addEventListener('blur', handleWindowBlur);
9461016
window.addEventListener('beforeunload', handleBeforeUnload);
1017+
9471018
return () => {
1019+
window.removeEventListener('blur', handleWindowBlur);
9481020
window.removeEventListener('beforeunload', handleBeforeUnload);
1021+
if (autoSaveTimeoutRef.current) {
1022+
clearTimeout(autoSaveTimeoutRef.current);
1023+
}
9491024
};
950-
}, [hasUnsavedChanges]);
1025+
}, [hasUnsavedChanges, isAutoSaving]);
1026+
1027+
1028+
// Set up an interval to update the current time every minute
1029+
useEffect(() => {
1030+
const interval = setInterval(() => {
1031+
setCurrentTime(new Date());
1032+
}, 60 * 1000); // Update every minute
1033+
1034+
return () => clearInterval(interval);
1035+
}, []);
9511036

9521037
useEffect(() => {
9531038
// Set whether current user can add comments based on their role and plan data
@@ -1159,7 +1244,11 @@ const PlanOverviewQuestionPage: React.FC = () => {
11591244

11601245
</div>
11611246
{parsed && questionField}
1162-
1247+
</div>
1248+
<div className="lastSaved mt-5"
1249+
aria-live="polite"
1250+
role="status">
1251+
{getLastSavedText()}
11631252
</div>
11641253
</Card>
11651254

0 commit comments

Comments
 (0)