diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx index 4841c46e..96d6bbfe 100644 --- a/app/javascript/components/AppStatusBar.tsx +++ b/app/javascript/components/AppStatusBar.tsx @@ -6,26 +6,73 @@ 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 => { + 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 ; + return ( + <> + +
+
+ + {hasDirtyChanges ? "Unsaved changes" : "Saved"} +
+
+ + ); } diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index df371b99..62daabdb 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"; @@ -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,8 @@ export default function BingoGameDataAdmin(props) { const { t, i18n } = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); + const suppressDirtyRef = useRef(false); + useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [messages, setMessages] = useState({}); const [gameProjects, setGameProjects] = useState([ @@ -85,6 +87,10 @@ export default function BingoGameDataAdmin(props) { }, [endpointStatus]); useEffect(() => { + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; + return; + } setDirty(true); }, [ gameTopic, @@ -161,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); @@ -200,7 +207,7 @@ export default function BingoGameDataAdmin(props) { }; const getBingoGameData = () => { - setDirty(true); + suppressDirtyRef.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 5bd70f65..c077fd8c 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"; @@ -10,7 +10,8 @@ import { startTask, endTask, addMessage, - Priorities + Priorities, + useDirtyStatus } from "../infrastructure/StatusSlice"; import { useTypedSelector } from "../infrastructure/AppReducers"; import axios from "axios"; @@ -39,7 +40,9 @@ export default function CandidateListEntry(props: Props) { const { bingoGameId } = useParams(); const [dirty, setDirty] = useState(false); + const suppressDirtyRef = useRef(false); const dispatch = useDispatch(); + useDirtyStatus(category, dirty); const [candidateListId, setCandidateListId] = useState(0); const [topic, setTopic] = useState(""); @@ -56,8 +59,8 @@ export default function CandidateListEntry(props: Props) { const [requestCollaborationUrl, setRequestCollaborationUrl] = useState(""); const getCandidateList = () => { + suppressDirtyRef.current = true; dispatch(startTask()); - setDirty(true); const url = props.rootPath === undefined ? `${endpoints.baseUrl}${bingoGameId}.json` @@ -182,6 +185,10 @@ export default function CandidateListEntry(props: Props) { }, [endpointStatus]); useEffect(() => { + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; + return; + } setDirty(true); }, [candidates]); diff --git a/app/javascript/components/ConceptsTable.tsx b/app/javascript/components/ConceptsTable.tsx index bbf09952..2c31e435 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 e60c0770..56c11312 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"; @@ -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,8 @@ export default function AssignmentDataAdmin(props) { const navigate = useNavigate(); const [dirty, setDirty] = useState(false); + const suppressDirtyRef = useRef(false); + useDirtyStatus(category, dirty); const [curTab, setCurTab] = useState(0); const [assignmentProjects, setAssignmentProjects] = useState([ { id: -1, name: "None Selected" } @@ -98,6 +100,10 @@ export default function AssignmentDataAdmin(props) { }, [endpointStatus]); useEffect(() => { + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; + return; + } setDirty(true); }, [ assignmentName, @@ -212,7 +218,7 @@ export default function AssignmentDataAdmin(props) { setAssignmentRubricId(assignment.rubric_id || -1); }; const getAssignmentData = () => { - setDirty(true); + suppressDirtyRef.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 19cd77c8..be39f53c 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 React, { useState, useEffect, useMemo, useRef } 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, useDirtyStatus } from "../infrastructure/StatusSlice"; import { IAssignment } from "./AssignmentViewer"; import { useTypedSelector } from "../infrastructure/AppReducers"; @@ -39,8 +40,11 @@ export default function AssignmentSubmission(props: Props) { ); const dispatch = useDispatch(); + const navigate = useNavigate(); const [t, i18n] = useTranslation(`${category}s`); const [dirty, setDirty] = useState(false); + const suppressDirtyRef = useRef(false); + useDirtyStatus(category, dirty); const [submissionId, setSubmissionId] = useState(); const [updatedDate, setUpdatedDate] = useState(null); @@ -58,12 +62,15 @@ export default function AssignmentSubmission(props: Props) { }, [endpointStatus, submissionId]); useEffect(() => { - if (endpointStatus) { - setDirty(true); + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; + return; } + setDirty(true); }, [submissionTextEditor, submissionLink]); const loadSubmission = () => { + suppressDirtyRef.current = true; const url = props.rootPath === undefined ? `${endpoints.submissionUrl}${submissionId}.json` : `/${props.rootPath}${endpoints.submissionUrl}${submissionId}.json`; @@ -92,8 +99,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(() => { @@ -112,7 +117,7 @@ export default function AssignmentSubmission(props: Props) { value={submissionTextEditor} headerTemplate={} onTextChange={e => { - setSubmissionTextEditor(e.htmlValue); + setSubmissionTextEditor(e.htmlValue || ""); }} /> @@ -166,6 +171,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 +190,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) diff --git a/app/javascript/components/checkin/InstallmentReport.tsx b/app/javascript/components/checkin/InstallmentReport.tsx index 709cafdc..bcc84658 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"; @@ -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,8 @@ export default function InstallmentReport(props: Props) { const [contributions, setContributions] = useState({}); const [installment, setInstallment] = useState({ comments: "" }); const [dirty, setDirty] = useState(false); + const suppressDirtyRef = useRef(false); + useDirtyStatus(category, dirty); const [redirectState, setRedirectState] = useState(RedirectState.DECIDING); const [redirectUrl, setRedirectUrl] = useState(undefined); @@ -98,7 +101,13 @@ export default function InstallmentReport(props: Props) { setInstallment(inst); }; - useEffect(() => setDirty(true), [contributions, installment]); + useEffect(() => { + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; + return; + } + setDirty(true); + }, [contributions, installment]); useEffect(() => { if (endpointStatus) { @@ -125,6 +134,7 @@ export default function InstallmentReport(props: Props) { //Retrieve the latest data const getContributions = () => { + suppressDirtyRef.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 9f15839b..93560583 100644 --- a/app/javascript/components/infrastructure/StatusSlice.ts +++ b/app/javascript/components/infrastructure/StatusSlice.ts @@ -1,3 +1,5 @@ +import { useEffect, useRef } from "react"; +import { useDispatch } from "react-redux"; import { createSlice } from "@reduxjs/toolkit"; export enum Priorities { @@ -86,4 +88,41 @@ export const { addMessage, acknowledgeMsg } = actions; + +export function useDirtyStatus(flagKey: string, dirty: boolean) { + const dispatch = useDispatch(); + const hasInitialized = 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; + } + + if (dirty === previousDirty.current) { + return; + } + + if (dirty) { + dispatch(setDirty(flagKey)); + } else { + dispatch(setClean(flagKey)); + } + previousDirty.current = dirty; + }, [dirty, flagKey, dispatch]); + + useEffect(() => { + return () => { + dispatch(setClean(flagKey)); + }; + }, [dispatch, flagKey]); +} + export default reducer;