modernize canvas_quizzes

fixes FOO-1409
flag  = none

no more client_apps, canvas_quizzes now lives as part of canvas-lms
proper inside app/jsx/, which makes the build leaner and leaves us with
one less thing to reason about

logical changes:

- converted from AMD to ES modules
- upgraded to recent react + react-router
- dropped RSVP in favor of native Promises
- used CanvasModal instead of home-grown Dialog
- removed dead code; notifications in particular were fishy as there had
  no dependents at all and did not even show up in the graph
- ported tests to Jest, added more unit ones and two integration ones
- removed "config.onError" and now throws errors where appropriate
- disabled console statements in non-dev

:: test plan ::

- create a (old-school) quiz containing all types of questions
- as 3 distinct students, take the quiz and try to randomize your
  answers

at this point it's helpful to have a reference to compare the screens; I
replicated the quiz on my production sandbox for this

- go to /courses/:id/quizzes/:id/submissions/:id/log
  - verify it looks OK
  - click on a specific question in the stream and verify the question
    inspector widget works OK
  - go back to stream and push "View table"
  - verify the table and its controls are OK

- go to /courses/:id/quizzes/:id/statistics
  - verify it looks OK
  - click on ? in the discrimination index chart and verify it displays
    a dialog with help content
  - click on "X respondents" in one of the charts and verify it displays
    a dialog with the respondent names
  - verify the interactive charts do interact as expected (no logic
    changed here so just a quick glance)
  - link to "View in SpeedGrader" for essay-like questions works

Change-Id: I79af5ff4f1479503b5e2528b613255dde5bc45d3
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/256118
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Simon Williams <simon@instructure.com>
QA-Review: Simon Williams <simon@instructure.com>
Product-Review: Simon Williams <simon@instructure.com>
This commit is contained in:
Ahmad Amireh 2021-01-01 16:58:12 -07:00
parent a4e1da3aea
commit 46f8efd61f
460 changed files with 10655 additions and 46740 deletions

View File

@ -16,9 +16,6 @@ docker-compose/
docker-compose.local.*.yml
docker-compose.override.yml
client_apps/canvas_quizzes/dist/
client_apps/canvas_quizzes/node_modules/
client_apps/canvas_quizzes/tmp/
coverage/
coverage-js/
gems/*/node_modules/
@ -32,7 +29,6 @@ packages/*/node_modules/
packages/canvas-planner/lib
public/dist/
public/doc/
public/javascripts/client_apps/
public/javascripts/translations/
!public/javascripts/translations/_core_en.js
tmp/*

View File

@ -1,3 +1,4 @@
/app/jsx/canvas_quizzes/**/*.test.js
/gems/canvas_i18nliner/*
/doc/*
/public/javascripts/translations/*
@ -5,7 +6,6 @@
/public/javascripts/bower/*
public/javascripts/react-dnd-test-backend.js
public/javascripts/mathquill.js
public/javascripts/symlink_to_node_modules/*
spec/selenium/helpers/jquery.simulate.js
spec/javascripts/support/jquery.mockjax.js

View File

@ -97,6 +97,55 @@ module.exports = {
'react/no-render-return-value': 'warn', // In future versions of react this will fail
'react/state-in-constructor': 'off',
'react/static-property-placement': 'off',
// don't restrict Math.pow for ** operator
// ref: https://github.com/airbnb/javascript/blob/1f786e154f6c32385607e1688370d7f2d053f88f/packages/eslint-config-airbnb-base/rules/best-practices.js#L225
'no-restricted-properties': ['error',
{
object: 'arguments',
property: 'callee',
message: 'arguments.callee is deprecated',
},
{
object: 'global',
property: 'isFinite',
message: 'Please use Number.isFinite instead',
},
{
object: 'self',
property: 'isFinite',
message: 'Please use Number.isFinite instead',
},
{
object: 'window',
property: 'isFinite',
message: 'Please use Number.isFinite instead',
},
{
object: 'global',
property: 'isNaN',
message: 'Please use Number.isNaN instead',
},
{
object: 'self',
property: 'isNaN',
message: 'Please use Number.isNaN instead',
},
{
object: 'window',
property: 'isNaN',
message: 'Please use Number.isNaN instead',
},
{
property: '__defineGetter__',
message: 'Please use Object.defineProperty instead.',
},
{
property: '__defineSetter__',
message: 'Please use Object.defineProperty instead.',
},
],
'no-restricted-syntax': [
// This is here because we are turning off 2 items from what AirBnB cares about.
'error',
@ -183,6 +232,15 @@ module.exports = {
'import/no-unresolved': 'off',
'import/no-webpack-loader-syntax': 'off'
}
}
},
{
files: ['app/jsx/canvas_quizzes/**/*'],
rules: {
'react/prop-types': 'off',
'prefer-const': 'warn',
'prettier/prettier': 'off',
'react/no-string-refs': 'warn',
}
},
]
}

3
.gitignore vendored
View File

@ -52,9 +52,6 @@ yarn-error.log
/spec/coffeescripts/plugins/
/spec/plugins/
# generated client app stuff
/public/javascripts/client_apps/
# canvas-gems
/gems/*/coverage
/gems/*/tmp

View File

@ -16,7 +16,6 @@
"include": [
"app/coffeescripts/.i18nrc",
"client_apps/canvas_quizzes/.i18nrc",
"gems/plugins/.i18nrc",
"public/javascripts/.i18nrc"
]

View File

