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
dd3c8c26
Commit
dd3c8c26
authored
Apr 04, 2014
by
Nimisha Asthagiri
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Support for Duplicate and Delete of XBlock Container Leaves (disabled under Feature Flag).
parent
76297c30
Show whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
381 additions
and
55 deletions
+381
-55
cms/envs/common.py
+6
-0
cms/static/js/spec/views/pages/container_spec.js
+257
-46
cms/static/js/views/pages/container.js
+96
-4
cms/templates/studio_xblock_wrapper.html
+22
-5
No files found.
cms/envs/common.py
View file @
dd3c8c26
...
...
@@ -99,6 +99,12 @@ FEATURES = {
# Turn off Advanced Security by default
'ADVANCED_SECURITY'
:
False
,
# Temporary feature flag for duplicating xblock leaves
'ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT'
:
False
,
# Temporary feature flag for deleting xblock leaves
'ENABLE_DELETE_XBLOCK_LEAF_COMPONENT'
:
False
,
}
ENABLE_JASMINE
=
False
...
...
cms/static/js/spec/views/pages/container_spec.js
View file @
dd3c8c26
define
([
"jquery"
,
"js/spec_helpers/create_sinon"
,
"js/spec_helpers/edit_helpers"
,
"js/views/feedback_notification"
,
"js/views/feedback_prompt"
,
"js/views/pages/container"
,
"js/models/xblock_info"
],
function
(
$
,
create_sinon
,
edit_helpers
,
ContainerPage
,
XBlockInfo
)
{
function
(
$
,
create_sinon
,
edit_helpers
,
Notification
,
Prompt
,
ContainerPage
,
XBlockInfo
)
{
describe
(
"ContainerPage"
,
function
()
{
var
model
,
containerPage
,
respondWithMockXBlockEditorFragment
,
mockContainerPage
;
mockContainerPage
=
readFixtures
(
'mock/mock-container-page.underscore'
);
var
lastRequest
,
renderContainerPage
,
expectComponents
,
respondWithHtml
,
model
,
containerPage
,
requests
,
mockContainerPage
=
readFixtures
(
'mock/mock-container-page.underscore'
),
ABTestFixture
=
readFixtures
(
'mock/mock-container-xblock.underscore'
);
beforeEach
(
function
()
{
edit_helpers
.
installEditTemplates
();
...
...
@@ -22,40 +24,50 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
});
});
respondWithMockXBlockEditorFragment
=
function
(
requests
,
response
)
{
lastRequest
=
function
()
{
return
requests
[
requests
.
length
-
1
];
};
respondWithHtml
=
function
(
html
)
{
var
requestIndex
=
requests
.
length
-
1
;
create_sinon
.
respondWithJson
(
requests
,
response
,
requestIndex
);
create_sinon
.
respondWithJson
(
requests
,
{
html
:
html
,
"resources"
:
[]
},
requestIndex
);
};
renderContainerPage
=
function
(
html
,
that
)
{
requests
=
create_sinon
.
requests
(
that
);
containerPage
.
render
();
respondWithHtml
(
html
);
};
expectComponents
=
function
(
container
,
locators
)
{
// verify expected components (in expected order) by their locators
var
components
=
$
(
container
).
find
(
'[data-locator]'
);
expect
(
components
.
length
).
toBe
(
locators
.
length
);
_
.
each
(
locators
,
function
(
locator
,
locator_index
)
{
expect
(
$
(
components
[
locator_index
]).
data
(
'locator'
)).
toBe
(
locator
);
});
};
describe
(
"Basic display"
,
function
()
{
var
mockContainerXBlockHtml
=
readFixtures
(
'mock/mock-container-xblock.underscore'
);
it
(
'can render itself'
,
function
()
{
var
requests
=
create_sinon
.
requests
(
this
);
containerPage
.
render
();
respondWithMockXBlockEditorFragment
(
requests
,
{
html
:
mockContainerXBlockHtml
,
resources
:
[]
});
renderContainerPage
(
mockContainerXBlockHtml
,
this
);
expect
(
containerPage
.
$el
.
select
(
'.xblock-header'
)).
toBeTruthy
();
expect
(
containerPage
.
$
(
'.wrapper-xblock'
)).
not
.
toHaveClass
(
'is-hidden'
);
expect
(
containerPage
.
$
(
'.no-container-content'
)).
toHaveClass
(
'is-hidden'
);
});
it
(
'shows a loading indicator'
,
function
()
{
var
requests
=
create_sinon
.
requests
(
this
);
requests
=
create_sinon
.
requests
(
this
);
containerPage
.
render
();
expect
(
containerPage
.
$
(
'.ui-loading'
)).
not
.
toHaveClass
(
'is-hidden'
);
respondWithMockXBlockEditorFragment
(
requests
,
{
html
:
mockContainerXBlockHtml
,
resources
:
[]
});
respondWithHtml
(
mockContainerXBlockHtml
);
expect
(
containerPage
.
$
(
'.ui-loading'
)).
toHaveClass
(
'is-hidden'
);
});
});
describe
(
"Editing an xblock"
,
function
()
{
var
mockContainerXBlockHtml
,
mockXBlockEditorHtml
,
...
...
@@ -79,19 +91,16 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
mockXBlockEditorHtml
=
readFixtures
(
'mock/mock-xblock-editor.underscore'
);
it
(
'can show an edit modal for a child xblock'
,
function
()
{
var
requests
=
create_sinon
.
requests
(
this
),
editButtons
;
containerPage
.
render
();
respondWithMockXBlockEditorFragment
(
requests
,
{
html
:
mockContainerXBlockHtml
,
resources
:
[]
});
var
editButtons
;
renderContainerPage
(
mockContainerXBlockHtml
,
this
);
editButtons
=
containerPage
.
$
(
'.edit-button'
);
// The container renders six mock xblocks, so there should be an equal number of edit buttons
expect
(
editButtons
.
length
).
toBe
(
6
);
editButtons
.
first
().
click
();
// Make sure that the correct xblock is requested to be edited
expect
(
requests
[
requests
.
length
-
1
].
url
).
toBe
(
'/xblock/locator-component-A1/studio_view'
);
expect
(
lastRequest
().
url
).
toBe
(
'/xblock/locator-component-A1/studio_view'
);
create_sinon
.
respondWithJson
(
requests
,
{
html
:
mockXBlockEditorHtml
,
resources
:
[]
...
...
@@ -100,14 +109,9 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
});
it
(
'can save changes to settings'
,
function
()
{
var
requests
,
editButtons
,
modal
,
mockUpdatedXBlockHtml
;
var
editButtons
,
modal
,
mockUpdatedXBlockHtml
;
mockUpdatedXBlockHtml
=
readFixtures
(
'mock/mock-updated-xblock.underscore'
);
requests
=
create_sinon
.
requests
(
this
);
containerPage
.
render
();
respondWithMockXBlockEditorFragment
(
requests
,
{
html
:
mockContainerXBlockHtml
,
resources
:
[]
});
renderContainerPage
(
mockContainerXBlockHtml
,
this
);
editButtons
=
containerPage
.
$
(
'.edit-button'
);
// The container renders six mock xblocks, so there should be an equal number of edit buttons
expect
(
editButtons
.
length
).
toBe
(
6
);
...
...
@@ -128,11 +132,10 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
create_sinon
.
respondWithJson
(
requests
,
{
id
:
model
.
id
});
// Respond to the request to refresh
respondWithMockXBlockEditorFragment
(
requests
,
{
html
:
mockUpdatedXBlockHtml
,
resources
:
[]
});
respondWithHtml
(
mockUpdatedXBlockHtml
);
// Verify that the xblock was updated
expect
(
containerPage
.
$
(
'.mock-updated-content'
).
text
()).
toBe
(
'Mock Update'
);
});
...
...
@@ -142,16 +145,224 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
var
mockContainerXBlockHtml
=
readFixtures
(
'mock/mock-empty-container-xblock.underscore'
);
it
(
'shows the "no children" message'
,
function
()
{
var
requests
=
create_sinon
.
requests
(
this
);
containerPage
.
render
();
respondWithMockXBlockEditorFragment
(
requests
,
{
html
:
mockContainerXBlockHtml
,
resources
:
[]
});
renderContainerPage
(
mockContainerXBlockHtml
,
this
);
expect
(
containerPage
.
$
(
'.no-container-content'
)).
not
.
toHaveClass
(
'is-hidden'
);
expect
(
containerPage
.
$
(
'.wrapper-xblock'
)).
toHaveClass
(
'is-hidden'
);
});
});
describe
(
"xblock operations"
,
function
()
{
var
getGroupElement
,
expectNumComponents
,
expectNotificationToBeShown
,
NUM_GROUPS
=
2
,
NUM_COMPONENTS_PER_GROUP
=
3
,
GROUP_TO_TEST
=
"A"
,
notificationSpies
,
allComponentsInGroup
=
_
.
map
(
_
.
range
(
NUM_COMPONENTS_PER_GROUP
),
function
(
index
)
{
return
'locator-component-'
+
GROUP_TO_TEST
+
(
index
+
1
);
}
);
beforeEach
(
function
()
{
notificationSpies
=
spyOnConstructor
(
Notification
,
"Mini"
,
[
"show"
,
"hide"
]);
notificationSpies
.
show
.
andReturn
(
notificationSpies
);
});
getGroupElement
=
function
()
{
return
containerPage
.
$
(
"[data-locator='locator-group-"
+
GROUP_TO_TEST
+
"']"
);
};
expectNumComponents
=
function
(
numComponents
)
{
expect
(
containerPage
.
$
(
'.wrapper-xblock.level-element'
).
length
).
toBe
(
numComponents
*
NUM_GROUPS
);
};
expectNotificationToBeShown
=
function
(
expectedTitle
)
{
expect
(
notificationSpies
.
constructor
).
toHaveBeenCalled
();
expect
(
notificationSpies
.
show
).
toHaveBeenCalled
();
expect
(
notificationSpies
.
hide
).
not
.
toHaveBeenCalled
();
expect
(
notificationSpies
.
constructor
.
mostRecentCall
.
args
[
0
].
title
).
toMatch
(
expectedTitle
);
};
describe
(
"Deleting an xblock"
,
function
()
{
var
clickDelete
,
deleteComponent
,
deleteComponentWithSuccess
,
promptSpies
;
beforeEach
(
function
()
{
promptSpies
=
spyOnConstructor
(
Prompt
,
"Warning"
,
[
"show"
,
"hide"
]);
promptSpies
.
show
.
andReturn
(
this
.
promptSpies
);
});
clickDelete
=
function
(
componentIndex
)
{
// find all delete buttons for the given group
var
deleteButtons
=
getGroupElement
().
find
(
".delete-button"
);
expect
(
deleteButtons
.
length
).
toBe
(
NUM_COMPONENTS_PER_GROUP
);
// click the requested delete button
deleteButtons
[
componentIndex
].
click
();
// expect delete confirmation
expect
(
promptSpies
.
constructor
).
toHaveBeenCalled
();
// no components should be deleted yet
expectNumComponents
(
NUM_COMPONENTS_PER_GROUP
);
};
deleteComponent
=
function
(
componentIndex
,
responseCode
)
{
// click delete button for given component
clickDelete
(
componentIndex
);
// click 'Yes' on delete confirmation
promptSpies
.
constructor
.
mostRecentCall
.
args
[
0
].
actions
.
primary
.
click
(
promptSpies
);
// expect 'deleting' notification to be shown
expectNotificationToBeShown
(
/Deleting/
);
// respond to request with given response code
lastRequest
().
respond
(
responseCode
,
{},
""
);
// expect request URL to contain given component's id
expect
(
lastRequest
().
url
).
toMatch
(
new
RegExp
(
"locator-component-"
+
GROUP_TO_TEST
+
(
componentIndex
+
1
))
);
};
deleteComponentWithSuccess
=
function
(
componentIndex
)
{
// delete component with an 'OK' response code
deleteComponent
(
componentIndex
,
200
);
// expect 'deleting' notification to be hidden
expect
(
notificationSpies
.
hide
).
toHaveBeenCalled
();
// verify the new list of components within the group
expectComponents
(
getGroupElement
(),
_
.
without
(
allComponentsInGroup
,
allComponentsInGroup
[
componentIndex
])
);
};
it
(
"deletes first xblock"
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
deleteComponentWithSuccess
(
0
);
});
it
(
"deletes middle xblock"
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
deleteComponentWithSuccess
(
1
);
});
it
(
"deletes last xblock"
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
deleteComponentWithSuccess
(
NUM_COMPONENTS_PER_GROUP
-
1
);
});
it
(
'does not delete xblock when clicking No in prompt'
,
function
()
{
var
numRequests
;
renderContainerPage
(
ABTestFixture
,
this
);
numRequests
=
requests
.
length
;
// click delete on the first component
clickDelete
(
0
);
// click 'No' on delete confirmation
promptSpies
.
constructor
.
mostRecentCall
.
args
[
0
].
actions
.
secondary
.
click
(
promptSpies
);
// all components should still exist
expectComponents
(
getGroupElement
(),
allComponentsInGroup
);
// no requests should have been sent to the server
expect
(
requests
.
length
).
toBe
(
numRequests
);
});
it
(
'does not delete xblock upon failure'
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
deleteComponent
(
0
,
500
);
expectComponents
(
getGroupElement
(),
allComponentsInGroup
);
expect
(
notificationSpies
.
hide
).
not
.
toHaveBeenCalled
();
});
});
describe
(
"Duplicating an xblock"
,
function
()
{
var
clickDuplicate
,
duplicateComponentWithResponse
,
duplicateComponentWithSuccess
,
refreshXBlockSpies
;
beforeEach
(
function
()
{
refreshXBlockSpies
=
spyOn
(
containerPage
,
"refreshXBlock"
);
});
clickDuplicate
=
function
(
componentIndex
)
{
// find all duplicate buttons for the given group
var
duplicateButtons
=
getGroupElement
().
find
(
".duplicate-button"
);
expect
(
duplicateButtons
.
length
).
toBe
(
NUM_COMPONENTS_PER_GROUP
);
// click the requested duplicate button
duplicateButtons
[
componentIndex
].
click
();
};
duplicateComponentWithResponse
=
function
(
componentIndex
,
responseCode
)
{
var
request
;
// click duplicate button for given component
clickDuplicate
(
componentIndex
);
// expect 'duplicating' notification to be shown
expectNotificationToBeShown
(
/Duplicating/
);
// verify content of request
request
=
lastRequest
();
request
.
respond
(
responseCode
,
{
"Content-Type"
:
"application/json"
},
JSON
.
stringify
({
'locator'
:
'locator-duplicated-component'
})
);
expect
(
request
.
url
).
toEqual
(
"/xblock"
);
expect
(
request
.
method
).
toEqual
(
"POST"
);
expect
(
JSON
.
parse
(
request
.
requestBody
)).
toEqual
(
JSON
.
parse
(
'{'
+
'"duplicate_source_locator": "locator-component-'
+
GROUP_TO_TEST
+
(
componentIndex
+
1
)
+
'",'
+
'"parent_locator": "locator-group-'
+
GROUP_TO_TEST
+
'"}'
)
);
};
duplicateComponentWithSuccess
=
function
(
componentIndex
)
{
// duplicate component with an 'OK' response code
duplicateComponentWithResponse
(
componentIndex
,
200
);
// expect 'duplicating' notification to be hidden
expect
(
notificationSpies
.
hide
).
toHaveBeenCalled
();
// expect parent container to be refreshed
expect
(
refreshXBlockSpies
).
toHaveBeenCalled
();
};
it
(
"duplicates first xblock"
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
duplicateComponentWithSuccess
(
0
);
});
it
(
"duplicates middle xblock"
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
duplicateComponentWithSuccess
(
1
);
});
it
(
"duplicates last xblock"
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
duplicateComponentWithSuccess
(
NUM_COMPONENTS_PER_GROUP
-
1
);
});
it
(
'does not duplicate xblock upon failure'
,
function
()
{
renderContainerPage
(
ABTestFixture
,
this
);
duplicateComponentWithResponse
(
0
,
500
);
expectComponents
(
getGroupElement
(),
allComponentsInGroup
);
expect
(
notificationSpies
.
hide
).
not
.
toHaveBeenCalled
();
expect
(
refreshXBlockSpies
).
not
.
toHaveBeenCalled
();
});
});
});
});
});
cms/static/js/views/pages/container.js
View file @
dd3c8c26
...
...
@@ -2,8 +2,8 @@
* XBlockContainerView is used to display an xblock which has children, and allows the
* user to interact with the children.
*/
define
([
"jquery"
,
"underscore"
,
"
js/views/baseview"
,
"js/views/xblock"
,
"js/views/modals/edit_xblock
"
],
function
(
$
,
_
,
BaseView
,
XBlockView
,
EditXBlockModal
)
{
define
([
"jquery"
,
"underscore"
,
"
gettext"
,
"js/views/feedback_notification"
,
"js/views/feedback_prompt"
,
"js/views/baseview"
,
"js/views/xblock"
,
"js/views/modals/edit_xblock"
,
"js/models/xblock_info
"
],
function
(
$
,
_
,
gettext
,
NotificationView
,
PromptView
,
BaseView
,
XBlockView
,
EditXBlockModal
,
XBlockInfo
)
{
var
XBlockContainerView
=
BaseView
.
extend
({
// takes XBlockInfo as a model
...
...
@@ -53,6 +53,10 @@ define(["jquery", "underscore", "js/views/baseview", "js/views/xblock", "js/view
return
$
(
target
).
closest
(
'[data-locator]'
);
},
getURLRoot
:
function
()
{
return
this
.
xblockView
.
model
.
urlRoot
;
},
addButtonActions
:
function
(
element
)
{
var
self
=
this
;
element
.
find
(
'.edit-button'
).
click
(
function
(
event
)
{
...
...
@@ -68,11 +72,98 @@ define(["jquery", "underscore", "js/views/baseview", "js/views/xblock", "js/view
}
});
});
element
.
find
(
'.duplicate-button'
).
click
(
function
(
event
)
{
event
.
preventDefault
();
self
.
duplicateComponent
(
self
.
findXBlockElement
(
event
.
target
)
);
});
element
.
find
(
'.delete-button'
).
click
(
function
(
event
)
{
event
.
preventDefault
();
self
.
deleteComponent
(
self
.
findXBlockElement
(
event
.
target
)
);
});
},
refreshXBlock
:
function
(
xblockInfo
,
xblockElement
)
{
duplicateComponent
:
function
(
xblockElement
)
{
var
self
=
this
,
temporaryView
;
parentElement
=
self
.
findXBlockElement
(
xblockElement
.
parent
()),
duplicating
=
new
NotificationView
.
Mini
({
title
:
gettext
(
'Duplicating…'
)
});
duplicating
.
show
();
return
$
.
postJSON
(
self
.
getURLRoot
(),
{
duplicate_source_locator
:
xblockElement
.
data
(
'locator'
),
parent_locator
:
parentElement
.
data
(
'locator'
)
},
function
(
data
)
{
// copy the element
var
duplicatedElement
=
xblockElement
.
clone
(
false
);
// place it after the original element
xblockElement
.
after
(
duplicatedElement
);
// update its locator id
duplicatedElement
.
attr
(
'data-locator'
,
data
.
locator
);
// have it refresh itself
self
.
refreshXBlockElement
(
duplicatedElement
);
// hide the notification
duplicating
.
hide
();
});
},
deleteComponent
:
function
(
xblockElement
)
{
var
self
=
this
,
deleting
;
return
new
PromptView
.
Warning
({
title
:
gettext
(
'Delete this component?'
),
message
:
gettext
(
'Deleting this component is permanent and cannot be undone.'
),
actions
:
{
primary
:
{
text
:
gettext
(
'Yes, delete this component'
),
click
:
function
(
prompt
)
{
prompt
.
hide
();
deleting
=
new
NotificationView
.
Mini
({
title
:
gettext
(
'Deleting…'
)
});
deleting
.
show
();
return
$
.
ajax
({
type
:
'DELETE'
,
url
:
self
.
getURLRoot
()
+
"/"
+
xblockElement
.
data
(
'locator'
)
+
"?"
+
$
.
param
({
recurse
:
true
,
all_versions
:
true
})
}).
success
(
function
()
{
deleting
.
hide
();
xblockElement
.
remove
();
});
}
},
secondary
:
{
text
:
gettext
(
'Cancel'
),
click
:
function
(
prompt
)
{
return
prompt
.
hide
();
}
}
}
}).
show
();
},
refreshXBlockElement
:
function
(
xblockElement
)
{
this
.
refreshXBlock
(
new
XBlockInfo
({
id
:
xblockElement
.
data
(
'locator'
)
}),
xblockElement
);
},
refreshXBlock
:
function
(
xblockInfo
,
xblockElement
)
{
var
self
=
this
,
temporaryView
;
// There is only one Backbone view created on the container page, which is
// for the container xblock itself. Any child xblocks rendered inside the
// container do not get a Backbone view. Thus, create a temporary XBlock
...
...
@@ -93,3 +184,4 @@ define(["jquery", "underscore", "js/views/baseview", "js/views/xblock", "js/view
return
XBlockContainerView
;
});
// end define();
cms/templates/studio_xblock_wrapper.html
View file @
dd3c8c26
<
%!
from
django
.
utils
.
translation
import
ugettext
as
_
%
>
<
%!
from
django
.
conf
import
settings
%
>
% if xblock.location != xblock_context['root_xblock'].location:
% if xblock.has_children:
<section
class=
"wrapper-xblock level-nesting"
data-locator=
"${locator}"
data-display-name=
"${xblock.display_name_with_default | h}"
data-category=
"${xblock.category | h}"
>
% else:
<section
class=
"wrapper-xblock level-element"
data-locator=
"${locator}"
data-display-name=
"${xblock.display_name_with_default | h}"
data-category=
"${xblock.category | h}"
>
% endif
<
%
section_class =
"level-nesting"
if
xblock
.
has_children
else
"
level-element
"
%
>
<section
class=
"wrapper-xblock ${section_class}"
data-locator=
"${locator}"
data-display-name=
"${xblock.display_name_with_default | h}"
data-category=
"${xblock.category | h}"
>
% endif
<header
class=
"xblock-header"
>
<div
class=
"header-details"
>
${xblock.display_name_with_default | h}
...
...
@@ -20,12 +20,29 @@
</a>
</li>
% endif
%if settings.FEATURES.get('ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT'):
<li
class=
"action-item action-duplicate"
>
<a
href=
"#"
data-tooltip=
"${_("
Duplicate
")}"
class=
"duplicate-button action-button"
>
<i
class=
"icon-copy"
></i>
<span
class=
"sr"
>
${_("Duplicate")}
</span>
</a>
</li>
% endif
%if settings.FEATURES.get('ENABLE_DELETE_XBLOCK_LEAF_COMPONENT'):
<li
class=
"action-item action-delete"
>
<a
href=
"#"
data-tooltip=
"${_("
Delete
")}"
class=
"delete-button action-button"
>
<i
class=
"icon-trash"
></i>
<span
class=
"sr"
>
${_("Delete")}
</span>
</a>
</li>
% endif
</ul>
</div>
</header>
<article
class=
"xblock-render"
>
${content}
</article>
% if xblock.location != xblock_context['root_xblock'].location:
</section>
% endif
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