Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
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
edx
edx-platform
Commits
7e8fcb85
Commit
7e8fcb85
authored
12 years ago
by
cahrens
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Updated Selenium test, deleted dead code related to Advanced Settings.
parent
40af08aa
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
96 additions
and
228 deletions
+96
-228
cms/djangoapps/contentstore/features/advanced-settings.feature
+30
-22
cms/djangoapps/contentstore/features/advanced-settings.py
+58
-48
cms/djangoapps/models/settings/course_metadata.py
+1
-2
cms/static/client_templates/advanced_entry.html
+1
-1
cms/static/js/models/settings/advanced.js
+1
-12
cms/static/js/views/settings/advanced_view.js
+4
-142
cms/templates/settings_advanced.html
+1
-1
No files found.
cms/djangoapps/contentstore/features/advanced-settings.feature
View file @
7e8fcb85
...
...
@@ -2,33 +2,41 @@ Feature: Advanced (manual) course policy
In order to specify course policy settings for which no custom user interface exists
I want to be able to manually enter JSON key/value pairs
#
Scenario: A course author sees default advanced settings
#
Given I have opened a new course in Studio
#
When I select the Advanced Settings
#
Then I see default advanced settings
Scenario
:
A
course author sees default advanced settings
Given
I have opened a new course in Studio
When
I select the Advanced Settings
Then
I see default advanced settings
Scenario
:
Add new entries, and they appear alphabetically after save
Given
I am on the Advanced Course Settings page in Studio
Then
the settings are alphabetized
# Scenario: Test cancel editing key value
# Given I am on the Advanced Course Settings page in Studio
# When I edit the value of a policy key
# And I press the "Cancel" notification button
# Then the policy key value is unchanged
#
Scenario
:
Test cancel editing key value
Given
I am on the Advanced Course Settings page in Studio
When
I edit the value of a policy key
And
I press the
"Cancel"
notification button
Then
the policy key value is unchanged
And
I reload the page
Then
the policy key value is unchanged
Scenario
:
Test editing key value
Given
I am on the Advanced Course Settings page in Studio
When
I edit the value of a policy key
And
I press the
"Save"
notification button
Then
the policy key value is changed
#
# Scenario: Add new entries, and they appear alphabetically after save
# Given I am on the Advanced Course Settings page in Studio
# When I create New Entries
# Then they are alphabetized
# And I reload the page
# Then they are alphabetized
#
# Scenario: Test how multi-line input appears
# Given I am on the Advanced Course Settings page in Studio
# When I create a JSON object
# Then it is displayed as formatted
And
I reload the page
Then
the policy key value is changed
Scenario
:
Test how multi-line input appears
Given
I am on the Advanced Course Settings page in Studio
When
I create a JSON object as a value
Then
it is displayed as formatted
And
I reload the page
Then
it is displayed as formatted
Scenario
:
Test automatic quoting of non-JSON values
Given
I am on the Advanced Course Settings page in Studio
When
I create a non-JSON value not in quotes
Then
it is displayed as a string
And
I reload the page
Then
it is displayed as a string
This diff is collapsed.
Click to expand it.
cms/djangoapps/contentstore/features/advanced-settings.py
View file @
7e8fcb85
from
lettuce
import
world
,
step
from
common
import
*
import
time
from
terrain.steps
import
reload_the_page
from
selenium.common.exceptions
import
WebDriverException
from
selenium.webdriver.support
import
expected_conditions
as
EC
...
...
@@ -11,6 +12,10 @@ http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdrive
"""
from
selenium.webdriver.common.keys
import
Keys
KEY_CSS
=
'.key input.policy-key'
VALUE_CSS
=
'textarea.json'
DISPLAY_NAME_KEY
=
"display_name"
DISPLAY_NAME_VALUE
=
'"Robot Super Course"'
############### ACTIONS ####################
@step
(
'I select the Advanced Settings$'
)
...
...
@@ -28,32 +33,26 @@ def i_am_on_advanced_course_settings(step):
step
.
given
(
'I select the Advanced Settings'
)
# TODO: this is copied from terrain's step.py. Need to figure out how to share that code.
@step
(
'I reload the page$'
)
def
reload_the_page
(
step
):
world
.
browser
.
reload
()
@step
(
u'I press the "([^"]*)" notification button$'
)
def
press_the_notification_button
(
step
,
name
):
def
is_visible
(
driver
):
return
EC
.
visibility_of_element_located
((
By
.
CSS_SELECTOR
,
css
,))
def
is_invisible
(
driver
):
return
EC
.
invisibility_of_element_located
((
By
.
CSS_SELECTOR
,
css
,))
return
EC
.
visibility_of_element_located
((
By
.
CSS_SELECTOR
,
css
,))
# def is_invisible(driver):
# return EC.invisibility_of_element_located((By.CSS_SELECTOR,css,))
css
=
'a.
%
s-button'
%
name
.
lower
()
wait_for
(
is_visible
)
try
:
css_click_at
(
css
)
wait_for
(
is_invisible
)
except
WebDriverException
,
e
:
time
.
sleep
(
float
(
1
))
css_click_at
(
css
)
wait_for
(
is_invisible
)
if
name
==
"Save"
:
css
=
""
wait_for
(
is_visible
)
# is_invisible is not returning a boolean, not working
# try:
# css_click_at(css)
# wait_for(is_invisible)
# except WebDriverException, e:
# css_click_at(css)
# wait_for(is_invisible)
@step
(
u'I edit the value of a policy key$'
)
...
...
@@ -62,16 +61,18 @@ def edit_the_value_of_a_policy_key(step):
It is hard to figure out how to get into the CodeMirror
area, so cheat and do it from the policy key field :)
"""
policy_key_css
=
'input.policy-key'
index
=
get_index_of
(
"display_name"
)
e
=
css_find
(
policy_key_css
)[
index
]
e
=
css_find
(
KEY_CSS
)[
get_index_of
(
DISPLAY_NAME_KEY
)]
e
.
_element
.
send_keys
(
Keys
.
TAB
,
Keys
.
END
,
Keys
.
ARROW_LEFT
,
' '
,
'X'
)
@step
(
'I create a JSON object$'
)
@step
(
'I create a JSON object
as a value
$'
)
def
create_JSON_object
(
step
):
create_entry
(
"json"
,
'{"key": "value", "key_2": "value_2"}'
)
click_save
()
change_display_name_value
(
step
,
'{"key": "value", "key_2": "value_2"}'
)
@step
(
'I create a non-JSON value not in quotes$'
)
def
create_value_not_in_quotes
(
step
):
change_display_name_value
(
step
,
'quote me'
)
############### RESULTS ####################
...
...
@@ -79,21 +80,32 @@ def create_JSON_object(step):
def
i_see_default_advanced_settings
(
step
):
# Test only a few of the existing properties (there are around 34 of them)
assert_policy_entries
(
[
"advanced_modules"
,
"display_name"
,
"show_calculator"
],
[
"[]"
,
'"Robot Super Course"'
,
"false"
],
False
)
[
"advanced_modules"
,
DISPLAY_NAME_KEY
,
"show_calculator"
],
[
"[]"
,
DISPLAY_NAME_VALUE
,
"false"
]
)
@step
(
'the
y
are alphabetized$'
)
@step
(
'the
settings
are alphabetized$'
)
def
they_are_alphabetized
(
step
):
assert_policy_entries
([
"a"
,
"display_name"
,
"z"
],
[
'"zebra"'
,
'"Robot Super Course"'
,
'"apple"'
])
key_elements
=
css_find
(
KEY_CSS
)
all_keys
=
[]
for
key
in
key_elements
:
all_keys
.
append
(
key
.
value
)
assert_equal
(
sorted
(
all_keys
),
all_keys
,
"policy keys were not sorted"
)
@step
(
'it is displayed as formatted$'
)
def
it_is_formatted
(
step
):
assert_policy_entries
([
"display_name"
,
"json"
],
[
'"Robot Super Course"'
,
'{
\n
"key": "value",
\n
"key_2": "value_2"
\n
}'
])
assert_policy_entries
([
DISPLAY_NAME_KEY
],
[
'{
\n
"key": "value",
\n
"key_2": "value_2"
\n
}'
])
@step
(
'it is displayed as a string'
)
def
it_is_formatted
(
step
):
assert_policy_entries
([
DISPLAY_NAME_KEY
],
[
'"quote me"'
])
@step
(
u'the policy key value is unchanged$'
)
def
the_policy_key_value_is_unchanged
(
step
):
assert_equal
(
get_display_name_value
(),
'"Robot Super Course"'
)
assert_equal
(
get_display_name_value
(),
DISPLAY_NAME_VALUE
)
@step
(
u'the policy key value is changed$'
)
...
...
@@ -102,36 +114,33 @@ def the_policy_key_value_is_changed(step):
############# HELPERS ###############
def
assert_policy_entries
(
expected_keys
,
expected_values
,
assertLength
=
True
):
key_css
=
'.key input.policy-key'
key_elements
=
css_find
(
key_css
)
if
assertLength
:
assert_equal
(
len
(
expected_keys
),
len
(
key_elements
))
value_css
=
'textarea.json'
def
assert_policy_entries
(
expected_keys
,
expected_values
):
for
counter
in
range
(
len
(
expected_keys
)):
index
=
get_index_of
(
expected_keys
[
counter
])
assert_false
(
index
==
-
1
,
"Could not find key: "
+
expected_keys
[
counter
])
assert_equal
(
expected_values
[
counter
],
css_find
(
value_css
)[
index
]
.
value
,
"value is incorrect"
)
assert_equal
(
expected_values
[
counter
],
css_find
(
VALUE_CSS
)[
index
]
.
value
,
"value is incorrect"
)
def
get_index_of
(
expected_key
):
key_css
=
'.key input.policy-key'
for
counter
in
range
(
len
(
css_find
(
key_css
))):
for
counter
in
range
(
len
(
css_find
(
KEY_CSS
))):
# Sometimes get stale reference if I hold on to the array of elements
key
=
css_find
(
key_css
)[
counter
]
.
value
key
=
css_find
(
KEY_CSS
)[
counter
]
.
value
if
key
==
expected_key
:
return
counter
return
-
1
def
click_save
():
css
=
"a.save-button"
css_click_at
(
css
)
def
get_display_name_value
():
policy_value_css
=
'textarea.json'
index
=
get_index_of
(
"display_name"
)
return
css_find
(
policy_value_css
)[
index
]
.
value
index
=
get_index_of
(
DISPLAY_NAME_KEY
)
return
css_find
(
VALUE_CSS
)[
index
]
.
value
def
change_display_name_value
(
step
,
new_value
):
e
=
css_find
(
KEY_CSS
)[
get_index_of
(
DISPLAY_NAME_KEY
)]
display_name
=
get_display_name_value
()
for
count
in
range
(
len
(
display_name
)):
e
.
_element
.
send_keys
(
Keys
.
TAB
,
Keys
.
END
,
Keys
.
BACK_SPACE
)
# Must delete "" before typing the JSON value
e
.
_element
.
send_keys
(
Keys
.
TAB
,
Keys
.
END
,
Keys
.
BACK_SPACE
,
Keys
.
BACK_SPACE
,
new_value
)
press_the_notification_button
(
step
,
"Save"
)
\ No newline at end of file
This diff is collapsed.
Click to expand it.
cms/djangoapps/models/settings/course_metadata.py
View file @
7e8fcb85
...
...
@@ -10,8 +10,7 @@ class CourseMetadata(object):
For CRUD operations on metadata fields which do not have specific editors on the other pages including any user generated ones.
The objects have no predefined attrs but instead are obj encodings of the editable metadata.
'''
# __new_advanced_key__ is used by client not server; so, could argue against it being here
FILTERED_LIST
=
XModuleDescriptor
.
system_metadata_fields
+
[
'start'
,
'end'
,
'enrollment_start'
,
'enrollment_end'
,
'tabs'
,
'graceperiod'
,
'__new_advanced_key__'
]
FILTERED_LIST
=
XModuleDescriptor
.
system_metadata_fields
+
[
'start'
,
'end'
,
'enrollment_start'
,
'enrollment_end'
,
'tabs'
,
'graceperiod'
]
@classmethod
def
fetch
(
cls
,
course_location
):
...
...
This diff is collapsed.
Click to expand it.
cms/static/client_templates/advanced_entry.html
View file @
7e8fcb85
<li
class=
"field-group course-advanced-policy-list-item"
>
<div
class=
"field is-not-editable text key"
id=
"<%=
(_.isEmpty(key) ? '__new_advanced_key__' : key)
%>"
>
<div
class=
"field is-not-editable text key"
id=
"<%=
key
%>"
>
<label
for=
"<%= keyUniqueId %>"
>
Policy Key:
</label>
<input
readonly
title=
"This field is disabled: policy keys cannot be edited."
type=
"text"
class=
"short policy-key"
id=
"<%= keyUniqueId %>"
value=
"<%= key %>"
/>
</div>
...
...
This diff is collapsed.
Click to expand it.
cms/static/js/models/settings/advanced.js
View file @
7e8fcb85
if
(
!
CMS
.
Models
[
'Settings'
])
CMS
.
Models
.
Settings
=
{};
CMS
.
Models
.
Settings
.
Advanced
=
Backbone
.
Model
.
extend
({
// the key for a newly added policy-- before the user has entered a key value
new_key
:
"__new_advanced_key__"
,
defaults
:
{
// the properties are whatever the user types in (in addition to whatever comes originally from the server)
...
...
@@ -12,16 +10,7 @@ CMS.Models.Settings.Advanced = Backbone.Model.extend({
blacklistKeys
:
[],
// an array which the controller should populate directly for now [static not instance based]
validate
:
function
(
attrs
)
{
var
errors
=
{};
for
(
var
key
in
attrs
)
{
if
(
key
===
this
.
new_key
||
_
.
isEmpty
(
key
))
{
errors
[
key
]
=
"A key must be entered."
;
}
else
if
(
_
.
contains
(
this
.
blacklistKeys
,
key
))
{
errors
[
key
]
=
key
+
" is a reserved keyword or can be edited on another screen"
;
}
}
if
(
!
_
.
isEmpty
(
errors
))
return
errors
;
// Keys can no longer be edited. We are currently not validating values.
},
save
:
function
(
attrs
,
options
)
{
...
...
This diff is collapsed.
Click to expand it.
cms/static/js/views/settings/advanced_view.js
View file @
7e8fcb85
...
...
@@ -6,14 +6,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
// Model class is CMS.Models.Settings.Advanced
events
:
{
'click .delete-button'
:
"deleteEntry"
,
'click .new-button'
:
"addEntry"
,
// update model on changes
'change .policy-key'
:
"updateKey"
,
// keypress to catch alpha keys and backspace/delete on some browsers
'keypress .policy-key'
:
"showSaveCancelButtons"
,
// keyup to catch backspace/delete reliably
'keyup .policy-key'
:
"showSaveCancelButtons"
,
'focus :input'
:
"focusInput"
,
'blur :input'
:
"blurInput"
// TODO enable/disable save based on validation (currently enabled whenever there are changes)
...
...
@@ -95,16 +87,11 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
mirror
.
setValue
(
stringValue
);
}
catch
(
quotedE
)
{
// TODO: validation error
console
.
log
(
"Error with JSON, even after converting to String."
);
console
.
log
(
quotedE
);
//
console.log("Error with JSON, even after converting to String.");
//
console.log(quotedE);
JSONValue
=
undefined
;
}
}
else
{
// TODO: validation error
console
.
log
(
"Error with JSON, but will not convert to String."
);
console
.
log
(
e
);
}
}
if
(
JSONValue
!==
undefined
)
{
self
.
clearValidationErrors
();
...
...
@@ -113,7 +100,6 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
}
});
},
showMessage
:
function
(
type
)
{
this
.
$el
.
find
(
".message-status"
).
removeClass
(
"is-shown"
);
if
(
type
)
{
...
...
@@ -128,56 +114,19 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
else
{
// This is the case of the page first rendering, or when Cancel is pressed.
this
.
hideSaveCancelButtons
();
this
.
toggleNewButton
(
true
);
}
},
showSaveCancelButtons
:
function
(
event
)
{
if
(
!
this
.
buttonsVisible
)
{
if
(
event
&&
(
event
.
type
===
'keypress'
||
event
.
type
===
'keyup'
))
{
// check whether it's really an altering event: note, String.fromCharCode(keyCode) will
// give positive values for control/command/option-letter combos; so, don't use it
if
(
!
((
event
.
charCode
&&
String
.
fromCharCode
(
event
.
charCode
)
!==
""
)
||
// 8 = backspace, 46 = delete
event
.
keyCode
===
8
||
event
.
keyCode
===
46
))
return
;
}
this
.
$el
.
find
(
".message-status"
).
removeClass
(
"is-shown"
);
$
(
'.wrapper-notification'
).
addClass
(
'is-shown'
);
this
.
buttonsVisible
=
true
;
}
},
hideSaveCancelButtons
:
function
()
{
$
(
'.wrapper-notification'
).
removeClass
(
'is-shown'
);
this
.
buttonsVisible
=
false
;
},
toggleNewButton
:
function
(
enable
)
{
var
newButton
=
this
.
$el
.
find
(
".new-button"
);
if
(
enable
)
{
newButton
.
removeClass
(
'disabled'
);
}
else
{
newButton
.
addClass
(
'disabled'
);
}
},
deleteEntry
:
function
(
event
)
{
event
.
preventDefault
();
// find out which entry
var
li$
=
$
(
event
.
currentTarget
).
closest
(
'li'
);
// Not data b/c the validation view uses it for a selector
var
key
=
$
(
'.key'
,
li$
).
attr
(
'id'
);
delete
this
.
selectorToField
[
this
.
fieldToSelectorMap
[
key
]];
delete
this
.
fieldToSelectorMap
[
key
];
if
(
key
!==
this
.
model
.
new_key
)
{
this
.
model
.
deleteKeys
.
push
(
key
);
this
.
model
.
unset
(
key
);
}
li$
.
remove
();
this
.
showSaveCancelButtons
();
},
saveView
:
function
(
event
)
{
// TODO one last verification scan:
// call validateKey on each to ensure proper format
...
...
@@ -201,102 +150,15 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
error
:
CMS
.
ServerError
});
},
addEntry
:
function
()
{
var
listEle$
=
this
.
$el
.
find
(
'.course-advanced-policy-list'
);
var
newEle
=
this
.
renderTemplate
(
""
,
""
);
listEle$
.
append
(
newEle
);
// need to re-find b/c replaceWith seems to copy rather than use the specific ele instance
var
policyValueDivs
=
this
.
$el
.
find
(
'#'
+
this
.
model
.
new_key
).
closest
(
'li'
).
find
(
'.json'
);
// only 1 but hey, let's take advantage of the context mechanism
_
.
each
(
policyValueDivs
,
this
.
attachJSONEditor
,
this
);
this
.
toggleNewButton
(
false
);
},
updateKey
:
function
(
event
)
{
var
parentElement
=
$
(
event
.
currentTarget
).
closest
(
'.key'
);
// old key: either the key as in the model or new_key.
// That is, it doesn't change as the val changes until val is accepted.
var
oldKey
=
parentElement
.
attr
(
'id'
);
// TODO: validation of keys with spaces. For now at least trim strings to remove initial and
// trailing whitespace
var
newKey
=
$
.
trim
(
$
(
event
.
currentTarget
).
val
());
if
(
oldKey
!==
newKey
)
{
// TODO: is it OK to erase other validation messages?
this
.
clearValidationErrors
();
if
(
!
this
.
validateKey
(
oldKey
,
newKey
))
return
;
if
(
this
.
model
.
has
(
newKey
))
{
var
error
=
{};
error
[
oldKey
]
=
'You have already defined "'
+
newKey
+
'" in the manual policy definitions.'
;
error
[
newKey
]
=
"You tried to enter a duplicate of this key."
;
this
.
model
.
trigger
(
"invalid"
,
this
.
model
,
error
);
return
false
;
}
// explicitly call validate to determine whether to proceed (relying on triggered error means putting continuation in the success
// method which is uglier I think?)
var
newEntryModel
=
{};
// set the new key's value to the old one's
newEntryModel
[
newKey
]
=
(
oldKey
===
this
.
model
.
new_key
?
''
:
this
.
model
.
get
(
oldKey
));
var
validation
=
this
.
model
.
validate
(
newEntryModel
);
if
(
validation
)
{
if
(
_
.
has
(
validation
,
newKey
))
{
// swap to the key which the map knows about
validation
[
oldKey
]
=
validation
[
newKey
];
}
this
.
model
.
trigger
(
"invalid"
,
this
.
model
,
validation
);
// abandon update
return
;
}
// Now safe to actually do the update
this
.
model
.
set
(
newEntryModel
);
// update maps
var
selector
=
this
.
fieldToSelectorMap
[
oldKey
];
this
.
selectorToField
[
selector
]
=
newKey
;
this
.
fieldToSelectorMap
[
newKey
]
=
selector
;
delete
this
.
fieldToSelectorMap
[
oldKey
];
if
(
oldKey
!==
this
.
model
.
new_key
)
{
// mark the old key for deletion and delete from field maps
this
.
model
.
deleteKeys
.
push
(
oldKey
);
this
.
model
.
unset
(
oldKey
)
;
}
else
{
// id for the new entry will now be the key value. Enable new entry button.
this
.
toggleNewButton
(
true
);
}
// check for newkey being the name of one which was previously deleted in this session
var
wasDeleting
=
this
.
model
.
deleteKeys
.
indexOf
(
newKey
);
if
(
wasDeleting
>=
0
)
{
this
.
model
.
deleteKeys
.
splice
(
wasDeleting
,
1
);
}
// Update the ID to the new value.
parentElement
.
attr
(
'id'
,
newKey
);
}
},
validateKey
:
function
(
oldKey
,
newKey
)
{
// model validation can't handle malformed keys nor notice if 2 fields have same key; so, need to add that chk here
// TODO ensure there's no spaces or illegal chars (note some checking for spaces currently done in model's
// validate method.
return
true
;
},
renderTemplate
:
function
(
key
,
value
)
{
var
newKeyId
=
_
.
uniqueId
(
'policy_key_'
),
newEle
=
this
.
template
({
key
:
key
,
value
:
JSON
.
stringify
(
value
,
null
,
4
),
keyUniqueId
:
newKeyId
,
valueUniqueId
:
_
.
uniqueId
(
'policy_value_'
)});
this
.
fieldToSelectorMap
[
(
_
.
isEmpty
(
key
)
?
this
.
model
.
new_key
:
key
)
]
=
newKeyId
;
this
.
selectorToField
[
newKeyId
]
=
(
_
.
isEmpty
(
key
)
?
this
.
model
.
new_key
:
key
)
;
this
.
fieldToSelectorMap
[
key
]
=
newKeyId
;
this
.
selectorToField
[
newKeyId
]
=
key
;
return
newEle
;
},
focusInput
:
function
(
event
)
{
$
(
event
.
target
).
prev
().
addClass
(
"is-focused"
);
},
...
...
This diff is collapsed.
Click to expand it.
cms/templates/settings_advanced.html
View file @
7e8fcb85
...
...
@@ -104,7 +104,7 @@ editor.render();
<i
class=
"ss-icon ss-symbolicons-block icon icon-warning"
>
⚠
</i>
<p><strong>
Note:
</strong>
Your changes will not take effect until you
<strong>
save your
progress
</strong>
. Take care with
key and
value formatting, as validation is
<strong>
not implemented
</strong>
.
</p>
progress
</strong>
. Take care with
policy
value formatting, as validation is
<strong>
not implemented
</strong>
.
</p>
</div>
<div
class=
"actions"
>
...
...
This diff is collapsed.
Click to expand it.
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