Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion community_base/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.5.11"
__version__ = "0.5.12"
9 changes: 9 additions & 0 deletions community_base/homework_steps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 : [""]);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class="homework-public-links" data-learning-public-links data-max-links="{{ stepper.assignment.context.learning_in_public_cap }}">
<div class="homework-public-links-heading">
<div>
<p class="homework-public-links-title">Your public posts</p>
<p data-testid="learning-public-link-guidance">Optional. Add up to {{ stepper.assignment.context.learning_in_public_cap }} links to posts about your progress.</p>
</div>
<output data-public-link-count aria-live="polite"></output>
</div>
<label for="homework-public-links-fallback" data-public-links-fallback-label>Public links, one per line</label>
<textarea id="homework-public-links-fallback" name="answer" data-public-links-value rows="3">{{ stepper.answer }}</textarea>
<div data-public-link-slots data-testid="learning-public-link-slots"></div>
<button type="button" data-add-public-link hidden><span aria-hidden="true">+</span> <span data-add-public-link-label>Add another link</span></button>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<ol class="homework-review-rows"{% if test_id %} data-testid="{{ test_id }}"{% endif %}>
{% for row in rows %}
<li class="homework-review-row">
<div class="homework-review-answer">
<strong>{% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}{% endif %}</strong>
{% if row.answer|length > 240 %}
<details>
<summary>{{ row.answer|truncatechars:160 }}</summary>
<div>{{ row.answer|linebreaksbr }}</div>
</details>
{% else %}
<span>{% if row.answer %}{{ row.answer|linebreaksbr }}{% else %}Not answered{% endif %}</span>
{% endif %}
</div>
{% if editable %}<a href="{{ row.url }}">Edit <span class="sr-only">{% if row.has_semantic_label %}{{ row.step_label }}{% else %}question {{ row.question_number }}{% endif %}</span></a>{% endif %}
</li>
{% endfor %}
</ol>
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ <h2>{{ stepper.question_label }}</h2>
{% for option, selected in stepper.options %}
<label><input type="checkbox" name="answer" value="{{ option.key }}"{% if selected %} checked{% endif %}> {{ option.label }}</label>
{% 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' %}
<label for="homework-answer">Your answer</label>
<textarea id="homework-answer" name="answer" maxlength="10000" rows="8">{{ stepper.answer }}</textarea>
Expand All @@ -65,27 +67,15 @@ <h2>Review &amp; submit</h2>
<section aria-labelledby="accepted-submission-title">
<h3 id="accepted-submission-title">Accepted submission</h3>
{% if stepper.accepted_submitted_at %}<p>Submitted {{ stepper.accepted_submitted_at }}</p>{% endif %}
<ol>
{% for row in stepper.review_display_rows %}
<li><a href="{{ row.url }}">{% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}: {{ row.prompt }}{% endif %}</a>
{% if row.answer %} — {{ row.answer }}{% else %} — No answer saved{% endif %}
</li>
{% endfor %}
</ol>
{% include "homework_steps/_review_rows.html" with rows=stepper.review_display_rows editable=False %}
{% for field, value in stepper.accepted_field_rows %}
<p>{{ field.label }} — {{ value|default:"No answer saved" }}</p>
{% endfor %}
</section>
{% if stepper.pending_draft_display_rows or stepper.pending_draft_field_rows %}
<section aria-labelledby="unsent-draft-title">
<h3 id="unsent-draft-title">Unsubmitted draft</h3>
<ol>
{% for row in stepper.pending_draft_display_rows %}
<li>{% 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 %}
</li>
{% endfor %}
</ol>
{% include "homework_steps/_review_rows.html" with rows=stepper.pending_draft_display_rows editable=False %}
{% for field, value in stepper.pending_draft_field_rows %}
<p>{{ field.label }} — {{ value|default:"No answer saved" }}</p>
{% endfor %}
Expand All @@ -94,13 +84,7 @@ <h3 id="unsent-draft-title">Unsubmitted draft</h3>
{% elif stepper.homework_state.has_saved_draft %}
<section aria-labelledby="saved-draft-title">
<h3 id="saved-draft-title">Saved draft — not submitted</h3>
<ol>
{% for row in stepper.unsent_draft_display_rows %}
<li>{% 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 %}
</li>
{% endfor %}
</ol>
{% include "homework_steps/_review_rows.html" with rows=stepper.unsent_draft_display_rows editable=False %}
{% for field, value in stepper.unsent_draft_field_rows %}
<p>{{ field.label }} — {{ value }}</p>
{% endfor %}
Expand All @@ -109,13 +93,7 @@ <h3 id="saved-draft-title">Saved draft — not submitted</h3>
<p>No submission was accepted.</p>
{% endif %}
{% else %}
<ol>
{% for row in stepper.review_display_rows %}
<li><a href="{{ row.url }}">{% if row.has_semantic_label %}{{ row.step_label }}{% else %}Question {{ row.question_number }}: {{ row.prompt }}{% endif %}</a>
{% if row.answer %} — {{ row.answer }}{% else %} — No answer saved{% endif %}
</li>
{% endfor %}
</ol>
{% include "homework_steps/_review_rows.html" with rows=stepper.review_display_rows editable=True %}
<form method="post" action="{{ stepper.action }}" data-homework-autosave data-save-url="{{ stepper.save_url }}">
{% csrf_token %}
<input type="hidden" name="assignment_key" value="{{ stepper.assignment.key }}">
Expand Down Expand Up @@ -143,3 +121,4 @@ <h3 id="saved-draft-title">Saved draft — not submitted</h3>
</section>
{% load static %}
<script defer src="{% static 'community_base/homework_steps.js' %}"></script>
<script defer src="{% static 'community_base/homework_public_links.js' %}"></script>
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
67 changes: 67 additions & 0 deletions tests/homework_steps/test_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,73 @@ 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</strong>" 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"<details>" in review.content
assert b"<summary>" 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)
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading