CQS - Loading states

While stats are being loaded:

  - Adds a spinner to the top of the page
  - Dims the summary table metrics and replaces them with "N/A"
  - Reads a "please wait while we're loading" sentence in the question
    breakdown section
  - Disables any buttons that are visible

Note: the summary percentile chart could use some work but it'll have to
be done in a different patch.

Closes CNVS-16597

TEST PLAN
---- ----

  - go to new quiz stats
  - try to get the page to load for a bit so you can see the effects,
    otherwise try reloading to test each item or get in touch and maybe
    we can figure something out
    + verify the items above are met while the stats are being loaded
    + verify everything goes back to normal when loading is complete

Change-Id: Ibd9049d3a81d85d376b79102c1567e470f06de9c
Reviewed-on: https://gerrit.instructure.com/43997
Reviewed-by: Ryan Taylor <rtaylor@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
QA-Review: Trevor deHaan <tdehaan@instructure.com>
Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
Ahmad Amireh 2014-11-06 11:19:45 +02:00
parent 2c76c27ad5
commit 1e0760b716
12 changed files with 194 additions and 25 deletions

View File

@ -35,6 +35,10 @@
}
}
tbody td {
transition: opacity 0.5s linear 0s;
}
.chart {
margin-top: 60px;
width: 100%;
@ -81,4 +85,10 @@
box-shadow: none;
}
}
&.loading {
tbody td {
opacity: 0.25;
}
}
}

View File

@ -5,6 +5,7 @@
@import "./question_statistics";
@import "../../vendor/css/jquery.qtip";
@import "./ext/qtip";
@import "./components/ic_spinner";
#canvas-quiz-statistics {
border-top: 1px solid $borderColor;

View File

@ -0,0 +1,64 @@
/**
* Courtesy of http://tobiasahlin.com/spinkit/
*/
.ic-Spinner {
display: inline-block;
vertical-align: middle;
margin-left: $whitespace;
margin-top: -6px;
height: 38px;
& > div {
background-color: $highlightColor;
height: 100%;
width: 10px;
margin-right: 1px;
display: inline-block;
-webkit-animation: ic-Spinner__stretchdelay 1.2s infinite ease-in-out;
animation: ic-Spinner__stretchdelay 1.2s infinite ease-in-out;
}
.rect2 {
-webkit-animation-delay: -1.1s;
animation-delay: -1.1s;
}
.rect3 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
.rect4 {
-webkit-animation-delay: -0.9s;
animation-delay: -0.9s;
}
.rect5 {
-webkit-animation-delay: -0.8s;
animation-delay: -0.8s;
}
}
@-webkit-keyframes ic-Spinner__stretchdelay {
0%, 40%, 100% {
-webkit-transform: scaleY(0.4);
transform: scaleY(0.4);
}
20% {
-webkit-transform: scaleY(1.0);
transform: scaleY(1.0);
}
}
@keyframes ic-Spinner__stretchdelay {
0%, 40%, 100% {
-webkit-transform: scaleY(0.4);
transform: scaleY(0.4);
}
20% {
-webkit-transform: scaleY(1.0);
transform: scaleY(1.0);
}
}

View File

@ -0,0 +1,19 @@
/** @jsx React.DOM */
define(function(require) {
var React = require('react');
var Spinner = React.createClass({
render: function() {
return(
<div className="ic-Spinner">
<div className="rect1"></div>
<div className="rect2"></div>
<div className="rect3"></div>
<div className="rect4"></div>
<div className="rect5"></div>
</div>
);
}
});
return Spinner;
});

View File