@ -1,7 +1,6 @@
{
"ignoreFiles": [
"app/stylesheets/vendor/**",
"client_apps/canvas_quizzes/vendor/**",
"public/**",
],
"rules": {

View File

@ -66,9 +66,6 @@ RUN set -eux; \
.yardoc \
app/stylesheets/brandable_css_brands \
app/views/info \
client_apps/canvas_quizzes/dist \
client_apps/canvas_quizzes/node_modules \
client_apps/canvas_quizzes/tmp \
config/locales/generated \
gems/canvas_i18nliner/node_modules \
log \
@ -92,7 +89,6 @@ RUN set -eux; \
pacts \
public/dist \
public/doc/api \
public/javascripts/client_apps \
public/javascripts/compiled \
public/javascripts/translations \
reports \

View File

@ -30,7 +30,6 @@ RUN --mount=target=/tmp/src \
\
/tmp/dst && \
find \
client_apps/* \
gems/canvas_i18nliner \
gems/plugins/* \
packages/* \
@ -76,7 +75,6 @@ RUN --mount=target=/tmp/src \
app/stylesheets \
app/views/jst \
bin \
client_apps \
config/environments \
config/locales \
frontend_build \
@ -92,7 +90,6 @@ RUN --mount=target=/tmp/src \
config/canvas_rails_switcher.rb \
config/environment.rb \
config/initializers/plugin_symlinks.rb \
config/initializers/client_app_symlinks.rb \
config/initializers/json.rb \
config/initializers/revved_asset_urls.rb \
db/migrate/*_regenerate_brand_files_based_on_new_defaults_*.rb \

View File

@ -137,10 +137,6 @@ pipeline {
}
}
tests['canvas_quizzes'] = {
sh 'build/new-jenkins/js/tests-quizzes.sh'
}
for(int i = 0; i < JSG_NODE_COUNT; i++) {
tests["Karma - Spec Group - jsg${i}"] = makeKarmaStage('jsg', i, JSG_NODE_COUNT)
}

1
app/jsx/.i18nignore Normal file
View File

@ -0,0 +1 @@
/canvas_quizzes/test

View File

@ -16,4 +16,17 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'quiz_statistics_cqs'
import $ from 'jquery'
import { configure, mount } from '../canvas_quizzes/statistics/main.js'
configure({
ajax: $.ajax,
loadOnStartup: true,
quizStatisticsUrl: ENV.quiz_statistics_url,
quizReportsUrl: ENV.quiz_reports_url,
courseSectionsUrl: ENV.course_sections_url
})
mount(document.body.querySelector('#content')).then(() => {
console.log('Yeah!!!')
})

View File

@ -17,11 +17,11 @@
*/
import $ from 'jquery'
import app from 'canvas_quizzes/apps/events'
import { configure, mount } from '../canvas_quizzes/events/main.js'
import ready from '@instructure/ready'
ready(() => {
app.configure({
configure({
ajax: $.ajax,
loadOnStartup: true,
quizUrl: ENV.quiz_url,
@ -31,7 +31,7 @@ ready(() => {
allowMatrixView: ENV.can_view_answer_audits
})
app
.mount(document.body.querySelector('#content'))
.then(() => console.log('Yeah, a canvas quiz app has been loaded!!!'))
mount(document.body.querySelector('#content')).then(() =>
console.log('Yeah, a canvas quiz app has been loaded!!!')
)
})

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,23 +16,21 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var $ = require('jquery');
import Dispatcher from './core/dispatcher'
import EventStore from './stores/events'
// We're already logging errors in config/initializers/rsvp.js
if(typeof(jasmine) !== "undefined" && typeof(jasmine !== undefined)){
jasmine.RSVP.logRSVPErrors = false;
}
const Actions = {}
return {
ajax: $.ajax,
Actions.dismissNotification = function(key) {
return Dispatcher.dispatch('notifications:dismiss', key)
}
xhr: {
timeout: 25
},
Actions.reloadEvents = function() {
EventStore.load()
}
onError: function(message) {
throw new Error(message);
}
};
});
Actions.setActiveAttempt = function(attempt) {
EventStore.setActiveAttempt(attempt)
}
export default Actions

View File

@ -0,0 +1,64 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {
BrowserRouter,
HashRouter,
Switch,
Route,
} from 'react-router-dom'
import AnswerMatrixRoute from '../routes/answer_matrix'
import AppRoute from '../routes/app'
import EventStreamRoute from '../routes/event_stream'
import QuestionRoute from '../routes/question'
export default function App(props) {
const matches = window.location.pathname.match(/(.*\/log)/)
const baseUrl = (matches && matches[0]) || ''
const Router = props.useHashRouter ? HashRouter : BrowserRouter
return (
<Router basename={baseUrl}>
<Switch>
<Route path="/questions/:id">
<AppRoute>
<QuestionRoute {...props} />
</AppRoute>
</Route>
<Route path="/answer_matrix">
<AppRoute>
<AnswerMatrixRoute {...props} />
</AppRoute>
</Route>
<Route path="/">
<AppRoute>
<EventStreamRoute {...props} />
</AppRoute>
</Route>
<Route path="*">
<AppRoute />
</Route>
</Switch>
</Router>
)
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Backbone from 'backbone'
import Event from '../models/event'
import fromJSONAPI from '../../shared/models/common/from_jsonapi'
import config from '../config'
import PaginatedCollection from '../mixins/paginated_collection'
export default Backbone.Collection.extend({
model: Event,
// eslint-disable-next-line object-shorthand
constructor: function() {
PaginatedCollection(this)
return Backbone.Collection.apply(this, arguments)
},
url() {
return config.eventsUrl
},
parse(payload) {
return fromJSONAPI(payload, 'quiz_submission_events')
}
})

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Backbone from 'backbone'
import Question from '../models/question'
import fromJSONAPI from '../../shared/models/common/from_jsonapi'
import config from '../config'
import PaginatedCollection from '../mixins/paginated_collection'
export default Backbone.Collection.extend({
model: Question,
// eslint-disable-next-line object-shorthand
constructor: function() {
PaginatedCollection(this)
return Backbone.Collection.apply(this, arguments)
},
url() {
return config.questionsUrl
},
parse(payload) {
return fromJSONAPI(payload, 'quiz_questions')
}
})

View File

@ -0,0 +1,37 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {render, fireEvent} from '@testing-library/react'
import React from 'react'
import Button from '../button'
const setup = props => {
return render(
<Button onClick={Function.prototype} {...props} />
)
}
describe('canvas_quizzes/components/button', () => {
it('calls onClick on click', () => {
const onClick = jest.fn()
const {getByTestId} = setup({ onClick })
expect(onClick.mock.calls.length).toBe(0)
fireEvent.click(getByTestId('button'))
expect(onClick.mock.calls.length).toBe(1)
})
})

View File

@ -0,0 +1,48 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import classSet from '../../shared/util/class_set'
/**
* @class Events.Components.Button
*
* A wrapper for `<button type="button" />` that abstracts the bootstrap CSS
* classes we need to specify for buttons.
*/
const Button = ({children, onClick, type = 'default'}) => {
const className = {}
className.btn = true
className['btn-default'] = type === 'default'
className['btn-danger'] = type === 'danger'
className['btn-success'] = type === 'success'
return (
<button
data-testid="button"
onClick={onClick}
type="button"
className={classSet(className)}
>
{children}
</button>
)
}
export default Button

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,12 +16,11 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// define(function(require) {
// var App = require('core/delegate');
import config from './config/environments/production'
import devConfig from './config/environments/development'
// describe('app', function() {
// it('write me', function() {
// expect(App).toBeTruthy();
// });
// });
// });
if (process.env.NODE_ENV === 'development') {
Object.assign(config, devConfig)
}
export default config

View File

@ -0,0 +1,30 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import $ from 'jquery'
export default {
xhr: {
timeout: 5000
},
pollingFrequency: 500,
ajax: $.ajax,
useHashRouter: false,
}

View File

@ -0,0 +1,72 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export default {
/**
* @cfg {Function} ajax
* An XHR request processor that has an API compatible with jQuery.ajax.
*/
ajax: undefined,
/**
* @cfg {String} quizUrl
* Canvas API endpoint for querying the current quiz.
*/
quizUrl: undefined,
/**
* @cfg {String} submissionUrl
* Canvas API endpoint for querying the current quiz submission.
*/
submissionUrl: undefined,
/**
* @cfg {String} eventsUrl
* Canvas API endpoint for querying the current quiz submission's events.
*/
eventsUrl: undefined,
/**
* @cfg {String} questionsUrl
* Canvas API endpoint for querying questions in the current quiz.
*/
questionsUrl: undefined,
attempt: undefined,
/**
* @cfg {Boolean} [loadOnStartup=true]
*
* Whether the app should query all the data it needs as soon as it is
* mounted.
*
* You may disable this behavior if you want to manually inject the app
* with data.
*/
loadOnStartup: true,
/**
* @cfg {Boolean} [allowMatrixView=true]
*
* Turn this off if you don't want the user to be able to view the answer
* matrix.
*/
allowMatrixView: true,
useHashRouter: false,
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,11 +16,8 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#HTMLReporter {
position: relative;
z-index: 0;
}
import './initializers/backbone'
.fixture {
position: relative;
export default function initializeApp() {
return Promise.resolve()
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,20 +16,12 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var $ = require('jquery');
import Backbone from 'backbone'
import config from '../../config'
import CoreAdapter from '../../../shared/core/adapter'
return {
xhr: {
timeout: 5000
},
const Adapter = new CoreAdapter(config)
pollingFrequency: 500,
ajax: $.ajax,
onError: function(message) {
throw new Error(message);
}
};
});
Backbone.ajax = function(options) {
return Adapter.request(options)
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define({
export default {
EVT_SESSION_STARTED: 'session_started',
EVT_PAGE_FOCUSED: 'page_focused',
EVT_PAGE_BLURRED: 'page_blurred',
@ -27,32 +27,13 @@ define({
EVT_FLAG_WARNING: 'warning',
EVT_FLAG_OK: 'ok',
EVENT_ATTRS: [
'id',
'event_type',
'event_data',
'created_at',
],
EVENT_ATTRS: ['id', 'event_type', 'event_data', 'created_at'],
EVENT_DATA_ATTRS: [
'quiz_question_id',
'answer'
],
EVENT_DATA_ATTRS: ['quiz_question_id', 'answer'],
SUBMISSION_ATTRS: [
'id',
'started_at',
'attempt'
],
SUBMISSION_ATTRS: ['id', 'started_at', 'attempt'],
QUESTION_ATTRS: [
'id',
'question_type',
'question_text',
'position',
'answers',
'matches'
],
QUESTION_ATTRS: ['id', 'question_type', 'question_text', 'position', 'answers', 'matches'],
Q_CALCULATED: 'calculated_question',
Q_ESSAY: 'essay_question',
@ -69,4 +50,4 @@ define({
// Answer text longer than this will be truncated for questions of types
// "essay" and other free-form input ones. This applies to the table view.
MAX_VISIBLE_CHARS: 50
});
}

View File

@ -0,0 +1,91 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import EventStore from '../stores/events'
import config from '../config'
let update
/**
* @class Events.Core.Controller
* @private
*
* The controller is responsible for keeping the UI up-to-date with the
* data layer.
*/
const Controller = {
getState: () => ({
useHashRouter: config.useHashRouter,
submission: EventStore.getSubmission(),
questions: EventStore.getQuestions(),
events: EventStore.getAll(),
isLoading: EventStore.isLoading(),
attempt: EventStore.getAttempt(),
availableAttempts: EventStore.getAvailableAttempts()
}),
/**
* Start listening to data updates.
*
* @param {Function} onUpdate
* A callback to notify when new data comes in.
*
* @param {Object} onUpdate.props
* A set of props ready for injecting into the app layout.
*
* @param {Object} onUpdate.props.quizStatistics
* Quiz statistics.
* See Stores.Statistics#getQuizStatistics().
*
* @param {Object} onUpdate.props.quizReports
* Quiz reports.
* See Stores.Statistics#getQuizReports().
*/
start(onUpdate) {
update = () => {
onUpdate(Controller.getState())
}
EventStore.addChangeListener(update)
if (config.loadOnStartup) {
return Controller.load()
} else {
return Promise.resolve()
}
},
/**
* Load initial application data; quiz statistics and reports.
*/
load() {
return EventStore.loadInitialData().then(EventStore.load.bind(EventStore))
},
/**
* Stop listening to data changes.
*/
stop() {
if (update) {
EventStore.removeChangeListener(update)
update = null
}
}
}
export default Controller

View File

@ -0,0 +1,97 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import ReactDOM from 'react-dom'
import _ from 'lodash.underscore'
import config from '../config'
import initialize from '../config/initializer'
import Layout from '../bundles/routes'
import controller from './controller'
const extend = _.extend
let container
/**
* @class Events.Core.Delegate
*
* The client app delegate. This is the main interface that embedding
* applications use to interact with the client app.
*/
const exports = {}
/**
* Configure the application. See Config for the supported options.
*
* @param {Object} options
* A set of options to override.
*/
const configure = function(options) {
extend(config, options)
}
/**
* Start the app and perform any necessary data loading.
*
* @param {HTMLElement} node
* The node to mount the app in.
*
* @param {Object} [options={}]
* Options to configure the app with. See config.js
*
* @return {Promise}
* Fulfilled when the app has been started and rendered.
*/
const mount = function(node, options) {
configure(options)
container = node
return initialize().then(function() {
ReactDOM.render(<Layout {...controller.getState()} />, container)
return controller.start(update)
})
}
const isMounted = function() {
return !!container
}
const update = function(props) {
ReactDOM.render(<Layout {...props} />, container)
}
const reload = function() {
controller.load()
}
const unmount = function() {
if (isMounted()) {
controller.stop()
ReactDOM.unmountComponentAtNode(container)
container = undefined
}
}
exports.configure = configure
exports.mount = mount
exports.isMounted = isMounted
exports.update = update
exports.reload = reload
exports.unmount = unmount
export default exports

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,8 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
var fs = require('fs');
import CoreDispatcher from '../../shared/core/dispatcher'
import config from '../config'
module.exports = function readJSON(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
};
export default new CoreDispatcher(config)

View File

@ -0,0 +1,28 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import config from './config'
import delegate from './core/delegate'
export const configure = delegate.configure
export const mount = delegate.mount
export const isMounted = delegate.isMounted
export const update = delegate.update
export const reload = delegate.reload
export const unmount = delegate.unmount
export {config}

View File

@ -0,0 +1,220 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import _ from 'lodash.underscore'
const find = _.find
const RE_EXTRACT_LINK = /<([^>]+)>; rel="([^"]+)",?\s*/g
const RE_EXTRACT_PP = /per_page=(\d+)/
// Extract pagination meta from a JSON-API payload inside the
// "meta.pagination" set.
const parseJsonApiPagination = function(respMeta, meta) {
if (!meta) meta = {}
meta.perPage = respMeta.per_page
meta.hasMore = !!respMeta.next
meta.nextPage = meta.hasMore ? respMeta.page + 1 : undefined
meta.count = respMeta.count
return meta
}
// Extract pagination from the Link header.
//
// Here's a good reference:
// https://developer.github.com/guides/traversing-with-pagination/
const parseLinkPagination = function(linkHeader, meta) {
let match
const links = []
if (!meta) meta = {}
while ((match = RE_EXTRACT_LINK.exec(linkHeader))) {
links.push({
rel: match[2],
href: match[1],
page: parseInt(/page=(\d+)/.exec(match[1])[1], 10)
})
}
const nextLink = find(links, {rel: 'next'})
const lastLink = find(links, {rel: 'last'})
meta.perPage = parseInt((RE_EXTRACT_PP.exec(linkHeader) || [])[1] || 0, 10)
meta.hasMore = !!nextLink
meta.nextPage = meta.hasMore ? nextLink.page : undefined
// Link header does not provide us with an accurate count of objects, so
// we'll estimate it if we know how many we get per page, and we know the
// index of the last page:
if (lastLink) {
meta.count = meta.perPage * lastLink.page
}
return meta
}
/**
* @class Events.Mixins.PaginatedCollection
* @extends {Backbone.Collection}
*
* Adds support for utilizing JSON-API pagination meta-data to allow fetching
* any page of a paginated API resource, or all pages at once.
*
* Usage example:
*
* var Collection = Backbone.Collection.extend({
* // install the mixin
* constructor: function() {
* PaginatedCollection(this);
* return Backbone.Collection.apply(this, arguments);
* },
*
* url: function() {
* return '/users';
* }
* });
*
* var collection = new Collection();
*
* collection.fetch(); // /users
* collection.length; // 10
*
* collection.fetchNext(); // /users?page=2
* collection.length; // 20
*
* // load all available users in one go:
* // /users?page=1
* // ...
* // /users?page=5
* collection.fetchAll().then(function() {
* collection.length; // 50
* });
*/
const Mixin = {
/**
* Fetch the next page, if available.
*
* @param {Object} options
* Normal options you'd pass to Backbone.Collection#fetch().
*
* @param {Number} [options.page]
* If specified, exactly that page will be fetched, otherwise we'll
* use the current cursor (or 1).
*
* @return {Promise}
* Resolves when the page has been loaded and the pagination meta
* parsed.
*/
fetchNext(options) {
const meta = this._paginationMeta
if (!options) {
options = {}
} else if (options.hasOwnProperty('xhr')) {
delete options.xhr
}
if (!options.data) {
options.data = {}
}
options.data.page = options.page || meta.nextPage
options.success = function(payload, statusText, xhr) {
const header = xhr.getResponseHeader('Link')
if (payload.meta && payload.meta.pagination) {
parseJsonApiPagination(payload.meta.pagination, meta)
} else if (header) {
parseLinkPagination(header, meta)
}
this.add(payload, {parse: true /* always parse */})
}.bind(this)
return this.sync('read', this, options)
},
/**
* @return {Boolean}
* Whether there's more data (that we know of) to pull in from the
* server.
*/
canLoadMore() {
return !!this._paginationMeta.hasMore
},
/**
* Fetch all available pages.
*
* @param {Object} options
* Options to pass to #fetchNext. "page" is not allowed here and
* will be ignored if specified.
*
* @return {Promise}
* Resolves when *all* pages have been loaded.
*/
fetchAll(options) {
const meta = this._paginationMeta
if (!options) {
options = {}
} else if (options.hasOwnProperty('page')) {
if (process.env.NODE_ENV === 'development') {
console.error(
'You should not specify a page when fetching all pages since it ' +
'will be reset to 1!'
)
}
delete options.page
}
if (options.reset) {
this.reset(null, {silent: true})
}
meta.nextPage = 1
return (function fetch(collection) {
return collection.fetchNext(options).then(function() {
if (meta.hasMore) {
return fetch(collection)
} else {
return collection
}
})
})(this)
},
/** @private */
_resetPaginationMeta() {
this._paginationMeta = {}
}
}
export default function applyMixin(collection) {
collection.fetchNext = Mixin.fetchNext
collection.fetchAll = Mixin.fetchAll
collection._resetPaginationMeta = Mixin._resetPaginationMeta
collection.on('reset', collection._resetPaginationMeta, collection)
collection._resetPaginationMeta()
}

View File

@ -0,0 +1,125 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Subject from '../question_answered_event_decorator';
import Backbone from 'backbone';
import { findWhere } from 'lodash'
describe('Models.QuestionAnsweredEventDecorator', () => {
describe('#decorateAnswerRecord', () => {
describe('inferring whether a question is answered', () => {
var record = {};
var questionType;
var subject = function(answer) {
record.answer = answer;
return Subject.decorateAnswerRecord({
questionType: questionType
}, record);
};
it('multiple_choice_question and many friends (scalar answers)', function() {
questionType = 'multiple_choice_question';
subject(null);
expect(record.answered).toEqual(false);
subject('123');
expect(record.answered).toEqual(true);
});
it('fill_in_multiple_blanks_question, multiple_dropdowns', function() {
questionType = 'fill_in_multiple_blanks_question';
subject({ color1: null, color2: null });
expect(record.answered).toEqual(false, 'should be false when all blanks are nulls');
subject({ color1: 'something', color2: null });
expect(record.answered).toEqual(true, 'should be true if any blank is filled with anything');
});
it('matching_question', function() {
questionType = 'matching_question';
subject([]);
expect(record.answered).toEqual(false);
subject(null);
expect(record.answered).toEqual(false);
subject([{ answer_id: '123', match_id: null }]);
expect(record.answered).toEqual(false);
subject([{ answer_id: '123', match_id: '456' }]);
expect(record.answered).toEqual(true);
});
it('multiple_answers, file_upload', function() {
questionType = 'matching_question';
subject([]);
expect(record.answered).toEqual(false);
subject(null);
expect(record.answered).toEqual(false);
subject(null);
expect(record.answered).toEqual(false);
});
});
});
describe('#run', function() {
it('should mark latest answers to all questions', function() {
var events = [
{
data: [
{ quizQuestionId: '1', answer: 'something' },
{ quizQuestionId: '2', answer: null }
]
},
{
data: [
{ quizQuestionId: '1', answer: 'something else' }
]
}
];
var eventCollection = events.map(function(attrs) {
return new Backbone.Model(attrs);
});
var questions = [
{ id: '1' },
{ id: '2' }
];
var findQuestionRecord = (eventIndex, id) => (
eventCollection[eventIndex].get('data').find(x => x.quizQuestionId === id)
);
Subject.run(eventCollection, questions);
expect(findQuestionRecord(0, '1').last).toBeFalsy();
expect(findQuestionRecord(1, '1').last).toBeTruthy();
expect(findQuestionRecord(0, '2').last).toBeTruthy();
});
});
});

View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Backbone from 'backbone'
import pickAndNormalize from '../../shared/models/common/pick_and_normalize'
import fromJSONAPI from '../../shared/models/common/from_jsonapi'
import K from '../constants'
const QuizSubmissionEvent = Backbone.Model.extend({
parse(payload) {
let attrs
attrs = fromJSONAPI(payload, 'quiz_submission_events', true)
attrs = pickAndNormalize(attrs, K.EVENT_ATTRS)
attrs.type = attrs.eventType
attrs.data = attrs.eventData
delete attrs.eventType
delete attrs.eventData
if (attrs.type === K.EVT_QUESTION_ANSWERED) {
attrs.data = attrs.data.map(function(record) {
return pickAndNormalize(record, K.EVENT_DATA_ATTRS)
})
}
if (attrs.type === K.EVT_PAGE_BLURRED) {
attrs.flag = K.EVT_FLAG_WARNING
} else if (attrs.type === K.EVT_PAGE_FOCUSED) {
attrs.flag = K.EVT_FLAG_OK
}
return attrs
}
})
export default QuizSubmissionEvent

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Backbone from 'backbone'
import pickAndNormalize from '../../shared/models/common/pick_and_normalize'
import fromJSONAPI from '../../shared/models/common/from_jsonapi'
import K from '../constants'
import inflections from '../../shared/util/inflections'
const camelize = inflections.camelize
export default Backbone.Model.extend({
parse(payload) {
let attrs
attrs = fromJSONAPI(payload, 'quiz_questions', true)
attrs = pickAndNormalize(attrs, K.QUESTION_ATTRS)
attrs.id = '' + attrs.id
attrs.readableType = camelize(
attrs.questionType.replace(/_question$/, '').replace(/_/g, ' '),
false
)
return attrs
}
})

View File

@ -0,0 +1,135 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import _ from 'lodash.underscore'
import K from '../constants'
const findWhere = _.findWhere
const keys = Object.keys
const QuestionAnsweredEventDecorator = {}
QuestionAnsweredEventDecorator.decorateAnswerRecord = function(question, record) {
let answered = false
const answer = record.answer
let blank
switch (question.questionType) {
case K.Q_NUMERICAL:
case K.Q_CALCULATED:
case K.Q_MULTIPLE_CHOICE:
case K.Q_SHORT_ANSWER:
case K.Q_ESSAY:
answered = answer !== null
break
case K.Q_FILL_IN_MULTIPLE_BLANKS:
case K.Q_MULTIPLE_DROPDOWNS:
for (blank in answer) {
if (answer.hasOwnProperty(blank)) {
answered = answer[blank] !== null
}
if (answered) {
break
}
}
break
case K.Q_MATCHING:
if (answer instanceof Array && answer.length > 0) {
// watch out that at this point, the attributes are not normalized
// and not camelCased:
answered = answer.some(function(pair) {
return pair.match_id !== null
})
}
break
case K.Q_MULTIPLE_ANSWERS:
case K.Q_FILE_UPLOAD:
answered = answer instanceof Array && answer.length > 0
break
default:
answered = answer !== null
}
record.answered = answered
}
/**
* Extend the raw event attributes as received from the API with some stuff
* that we'll need when rendering the views.
*
* This "decoration" could be done once after the payload is received and it
* is not necessary to re-perform them, unless the event answer data has been
* mutated.
*
* The decorations are:
*
* 1. `answered`
* This is applied on the answer records inside the model's "data" attr.
* ---
* A boolean indicating whether an answer is present. This
* differs in semantics based on the question type and that's why we can't
* simply test for "answer" to be null or "".
*
* 2. `last`
* This is applied on the answer records inside the model's "data" attr.
* ---
* A boolean indicating whether this answer record is the final answer
* provided to the referenced question.
*
* @param {Models.Event[]} events
* An array of Event instances of type EVT_QUESTION_ANSWERED.
*
* @param {Object[]} questions
* An array of question data; this must contain all the questions
* referenced by the event set above.
*
* @return {null}
* Nothing is returned as the decoration is done in-place on the model
* attributes.
*/
QuestionAnsweredEventDecorator.run = function(events, questions) {
let finalAnswerEvents = {}
events.forEach(function(event) {
event.attributes.data.forEach(function(record) {
const question = questions.filter(function(question) {
return question.id === record.quizQuestionId
})[0]
finalAnswerEvents[question.id] = event
QuestionAnsweredEventDecorator.decorateAnswerRecord(question, record)
})
})
keys(finalAnswerEvents).forEach(function(quizQuestionId) {
const event = finalAnswerEvents[quizQuestionId]
findWhere(event.attributes.data, {
quizQuestionId
}).last = true
})
finalAnswerEvents = null
}
export default QuestionAnsweredEventDecorator

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,20 +16,23 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// Helper for use with sinon.server.respondWith() to save you from:
//
// - JSON.stringifying() the body
// - adding JSON response Content-Type headers
// - remembering whether headers or body go first!
//
this.xhrResponse = function(statusCode, body, headers) {
if (!headers) {
headers = {};
}
import Backbone from 'backbone'
import pickAndNormalize from '../../shared/models/common/pick_and_normalize'
import fromJSONAPI from '../../shared/models/common/from_jsonapi'
import K from '../constants'
import config from '../config'
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
export default Backbone.Model.extend({
url() {
return config.submissionUrl
},
return [ statusCode, headers, JSON.stringify(body) ];
};
parse(payload) {
let attrs
attrs = fromJSONAPI(payload, 'quiz_submissions', true)
attrs = pickAndNormalize(attrs, K.SUBMISSION_ATTRS)
return attrs
}
})

View File

@ -0,0 +1,33 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import AnswerMatrix from '../answer_matrix'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/routes/answer_matrix', () => {
it('renders', () => {
render(
<MemoryRouter>
<AnswerMatrix params={{}} query={{}} />
</MemoryRouter>
)
})
})

View File

@ -0,0 +1,33 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import AppRoute from '../app'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/AppRoute', () => {
it('renders', () => {
render(
<MemoryRouter>
<AppRoute params={{ id: 1 }} query={{}} />
</MemoryRouter>
)
})
})

View File

@ -0,0 +1,33 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import EventStreamRoute from '../event_stream'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/routes/event_stream', () => {
it('renders', () => {
render(
<MemoryRouter>
<EventStreamRoute />
</MemoryRouter>
)
})
})

View File

@ -0,0 +1,33 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import QuestionRoute from '../question'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/QuestionRoute', () => {
it('renders', () => {
render(
<MemoryRouter>
<QuestionRoute questions={[]} query={{}} />
</MemoryRouter>
)
})
})

View File

@ -1,6 +1,5 @@
/** @jsx React.DOM */
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -17,13 +16,23 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var React = require('old_version_of_react_used_by_canvas_quizzes_client_apps');
var I18n = require('i18n!quiz_log_auditing').default;
import React from 'react'
import AnswerMatrix from '../views/answer_matrix'
import Config from '../config'
const AnswerMatrixRoute = props => {
if (!Config.allowMatrixView) {
return null
}
return (
<em className="ic-QuestionInspector__NoAnswer">
{I18n.t('no_answer', 'No answer')}
</em>
);
});
<AnswerMatrix
loading={props.isLoading}
questions={props.questions}
events={props.events}
submission={props.submission}
/>
)
}
export default AnswerMatrixRoute

View File

@ -0,0 +1,39 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import Actions from '../actions'
import Query from './query'
class AppRoute extends React.Component {
componentDidUpdate() {
if (this.props.query.attempt) {
Actions.setActiveAttempt(this.props.query.attempt)
}
}
render() {
return <div id="ic-QuizInspector">{this.props.children}</div>
}
}
export default props => (
<Query>
<AppRoute {...props} />
</Query>
)

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import EventStream from '../views/event_stream'
import Session from '../views/session'
const EventStreamRoute = props => (
<div>
<Session
submission={props.submission}
attempt={props.attempt}
availableAttempts={props.availableAttempts}
/>
<EventStream
submission={props.submission}
events={props.events}
questions={props.questions}
attempt={props.attempt}
/>
</div>
)
export default EventStreamRoute

View File

@ -0,0 +1,39 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import { useLocation } from 'react-router-dom'
// A custom hook that builds on useLocation to parse
// the query string for you.
function useQuery() {
const params = new URLSearchParams(useLocation().search)
const query = {}
for (const key of params.keys()) {
query[key] = params.get(key)
}
return query
}
export default function Query(props) {
const query = useQuery()
return React.cloneElement(props.children, {query})
}

View File

@ -0,0 +1,70 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import PropTypes from 'prop-types'
import {useParams} from 'react-router-dom'
import QuestionInspector from '../views/question_inspector'
import QuestionListing from '../views/question_listing'
import Query from './query'
const QuestionRoute = props => {
const {id: questionId} = useParams()
const question = props.questions.find(question => {
return question.id === questionId
})
return (
<div>
<div id="not_right_side">
<div id="content-wrapper">
<div id="content" role="main" className="container-fluid">
<QuestionInspector
loading={props.isLoading}
question={question}
currentEventId={props.query.event}
inspectedQuestionId={questionId}
events={props.events}
/>
</div>
</div>
</div>
<div id="right-side-wrapper">
<aside id="right-side">
<QuestionListing
activeQuestionId={questionId}
activeEventId={props.query.event}
questions={props.questions}
query={props.query}
/>
</aside>
</div>
</div>
)
}
QuestionRoute.propTypes = {
questions: PropTypes.array.isRequired
}
export default props => (
<Query>
<QuestionRoute {...props} />
</Query>
)

View File

@ -0,0 +1,178 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import _ from 'lodash.underscore'
import Config from '../config'
import Dispatcher from '../core/dispatcher'
import Environment from '../../shared/core/environment'
import EventCollection from '../collections/events'
import K from '../constants'
import QuestionAnsweredEventDecorator from '../models/question_answered_event_decorator'
import QuestionCollection from '../collections/questions'
import Store from '../../shared/core/store'
import Submission from '../models/submission'
const range = _.range
export default new Store(
'events',
{
getInitialState() {
let attempt = Config.attempt
const requestedAttempt = Environment.getQueryParameter('attempt')
if (requestedAttempt) {
attempt = parseInt(requestedAttempt, 10)
}
return {
submission: new Submission(),
events: new EventCollection(),
questions: new QuestionCollection(),
loading: false,
attempt,
/**
* @property {Integer} latestAttempt
*
* Not necessarily the current attempt of the submission we're using,
* but instead the latest attempt available.
*
* @see #loadInitialData.
*/
latestAttempt: attempt
}
},
/**
* Alright, we need to query the submission for the first time ignoring
* any specified attempt index so that we can tell how many attempts there
* are.
*
* The API does not expose that piece of information.
*
* This needs to be called at most once per submission during the lifetime
* of the app.
*/
loadInitialData() {
return this.state.submission.fetch().then(() => {
const newState = {}
const latestAttempt = this.state.submission.get('attempt')
if (!this.state.attempt || this.state.attempt > latestAttempt) {
newState.attempt = latestAttempt
}
newState.latestAttempt = latestAttempt
this.setState(newState)
})
},
load() {
this.setState({loading: true})
return this.loadSubmission()
.then(this.loadQuestions.bind(this))
.then(this.loadEvents.bind(this))
.finally(() => {
this.setState({loading: false})
})
},
loadSubmission() {
let data
if (this.state.attempt) {
data = {attempt: this.state.attempt}
}
return this.state.submission.fetch({data})
},
loadQuestions() {
return this.state.questions.fetchAll({
reset: true,
data: {
quiz_submission_id: this.state.submission.get('id'),
quiz_submission_attempt: this.state.attempt
}
})
},
loadEvents() {
const events = this.state.events
const questions = this.getQuestions()
return events
.fetchAll({
reset: true,
data: {
attempt: this.state.attempt,
per_page: 50
}
})
.then(function decorateAnswerEvents(/* payload */) {
const answerEvents = events.filter(function(model) {
return model.get('type') === K.EVT_QUESTION_ANSWERED
})
QuestionAnsweredEventDecorator.run(answerEvents, questions)
return events
})
},
isLoading() {
return this.state.loading
},
getAll() {
return this.state.events.toJSON()
},
getQuestions() {
return this.state.questions.toJSON()
},
getSubmission() {
return this.state.submission.toJSON()
},
getAttempt() {
return this.state.attempt
},
getAvailableAttempts() {
return range(1, Math.max(1, (this.state.latestAttempt || 0) + 1))
},
setActiveAttempt(_attempt) {
const attempt = parseInt(_attempt, 10)
if (this.getAvailableAttempts().indexOf(attempt) === -1) {
throw new Error("Invalid attempt '" + attempt + "'")
} else if (this.state.attempt !== attempt) {
this.state.attempt = attempt
this.load()
}
}
},
Dispatcher
)

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,25 +16,23 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var $ = require('jquery');
var Void = require('canvas_packages/jquery/instructure_date_and_time');
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import QuestionListing from '../question_listing'
import assertChange from 'chai-assert-change'
import K from '../../constants'
var exports = {};
exports.friendlyDatetime = function(dateTime, perspective) {
var muddledDateTime = dateTime;
if (muddledDateTime) {
muddledDateTime.clone = function() {
return new Date(muddledDateTime.getTime());
};
}
return $.friendlyDatetime(muddledDateTime, perspective);
};
exports.fudgeDateForProfileTimezone = $.fudgeDateForProfileTimezone;
return exports;
});
describe('canvas_quizzes/events/views/question_listing', () => {
it('renders', () => {
render(
<MemoryRouter>
<QuestionListing
questions={[
{ id: 'q1', questionType: K.Q_SHORT_ANSWER }
]}
/>
</MemoryRouter>
)
})
})

View File

@ -0,0 +1,49 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import Session from '../session'
import assertChange from 'chai-assert-change'
import K from '../../constants'
describe('canvas_quizzes/events/views/session', () => {
it('renders', () => {
render(
<MemoryRouter>
<Session />
</MemoryRouter>
)
})
it('renders a link for every available attempt', () => {
const { queryByTestId, getByTestId } = render(
<MemoryRouter>
<Session availableAttempts={[1,2,3]} attempt={2} />
</MemoryRouter>
)
expect(queryByTestId('attempt-1')).toBeTruthy()
expect(getByTestId('attempt-1').nodeName).toBe('A')
expect(queryByTestId('attempt-3')).toBeTruthy()
expect(getByTestId('attempt-3').nodeName).toBe('A')
expect(queryByTestId('current-attempt')).toBeTruthy()
expect(getByTestId('current-attempt').nodeName).not.toBe('A')
})
})

View File

@ -0,0 +1,134 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import Cell from '../cell'
import assertChange from 'chai-assert-change'
import K from '../../../constants'
describe('canvas_quizzes/events/views/answer_matrix/cell', () => {
it('renders', () => {
render(
<Cell
question={{
id: 'q1'
}}
event={{
data: [{ quizQuestionId: 'q1' }]
}}
/>
)
})
it('renders nothing if there is no event', () => {
render(<Cell question={{ id: 'q1' }} />)
})
describe('when not expanded', () => {
const question = {
question: { id: '1', questionType: 'multiple_choice_question' }
}
it('shows an emblem for an empty answer', function() {
const { getByTestId } = render(
<Cell {...question}
event={{ data: [{ quizQuestionId: '1', answer: null }] }}
/>
);
expect(getByTestId('emblem').classList).toContain('is-empty')
});
it('shows an emblem for an answer', function() {
const { getByTestId } = render(
<Cell {...question}
event={{
data: [
{ quizQuestionId: '1', answer: '123', answered: true }
]
}}
/>
);
expect(getByTestId('emblem').classList).toContain('is-answered')
});
it('shows an emblem for the last answer', function() {
const { getByTestId } = render(
<Cell {...question}
event={{
data: [
{ quizQuestionId: '1', answer: '123', answered: true, last: true }
]
}}
/>
);
expect(getByTestId('emblem').classList).toContain('is-answered')
expect(getByTestId('emblem').classList).toContain('is-last')
});
it('shows nothing for no answer', function() {
const { queryByTestId } = render(
<Cell {...question} />
);
expect(queryByTestId('emblem')).toBeFalsy()
});
})
it('expands and truncates', () => {
render(
<Cell
expanded
shouldTruncate
maxVisibleChars={5}
question={{
id: 'q1',
questionType: K.Q_SHORT_ANSWER
}}
event={{
data: [{ quizQuestionId: 'q1', answer: 'hello world' }]
}}
/>
)
expect(document.body.textContent).toMatch('hello...')
})
it('expands as json', () => {
render(
<Cell
expanded
shouldTruncate
question={{
id: 'q1',
questionType: K.Q_MULTIPLE_CHOICE
}}
event={{
data: [{ quizQuestionId: 'q1' }]
}}
/>
)
})
})

View File

@ -0,0 +1,30 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import Emblem from '../emblem'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/answer_matrix/emblem', () => {
it('renders', () => {
render(
<Emblem />
)
})
})

View File

@ -0,0 +1,140 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import AnswerMatrix from '../'
import assertChange from 'chai-assert-change'
import K from '../../../constants'
describe('canvas_quizzes/events/views/answer_matrix', () => {
it('renders', () => {
render(
<MemoryRouter>
<AnswerMatrix />
</MemoryRouter>
)
})
it('truncates', () => {
const { getByText, getByTestId } = render(
<MemoryRouter>
<AnswerMatrix
maxVisibleChars={5}
questions={[
{ id: 'q1', questionType: K.Q_SHORT_ANSWER }
]}
events={[
{
id: 'e1',
type: K.EVT_QUESTION_ANSWERED,
createdAt: "2014-11-16T13:39:19Z",
data: [{ quizQuestionId: 'q1', answer: 'hello world', answered: true }]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
</MemoryRouter>
)
// we must expand it first
fireEvent.click(getByTestId('event-toggler-e1'))
assertChange({
fn: () => fireEvent.click( getByText('Truncate textual answers') ),
of: () => getByTestId('cell-e1').textContent,
from: 'hello world',
to: 'hello...'
})
})
it('expands all events', () => {
const { getByText, getByTestId } = render(
<MemoryRouter>
<AnswerMatrix
maxVisibleChars={5}
questions={[
{ id: 'q1', questionType: K.Q_SHORT_ANSWER }
]}
events={[
{
id: 'e1',
type: K.EVT_QUESTION_ANSWERED,
createdAt: "2014-11-16T13:39:19Z",
data: [{ quizQuestionId: 'q1', answer: 'hello world', answered: true }]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
</MemoryRouter>
)
assertChange({
fn: () => fireEvent.click( getByText('Expand all answers') ),
of: () => {
try { return !!getByTestId('cell-e1') } catch (e) { return false }
},
from: false,
to: true
})
})
it('inverts', () => {
const { getByText, getByTestId } = render(
<MemoryRouter>
<AnswerMatrix
maxVisibleChars={5}
questions={[
{ id: 'q1', questionType: K.Q_SHORT_ANSWER }
]}
events={[
{
id: 'e1',
type: K.EVT_QUESTION_ANSWERED,
createdAt: "2014-11-16T13:39:19Z",
data: [{ quizQuestionId: 'q1', answer: 'hello world', answered: true }]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
</MemoryRouter>
)
assertChange({
fn: () => fireEvent.click( getByText('Invert') ),
of: () => {
try { return !!getByTestId('question-toggler-q1') } catch (e) { return false }
},
from: false,
to: true
})
})
})

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import InvertedTable from '../inverted_table'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/answer_matrix/inverted_table', () => {
it('renders', () => {
render(
<InvertedTable
expandAll
questions={[
{ id: 'q1' }
]}
events={[
{
id: 'e1',
type: 'question_answered',
createdAt: "2014-11-16T13:39:19Z",
data: [{"quizQuestionId":"q1","answer":null,"answered":false}]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
)
})
it('expands a question when clicked', () => {
const { getByTestId } = render(
<InvertedTable
questions={[
{ id: 'q1' }
]}
events={[
{
id: 'e1',
type: 'question_answered',
createdAt: "2014-11-16T13:39:19Z",
data: [{"quizQuestionId":"q1","answer":'ANSWER!',"answered":false}]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
)
assertChange({
fn: () => fireEvent.click(getByTestId('question-toggler-q1')),
of: () => !!document.body.textContent.match('ANSWER!'),
from: false,
to: true
})
})
})

View File

@ -0,0 +1,30 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import Legend from '../legend'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/answer_matrix/legend', () => {
it('renders', () => {
render(
<Legend />
)
})
})

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import Option from '../option'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/answer_matrix/option', () => {
it('renders', () => {
render(
<Option />
)
})
it('emits onChange', () => {
const onChange = jest.fn()
const { getByTestId } = render(<Option onChange={onChange} />)
assertChange({
fn: () => fireEvent.click(getByTestId('checkbox')),
of: () => onChange.mock.calls.length,
by: 1
})
})
})

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import Table from '../table'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/answer_matrix/table', () => {
it('renders', () => {
render(
<Table
expandAll
questions={[
{ id: 'q1' }
]}
events={[
{
id: 'e1',
type: 'question_answered',
createdAt: "2014-11-16T13:39:19Z",
data: [{"quizQuestionId":"q1","answer":null,"answered":false}]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
)
})
it('expands a question when clicked', () => {
const { getByTestId } = render(
<Table
questions={[
{ id: 'q1' }
]}
events={[
{
id: 'e1',
type: 'question_answered',
createdAt: "2014-11-16T13:39:19Z",
data: [{"quizQuestionId":"q1","answer":'ANSWER!',"answered":false}]
}
]}
submission={{
"startedAt": "2014-11-16T13:37:19Z"
}}
/>
)
assertChange({
fn: () => fireEvent.click(getByTestId('event-toggler-e1')),
of: () => !!document.body.textContent.match('ANSWER!'),
from: false,
to: true
})
})
})

View File

@ -0,0 +1,80 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import K from '../../constants'
import Emblem from './emblem'
// These questions types will have their answer cells truncated if it goes
// over the character visibility threshold:
const FREE_FORM_QUESTION_TYPES = [K.Q_ESSAY, K.Q_SHORT_ANSWER]
/**
* @class Cell
* @memberOf Views.AnswerMatrix
*
* A table cell that renders an answer to a question, based on the question
* type, the table options, and other things.
*/
const Cell = props => {
let formattedAnswer, answerSz, encodeAsJson
const record = props.event.data.find(x => x.quizQuestionId === props.question.id)
if (!record) {
return null
}
formattedAnswer = record.answer
encodeAsJson = true
// show the answer only if the expandAll option is turned on, or the
// current event is activated (i.e, the row was clicked):
if (props.expanded) {
if (FREE_FORM_QUESTION_TYPES.indexOf(props.question.questionType) > -1) {
encodeAsJson = false
if (props.shouldTruncate) {
formattedAnswer = record.answer || ''
answerSz = formattedAnswer.length
if (answerSz > props.maxVisibleChars) {
formattedAnswer = formattedAnswer.substr(0, props.maxVisibleChars)
formattedAnswer += '...'
}
}
}
return (
<pre data-testid={`cell-${props.event.id}`}>
{encodeAsJson ? JSON.stringify(formattedAnswer, null, 2) : formattedAnswer}
</pre>
)
} else {
return <Emblem {...record} />
}
}
Cell.defaultProps = {
expanded: false,
shouldTruncate: false,
event: {data: []},
question: {},
maxVisibleChars: K.MAX_VISIBLE_CHARS
}
export default Cell

View File

@ -0,0 +1,49 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
/**
* @class Events.Views.AnswerMatrix.Emblem
*
* Woop.
*
* @seed An emblem for an empty answer.
* {}
*
* @seed An emblem for some answer.
* { "answered": true }
*
* @seed An emblem for the final answer.
* { "answered": true, "last": true }
*/
const Emblem = ({answered, last}) => {
let className = 'ic-AnswerMatrix__Emblem'
if (answered && last) {
className += ' is-answered is-last'
} else if (answered) {
className += ' is-answered'
} else {
className += ' is-empty'
}
return <i data-testid="emblem" className={className} />
}
export default Emblem

View File

@ -0,0 +1,133 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import I18n from 'i18n!quiz_log_auditing.table_view'
import InvertedTable from './inverted_table'
import K from '../../constants'
import Legend from './legend'
import Option from './option'
import React from 'react'
import {Link} from 'react-router-dom'
import Table from './table'
class AnswerMatrix extends React.Component {
state = {
activeEventId: null,
shouldTruncate: false,
expandAll: false,
invert: false
}
static defaultProps = {
questions: [],
events: [],
submission: {
createdAt: new Date().toJSON()
}
}
render() {
const events = this.props.events.filter(function(e) {
return e.type === K.EVT_QUESTION_ANSWERED
})
let className
if (this.state.expandAll) {
className = 'expanded'
}
return (
<div data-testid="answer-matrix" id="ic-AnswerMatrix" className={className}>
<h1 className="ic-QuizInspector__Header">
{I18n.t('Answer Sequence')}
<div className="ic-QuizInspector__HeaderControls">
<Option
onChange={this.setOption.bind(this)}
name="shouldTruncate"
label={I18n.t('options.truncate', 'Truncate textual answers')}
checked={this.state.shouldTruncate}
/>
<Option
onChange={this.setOption.bind(this)}
name="expandAll"
label={I18n.t('options.expand_all', 'Expand all answers')}
checked={this.state.expandAll}
/>
<Option
onChange={this.setOption.bind(this)}
name="invert"
label={I18n.t('options.invert', 'Invert')}
checked={this.state.invert}
/>
<Link to={{pathname: '/', search: window.location.search}} className="btn btn-default">
{I18n.t('buttons.go_to_stream', 'View Stream')}
</Link>
</div>
</h1>
<Legend />
<div className="table-scroller">
{this.state.invert ? this.renderInverted(events) : this.renderNormal(events)}
</div>
</div>
)
}
renderNormal(events) {
return (
<Table
events={events}
questions={this.props.questions}
submission={this.props.submission}
expandAll={this.state.expandAll}
shouldTruncate={this.state.shouldTruncate}
maxVisibleChars={this.props.maxVisibleChars}
/>
)
}
renderInverted(events) {
return (
<InvertedTable
events={events}
questions={this.props.questions}
submission={this.props.submission}
expandAll={this.state.expandAll}
shouldTruncate={this.state.shouldTruncate}
activeEventId={this.state.activeEventId}
maxVisibleChars={this.props.maxVisibleChars}
/>
)
}
setOption(option, isChecked) {
const newState = {}
newState[option] = isChecked
this.setState(newState)
}
}
export default AnswerMatrix

View File

@ -0,0 +1,93 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Cell from './cell'
import I18n from 'i18n!quiz_log_auditing.inverted_table_view'
import React from 'react'
import secondsToTime from '../../../shared/util/seconds_to_time'
/**
* @class Events.Views.AnswerMatrix.InvertedTable
*
* A table displaying the event series on the X axis, and the questions
* on the Y axis. This table is optimal for inspecting answer contents, while
* the "normal" table is optimized for viewing the answer sequence.
*/
class InvertedTable extends React.Component {
state = {
activeQuestionId: null
}
render() {
return (
<table className="ic-AnswerMatrix__Table ic-Table ic-Table--hover-row ic-Table--striped">
<thead>
<tr className="ic-Table__row--bg-neutral">
<th key="question">{I18n.t('Question')}</th>
{this.props.events.map(this.renderHeaderCell.bind(this))}
</tr>
</thead>
<tbody>{this.props.questions.map(this.renderContentRow.bind(this))}</tbody>
</table>
)
}
renderHeaderCell(event) {
const secondsSinceStart =
(new Date(event.createdAt) - new Date(this.props.submission.startedAt)) / 1000
return <th key={'header-' + event.id}>{secondsToTime(secondsSinceStart)}</th>
}
renderContentRow(question) {
const expanded = this.props.expandAll || question.id === this.state.activeQuestionId
const shouldTruncate = this.props.shouldTruncate
return (
<tr
key={'question-' + question.id}
onClick={this.toggleAnswerVisibility.bind(this, question)}
data-testid={`question-toggler-${question.id}`}
>
<td key="question">{question.id}</td>
{this.props.events.map(event => (
<td key={['q', question.id, 'e', event.id].join('_')}>
<Cell
question={question}
event={event}
expanded={expanded}
shouldTruncate={shouldTruncate}
maxVisibleChars={this.props.maxVisibleChars}
/>
</td>
))}
</tr>
)
}
toggleAnswerVisibility(question) {
this.setState(state => ({
activeQuestionId: question.id === state.activeQuestionId ? null : question.id
}))
}
}
export default InvertedTable

View File

@ -0,0 +1,56 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Emblem from './emblem'
import I18n from 'i18n!quiz_log_auditing.table_view'
import React from 'react'
/**
* @class Events.Views.AnswerMatrix.Legend
*
* A legend that explains what each type of "answer circle" denotes.
*
* @seed
* {}
*/
const Legend = () => (
<dl id="ic-AnswerMatrix__Legend">
<dt>{I18n.t('legend.empty_circle', 'Empty Circle')}</dt>
<dd>
<Emblem />
{I18n.t('legend.empty_circle_desc', 'An empty answer.')}
</dd>
<dt>{I18n.t('legend.dotted_circle', 'Dotted Circle')}</dt>
<dd>
<Emblem answered />
{I18n.t('legend.dotted_circle_desc', 'An answer, regardless of correctness.')}
</dd>
<dt>{I18n.t('legend.filled_circle', 'Filled Circle')}</dt>
<dd>
<Emblem answered last />
{I18n.t(
'legend.filled_circle_desc',
'The final answer for the question, the one that counts.'
)}
</dd>
</dl>
)
export default Legend

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,22 +16,23 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@import "/vendor/canvas_public/stylesheets_compiled/new_styles_normal_contrast/pages/g_vendor.css";
@import "/vendor/canvas_public/stylesheets_compiled/new_styles_normal_contrast/base/c-common.css";
@import "/dist/canvas_quizzes.css";
import React from 'react'
.seed-data { display: none; }
.seed-name {
cursor: pointer;
const Option = ({checked, label, name, onChange}) => {
return (
// it's a fluke since we are passing children for the label
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label>
<input
data-testid="checkbox"
type="checkbox"
onChange={e => onChange(name, e.target.checked)}
checked={checked}
/>
{label}
</label>
)
}
.seed-runner {
max-width: 100%;
max-height: 320px;
overflow: auto;
}
.seed-runner:not(:empty) {
margin: 10px;
margin-left: -9px;
}
export default Option

View File

@ -0,0 +1,121 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Cell from './cell'
import I18n from 'i18n!quiz_log_auditing.table_view'
import React from 'react'
import secondsToTime from '../../../shared/util/seconds_to_time'
/**
* @class Events.Views.AnswerMatrix.Table
*
* A table displaying the sequence of answers the student has provided to all
* the questions. The answer cells will variate in shape based on the presence
* of the answer and its position.
*
* @see Events.Views.AnswerMatrix.Emblem
*
* @seed A table of 8 questions and 25 events.
* "apps/events/test/fixtures/loaded_table.json"
*/
class Table extends React.Component {
state = {
activeEventId: null
}
static defaultProps = {
questions: [],
events: [],
submission: {}
}
render() {
return (
<table className="ic-AnswerMatrix__Table ic-Table ic-Table--hover-row ic-Table--condensed">
<thead>
<tr className="ic-Table__row--bg-neutral">
<th key="timestamp">
<div>{I18n.t('headers.timestamp', 'Timestamp')}</div>
</th>
{this.props.questions.map(this.renderHeaderCell.bind(this))}
</tr>
</thead>
<tbody>{this.props.events.map(this.renderContentRow.bind(this))}</tbody>
</table>
)
}
renderHeaderCell(question) {
return (
<th key={'question-' + question.id}>
<div>
{I18n.t('headers.question', 'Question %{position}', {
position: question.position
})}
<small>({question.id})</small>
</div>
</th>
)
}
renderContentRow(event) {
let className
const expanded = this.props.expandAll || event.id === this.state.activeEventId
const shouldTruncate = this.props.shouldTruncate
const secondsSinceStart =
(new Date(event.createdAt) - new Date(this.props.submission.startedAt)) / 1000
if (this.props.activeEventId === event.id) {
className = 'active'
}
return (
<tr
key={'event-' + event.id}
className={className}
onClick={this.toggleAnswerVisibility.bind(this, event)}
data-testid={`event-toggler-${event.id}`}
>
<td>{secondsToTime(secondsSinceStart)}</td>
{this.props.questions.map(question => (
<td key={['q', question.id, 'e', event.id].join('_')}>
<Cell
question={question}
event={event}
expanded={expanded}
shouldTruncate={shouldTruncate}
maxVisibleChars={this.props.maxVisibleChars}
/>
</td>
))}
</tr>
)
}
toggleAnswerVisibility(event) {
this.setState(state => ({
activeEventId: event.id === state.activeEventId ? null : event.id
}))
}
}
export default Table

View File

@ -0,0 +1,114 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import Event from '../event'
import assertChange from 'chai-assert-change'
import K from '../../../constants'
describe('canvas_quizzes/events/views/event_stream/event', () => {
it('renders EVT_SESSION_STARTED', () => {
render(
<MemoryRouter>
<Event
createdAt="2014-11-16T13:39:19Z"
startedAt="2014-11-16T13:37:19Z"
type={K.EVT_SESSION_STARTED}
/>
</MemoryRouter>
)
})
it('renders EVT_QUESTION_ANSWERED', () => {
render(
<MemoryRouter>
<Event
createdAt="2014-11-16T13:39:19Z"
startedAt="2014-11-16T13:37:19Z"
type={K.EVT_QUESTION_ANSWERED}
questions={[{ id: 'q1', questionType: K.Q_SHORT_ANSWER }]}
data={[
{ quizQuestionId: 'q1', answer: 'hello world', answered: true }
]}
/>
</MemoryRouter>
)
})
it('renders EVT_QUESTION_VIEWED', () => {
render(
<MemoryRouter>
<Event
createdAt="2014-11-16T13:39:19Z"
startedAt="2014-11-16T13:37:19Z"
type={K.EVT_QUESTION_VIEWED}
questions={[{ id: 'q1', questionType: K.Q_SHORT_ANSWER }]}
data={[
{ quizQuestionId: 'q1', answer: 'hello world', answered: true }
]}
/>
</MemoryRouter>
)
})
it('renders EVT_PAGE_BLURRED', () => {
render(
<MemoryRouter>
<Event
createdAt="2014-11-16T13:39:19Z"
startedAt="2014-11-16T13:37:19Z"
type={K.EVT_QUESTION_VIEWED}
questions={[]}
data={[]}
/>
</MemoryRouter>
)
})
it('renders EVT_PAGE_FOCUSED', () => {
render(
<MemoryRouter>
<Event
createdAt="2014-11-16T13:39:19Z"
startedAt="2014-11-16T13:37:19Z"
type={K.EVT_QUESTION_VIEWED}
questions={[]}
data={[]}
/>
</MemoryRouter>
)
})
it('renders EVT_QUESTION_FLAGGED', () => {
render(
<MemoryRouter>
<Event
createdAt="2014-11-16T13:39:19Z"
startedAt="2014-11-16T13:37:19Z"
type={K.EVT_QUESTION_FLAGGED}
questions={[{ id: 'q1', questionType: K.Q_SHORT_ANSWER }]}
data={[
{ quizQuestionId: 'q1', answer: 'hello world', answered: true }
]}
/>
</MemoryRouter>
)
})
})

View File

@ -0,0 +1,43 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import { MemoryRouter } from 'react-router-dom'
import EventStream from '../'
import assertChange from 'chai-assert-change'
import K from '../../../constants'
describe('canvas_quizzes/events/views/event_stream', () => {
it('renders', () => {
render(
<MemoryRouter>
<EventStream
events={[
{
id: 'e1',
createdAt: "2014-11-16T13:39:19Z",
startedAt: "2014-11-16T13:37:19Z",
type: K.EVT_SESSION_STARTED,
}
]}
/>
</MemoryRouter>
)
})
})

View File

@ -0,0 +1,187 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import classSet from '../../../shared/util/class_set'
import I18n from 'i18n!quiz_log_auditing.event_stream'
import K from '../../constants'
import React from 'react'
import secondsToTime from '../../../shared/util/seconds_to_time'
import SightedUserContent from '../../../shared/components/sighted_user_content'
import {Link} from 'react-router-dom'
import {IconTroubleLine, IconCompleteLine, IconEmptyLine} from '@instructure/ui-icons'
class Event extends React.Component {
static defaultProps = {
startedAt: new Date()
}
render() {
const e = this.props
const className = classSet({
'ic-ActionLog__Entry': true,
'is-warning': e.flag === K.EVT_FLAG_WARNING,
'is-ok': e.flag === K.EVT_FLAG_OK,
'is-neutral': !e.flag
})
return (
<li className={className} key={'event-' + e.id}>
{this.renderRow(e)}
</li>
)
}
renderRow(e) {
const secondsSinceStart = (new Date(e.createdAt) - new Date(e.startedAt)) / 1000
return (
<div>
<span className="ic-ActionLog__EntryTimestamp">{secondsToTime(secondsSinceStart)}</span>
<SightedUserContent className="ic-ActionLog__EntryFlag">
{this.renderFlag(e.flag)}
</SightedUserContent>
<div className="ic-ActionLog__EntryDescription">{this.renderDescription(e)}</div>
</div>
)
}
renderFlag(flag) {
if (flag === K.EVT_FLAG_WARNING) {
return <IconTroubleLine />
} else if (flag === K.EVT_FLAG_OK) {
return <IconCompleteLine />
} else {
return <IconEmptyLine />
}
}
renderDescription(event) {
switch (event.type) {
case K.EVT_SESSION_STARTED:
return I18n.t('session_started', 'Session started')
case K.EVT_QUESTION_ANSWERED: {
let valid_answers = event.data.filter(function(i) {
return i.answer != null
})
if (valid_answers.length === 0) {
return null
}
return (
<div>
{I18n.t(
'question_answered',
{
one: 'Answered question:',
other: 'Answered the following questions:'
},
{count: valid_answers.length}
)}
<div className="ic-QuestionAnchors">
{valid_answers.map(this.renderQuestionAnchor.bind(this))}
</div>
</div>
)
}
case K.EVT_QUESTION_VIEWED:
return (
<div>
{I18n.t(
'question_viewed',
{
one: 'Viewed (and possibly read) question',
other: 'Viewed (and possibly read) the following questions:'
},
{count: event.data.length}
)}
<div className="ic-QuestionAnchors">
{event.data.map(this.renderQuestionAnchor.bind(this))}
</div>
</div>
)
case K.EVT_PAGE_BLURRED:
return I18n.t('page_blurred', 'Stopped viewing the Canvas quiz-taking page...')
case K.EVT_PAGE_FOCUSED:
return I18n.t('page_focused', 'Resumed.')
case K.EVT_QUESTION_FLAGGED: {
let label
if (event.data.flagged) {
label = I18n.t('question_flagged', 'Flagged question:')
} else {
label = I18n.t('question_unflagged', 'Unflagged question:')
}
return (
<div>
{label}
<div className="ic-QuestionAnchors">
{this.renderQuestionAnchor(event.data.questionId)}
</div>
</div>
)
}
default:
return null
}
}
renderQuestionAnchor(record) {
let id
let question
let position
if (typeof record === 'object') {
id = record.quizQuestionId
} else {
id = record
}
question = this.props.questions.find(q => {
return q.id === id
})
position = question && question.position
return (
<Link
key={'question-anchor' + id}
to={{
pathname: `/questions/${id}`,
search: `?event=${this.props.id}&attempt=${this.props.attempt}`
}}
className="ic-QuestionAnchors__Anchor"
>
{'#' + position}
</Link>
)
}
}
export default Event

View File

@ -0,0 +1,87 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Event from './event'
import I18n from 'i18n!quiz_log_auditing.event_stream'
import K from '../../constants'
import React from 'react'
const visibleEventTypes = [
K.EVT_PAGE_BLURRED,
K.EVT_PAGE_FOCUSED,
K.EVT_QUESTION_ANSWERED,
K.EVT_QUESTION_FLAGGED,
K.EVT_QUESTION_VIEWED,
K.EVT_SESSION_STARTED
]
class EventStream extends React.Component {
static defaultProps = {
events: [],
submission: {},
questions: []
}
render() {
const visibleEvents = this.getVisibleEvents(this.props.events)
return (
<div data-testid="event-stream" id="ic-EventStream">
<h2>{I18n.t('headers.action_log', 'Action Log')}</h2>
{visibleEvents.length === 0 && (
<p>
{I18n.t(
'notices.no_events_available',
'There were no events logged during the quiz-taking session.'
)}
</p>
)}
<ol id="ic-EventStream__ActionLog">{visibleEvents.map(this.renderEvent.bind(this))}</ol>
</div>
)
}
renderEvent(e) {
const props = {
...e,
startedAt: this.props.submission.startedAt,
questions: this.props.questions,
attempt: this.props.attempt
}
return <Event key={e.id} {...props} />
}
getVisibleEvents(events) {
return events.filter(function(e) {
if (visibleEventTypes.indexOf(e.type) === -1) {
return false
}
if (e.type !== K.EVT_QUESTION_ANSWERED) {
return true
}
return e.data.some(i => {
return i.answer != null
})
})
}
}
export default EventStream

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Essay from './answers/essay'
import FIMB from './answers/fill_in_multiple_blanks'
import Matching from './answers/matching'
import MultipleAnswers from './answers/multiple_answers'
import MultipleChoice from './answers/multiple_choice'
import MultipleDropdowns from './answers/multiple_dropdowns'
import React from 'react'
const GenericRenderer = props => <div>{'' + props.answer}</div>
const Renderers = [Essay, FIMB, Matching, MultipleAnswers, MultipleChoice, MultipleDropdowns]
const getRenderer = questionType => (
Renderers.find(entry => entry.questionTypes.includes(questionType)) || GenericRenderer
)
const Answer = props => {
const Renderer = getRenderer(props.question.questionType)
return <Renderer {...props} />
}
export default Answer

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import Essay from '../essay'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/question_inspector/answers/essay', () => {
it('renders', () => {
render(<Essay answer="yea!" />)
expect(document.body.textContent).toMatch('yea!')
})
it('toggles between views', () => {
const { getByText } = render(<Essay answer="<span id='custom-el'>yea!</span>" />)
assertChange({
fn: () => fireEvent.click(getByText('View HTML')),
of: () => !!document.getElementById('custom-el'),
from: false,
to: true
})
})
})

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,12 +16,14 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// Creates a DOM element that ReactSuite tests will use to mount the subject
// in. Although jasmine_react does that automatically on the start of each
// ReactSuite, we will prepare it before-hand and expose it to jasmine.fixture
// if you need to access directly.
require([ 'jasmine_react' ], function(ReactSuite) {
console.log('Preparing jasmine DOM fixture at `jasmine.fixture`');
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import FillInMultipleBlanks from '../fill_in_multiple_blanks'
import assertChange from 'chai-assert-change'
jasmine.fixture = ReactSuite.createDOMFixture();
});
describe('canvas_quizzes/events/views/question_inspector/answers/fill_in_multiple_blanks', () => {
it('renders', () => {
render(<FillInMultipleBlanks answer={{one: 'yea!'}} />)
expect(document.body.textContent).toMatch('yea!')
})
})

View File

@ -0,0 +1,37 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import Matching from '../matching'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/question_inspector/answers/matching', () => {
it('renders', () => {
render(<Matching
question={{
answers: [{ id: 1, match_id: 2, left: '[match one]', right: 'nope' }],
matches: [{ match_id: 2, text: '[did match]' }]
}}
answer={[{ answer_id: 1, match_id: 2 }]}
/>)
expect(document.body.textContent).toMatch('[match one]')
expect(document.body.textContent).toMatch('[did match]')
})
})

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import MultipleAnswers from '../multiple_answers'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/question_inspector/answers/multiple_answers', () => {
it('renders', () => {
const { getByTestId } = render(<MultipleAnswers
question={{
answers: [
{ id: 1, text: 'one' },
{ id: 2, text: 'two' },
{ id: 3, text: 'three' }
],
}}
answer={['1', '3']}
/>)
expect(getByTestId('answer-1').checked).toBe(true)
expect(getByTestId('answer-2').checked).toBe(false)
expect(getByTestId('answer-3').checked).toBe(true)
})
})

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import MultipleChoice from '../multiple_choice'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/question_inspector/answers/multiple_choice', () => {
it('renders', () => {
const { getByTestId } = render(<MultipleChoice
question={{
answers: [
{ id: 1, text: 'one' },
{ id: 2, text: 'two' },
{ id: 3, text: 'three' }
],
}}
answer="1"
/>)
expect(getByTestId('answer-1').checked).toBe(true)
expect(getByTestId('answer-2').checked).toBe(false)
expect(getByTestId('answer-3').checked).toBe(false)
})
})

View File

@ -0,0 +1,41 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import MultipleDropdowns from '../multiple_dropdowns'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/question_inspector/answers/multiple_dropdowns', () => {
it('renders', () => {
render(<MultipleDropdowns
question={{
answers: [
{ id: 1, text: 'yea!' },
{ id: 2, text: 'two' },
{ id: 3, text: 'three' }
],
}}
answer={{
'one': '1'
}}
/>)
expect(document.body.textContent).toMatch('yea!')
})
})

View File

@ -0,0 +1,29 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import NoAnswer from '../no_answer'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/events/views/question_inspector/answers/no_answer', () => {
it('renders', () => {
render(<NoAnswer />)
expect(document.body.textContent).toMatch('No answer')
})
})

