Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
X
xblock-drag-and-drop-v2
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
OpenEdx
xblock-drag-and-drop-v2
Commits
13ecee01
Commit
13ecee01
authored
Dec 14, 2016
by
Matjaz Gregoric
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
[TNL-6018] Use SR.readTexts to read feedback.
parent
c773d9fa
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
194 additions
and
65 deletions
+194
-65
drag_and_drop_v2/public/js/drag_and_drop.js
+35
-1
tests/integration/test_base.py
+48
-15
tests/integration/test_events.py
+1
-1
tests/integration/test_interaction.py
+90
-34
tests/integration/test_interaction_assessment.py
+20
-14
No files found.
drag_and_drop_v2/public/js/drag_and_drop.js
View file @
13ecee01
...
...
@@ -868,6 +868,7 @@ function DragAndDropBlock(runtime, element, configuration) {
var
applyState
=
function
(
keepDraggableInit
)
{
sendFeedbackPopupEvents
();
updateDOM
();
readScreenReaderMessages
();
if
(
!
keepDraggableInit
)
{
destroyDraggable
();
if
(
!
state
.
finished
)
{
...
...
@@ -904,7 +905,7 @@ function DragAndDropBlock(runtime, element, configuration) {
return
feedback_msgs_list
.
map
(
function
(
message
)
{
return
message
.
message
;
}).
join
(
'
\
n'
);
};
var
updateDOM
=
function
(
state
)
{
var
updateDOM
=
function
()
{
var
new_vdom
=
render
(
state
);
var
patches
=
virtualDom
.
diff
(
__vdom
,
new_vdom
);
root
=
virtualDom
.
patch
(
root
,
patches
);
...
...
@@ -912,6 +913,39 @@ function DragAndDropBlock(runtime, element, configuration) {
__vdom
=
new_vdom
;
};
// Uses edX JS accessibility tools to read feedback messages when present.
var
readScreenReaderMessages
=
function
()
{
if
(
window
.
SR
&&
window
.
SR
.
readTexts
)
{
var
pluckMessages
=
function
(
feedback_items
)
{
return
feedback_items
.
map
(
function
(
item
)
{
return
item
.
message
;
});
};
var
messages
=
[];
// In standard mode, it makes more sense to read the per-item feedback before overall feedback.
if
(
state
.
feedback
&&
configuration
.
mode
===
DragAndDropBlock
.
STANDARD_MODE
)
{
messages
=
messages
.
concat
(
pluckMessages
(
state
.
feedback
));
}
if
(
state
.
overall_feedback
)
{
messages
=
messages
.
concat
(
pluckMessages
(
state
.
overall_feedback
));
}
// In assessment mode overall feedback comes first then multiple per-item feedbacks.
if
(
state
.
feedback
&&
configuration
.
mode
===
DragAndDropBlock
.
ASSESSMENT_MODE
)
{
if
(
state
.
feedback
.
length
>
0
)
{
if
(
!
state
.
last_action_correct
)
{
messages
.
push
(
gettext
(
"Some of your answers were not correct."
))
}
messages
=
messages
.
concat
(
gettext
(
"Hints:"
),
pluckMessages
(
state
.
feedback
)
);
}
}
SR
.
readTexts
(
messages
);
}
};
var
publishEvent
=
function
(
data
)
{
$
.
ajax
({
type
:
'POST'
,
...
...
tests/integration/test_base.py
View file @
13ecee01
...
...
@@ -163,6 +163,27 @@ class BaseIntegrationTest(SeleniumBaseTest):
focused_element
=
self
.
browser
.
switch_to
.
active_element
self
.
assertTrue
(
element
!=
focused_element
,
'expected element to not have focus'
)
def
_patch_sr_read_texts
(
self
):
"""
Creates a mock SR.readTexts function that stores submitted texts into a global variable
for later inspection.
Returns a getter function that returns stored SR texts.
"""
self
.
browser
.
execute_script
(
"""
window.SR = {
received_texts: [],
readTexts: function(texts) {
window.SR.received_texts.push(texts);
}
};
"""
)
def
get_sr_texts
():
return
self
.
browser
.
execute_script
(
'return window.SR.received_texts'
)
return
get_sr_texts
@staticmethod
def
get_element_html
(
element
):
return
element
.
get_attribute
(
'innerHTML'
)
.
strip
()
...
...
@@ -239,22 +260,32 @@ class DefaultDataTestMixin(object):
class
InteractionTestBase
(
object
):
POPUP_ERROR_CLASS
=
"popup-incorrect"
@classmethod
def
_get_items_with_zone
(
cls
,
items_map
):
def
setUp
(
self
):
super
(
InteractionTestBase
,
self
)
.
setUp
()
scenario_xml
=
self
.
_get_scenario_xml
()
self
.
_add_scenario
(
self
.
PAGE_ID
,
self
.
PAGE_TITLE
,
scenario_xml
)
self
.
_page
=
self
.
go_to_page
(
self
.
PAGE_TITLE
)
# Resize window so that the entire drag container is visible.
# Selenium has issues when dragging to an area that is off screen.
self
.
browser
.
set_window_size
(
1024
,
1024
)
@staticmethod
def
_get_items_with_zone
(
items_map
):
return
{
item_key
:
definition
for
item_key
,
definition
in
items_map
.
items
()
if
definition
.
zone_ids
!=
[]
}
@
class
method
def
_get_items_without_zone
(
cls
,
items_map
):
@
static
method
def
_get_items_without_zone
(
items_map
):
return
{
item_key
:
definition
for
item_key
,
definition
in
items_map
.
items
()
if
definition
.
zone_ids
==
[]
}
@
class
method
def
_get_items_by_zone
(
cls
,
items_map
):
@
static
method
def
_get_items_by_zone
(
items_map
):
zone_ids
=
set
([
definition
.
zone_ids
[
0
]
for
_
,
definition
in
items_map
.
items
()
if
definition
.
zone_ids
])
return
{
zone_id
:
{
item_key
:
definition
for
item_key
,
definition
in
items_map
.
items
()
...
...
@@ -262,15 +293,17 @@ class InteractionTestBase(object):
for
zone_id
in
zone_ids
}
def
setUp
(
self
):
super
(
InteractionTestBase
,
self
)
.
setUp
()
scenario_xml
=
self
.
_get_scenario_xml
()
self
.
_add_scenario
(
self
.
PAGE_ID
,
self
.
PAGE_TITLE
,
scenario_xml
)
self
.
_page
=
self
.
go_to_page
(
self
.
PAGE_TITLE
)
# Resize window so that the entire drag container is visible.
# Selenium has issues when dragging to an area that is off screen.
self
.
browser
.
set_window_size
(
1024
,
800
)
@staticmethod
def
_get_incorrect_zone_for_item
(
item
,
zones
):
"""Returns the first zone that is not correct for this item."""
zone_id
=
None
zone_title
=
None
for
z_id
,
z_title
in
zones
:
if
z_id
not
in
item
.
zone_ids
:
zone_id
=
z_id
zone_title
=
z_title
break
return
[
zone_id
,
zone_title
]
def
_get_item_by_value
(
self
,
item_value
):
return
self
.
_page
.
find_elements_by_xpath
(
".//div[@data-value='{item_id}']"
.
format
(
item_id
=
item_value
))[
0
]
...
...
tests/integration/test_events.py
View file @
13ecee01
...
...
@@ -76,7 +76,7 @@ class EventsFiredTest(DefaultDataTestMixin, ParameterizedTestsMixin, BaseEventsT
@data
(
*
enumerate
(
scenarios
))
# pylint: disable=star-args
@unpack
def
test_event
(
self
,
index
,
event
):
self
.
parameterized_item_positive_feedback_on_good_move
(
self
.
items_map
)
self
.
parameterized_item_positive_feedback_on_good_move
_standard
(
self
.
items_map
)
dummy
,
name
,
published_data
=
self
.
publish
.
call_args_list
[
index
][
0
]
self
.
assertEqual
(
name
,
event
[
'name'
])
self
.
assertEqual
(
published_data
,
event
[
'data'
])
...
...
tests/integration/test_interaction.py
View file @
13ecee01
...
...
@@ -50,35 +50,95 @@ class ParameterizedTestsMixin(object):
ActionChains
(
self
.
browser
)
.
send_keys
(
Keys
.
TAB
)
.
perform
()
self
.
assertFocused
(
go_to_beginning_button
)
def
parameterized_item_positive_feedback_on_good_move
(
self
,
items_map
,
scroll_down
=
100
,
action_key
=
None
,
assessment_mode
=
Fals
e
def
parameterized_item_positive_feedback_on_good_move
_standard
(
self
,
items_map
,
scroll_down
=
100
,
action_key
=
None
,
feedback
=
Non
e
):
if
feedback
is
None
:
feedback
=
self
.
feedback
get_sr_texts
=
self
.
_patch_sr_read_texts
()
popup
=
self
.
_get_popup
()
feedback_popup_content
=
self
.
_get_popup_content
()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self
.
scroll_down
(
pixels
=
scroll_down
)
for
definition
in
self
.
_get_items_with_zone
(
items_map
)
.
values
():
items_with_zones
=
self
.
_get_items_with_zone
(
items_map
)
.
values
()
for
i
,
definition
in
enumerate
(
items_with_zones
):
self
.
place_item
(
definition
.
item_id
,
definition
.
zone_ids
[
0
],
action_key
)
self
.
wait_until_ondrop_xhr_finished
(
self
.
_get_item_by_value
(
definition
.
item_id
))
self
.
assert_placed_item
(
definition
.
item_id
,
definition
.
zone_title
,
assessment_mode
=
assessment_mod
e
)
self
.
assert_placed_item
(
definition
.
item_id
,
definition
.
zone_title
,
assessment_mode
=
Fals
e
)
feedback_popup_html
=
feedback_popup_content
.
get_attribute
(
'innerHTML'
)
if
assessment_mode
:
self
.
assertEqual
(
feedback_popup_html
,
''
)
self
.
assertFalse
(
popup
.
is_displayed
())
self
.
assertEqual
(
feedback_popup_html
,
"<p>{}</p>"
.
format
(
definition
.
feedback_positive
))
self
.
assert_popup_correct
(
popup
)
self
.
assertTrue
(
popup
.
is_displayed
())
expected_sr_texts
=
[
definition
.
feedback_positive
]
if
i
==
len
(
items_with_zones
)
-
1
:
# We just dropped the last item, so the problem is done and we should see the final feedback.
overall_feedback
=
feedback
[
'final'
]
else
:
overall_feedback
=
feedback
[
'intro'
]
expected_sr_texts
.
append
(
overall_feedback
)
self
.
assertEqual
(
get_sr_texts
()[
-
1
],
expected_sr_texts
)
if
action_key
:
# Next TAB keypress should move focus to "Go to Beginning button"
self
.
_test_next_tab_goes_to_go_to_beginning_button
()
else
:
self
.
assertEqual
(
feedback_popup_html
,
"<p>{}</p>"
.
format
(
definition
.
feedback_positive
))
self
.
assert_popup_correct
(
popup
)
def
parameterized_item_positive_feedback_on_good_move_assessment
(
self
,
items_map
,
scroll_down
=
100
,
action_key
=
None
,
feedback
=
None
):
if
feedback
is
None
:
feedback
=
self
.
feedback
get_sr_texts
=
self
.
_patch_sr_read_texts
()
popup
=
self
.
_get_popup
()
feedback_popup_content
=
self
.
_get_popup_content
()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self
.
scroll_down
(
pixels
=
scroll_down
)
items_with_zones
=
self
.
_get_items_with_zone
(
items_map
)
.
values
()
for
definition
in
items_with_zones
:
self
.
place_item
(
definition
.
item_id
,
definition
.
zone_ids
[
0
],
action_key
)
self
.
wait_until_ondrop_xhr_finished
(
self
.
_get_item_by_value
(
definition
.
item_id
))
self
.
assert_placed_item
(
definition
.
item_id
,
definition
.
zone_title
,
assessment_mode
=
True
)
feedback_popup_html
=
feedback_popup_content
.
get_attribute
(
'innerHTML'
)
self
.
assertEqual
(
feedback_popup_html
,
''
)
self
.
assertFalse
(
popup
.
is_displayed
())
self
.
assertEqual
(
get_sr_texts
()[
-
1
],
[
feedback
[
'intro'
]])
def
parameterized_item_negative_feedback_on_bad_move_standard
(
self
,
items_map
,
all_zones
,
scroll_down
=
100
,
action_key
=
None
,
feedback
=
None
):
if
feedback
is
None
:
feedback
=
self
.
feedback
get_sr_texts
=
self
.
_patch_sr_read_texts
()
popup
=
self
.
_get_popup
()
feedback_popup_content
=
self
.
_get_popup_content
()
# Scroll drop zones into view to make sure Selenium can successfully drop items
self
.
scroll_down
(
pixels
=
scroll_down
)
for
definition
in
items_map
.
values
():
zone_id
,
_
=
self
.
_get_incorrect_zone_for_item
(
definition
,
all_zones
)
if
zone_id
is
not
None
:
# Some items may be placed in any zone, ignore those.
self
.
place_item
(
definition
.
item_id
,
zone_id
,
action_key
)
self
.
wait_until_html_in
(
definition
.
feedback_negative
,
feedback_popup_content
)
self
.
assert_popup_incorrect
(
popup
)
self
.
assertTrue
(
popup
.
is_displayed
())
self
.
assert_reverted_item
(
definition
.
item_id
)
expected_sr_texts
=
[
definition
.
feedback_negative
,
feedback
[
'intro'
]]
self
.
assertEqual
(
get_sr_texts
()[
-
1
],
expected_sr_texts
)
self
.
_test_popup_focus_and_close
(
popup
,
action_key
)
def
parameterized_item_negative_feedback_on_bad_move
(
self
,
items_map
,
all_zones
,
scroll_down
=
100
,
action_key
=
None
,
assessment_mode
=
Fals
e
def
parameterized_item_negative_feedback_on_bad_move
_assessment
(
self
,
items_map
,
all_zones
,
scroll_down
=
100
,
action_key
=
None
,
feedback
=
Non
e
):
if
feedback
is
None
:
feedback
=
self
.
feedback
get_sr_texts
=
self
.
_patch_sr_read_texts
()
popup
=
self
.
_get_popup
()
feedback_popup_content
=
self
.
_get_popup_content
()
...
...
@@ -86,30 +146,18 @@ class ParameterizedTestsMixin(object):
self
.
scroll_down
(
pixels
=
scroll_down
)
for
definition
in
items_map
.
values
():
# Get first zone that is not correct for this item.
zone_id
=
None
zone_title
=
None
for
z_id
,
z_title
in
all_zones
:
if
z_id
not
in
definition
.
zone_ids
:
zone_id
=
z_id
zone_title
=
z_title
break
zone_id
,
zone_title
=
self
.
_get_incorrect_zone_for_item
(
definition
,
all_zones
)
if
zone_id
is
not
None
:
# Some items may be placed in any zone, ignore those.
self
.
place_item
(
definition
.
item_id
,
zone_id
,
action_key
)
if
assessment_mode
:
self
.
wait_until_ondrop_xhr_finished
(
self
.
_get_item_by_value
(
definition
.
item_id
))
feedback_popup_html
=
feedback_popup_content
.
get_attribute
(
'innerHTML'
)
self
.
assertEqual
(
feedback_popup_html
,
''
)
self
.
assertFalse
(
popup
.
is_displayed
())
self
.
assert_placed_item
(
definition
.
item_id
,
zone_title
,
assessment_mode
=
True
)
self
.
assertEqual
(
get_sr_texts
()[
-
1
],
[
feedback
[
'intro'
]])
self
.
_test_popup_focus_and_close
(
popup
,
action_key
)
if
action_key
:
self
.
_test_next_tab_goes_to_go_to_beginning_button
()
else
:
self
.
wait_until_html_in
(
definition
.
feedback_negative
,
feedback_popup_content
)
self
.
assert_popup_incorrect
(
popup
)
self
.
assertTrue
(
popup
.
is_displayed
())
self
.
assert_reverted_item
(
definition
.
item_id
)
self
.
_test_popup_focus_and_close
(
popup
,
action_key
)
def
parameterized_move_items_between_zones
(
self
,
items_map
,
all_zones
,
scroll_down
=
100
,
action_key
=
None
):
# Scroll drop zones into view to make sure Selenium can successfully drop items
...
...
@@ -247,11 +295,13 @@ class StandardInteractionTest(DefaultDataTestMixin, InteractionTestBase, Paramet
"""
@data
(
*
ITEM_DRAG_KEYBOARD_KEYS
)
def
test_item_positive_feedback_on_good_move
(
self
,
action_key
):
self
.
parameterized_item_positive_feedback_on_good_move
(
self
.
items_map
,
action_key
=
action_key
)
self
.
parameterized_item_positive_feedback_on_good_move
_standard
(
self
.
items_map
,
action_key
=
action_key
)
@data
(
*
ITEM_DRAG_KEYBOARD_KEYS
)
def
test_item_negative_feedback_on_bad_move
(
self
,
action_key
):
self
.
parameterized_item_negative_feedback_on_bad_move
(
self
.
items_map
,
self
.
all_zones
,
action_key
=
action_key
)
self
.
parameterized_item_negative_feedback_on_bad_move_standard
(
self
.
items_map
,
self
.
all_zones
,
action_key
=
action_key
)
@data
(
*
ITEM_DRAG_KEYBOARD_KEYS
)
def
test_cannot_move_items_between_zones
(
self
,
action_key
):
...
...
@@ -477,16 +527,22 @@ class MultipleBlocksDataInteraction(ParameterizedTestsMixin, InteractionTestBase
def
test_item_positive_feedback_on_good_move
(
self
):
self
.
_switch_to_block
(
0
)
self
.
parameterized_item_positive_feedback_on_good_move
(
self
.
item_maps
[
'block1'
])
self
.
parameterized_item_positive_feedback_on_good_move_standard
(
self
.
item_maps
[
'block1'
],
feedback
=
self
.
feedback
[
'block1'
]
)
self
.
_switch_to_block
(
1
)
self
.
parameterized_item_positive_feedback_on_good_move
(
self
.
item_maps
[
'block2'
],
scroll_down
=
1000
)
self
.
parameterized_item_positive_feedback_on_good_move_standard
(
self
.
item_maps
[
'block2'
],
feedback
=
self
.
feedback
[
'block2'
],
scroll_down
=
1000
)
def
test_item_negative_feedback_on_bad_move
(
self
):
self
.
_switch_to_block
(
0
)
self
.
parameterized_item_negative_feedback_on_bad_move
(
self
.
item_maps
[
'block1'
],
self
.
all_zones
[
'block1'
])
self
.
parameterized_item_negative_feedback_on_bad_move_standard
(
self
.
item_maps
[
'block1'
],
self
.
all_zones
[
'block1'
],
feedback
=
self
.
feedback
[
'block1'
]
)
self
.
_switch_to_block
(
1
)
self
.
parameterized_item_negative_feedback_on_bad_move
(
self
.
item_maps
[
'block2'
],
self
.
all_zones
[
'block2'
],
scroll_down
=
1000
self
.
parameterized_item_negative_feedback_on_bad_move
_standard
(
self
.
item_maps
[
'block2'
],
self
.
all_zones
[
'block2'
],
feedback
=
self
.
feedback
[
'block2'
],
scroll_down
=
1000
)
def
test_final_feedback_and_reset
(
self
):
...
...
tests/integration/test_interaction_assessment.py
View file @
13ecee01
...
...
@@ -81,14 +81,12 @@ class AssessmentInteractionTest(
"""
@data
(
*
ITEM_DRAG_KEYBOARD_KEYS
)
def
test_item_no_feedback_on_good_move
(
self
,
action_key
):
self
.
parameterized_item_positive_feedback_on_good_move
(
self
.
items_map
,
action_key
=
action_key
,
assessment_mode
=
True
)
self
.
parameterized_item_positive_feedback_on_good_move_assessment
(
self
.
items_map
,
action_key
=
action_key
)
@data
(
*
ITEM_DRAG_KEYBOARD_KEYS
)
def
test_item_no_feedback_on_bad_move
(
self
,
action_key
):
self
.
parameterized_item_negative_feedback_on_bad_move
(
self
.
items_map
,
self
.
all_zones
,
action_key
=
action_key
,
assessment_mode
=
True
self
.
parameterized_item_negative_feedback_on_bad_move
_assessment
(
self
.
items_map
,
self
.
all_zones
,
action_key
=
action_key
)
@data
(
*
ITEM_DRAG_KEYBOARD_KEYS
)
...
...
@@ -250,6 +248,20 @@ class AssessmentInteractionTest(
"""
Test updating overall feedback after submitting solution in assessment mode
"""
get_sr_texts
=
self
.
_patch_sr_read_texts
()
def
check_feedback
(
overall_feedback_lines
,
per_item_feedback_lines
=
None
):
# Check that the feedback is correctly displayed in the overall feedback area.
expected_overall_feedback
=
"
\n
"
.
join
([
"FEEDBACK"
]
+
overall_feedback_lines
)
self
.
assertEqual
(
self
.
_get_feedback
()
.
text
,
expected_overall_feedback
)
# Check that the SR.readTexts function was passed correct feedback messages.
sr_feedback_lines
=
overall_feedback_lines
if
per_item_feedback_lines
:
sr_feedback_lines
+=
[
"Some of your answers were not correct."
,
"Hints:"
]
sr_feedback_lines
+=
per_item_feedback_lines
self
.
assertEqual
(
get_sr_texts
()[
-
1
],
sr_feedback_lines
)
# used keyboard mode to avoid bug/feature with selenium "selecting" everything instead of dragging an element
self
.
place_item
(
0
,
TOP_ZONE_ID
,
Keys
.
RETURN
)
...
...
@@ -261,29 +273,25 @@ class AssessmentInteractionTest(
expected_grade
=
2.0
/
5.0
feedback_lines
=
[
"FEEDBACK"
,
FeedbackMessages
.
correctly_placed
(
1
),
FeedbackMessages
.
not_placed
(
3
),
START_FEEDBACK
,
FeedbackMessages
.
GRADE_FEEDBACK_TPL
.
format
(
score
=
expected_grade
)
]
expected_feedback
=
"
\n
"
.
join
(
feedback_lines
)
self
.
assertEqual
(
self
.
_get_feedback
()
.
text
,
expected_feedback
)
check_feedback
(
feedback_lines
)
# Place the item into incorrect zone. The score does not change.
self
.
place_item
(
1
,
BOTTOM_ZONE_ID
,
Keys
.
RETURN
)
self
.
click_submit
()
feedback_lines
=
[
"FEEDBACK"
,
FeedbackMessages
.
correctly_placed
(
1
),
FeedbackMessages
.
misplaced_returned
(
1
),
FeedbackMessages
.
not_placed
(
2
),
START_FEEDBACK
,
FeedbackMessages
.
GRADE_FEEDBACK_TPL
.
format
(
score
=
expected_grade
)
]
expected_feedback
=
"
\n
"
.
join
(
feedback_lines
)
self
.
assertEqual
(
self
.
_get_feedback
()
.
text
,
expected_feedback
)
check_feedback
(
feedback_lines
,
[
"No, this item does not belong here. Try again."
])
# reach final attempt
for
_
in
xrange
(
self
.
MAX_ATTEMPTS
-
3
):
...
...
@@ -299,13 +307,11 @@ class AssessmentInteractionTest(
expected_grade
=
1.0
feedback_lines
=
[
"FEEDBACK"
,
FeedbackMessages
.
correctly_placed
(
4
),
FINISH_FEEDBACK
,
FeedbackMessages
.
FINAL_ATTEMPT_TPL
.
format
(
score
=
expected_grade
)
]
expected_feedback
=
"
\n
"
.
join
(
feedback_lines
)
self
.
assertEqual
(
self
.
_get_feedback
()
.
text
,
expected_feedback
)
check_feedback
(
feedback_lines
)
def
test_per_item_feedback_multiple_misplaced
(
self
):
self
.
place_item
(
0
,
MIDDLE_ZONE_ID
,
Keys
.
RETURN
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment