From a36cd13c080e05853913f28c1688a7f47e928f88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:38:00 +0000 Subject: [PATCH 1/8] Initial plan From 7c6efa0b21db7b0fd57205bc88dba58a007c1fb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:41:15 +0000 Subject: [PATCH 2/8] Fix assignment completion feedback and redirect UX Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- .../assignments/AssignmentSubmission.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/javascript/components/assignments/AssignmentSubmission.tsx b/app/javascript/components/assignments/AssignmentSubmission.tsx index 19cd77c80..e1a29d804 100644 --- a/app/javascript/components/assignments/AssignmentSubmission.tsx +++ b/app/javascript/components/assignments/AssignmentSubmission.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useMemo } from "react"; +import { useNavigate } from "react-router"; import { Temporal, TemporalSettings as Settings, parseISO } from "../infrastructure/TemporalSettings"; //Redux store stuff import { useDispatch } from "react-redux"; -import { startTask, endTask } from "../infrastructure/StatusSlice"; +import { startTask, endTask, addMessage, Priorities } from "../infrastructure/StatusSlice"; import { IAssignment } from "./AssignmentViewer"; import { useTypedSelector } from "../infrastructure/AppReducers"; @@ -39,6 +40,7 @@ export default function AssignmentSubmission(props: Props) { ); const dispatch = useDispatch(); + const navigate = useNavigate(); const [t, i18n] = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); @@ -112,7 +114,7 @@ export default function AssignmentSubmission(props: Props) { value={submissionTextEditor} headerTemplate={} onTextChange={e => { - setSubmissionTextEditor(e.htmlValue); + setSubmissionTextEditor(e.htmlValue || ""); }} /> @@ -166,6 +168,12 @@ export default function AssignmentSubmission(props: Props) { }) .then(response => { const data = response.data; + const successMessage = data?.messages?.main; + + if (successMessage) { + dispatch(addMessage(successMessage, new Date(), Priorities.INFO)); + } + if (data.messages !== null && Object.keys(data.messages).length < 2) { setSubmissionId(data.submission.id); let receivedDate = parseISO(data.submission.updated_at, Settings.timezone); @@ -179,7 +187,12 @@ export default function AssignmentSubmission(props: Props) { setWithdrawnDate(receivedDate); } setRecordedScore(data.submission.recorded_score); - setSubmissionTextEditor(data.submission.sub_text); + setSubmissionTextEditor(data.submission.sub_text || ""); + setDirty(false); + + if (submitIt) { + navigate("/home"); + } } }) .then(props.reloadCallback) From f1e759d72b4ab6fa46d6774625a8e7e828a261e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:56:03 +0000 Subject: [PATCH 3/8] Apply remaining changes Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/javascript/components/AppStatusBar.tsx | 26 ++++++++++++++++++- .../BingoBoards/BingoGameDataAdmin.tsx | 3 ++- .../BingoBoards/CandidateListEntry.tsx | 4 ++- app/javascript/components/ConceptsTable.tsx | 3 ++- .../assignments/AssignmentDataAdmin.tsx | 3 ++- .../assignments/AssignmentSubmission.tsx | 3 ++- .../components/checkin/InstallmentReport.tsx | 4 ++- .../components/infrastructure/StatusSlice.ts | 16 ++++++++++++ 8 files changed, 55 insertions(+), 7 deletions(-) diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx index 4841c46e6..bef6f597f 100644 --- a/app/javascript/components/AppStatusBar.tsx +++ b/app/javascript/components/AppStatusBar.tsx @@ -10,6 +10,9 @@ export default function AppStatusBar(props) { const messages = useTypedSelector(state => { return state.status.messages; }); + const hasDirtyChanges = useTypedSelector(state => { + return Object.values(state.status.dirtyStatus).some(Boolean); + }); const dispatch = useDispatch(); const toast = React.useRef(null); @@ -27,5 +30,26 @@ export default function AppStatusBar(props) { }); }, [messages]); - return ; + return ( + <> + +
+ + {hasDirtyChanges ? "Unsaved changes" : "Saved"} +
+ + ); } diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index df371b99f..83fed88e3 100644 --- a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx +++ b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx @@ -10,7 +10,7 @@ import { Button } from "primereact/button"; import { useTranslation } from "react-i18next"; import { useTypedSelector } from "../infrastructure/AppReducers"; -import { startTask, endTask } from "../infrastructure/StatusSlice"; +import { startTask, endTask, useDirtyStatus } from "../infrastructure/StatusSlice"; import axios from "axios"; import { Editor } from "primereact/editor"; import EditorToolbar from "../toolbars/EditorToolbar"; @@ -42,6 +42,7 @@ export default function BingoGameDataAdmin(props) { const { t, i18n } = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); + useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [messages, setMessages] = useState({}); const [gameProjects, setGameProjects] = useState([ diff --git a/app/javascript/components/BingoBoards/CandidateListEntry.tsx b/app/javascript/components/BingoBoards/CandidateListEntry.tsx index 5bd70f652..7b4d49f1d 100644 --- a/app/javascript/components/BingoBoards/CandidateListEntry.tsx +++ b/app/javascript/components/BingoBoards/CandidateListEntry.tsx @@ -10,7 +10,8 @@ import { startTask, endTask, addMessage, - Priorities + Priorities, + useDirtyStatus } from "../infrastructure/StatusSlice"; import { useTypedSelector } from "../infrastructure/AppReducers"; import axios from "axios"; @@ -40,6 +41,7 @@ export default function CandidateListEntry(props: Props) { const [dirty, setDirty] = useState(false); const dispatch = useDispatch(); + useDirtyStatus(category, dirty); const [candidateListId, setCandidateListId] = useState(0); const [topic, setTopic] = useState(""); diff --git a/app/javascript/components/ConceptsTable.tsx b/app/javascript/components/ConceptsTable.tsx index bbf099526..2c31e4357 100644 --- a/app/javascript/components/ConceptsTable.tsx +++ b/app/javascript/components/ConceptsTable.tsx @@ -15,7 +15,7 @@ import { Column } from "primereact/column"; import { Dialog } from "primereact/dialog"; import { useDispatch } from "react-redux"; -import { startTask, endTask, addMessage, Priorities } from "./infrastructure/StatusSlice"; +import { startTask, endTask, addMessage, Priorities, useDirtyStatus } from "./infrastructure/StatusSlice"; import { InputText } from "primereact/inputtext"; enum OPT_COLS { @@ -52,6 +52,7 @@ export default function ConceptsTable() { const [editing, setEditing] = useState(false); const [dirty, setDirty] = useState(false); + useDirtyStatus(category, dirty); const [conceptName, setConceptName] = useState(""); const [conceptId, setConceptId] = useState(-1); diff --git a/app/javascript/components/assignments/AssignmentDataAdmin.tsx b/app/javascript/components/assignments/AssignmentDataAdmin.tsx index e60c07703..b856261f0 100644 --- a/app/javascript/components/assignments/AssignmentDataAdmin.tsx +++ b/app/javascript/components/assignments/AssignmentDataAdmin.tsx @@ -20,7 +20,7 @@ import { useTranslation } from "react-i18next"; import EditorToolbar from "../toolbars/EditorToolbar"; import { useTypedSelector } from "../infrastructure/AppReducers"; -import { startTask, endTask, addMessage, Priorities } from "../infrastructure/StatusSlice"; +import { startTask, endTask, addMessage, Priorities, useDirtyStatus } from "../infrastructure/StatusSlice"; import { Col, Container, Row } from "react-grid-system"; import { utcAdjustDate, utcAdjustEndDate } from "../infrastructure/Utilities"; import { FloatLabel } from "primereact/floatlabel"; @@ -44,6 +44,7 @@ export default function AssignmentDataAdmin(props) { const navigate = useNavigate(); const [dirty, setDirty] = useState(false); + useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [assignmentProjects, setAssignmentProjects] = useState([ { id: -1, name: "None Selected" } diff --git a/app/javascript/components/assignments/AssignmentSubmission.tsx b/app/javascript/components/assignments/AssignmentSubmission.tsx index e1a29d804..6bf0cb5aa 100644 --- a/app/javascript/components/assignments/AssignmentSubmission.tsx +++ b/app/javascript/components/assignments/AssignmentSubmission.tsx @@ -4,7 +4,7 @@ import { Temporal, TemporalSettings as Settings, parseISO } from "../infrastruct //Redux store stuff import { useDispatch } from "react-redux"; -import { startTask, endTask, addMessage, Priorities } from "../infrastructure/StatusSlice"; +import { startTask, endTask, addMessage, Priorities, useDirtyStatus } from "../infrastructure/StatusSlice"; import { IAssignment } from "./AssignmentViewer"; import { useTypedSelector } from "../infrastructure/AppReducers"; @@ -43,6 +43,7 @@ export default function AssignmentSubmission(props: Props) { const navigate = useNavigate(); const [t, i18n] = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); + useDirtyStatus(category, dirty); const [submissionId, setSubmissionId] = useState(); const [updatedDate, setUpdatedDate] = useState(null); diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx index 709cafdc1..a181080a9 100644 --- a/app/javascript/components/checkin/InstallmentReport.tsx +++ b/app/javascript/components/checkin/InstallmentReport.tsx @@ -10,7 +10,8 @@ import { startTask, endTask, addMessage, - Priorities + Priorities, + useDirtyStatus } from "../infrastructure/StatusSlice"; import { useTranslation } from "react-i18next"; import { useTypedSelector } from "../infrastructure/AppReducers"; @@ -79,6 +80,7 @@ export default function InstallmentReport(props: Props) { const [contributions, setContributions] = useState({}); const [installment, setInstallment] = useState({ comments: "" }); const [dirty, setDirty] = useState(false); + useDirtyStatus(category, dirty); const [redirectState, setRedirectState] = useState(RedirectState.DECIDING); const [redirectUrl, setRedirectUrl] = useState(undefined); diff --git a/app/javascript/components/infrastructure/StatusSlice.ts b/app/javascript/components/infrastructure/StatusSlice.ts index 9f15839ba..7945e4002 100644 --- a/app/javascript/components/infrastructure/StatusSlice.ts +++ b/app/javascript/components/infrastructure/StatusSlice.ts @@ -1,3 +1,5 @@ +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; import { createSlice } from "@reduxjs/toolkit"; export enum Priorities { @@ -86,4 +88,18 @@ export const { addMessage, acknowledgeMsg } = actions; + +export function useDirtyStatus(flagKey: string, dirty: boolean) { + const dispatch = useDispatch(); + + useEffect(() => { + if (dirty) { + dispatch(setDirty(flagKey)); + return; + } + + dispatch(setClean(flagKey)); + }, [dirty, flagKey, dispatch]); +} + export default reducer; From 1965a9c7b7539677d72724cba8bfe2335a075316 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:18:47 +0000 Subject: [PATCH 4/8] Fix stale dirty-state initialization in form loaders Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/javascript/components/AppStatusBar.tsx | 52 +++++++++++++------ .../BingoBoards/BingoGameDataAdmin.tsx | 9 +++- .../BingoBoards/CandidateListEntry.tsx | 8 ++- .../assignments/AssignmentDataAdmin.tsx | 8 ++- .../assignments/AssignmentSubmission.tsx | 11 ++-- .../components/checkin/InstallmentReport.tsx | 11 +++- .../components/infrastructure/StatusSlice.ts | 23 ++++++-- 7 files changed, 89 insertions(+), 33 deletions(-) diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx index bef6f597f..0476a2ca4 100644 --- a/app/javascript/components/AppStatusBar.tsx +++ b/app/javascript/components/AppStatusBar.tsx @@ -6,45 +6,65 @@ import { acknowledgeMsg } from "./infrastructure/StatusSlice"; import { Toast } from "primereact/toast"; -export default function AppStatusBar(props) { - const messages = useTypedSelector(state => { - return state.status.messages; +type AppMessage = { + text: string; + priority: "error" | "info" | "warning"; + dismissed: boolean; +}; + +export default function AppStatusBar() { + const messages = useTypedSelector((state): AppMessage[] => { + return state.status.messages ?? []; }); const hasDirtyChanges = useTypedSelector(state => { - return Object.values(state.status.dirtyStatus).some(Boolean); + const dirtyStatus = state.status.dirtyStatus as Record; + for (const key in dirtyStatus) { + if (dirtyStatus[key]) { + return true; + } + } + return false; }); const dispatch = useDispatch(); - const toast = React.useRef(null); + const toast = React.useRef(null); useEffect(() => { - messages.forEach((message, index) => { + messages.forEach((message: AppMessage, index: number) => { if (!message.dismissed) { - toast.current.show({ - severity: message.priority, - summary: message.priority, - detail: message.text, - life: 30000 - }); + if (toast.current) { + toast.current.show({ + severity: message.priority, + summary: message.priority, + detail: message.text, + life: 30000 + }); + } dispatch(acknowledgeMsg(index)); } }); - }, [messages]); + }, [dispatch, messages]); return ( <>
diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index 83fed88e3..11deaa01d 100644 --- a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx +++ b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useState, useEffect, useMemo } from "react"; +import React, { Suspense, useState, useEffect, useMemo, useRef } from "react"; import { useNavigate, useParams } from "react-router"; import { useDispatch } from "react-redux"; @@ -42,6 +42,7 @@ export default function BingoGameDataAdmin(props) { const { t, i18n } = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); + const hasLoadedRef = useRef(false); useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [messages, setMessages] = useState({}); @@ -86,6 +87,10 @@ export default function BingoGameDataAdmin(props) { }, [endpointStatus]); useEffect(() => { + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + return; + } setDirty(true); }, [ gameTopic, @@ -162,6 +167,7 @@ export default function BingoGameDataAdmin(props) { setGameGroupDiscount(bingo_game.group_discount || 0); setGameGroupProjectId(bingo_game.project_id); setFoundWords(data.found_words); + setDirty(false); //getBingoGameData(); //setDirty(false); @@ -201,7 +207,6 @@ export default function BingoGameDataAdmin(props) { }; const getBingoGameData = () => { - setDirty(true); dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === bingoGameId) { diff --git a/app/javascript/components/BingoBoards/CandidateListEntry.tsx b/app/javascript/components/BingoBoards/CandidateListEntry.tsx index 7b4d49f1d..e75e46158 100644 --- a/app/javascript/components/BingoBoards/CandidateListEntry.tsx +++ b/app/javascript/components/BingoBoards/CandidateListEntry.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useParams } from "react-router"; import { Panel } from "primereact/panel"; @@ -40,6 +40,7 @@ export default function CandidateListEntry(props: Props) { const { bingoGameId } = useParams(); const [dirty, setDirty] = useState(false); + const hasLoadedRef = useRef(false); const dispatch = useDispatch(); useDirtyStatus(category, dirty); @@ -59,7 +60,6 @@ export default function CandidateListEntry(props: Props) { const getCandidateList = () => { dispatch(startTask()); - setDirty(true); const url = props.rootPath === undefined ? `${endpoints.baseUrl}${bingoGameId}.json` @@ -184,6 +184,10 @@ export default function CandidateListEntry(props: Props) { }, [endpointStatus]); useEffect(() => { + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + return; + } setDirty(true); }, [candidates]); diff --git a/app/javascript/components/assignments/AssignmentDataAdmin.tsx b/app/javascript/components/assignments/AssignmentDataAdmin.tsx index b856261f0..573e45ad6 100644 --- a/app/javascript/components/assignments/AssignmentDataAdmin.tsx +++ b/app/javascript/components/assignments/AssignmentDataAdmin.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useState, useEffect } from "react"; +import React, { Suspense, useState, useEffect, useRef } from "react"; import { useParams } from "react-router"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router"; @@ -44,6 +44,7 @@ export default function AssignmentDataAdmin(props) { const navigate = useNavigate(); const [dirty, setDirty] = useState(false); + const hasLoadedRef = useRef(false); useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [assignmentProjects, setAssignmentProjects] = useState([ @@ -99,6 +100,10 @@ export default function AssignmentDataAdmin(props) { }, [endpointStatus]); useEffect(() => { + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + return; + } setDirty(true); }, [ assignmentName, @@ -213,7 +218,6 @@ export default function AssignmentDataAdmin(props) { setAssignmentRubricId(assignment.rubric_id || -1); }; const getAssignmentData = () => { - setDirty(true); dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === assignmentId) { diff --git a/app/javascript/components/assignments/AssignmentSubmission.tsx b/app/javascript/components/assignments/AssignmentSubmission.tsx index 6bf0cb5aa..7f635e5c7 100644 --- a/app/javascript/components/assignments/AssignmentSubmission.tsx +++ b/app/javascript/components/assignments/AssignmentSubmission.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { useNavigate } from "react-router"; import { Temporal, TemporalSettings as Settings, parseISO } from "../infrastructure/TemporalSettings"; @@ -43,6 +43,7 @@ export default function AssignmentSubmission(props: Props) { const navigate = useNavigate(); const [t, i18n] = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); + const hasLoadedRef = useRef(false); useDirtyStatus(category, dirty); const [submissionId, setSubmissionId] = useState(); @@ -61,9 +62,11 @@ export default function AssignmentSubmission(props: Props) { }, [endpointStatus, submissionId]); useEffect(() => { - if (endpointStatus) { - setDirty(true); + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + return; } + setDirty(true); }, [submissionTextEditor, submissionLink]); const loadSubmission = () => { @@ -95,8 +98,6 @@ export default function AssignmentSubmission(props: Props) { data.submission.recorded_score || data.submission.calculated_score ); setSubmissionTextEditor(data.submission.sub_text || ""); - }) - .then(response => { setDirty(false); }) .finally(() => { diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx index a181080a9..d88b4b9da 100644 --- a/app/javascript/components/checkin/InstallmentReport.tsx +++ b/app/javascript/components/checkin/InstallmentReport.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useState, useEffect } from "react"; +import React, { Suspense, useState, useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router"; import { Accordion, AccordionTab } from "primereact/accordion"; @@ -80,6 +80,7 @@ export default function InstallmentReport(props: Props) { const [contributions, setContributions] = useState({}); const [installment, setInstallment] = useState({ comments: "" }); const [dirty, setDirty] = useState(false); + const hasLoadedRef = useRef(false); useDirtyStatus(category, dirty); const [redirectState, setRedirectState] = useState(RedirectState.DECIDING); @@ -100,7 +101,13 @@ export default function InstallmentReport(props: Props) { setInstallment(inst); }; - useEffect(() => setDirty(true), [contributions, installment]); + useEffect(() => { + if (!hasLoadedRef.current) { + hasLoadedRef.current = true; + return; + } + setDirty(true); + }, [contributions, installment]); useEffect(() => { if (endpointStatus) { diff --git a/app/javascript/components/infrastructure/StatusSlice.ts b/app/javascript/components/infrastructure/StatusSlice.ts index 7945e4002..2393fd9cc 100644 --- a/app/javascript/components/infrastructure/StatusSlice.ts +++ b/app/javascript/components/infrastructure/StatusSlice.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useDispatch } from "react-redux"; import { createSlice } from "@reduxjs/toolkit"; @@ -91,14 +91,29 @@ export const { export function useDirtyStatus(flagKey: string, dirty: boolean) { const dispatch = useDispatch(); + const hasInitialized = useRef(false); + const previousDirty = useRef(false); useEffect(() => { - if (dirty) { - dispatch(setDirty(flagKey)); + if (!hasInitialized.current) { + hasInitialized.current = true; + previousDirty.current = dirty; + if (dirty) { + dispatch(setClean(flagKey)); + } return; } - dispatch(setClean(flagKey)); + if (dirty === previousDirty.current) { + return; + } + + if (dirty) { + dispatch(setDirty(flagKey)); + } else { + dispatch(setClean(flagKey)); + } + previousDirty.current = dirty; }, [dirty, flagKey, dispatch]); } From 3809eacadad1b2d3f873db6a1cb31ce204d7505b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:21:11 +0000 Subject: [PATCH 5/8] Apply remaining changes Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- .../components/BingoBoards/BingoGameDataAdmin.tsx | 7 ++++--- .../components/BingoBoards/CandidateListEntry.tsx | 7 ++++--- .../components/assignments/AssignmentDataAdmin.tsx | 7 ++++--- .../components/assignments/AssignmentSubmission.tsx | 7 ++++--- app/javascript/components/checkin/InstallmentReport.tsx | 7 ++++--- app/javascript/components/infrastructure/StatusSlice.ts | 3 --- 6 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index 11deaa01d..0d087e665 100644 --- a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx +++ b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx @@ -42,7 +42,7 @@ export default function BingoGameDataAdmin(props) { const { t, i18n } = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); - const hasLoadedRef = useRef(false); + const isLoadingRef = useRef(false); useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [messages, setMessages] = useState({}); @@ -87,8 +87,8 @@ export default function BingoGameDataAdmin(props) { }, [endpointStatus]); useEffect(() => { - if (!hasLoadedRef.current) { - hasLoadedRef.current = true; + if (isLoadingRef.current) { + isLoadingRef.current = false; return; } setDirty(true); @@ -207,6 +207,7 @@ export default function BingoGameDataAdmin(props) { }; const getBingoGameData = () => { + isLoadingRef.current = true; dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === bingoGameId) { diff --git a/app/javascript/components/BingoBoards/CandidateListEntry.tsx b/app/javascript/components/BingoBoards/CandidateListEntry.tsx index e75e46158..7a0cd39ea 100644 --- a/app/javascript/components/BingoBoards/CandidateListEntry.tsx +++ b/app/javascript/components/BingoBoards/CandidateListEntry.tsx @@ -40,7 +40,7 @@ export default function CandidateListEntry(props: Props) { const { bingoGameId } = useParams(); const [dirty, setDirty] = useState(false); - const hasLoadedRef = useRef(false); + const isLoadingRef = useRef(false); const dispatch = useDispatch(); useDirtyStatus(category, dirty); @@ -59,6 +59,7 @@ export default function CandidateListEntry(props: Props) { const [requestCollaborationUrl, setRequestCollaborationUrl] = useState(""); const getCandidateList = () => { + isLoadingRef.current = true; dispatch(startTask()); const url = props.rootPath === undefined @@ -184,8 +185,8 @@ export default function CandidateListEntry(props: Props) { }, [endpointStatus]); useEffect(() => { - if (!hasLoadedRef.current) { - hasLoadedRef.current = true; + if (isLoadingRef.current) { + isLoadingRef.current = false; return; } setDirty(true); diff --git a/app/javascript/components/assignments/AssignmentDataAdmin.tsx b/app/javascript/components/assignments/AssignmentDataAdmin.tsx index 573e45ad6..840b70628 100644 --- a/app/javascript/components/assignments/AssignmentDataAdmin.tsx +++ b/app/javascript/components/assignments/AssignmentDataAdmin.tsx @@ -44,7 +44,7 @@ export default function AssignmentDataAdmin(props) { const navigate = useNavigate(); const [dirty, setDirty] = useState(false); - const hasLoadedRef = useRef(false); + const isLoadingRef = useRef(false); useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [assignmentProjects, setAssignmentProjects] = useState([ @@ -100,8 +100,8 @@ export default function AssignmentDataAdmin(props) { }, [endpointStatus]); useEffect(() => { - if (!hasLoadedRef.current) { - hasLoadedRef.current = true; + if (isLoadingRef.current) { + isLoadingRef.current = false; return; } setDirty(true); @@ -218,6 +218,7 @@ export default function AssignmentDataAdmin(props) { setAssignmentRubricId(assignment.rubric_id || -1); }; const getAssignmentData = () => { + isLoadingRef.current = true; dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === assignmentId) { diff --git a/app/javascript/components/assignments/AssignmentSubmission.tsx b/app/javascript/components/assignments/AssignmentSubmission.tsx index 7f635e5c7..868914c58 100644 --- a/app/javascript/components/assignments/AssignmentSubmission.tsx +++ b/app/javascript/components/assignments/AssignmentSubmission.tsx @@ -43,7 +43,7 @@ export default function AssignmentSubmission(props: Props) { const navigate = useNavigate(); const [t, i18n] = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); - const hasLoadedRef = useRef(false); + const isLoadingRef = useRef(false); useDirtyStatus(category, dirty); const [submissionId, setSubmissionId] = useState(); @@ -62,14 +62,15 @@ export default function AssignmentSubmission(props: Props) { }, [endpointStatus, submissionId]); useEffect(() => { - if (!hasLoadedRef.current) { - hasLoadedRef.current = true; + if (isLoadingRef.current) { + isLoadingRef.current = false; return; } setDirty(true); }, [submissionTextEditor, submissionLink]); const loadSubmission = () => { + isLoadingRef.current = true; const url = props.rootPath === undefined ? `${endpoints.submissionUrl}${submissionId}.json` : `/${props.rootPath}${endpoints.submissionUrl}${submissionId}.json`; diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx index d88b4b9da..1b5e7b554 100644 --- a/app/javascript/components/checkin/InstallmentReport.tsx +++ b/app/javascript/components/checkin/InstallmentReport.tsx @@ -80,7 +80,7 @@ export default function InstallmentReport(props: Props) { const [contributions, setContributions] = useState({}); const [installment, setInstallment] = useState({ comments: "" }); const [dirty, setDirty] = useState(false); - const hasLoadedRef = useRef(false); + const isLoadingRef = useRef(false); useDirtyStatus(category, dirty); const [redirectState, setRedirectState] = useState(RedirectState.DECIDING); @@ -102,8 +102,8 @@ export default function InstallmentReport(props: Props) { }; useEffect(() => { - if (!hasLoadedRef.current) { - hasLoadedRef.current = true; + if (isLoadingRef.current) { + isLoadingRef.current = false; return; } setDirty(true); @@ -134,6 +134,7 @@ export default function InstallmentReport(props: Props) { //Retrieve the latest data const getContributions = () => { + isLoadingRef.current = true; const url = props.rootPath === undefined ? `${endpoints.baseUrl}${projectId}.json` diff --git a/app/javascript/components/infrastructure/StatusSlice.ts b/app/javascript/components/infrastructure/StatusSlice.ts index 2393fd9cc..d377fc8aa 100644 --- a/app/javascript/components/infrastructure/StatusSlice.ts +++ b/app/javascript/components/infrastructure/StatusSlice.ts @@ -98,9 +98,6 @@ export function useDirtyStatus(flagKey: string, dirty: boolean) { if (!hasInitialized.current) { hasInitialized.current = true; previousDirty.current = dirty; - if (dirty) { - dispatch(setClean(flagKey)); - } return; } From a5007f6ad4b869ae09466df6ee3460fa97fbf13b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:07:02 +0000 Subject: [PATCH 6/8] Tighten fresh-load dirty suppression for data-entry screens Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/javascript/components/AppStatusBar.tsx | 45 ++++++++++--------- .../BingoBoards/BingoGameDataAdmin.tsx | 11 +++-- .../BingoBoards/CandidateListEntry.tsx | 11 +++-- .../assignments/AssignmentDataAdmin.tsx | 11 +++-- .../assignments/AssignmentSubmission.tsx | 11 +++-- .../components/checkin/InstallmentReport.tsx | 11 +++-- 6 files changed, 58 insertions(+), 42 deletions(-) diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx index 0476a2ca4..0fc8e8ea6 100644 --- a/app/javascript/components/AppStatusBar.tsx +++ b/app/javascript/components/AppStatusBar.tsx @@ -47,28 +47,29 @@ export default function AppStatusBar() { return ( <> -
- - {hasDirtyChanges ? "Unsaved changes" : "Saved"} +
+
+ + {hasDirtyChanges ? "Unsaved changes" : "Saved"} +
); diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index 0d087e665..e25aded90 100644 --- a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx +++ b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx @@ -42,7 +42,7 @@ export default function BingoGameDataAdmin(props) { const { t, i18n } = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); - const isLoadingRef = useRef(false); + const suppressDirtyRef = useRef(false); useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [messages, setMessages] = useState({}); @@ -87,8 +87,8 @@ export default function BingoGameDataAdmin(props) { }, [endpointStatus]); useEffect(() => { - if (isLoadingRef.current) { - isLoadingRef.current = false; + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; return; } setDirty(true); @@ -207,7 +207,10 @@ export default function BingoGameDataAdmin(props) { }; const getBingoGameData = () => { - isLoadingRef.current = true; + suppressDirtyRef.current = true; + setTimeout(() => { + suppressDirtyRef.current = false; + }, 0); dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === bingoGameId) { diff --git a/app/javascript/components/BingoBoards/CandidateListEntry.tsx b/app/javascript/components/BingoBoards/CandidateListEntry.tsx index 7a0cd39ea..5d7914ed1 100644 --- a/app/javascript/components/BingoBoards/CandidateListEntry.tsx +++ b/app/javascript/components/BingoBoards/CandidateListEntry.tsx @@ -40,7 +40,7 @@ export default function CandidateListEntry(props: Props) { const { bingoGameId } = useParams(); const [dirty, setDirty] = useState(false); - const isLoadingRef = useRef(false); + const suppressDirtyRef = useRef(false); const dispatch = useDispatch(); useDirtyStatus(category, dirty); @@ -59,7 +59,10 @@ export default function CandidateListEntry(props: Props) { const [requestCollaborationUrl, setRequestCollaborationUrl] = useState(""); const getCandidateList = () => { - isLoadingRef.current = true; + suppressDirtyRef.current = true; + setTimeout(() => { + suppressDirtyRef.current = false; + }, 0); dispatch(startTask()); const url = props.rootPath === undefined @@ -185,8 +188,8 @@ export default function CandidateListEntry(props: Props) { }, [endpointStatus]); useEffect(() => { - if (isLoadingRef.current) { - isLoadingRef.current = false; + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; return; } setDirty(true); diff --git a/app/javascript/components/assignments/AssignmentDataAdmin.tsx b/app/javascript/components/assignments/AssignmentDataAdmin.tsx index 840b70628..de59bcc40 100644 --- a/app/javascript/components/assignments/AssignmentDataAdmin.tsx +++ b/app/javascript/components/assignments/AssignmentDataAdmin.tsx @@ -44,7 +44,7 @@ export default function AssignmentDataAdmin(props) { const navigate = useNavigate(); const [dirty, setDirty] = useState(false); - const isLoadingRef = useRef(false); + const suppressDirtyRef = useRef(false); useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [assignmentProjects, setAssignmentProjects] = useState([ @@ -100,8 +100,8 @@ export default function AssignmentDataAdmin(props) { }, [endpointStatus]); useEffect(() => { - if (isLoadingRef.current) { - isLoadingRef.current = false; + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; return; } setDirty(true); @@ -218,7 +218,10 @@ export default function AssignmentDataAdmin(props) { setAssignmentRubricId(assignment.rubric_id || -1); }; const getAssignmentData = () => { - isLoadingRef.current = true; + suppressDirtyRef.current = true; + setTimeout(() => { + suppressDirtyRef.current = false; + }, 0); dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === assignmentId) { diff --git a/app/javascript/components/assignments/AssignmentSubmission.tsx b/app/javascript/components/assignments/AssignmentSubmission.tsx index 868914c58..35be9ad38 100644 --- a/app/javascript/components/assignments/AssignmentSubmission.tsx +++ b/app/javascript/components/assignments/AssignmentSubmission.tsx @@ -43,7 +43,7 @@ export default function AssignmentSubmission(props: Props) { const navigate = useNavigate(); const [t, i18n] = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); - const isLoadingRef = useRef(false); + const suppressDirtyRef = useRef(false); useDirtyStatus(category, dirty); const [submissionId, setSubmissionId] = useState(); @@ -62,15 +62,18 @@ export default function AssignmentSubmission(props: Props) { }, [endpointStatus, submissionId]); useEffect(() => { - if (isLoadingRef.current) { - isLoadingRef.current = false; + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; return; } setDirty(true); }, [submissionTextEditor, submissionLink]); const loadSubmission = () => { - isLoadingRef.current = true; + suppressDirtyRef.current = true; + setTimeout(() => { + suppressDirtyRef.current = false; + }, 0); const url = props.rootPath === undefined ? `${endpoints.submissionUrl}${submissionId}.json` : `/${props.rootPath}${endpoints.submissionUrl}${submissionId}.json`; diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx index 1b5e7b554..aedb1a593 100644 --- a/app/javascript/components/checkin/InstallmentReport.tsx +++ b/app/javascript/components/checkin/InstallmentReport.tsx @@ -80,7 +80,7 @@ export default function InstallmentReport(props: Props) { const [contributions, setContributions] = useState({}); const [installment, setInstallment] = useState({ comments: "" }); const [dirty, setDirty] = useState(false); - const isLoadingRef = useRef(false); + const suppressDirtyRef = useRef(false); useDirtyStatus(category, dirty); const [redirectState, setRedirectState] = useState(RedirectState.DECIDING); @@ -102,8 +102,8 @@ export default function InstallmentReport(props: Props) { }; useEffect(() => { - if (isLoadingRef.current) { - isLoadingRef.current = false; + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; return; } setDirty(true); @@ -134,7 +134,10 @@ export default function InstallmentReport(props: Props) { //Retrieve the latest data const getContributions = () => { - isLoadingRef.current = true; + suppressDirtyRef.current = true; + setTimeout(() => { + suppressDirtyRef.current = false; + }, 0); const url = props.rootPath === undefined ? `${endpoints.baseUrl}${projectId}.json` From c02d3e6a1d5aabd314d4762c381ce473225f465e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:08:12 +0000 Subject: [PATCH 7/8] Remove premature dirty suppression reset and keep hydration guard in the effect path Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx | 3 --- app/javascript/components/BingoBoards/CandidateListEntry.tsx | 3 --- app/javascript/components/assignments/AssignmentDataAdmin.tsx | 3 --- app/javascript/components/assignments/AssignmentSubmission.tsx | 3 --- app/javascript/components/checkin/InstallmentReport.tsx | 3 --- 5 files changed, 15 deletions(-) diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index e25aded90..62daabdb2 100644 --- a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx +++ b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx @@ -208,9 +208,6 @@ export default function BingoGameDataAdmin(props) { const getBingoGameData = () => { suppressDirtyRef.current = true; - setTimeout(() => { - suppressDirtyRef.current = false; - }, 0); dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === bingoGameId) { diff --git a/app/javascript/components/BingoBoards/CandidateListEntry.tsx b/app/javascript/components/BingoBoards/CandidateListEntry.tsx index 5d7914ed1..c077fd8c0 100644 --- a/app/javascript/components/BingoBoards/CandidateListEntry.tsx +++ b/app/javascript/components/BingoBoards/CandidateListEntry.tsx @@ -60,9 +60,6 @@ export default function CandidateListEntry(props: Props) { const getCandidateList = () => { suppressDirtyRef.current = true; - setTimeout(() => { - suppressDirtyRef.current = false; - }, 0); dispatch(startTask()); const url = props.rootPath === undefined diff --git a/app/javascript/components/assignments/AssignmentDataAdmin.tsx b/app/javascript/components/assignments/AssignmentDataAdmin.tsx index de59bcc40..56c11312e 100644 --- a/app/javascript/components/assignments/AssignmentDataAdmin.tsx +++ b/app/javascript/components/assignments/AssignmentDataAdmin.tsx @@ -219,9 +219,6 @@ export default function AssignmentDataAdmin(props) { }; const getAssignmentData = () => { suppressDirtyRef.current = true; - setTimeout(() => { - suppressDirtyRef.current = false; - }, 0); dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === assignmentId) { diff --git a/app/javascript/components/assignments/AssignmentSubmission.tsx b/app/javascript/components/assignments/AssignmentSubmission.tsx index 35be9ad38..be39f53c4 100644 --- a/app/javascript/components/assignments/AssignmentSubmission.tsx +++ b/app/javascript/components/assignments/AssignmentSubmission.tsx @@ -71,9 +71,6 @@ export default function AssignmentSubmission(props: Props) { const loadSubmission = () => { suppressDirtyRef.current = true; - setTimeout(() => { - suppressDirtyRef.current = false; - }, 0); const url = props.rootPath === undefined ? `${endpoints.submissionUrl}${submissionId}.json` : `/${props.rootPath}${endpoints.submissionUrl}${submissionId}.json`; diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx index aedb1a593..bcc84658a 100644 --- a/app/javascript/components/checkin/InstallmentReport.tsx +++ b/app/javascript/components/checkin/InstallmentReport.tsx @@ -135,9 +135,6 @@ export default function InstallmentReport(props: Props) { //Retrieve the latest data const getContributions = () => { suppressDirtyRef.current = true; - setTimeout(() => { - suppressDirtyRef.current = false; - }, 0); const url = props.rootPath === undefined ? `${endpoints.baseUrl}${projectId}.json` From ee3e66f78199d71bddecc57f175c812cb3d8faa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:29:27 +0000 Subject: [PATCH 8/8] Fix stale dirty state and status bar layering Co-authored-by: mgmodell <7279993+mgmodell@users.noreply.github.com> --- app/javascript/components/AppStatusBar.tsx | 6 ++++-- .../components/infrastructure/StatusSlice.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx index 0fc8e8ea6..96d6bbfef 100644 --- a/app/javascript/components/AppStatusBar.tsx +++ b/app/javascript/components/AppStatusBar.tsx @@ -47,7 +47,7 @@ export default function AppStatusBar() { return ( <> -
+
diff --git a/app/javascript/components/infrastructure/StatusSlice.ts b/app/javascript/components/infrastructure/StatusSlice.ts index d377fc8aa..93560583a 100644 --- a/app/javascript/components/infrastructure/StatusSlice.ts +++ b/app/javascript/components/infrastructure/StatusSlice.ts @@ -92,12 +92,17 @@ export const { export function useDirtyStatus(flagKey: string, dirty: boolean) { const dispatch = useDispatch(); const hasInitialized = useRef(false); - const previousDirty = useRef(false); + const previousDirty = useRef(dirty); useEffect(() => { if (!hasInitialized.current) { hasInitialized.current = true; previousDirty.current = dirty; + if (dirty) { + dispatch(setDirty(flagKey)); + } else { + dispatch(setClean(flagKey)); + } return; } @@ -112,6 +117,12 @@ export function useDirtyStatus(flagKey: string, dirty: boolean) { } previousDirty.current = dirty; }, [dirty, flagKey, dispatch]); + + useEffect(() => { + return () => { + dispatch(setClean(flagKey)); + }; + }, [dispatch, flagKey]); } export default reducer;