View File

@ -0,0 +1,62 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Button from '../../../components/button'
import I18n from 'i18n!quiz_log_auditing.question_answers.essay'
import K from '../../../constants'
import React from 'react'
class Essay extends React.Component {
static defaultProps = {
answer: ''
}
state = {
htmlView: false
}
render() {
let content
if (this.state.htmlView) {
content = <div dangerouslySetInnerHTML={{__html: this.props.answer}} />
} else {
content = <pre>{this.props.answer}</pre>
}
return (
<div>
{content}
<Button type="default" onClick={this.toggleView.bind(this)}>
{this.state.htmlView
? I18n.t('view_plain_answer', 'View Plain')
: I18n.t('view_html_answer', 'View HTML')}
</Button>
</div>
)
}
toggleView() {
this.setState(state => ({htmlView: !state.htmlView}))
}
}
Essay.questionTypes = [K.Q_ESSAY]
export default Essay

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,22 +16,25 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var Dispatcher = require('./core/dispatcher');
var EventStore = require('./stores/events');
var Actions = {};
import React from 'react'
import K from '../../../constants'
import NO_ANSWER from './no_answer'
Actions.dismissNotification = function(key) {
return Dispatcher.dispatch('notifications:dismiss', key).promise;
};
const FIMB = ({ answer }) => (
<table>
<tbody>
{Object.keys(answer).map(function(blank) {
return (
<tr key={'blank' + blank}>
<th scope="row">{blank}</th>
<td>{answer[blank] || NO_ANSWER}</td>
</tr>
)
})}
</tbody>
</table>
)
Actions.reloadEvents = function() {
EventStore.load();
};
FIMB.questionTypes = [K.Q_FILL_IN_MULTIPLE_BLANKS]
Actions.setActiveAttempt = function(attempt) {
EventStore.setActiveAttempt(attempt);
};
return Actions;
});
export default FIMB

