Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added multiline attribute to string input element #9438

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
099cf70
Added multiline attribute to string input function
eliotwrobson Feb 19, 2024
beb6bd2
Updated example question
eliotwrobson Feb 19, 2024
f17253a
Comments
eliotwrobson Feb 19, 2024
d4be415
Merge branch 'master' into string_textinput
eliotwrobson Feb 21, 2024
6a4aab3
trying something
eliotwrobson Feb 21, 2024
ebc8929
Merge branch 'string_textinput' of https://github.com/eliotwrobson/Pr…
eliotwrobson Feb 21, 2024
6136e8e
Revised example question
eliotwrobson Feb 21, 2024
0eef7c6
Updated newline example
eliotwrobson Feb 22, 2024
bea95e4
Updated panel display
eliotwrobson Feb 22, 2024
6def210
Merge branch 'master' into string_textinput
eliotwrobson Feb 22, 2024
6d09f46
Merge branch 'master' into string_textinput
eliotwrobson Apr 2, 2024
6fbfa86
Initial changes
eliotwrobson Apr 2, 2024
30b25bd
default changes
eliotwrobson Apr 2, 2024
561a2cd
Update docs/elements.md
eliotwrobson Apr 2, 2024
5727541
Python changes
eliotwrobson Apr 2, 2024
19e04f6
Question changes
eliotwrobson Apr 2, 2024
c72d8a0
stuff
eliotwrobson Apr 2, 2024
cb9f1cd
format
eliotwrobson Apr 2, 2024
1ac994a
Merge branch 'master' into string_textinput
eliotwrobson Apr 3, 2024
d1d1992
Added escape unicode option
eliotwrobson Apr 6, 2024
ab75b7b
Merge branch 'master' into string_textinput
eliotwrobson Apr 6, 2024
a28fd22
types
eliotwrobson Apr 6, 2024
7318784
Added multiline transform
eliotwrobson Apr 9, 2024
1e734b6
Initial change, still seeing some things not printing but a good star…
eliotwrobson Apr 12, 2024
15490d1
Logic + display for new behavior
eliotwrobson Apr 13, 2024
8a9c2ee
format
eliotwrobson Apr 13, 2024
c5e2d64
Update docs/elements.md
eliotwrobson Apr 13, 2024
0402f66
removed attribute
eliotwrobson Apr 13, 2024
6e4e57b
Merge branch 'string_textinput' of https://github.com/eliotwrobson/Pr…
eliotwrobson Apr 13, 2024
9032c6d
Updated docs about newline behavior
eliotwrobson Apr 13, 2024
55a1a49
Updated example question and other behavior
eliotwrobson Apr 13, 2024
6daf5f1
Format
eliotwrobson Apr 13, 2024
bee5f79
Merge branch 'master' into string_textinput
eliotwrobson Apr 13, 2024
6bb8fe8
Removed line break
eliotwrobson Apr 13, 2024
a60af8e
Merge branch 'string_textinput' of https://github.com/eliotwrobson/Pr…
eliotwrobson Apr 13, 2024
46cdfeb
Merge branch 'master' into string_textinput
eliotwrobson Apr 22, 2024
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
144 changes: 127 additions & 17 deletions apps/prairielearn/elements/pl-string-input/pl-string-input.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,71 @@
container: 'body',
template: '<div class="popover pl-string-input-popover" role="tooltip"><div class="arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>',
});
document.querySelectorAll('.multiline-input').forEach((input) => {
input.addEventListener('input', function () {
// Adjusts the height based on the feedback content. If the feedback changes, the height
// changes as well. This is done by resetting the height (so the scrollHeight is computed
// based on the minimum height) and then using the scrollHeight plus padding as the new height.
this.style.height = '';
if (this.scrollHeight) {
const style = window.getComputedStyle(this);
this.style.height =
this.scrollHeight + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) + 'px';
}
});
input.dispatchEvent(new Event('input'));
});
});
</script>

