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 05/28] 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 06/28] 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 07/28] 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 08/28] 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;
From 2f9eb59a3c99e959462ba584b0444c0a15c35d03 Mon Sep 17 00:00:00 2001
From: Micah Gideon Modell
Date: Thu, 10 Sep 2026 18:41:08 +0000
Subject: [PATCH 09/28] in progress - need another dirty state
---
Gemfile.lock | 10 +--
app/javascript/components/ActivityLib.tsx | 38 ++++-----
app/javascript/components/AppStatusBar.tsx | 35 ---------
app/javascript/components/PageWrapper.tsx | 2 +-
.../components/checkin/InstallmentReport.tsx | 2 +
.../infrastructure/DirtyIndicator.tsx | 52 +++++++++++++
.../components/toolbars/AppHeader.tsx | 8 +-
.../components/toolbars/HelpMenu.tsx | 77 +++++++++++--------
app/javascript/css/application.css | 15 ++++
config/locales/home.en.yml | 1 +
db/schema.rb | 2 +-
mise.toml | 2 +-
12 files changed, 147 insertions(+), 97 deletions(-)
create mode 100644 app/javascript/components/infrastructure/DirtyIndicator.tsx
diff --git a/Gemfile.lock b/Gemfile.lock
index 8f0c91fbd..96db9c581 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -376,8 +376,8 @@ GEM
logger
rack (>= 2.2.3)
rack-protection
- omniauth-google-oauth2 (1.2.2)
- jwt (>= 2.9.2)
+ omniauth-google-oauth2 (1.2.3)
+ jwt (>= 2.9.2, < 4)
oauth2 (~> 2.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
@@ -592,7 +592,7 @@ GEM
railties (>= 5.2)
semantic_range (>= 2.3.0)
simple_po_parser (1.1.6)
- simpleidn (0.2.3)
+ simpleidn (0.3.0)
snaky_hash (2.0.7)
hashie (>= 0.1.0, < 6)
version_gem (~> 1.1, >= 1.1.14)
@@ -869,7 +869,7 @@ CHECKSUMS
oauth2 (2.0.25) sha256=2f736a2f93c2caa67c1b08dc3c9889bb907d62643f3183fefaa1723cba6a82ac
oj (3.17.6) sha256=f511257e01a12030c3ceb8023cd2e96fca924ea9c741f57ea366816f1fd8e68c
omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c
- omniauth-google-oauth2 (1.2.2) sha256=74c3f3d0221c048f938846092fb15a1f15237526f50a7c93d9793f9a4ff1be11
+ omniauth-google-oauth2 (1.2.3) sha256=bdbab67e64e50b7902eee2dcfc2225575cefba15ab89a3b6e92c8947987c1da9
omniauth-oauth2 (1.9.0) sha256=ed15f6d9d20991807ce114cc5b9c1453bce3645b64e51c68c90cff5ff153fee8
orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
@@ -944,7 +944,7 @@ CHECKSUMS
sexp_processor (4.17.5) sha256=ae2b48ba98353d5d465ce8759836b7a05f2e12c5879fcd14d7815b026de32f0e
shakapacker (10.3.2) sha256=cfc8fa328d39b5321912376faa13710bd1ac10bc3eab3c95e6d71eb28f9ba0e0
simple_po_parser (1.1.6) sha256=122687d44d3de516a0e69e2f383a4180f5015e8c5ed5a7f2258f2b376f64cbf3
- simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29
+ simpleidn (0.3.0) sha256=12ca730bed2f3db04d11e9bfd1bca3e11fb37f55b21eb2e9793fb5814bf54d03
snaky_hash (2.0.7) sha256=7d02c70012a3f932e48860cd024577908300c9aa615e0cb9b450aaa749cbcb4d
solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03
spring (4.7.0) sha256=f81bf6d41f05518ed5d0128ce5c75fb6dd9c6153f158129c8b85c4461ca0b72c
diff --git a/app/javascript/components/ActivityLib.tsx b/app/javascript/components/ActivityLib.tsx
index c672e1ac2..7706b4155 100644
--- a/app/javascript/components/ActivityLib.tsx
+++ b/app/javascript/components/ActivityLib.tsx
@@ -9,95 +9,95 @@ export function iconForType(type: string) {
switch (type.toLowerCase()) {
case "home":
iconData.className = "home";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "welcome":
iconData.className = "welcome";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "profile":
iconData.className = "profile";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "perspective":
iconData.className = "perspective";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "concept":
case "concepts":
iconData.className = "concept";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "group experience":
case "experience":
case "experiences":
iconData.className = "experience";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "project":
case "assessment":
case "assessments":
iconData.className = "assessment";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "terms list":
case "bingo_game":
case "bingo games":
iconData.className = "bingo_game";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "group assignment":
case "assignment":
case "assignments":
iconData.className = "assignment";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "submission":
iconData.className = "submission";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "rubric":
case "rubrics":
iconData.className = "rubric";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "course":
case "courses":
iconData.className = "course";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "user":
case "users":
iconData.className = "user";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "reporting":
iconData.className = "reporting";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "administration":
case "admin":
iconData.className = "admin";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "school":
case "schools":
iconData.className = "school";
- iconData.icon = ;
+ iconData.icon = ;
break;
case "consent_form":
case "consent_forms":
iconData.className = "consent_form";
- iconData.icon = ;
+ iconData.icon = ;
break;
case 'demo':
case 'demonstration':
iconData.className = "demonstration";
- iconData.icon = ;
+ iconData.icon = ;
break;
default:
iconData.className = `unit-${type}`;
- iconData.icon = ;
+ iconData.icon = ;
}
return (
<>
diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx
index 96d6bbfef..490cc0d40 100644
--- a/app/javascript/components/AppStatusBar.tsx
+++ b/app/javascript/components/AppStatusBar.tsx
@@ -16,15 +16,6 @@ export default function AppStatusBar() {
const messages = useTypedSelector((state): AppMessage[] => {
return state.status.messages ?? [];
});
- const hasDirtyChanges = useTypedSelector(state => {
- 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);
@@ -47,32 +38,6 @@ export default function AppStatusBar() {
return (
<>
-
-
-
- {hasDirtyChanges ? "Unsaved changes" : "Saved"}
-
-
>
);
}
diff --git a/app/javascript/components/PageWrapper.tsx b/app/javascript/components/PageWrapper.tsx
index 20c7a682b..50f36d33d 100644
--- a/app/javascript/components/PageWrapper.tsx
+++ b/app/javascript/components/PageWrapper.tsx
@@ -62,7 +62,6 @@ export default function PageWrapper(props: Readonly) {
element={
}>
-
@@ -72,6 +71,7 @@ export default function PageWrapper(props: Readonly) {
+
}
diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx
index bcc84658a..ec0252634 100644
--- a/app/javascript/components/checkin/InstallmentReport.tsx
+++ b/app/javascript/components/checkin/InstallmentReport.tsx
@@ -202,6 +202,7 @@ export default function InstallmentReport(props: Props) {
setDirty(false);
setGroup(data.group);
+ suppressDirtyRef.current = true;
setProject(data.installment.project);
})
.catch(error => {
@@ -249,6 +250,7 @@ export default function InstallmentReport(props: Props) {
},
{}
);
+ suppressDirtyRef.current = true;
setContributions(receivedContributions);
navigate('/home');
}
diff --git a/app/javascript/components/infrastructure/DirtyIndicator.tsx b/app/javascript/components/infrastructure/DirtyIndicator.tsx
new file mode 100644
index 000000000..bcb444958
--- /dev/null
+++ b/app/javascript/components/infrastructure/DirtyIndicator.tsx
@@ -0,0 +1,52 @@
+import React, { useEffect } from "react";
+import { useTypedSelector } from "./AppReducers";
+import { useTranslation } from "react-i18next";
+
+type Props = {
+};
+export default function DirtyIndicator(props: Props) {
+
+ const category = 'home';
+ const {t} = useTranslation(category);
+
+ const hasDirtyChanges = useTypedSelector(state => {
+ const dirtyStatus = state.status.dirtyStatus as Record
;
+ for (const key in dirtyStatus) {
+ if (dirtyStatus[key]) {
+ return true;
+ }
+ }
+ return false;
+ });
+
+ return (
+ hasDirtyChanges ? (
+
+
+ ) : null
+ )
+}
\ No newline at end of file
diff --git a/app/javascript/components/toolbars/AppHeader.tsx b/app/javascript/components/toolbars/AppHeader.tsx
index bf56e5ff5..ba77863ec 100644
--- a/app/javascript/components/toolbars/AppHeader.tsx
+++ b/app/javascript/components/toolbars/AppHeader.tsx
@@ -10,7 +10,11 @@ import { Toolbar } from "primereact/toolbar";
import { Skeleton } from "primereact/skeleton";
import AppBreadCrumb from "./AppBreadcrumb";
-export default function AppHeader(props) {
+type Props = {
+ identifier?: string;
+}
+
+export default function AppHeader(props: Props) {
const endpointSet = "home";
const [t] = useTranslation( endpointSet);
const endpoints = useTypedSelector(
@@ -22,7 +26,7 @@ export default function AppHeader(props) {
const working = useTypedSelector(state => {
let accum = 0;
- if (undefined === props.identifier) {
+ if (undefined !== props.identifier) {
accum = state.status.tasks[props.identifier];
} else {
accum = Number(
diff --git a/app/javascript/components/toolbars/HelpMenu.tsx b/app/javascript/components/toolbars/HelpMenu.tsx
index b1ec73ed5..8a1a68444 100644
--- a/app/javascript/components/toolbars/HelpMenu.tsx
+++ b/app/javascript/components/toolbars/HelpMenu.tsx
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useLocation } from "react-router";
// Icons
-import {driver } from "driver.js";
+import { driver } from "driver.js";
import "driver.js/dist/driver.css";
import { useTranslation } from "react-i18next";
@@ -11,6 +11,7 @@ import { useTour } from "../infrastructure/TourContext";
import { Button } from "primereact/button";
import { Sidebar } from "primereact/sidebar";
import LangButton from "./LangButton";
+import DirtyIndicator from "../infrastructure/DirtyIndicator";
type Props = {
lookupUrl: string;
@@ -75,44 +76,54 @@ export default function HelpMenu(props: Props) {
const pathComponents = location.pathname.split("/");
return (
-
- setShowInfo(false)}
- >
- {candidateFeedbackInfo()}
-
-
-