View File

@ -0,0 +1,51 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import K from '../../../constants'
import NO_ANSWER from './no_answer'
import React from 'react'
const Matching = ({ answer, question }) => (
<table>
<tbody>
{question.answers.map(questionAnswer => {
let match
const answerRecord = answer.find(
record => String(record.answer_id) === String(questionAnswer.id)
)
if (answerRecord) {
match = question.matches.find(
match => String(match.match_id) === String(answerRecord.match_id)
)
}
return (
<tr key={'answer-' + questionAnswer.id}>
<th scope="col">{questionAnswer.left}</th>
<td>{match ? match.text : NO_ANSWER}</td>
</tr>
)
})}
</tbody>
</table>
)
Matching.questionTypes = [K.Q_MATCHING]
export default Matching

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import K from '../../../constants'
class MultipleAnswers extends React.Component {
static defaultProps = {
answer: [],
question: {answers: []}
}
render() {
return (
<div className="ic-QuestionInspector__MultipleAnswers">
{this.props.question.answers.map(this.renderAnswer.bind(this))}
</div>
)
}
renderAnswer(answer) {
const isSelected = this.props.answer.indexOf('' + answer.id) > -1
return (
<div key={'answer' + answer.id}>
<input
data-testid={`answer-${answer.id}`}
type="checkbox"
readOnly
disabled={!isSelected}
checked={isSelected}
/>
{answer.text}
</div>
)
}
}
MultipleAnswers.questionTypes = [K.Q_MULTIPLE_ANSWERS]
export default MultipleAnswers

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import K from '../../../constants'
class MultipleChoice extends React.Component {
static defaultProps = {
answer: [],
question: {answers: []}
}
render() {
return (
<div className="ic-QuestionInspector__MultipleChoice">
{this.props.question.answers.map(this.renderAnswer.bind(this))}
</div>
)
}
renderAnswer(answer) {
const isSelected = this.props.answer.indexOf('' + answer.id) > -1
return (
<div key={'answer' + answer.id}>
<input
data-testid={`answer-${answer.id}`}
type="radio"
readOnly
disabled={!isSelected}
checked={isSelected}
/>
{answer.text}
</div>
)
}
}
MultipleChoice.questionTypes = [K.Q_MULTIPLE_CHOICE, K.Q_TRUE_FALSE]
export default MultipleChoice

View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import K from '../../../constants'
import NO_ANSWER from './no_answer'
class MultipleDropdowns extends React.Component {
render() {
const {question} = this.props
const studentAnswer = this.props.answer
return (
<table>
<tbody>
{Object.keys(studentAnswer).map(blank => {
const answerText =
question.answers.find(answer => {
return '' + answer.id === studentAnswer[blank]
}) || {}
return (
<tr key={'blank' + blank}>
<th scope="row">{blank}</th>
<td>{answerText.text || NO_ANSWER}</td>
</tr>
)
})}
</tbody>
</table>
)
}
}
MultipleDropdowns.questionTypes = [K.Q_MULTIPLE_DROPDOWNS]
export default MultipleDropdowns

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import I18n from 'i18n!quiz_log_auditing'
export default () => (
<em className="ic-QuestionInspector__NoAnswer">{I18n.t('no_answer', 'No answer')}</em>
)

View File

@ -0,0 +1,140 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import $ from 'jquery'
import Answer from './answer'
import classSet from '../../../shared/util/class_set'
import I18n from 'i18n!quiz_log_auditing'
import K from '../../constants'
import NoAnswer from './answers/no_answer'
import React from 'react'
class QuestionInspector extends React.Component {
static defaultProps = {
question: undefined,
events: []
}
componentDidMount() {
$('body').addClass('with-right-side')
}
componentWillUnmount() {
$('body').removeClass('with-right-side')
}
render() {
return (
<div id="ic-QuizInspector__QuestionInspector">
{this.props.question && this.renderQuestion(this.props.question)}
</div>
)
}
renderQuestion(question) {
const currentEventId = this.props.currentEventId
const answers = []
this.props.events
.filter(function(event) {
return (
event.type === K.EVT_QUESTION_ANSWERED &&
event.data.some(function(record) {
return record.quizQuestionId === question.id
})
)
})
.sort(function(a, b) {
return new Date(a.createdAt) - new Date(b.createdAt)
})
.forEach(function(event) {
const records = event.data.filter(function(record) {
return record.quizQuestionId === question.id
})
records.forEach(function(record) {
answers.push({
active: event.id === currentEventId,
value: record.answer,
answered: record.answered
})
})
})
return (
<div>
<h1 className="ic-QuestionInspector__QuestionHeader">
{I18n.t('question_header', 'Question #%{position}', {
position: question.position
})}
<span className="ic-QuestionInspector__QuestionType">
{I18n.t('question_type', '%{type}', {type: question.readableType})}
</span>
<span className="ic-QuestionInspector__QuestionId">(id: {question.id})</span>
</h1>
<div
className="ic-QuestionInspector__QuestionText"
dangerouslySetInnerHTML={{__html: question.questionText}}
/>
<hr />
<p>
{I18n.t(
'question_response_count',
{
zero: 'This question was never answered.',
one: 'This question was answered once.',
other: 'This question was answered %{count} times.'
},
{count: answers.length}
)}
</p>
<ol id="ic-QuestionInspector__Answers">{answers.map(this.renderAnswer.bind(this))}</ol>
</div>
)
}
renderAnswer(record, index) {
let answer
const className = classSet({
'ic-QuestionInspector__Answer': true,
'ic-QuestionInspector__Answer--is-active': !!record.active
})
if (record.answered) {
answer = (
<Answer
key={'answer-' + index}
answer={record.value}
isActive={record.active}
question={this.props.question}
/>
)
} else {
answer = <NoAnswer />
}
return <li key={'answer-' + index} className={className}>{answer}</li>
}
}
export default QuestionInspector

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import I18n from 'i18n!quiz_log_auditing.navigation'
import React from 'react'
import {Link} from 'react-router-dom'
import {IconArrowStartLine} from '@instructure/ui-icons'
class QuestionListing extends React.Component {
static defaultProps = {
questions: [],
activeQuestionId: undefined,
activeEventId: undefined
}
render() {
return (
<div>
<h2>{I18n.t('questions', 'Questions')}</h2>
<ol id="ic-QuizInspector__QuestionListing">
{this.props.questions
.sort(function(a, b) {
return a.position > b.position
})
.map(this.renderQuestion.bind(this))}
</ol>
<Link
className="no-hover"
to={{
pathname: '/',
search: window.location.search
}}
>
<IconArrowStartLine /> {I18n.t('links.back_to_session_information', 'Back to Log')}
</Link>
</div>
)
}
renderQuestion(question) {
return (
<li key={question.id}>
<Link
className={this.props.activeQuestionId === question.id ? 'active' : undefined}
to={{
pathname: `/questions/${question.id}`,
search: window.location.search
}}
>
{I18n.t('links.question', 'Question %{position}', {
position: question.position
})}
</Link>
</li>
)
}
}
export default QuestionListing

View File

@ -0,0 +1,141 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import Actions from '../actions'
import Button from '../components/button'
import Config from '../config'
import I18n from 'i18n!quiz_log_auditing'
import React from 'react'
import ScreenReaderContent from '../../shared/components/screen_reader_content'
import {IconRefreshLine} from '@instructure/ui-icons'
import {Link} from 'react-router-dom'
class Session extends React.Component {
static defaultProps = {
submission: {},
availableAttempts: []
}
state = {
accessibilityWarningFocused: false
}
render() {
let accessibilityWarningClasses = 'ic-QuizInspector__accessibility-warning'
if (!this.state.accessibilityWarningFocused) {
accessibilityWarningClasses += ' screenreader-only'
}
const warningMessage = I18n.t(
'links.log_accessibility_warning',
'Warning: For improved accessibility when using Quiz Logs, please remain in the current Stream View.'
)
return (
<div id="ic-QuizInspector__Session">
<div className="ic-QuizInspector__Header">
<h1>{I18n.t('page_header', 'Session Information')}</h1>
<div className="ic-QuizInspector__HeaderControls">
<Button onClick={Actions.reloadEvents}>
<ScreenReaderContent>{I18n.t('buttons.reload_events', 'Reload')}</ScreenReaderContent>
<IconRefreshLine />
</Button>{' '}
{Config.allowMatrixView && (
<span>
<span
id="refreshButtonDescription"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex="0"
className={accessibilityWarningClasses}
onFocus={this.toggleViewable.bind(this)}
onBlur={this.toggleViewable.bind(this)}
aria-label={warningMessage}
>
{warningMessage}
</span>
<Link
data-testid="view-table-button"
to={{pathname: '/answer_matrix', search: window.location.search}}
className="btn btn-default"
aria-describedby="refreshButtonDescription"
>
{I18n.t('buttons.table_view', 'View Table')}
</Link>
</span>
)}
</div>
</div>
<table>
<tbody>
<tr>
<th scope="row">{I18n.t('session_table_headers.started_at', 'Started at')}</th>
<td>{new Date(this.props.submission.startedAt).toString()}</td>
</tr>
<tr>
<th scope="row">{I18n.t('session_table_headers.attempt', 'Attempt')}</th>
<td>
<div id="ic-QuizInspector__AttemptController">
{this.props.availableAttempts.map(this.renderAttemptLink.bind(this))}
</div>
</td>
</tr>
</tbody>
</table>
</div>
)
}
renderAttemptLink(attempt) {
let className = 'ic-AttemptController__Attempt'
if (attempt === this.props.attempt) {
className += ' ic-AttemptController__Attempt--is-active'
return (
<div data-testid="current-attempt" className={className} key={'attempt-' + attempt}>
{attempt}
</div>
)
} else {
return (
<Link
data-testid={`attempt-${attempt}`}
key={attempt}
to={{
pathname: '/',
search: `?attempt=${attempt}`
}}
className={className}
>
{attempt}
</Link>
)
}
}
toggleViewable() {
this.setState(state => ({
accessibilityWarningFocused: !state.accessibilityWarningFocused
}))
}
}
export default Session

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import ScreenReaderContent from '../screen_reader_content'
import assertChange from 'chai-assert-change'
describe('canvas_quizzes/components/screen_reader_content', () => {
it('renders', () => {
render(<ScreenReaderContent>yea!</ScreenReaderContent>)
expect(document.body.textContent).toMatch('yea!')
})
it('forces sentence delimiter', () => {
render(<ScreenReaderContent forceSentenceDelimiter>yea!</ScreenReaderContent>)
expect(document.body.textContent).toMatch('yea!')
})
it('rejects html', () => {
render(<ScreenReaderContent forceSentenceDelimiter dangerouslySetInnerHTML={{__html: '<span>yea!</span>'}} />)
expect(document.body.textContent).not.toMatch('yea!')
})
})

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,18 +16,14 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var Backbone = require('canvas_packages/backbone');
var pickAndNormalize = require('./common/pick_and_normalize');
var K = require('../constants');
import {act, render, fireEvent} from '@testing-library/react'
import React from 'react'
import SightedUserContent from '../sighted_user_content'
import assertChange from 'chai-assert-change'
return Backbone.Model.extend({
url: function() {
return this.get('url');
},
parse: function(payload) {
return pickAndNormalize(payload, K.PROGRESS_ATTRS);
},
});
});
describe('canvas_quizzes/components/sighted_user_content', () => {
it('renders', () => {
render(<SightedUserContent>yea!</SightedUserContent>)
expect(document.body.textContent).toMatch('yea!')
})
})

