diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index ca89545ec0..c37fb1bc9f 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -142,5 +142,6 @@ def _section_analytics(course_id): 'section_key': 'analytics', 'section_display_name': 'Analytics', 'get_distribution_url': reverse('get_distribution', kwargs={'course_id': course_id}), + 'proxy_legacy_analytics_url': reverse('proxy_legacy_analytics', kwargs={'course_id': course_id}), } return section_data diff --git a/lms/static/coffee/src/instructor_dashboard/analytics.coffee b/lms/static/coffee/src/instructor_dashboard/analytics.coffee index d6e1ffdd3e..3229f51899 100644 --- a/lms/static/coffee/src/instructor_dashboard/analytics.coffee +++ b/lms/static/coffee/src/instructor_dashboard/analytics.coffee @@ -6,60 +6,32 @@ plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments -# Analytics Section -class Analytics - constructor: (@$section) -> - @$section.data 'wrapper', @ - # gather elements - @$display = @$section.find '.distribution-display' - @$display_text = @$display.find '.distribution-display-text' - @$display_graph = @$display.find '.distribution-display-graph' - @$display_table = @$display.find '.distribution-display-table' - @$distribution_select = @$section.find 'select#distributions' - @$request_response_error = @$display.find '.request-response-error' - - @populate_selector => @$distribution_select.change => @on_selector_change() +class ProfileDistributionWidget + constructor: ({@$container, @feature, title, @endpoint}) -> + # render template + template_params = + title: title + feature: @feature + endpoint: @endpoint + template_html = $("#profile-distribution-widget-template").html() + @$container.html Mustache.render template_html, template_params reset_display: -> - @$display_text.empty() - @$display_graph.empty() - @$display_table.empty() - @$request_response_error.empty() + @$container.find('.display-errors').empty() + @$container.find('.display-text').empty() + @$container.find('.display-graph').empty() + @$container.find('.display-table').empty() - # fetch and list available distributions - # `cb` is a callback to be run after - populate_selector: (cb) -> - # ask for no particular distribution to get list of available distribuitions. - @get_profile_distributions undefined, - # on error, print to console and dom. - error: std_ajax_err => @$request_response_error.text "Error getting available distributions." - success: (data) => - # replace loading text in drop-down with "-- Select Distribution --" - @$distribution_select.find('option').eq(0).text "-- Select Distribution --" - - # add all fetched available features to drop-down - for feature in data.available_features - opt = $ '', - text: data.feature_display_names[feature] - data: - feature: feature - - @$distribution_select.append opt - - # call callback if one was supplied - cb?() + show_error: (msg) -> + @$container.find('.display-errors').text msg # display data - on_selector_change: -> - opt = @$distribution_select.children('option:selected') - feature = opt.data 'feature' - + load: -> @reset_display() - # only proceed if there is a feature attached to the selected option. - return unless feature - @get_profile_distributions feature, - error: std_ajax_err => @$request_response_error.text "Error getting distribution for '#{feature}'." + + @get_profile_distributions @feature, + error: std_ajax_err => @show_error "Error fetching distribution." success: (data) => feature_res = data.feature_results if feature_res.type is 'EASY_CHOICE' @@ -70,9 +42,9 @@ class Analytics forceFitColumns: true columns = [ - id: feature - field: feature - name: feature + id: @feature + field: @feature + name: data.feature_display_names[@feature] , id: 'count' field: 'count' @@ -81,16 +53,16 @@ class Analytics grid_data = _.map feature_res.data, (value, key) -> datapoint = {} - datapoint[feature] = feature_res.choices_display_names[key] + datapoint[@feature] = feature_res.choices_display_names[key] datapoint['count'] = value datapoint table_placeholder = $ '
', class: 'slickgrid' - @$display_table.append table_placeholder + @$container.find('.display-table').append table_placeholder grid = new Slick.Grid(table_placeholder, grid_data, columns, options) else if feature_res.feature is 'year_of_birth' - graph_placeholder = $ '', class: 'year-of-birth' - @$display_graph.append graph_placeholder + graph_placeholder = $ '', class: 'graph-placeholder' + @$container.find('.display-graph').append graph_placeholder graph_data = _.map feature_res.data, (value, key) -> [parseInt(key), value] @@ -99,7 +71,7 @@ class Analytics ] else console.warn("unable to show distribution #{feature_res.type}") - @$display_text.text 'Unavailable Metric Display\n' + JSON.stringify(feature_res) + @show_error 'Unavailable metric display.' # fetch distribution data from server. # `handler` can be either a callback for success @@ -107,7 +79,7 @@ class Analytics get_profile_distributions: (feature, handler) -> settings = dataType: 'json' - url: @$distribution_select.data 'endpoint' + url: @endpoint data: feature: feature if typeof handler is 'function' @@ -117,13 +89,138 @@ class Analytics $.ajax settings - # slickgrid's layout collapses when rendered - # in an invisible div. use this method to reload - # the AuthList widget - refresh: -> - @on_selector_change() - # handler for when the section title is clicked. +class GradeDistributionDisplay + constructor: ({@$container, @endpoint}) -> + template_params = {} + template_html = $('#grade-distributions-widget-template').html() + @$container.html Mustache.render template_html, template_params + @$problem_selector = @$container.find '.problem-selector' + + reset_display: -> + @$container.find('.display-errors').empty() + @$container.find('.display-text').empty() + @$container.find('.display-graph').empty() + + show_error: (msg) -> + @$container.find('.display-errors').text msg + + load: -> + @get_grade_distributions + error: std_ajax_err => @show_error "Error fetching grade distributions." + success: (data) => + @$container.find('.last-updated').text "Last Updated: #{data.time}" + + # populate selector + @$problem_selector.empty() + for {module_id, grade_info} in data.data + I4X_PROBLEM = /i4x:\/\/.*\/.*\/problem\/(.*)/ + label = (I4X_PROBLEM.exec module_id)?[1] + label ?= module_id + + @$problem_selector.append $ '', + text: label + data: + module_id: module_id + grade_info: grade_info + + @$problem_selector.change => + $opt = @$problem_selector.children('option:selected') + return unless $opt.length > 0 + @reset_display() + @render_distribution + module_id: $opt.data 'module_id' + grade_info: $opt.data 'grade_info' + + # one-time first selection of first list item. + @$problem_selector.change() + + render_distribution: ({module_id, grade_info}) -> + $display_graph = @$container.find('.display-graph') + + graph_data = grade_info.map ({grade, max_grade, num_students}) -> [grade, num_students] + total_students = _.reduce ([0].concat grade_info), + (accum, {grade, max_grade, num_students}) -> accum + num_students + + # show total students + @$container.find('.display-text').text "#{total_students} students scored." + + # render to graph + graph_placeholder = $ '', class: 'graph-placeholder' + $display_graph.append graph_placeholder + + graph_data = graph_data + + $.plot graph_placeholder, [ + data: graph_data + bars: show: true + color: '#1d9dd9' + ] + + + # `handler` can be either a callback for success + # or a mapping e.g. {success: ->, error: ->, complete: ->} + # + # the data passed to the success handler takes this form: + # { + # "aname": "ProblemGradeDistribution", + # "time": "2013-07-31T20:25:56+00:00", + # "course_id": "MITx/6.002x/2013_Spring", + # "options": { + # "course_id": "MITx/6.002x/2013_Spring", + # "_id": "6fudge2b49somedbid1e1", + # "data": [ + # { + # "module_id": "i4x://MITx/6.002x/problem/Capacitors_and_Energy_Storage", + # "grade_info": [ + # { + # "grade": 0.0, + # "max_grade": 100.0, + # "num_students": 3 + # }, ... for each grade number between 0 and max_grade + # ], + # } + get_grade_distributions: (handler) -> + settings = + dataType: 'json' + url: @endpoint + data: aname: 'ProblemGradeDistribution' + + if typeof handler is 'function' + _.extend settings, success: handler + else + _.extend settings, handler + + $.ajax settings + + +# Analytics Section +class Analytics + constructor: (@$section) -> + @$section.data 'wrapper', @ + + @$pd_containers = @$section.find '.profile-distribution-widget-container' + @$gd_containers = @$section.find '.grade-distributions-widget-container' + + @pdws = _.map (@$pd_containers), (container) => + new ProfileDistributionWidget + $container: $(container) + feature: $(container).data 'feature' + title: $(container).data 'title' + endpoint: $(container).data 'endpoint' + + @gdws = _.map (@$gd_containers), (container) => + new GradeDistributionDisplay + $container: $(container) + endpoint: $(container).data 'endpoint' + + refresh: -> + for pdw in @pdws + pdw.load() + + for gdw in @gdws + gdw.load() + onClickTitle: -> @refresh() diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee index 91b31cf221..5bc312a529 100644 --- a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee @@ -33,6 +33,46 @@ CSS_INSTRUCTOR_NAV = 'instructor-nav' # prefix for deep-linking HASH_LINK_PREFIX = '#view-' + +# helper class for queueing and fault isolation. +# Will execute functions marked by waiter.after only after all functions marked by +# waiter.waitFor have been called. +class SafeWaiter + constructor: -> + @after_handlers = [] + @waitFor_handlers = [] + @fired = false + + after: (f) -> + if @fired + f() + else + @after_handlers.push f + + waitFor: (f) -> + return if @fired + @waitFor_handlers.push f + + # wrap the function so that it notifies the waiter + # and can fire the after handlers. + => + @waitFor_handlers = @waitFor_handlers.filter (g) -> g isnt f + if @waitFor_handlers.length is 0 + plantTimeout 0, => + @fired = true + for cb in @after_handlers + cb() + + f.apply this, arguments + + +# waiter for dashboard sections. +# Will only execute after all sections have at least attempted to load. +# This is here to facilitate section constructors isolated by setTimeout +# while still being able to interact with them under the guarantee +# that the sections will be initialized at call time. +sections_have_loaded = new SafeWaiter + # once we're ready, check if this page is the instructor dashboard $ => instructor_dashboard_content = $ ".#{CSS_INSTRUCTOR_CONTENT}" @@ -45,9 +85,9 @@ $ => # handles hiding and showing sections setup_instructor_dashboard = (idash_content) => # clickable section titles - links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a') + $links = idash_content.find(".#{CSS_INSTRUCTOR_NAV}").find('a') - for link in ($ link for link in links) + for link in ($ link for link in $links) link.click (e) -> e.preventDefault() @@ -70,24 +110,24 @@ setup_instructor_dashboard = (idash_content) => # write to url location.hash = "#{HASH_LINK_PREFIX}#{section_name}" - plantTimeout 0, -> section.data('wrapper')?.onClickTitle?() - # plantTimeout 0, -> section.data('wrapper')?.onExit?() + sections_have_loaded.after -> + section.data('wrapper')?.onClickTitle?() + + # TODO enable onExit handler # activate an initial section by 'clicking' on it. # check for a deep-link, or click the first link. click_first_link = -> - link = links.eq(0) + link = $links.eq(0) link.click() - link.data('wrapper')?.onClickTitle?() if (new RegExp "^#{HASH_LINK_PREFIX}").test location.hash rmatch = (new RegExp "^#{HASH_LINK_PREFIX}(.*)").exec location.hash section_name = rmatch[1] - link = links.filter "[data-section='#{section_name}']" + link = $links.filter "[data-section='#{section_name}']" if link.length == 1 link.click() - link.data('wrapper')?.onClickTitle?() else click_first_link() else @@ -98,9 +138,14 @@ setup_instructor_dashboard = (idash_content) => # enable sections setup_instructor_dashboard_sections = (idash_content) -> # see fault isolation NOTE at top of file. - # an error thrown in one section will not block other sections from exectuing. - plantTimeout 0, -> new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info" - plantTimeout 0, -> new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download" - plantTimeout 0, -> new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership" - plantTimeout 0, -> new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" - plantTimeout 0, -> new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics" + # If an error thrown in one section, it will not stop other sections from exectuing. + plantTimeout 0, sections_have_loaded.waitFor -> + new window.InstructorDashboard.sections.CourseInfo idash_content.find ".#{CSS_IDASH_SECTION}#course_info" + plantTimeout 0, sections_have_loaded.waitFor -> + new window.InstructorDashboard.sections.DataDownload idash_content.find ".#{CSS_IDASH_SECTION}#data_download" + plantTimeout 0, sections_have_loaded.waitFor -> + new window.InstructorDashboard.sections.Membership idash_content.find ".#{CSS_IDASH_SECTION}#membership" + plantTimeout 0, sections_have_loaded.waitFor -> + new window.InstructorDashboard.sections.StudentAdmin idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" + plantTimeout 0, sections_have_loaded.waitFor -> + new window.InstructorDashboard.sections.Analytics idash_content.find ".#{CSS_IDASH_SECTION}#analytics" diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index b70b2c781b..61dab3ef1c 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -36,6 +36,11 @@ section.instructor-dashboard-content-2 { color: $error-red; } + .display-errors { + line-height: 3em; + color: $error-red; + } + .slickgrid { margin-left: 1px; color:#333333; @@ -320,25 +325,40 @@ section.instructor-dashboard-content-2 { } -.instructor-dashboard-wrapper-2 section.idash-section#analytics { - .distribution-display { - margin-top: 1.2em; +.profile-distribution-widget { + margin-bottom: $baseline * 2; - .distribution-display-graph { - .year-of-birth { - width: 750px; - height: 250px; - } - } + .display-text {} - .distribution-display-table { - .slickgrid { - height: 400px; - } + .display-graph .graph-placeholder { + width: 750px; + height: 250px; + } + + .display-table { + .slickgrid { + height: 250px; } } } +.grade-distributions-widget { + margin-bottom: $baseline * 2; + + .last-updated { + line-height: 2.2em; + font-size: 10pt; + } + + .display-graph .graph-placeholder { + width: 750px; + height: 200px; + } + + .display-text { + line-height: 2em; + } +} .member-list-widget { $width: 20 * $baseline; diff --git a/lms/templates/instructor/instructor_dashboard_2/analytics.html b/lms/templates/instructor/instructor_dashboard_2/analytics.html index ebb8a8cb3c..8469c1db93 100644 --- a/lms/templates/instructor/instructor_dashboard_2/analytics.html +++ b/lms/templates/instructor/instructor_dashboard_2/analytics.html @@ -1,12 +1,54 @@ <%page args="section_data"/> -