Commit d9a65a96 by Brian Jacobel Committed by GitHub

Merge pull request #14167 from edx/bjacobel/sequence-coffee-to-js

Move Sequence coffee files to JS
parents ebe332fc 749909b8
...@@ -52,7 +52,6 @@ common/lib/xmodule/xmodule/js/src/html/edit.js ...@@ -52,7 +52,6 @@ common/lib/xmodule/xmodule/js/src/html/edit.js
common/lib/xmodule/xmodule/js/src/raw/edit/json.js common/lib/xmodule/xmodule/js/src/raw/edit/json.js
common/lib/xmodule/xmodule/js/src/raw/edit/metadata-only.js common/lib/xmodule/xmodule/js/src/raw/edit/metadata-only.js
common/lib/xmodule/xmodule/js/src/raw/edit/xml.js common/lib/xmodule/xmodule/js/src/raw/edit/xml.js
common/lib/xmodule/xmodule/js/src/sequence/display.js
common/lib/xmodule/xmodule/js/src/sequence/edit.js common/lib/xmodule/xmodule/js/src/sequence/edit.js
common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.js common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.js
common/lib/xmodule/xmodule/js/src/vertical/edit.js common/lib/xmodule/xmodule/js/src/vertical/edit.js
......
class @Sequence
constructor: (element) ->
@updatedProblems = {}
@requestToken = $(element).data('request-token')
@el = $(element).find('.sequence')
@path = $('.path')
@contents = @$('.seq_contents')
@content_container = @$('#seq_content')
@sr_container = @$('.sr-is-focusable')
@num_contents = @contents.length
@id = @el.data('id')
@ajaxUrl = @el.data('ajax-url')
@nextUrl = @el.data('next-url')
@prevUrl = @el.data('prev-url')
@base_page_title = " | " + document.title
@initProgress()
@bind()
@render parseInt(@el.data('position'))
$: (selector) ->
$(selector, @el)
bind: ->
@$('#sequence-list .nav-item').click @goto
@el.on 'bookmark:add', @addBookmarkIconToActiveNavItem
@el.on 'bookmark:remove', @removeBookmarkIconFromActiveNavItem
@$('#sequence-list .nav-item').on('focus mouseenter', @displayTabTooltip)
@$('#sequence-list .nav-item').on('blur mouseleave', @hideTabTooltip)
displayTabTooltip: (event) =>
$(event.currentTarget).find('.sequence-tooltip').removeClass('sr')
hideTabTooltip: (event) =>
$(event.currentTarget).find('.sequence-tooltip').addClass('sr')
initProgress: ->
@progressTable = {} # "#problem_#{id}" -> progress
updatePageTitle: ->
# update the page title to include the current section
position_link = @link_for(@position)
if position_link and position_link.data('page-title')
document.title = position_link.data('page-title') + @base_page_title
hookUpContentStateChangeEvent: ->
$('.problems-wrapper').bind(
'contentChanged',
(event, problem_id, new_content_state, new_state) =>
@addToUpdatedProblems problem_id, new_content_state, new_state
)
addToUpdatedProblems: (problem_id, new_content_state, new_state) =>
# Used to keep updated problem's state temporarily.
# params:
# 'problem_id' is problem id.
# 'new_content_state' is the updated content of the problem.
# 'new_state' is the updated state of the problem.
# initialize for the current sequence if there isn't any updated problem
# for this position.
if not @anyUpdatedProblems @position
@updatedProblems[@position] = {}
# Now, put problem content and score against problem id for current active sequence.
@updatedProblems[@position][problem_id] = [new_content_state, new_state]
anyUpdatedProblems:(position) ->
# check for the updated problems for given sequence position.
# params:
# 'position' can be any sequence position.
return @updatedProblems[position] != undefined
hookUpProgressEvent: ->
$('.problems-wrapper').bind 'progressChanged', @updateProgress
mergeProgress: (p1, p2) ->
# if either is "NA", return the other one
if p1 == "NA"
return p2
if p2 == "NA"
return p1
# Both real progresses
if p1 == "done" and p2 == "done"
return "done"
# not done, so if any progress on either, in_progress
w1 = p1 == "done" or p1 == "in_progress"
w2 = p2 == "done" or p2 == "in_progress"
if w1 or w2
return "in_progress"
return "none"
updateProgress: =>
new_progress = "NA"
_this = this
$('.problems-wrapper').each (index) ->
progress = $(this).data 'progress_status'
new_progress = _this.mergeProgress progress, new_progress
@progressTable[@position] = new_progress
enableButton: (button_class, button_action) ->
@$(button_class).removeClass('disabled').removeAttr('disabled').click(button_action)
disableButton: (button_class) ->
@$(button_class).addClass('disabled').attr('disabled', true)
setButtonLabel: (button_class, button_label) ->
@$(button_class + ' .sr').html(button_label)
updateButtonState: (button_class, button_action, action_label_prefix, is_at_boundary, boundary_url) ->
if is_at_boundary and boundary_url == 'None'
@disableButton(button_class)
else
button_label = action_label_prefix + (if is_at_boundary then ' Subsection' else ' Unit')
@setButtonLabel(button_class, button_label)
@enableButton(button_class, button_action)
toggleArrows: =>
@$('.sequence-nav-button').unbind('click')
# previous button
is_first_tab = @position == 1
previous_button_class = '.sequence-nav-button.button-previous'
@updateButtonState(
previous_button_class, # bound element
@selectPrevious, # action
'Previous', # label prefix
is_first_tab, # is boundary?
@prevUrl # boundary_url
)
# next button
is_last_tab = @position >= @contents.length # use inequality in case contents.length is 0 and position is 1.
next_button_class = '.sequence-nav-button.button-next'
@updateButtonState(
next_button_class, # bound element
@selectNext, # action
'Next', # label prefix
is_last_tab, # is boundary?
@nextUrl # boundary_url
)
render: (new_position) ->
if @position != new_position
if @position != undefined
@mark_visited @position
modx_full_url = "#{@ajaxUrl}/goto_position"
$.postWithPrefix modx_full_url, position: new_position
# On Sequence change, fire custom event "sequence:change" on element.
# Added for aborting video bufferization, see ../video/10_main.js
@el.trigger "sequence:change"
@mark_active new_position
current_tab = @contents.eq(new_position - 1)
bookmarked = if @el.find('.active .bookmark-icon').hasClass('bookmarked') then true else false
@content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby")).data('bookmarked', bookmarked)
# update the data-attributes with latest contents only for updated problems.
if @anyUpdatedProblems new_position
$.each @updatedProblems[new_position], (problem_id, latest_data) =>
latest_content = latest_data[0]
latest_response = latest_data[1]
@content_container
.find("[data-problem-id='#{ problem_id }']")
.data('content', latest_content)
.data('problem-score', latest_response.current_score)
.data('problem-total-possible', latest_response.total_possible)
.data('attempts-used', latest_response.attempts_used)
XBlock.initializeBlocks(@content_container, @requestToken)
window.update_schematics() # For embedded circuit simulator exercises in 6.002x
@position = new_position
@toggleArrows()
@hookUpContentStateChangeEvent()
@hookUpProgressEvent()
@updatePageTitle()
sequence_links = @content_container.find('a.seqnav')
sequence_links.click @goto
@path.text(@el.find('.nav-item.active').data('path'))
@sr_container.focus()
goto: (event) =>
event.preventDefault()
if $(event.currentTarget).hasClass 'seqnav' # Links from courseware <a class='seqnav' href='n'>...</a>, was .target
new_position = $(event.currentTarget).attr('href')
else # Tab links generated by backend template
new_position = $(event.currentTarget).data('element')
if (1 <= new_position) and (new_position <= @num_contents)
is_bottom_nav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0
if is_bottom_nav
widget_placement = 'bottom'
else
widget_placement = 'top'
Logger.log "edx.ui.lms.sequence.tab_selected", # Formerly known as seq_goto
current_tab: @position
target_tab: new_position
tab_count: @num_contents
id: @id
widget_placement: widget_placement
# On Sequence change, destroy any existing polling thread
# for queued submissions, see ../capa/display.js
if window.queuePollerID
window.clearTimeout(window.queuePollerID)
delete window.queuePollerID
@render new_position
else
alert_template = gettext("Sequence error! Cannot navigate to %(tab_name)s in the current SequenceModule. Please contact the course staff.")
alert_text = interpolate(alert_template, {tab_name: new_position}, true)
alert alert_text
selectNext: (event) => @_change_sequential 'next', event
selectPrevious: (event) => @_change_sequential 'previous', event
# `direction` can be 'previous' or 'next'
_change_sequential: (direction, event) =>
# silently abort if direction is invalid.
return unless direction in ['previous', 'next']
event.preventDefault()
analytics_event_name = "edx.ui.lms.sequence.#{direction}_selected"
is_bottom_nav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0
if is_bottom_nav
widget_placement = 'bottom'
else
widget_placement = 'top'
Logger.log analytics_event_name, # Formerly known as seq_next and seq_prev
id: @id
current_tab: @position
tab_count: @num_contents
widget_placement: widget_placement
if (direction == 'next') and (@position >= @contents.length)
window.location.href = @nextUrl
else if (direction == 'previous') and (@position == 1)
window.location.href = @prevUrl
else
# If the bottom nav is used, scroll to the top of the page on change.
if is_bottom_nav
$.scrollTo 0, 150
offset =
next: 1
previous: -1
new_position = @position + offset[direction]
@render new_position
link_for: (position) ->
@$("#sequence-list .nav-item[data-element=#{position}]")
mark_visited: (position) ->
# Don't overwrite class attribute to avoid changing Progress class
element = @link_for(position)
element.removeClass("inactive")
.removeClass("active")
.addClass("visited")
mark_active: (position) ->
# Don't overwrite class attribute to avoid changing Progress class
element = @link_for(position)
element.removeClass("inactive")
.removeClass("visited")
.addClass("active")
addBookmarkIconToActiveNavItem: (event) =>
event.preventDefault()
@el.find('.nav-item.active .bookmark-icon').removeClass('is-hidden').addClass('bookmarked')
@el.find('.nav-item.active .bookmark-icon-sr').text(gettext('Bookmarked'))
removeBookmarkIconFromActiveNavItem: (event) =>
event.preventDefault()
@el.find('.nav-item.active .bookmark-icon').removeClass('bookmarked').addClass('is-hidden')
@el.find('.nav-item.active .bookmark-icon-sr').text('')
/* eslint-disable no-underscore-dangle */
/* globals Logger, interpolate */
(function() {
'use strict';
this.Sequence = (function() {
function Sequence(element) {
var self = this;
this.removeBookmarkIconFromActiveNavItem = function(event) {
return Sequence.prototype.removeBookmarkIconFromActiveNavItem.apply(self, [event]);
};
this.addBookmarkIconToActiveNavItem = function(event) {
return Sequence.prototype.addBookmarkIconToActiveNavItem.apply(self, [event]);
};
this._change_sequential = function(direction, event) {
return Sequence.prototype._change_sequential.apply(self, [direction, event]);
};
this.selectPrevious = function(event) {
return Sequence.prototype.selectPrevious.apply(self, [event]);
};
this.selectNext = function(event) {
return Sequence.prototype.selectNext.apply(self, [event]);
};
this.goto = function(event) {
return Sequence.prototype.goto.apply(self, [event]);
};
this.toggleArrows = function() {
return Sequence.prototype.toggleArrows.apply(self);
};
this.addToUpdatedProblems = function(problemId, newContentState, newState) {
return Sequence.prototype.addToUpdatedProblems.apply(self, [problemId, newContentState, newState]);
};
this.hideTabTooltip = function(event) {
return Sequence.prototype.hideTabTooltip.apply(self, [event]);
};
this.displayTabTooltip = function(event) {
return Sequence.prototype.displayTabTooltip.apply(self, [event]);
};
this.updatedProblems = {};
this.requestToken = $(element).data('request-token');
this.el = $(element).find('.sequence');
this.path = $('.path');
this.contents = this.$('.seq_contents');
this.content_container = this.$('#seq_content');
this.sr_container = this.$('.sr-is-focusable');
this.num_contents = this.contents.length;
this.id = this.el.data('id');
this.ajaxUrl = this.el.data('ajax-url');
this.nextUrl = this.el.data('next-url');
this.prevUrl = this.el.data('prev-url');
this.base_page_title = ' | ' + document.title;
this.bind();
this.render(parseInt(this.el.data('position'), 10));
}
Sequence.prototype.$ = function(selector) {
return $(selector, this.el);
};
Sequence.prototype.bind = function() {
this.$('#sequence-list .nav-item').click(this.goto);
this.el.on('bookmark:add', this.addBookmarkIconToActiveNavItem);
this.el.on('bookmark:remove', this.removeBookmarkIconFromActiveNavItem);
this.$('#sequence-list .nav-item').on('focus mouseenter', this.displayTabTooltip);
this.$('#sequence-list .nav-item').on('blur mouseleave', this.hideTabTooltip);
};
Sequence.prototype.displayTabTooltip = function(event) {
$(event.currentTarget).find('.sequence-tooltip').removeClass('sr');
};
Sequence.prototype.hideTabTooltip = function(event) {
$(event.currentTarget).find('.sequence-tooltip').addClass('sr');
};
Sequence.prototype.updatePageTitle = function() {
// update the page title to include the current section
var positionLink = this.link_for(this.position);
if (positionLink && positionLink.data('page-title')) {
document.title = positionLink.data('page-title') + this.base_page_title;
}
};
Sequence.prototype.hookUpContentStateChangeEvent = function() {
var self = this;
return $('.problems-wrapper').bind('contentChanged', function(event, problemId, newContentState, newState) {
return self.addToUpdatedProblems(problemId, newContentState, newState);
});
};
Sequence.prototype.addToUpdatedProblems = function(problemId, newContentState, newState) {
/**
* Used to keep updated problem's state temporarily.
* params:
* 'problem_id' is problem id.
* 'new_content_state' is the updated content of the problem.
* 'new_state' is the updated state of the problem.
*/
// initialize for the current sequence if there isn't any updated problem for this position.
if (!this.anyUpdatedProblems(this.position)) {
this.updatedProblems[this.position] = {};
}
// Now, put problem content and score against problem id for current active sequence.
this.updatedProblems[this.position][problemId] = [newContentState, newState];
};
Sequence.prototype.anyUpdatedProblems = function(position) {
/**
* check for the updated problems for given sequence position.
* params:
* 'position' can be any sequence position.
*/
return typeof(this.updatedProblems[position]) !== 'undefined';
};
Sequence.prototype.enableButton = function(buttonClass, buttonAction) {
this.$(buttonClass)
.removeClass('disabled')
.removeAttr('disabled')
.click(buttonAction);
};
Sequence.prototype.disableButton = function(buttonClass) {
this.$(buttonClass).addClass('disabled').attr('disabled', true);
};
Sequence.prototype.updateButtonState = function(buttonClass, buttonAction, isAtBoundary, boundaryUrl) {
if (isAtBoundary && boundaryUrl === 'None') {
this.disableButton(buttonClass);
} else {
this.enableButton(buttonClass, buttonAction);
}
};
Sequence.prototype.toggleArrows = function() {
var isFirstTab, isLastTab, nextButtonClass, previousButtonClass;
this.$('.sequence-nav-button').unbind('click');
// previous button
isFirstTab = this.position === 1;
previousButtonClass = '.sequence-nav-button.button-previous';
this.updateButtonState(previousButtonClass, this.selectPrevious, isFirstTab, this.prevUrl);
// next button
// use inequality in case contents.length is 0 and position is 1.
isLastTab = this.position >= this.contents.length;
nextButtonClass = '.sequence-nav-button.button-next';
this.updateButtonState(nextButtonClass, this.selectNext, isLastTab, this.nextUrl);
};
Sequence.prototype.render = function(newPosition) {
var bookmarked, currentTab, modxFullUrl, sequenceLinks,
self = this;
if (this.position !== newPosition) {
if (this.position) {
this.mark_visited(this.position);
modxFullUrl = '' + this.ajaxUrl + '/goto_position';
$.postWithPrefix(modxFullUrl, {
position: newPosition
});
}
// On Sequence change, fire custom event 'sequence:change' on element.
// Added for aborting video bufferization, see ../video/10_main.js
this.el.trigger('sequence:change');
this.mark_active(newPosition);
currentTab = this.contents.eq(newPosition - 1);
bookmarked = this.el.find('.active .bookmark-icon').hasClass('bookmarked');
// update the data-attributes with latest contents only for updated problems.
this.content_container
.html(currentTab.text())
.attr('aria-labelledby', currentTab.attr('aria-labelledby'))
.data('bookmarked', bookmarked);
if (this.anyUpdatedProblems(newPosition)) {
$.each(this.updatedProblems[newPosition], function(problemId, latestData) {
var latestContent, latestResponse;
latestContent = latestData[0];
latestResponse = latestData[1];
self.content_container
.find("[data-problem-id='" + problemId + "']")
.data('content', latestContent)
.data('problem-score', latestResponse.current_score)
.data('problem-total-possible', latestResponse.total_possible)
.data('attempts-used', latestResponse.attempts_used);
});
}
XBlock.initializeBlocks(this.content_container, this.requestToken);
// For embedded circuit simulator exercises in 6.002x
window.update_schematics();
this.position = newPosition;
this.toggleArrows();
this.hookUpContentStateChangeEvent();
this.updatePageTitle();
sequenceLinks = this.content_container.find('a.seqnav');
sequenceLinks.click(this.goto);
this.path.text(this.el.find('.nav-item.active').data('path'));
this.sr_container.focus();
}
};
Sequence.prototype.goto = function(event) {
var alertTemplate, alertText, isBottomNav, newPosition, widgetPlacement;
event.preventDefault();
// Links from courseware <a class='seqnav' href='n'>...</a>, was .target_tab
if ($(event.currentTarget).hasClass('seqnav')) {
newPosition = $(event.currentTarget).attr('href');
// Tab links generated by backend template
} else {
newPosition = $(event.currentTarget).data('element');
}
if ((newPosition >= 1) && (newPosition <= this.num_contents)) {
isBottomNav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0;
if (isBottomNav) {
widgetPlacement = 'bottom';
} else {
widgetPlacement = 'top';
}
// Formerly known as seq_goto
Logger.log('edx.ui.lms.sequence.tab_selected', {
current_tab: this.position,
target_tab: newPosition,
tab_count: this.num_contents,
id: this.id,
widget_placement: widgetPlacement
});
// On Sequence change, destroy any existing polling thread
// for queued submissions, see ../capa/display.js
if (window.queuePollerID) {
window.clearTimeout(window.queuePollerID);
delete window.queuePollerID;
}
this.render(newPosition);
} else {
alertTemplate = gettext('Sequence error! Cannot navigate to %(tab_name)s in the current SequenceModule. Please contact the course staff.'); // eslint-disable-line max-len
alertText = interpolate(alertTemplate, {
tab_name: newPosition
}, true);
alert(alertText); // eslint-disable-line no-alert
}
};
Sequence.prototype.selectNext = function(event) {
this._change_sequential('next', event);
};
Sequence.prototype.selectPrevious = function(event) {
this._change_sequential('previous', event);
};
// `direction` can be 'previous' or 'next'
Sequence.prototype._change_sequential = function(direction, event) {
var analyticsEventName, isBottomNav, newPosition, offset, widgetPlacement;
// silently abort if direction is invalid.
if (direction !== 'previous' && direction !== 'next') {
return;
}
event.preventDefault();
analyticsEventName = 'edx.ui.lms.sequence.' + direction + '_selected';
isBottomNav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0;
if (isBottomNav) {
widgetPlacement = 'bottom';
} else {
widgetPlacement = 'top';
}
// Formerly known as seq_next and seq_prev
Logger.log(analyticsEventName, {
id: this.id,
current_tab: this.position,
tab_count: this.num_contents,
widget_placement: widgetPlacement
});
if ((direction === 'next') && (this.position >= this.contents.length)) {
window.location.href = this.nextUrl;
} else if ((direction === 'previous') && (this.position === 1)) {
window.location.href = this.prevUrl;
} else {
// If the bottom nav is used, scroll to the top of the page on change.
if (isBottomNav) {
$.scrollTo(0, 150);
}
offset = {
next: 1,
previous: -1
};
newPosition = this.position + offset[direction];
this.render(newPosition);
}
};
Sequence.prototype.link_for = function(position) {
return this.$('#sequence-list .nav-item[data-element=' + position + ']');
};
Sequence.prototype.mark_visited = function(position) {
// Don't overwrite class attribute to avoid changing Progress class
var element = this.link_for(position);
element.removeClass('inactive').removeClass('active').addClass('visited');
};
Sequence.prototype.mark_active = function(position) {
// Don't overwrite class attribute to avoid changing Progress class
var element = this.link_for(position);
element.removeClass('inactive').removeClass('visited').addClass('active');
};
Sequence.prototype.addBookmarkIconToActiveNavItem = function(event) {
event.preventDefault();
this.el.find('.nav-item.active .bookmark-icon').removeClass('is-hidden').addClass('bookmarked');
this.el.find('.nav-item.active .bookmark-icon-sr').text(gettext('Bookmarked'));
};
Sequence.prototype.removeBookmarkIconFromActiveNavItem = function(event) {
event.preventDefault();
this.el.find('.nav-item.active .bookmark-icon').removeClass('bookmarked').addClass('is-hidden');
this.el.find('.nav-item.active .bookmark-icon-sr').text('');
};
return Sequence;
}());
}).call(this);
var SequenceNav = function($element) {
var _this = this;
var $element = $element;
var $wrapper = $element.find('.sequence-list-wrapper');
var $list = $element.find('#sequence-list');
var $arrows = $element.find('.sequence-nav-button');
var maxScroll = $list.width() - $wrapper.width();
var $body = $('body');
var listOrigin;
var mouseOrigin;
var startDrag = function(e) {
updateWidths();
mouseOrigin = e.pageX;
listOrigin = $list.position().left;
$body.css('-webkit-user-select', 'none');
$body.bind('mousemove', moveDrag);
$body.bind('mouseup', stopDrag);
};
var moveDrag = function(e) {
var offset = e.pageX - mouseOrigin;
var targetLeft = clamp(listOrigin + offset, -maxScroll, 0);
updateHorizontalPosition(targetLeft);
};
var stopDrag = function(e) {
$body.css('-webkit-user-select', 'auto');
$body.unbind('mousemove', moveDrag);
$body.unbind('mouseup', stopDrag);
};
var clamp = function(val, min, max) {
if(val > max) return max;
if(val < min) return min;
return val;
};
var updateWidths = function(e) {
maxScroll = $list.width() - $wrapper.width();
var targetLeft = clamp($list.position().left, -maxScroll, 0);
updateHorizontalPosition(targetLeft);
};
var updateHorizontalPosition = function(left) {
$list.css({
'left': left + 'px'
});
};
var checkPosition = function(e) {
var $active = $element.find('.active');
if(!$active[0]) {
return;
}
if($active.position().left + $active.width() > $wrapper.width() - $list.position().left) {
$list.animate({
'left': (-$active.position().left + $wrapper.width() - $active.width() - 10) + 'px'
}, {});
} else if($active.position().left < -$list.position().left) {
$list.animate({
'left': (-$active.position().left + 10) + 'px'
}, {});
}
};
$wrapper.bind('mousedown', startDrag);
$arrows.bind('click', checkPosition);
$(window).bind('resize', updateWidths);
setTimeout(function() {
checkPosition();
}, 200);
};
...@@ -161,8 +161,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule): ...@@ -161,8 +161,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
Layout module which lays out content in a temporal sequence Layout module which lays out content in a temporal sequence
""" """
js = { js = {
'coffee': [resource_string(__name__, 'js/src/sequence/display.coffee')], 'js': [resource_string(__name__, 'js/src/sequence/display.js')],
'js': [resource_string(__name__, 'js/src/sequence/display/jquery.sequence.js')],
} }
css = { css = {
'scss': [resource_string(__name__, 'css/sequence/display.scss')], 'scss': [resource_string(__name__, 'css/sequence/display.scss')],
......
...@@ -64,10 +64,3 @@ ...@@ -64,10 +64,3 @@
</button> </button>
</nav> </nav>
</div> </div>
<script type="text/javascript">
var sequenceNav;
$(document).ready(function() {
sequenceNav = new SequenceNav($('.sequence-nav'));
});
</script>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment