From ff52c863ec4cb714b11eccd1e15a4790dae6fc74 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Sat, 26 Sep 2026 21:57:56 +0200 Subject: [PATCH 1/6] Share compact review rows and public link editor --- .../community_base/homework_public_links.js | 95 +++++++++++++++++++ .../homework_steps/_public_links.html | 12 +++ .../homework_steps/_review_rows.html | 11 +++ .../templates/homework_steps/_stepper.html | 35 ++----- tests/homework_steps/test_flow.py | 43 +++++++++ 5 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 community_base/homework_steps/static/community_base/homework_public_links.js create mode 100644 community_base/homework_steps/templates/homework_steps/_public_links.html create mode 100644 community_base/homework_steps/templates/homework_steps/_review_rows.html 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..61b3fdb7 --- /dev/null +++ b/community_base/homework_steps/static/community_base/homework_public_links.js @@ -0,0 +1,95 @@ +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 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) { + group.hidden = true; + return; + } + + 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..f3131c9e --- /dev/null +++ b/community_base/homework_steps/templates/homework_steps/_public_links.html @@ -0,0 +1,12 @@ + diff --git a/community_base/homework_steps/templates/homework_steps/_review_rows.html b/community_base/homework_steps/templates/homework_steps/_review_rows.html new file mode 100644 index 00000000..b1a9b04b --- /dev/null +++ b/community_base/homework_steps/templates/homework_steps/_review_rows.html @@ -0,0 +1,11 @@ +
    + {% for row in rows %} +
  1. +
    + {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}{% endif %} + {% if row.answer %}{{ row.answer }}{% else %}Not answered{% endif %} +
    + {% if editable %}Edit {% if row.has_semantic_label %}{{ row.step_label }}{% else %}question {{ row.question_number }}{% endif %}{% endif %} +
  2. + {% endfor %} +
diff --git a/community_base/homework_steps/templates/homework_steps/_stepper.html b/community_base/homework_steps/templates/homework_steps/_stepper.html index 6effe75b..8ab3b648 100644 --- a/community_base/homework_steps/templates/homework_steps/_stepper.html +++ b/community_base/homework_steps/templates/homework_steps/_stepper.html @@ -43,6 +43,8 @@

{{ stepper.question_label }}

{% for option, selected in stepper.options %} {% endfor %} + {% elif stepper.step == 'learning-in-public' and stepper.assignment.context.learning_in_public_cap %} + {% include "homework_steps/_public_links.html" %} {% elif stepper.question.type == 'long_text' %} @@ -65,13 +67,7 @@

Review & submit

Accepted submission

{% if stepper.accepted_submitted_at %}

Submitted {{ stepper.accepted_submitted_at }}

{% endif %} -
    - {% for row in stepper.review_display_rows %} -
  1. {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}: {{ row.prompt }}{% endif %} - {% if row.answer %} — {{ row.answer }}{% else %} — No answer saved{% endif %} -
  2. - {% endfor %} -
+ {% include "homework_steps/_review_rows.html" with rows=stepper.review_display_rows editable=False %} {% for field, value in stepper.accepted_field_rows %}

{{ field.label }} — {{ value|default:"No answer saved" }}

{% endfor %} @@ -79,13 +75,7 @@

Accepted submission

{% if stepper.pending_draft_display_rows or stepper.pending_draft_field_rows %}

Unsubmitted draft

-
    - {% for row in stepper.pending_draft_display_rows %} -
  1. {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}: {{ row.prompt }}{% endif %} - {% if row.answer %} — {{ row.answer }}{% else %} — No answer saved{% endif %} -
  2. - {% endfor %} -
+ {% include "homework_steps/_review_rows.html" with rows=stepper.pending_draft_display_rows editable=False %} {% for field, value in stepper.pending_draft_field_rows %}

{{ field.label }} — {{ value|default:"No answer saved" }}

{% endfor %} @@ -94,13 +84,7 @@

Unsubmitted draft

{% elif stepper.homework_state.has_saved_draft %}

Saved draft — not submitted

-
    - {% for row in stepper.unsent_draft_display_rows %} -
  1. {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}: {{ row.prompt }}{% endif %} - {% if row.answer %} — {{ row.answer }}{% else %} — No answer saved{% endif %} -
  2. - {% endfor %} -
+ {% include "homework_steps/_review_rows.html" with rows=stepper.unsent_draft_display_rows editable=False %} {% for field, value in stepper.unsent_draft_field_rows %}

{{ field.label }} — {{ value }}

{% endfor %} @@ -109,13 +93,7 @@

