Skip to content
Draft
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
59 changes: 37 additions & 22 deletions course/page/inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,33 +632,48 @@ def body(self, page_context: PageContext, page_data: PageData):
return markup_to_html(page_context, self.prompt)

def get_question(self, page_context: PageContext):
# for correct render of question with more than one
# paragraph, replace <p> tags to new input-group.

div_start_css_class_list = [
# Replace <p> tags with appropriate containers depending on content:
# - paragraphs containing both text and blanks get a flex container
# so that the input widget sits inline with the surrounding text;
# - paragraphs containing only a single blank get no wrapper at all,
# letting the form field render as a normal block element; and
# - text-only paragraphs are left unchanged as <p> elements.

flex_class_list = [
"input-group",
# ensure spacing between input and text, mathjax and text
"gap-1",
"align-items-center"
"align-items-center",
]
flex_div_start = f"<div class=\"{' '.join(flex_class_list)}\">"

question_html = markup_to_html(page_context, self.question)

def replace_paragraph(m: re.Match[str]) -> str:
content = m.group(1)
# Blank-only paragraph: strip the <p> wrapper so the form field
# renders as a regular block element (no flex container).
if re.match(r"^\s*\[\[[a-zA-Z_]\w*\]\]\s*$", content):
return content.strip()
# Mixed text-and-blank paragraph: use a flex container so the
# input widget sits inline with the surrounding text.
if WRAPPED_NAME_RE.search(content):
return f"{flex_div_start}{content}</div>"
# Text-only paragraph: keep as-is.
return m.group(0)

result = re.sub(
r"<p>(.*?)</p>", replace_paragraph, question_html, flags=re.DOTALL)

# Add mb-4 to the last flex div so there is spacing after the last
# inline input field (before whatever follows the form).
if flex_div_start in result:
last_flex_div_start = (
f"<div class=\"{' '.join([*flex_class_list, 'mb-4'])}\">")
# https://stackoverflow.com/a/59082116/3437454
result = last_flex_div_start.join(result.rsplit(flex_div_start, 1))

replace_p_start = f"<div class=\"{' '.join(div_start_css_class_list)}\">"

question_html = markup_to_html(
page_context,
self.question
).replace(
"<p>",
replace_p_start
).replace("</p>", "</div>")

# add mb-4 class to the last paragraph so as to add spacing before
# submit buttons.
last_div_start = (
f"<div class=\"{' '.join([*div_start_css_class_list, 'mb-4'])}\">")

# https://stackoverflow.com/a/59082116/3437454
return last_div_start.join(question_html.rsplit(replace_p_start, 1))
return result

def get_form_info(self, page_context: PageContext):
return FormInfo(
Expand Down
86 changes: 86 additions & 0 deletions tests/test_pages/test_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,44 @@
- <plain>bar
"""

INLINE_MULTI_MARKDOWN_BLOCK_BLANKS = """
type: InlineMultiQuestion
id: inlinemulti
value: 10
prompt: |

# An InlineMultiQuestion example

Questions with blanks on their own paragraphs.

question: |

Text before the first blank.

[[blank1]]

Text between the two blanks.

[[blank2]]

answers:

blank1:
type: ShortAnswer
width: 4em
correct_answer:
- <plain> FOO
- <plain>foo

blank2:
type: ShortAnswer
width: 4em
correct_answer:
- <plain> BAR
- <plain>bar

"""

INLINE_MULTI_MARKDOWN_NO_ANSWER_FIELD = """
type: InlineMultiQuestion
id: inlinemulti
Expand Down Expand Up @@ -817,6 +855,54 @@ def test_embedded_question_no_extra_html(self):
# There's no html string between rendered blank1 field and blank2 field
self.assertIn('</div> <div id="div_id_blank2"', resp.content.decode())

def test_block_blank_no_flex_container_on_text_paragraphs(self):
"""Regression test: text-only paragraphs must not be wrapped in a
flex (``input-group``) container when blanks appear on their own
paragraphs (i.e. separated from surrounding text by blank lines)."""
markdown = INLINE_MULTI_MARKDOWN_BLOCK_BLANKS
resp = self.get_page_sandbox_preview_response(markdown)
self.assertEqual(resp.status_code, 200)
self.assertSandboxHasValidPage(resp)

content = resp.content.decode()

# Text-only paragraphs must keep their <p> wrapper and must NOT
# be placed inside an ``input-group`` flex container.
self.assertIn("<p>Text before the first blank.</p>", content)
self.assertIn("<p>Text between the two blanks.</p>", content)

# The text paragraphs must not be inside flex containers.
self.assertNotIn(
'class="input-group gap-1 align-items-center">'
"Text before the first blank.",
content)
self.assertNotIn(
'class="input-group gap-1 align-items-center">'
"Text between the two blanks.",
content)

# The form fields must still be present.
self.assertIn('id="div_id_blank1"', content)
self.assertIn('id="div_id_blank2"', content)

# Submitting correct answers should work normally.
resp = self.get_page_sandbox_submit_answer_response(
markdown,
answer_data={"blank1": "foo", "blank2": "bar"})
self.assertResponseContextAnswerFeedbackCorrectnessEquals(resp, 1)

def test_inline_blank_uses_flex_container(self):
"""Blanks appearing inline with text (on the same paragraph line)
must still be wrapped in a flex container for proper inline layout."""
markdown = INLINE_MULTI_MARKDOWN_SINGLE
resp = self.get_page_sandbox_preview_response(markdown)
self.assertEqual(resp.status_code, 200)
self.assertSandboxHasValidPage(resp)

content = resp.content.decode()
# The paragraph with an inline blank should be wrapped in a flex container.
self.assertIn('class="input-group gap-1 align-items-center', content)

def test_embedded_weight_count(self):
markdown = (INLINE_MULTI_MARKDOWN_EMBEDDED_ATTR_PATTERN
% {"attr1": "weight: 15",
Expand Down
Loading