@ -1,5 +1,4 @@
define(function(require) {
var rawAjax = require('../../util/xhr_request');
var $ = require('canvas_packages/jquery');
var Root = this;
var DEBUG = {

View File

@ -19,6 +19,18 @@ define(function(require) {
options.data = JSON.stringify(options.data);
}
//>>excludeStart("production", pragmas.production);
if (config.fakeXHRDelay) {
var svc = RSVP.defer();
setTimeout(function() {
RSVP.Promise.cast(ajax(options)).then(svc.resolve, svc.reject);
}, config.fakeXHRDelay);
return svc.promise;
}
//>>excludeEnd("production");
return RSVP.Promise.cast(ajax(options));
}
};

View File

@ -7,6 +7,7 @@ define(function(require) {
var onChange = function() {
update({
quizStatistics: quizStatistics.get(),
isLoadingStatistics: quizStatistics.isLoading(),
quizReports: quizReports.getAll(),
});
};

View File

@ -62,6 +62,16 @@ define(function(require) {
*/
__reset__: function() {
this._callbacks = [];
this.state = this.getInitialState();
},
getInitialState: function() {
return {};
},
setState: function(newState) {
extend(this.state, newState);
this.emitChange();
}
});

View File

@ -11,6 +11,12 @@ define(function(require) {
* Load stats.
*/
var store = new Store('statistics', {
getInitialState: function() {
return {
loading: false
};
},
/**
* Load quiz statistics.
*
@ -22,13 +28,16 @@ define(function(require) {
* Fulfills when the stats have been loaded and injected.
*/
load: function() {
var onLoad = this.populate.bind(this);
if (!config.quizStatisticsUrl) {
return config.onError('Missing configuration parameter "quizStatisticsUrl".');
}
return quizStats.fetch().then(onLoad);
this.setState({ loading: true });
return quizStats.fetch().then(function onLoad(payload) {
this.populate(payload);
this.setState({ loading: false });
}.bind(this));
},
/**
@ -66,6 +75,10 @@ define(function(require) {
return false;
},
isLoading: function() {
return this.state.loading;
},
getSubmissionStatistics: function() {
var stats = this.get();
if (stats) {

View File

@ -46,8 +46,6 @@ define(function(require) {
var props = this.props;
var quizStatistics = this.props.quizStatistics;
var submissionStatistics = quizStatistics.submissionStatistics;
var questionStatistics = quizStatistics.questionStatistics;
var participantCount = submissionStatistics.uniqueCount;
return(
<div id="canvas-quiz-statistics">
@ -65,6 +63,7 @@ define(function(require) {
durationAverage={submissionStatistics.durationAverage}
quizReports={this.props.quizReports}
scores={submissionStatistics.scores}
loading={this.props.isLoadingStatistics}
/>
</section>
@ -79,17 +78,47 @@ define(function(require) {
<ToggleDetailsButton
onClick={this.toggleAllDetails}
expanded={quizStatistics.expandingAll}
disabled={this.props.isLoadingStatistics}
controlsAll />
</SightedUserContent>
</aside>
</header>
{questionStatistics.map(this.renderQuestion.bind(null, participantCount))}
{this.renderQuestions()}
</section>
</div>
);
},
renderQuestions: function() {
var isLoadingStatistics = this.props.isLoadingStatistics;
var questionStatistics = this.props.quizStatistics.questionStatistics;
var participantCount = this.props.quizStatistics.submissionStatistics.uniqueCount;
if (isLoadingStatistics) {
return (
<p>
{I18n.t('loading_questions',
'Question statistics are being loaded. Please wait a while.')}
</p>
);
}
else if (questionStatistics.length === 0) {
return (
<p>
{I18n.t('empty_question_breakdown', 'There are no question statistics available.')}
</p>
);
}
else {
return (
<div>
{questionStatistics.map(this.renderQuestion.bind(null, participantCount))}
</div>
);
}
},
renderQuestion: function(participantCount, question) {
var renderer = Renderers[question.questionType] || QuestionRenderer;
var stats = this.props.quizStatistics;

View File

@ -40,6 +40,7 @@ define(function(require) {
<button
title={label}
onClick={this.props.onClick}
disabled={this.props.disabled}
className="btn"
aria-live="polite">
<ScreenReaderContent children={label} />

View File

@ -9,6 +9,8 @@ define(function(require) {
var formatNumber = require('../util/format_number');
var SightedUserContent = require('jsx!../components/sighted_user_content');
var ScreenReaderContent = require('jsx!../components/screen_reader_content');
var Spinner = require('jsx!../components/spinner');
var NA_LABEL = I18n.t('not_available_abbrev', 'N/A');
var Column = React.createClass({
render: function() {
@ -48,18 +50,23 @@ define(function(require) {
},
render: function() {
var isLoading = this.props.loading;
return(
<div id="summary-statistics">
<div id="summary-statistics" className={isLoading ? 'loading' : undefined}>
<header className="padded">
<h2 className="section-title inline">
{I18n.t('quiz_summary', 'Quiz Summary')}
</h2>
{isLoading && <Spinner />}
<div className="pull-right">
{this.props.quizReports.map(this.renderReport)}
</div>
</header>
<table className="text-left">
<ScreenReaderContent tagName="caption" forceSentenceDelimiter>
{I18n.t('table_description',
@ -90,24 +97,27 @@ define(function(require) {
<tbody>
<tr>
<td className="emphasized">
{this.ratioFor(this.props.scoreAverage)}%
</td>
<td>{this.ratioFor(this.props.scoreHigh)}%</td>
<td>{this.ratioFor(this.props.scoreLow)}%</td>
<td>{formatNumber(round(this.props.scoreStdev, 2), 2)}</td>
<td>
<ScreenReaderContent forceSentenceDelimiter>
{secondsToTime.toReadableString(this.props.durationAverage)}
</ScreenReaderContent>
{/*
try to hide the [HH:]MM:SS timestamp from SR users because
it's not really useful, however this doesn't work in all
modes such as the Speak-All mode (at least on VoiceOver)
*/}
<SightedUserContent>
{secondsToTime(this.props.durationAverage)}
</SightedUserContent>
{isLoading ? NA_LABEL : (this.ratioFor(this.props.scoreAverage) + '%')}
</td>
<td>{isLoading ? NA_LABEL : (this.ratioFor(this.props.scoreHigh) + '%')}</td>
<td>{isLoading ? NA_LABEL : (this.ratioFor(this.props.scoreLow) + '%')}</td>
<td>{isLoading ? NA_LABEL : formatNumber(round(this.props.scoreStdev, 2), 2)}</td>
{isLoading ?
<td key="duration">{NA_LABEL}</td> :
<td key="duration">
<ScreenReaderContent forceSentenceDelimiter>
{secondsToTime.toReadableString(this.props.durationAverage)}
</ScreenReaderContent>
{/*
try to hide the [HH:]MM:SS timestamp from SR users because
it's not really useful, however this doesn't work in all
modes such as the Speak-All mode (at least on VoiceOver)
*/}
<SightedUserContent>
{secondsToTime(this.props.durationAverage)}
</SightedUserContent>
</td>
}
</tr>
</tbody>
</table>