fixed todolist not marking complete bug

This commit is contained in:
Guy Sandler 2026-10-12 16:29:06 -07:00
parent 6850b3cb4f
commit 20430ba343
2 changed files with 110 additions and 29 deletions

View File

@ -4653,6 +4653,11 @@ function markAs(item, element, makeComplete) {
const csrfToken = CSRFtoken(); const csrfToken = CSRFtoken();
const completeState = makeComplete; const completeState = makeComplete;
// Pre-click state, captured so the optimistic UI can be rolled back if
// the server definitively refuses the change (see revertOptimisticUI).
const previousComplete = item.planner_override ? item.planner_override.marked_complete === true : false;
const hadPin = isPinnedIncomplete(item);
// Checking an item off clears any local incomplete pin; unchecking a // Checking an item off clears any local incomplete pin; unchecking a
// genuinely submitted item sets one, since the submission itself can't // genuinely submitted item sets one, since the submission itself can't
// be undone server-side. // be undone server-side.
@ -4687,8 +4692,9 @@ function markAs(item, element, makeComplete) {
element.style.opacity = "0"; element.style.opacity = "0";
// update progress rings immediately so they animate while the item slides/fades // update progress rings immediately so they animate while the item slides/fades
const refreshProgressRings = () => {
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder"); const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) { if (!progressPlaceholder || typeof assignments?.then !== 'function' || !progressRingsEnabled()) return;
assignments.then(data => { assignments.then(data => {
const courseId = getCurrentCourseId(); const courseId = getCurrentCourseId();
const scopedData = getTodoScopedData(data.map(d => Object.assign({}, d)), courseId); const scopedData = getTodoScopedData(data.map(d => Object.assign({}, d)), courseId);
@ -4708,7 +4714,36 @@ function markAs(item, element, makeComplete) {
renderProgressRings(progressPlaceholder, scopedData); renderProgressRings(progressPlaceholder, scopedData);
}); });
};
refreshProgressRings();
// --- Failure rollback ---
// Restores the pre-click state when the server definitively refuses the
// toggle: a POST 400 whose existing override can't be recovered, a failed
// PUT, or a network error. Canvas's planner_override uniqueness scope is
// (user, plannable_type, plannable_id) regardless of workflow_state, but
// soft-deleted override rows are invisible to BOTH read endpoints, so a
// dormant row can permanently block the POST with 400 "already exists".
// Without a rollback the optimistic UI would silently lie until the next
// reload, when the item reappears unchecked — the reported "cannot check
// off / won't move to Completed" bug.
const revertOptimisticUI = (reason) => {
item.planner_override = item.planner_override || {};
item.planner_override.marked_complete = previousComplete;
// undo the pin side effects of this toggle
if (completeState) {
if (hadPin) setPinnedIncomplete(item, true);
} else if (item.submissions?.submitted && !hadPin) {
setPinnedIncomplete(item, false);
} }
console.warn("planner override not persisted, reverting UI:", reason);
// snap the card back if it is still in the DOM (before the 400ms swap)
element.style.transform = "";
element.style.opacity = "";
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
refreshProgressRings();
};
setTimeout(() => { setTimeout(() => {
clearTodoList(); clearTodoList();
@ -4720,9 +4755,9 @@ function markAs(item, element, makeComplete) {
// successful POST/PUT is stored on the in-memory item so subsequent // successful POST/PUT is stored on the in-memory item so subsequent
// toggles PUT (update) instead of POSTing a duplicate. If a POST still // toggles PUT (update) instead of POSTing a duplicate. If a POST still
// collides with an existing override (400 "already exists"), we look up // collides with an existing override (400 "already exists"), we look up
// the real id and retry with a PUT. Only if all of that fails is the // the real id and retry with a PUT. If every attempt fails, the
// response treated as a soft success: the UI already reflects the // optimistic UI is rolled back so the card visibly returns instead of
// intended state, so we just log it. // silently dropping the check-off on the next reload.
const sendOverride = (override) => fetch(domain + "/api/v1/planner/overrides" + (override && override.id ? "/" + override.id : ""), { const sendOverride = (override) => fetch(domain + "/api/v1/planner/overrides" + (override && override.id ? "/" + override.id : ""), {
method: override && override.id ? "PUT" : "POST", method: override && override.id ? "PUT" : "POST",
headers: { headers: {
@ -4738,6 +4773,46 @@ function markAs(item, element, makeComplete) {
}) })
}); });
// Loads every planner override the user has. The index endpoint is
// paginated at 10 items per page and only ever returns ACTIVE overrides —
// Canvas soft-deletes overrides (workflow_state="deleted") instead of
// removing the row, and those dormant rows still satisfy the
// (user, plannable_type, plannable_id) uniqueness constraint. A dormant
// row therefore makes POST create fail with 400 "already exists" while
// being unlistable, which is why items in some courses could never be
// checked off: the old recovery read a single un-paginated page, missed
// the row, and never PUT. Page through with per_page=100 following the
// Link header (same pattern as the planner items fetch). Returns null on
// any failure so the caller can distinguish "no match" from "unknown".
const fetchAllOverrides = async () => {
const all = [];
let url = domain + "/api/v1/planner/overrides?per_page=100";
for (let page = 0; page < 10 && url; page++) {
const pageResp = await fetch(url, { headers: { "accept": "application/json" } });
if (!pageResp.ok) return null;
const data = await pageResp.json();
if (!Array.isArray(data)) return null;
all.push(...data);
url = getNextPageUrl(pageResp.headers.get("Link"));
}
return all;
};
// A stored override may not carry the identity the planner items feed
// gave us: Canvas re-points overrides on save (discussion overrides move
// to the root topic id, assignment overrides can be re-typed to
// quiz/discussion_topic/wiki_page with a different id), so also match on
// the associated assignment_id as a fallback.
const matchesItem = (o) => {
if (!o) return false;
if (String(o.plannable_id) === String(item.plannable_id) &&
String(o.plannable_type).toLowerCase() === String(item.plannable_type).toLowerCase()) return true;
const assignmentId = item.plannable && item.plannable.assignment_id != null
? item.plannable.assignment_id
: (item.plannable_type === "assignment" ? item.plannable_id : null);
return assignmentId != null && o.assignment_id != null && String(o.assignment_id) === String(assignmentId);
};
sendOverride(item.planner_override) sendOverride(item.planner_override)
.then(async resp => { .then(async resp => {
if (resp.ok) { if (resp.ok) {
@ -4758,38 +4833,44 @@ function markAs(item, element, makeComplete) {
} }
// A POST can return 400 because an override already exists server-side // A POST can return 400 because an override already exists server-side
// under an id we never learned (e.g. it was created earlier without the // under an id we never learned (e.g. it was created earlier without the
// response being captured). Look up the user's overrides, find the one // response being captured, or a dormant soft-deleted row blocks it).
// for this item, and retry with a PUT so the toggle actually persists. // Look up the user's overrides, find the one for this item, and retry
// with a PUT so the toggle actually persists.
if (!(item.planner_override && item.planner_override.id)) { if (!(item.planner_override && item.planner_override.id)) {
try { try {
const listResp = await fetch(domain + "/api/v1/planner/overrides", { const overrides = await fetchAllOverrides();
headers: { "accept": "application/json" } const match = overrides && overrides.find(matchesItem);
});
if (listResp.ok) {
const overrides = await listResp.json();
const match = (Array.isArray(overrides) ? overrides : []).find(o =>
o &&
String(o.plannable_id) === String(item.plannable_id) &&
String(o.plannable_type).toLowerCase() === String(item.plannable_type).toLowerCase());
if (match && match.id) { if (match && match.id) {
item.planner_override = item.planner_override || {}; item.planner_override = item.planner_override || {};
item.planner_override.id = match.id; item.planner_override.id = match.id;
const retry = await sendOverride(item.planner_override); const retry = await sendOverride(item.planner_override);
if (!retry.ok) { if (retry.ok) {
console.warn("planner override PUT retry returned", retry.status); try {
} const saved = await retry.json();
if (saved && saved.id) item.planner_override.id = saved.id;
} catch (e) { /* id stays unknown */ }
console.log("marked as complete (recovered override id)");
return; return;
} }
console.warn("planner override PUT retry returned", retry.status);
return revertOptimisticUI("PUT retry returned " + retry.status);
} }
// No matching override (or the listing failed): nothing to
// PUT against, so the change was never persisted.
return revertOptimisticUI("POST returned " + resp.status + " and no matching override was found");
} catch (e) { } catch (e) {
console.error("error recovering planner override id", e); console.error("error recovering planner override id", e);
return revertOptimisticUI("override recovery failed: " + e);
} }
} }
// Still non-OK with nothing to recover: logged but not surfaced, the // A PUT on a known id failed (e.g. the override was removed
// UI already reflects the intended state. // server-side between our listing and the toggle): roll back.
console.warn("planner override request returned", resp.status, "— UI already updated optimistically"); return revertOptimisticUI("request returned " + resp.status);
}) })
.catch(err => console.error("error marking as complete", err)); .catch(err => {
console.error("error marking as complete", err);
revertOptimisticUI(err);
});
} }

View File

@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "Canvas Refined", "name": "Canvas Refined",
"description": "Even More Feature packed extension for Canvas.", "description": "Even More Feature packed extension for Canvas.",
"version": "7.1.0", "version": "7.1.1",
"icons": { "icons": {
"16": "icon/icon-16.png", "16": "icon/icon-16.png",
"32": "icon/icon-32.png", "32": "icon/icon-32.png",