View File

@ -0,0 +1,101 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { forwardRef } from 'react'
import PropTypes from 'prop-types'
/**
* @class Components.ScreenReaderContent
* @alternateClassName ScreenReaderContent
*
* A component that is only "visible" to screen-reader ATs. Sighted users
* will not see nor be able to interact with instances of this component.
*
* See Components.SightedUserContent for the "counterpart" of this component,
* although with less reliability.
*
*/
const ScreenReaderContent = forwardRef((props, ref) => {
const Tag = props.tagName || 'span'
const tagProps = {}
const customChildren = []
tagProps.className = 'screenreader-only'
if (props.forceSentenceDelimiter) {
customChildren.push(generateSentenceDelimiter())
}
if (customChildren.length) {
// React disallows setting the @dangerouslySetInnerHTML prop and passing
// children at the same time. So if the caller is attempting to pass
// this prop and is also asking for enhancements that require custom
// children such as @forceSentenceDelimiter then we cannot accomodate
// the request and should notify them.
//
// The same effect could be achieved by setting that prop on a *child*
// passed to the SRC component, e.g:
//
// <ScreenReaderContent forceSentenceDelimiter>
// <span dangerouslySetInnerHTML={{__html: '<b>hi</b>'}} />
// </ScreenReaderContent>
//
// // instead of:
//
// <ScreenReaderContent
// forceSentenceDelimiter
// dangerouslySetInnerHTML={{__html: '<b>hi</b>'}} />
if (props.dangerouslySetInnerHTML) {
if (process.env.NODE_ENV === 'development') {
console.error(`
You are attempting to set the dangerouslySetInnerHTML prop
on a ScreenReaderContent component, which prevents it from enabling
further accessibility enhancements.
Try setting that property on a passed child instead.
`)
}
} else {
tagProps.children = [props.children, customChildren]
}
} else {
// no custom children, pass children as-is:
tagProps.children = props.children
}
return <Tag ref={ref} {...tagProps}>{tagProps.children}</Tag>
})
const generateSentenceDelimiter = () => (
<em key="sentence-delimiter" role="presentation" aria-hidden>. </em>
)
ScreenReaderContent.propTypes = {
/**
* @property {Boolean} [forceSentenceDelimiter=false]
*
* If you're passing in dynamic content and you're noticing that it's not
* being read as a full sentence (e.g, some SRs are reading it along with
* the next element), then you can try setting this property to true and
* it will work a trick to make the SR pause after reading this element,
* just as if it were a proper sentence.
*/
forceSentenceDelimiter: PropTypes.bool
}
export default ScreenReaderContent

View File

@ -0,0 +1,62 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import cx from 'classnames'
/**
* @class Components.SightedUserContent
*
* A component that *tries* to hide itself from screen-readers, absolutely
* expecting that you're providing a more accessible version of the resource
* using something like a ScreenReaderContent component.
*
* Be warned that this does not totally prevent all screen-readers from
* seeing this content in all modes. For example, VoiceOver in OS X will
* still see this element when running in the "Say-All" mode and read it
* along with the accessible version you're providing.
*
* > **Warning**
* >
* > Use of this component is discouraged unless there's no alternative!!!
* >
* > The only one case that justifies its use is when design provides a
* > totally inaccessible version of a resource, and you're trying to
* > accommodate the design (for sighted users,) and provide a genuine layer
* > of accessibility (for others.)
*/
const SightedUserContent = ({tagName: Tag = 'span', ...props}) => {
return (
<Tag
{...props}
// HTML5 [hidden] works in many screen-readers and in some cases, like
// VoiceOver's Say-All mode, is the only thing that works for skipping
// content. However, this clearly has the downside of hiding the
// content from sighted users as well, so we resort to CSS to get the
// items back into display and we win-win.
hidden
aria-hidden
role="presentation"
className={cx('sighted-user-content', props.className)}
>
{props.children}
</Tag>
)
}
export default SightedUserContent

View File

@ -0,0 +1,31 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
const Spinner = () => (
<div className="ic-Spinner">
<div className="rect1" />
<div className="rect2" />
<div className="rect3" />
<div className="rect4" />
<div className="rect5" />
</div>
)
export default Spinner

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,15 +16,12 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
module.exports = {
dist: {
options: {
style: 'expanded',
outputStyle: 'nested'
},
import React from 'react'
files: {
"dist/<%= grunt.config.get('pkg.name') %>.css": 'apps/common/css/main.scss',
export default Stateless =>
// eslint-disable-next-line react/prefer-stateless-function
class extends React.Component {
render() {
return <Stateless {...this.props} />
}
}
};

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -16,7 +16,7 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define({
export default {
PROGRESS_ATTRS: [
'id',
'completion',
@ -24,15 +24,10 @@ define({
'workflow_state'
],
ATTACHMENT_ATTRS: [
'created_at',
'url'
],
ATTACHMENT_ATTRS: ['created_at', 'url'],
PROGRESS_QUEUED: 'queued',
PROGRESS_ACTIVE: 'running',
PROGRESS_COMPLETE: 'completed',
PROGRESS_FAILED: 'failed',
KC_RETURN: 13,
});
PROGRESS_FAILED: 'failed'
}

View File

@ -0,0 +1,45 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import $ from 'jquery'
const Adapter = function(inputConfig) {
this.config = inputConfig
}
Adapter.prototype.request = function(options) {
const ajax = this.config.ajax || $.ajax
options.headers = options.headers || {}
options.headers['Content-Type'] = 'application/json'
options.headers.Accept = 'application/vnd.api+json'
if (this.config.apiToken) {
options.headers.Authorization = 'Bearer ' + this.config.apiToken
}
if (options.type !== 'GET' && options.data) {
options.data = JSON.stringify(options.data)
}
return new Promise((resolve, reject) => {
ajax(options).then(resolve, reject)
})
}
export default Adapter

View File

@ -0,0 +1,57 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const callbacks = {}
let gActionIndex = 0
const Dispatcher = function(inputConfig) {
this.config = inputConfig
}
Dispatcher.prototype.dispatch = function(action, params) {
return new Promise((resolve, reject) => {
const actionIndex = ++gActionIndex
const callback = callbacks[action]
if (callback) {
callback(params, resolve, reject)
} else {
reject(new Error('Unknown action "' + action + '"'))
}
return actionIndex
})
}
Dispatcher.prototype.register = function(action, callback) {
if (callbacks[action]) {
throw new Error("A handler is already registered to '" + action + "'")
}
callbacks[action] = callback
return callback
}
Dispatcher.prototype.clear = function() {
for (const action of Object.keys(callbacks)) {
callbacks[action] = null
}
}
export default Dispatcher

View File

@ -0,0 +1,125 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import $ from 'jquery'
import _ from 'lodash.underscore'
const extend = _.extend
/**
* @class Common.Core.Environment
*
* API for manipulating the search query.
*/
const Environment = {
/**
* @property {Object} query
* The extracted GET query parameters. See #parseQueryString
*/
query: {},
/**
* Extract query parameters from a query string. The method can handle
* scalar and 1-level array values.
*
* @param {String} query
* The query string from the location bar. Like:
* "?foo=bar" or "foo=bar&arr[]=1&arr[]=2"
*
* @return {Object}
* Contains the key-value pairs found in the query string.
*/
parseQueryString(query) {
const items = query.replace(/^\?/, '').split('&')
return items.reduce(function(params, item) {
const pair = item.split('=')
let key = decodeURIComponent(pair[0])
const value = decodeURIComponent(pair[1])
if (key && key.length) {
if (key.substr(-2, 2) === '[]') {
key = key.substr(0, key.length - 2)
params[key] = params[key] || []
params[key].push(value)
} else {
params[key] = value
}
}
return params
}, {})
},
/**
* Create or replace a bunch of parameters in the query string.
*
* @example
* // Say the search has something like ?foo=bar&from=03/01/2014
* Env.updateQueryString({
* from: "03/28/2014"
* });
* // => ?foo=bar&from=03/28/2014
*
*/
updateQueryString(params) {
this.query = extend({}, this.query, params)
window.history.pushState(
'',
'',
[window.location.pathname, decodeURIComponent($.param(this.query))].join('?')
)
},
getQueryParameter(key) {
return this.query[key]
},
removeQueryParameter(key) {
this.removeQueryParameters([key])
},
removeQueryParameters(keys) {
const query = this.query
keys.forEach(function(key) {
delete query[key]
})
this.updateQueryString({})
}
}
// Extract the actual query string either from location.search if it's there,
// or from the hash if we're using hash-based history, or from the href
// as the last resort.
const extractQueryString = function() {
if (window.location.search.length) {
return window.location.search
} else if (window.location.hash.length) {
return window.location.hash.split('?')[1] || ''
} else {
return window.location.href.split('?')[1] || ''
}
}
Environment.query = Environment.parseQueryString(extractQueryString())
export default Environment

View File

@ -0,0 +1,99 @@
/*
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import _ from 'lodash.underscore'
const extend = _.extend
const Store = function(key, proto, Dispatcher) {
const emitChange = this.emitChange.bind(this)
extend(this, proto || {})
this._key = key
this.__reset__()
Object.keys(this.actions).forEach(
function(action) {
const handler = this.actions[action].bind(this)
const scopedAction = [key, action].join(':')
Dispatcher.register(scopedAction, function(params, resolve, reject) {
try {
handler(
params,
function onChange(rc) {
resolve(rc)
emitChange()
},
reject
)
} catch (e) {
reject(e)
}
})
}.bind(this)
)
return this
}
extend(Store.prototype, {
actions: {},
addChangeListener(callback) {
this._callbacks.push(callback)
},
removeChangeListener(callback) {
const index = this._callbacks.indexOf(callback)
if (index > -1) {
this._callbacks.splice(index, 1)
}
},
emitChange() {
this._callbacks.forEach(function(callback) {
callback()
})
},
/**
* @private
*
* A hook for tests to reset the Store to its initial state. Override this
* to restore any side-effects.
*
* Usually during the life-time of the app, we will never have to reset a
* Store, but in tests we do.
*/
__reset__() {
this._callbacks = []
this.state = this.getInitialState()
},
getInitialState() {
return {}
},
setState(newState) {
extend(this.state, newState)
this.emitChange()
}
})
export default Store

View File

@ -1,6 +1,5 @@
/** @jsx React.DOM */
/*
* Copyright (C) 2014 - present Instructure, Inc.
* Copyright (C) 2021 - present Instructure, Inc.
*
* This file is part of Canvas.
*
@ -17,16 +16,20 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
define(function(require) {
var React = require('../../ext/react');
var Essay = require('jsx!./essay');
export default function fromJSONAPI(payload, collKey, wantsObject) {
let data = {}
var Calculated = React.createClass({
render: Essay.type.prototype.render,
renderLinkButton: function() {
return false;
if (payload) {
if (payload[collKey]) {
data = payload[collKey]
} else {
data = payload
}
});
}
return Calculated;
});
if (wantsObject && Array.isArray(data)) {
return data[0]
} else {
return data
}
}

Some files were not shown because too many files have changed in this diff Show More