{{#inline}}<span class="form-inline d-inline-block ml-2">{{/inline}}
<span id="pl-string-input-{{uuid}}" class="input-group pl-string-input">
{{#multiline}}
{{#label}}
<span class="input-group-prepend">
<span class="input-group-text" id="pl-string-input-{{uuid}}-label">{{{label}}}</span>
</span>
<label for="pl-string-input-input-{{uuid}}" class="form-label">{{{label}}}</label>
{{/label}}
{{/multiline}}
<span id="pl-string-input-{{uuid}}" class="input-group pl-string-input">
{{^multiline}}
{{#label}}
<span class="input-group-prepend">
<span class="input-group-text" id="pl-string-input-{{uuid}}-label">{{{label}}}</span>
</span>
{{/label}}
<input
{{/multiline}}
{{#multiline}}
<textarea
wrap="soft"
{{/multiline}}
id="pl-string-input-input-{{uuid}}"
name="{{name}}"
type="text"
inputmode="text"
class="form-control pl-string-input-input"
size="{{size}}"
class="form-control pl-string-input-input {{#multiline}}multiline-input{{/multiline}}"
{{^multiline}}
size="{{size}}"
{{#raw_submitted_answer}}value="{{raw_submitted_answer}}"{{/raw_submitted_answer}}
{{/multiline}}
{{#multiline}}
cols="{{size}}"
rows="2"
{{/multiline}}
autocomplete="off"
{{^editable}}disabled{{/editable}}
{{#raw_submitted_answer}}value="{{raw_submitted_answer}}"{{/raw_submitted_answer}}
aria-describedby="pl-symbolic-input-{{uuid}}-label pl-symbolic-input-{{uuid}}-suffix"
aria-describedby="pl-string-input-{{uuid}}-label pl-string-input-{{uuid}}-suffix"
placeholder="{{placeholder}}"
/>
{{^multiline}}
>
{{/multiline}}
{{#multiline}}
>{{raw_submitted_answer}}</textarea>
{{/multiline}}
<span class="input-group-append">
{{#suffix}}<span class="input-group-text" id="pl-symbolic-input-{{uuid}}-suffix">{{suffix}}</span>{{/suffix}}

{{^multiline}}
{{#suffix}}
<span class="input-group-text" id="pl-string-input-{{uuid}}-suffix">{{suffix}}</span>
{{/suffix}}
{{/multiline}}
{{#show_info}}
<a role="button" class="btn btn-light border d-flex align-items-center" data-toggle="popover" data-html="true" title="String" data-content="{{info}}" data-placement="auto" data-trigger="focus" tabindex="0">
<i class="fa fa-question-circle" aria-hidden="true"></i>
Expand Down Expand Up @@ -69,6 +109,11 @@
{{/parse_error}}
</span>
</span>
{{#multiline}}
{{#suffix}}
<label for="pl-string-input-input-{{uuid}}" class="form-label">{{{suffix}}}</label>
{{/suffix}}
{{/multiline}}
{{#inline}}</span>{{/inline}}
{{/question}}

Expand Down Expand Up @@ -112,9 +157,41 @@
</span>
{{/error}}
{{^error}}
{{#label}}<span>{{{label}}}</span>{{/label}}
<code class="user-output">{{a_sub}}</code>
{{#suffix}}<span>{{suffix}}</span>{{/suffix}}
{{^multiline}}
{{#label}}<span>{{{label}}}</span>{{/label}}
<code class="user-output">{{a_sub}}</code>
{{#suffix}}<span>{{suffix}}</span>{{/suffix}}
{{/multiline}}
{{#multiline}}
{{#label}}
<label class="form-label" for="pl-string-input-submission-{{uuid}}">{{{label}}}</label>
{{/label}}
<span class="input-group">
<textarea
wrap="soft"
type="text"
inputmode="text"
class="form-control multiline-input"
style="resize:none;"
disabled
id="pl-string-input-submission-{{uuid}}"
>{{a_sub}}</textarea>
</span>
{{#suffix}}
<label class="form-label" for="pl-string-input-submission-{{uuid}}">{{{suffix}}}</label>
{{/suffix}}
{{/multiline}}

<!-- Show submitted answer submission was parsed from -->
<a href="javascript:void(0);" role="button" class="ml-1 btn btn-sm btn-secondary small border"
data-placement="auto" data-trigger="focus" data-toggle="popover" data-html="true"
title="Raw String" tabindex="0"
data-content="Invisible unicode characters are represented as
<code>&lt; U+xxxx &gt;</code>:<br>
<pre>{{escaped_submitted_answer}}</pre>">
<i class="fa fa-question-circle" aria-hidden="true"></i>
</a>
Copy link
Collaborator Author

@eliotwrobson eliotwrobson Apr 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I think this PR is almost ready to go! The only things left are messing with formatting on a few things, which I would greatly appreciate a small amount of guidance with.

  • Right now, I'm not sure whether the display of the suffix looks weird or not, the spacing is slightly different than the top label, but I'm not sure if this is standard.
  • Is there a way to avoid having to put spaces in the first code tag in this popover? When I remove the spaces, the < and > don't display for some reason:

image

Other than that I think the behavior here is pretty reasonable and overall makes this element more intuitive.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to avoid having to put spaces in the first code tag in this popover? When I remove the spaces, the < and > don't display for some reason:

Are you escaping the lt/gt? Assuming this is inside an argument, you may need to escape twice (&amp;lt; for <).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code for this popover is in the lines just above. I didn't need to escape twice to get it to work, but it stops working once I get rid of the surrounding whitespace.


{{#correct}}<span class="badge badge-success"><i class="fa fa-check" aria-hidden="true"></i> 100%</span>{{/correct}}
{{#partial}}<span class="badge badge-warning"><i class="fa fa-circle-o" aria-hidden="true"></i> {{partial}}%</span>{{/partial}}
{{#incorrect}}<span class="badge badge-danger"><i class="fa fa-times" aria-hidden="true"></i> 0%</span>{{/incorrect}}
Expand All @@ -123,9 +200,42 @@
{{/submission}}

{{#answer}}
{{#label}}<span>{{{label}}}</span>{{/label}}
<code class="user-output">{{a_tru}}</code>
{{#suffix}}<span>{{suffix}}</span>{{/suffix}}
{{^multiline}}
{{#label}}<span>{{{label}}}</span>{{/label}}
<code class="user-output">{{a_tru}}</code>
{{#suffix}}<span>{{suffix}}</span>{{/suffix}}
{{/multiline}}
{{#multiline}}
{{#label}}
<label class="form-label" for="pl-string-input-answer-{{uuid}}">{{{label}}}</label>
{{/label}}
<span class="input-group">
<textarea
wrap="soft"
type="text"
inputmode="text"
class="form-control multiline-input"
style="resize:none;"
disabled
id="pl-string-input-answer-{{uuid}}"
>{{a_tru}}</textarea>
</span>
{{#suffix}}
<label class="form-label" for="pl-string-input-answer-{{uuid}}">{{{suffix}}}</label>
{{/suffix}}
{{/multiline}}

<!-- TODO figure out why it isn't printing what I expect -->
<!-- Show correct answer submission was parsed from -->
<!-- Show submitted answer submission was parsed from -->
<a href="javascript:void(0);" role="button" class="ml-1 btn btn-sm btn-secondary small border"
data-placement="auto" data-trigger="focus" data-toggle="popover" data-html="true"
title="Raw String" tabindex="0"
data-content="Invisible unicode characters are represented as
<code>&lt; U+xxxx &gt;</code>:<br>
<pre>{{escaped_correct_answer}}</pre><br>">
<i class="fa fa-question-circle" aria-hidden="true"></i>
</a>
{{/answer}}

{{#format}}
Expand Down
79 changes: 52 additions & 27 deletions apps/prairielearn/elements/pl-string-input/pl-string-input.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import html
import random
import re
from enum import Enum
from typing import Any

Expand All @@ -13,6 +15,17 @@ class DisplayType(Enum):
BLOCK = "block"


SPACE_HINT_DICT: dict[tuple[bool, bool], str] = {
(True, True): "All spaces will be removed from your answer.",
(True, False): "Leading and trailing spaces will be removed from your answer.",
(
False,
True,
): "All spaces between text will be removed but leading and trailing spaces will be left as part of your answer.",
(False, False): "Leading and trailing spaces will be left as part of your answer.",
}


WEIGHT_DEFAULT = 1
CORRECT_ANSWER_DEFAULT = None
LABEL_DEFAULT = None
Expand All @@ -27,6 +40,7 @@ class DisplayType(Enum):
SHOW_HELP_TEXT_DEFAULT = True
SHOW_SCORE_DEFAULT = True
NORMALIZE_TO_ASCII_DEFAULT = False
MULTILINE_DEFAULT = False

STRING_INPUT_MUSTACHE_TEMPLATE_NAME = "pl-string-input.mustache"

Expand All @@ -49,6 +63,8 @@ def prepare(element_html: str, data: pl.QuestionData) -> None:
"show-help-text",
"normalize-to-ascii",
"show-score",
"multiline",
"escape-unicode",
]
pl.check_attribs(element, required_attribs, optional_attribs)

Expand All @@ -70,46 +86,41 @@ def render(element_html: str, data: pl.QuestionData) -> str:
name = pl.get_string_attrib(element, "answers-name")
label = pl.get_string_attrib(element, "label", LABEL_DEFAULT)
suffix = pl.get_string_attrib(element, "suffix", SUFFIX_DEFAULT)
display = pl.get_enum_attrib(element, "display", DisplayType, DISPLAY_DEFAULT)
remove_leading_trailing = pl.get_boolean_attrib(
element, "remove-leading-trailing", REMOVE_LEADING_TRAILING_DEFAULT
)

remove_spaces = pl.get_boolean_attrib(
element, "remove-spaces", REMOVE_SPACES_DEFAULT
)
placeholder = pl.get_string_attrib(element, "placeholder", PLACEHOLDER_DEFAULT)
show_score = pl.get_boolean_attrib(element, "show-score", SHOW_SCORE_DEFAULT)

raw_submitted_answer = data["raw_submitted_answers"].get(name)

multiline = pl.get_boolean_attrib(element, "multiline", MULTILINE_DEFAULT)
score = data["partial_scores"].get(name, {"score": None}).get("score", None)
parse_error = data["format_errors"].get(name)

# Defaults here depend on multiline
display = pl.get_enum_attrib(
element,
"display",
DisplayType,
DisplayType.BLOCK if multiline else DISPLAY_DEFAULT,
)
remove_leading_trailing = pl.get_boolean_attrib(
element, "remove-leading-trailing", multiline or REMOVE_LEADING_TRAILING_DEFAULT
)

# Get template
with open(STRING_INPUT_MUSTACHE_TEMPLATE_NAME, "r", encoding="utf-8") as f:
template = f.read()

if data["panel"] == "question":
editable = data["editable"]

space_hint_pair = (remove_leading_trailing, remove_spaces)
match space_hint_pair:
case (True, True):
space_hint = "All spaces will be removed from your answer."
case (True, False):
space_hint = (
"Leading and trailing spaces will be removed from your answer."
)
case (False, True):
space_hint = "All spaces between text will be removed but leading and trailing spaces will be left as part of your answer."
case (False, False):
space_hint = (
"Leading and trailing spaces will be left as part of your answer."
)
case _:
raise Exception("Should never reach here.")

info_params = {"format": True, "space_hint": space_hint}
space_hint = SPACE_HINT_DICT[(remove_leading_trailing, remove_spaces)]
info_params = {
"format": True,
"space_hint": space_hint,
}
info = chevron.render(template, info_params).strip()

show_help_text = pl.get_boolean_attrib(
Expand All @@ -130,6 +141,7 @@ def render(element_html: str, data: pl.QuestionData) -> str:
display.value: True,
"raw_submitted_answer": raw_submitted_answer,
"parse_error": parse_error,
"multiline": multiline,
}

if show_score and score is not None:
Expand All @@ -145,19 +157,23 @@ def render(element_html: str, data: pl.QuestionData) -> str:
"suffix": suffix,
"parse_error": parse_error,
"uuid": pl.get_uuid(),
"multiline": multiline,
}

if parse_error is None and name in data["submitted_answers"]:
# Get submitted answer, raising an exception if it does not exist
a_sub = data["submitted_answers"].get(name, None)

if a_sub is None:
raise Exception("submitted answer is None")

# If answer is in a format generated by pl.to_json, convert it
# back to a standard type (otherwise, do nothing)
a_sub = pl.from_json(a_sub)
a_sub = pl.escape_unicode_string(a_sub)

html_params["escaped_submitted_answer"] = html.escape(
pl.escape_unicode_string(a_sub)
)
html_params["a_sub"] = a_sub
elif name not in data["submitted_answers"]:
html_params["missing_input"] = True
Expand Down Expand Up @@ -185,6 +201,9 @@ def render(element_html: str, data: pl.QuestionData) -> str:
"label": label,
"a_tru": a_tru,
"suffix": suffix,
"multiline": multiline,
"uuid": pl.get_uuid(),
"escaped_correct_answer": html.escape(pl.escape_unicode_string(a_tru)),
}

return chevron.render(template, html_params).strip()
Expand All @@ -204,8 +223,9 @@ def parse(element_html: str, data: pl.QuestionData) -> None:
element, "remove-spaces", REMOVE_SPACES_DEFAULT
)

multiline = pl.get_boolean_attrib(element, "multiline", MULTILINE_DEFAULT)
remove_leading_trailing = pl.get_boolean_attrib(
element, "remove-leading-trailing", REMOVE_LEADING_TRAILING_DEFAULT
element, "remove-leading-trailing", multiline or REMOVE_LEADING_TRAILING_DEFAULT
)

# Get submitted answer or return parse_error if it does not exist
Expand All @@ -227,6 +247,9 @@ def parse(element_html: str, data: pl.QuestionData) -> None:
if remove_spaces:
a_sub = "".join(a_sub.split())

# Always simplify multiline characters (if they still exist)
a_sub = re.sub("\r*\n", "\n", a_sub)
eliotwrobson marked this conversation as resolved.
Show resolved Hide resolved

if not a_sub and not allow_blank:
data["format_errors"][
name
Expand All @@ -248,9 +271,10 @@ def grade(element_html: str, data: pl.QuestionData) -> None:
element, "remove-spaces", REMOVE_SPACES_DEFAULT
)

multiline = pl.get_boolean_attrib(element, "multiline", MULTILINE_DEFAULT)
# Get remove-leading-trailing option
remove_leading_trailing = pl.get_boolean_attrib(
element, "remove-leading-trailing", REMOVE_LEADING_TRAILING_DEFAULT
element, "remove-leading-trailing", multiline or REMOVE_LEADING_TRAILING_DEFAULT
)

# Get string case sensitivity option
Expand All @@ -263,7 +287,8 @@ def grade(element_html: str, data: pl.QuestionData) -> None:
return

# explicitly cast the true answer to a string, to handle the case where the answer might be a number or some other type
a_tru = str(a_tru)
# Always simplify multiline characters (if they still exist)
a_tru = re.sub("\r*\n", "\n", str(a_tru))

def grade_function(a_sub: Any) -> tuple[bool, None]:
# If submitted answer is in a format generated by pl.to_json, convert it
Expand Down
11 changes: 6 additions & 5 deletions apps/prairielearn/python/prairielearn.py
Original file line number Diff line number Diff line change
Expand Up @@ -1660,12 +1660,13 @@ def escape_unicode_string(string: str) -> str:
https://en.wikipedia.org/wiki/Unicode_character_property#General_Category
"""

def escape_unprintable(x):
def escape_unprintable(x: str) -> str:
category = unicodedata.category(x)
if category == "Cc" or category == "Cf":
return f"<U+{ord(x):x}>"
else:
return x

if category in {"Cc", "Cf"}:
return f"<U+{ord(x):04X}>"

return x

return "".join(map(escape_unprintable, string))

Expand Down
Loading
Loading