Commit 3d09290c by Diana Huang Committed by Andy Armstrong

Add a dismiss button to welcome message.

Store user's preference indefinitely.
parent 72fb9722
define ["js/models/uploads", "js/views/uploads", "js/models/chapter",
define ["sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
"edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "js/spec_helpers/modal_helpers"],
(FileUpload, UploadDialog, Chapter, AjaxHelpers, modal_helpers) ->
(sinon, FileUpload, UploadDialog, Chapter, AjaxHelpers, modal_helpers) ->
describe "UploadDialog", ->
tpl = readFixtures("upload-dialog.underscore")
......
define(['js/utils/drag_and_drop', 'common/js/components/views/feedback_notification',
define(['sinon', 'js/utils/drag_and_drop', 'common/js/components/views/feedback_notification',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'jquery', 'underscore'],
function(ContentDragger, Notification, AjaxHelpers, $, _) {
function(sinon, ContentDragger, Notification, AjaxHelpers, $, _) {
'use strict';
describe('Overview drag and drop functionality', function() {
beforeEach(function() {
setFixtures(readFixtures('mock/mock-outline.underscore'));
......
/* global define, sinon */
/* global define */
define([
'jquery',
'sinon',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/certificates/models/certificate_exception',
'js/certificates/views/certificate_whitelist',
'js/certificates/views/certificate_whitelist_editor',
'js/certificates/collections/certificate_whitelist'
],
function($, AjaxHelpers, CertificateExceptionModel, CertificateWhiteListView, CertificateWhiteListEditorView,
function($, sinon, AjaxHelpers, CertificateExceptionModel, CertificateWhiteListView, CertificateWhiteListEditorView,
CertificateWhiteListCollection) {
'use strict';
describe('edx.certificates.models.certificates_exception.CertificateExceptionModel', function() {
var certificate_exception = null;
var certificateException = null;
var assertValid = function(fields, isValid, expectedErrors) {
certificate_exception.set(fields);
var errors = certificate_exception.validate(certificate_exception.attributes);
var errors;
certificateException.set(fields);
errors = certificateException.validate(certificateException.attributes);
if (isValid) {
expect(errors).toBe(undefined);
......@@ -29,8 +32,8 @@ define([
};
beforeEach(function() {
certificate_exception = new CertificateExceptionModel({user_name: 'test_user'}, {url: 'test/url/'});
certificate_exception.set({
certificateException = new CertificateExceptionModel({user_name: 'test_user'}, {url: 'test/url/'});
certificateException.set({
notes: 'Test notes'
});
});
......@@ -50,9 +53,9 @@ define([
});
describe('edx.certificates.collections.certificate_whitelist.CertificateWhiteList', function() {
var certificate_white_list = null,
certificate_exception_url = 'test/url/';
var certificates_exceptions_json = [
var certificateWhiteList = null,
certificateExceptionUrl = 'test/url/';
var certificatesExceptionsJson = [
{
id: 1,
user_id: 1,
......@@ -74,38 +77,48 @@ define([
];
beforeEach(function() {
certificate_white_list = new CertificateWhiteListCollection(certificates_exceptions_json, {
certificateWhiteList = new CertificateWhiteListCollection(certificatesExceptionsJson, {
parse: true,
canBeEmpty: true,
url: certificate_exception_url,
generate_certificates_url: certificate_exception_url
url: certificateExceptionUrl,
generate_certificates_url: certificateExceptionUrl
});
});
it('has 2 models in the collection after initialization', function() {
expect(certificate_white_list.models.length).toEqual(2);
expect(certificateWhiteList.models.length).toEqual(2);
});
it("returns correct model on getModel call and 'undefined' if queried model is not present", function() {
expect(certificate_white_list.getModel({user_name: 'test1'})).not.toBe(undefined);
expect(certificate_white_list.getModel({user_name: 'test_invalid_user'})).toBe(undefined);
expect(certificateWhiteList.getModel({user_name: 'test1'})).not.toBe(undefined);
expect(certificateWhiteList.getModel({user_name: 'test_invalid_user'})).toBe(undefined);
expect(certificate_white_list.getModel({user_email: 'test1@test.com'})).not.toBe(undefined);
expect(certificate_white_list.getModel({user_email: 'test_invalid_user@test.com'})).toBe(undefined);
expect(certificateWhiteList.getModel({user_email: 'test1@test.com'})).not.toBe(undefined);
expect(certificateWhiteList.getModel({user_email: 'test_invalid_user@test.com'})).toBe(undefined);
expect(certificate_white_list.getModel({user_name: 'test1'}).attributes).toEqual(
expect(certificateWhiteList.getModel({user_name: 'test1'}).attributes).toEqual(
{
id: 1, user_id: 1, user_name: 'test1', user_email: 'test1@test.com',
course_id: 'edX/test/course', created: 'Thursday, October 29, 2015',
notes: 'test notes for test certificate exception', certificate_generated: ''
id: 1,
user_id: 1,
user_name: 'test1',
user_email: 'test1@test.com',
course_id: 'edX/test/course',
created: 'Thursday, October 29, 2015',
notes: 'test notes for test certificate exception',
certificate_generated: ''
}
);
expect(certificate_white_list.getModel({user_email: 'test2@test.com'}).attributes).toEqual(
expect(certificateWhiteList.getModel({user_email: 'test2@test.com'}).attributes).toEqual(
{
id: 2, user_id: 2, user_name: 'test2', user_email: 'test2@test.com',
course_id: 'edX/test/course', created: 'Thursday, October 29, 2015',
notes: 'test notes for test certificate exception', certificate_generated: ''
id: 2,
user_id: 2,
user_name: 'test2',
user_email: 'test2@test.com',
course_id: 'edX/test/course',
created: 'Thursday, October 29, 2015',
notes: 'test notes for test certificate exception',
certificate_generated: ''
}
);
});
......@@ -114,13 +127,13 @@ define([
var successCallback = sinon.spy(),
errorCallback = sinon.spy(),
requests = AjaxHelpers.requests(this),
add_students = 'all';
addStudents = 'all';
var expected = {
url: certificate_exception_url + add_students,
url: certificateExceptionUrl + addStudents,
postData: []
};
certificate_white_list.sync({success: successCallback, error: errorCallback}, add_students);
certificateWhiteList.sync({success: successCallback, error: errorCallback}, addStudents);
AjaxHelpers.expectJsonRequest(requests, 'POST', expected.url, expected.postData);
});
......@@ -128,21 +141,22 @@ define([
var successCallback = sinon.spy(),
errorCallback = sinon.spy(),
requests = AjaxHelpers.requests(this),
add_students = 'new';
addStudents = 'new',
expected;
certificate_white_list.add({user_name: 'test3', notes: 'test3 notes', new: true});
certificate_white_list.sync({success: successCallback, error: errorCallback}, add_students);
certificateWhiteList.add({user_name: 'test3', notes: 'test3 notes', new: true});
certificateWhiteList.sync({success: successCallback, error: errorCallback}, addStudents);
var expected = {
url: certificate_exception_url + add_students,
expected = {
url: certificateExceptionUrl + addStudents,
postData: [
{user_id: '',
user_name: 'test3',
user_email: '',
created: '',
notes: 'test3 notes',
certificate_generated: '',
new: true}
user_name: 'test3',
user_email: '',
created: '',
notes: 'test3 notes',
certificate_generated: '',
new: true}
]
};
AjaxHelpers.expectJsonRequest(requests, 'POST', expected.url, expected.postData);
......@@ -151,9 +165,9 @@ define([
describe('edx.certificates.views.certificate_whitelist.CertificateWhiteListView', function() {
var view = null,
certificate_exception_url = 'test/url/';
certificateExceptionUrl = 'test/url/';
var certificates_exceptions_json = [
var certificatesExceptionsJson = [
{
id: 1,
user_id: 1,
......@@ -175,17 +189,17 @@ define([
];
beforeEach(function() {
var fixture;
setFixtures();
var fixture =
readFixtures('templates/instructor/instructor_dashboard_2/certificate-white-list.underscore');
fixture = readFixtures('templates/instructor/instructor_dashboard_2/certificate-white-list.underscore');
setFixtures("<script type='text/template' id='certificate-white-list-tpl'>" + fixture + '</script>' +
"<div class='white-listed-students' id='white-listed-students'></div>");
this.certificate_white_list = new CertificateWhiteListCollection(certificates_exceptions_json, {
this.certificate_white_list = new CertificateWhiteListCollection(certificatesExceptionsJson, {
parse: true,
canBeEmpty: true,
url: certificate_exception_url,
generate_certificates_url: certificate_exception_url
url: certificateExceptionUrl,
generate_certificates_url: certificateExceptionUrl
});
......@@ -231,10 +245,10 @@ define([
{user_name: user, notes: notes, user_email: email}
]);
expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()).
toMatch(notes);
expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()).
toMatch(email);
expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html())
.toMatch(notes);
expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html())
.toMatch(email);
});
it('verifies collection sync is called when "Generate Exception Certificates" is clicked', function() {
......@@ -248,8 +262,8 @@ define([
view.$el.find('#generate-exception-certificates').click();
expect(view.collection.sync.called).toBe(true);
expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback})).
toBe(true);
expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}))
.toBe(true);
});
it('verifies sync is called with "new/all" argument depending upon selected radio button', function() {
......@@ -263,24 +277,24 @@ define([
view.$el.find('#generate-exception-certificates').click();
// By default 'Generate a Certificate for all New additions to the Exception list ' is selected
expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}), 'new').
toBe(true);
expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}), 'new')
.toBe(true);
// Select 'Generate a Certificate for all users on the Exception list ' option
view.$el.find('input:radio[name=generate-exception-certificates-radio][value=all]').click();
view.$el.find('#generate-exception-certificates').click();
expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}), 'all').
toBe(true);
expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}), 'all')
.toBe(true);
});
});
describe('edx.certificates.views.certificate_whitelist_editor.CertificateWhiteListEditorView', function() {
var view = null,
list_view = null,
certificate_exception_url = 'test/url/';
var certificates_exceptions_json = [
listView = null,
certificateExceptionUrl = 'test/url/';
var certificatesExceptionsJson = [
{
url: certificate_exception_url,
url: certificateExceptionUrl,
id: 1,
user_id: 1,
user_name: 'test1',
......@@ -291,7 +305,7 @@ define([
new: true
},
{
url: certificate_exception_url,
url: certificateExceptionUrl,
id: 2,
user_id: 2,
user_name: 'test2',
......@@ -303,41 +317,42 @@ define([
];
beforeEach(function() {
var fixture, fixture2, certificateWhiteList;
setFixtures();
var fixture = readFixtures(
fixture = readFixtures(
'templates/instructor/instructor_dashboard_2/certificate-white-list-editor.underscore'
);
var fixture_2 = readFixtures(
fixture2 = readFixtures(
'templates/instructor/instructor_dashboard_2/certificate-white-list.underscore'
);
setFixtures(
"<script type='text/template' id='certificate-white-list-editor-tpl'>" + fixture + '</script>' +
"<script type='text/template' id='certificate-white-list-tpl'>" + fixture_2 + '</script>' +
"<script type='text/template' id='certificate-white-list-tpl'>" + fixture2 + '</script>' +
"<div id='certificate-white-list-editor'></div>" +
"<div class='white-listed-students' id='white-listed-students'></div>"
);
var certificate_white_list = new CertificateWhiteListCollection(certificates_exceptions_json, {
certificateWhiteList = new CertificateWhiteListCollection(certificatesExceptionsJson, {
parse: true,
canBeEmpty: true,
url: certificate_exception_url,
generate_certificates_url: certificate_exception_url
url: certificateExceptionUrl,
generate_certificates_url: certificateExceptionUrl
});
view = new CertificateWhiteListEditorView({
collection: certificate_white_list,
url: certificate_exception_url
collection: certificateWhiteList,
url: certificateExceptionUrl
});
view.render();
list_view = new CertificateWhiteListView({
collection: certificate_white_list,
listView = new CertificateWhiteListView({
collection: certificateWhiteList,
certificateWhiteListEditorView: view
});
list_view.render();
listView.render();
});
it('verifies view is initialized and rendered successfully', function() {
......@@ -348,16 +363,16 @@ define([
});
it('verifies success and error messages', function() {
var message_selector = '.message',
success_message = 'test_user has been successfully added to the exception list. Click Generate' +
var messageSelector = '.message',
successMessage = 'test_user has been successfully added to the exception list. Click Generate' +
' Exception Certificate below to send the certificate.',
requests = AjaxHelpers.requests(this),
duplicate_user = 'test_user';
duplicateUser = 'test_user';
var error_messages = {
var errorMessages = {
empty_user_name_email: 'Student username/email field is required and can not be empty. ' +
'Kindly fill in username/email and then press "Add to Exception List" button.',
duplicate_user: '<p>' + (duplicate_user) + ' already in exception list.</p>'
duplicate_user: '<p>' + (duplicateUser) + ' already in exception list.</p>'
};
// click 'Add Exception' button with empty username/email field
......@@ -365,10 +380,10 @@ define([
view.$el.find('#add-exception').click();
// Verify error message for missing username/email
expect(view.$el.find(message_selector).html()).toMatch(error_messages.empty_user_name_email);
expect(view.$el.find(messageSelector).html()).toMatch(errorMessages.empty_user_name_email);
// Add a new Exception to list
view.$el.find('#certificate-exception').val(duplicate_user);
view.$el.find('#certificate-exception').val(duplicateUser);
view.$el.find('#notes').val('test user notes');
view.$el.find('#add-exception').click();
......@@ -377,7 +392,7 @@ define([
{
id: 3,
user_id: 3,
user_name: duplicate_user,
user_name: duplicateUser,
user_email: 'test2@test.com',
course_id: 'edX/test/course',
created: 'Thursday, October 29, 2015',
......@@ -386,29 +401,29 @@ define([
);
// Verify success message
expect(view.$el.find(message_selector).html()).toMatch(success_message);
expect(view.$el.find(messageSelector).html()).toMatch(successMessage);
// Add a duplicate Certificate Exception
view.$el.find('#certificate-exception').val(duplicate_user);
view.$el.find('#certificate-exception').val(duplicateUser);
view.$el.find('#notes').val('test user notes');
view.$el.find('#add-exception').click();
// Verify success message
expect(view.$el.find(message_selector).html()).toEqual(error_messages.duplicate_user);
expect(view.$el.find(messageSelector).html()).toEqual(errorMessages.duplicate_user);
});
it('verifies certificate exception can be deleted by clicking "delete" ', function() {
var user_name = 'test1',
certificate_exception_selector = "div.white-listed-students table tr:contains('" + user_name + "')",
delete_btn_selector =
certificate_exception_selector + ' td .delete-exception',
var username = 'test1',
certificateExceptionSelector = "div.white-listed-students table tr:contains('" + username + "')",
deleteBtnSelector =
certificateExceptionSelector + ' td .delete-exception',
requests = AjaxHelpers.requests(this);
$(delete_btn_selector).click();
$(deleteBtnSelector).click();
AjaxHelpers.respondWithJson(requests, {});
// Verify the certificate exception is removed from the list
expect($(certificate_exception_selector).length).toBe(0);
expect($(certificateExceptionSelector).length).toBe(0);
});
});
}
......
......@@ -10,6 +10,10 @@
font-weight: $font-bold;
color: $black;
}
.dismiss-message {
@include float(right);
}
}
// Course sidebar
......
(function(define) {
'use strict';
define(['gettext', 'jquery', 'underscore', 'backbone', 'js/views/message_banner'],
function(gettext, $, _, Backbone, MessageBannerView) {
return Backbone.View.extend({
......
(function(define) {
'use strict';
define([
'gettext', 'jquery', 'underscore', 'backbone', 'logger', 'moment', 'edx-ui-toolkit/js/utils/html-utils',
'common/js/components/views/paging_header', 'common/js/components/views/paging_footer',
......
<div class="welcome-message">
<div class="dismiss-message">
<button type="button" class="btn-link">${_("Dismiss")}</button>
</div>
This is a useful welcome message!
</div>
/* globals $ */
import 'jquery.cookie';
export class WelcomeMessage { // eslint-disable-line import/prefer-default-export
constructor(dismissUrl) {
$('.dismiss-message button').click(() => {
$.ajax({
type: 'POST',
url: dismissUrl,
headers: {
'X-CSRFToken': $.cookie('csrftoken'),
},
success: () => {
$('.welcome-message').hide();
},
});
});
}
}
/* globals $, loadFixtures */
import {
expectRequest,
requests as mockRequests,
respondWithJson,
} from 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers';
import { WelcomeMessage } from '../WelcomeMessage';
describe('Welcome Message factory', () => {
describe('Ensure button click', () => {
const endpointUrl = '/course/course_id/dismiss_message/';
beforeEach(() => {
loadFixtures('course_experience/fixtures/welcome-message-fragment.html');
new WelcomeMessage(endpointUrl); // eslint-disable-line no-new
});
it('When button click is made, ajax call is made and message is hidden.', () => {
const $message = $('.welcome-message');
const requests = mockRequests(this);
document.querySelector('.dismiss-message button').dispatchEvent(new Event('click'));
expectRequest(
requests,
'POST',
endpointUrl,
);
respondWithJson(requests);
expect($message.attr('style')).toBe('display: none;');
requests.restore();
});
});
});
......@@ -4,11 +4,21 @@
<%namespace name='static' file='../static_content.html'/>
<%!
from openedx.core.djangolib.js_utils import js_escaped_string
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML
%>
<%block name="content">
<div class="welcome-message">
<div class="dismiss-message">
<button type="button" class="btn-link">${_("Dismiss")}</button>
</div>
${HTML(welcome_message_html)}
</div>
</%block>
<%static:webpack entry="WelcomeMessage">
new WelcomeMessage("${dismiss_url | n, js_escaped_string}");
</%static:webpack>
......@@ -89,7 +89,7 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(48):
with self.assertNumQueries(49):
with check_mongo_calls(5):
url = course_home_url(self.course)
self.client.get(url)
......@@ -27,6 +27,18 @@ def welcome_message_url(course):
)
def dismiss_message_url(course):
"""
Returns the URL for the dismiss message endpoint.
"""
return reverse(
'openedx.course_experience.dismiss_welcome_message',
kwargs={
'course_id': unicode(course.id),
}
)
class TestWelcomeMessageView(ModuleStoreTestCase):
"""
Tests for the course welcome message fragment view.
......@@ -41,10 +53,8 @@ class TestWelcomeMessageView(ModuleStoreTestCase):
chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
section = ItemFactory.create(category='sequential', parent_location=chapter.location)
ItemFactory.create(category='vertical', parent_location=section.location)
self.user = UserFactory(password=TEST_PASSWORD)
CourseEnrollment.enroll(self.user, self.course.id)
self.client.login(username=self.user.username, password=TEST_PASSWORD)
def tearDown(self):
......@@ -58,6 +68,7 @@ class TestWelcomeMessageView(ModuleStoreTestCase):
response = self.client.get(welcome_message_url(self.course))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Second Update')
self.assertContains(response, 'Dismiss')
def test_replace_urls(self):
img_url = 'img.png'
......@@ -72,3 +83,15 @@ class TestWelcomeMessageView(ModuleStoreTestCase):
def test_empty_welcome_message(self):
response = self.client.get(welcome_message_url(self.course))
self.assertEqual(response.status_code, 204)
def test_dismiss_message(self):
create_course_update(self.course, self.user, 'First Update', date='January 1, 2017')
response = self.client.get(welcome_message_url(self.course))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'First Update')
self.client.post(dismiss_message_url(self.course))
response = self.client.get(welcome_message_url(self.course))
self.assertNotIn('First Update', response)
self.assertEqual(response.status_code, 204)
......@@ -7,8 +7,8 @@ from django.conf.urls import url
from views.course_home import CourseHomeFragmentView, CourseHomeView
from views.course_outline import CourseOutlineFragmentView
from views.course_updates import CourseUpdatesFragmentView, CourseUpdatesView
from views.welcome_message import WelcomeMessageFragmentView
from views.course_sock import CourseSockFragmentView
from views.welcome_message import WelcomeMessageFragmentView, dismiss_welcome_message
urlpatterns = [
url(
......@@ -46,4 +46,9 @@ urlpatterns = [
CourseSockFragmentView.as_view(),
name='openedx.course_experience.course_sock_fragment_view',
),
url(
r'^dismiss_welcome_message$',
dismiss_welcome_message,
name='openedx.course_experience.dismiss_welcome_message',
),
]
......@@ -2,13 +2,19 @@
View logic for handling course welcome messages.
"""
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseBadRequest
from django.template.loader import render_to_string
from django.views.decorators.csrf import ensure_csrf_cookie
from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment
from course_updates import CourseUpdatesFragmentView
from courseware.courses import get_course_info_section_module, get_course_with_access
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
from openedx.core.djangoapps.user_api.course_tag.api import set_course_tag, get_course_tag
PREFERENCE_KEY = 'view-welcome-message'
class WelcomeMessageFragmentView(EdxFragmentView):
......@@ -27,12 +33,20 @@ class WelcomeMessageFragmentView(EdxFragmentView):
if not welcome_message_html:
return None
dismiss_url = reverse(
'openedx.course_experience.dismiss_welcome_message', kwargs={'course_id': unicode(course_key)}
)
context = {
'dismiss_url': dismiss_url,
'welcome_message_html': welcome_message_html,
}
html = render_to_string('course_experience/welcome-message-fragment.html', context)
return Fragment(html)
if get_course_tag(request.user, course_key, PREFERENCE_KEY) == 'False':
return None
else:
html = render_to_string('course_experience/welcome-message-fragment.html', context)
return Fragment(html)
@classmethod
def welcome_message_html(cls, request, course):
......@@ -51,3 +65,13 @@ class WelcomeMessageFragmentView(EdxFragmentView):
content = info_block.system.replace_urls(ordered_updates[0]['content'])
return content
@ensure_csrf_cookie
def dismiss_welcome_message(request, course_id):
"""
Given the course_id in the request, disable displaying the welcome message for the user.
"""
course_key = CourseKey.from_string(course_id)
set_course_tag(request.user, course_key, PREFERENCE_KEY, 'False')
return HttpResponse()
......@@ -34,7 +34,7 @@
"eslint-config-edx": "^2.0.1",
"eslint-config-edx-es5": "^2.0.0",
"eslint-import-resolver-webpack": "^0.8.1",
"jasmine-core": "^2.4.1",
"jasmine-core": "^2.6.4",
"jasmine-jquery": "^2.1.1",
"karma": "^0.13.22",
"karma-chrome-launcher": "^0.2.3",
......@@ -50,7 +50,7 @@
"pa11y": "4.0.1",
"pa11y-reporter-json-oldnode": "1.0.0",
"plato": "1.2.2",
"sinon": "^1.17.7",
"sinon": "2.3.5",
"squirejs": "^0.1.0"
}
}
......@@ -20,6 +20,7 @@ var wpconfig = {
entry: {
CourseOutline: './openedx/features/course_experience/static/course_experience/js/CourseOutline.js',
CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',
WelcomeMessage: './openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js',
Import: './cms/static/js/features/import/factories/import.js'
},
......
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