Saved draft — not submitted

No submission was accepted.

{% endif %} {% else %} -
    - {% for row in stepper.review_display_rows %} -
  1. {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}: {{ row.prompt }}{% endif %} - {% if row.answer %} — {{ row.answer }}{% else %} — No answer saved{% endif %} -
  2. - {% endfor %} -
+ {% include "homework_steps/_review_rows.html" with rows=stepper.review_display_rows editable=True %}
{% csrf_token %} @@ -143,3 +121,4 @@

Saved draft — not submitted

{% load static %} + diff --git a/tests/homework_steps/test_flow.py b/tests/homework_steps/test_flow.py index 71a36d30..70aa7965 100644 --- a/tests/homework_steps/test_flow.py +++ b/tests/homework_steps/test_flow.py @@ -340,6 +340,49 @@ def test_semantic_step_label_is_used_in_navigation_question_and_review(user, ass assert b"Question 1: Share one progress link." not in review_page.content +def test_public_link_step_uses_configured_editor_and_saves_links(user): + public_links = Assignment( + key="course:cohort-1:public-links", + title="Homework with public posts", + questions=( + Question( + "learning-in-public", + "Share your progress.", + "long_text", + step_label="Learning in Public", + ), + ), + context={"learning_in_public_cap": 3}, + ) + adapter = Adapter() + + page = flow(user, public_links, adapter, query="?homework_step=learning-in-public") + assert b'data-learning-public-links data-max-links="3"' in page.content + assert b'Optional. Add up to 3 links to posts about your progress.' in page.content + assert b'community_base/homework_public_links.js' in page.content + + saved = flow( + user, + public_links, + adapter, + method="POST", + data={ + "homework_step": "learning-in-public", + "revision": "0", + "answer": "https://example.com/one\nhttps://example.com/two", + "next_step": "review", + }, + ) + assert saved.status_code == 302 + draft = HomeworkDraft.objects.get(user=user, assignment_key=public_links.key) + assert draft.answers["learning-in-public"] == ( + "https://example.com/one\nhttps://example.com/two" + ) + review = flow(user, public_links, adapter, query="?homework_step=review") + assert b"https://example.com/one" in review.content + assert b"Learning in Public" in review.content + + def test_draft_status_help_is_available_on_question_and_review_steps(user, assignment): for query in ("?homework_step=q1", "?homework_step=review"): response = flow(user, assignment, Adapter(), query=query) From 0afb6a264a25dea652836be7887bec480c168665 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Sat, 26 Sep 2026 21:59:38 +0200 Subject: [PATCH 2/6] Format shared public link test --- tests/homework_steps/test_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/homework_steps/test_flow.py b/tests/homework_steps/test_flow.py index 70aa7965..9955e723 100644 --- a/tests/homework_steps/test_flow.py +++ b/tests/homework_steps/test_flow.py @@ -358,8 +358,8 @@ def test_public_link_step_uses_configured_editor_and_saves_links(user): page = flow(user, public_links, adapter, query="?homework_step=learning-in-public") assert b'data-learning-public-links data-max-links="3"' in page.content - assert b'Optional. Add up to 3 links to posts about your progress.' in page.content - assert b'community_base/homework_public_links.js' in page.content + assert b"Optional. Add up to 3 links to posts about your progress." in page.content + assert b"community_base/homework_public_links.js" in page.content saved = flow( user, From 1ebbf661fd8a30295f661152320152be00d96623 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Sat, 26 Sep 2026 22:06:06 +0200 Subject: [PATCH 3/6] Keep public links editable without JavaScript --- community_base/homework_steps/README.md | 9 +++++++++ .../static/community_base/homework_public_links.js | 7 ++++++- .../templates/homework_steps/_public_links.html | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) 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 index 61b3fdb7..f83952b8 100644 --- a/community_base/homework_steps/static/community_base/homework_public_links.js +++ b/community_base/homework_steps/static/community_base/homework_public_links.js @@ -1,15 +1,20 @@ 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) { - group.hidden = true; 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 = () => { diff --git a/community_base/homework_steps/templates/homework_steps/_public_links.html b/community_base/homework_steps/templates/homework_steps/_public_links.html index f3131c9e..383fa861 100644 --- a/community_base/homework_steps/templates/homework_steps/_public_links.html +++ b/community_base/homework_steps/templates/homework_steps/_public_links.html @@ -6,7 +6,8 @@ - + +
- + From 8a2a625dd016b7f65cb030e89d072de0bb86a10f Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Sat, 26 Sep 2026 22:07:56 +0200 Subject: [PATCH 4/6] Preserve answer line breaks in review rows --- .../homework_steps/templates/homework_steps/_review_rows.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community_base/homework_steps/templates/homework_steps/_review_rows.html b/community_base/homework_steps/templates/homework_steps/_review_rows.html index b1a9b04b..a90662f1 100644 --- a/community_base/homework_steps/templates/homework_steps/_review_rows.html +++ b/community_base/homework_steps/templates/homework_steps/_review_rows.html @@ -3,7 +3,7 @@
  • {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}{% endif %} - {% if row.answer %}{{ row.answer }}{% else %}Not answered{% endif %} + {% if row.answer %}{{ row.answer|linebreaksbr }}{% else %}Not answered{% endif %}
    {% if editable %}Edit {% if row.has_semantic_label %}{{ row.step_label }}{% else %}question {{ row.question_number }}{% endif %}{% endif %}
  • From ac2682803db5301878a87efdb867cfeec04add0a Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Sat, 26 Sep 2026 22:25:56 +0200 Subject: [PATCH 5/6] Collapse long review answers with full-text disclosure --- .../homework_steps/_review_rows.html | 9 ++++++- tests/homework_steps/test_flow.py | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/community_base/homework_steps/templates/homework_steps/_review_rows.html b/community_base/homework_steps/templates/homework_steps/_review_rows.html index a90662f1..88ddc061 100644 --- a/community_base/homework_steps/templates/homework_steps/_review_rows.html +++ b/community_base/homework_steps/templates/homework_steps/_review_rows.html @@ -3,7 +3,14 @@
  • {% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}{% endif %} - {% if row.answer %}{{ row.answer|linebreaksbr }}{% else %}Not answered{% endif %} + {% if row.answer|length > 240 %} +
    + {{ row.answer|truncatechars:160 }} +
    {{ row.answer|linebreaksbr }}
    +
    + {% else %} + {% if row.answer %}{{ row.answer|linebreaksbr }}{% else %}Not answered{% endif %} + {% endif %}
    {% if editable %}Edit {% if row.has_semantic_label %}{{ row.step_label }}{% else %}question {{ row.question_number }}{% endif %}{% endif %}
  • diff --git a/tests/homework_steps/test_flow.py b/tests/homework_steps/test_flow.py index 9955e723..acba643e 100644 --- a/tests/homework_steps/test_flow.py +++ b/tests/homework_steps/test_flow.py @@ -383,6 +383,30 @@ def test_public_link_step_uses_configured_editor_and_saves_links(user): assert b"Learning in Public" in review.content +def test_long_review_answer_has_expandable_full_text(user, assignment): + adapter = Adapter() + long_answer = "A" * 300 + flow(user, assignment, adapter, query="?homework_step=q2") + saved = flow( + user, + assignment, + adapter, + method="POST", + data={ + "homework_step": "q2", + "revision": "0", + "answer": long_answer, + "next_step": "review", + }, + ) + assert saved.status_code == 302 + + review = flow(user, assignment, adapter, query="?homework_step=review") + assert b"
    " in review.content + assert b"" in review.content + assert review.content.count(long_answer.encode()) == 1 + + def test_draft_status_help_is_available_on_question_and_review_steps(user, assignment): for query in ("?homework_step=q1", "?homework_step=review"): response = flow(user, assignment, Adapter(), query=query) From 378e7b3461bfeac30e5cce49252e4ecc652a3cc6 Mon Sep 17 00:00:00 2001 From: Alexey Grigorev Date: Sat, 26 Sep 2026 22:37:09 +0200 Subject: [PATCH 6/6] Bump community-base to 0.5.12 --- CHANGELOG.md | 6 ++++++ community_base/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 4 ++-- 4 files changed, 10 insertions(+), 4 deletions(-) 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/pyproject.toml b/pyproject.toml index 75d52103..f6f5bc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "community-base" -version = "0.5.11" +version = "0.5.12" description = "Shared Django apps for DataTalks.Club community sites" requires-python = ">=3.13" dependencies = [ diff --git a/uv.lock b/uv.lock index 3bdfb161..53a26bec 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.13" [options] -exclude-newer = "2026-09-19T18:15:20.735046068Z" +exclude-newer = "2026-09-19T20:36:53.115292388Z" exclude-newer-span = "P7D" [[package]] @@ -302,7 +302,7 @@ wheels = [ [[package]] name = "community-base" -version = "0.5.11" +version = "0.5.12" source = { editable = "." } dependencies = [ { name = "apispec" },