diff --git a/CHANGELOG.md b/CHANGELOG.md index 8133e8fa..89bb1343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.5.12 + +- #308: share compact, accessible homework Review rows with expandable long answers and distinct + accepted/draft snapshots. Add a configurable Learning in Public link editor that preserves + autosave, supports an optional blank state, and falls back to a plain textarea without JavaScript. + ## 0.5.11 - #301: add a six-state learner homework descriptor and a read-only helper for rendering the same diff --git a/community_base/__init__.py b/community_base/__init__.py index 57f9f92e..e1e093c0 100644 --- a/community_base/__init__.py +++ b/community_base/__init__.py @@ -1 +1 @@ -__version__ = "0.5.11" +__version__ = "0.5.12" diff --git a/community_base/homework_steps/README.md b/community_base/homework_steps/README.md index 2b48b0fe..aed28745 100644 --- a/community_base/homework_steps/README.md +++ b/community_base/homework_steps/README.md @@ -89,6 +89,15 @@ resolving and authorizing the assignment. Put per-question AJAX URLs in On a successful legacy form submission, call `clear_draft(user, assignment.key)` so old step answers cannot reappear. +The shared Review list is `homework_steps/_review_rows.html`. It uses semantic step labels when +available and keeps accepted answers separate from pending drafts. For a question keyed +`learning-in-public`, set `Assignment.context["learning_in_public_cap"]` to a positive maximum to +render the shared `homework_steps/_public_links.html` editor. It stores nonempty links as a +newline-separated text answer, which the host adapter can validate and map to its submission +model. Sites that override the stepper should include those partials and load +`community_base/homework_public_links.js` alongside `community_base/homework_steps.js`. The +package provides structural CSS classes; sites apply their own styles. + The default `step_param` continues to read and generate query-step URLs for existing bookmarks. A host can add canonical route-step URLs by passing the route's step value as `route_step` and a `step_url_builder` that maps a step key to a path, for example `/homework/intro`. The handler uses diff --git a/community_base/homework_steps/static/community_base/homework_public_links.js b/community_base/homework_steps/static/community_base/homework_public_links.js new file mode 100644 index 00000000..f83952b8 --- /dev/null +++ b/community_base/homework_steps/static/community_base/homework_public_links.js @@ -0,0 +1,100 @@ +document.querySelectorAll("[data-learning-public-links]").forEach((group) => { + const maxLinks = Number.parseInt(group.dataset.maxLinks, 10); + const valueField = group.querySelector("[data-public-links-value]"); + const fallbackLabel = group.querySelector("[data-public-links-fallback-label]"); + const slots = group.querySelector("[data-public-link-slots]"); + const addButton = group.querySelector("[data-add-public-link]"); + const addLabel = group.querySelector("[data-add-public-link-label]"); + const count = group.querySelector("[data-public-link-count]"); + if (!valueField || !slots || !addButton || !Number.isFinite(maxLinks) || maxLinks < 1) { + return; + } + + valueField.hidden = true; + valueField.style.display = "none"; + if (fallbackLabel) fallbackLabel.hidden = true; + addButton.hidden = false; + + const savedLinks = valueField.value.split(/\r?\n/).map((link) => link.trim()).filter(Boolean); + + const updateAnswer = () => { + const links = Array.from(slots.querySelectorAll("[data-public-link-input]")) + .map((input) => input.value.trim()) + .filter(Boolean); + valueField.value = links.join("\n"); + if (count) count.textContent = `${links.length} of ${maxLinks} added`; + }; + + const renderSlots = (values, focusIndex = -1) => { + slots.replaceChildren(); + values.forEach((value, index) => { + const row = document.createElement("div"); + row.className = "homework-public-link-row"; + row.dataset.publicLinkRow = "true"; + + const field = document.createElement("div"); + field.className = "homework-public-link-field"; + + const label = document.createElement("label"); + label.className = "homework-public-link-label"; + label.htmlFor = `learning-public-link-${index + 1}`; + label.textContent = `Public link ${index + 1}`; + + const input = document.createElement("input"); + input.id = label.htmlFor; + input.type = "url"; + input.inputMode = "url"; + input.autocomplete = "url"; + input.placeholder = "https://"; + input.value = value; + input.dataset.publicLinkInput = "true"; + input.className = "homework-public-link-input"; + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "homework-public-link-remove"; + remove.dataset.removePublicLink = "true"; + remove.setAttribute("aria-label", `Remove link ${index + 1}`); + remove.textContent = "Remove"; + remove.hidden = values.length === 1; + remove.style.display = values.length === 1 ? "none" : ""; + + field.append(label, input); + row.append(field, remove); + slots.append(row); + }); + + addButton.hidden = values.length >= maxLinks; + addButton.style.display = values.length >= maxLinks ? "none" : ""; + if (addLabel) addLabel.textContent = values.length ? "Add another link" : "Add a link"; + updateAnswer(); + if (focusIndex >= 0) slots.querySelectorAll("[data-public-link-input]")[focusIndex]?.focus(); + }; + + slots.addEventListener("input", (event) => { + if (event.target.matches("[data-public-link-input]")) updateAnswer(); + }); + slots.addEventListener("click", (event) => { + const remove = event.target.closest("[data-remove-public-link]"); + if (!remove) return; + const row = remove.closest("[data-public-link-row]"); + const rows = Array.from(slots.querySelectorAll("[data-public-link-row]")); + const removeIndex = rows.indexOf(row); + const values = Array.from(slots.querySelectorAll("[data-public-link-input]")) + .map((input) => input.value); + values.splice(removeIndex, 1); + renderSlots(values); + updateAnswer(); + valueField.dispatchEvent(new Event("input", { bubbles: true })); + }); + addButton.addEventListener("click", () => { + const values = Array.from(slots.querySelectorAll("[data-public-link-input]")) + .map((input) => input.value); + if (values.length >= maxLinks) return; + const focusIndex = values.length; + values.push(""); + renderSlots(values, focusIndex); + }); + + renderSlots(savedLinks.length ? savedLinks : [""]); +}); diff --git a/community_base/homework_steps/templates/homework_steps/_public_links.html b/community_base/homework_steps/templates/homework_steps/_public_links.html new file mode 100644 index 00000000..383fa861 --- /dev/null +++ b/community_base/homework_steps/templates/homework_steps/_public_links.html @@ -0,0 +1,13 @@ +
Your public posts
+Optional. Add up to {{ stepper.assignment.context.learning_in_public_cap }} links to posts about your progress.
+Submitted {{ stepper.accepted_submitted_at }}
{% endif %} -{{ field.label }} — {{ value|default:"No answer saved" }}
{% endfor %} @@ -79,13 +75,7 @@{{ field.label }} — {{ value|default:"No answer saved" }}
{% endfor %} @@ -94,13 +84,7 @@{{ field.label }} — {{ value }}
{% endfor %} @@ -109,13 +93,7 @@No submission was accepted.
{% endif %} {% else %} -