port conditional release rules editor UI
test plan: * enable native conditional release on the root account via the console by running: Account.default.tap do |a| a.settings[:use_native_conditional_release] = true a.save! end * enable the "Mastery Paths" feature on the account or course if not already * the "Mastery Paths" editor tab should be shown when editing an assignment * the editor should load independent of the conditional release service * it should allow selecting assignments and modifying scoring ranges * it should save via the native rules API when the assignment is saved * when the assignment is opened again for editing, it should pull the rules back from the native API and should appear as previously saved closes #LA-1063 Change-Id: Iea3b2f8346b8989542010f9d571bf1eee5e03622 Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/239985 Reviewed-by: Ed Schiebel <eschiebel@instructure.com> Reviewed-by: Jeremy Stanley <jeremy@instructure.com> Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> QA-Review: Robin Kuss <rkuss@instructure.com> Product-Review: James Williams <jamesw@instructure.com>
This commit is contained in:
parent
b2aa50d31f
commit
7b44b5f4ce
|
@ -30,13 +30,13 @@ module ConditionalRelease
|
|||
rules = rules.preload(Rule.all_includes) if include_param.include?('all')
|
||||
rules = rules.with_assignments if value_to_boolean(params[:active])
|
||||
|
||||
render json: rules.as_json(include: json_includes, include_root: false)
|
||||
render json: rules.as_json(include: json_includes, include_root: false, except: [:root_account_id, :deleted_at])
|
||||
end
|
||||
|
||||
# GET /api/rules/:id
|
||||
def show
|
||||
rule = get_rule
|
||||
render json: rule.as_json(include: json_includes, include_root: false)
|
||||
render json: rule.as_json(include: json_includes, include_root: false, except: [:root_account_id, :deleted_at])
|
||||
end
|
||||
|
||||
# POST /api/rules
|
||||
|
@ -52,7 +52,7 @@ module ConditionalRelease
|
|||
rule = Rule.new(create_params)
|
||||
|
||||
if rule.save
|
||||
render json: rule.as_json(include: all_includes, include_root: false)
|
||||
render json: rule.as_json(include: all_includes, include_root: false, except: [:root_account_id, :deleted_at])
|
||||
else
|
||||
render json: rule.errors, status: :bad_request
|
||||
end
|
||||
|
@ -70,7 +70,7 @@ module ConditionalRelease
|
|||
:assignment_set_associations
|
||||
)
|
||||
if rule.update(update_params)
|
||||
render json: rule.as_json(include: all_includes, include_root: false)
|
||||
render json: rule.as_json(include: all_includes, include_root: false, except: [:root_account_id, :deleted_at])
|
||||
else
|
||||
render json: rule.errors, status: :bad_request
|
||||
end
|
||||
|
@ -87,7 +87,7 @@ module ConditionalRelease
|
|||
|
||||
def get_rules
|
||||
rules = @context.conditional_release_rules.active
|
||||
rules = rules.where(trigger_assignment_id: params[:trigger_assignment]) unless params[:trigger_assignment].blank?
|
||||
rules = rules.where(trigger_assignment_id: params[:trigger_assignment_id]) unless params[:trigger_assignment_id].blank?
|
||||
rules
|
||||
end
|
||||
|
||||
|
@ -100,7 +100,14 @@ module ConditionalRelease
|
|||
end
|
||||
|
||||
def all_includes
|
||||
{ scoring_ranges: { include: { assignment_sets: { include: :assignment_set_associations } } } }
|
||||
{ scoring_ranges: {
|
||||
include: {
|
||||
assignment_sets: {
|
||||
include: {assignment_set_associations: {except: [:root_account_id, :deleted_at]}},
|
||||
except: [:root_account_id, :deleted_at]
|
||||
} },
|
||||
except: [:root_account_id, :deleted_at]
|
||||
} }
|
||||
end
|
||||
|
||||
def json_includes
|
||||
|
|
|
@ -356,7 +356,7 @@ class Quizzes::QuizzesController < ApplicationController
|
|||
set_master_course_js_env_data(@quiz, @context)
|
||||
|
||||
js_bundle :quizzes_bundle
|
||||
css_bundle :quizzes, :tinymce
|
||||
css_bundle :quizzes, :tinymce, :conditional_release_editor
|
||||
render :new, stream: can_stream_template?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {createAction} from 'redux-actions'
|
||||
import * as validations from './validations'
|
||||
import GradingTypes from './grading-types'
|
||||
|
||||
import CyoeApi from './cyoe-api'
|
||||
import * as AssignmentPickerActions from './assignment-picker-actions'
|
||||
|
||||
export const SET_BASE_URL = 'SET_BASE_URL'
|
||||
export const setBaseUrl = createAction(SET_BASE_URL)
|
||||
|
||||
export const SET_COURSE_ID = 'SET_COURSE_ID'
|
||||
export const setCourseId = createAction(SET_COURSE_ID)
|
||||
|
||||
export const SET_ARIA_ALERT = 'SET_ARIA_ALERT'
|
||||
export const setAriaAlert = createAction(SET_ARIA_ALERT)
|
||||
|
||||
export const CLEAR_ARIA_ALERT = 'CLEAR_ARIA_ALERT'
|
||||
export const clearAriaAlert = createAction(CLEAR_ARIA_ALERT)
|
||||
|
||||
export const SET_GLOBAL_WARNING = 'SET_GLOBAL_WARNING'
|
||||
export const setGlobalWarning = createAction(SET_GLOBAL_WARNING)
|
||||
|
||||
export const CLEAR_GLOBAL_WARNING = 'CLEAR_GLOBAL_WARNING'
|
||||
export const clearGlobalWarning = createAction(CLEAR_GLOBAL_WARNING)
|
||||
|
||||
export const LOAD_DEFAULT_RULE = 'LOAD_DEFAULT_RULE'
|
||||
export const loadDefaultRule = createAction(LOAD_DEFAULT_RULE)
|
||||
|
||||
export const LOAD_RULE_FOR_ASSIGNMENT = 'LOAD_RULE_FOR_ASSIGNMENT'
|
||||
export const loadRuleForAssignment = createAction(LOAD_RULE_FOR_ASSIGNMENT, state =>
|
||||
CyoeApi.getRuleForAssignment(state)
|
||||
)
|
||||
|
||||
export const SET_SCORE_AT_INDEX = 'SET_SCORE_AT_INDEX'
|
||||
export const setScoreAtIndex = (index, score) => {
|
||||
return (dispatch, _getState) => {
|
||||
// even erroneous input should be reflected in the interface
|
||||
dispatch(setScoreAtIndexFrd(index, score))
|
||||
dispatch(validateScores(true))
|
||||
}
|
||||
}
|
||||
|
||||
const setScoreAtIndexFrd = createAction(SET_SCORE_AT_INDEX, (index, score) => ({index, score}))
|
||||
|
||||
export const UPDATE_ASSIGNMENT = 'UPDATE_ASSIGNMENT'
|
||||
export const updateAssignment = assignment => {
|
||||
return (dispatch, _getState) => {
|
||||
dispatch(updateAssignmentFrd(assignment))
|
||||
|
||||
// ensure letter grades don't have inexplicable 'number out of range' errors
|
||||
if (
|
||||
assignment.grading_type === GradingTypes.letter_grade.key ||
|
||||
assignment.grading_type === GradingTypes.gpa_scale.key
|
||||
) {
|
||||
dispatch(clearOutOfRangeScores)
|
||||
}
|
||||
|
||||
dispatch(validateScores(false))
|
||||
}
|
||||
}
|
||||
|
||||
const updateAssignmentFrd = createAction(UPDATE_ASSIGNMENT)
|
||||
|
||||
const getScores = state => {
|
||||
const allScores = state.getIn(['rule', 'scoring_ranges']).map(s => s.get('lower_bound'))
|
||||
return allScores.pop() // last score will always be null
|
||||
}
|
||||
|
||||
const validateScores = notifyOnError => {
|
||||
return (dispatch, getState) => {
|
||||
const scoringInfo = getState().get('trigger_assignment')
|
||||
const scores = getScores(getState())
|
||||
|
||||
const errors = validations.validateScores(scores, scoringInfo)
|
||||
|
||||
errors.forEach((error, errorIndex) => {
|
||||
if (notifyOnError) {
|
||||
const currentError = getState().getIn(['rule', 'scoring_ranges', errorIndex, 'error'])
|
||||
if (error && error !== currentError) {
|
||||
dispatch(setAriaAlert(error))
|
||||
}
|
||||
}
|
||||
dispatch(setErrorAtScoreIndex(errorIndex, error))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const clearOutOfRangeScores = (dispatch, getState) => {
|
||||
const scores = getScores(getState())
|
||||
scores.forEach((score, index) => {
|
||||
if (score < 0 || score > 1) {
|
||||
dispatch(setScoreAtIndexFrd(index, ''))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const SET_ERROR_AT_SCORE_INDEX = 'SET_ERROR_AT_SCORE_INDEX'
|
||||
export const setErrorAtScoreIndex = createAction(SET_ERROR_AT_SCORE_INDEX, (index, error) => ({
|
||||
index,
|
||||
error
|
||||
}))
|
||||
|
||||
// Creates, saves, or deletes rule depending on current state
|
||||
// Returns a promise
|
||||
export const commitRule = state => {
|
||||
const trigger = state.getIn(['trigger_assignment', 'id'])
|
||||
const ruleId = state.getIn(['rule', 'id'])
|
||||
if (trigger) {
|
||||
return saveRule(state)
|
||||
} else if (ruleId) {
|
||||
return deleteRule(state)
|
||||
} else {
|
||||
return createAction('NO_OP', s => Promise.resolve(s))(state)
|
||||
}
|
||||
}
|
||||
|
||||
export const SAVE_RULE = 'SAVE_RULE'
|
||||
export const saveRule = createAction(SAVE_RULE, state => CyoeApi.saveRule(state))
|
||||
|
||||
export const DELETE_RULE = 'DELETE_RULE'
|
||||
export const deleteRule = createAction(DELETE_RULE, state => CyoeApi.deleteRule(state))
|
||||
|
||||
// @payload: index: scoring range index to remove assignment from
|
||||
// assignmentSetIndex: index of assignmnet set to remove assignment from
|
||||
// assignment: canvas id of assignment to remove
|
||||
export const REMOVE_ASSIGNMENT = 'REMOVE_ASSIGNMENT'
|
||||
export const removeAssignment = createAction(REMOVE_ASSIGNMENT)
|
||||
|
||||
// @payload: store state (requires courseId in state)
|
||||
export const GET_ASSIGNMENTS = 'GET_ASSIGNMENTS'
|
||||
export const getAssignments = createAction(GET_ASSIGNMENTS, state => CyoeApi.getAssignments(state))
|
||||
|
||||
// @payload: list of assignment instances
|
||||
export const ADD_ASSIGNMENTS_TO_RANGE_SET = 'ADD_ASSIGNMENTS_TO_RANGE_SET'
|
||||
export const addAssignmentsToRangeSet = createAction(ADD_ASSIGNMENTS_TO_RANGE_SET)
|
||||
|
||||
// @payload: path: path object of where to insert assignment
|
||||
// assignment: canvas assignment id of assignment to be inserted
|
||||
export const INSERT_ASSIGNMENT = 'INSERT_ASSIGNMENT'
|
||||
export const insertAssignment = createAction(INSERT_ASSIGNMENT)
|
||||
|
||||
// @payload: path: current assignment path
|
||||
// index: new assignment index
|
||||
export const UPDATE_ASSIGNMENT_INDEX = 'UPDATE_ASSIGNMENT_INDEX'
|
||||
export const updateAssignmentIndex = createAction(UPDATE_ASSIGNMENT_INDEX)
|
||||
|
||||
// @payload: oldPath: current path of assignment to be moved
|
||||
// newPath: path to move the assignment to
|
||||
// assignment: assignment id of assignment being moved
|
||||
export const moveAssignment = (oldPath, newPath, assignment) => {
|
||||
return dispatch => {
|
||||
if (oldPath.range === newPath.range && oldPath.set === newPath.set) {
|
||||
dispatch(updateAssignmentIndex({path: oldPath, assignmentIndex: newPath.assignment}))
|
||||
} else {
|
||||
dispatch(
|
||||
insertAssignment({
|
||||
path: newPath,
|
||||
assignment
|
||||
})
|
||||
)
|
||||
|
||||
dispatch(removeAssignment({path: oldPath}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @payload: leftSetIndex: index in the left set to be merged with the right set
|
||||
export const MERGE_ASSIGNMENT_SETS = 'MERGE_ASSIGNMENT_SETS'
|
||||
export const mergeAssignmentSets = createAction(MERGE_ASSIGNMENT_SETS)
|
||||
|
||||
// @payload: assignmentSetIndex: index of the set in scoring range
|
||||
// splitIndex: the assignment index where to split the set
|
||||
export const SPLIT_ASSIGNMENT_SET = 'SPLIT_ASSIGNMENT_SET'
|
||||
export const splitAssignmentSet = createAction(SPLIT_ASSIGNMENT_SET)
|
||||
|
||||
// @payload: list of assignment instances
|
||||
export const assignmentPicker = AssignmentPickerActions
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 * as actions from './actions'
|
||||
import {getScoringRangeSplitWarning} from './score-helpers'
|
||||
|
||||
// reset aria alert 500ms after being set so that subsequent alerts
|
||||
// can be triggered with the same text
|
||||
export const clearAriaAlert = (state, dispatch) => {
|
||||
if (state.get('aria_alert')) {
|
||||
setTimeout(() => dispatch(actions.clearAriaAlert()), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// clear warning about too many assignment sets in a scoring
|
||||
// range when that condition has been resolved
|
||||
export const clearScoringRangeWarning = (state, dispatch) => {
|
||||
const message = getScoringRangeSplitWarning()
|
||||
if (state.get('global_warning') === message) {
|
||||
let needsWarning = false
|
||||
state.getIn(['rule', 'scoring_ranges']).forEach(sr => {
|
||||
if (sr.get('assignment_sets').size > 2) {
|
||||
needsWarning = true
|
||||
}
|
||||
})
|
||||
if (!needsWarning) {
|
||||
dispatch(actions.clearGlobalWarning())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actors = [clearAriaAlert, clearScoringRangeWarning]
|
||||
|
||||
let acting = false
|
||||
|
||||
export default function initActors(store) {
|
||||
store.subscribe(() => {
|
||||
// Ensure that any action dispatched by actors do not result in a new
|
||||
// actor run, allowing actors to dispatch with impunity
|
||||
if (!acting) {
|
||||
acting = true
|
||||
actors.forEach(actor => {
|
||||
actor(store.getState(), store.dispatch)
|
||||
})
|
||||
acting = false
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 RANGE = 0
|
||||
const SET = 1
|
||||
const ASSIGNMENT = 2
|
||||
|
||||
const MAX_SIZE = 3
|
||||
|
||||
const validateValue = value => {
|
||||
const parsedValue = parseInt(value, 10)
|
||||
if (!Number.isNaN(parsedValue)) {
|
||||
return parsedValue
|
||||
} else {
|
||||
throw new Error(`Path value "${value}" is not a number`)
|
||||
}
|
||||
}
|
||||
|
||||
export default class Path {
|
||||
constructor(...pathSegments) {
|
||||
this._pathSegments = pathSegments.slice(0, MAX_SIZE).map(val => validateValue(val))
|
||||
}
|
||||
|
||||
get range() {
|
||||
return this._pathSegments[RANGE]
|
||||
}
|
||||
|
||||
get set() {
|
||||
return this._pathSegments[SET]
|
||||
}
|
||||
|
||||
get assignment() {
|
||||
return this._pathSegments[ASSIGNMENT]
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._pathSegments.length
|
||||
}
|
||||
|
||||
push(value) {
|
||||
value = validateValue(value)
|
||||
const size = this.size
|
||||
|
||||
if (size >= MAX_SIZE) {
|
||||
throw new Error('Path is full')
|
||||
} else {
|
||||
const pathSegmentsCopy = this._pathSegments.slice(0, size)
|
||||
pathSegmentsCopy.push(value)
|
||||
|
||||
return new Path(...pathSegmentsCopy)
|
||||
}
|
||||
}
|
||||
|
||||
pop() {
|
||||
const size = this.size
|
||||
|
||||
if (size === 0) {
|
||||
throw new Error('Path is empty')
|
||||
} else {
|
||||
const pathSegmentsCopy = this._pathSegments.slice(0, -1)
|
||||
return new Path(...pathSegmentsCopy)
|
||||
}
|
||||
}
|
||||
|
||||
toJS() {
|
||||
const {range, set, assignment} = this
|
||||
return {range, set, assignment}
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return this._pathSegments
|
||||
.map((val, idx) => val === other._pathSegments[idx])
|
||||
.reduce((prev, cur) => prev && cur, true)
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this._pathSegments.toString()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {createAction} from 'redux-actions'
|
||||
|
||||
// @payload: string to filter assignments by name
|
||||
export const FILTER_ASSIGNMENTS_BY_NAME = 'FILTER_BY_NAME'
|
||||
export const filterAssignmentsByName = createAction(FILTER_ASSIGNMENTS_BY_NAME)
|
||||
|
||||
// @payload: string representing category id
|
||||
export const FILTER_ASSIGNMENTS_BY_CATEGORY = 'FILTER_BY_CATEGORY'
|
||||
export const filterAssignmentsByCategory = createAction(FILTER_ASSIGNMENTS_BY_CATEGORY)
|
||||
|
||||
// @payload: string representing assignment id
|
||||
export const SELECT_ASSIGNMENT_IN_PICKER = 'SELECT_ASSIGNMENT_IN_PICKER'
|
||||
export const selectAssignmentInPicker = createAction(SELECT_ASSIGNMENT_IN_PICKER)
|
||||
|
||||
// @payload: string representing assignment id
|
||||
export const UNSELECT_ASSIGNMENT_IN_PICKER = 'UNSELECT_ASSIGNMENT_IN_PICKER'
|
||||
export const unselectAssignmentInPicker = createAction(UNSELECT_ASSIGNMENT_IN_PICKER)
|
||||
|
||||
// @payload: range model instance
|
||||
export const SET_ASSIGNMENT_PICKER_TARGET = 'SET_ASSIGNMENT_PICKER_TARGET'
|
||||
export const setAssignmentPickerTarget = createAction(SET_ASSIGNMENT_PICKER_TARGET)
|
||||
|
||||
// @payload: none
|
||||
export const OPEN_ASSIGNMENT_PICKER = 'OPEN_ASSIGNMENT_PICKER'
|
||||
export const openAssignmentPicker = createAction(OPEN_ASSIGNMENT_PICKER)
|
||||
|
||||
// @payload: none
|
||||
export const CLOSE_ASSIGNMENT_PICKER = 'CLOSE_ASSIGNMENT_PICKER'
|
||||
export const closeAssignmentPicker = createAction(CLOSE_ASSIGNMENT_PICKER)
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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!conditional_release'
|
||||
|
||||
export const ALL_ID = 'all'
|
||||
export const OTHER_ID = 'other'
|
||||
|
||||
const Categories = [
|
||||
{
|
||||
label: () => I18n.t('All Items'),
|
||||
id: ALL_ID
|
||||
},
|
||||
{
|
||||
label: () => I18n.t('Assignments'),
|
||||
id: 'assignment',
|
||||
submission_types: [
|
||||
'online_upload',
|
||||
'online_text_entry',
|
||||
'online_url',
|
||||
'on_paper',
|
||||
'external_tool',
|
||||
'not_graded',
|
||||
'media_recording',
|
||||
'none'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: () => I18n.t('Quizzes'),
|
||||
id: 'quiz',
|
||||
submission_types: ['online_quiz']
|
||||
},
|
||||
{
|
||||
label: () => I18n.t('Discussions'),
|
||||
id: 'discussion',
|
||||
submission_types: ['discussion_topic']
|
||||
},
|
||||
{
|
||||
label: () => I18n.t('Pages'),
|
||||
id: 'page',
|
||||
submission_types: ['wiki_page']
|
||||
},
|
||||
{
|
||||
label: () => I18n.t('Other'),
|
||||
id: OTHER_ID,
|
||||
submission_types: ['']
|
||||
}
|
||||
]
|
||||
|
||||
export default Categories
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {connect} from 'react-redux'
|
||||
import {bindActionCreators} from 'redux'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import {Button} from '@instructure/ui-buttons'
|
||||
import {Menu} from '@instructure/ui-menu'
|
||||
import {View} from '@instructure/ui-layout'
|
||||
import {IconMoreLine, IconEditLine, IconUpdownLine, IconTrashLine} from '@instructure/ui-icons'
|
||||
import {List, Map} from 'immutable'
|
||||
|
||||
import Path from '../assignment-path'
|
||||
import * as actions from '../actions'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
import {transformScore} from '../score-helpers'
|
||||
|
||||
const {object, func} = PropTypes
|
||||
|
||||
export class AssignmentCardMenu extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
path: object.isRequired,
|
||||
ranges: object.isRequired,
|
||||
assignment: object.isRequired,
|
||||
removeAssignment: func.isRequired,
|
||||
triggerAssignment: object,
|
||||
|
||||
// action props
|
||||
moveAssignment: func.isRequired,
|
||||
setAriaAlert: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
handleMoveSelect(range, i) {
|
||||
const movePath = new Path(i, 0)
|
||||
this.props.moveAssignment(this.props.path, movePath, this.props.assignment.get('id').toString())
|
||||
|
||||
this.props.setAriaAlert(
|
||||
I18n.t('Moved assignment %{name} to scoring range %{lower} - %{upper}', {
|
||||
name: this.props.assignment.get('name'),
|
||||
lower: transformScore(range.get('lower_bound'), this.props.triggerAssignment, false),
|
||||
upper: transformScore(range.get('upper_bound'), this.props.triggerAssignment, true)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
createMoveSelectCallback(range, i) {
|
||||
return this.handleMoveSelect.bind(this, range, i)
|
||||
}
|
||||
|
||||
renderMoveOptions() {
|
||||
return this.props.ranges
|
||||
.map((range, i) => {
|
||||
return (
|
||||
<Menu.Item key={range.get('id')} onSelect={this.createMoveSelectCallback(range, i)}>
|
||||
<IconUpdownLine />
|
||||
<View margin="0 0 0 x-small">
|
||||
{I18n.t('Move to %{lower} - %{upper}', {
|
||||
lower: transformScore(
|
||||
range.get('lower_bound'),
|
||||
this.props.triggerAssignment,
|
||||
false
|
||||
),
|
||||
upper: transformScore(range.get('upper_bound'), this.props.triggerAssignment, true)
|
||||
})}
|
||||
</View>
|
||||
</Menu.Item>
|
||||
)
|
||||
})
|
||||
.filter((range, i) => i !== this.props.path.range)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Menu
|
||||
trigger={
|
||||
<Button icon={IconMoreLine}>
|
||||
<ScreenReaderContent>
|
||||
{I18n.t('assignment %{name} options', {name: this.props.assignment.get('name')})}
|
||||
</ScreenReaderContent>
|
||||
</Button>
|
||||
}
|
||||
placement="bottom start"
|
||||
>
|
||||
<Menu.Item
|
||||
onClick={() => window.open(this.props.assignment.get('html_url') + '/edit', '_blank')}
|
||||
>
|
||||
<IconEditLine /> <View margin="0 0 0 x-small">{I18n.t('Edit')}</View>
|
||||
</Menu.Item>
|
||||
{this.renderMoveOptions()}
|
||||
<Menu.Item onSelect={this.props.removeAssignment}>
|
||||
<IconTrashLine /> <View margin="0 0 0 x-small">{I18n.t('Remove')}</View>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedAssignmentCardMenu = connect(
|
||||
state => ({
|
||||
ranges: state.getIn(['rule', 'scoring_ranges'], List()),
|
||||
triggerAssignment: state.get('trigger_assignment', Map())
|
||||
}),
|
||||
dispatch =>
|
||||
bindActionCreators(
|
||||
{
|
||||
moveAssignment: actions.moveAssignment,
|
||||
setAriaAlert: actions.setAriaAlert
|
||||
},
|
||||
dispatch
|
||||
)
|
||||
)(AssignmentCardMenu)
|
||||
|
||||
export default ConnectedAssignmentCardMenu
|
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 classNames from 'classnames'
|
||||
import {DragSource} from 'react-dnd'
|
||||
|
||||
import AssignmentMenu from './assignment-card-menu'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
|
||||
const {object, bool, func} = PropTypes
|
||||
|
||||
// implements the drag source contract
|
||||
const assignmentSource = {
|
||||
canDrag(props) {
|
||||
return !!props.assignment
|
||||
},
|
||||
|
||||
beginDrag(props) {
|
||||
return {
|
||||
path: props.path,
|
||||
id: props.assignment.get('id').toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AssignmentCard extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
path: object.isRequired,
|
||||
assignment: object,
|
||||
removeAssignment: func.isRequired,
|
||||
onDragOver: func.isRequired,
|
||||
onDragLeave: func.isRequired,
|
||||
|
||||
// injected by React DnD
|
||||
isDragging: bool.isRequired,
|
||||
connectDragSource: func.isRequired,
|
||||
connectDragPreview: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.removeAssignment = this.removeAssignment.bind(this)
|
||||
this.handleDragOver = this.handleDragOver.bind(this)
|
||||
this.contentRef = React.createRef()
|
||||
}
|
||||
|
||||
removeAssignment() {
|
||||
this.props.removeAssignment(this.props.path, this.props.assignment)
|
||||
}
|
||||
|
||||
handleDragOver() {
|
||||
this.props.onDragOver(this.props.path.assignment)
|
||||
}
|
||||
|
||||
renderMenu() {
|
||||
if (!this.props.assignment) return null
|
||||
else {
|
||||
return (
|
||||
<AssignmentMenu
|
||||
assignment={this.props.assignment}
|
||||
removeAssignment={this.removeAssignment}
|
||||
path={this.props.path}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemClass(category) {
|
||||
if (category === 'page') {
|
||||
return 'document'
|
||||
}
|
||||
return category
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
if (this.props.assignment) {
|
||||
const points =
|
||||
this.props.assignment.get('category') !== 'page'
|
||||
? I18n.t('%{points} pts', {
|
||||
points: I18n.n(this.props.assignment.get('points_possible') || 0)
|
||||
})
|
||||
: ''
|
||||
|
||||
const label = this.props.assignment.get('name')
|
||||
|
||||
return (
|
||||
<div className="cr-assignment-card__content" ref={this.contentRef} aria-label={label}>
|
||||
<i
|
||||
className={`cr-assignment-card__icon icon-${this.itemClass(
|
||||
this.props.assignment.get('category')
|
||||
)}`}
|
||||
/>
|
||||
<p className="cr-assignment-card__points">{points}</p>
|
||||
<p className="cr-assignment-card__title">{this.props.assignment.get('name')}</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return <p>{I18n.t('Loading..')}</p>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const classes = classNames({
|
||||
'cr-assignment-card': true,
|
||||
'cr-assignment-card__loading': !this.props.assignment,
|
||||
'cr-assignment-card__dragging': this.props.isDragging
|
||||
})
|
||||
|
||||
return this.props.connectDragPreview(
|
||||
this.props.connectDragSource(
|
||||
<div
|
||||
className={classes}
|
||||
onDragOver={this.handleKeyDownDragOver}
|
||||
onDragLeave={this.props.onDragLeave}
|
||||
>
|
||||
{this.renderContent()}
|
||||
{this.renderMenu()}
|
||||
</div>,
|
||||
{dropEffect: 'move'}
|
||||
),
|
||||
{captureDraggingState: true}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DragSource('AssignmentCard', assignmentSource, (connect, monitor) => ({
|
||||
connectDragSource: connect.dragSource(),
|
||||
connectDragPreview: connect.dragPreview(),
|
||||
isDragging: monitor.isDragging()
|
||||
}))(AssignmentCard)
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 _ from 'lodash'
|
||||
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import categories from '../categories'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
|
||||
const {func, string} = PropTypes
|
||||
|
||||
class AssignmentFilter extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
onNameFilter: func.isRequired,
|
||||
onCategoryFilter: func.isRequired,
|
||||
defaultCategoryFilter: string
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.filterByName = _.debounce(this.filterByName.bind(this), 250)
|
||||
this.filterByCategory = this.filterByCategory.bind(this)
|
||||
this.nameFilterRef = React.createRef()
|
||||
}
|
||||
|
||||
filterByName(_e) {
|
||||
this.props.onNameFilter(this.nameFilterRef.current.value.trim())
|
||||
}
|
||||
|
||||
filterByCategory(e) {
|
||||
this.props.onCategoryFilter(e.target.value)
|
||||
}
|
||||
|
||||
renderCategories() {
|
||||
return categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.label()}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="cr-assignments-filter">
|
||||
<ScreenReaderContent>
|
||||
<label
|
||||
className="cr-assignments-filter__name-filter__label"
|
||||
htmlFor="cr-assignments-filter__name-filter"
|
||||
>
|
||||
{I18n.t('Search Assignments')}
|
||||
</label>
|
||||
</ScreenReaderContent>
|
||||
<input
|
||||
id="cr-assignments-filter__name-filter"
|
||||
className="cr-assignments-filter__name-filter"
|
||||
placeholder={I18n.t('Search')}
|
||||
type="text"
|
||||
ref={this.nameFilterRef}
|
||||
onChange={this.filterByName}
|
||||
/>
|
||||
<ScreenReaderContent>
|
||||
<label
|
||||
className="cr-assignments-filter__category-filter__label"
|
||||
htmlFor="cr-assignments-filter__category-filter"
|
||||
>
|
||||
{I18n.t('Filter Assignment Category')}
|
||||
</label>
|
||||
</ScreenReaderContent>
|
||||
<select
|
||||
id="cr-assignments-filter__category-filter"
|
||||
className="cr-assignments-filter__category-filter"
|
||||
defaultValue={this.props.defaultCategoryFilter}
|
||||
onChange={this.filterByCategory}
|
||||
>
|
||||
{this.renderCategories()}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default AssignmentFilter
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {List} from 'immutable'
|
||||
import shortid from 'shortid'
|
||||
import classNames from 'classnames'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
|
||||
const {object, func} = PropTypes
|
||||
|
||||
class AssignmentList extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
assignments: object.isRequired,
|
||||
disabledAssignments: object,
|
||||
selectedAssignments: object,
|
||||
onSelectAssignment: func.isRequired,
|
||||
onUnselectAssignment: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
static get defaultProps() {
|
||||
return {
|
||||
disabledAssignments: List(),
|
||||
selectedAssignments: List()
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.toggleItemSelection = this.toggleItemSelection.bind(this)
|
||||
}
|
||||
|
||||
toggleItemSelection(e) {
|
||||
const id = e.target.value
|
||||
const checked = e.target.checked
|
||||
|
||||
if (checked) {
|
||||
this.props.onSelectAssignment(id)
|
||||
} else {
|
||||
this.props.onUnselectAssignment(id)
|
||||
}
|
||||
}
|
||||
|
||||
itemClass(category) {
|
||||
if (category === 'page') {
|
||||
return 'document'
|
||||
}
|
||||
return category
|
||||
}
|
||||
|
||||
renderItem(item, i) {
|
||||
const isDisabled = this.props.disabledAssignments.includes(item.get('id').toString())
|
||||
const isSelected = !isDisabled && this.props.selectedAssignments.includes(item.get('id'))
|
||||
const itemId = shortid.generate()
|
||||
|
||||
const itemClasses = {
|
||||
'cr-assignments-list__item': true,
|
||||
'cr-assignments-list__item__disabled': isDisabled,
|
||||
'cr-assignments-list__item__selected': isSelected
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
aria-label={I18n.t('%{item_category} category icon for item name %{item_name}', {
|
||||
item_category: item.get('category'),
|
||||
item_name: item.get('name')
|
||||
})}
|
||||
className={classNames(itemClasses)}
|
||||
>
|
||||
<input
|
||||
disabled={isDisabled}
|
||||
id={itemId}
|
||||
type="checkbox"
|
||||
value={item.get('id')}
|
||||
onChange={this.toggleItemSelection}
|
||||
defaultChecked={isSelected}
|
||||
/>
|
||||
<label htmlFor={itemId} className="cr-label__cbox">
|
||||
<span className="cr-assignments-list__item__icon">
|
||||
<i aria-hidden className={`icon-${this.itemClass(item.get('category'))}`} />
|
||||
</span>
|
||||
<span className="ic-Label__text">{item.get('name')}</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.assignments.size) {
|
||||
return (
|
||||
<ul className="cr-assignments-list">
|
||||
{this.props.assignments.map((item, i) => {
|
||||
return this.renderItem(item, i)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
} else {
|
||||
return <p>{I18n.t('No items found')}</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AssignmentList
|
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 Modal from 'react-modal'
|
||||
|
||||
import ConnectedAssignmentPicker from './assignment-picker'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import {transformScore} from '../score-helpers'
|
||||
|
||||
const {object, bool, func} = PropTypes
|
||||
|
||||
class AssignmentPickerModal extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
target: object,
|
||||
appElement: object,
|
||||
isOpen: bool.isRequired,
|
||||
onRequestClose: func.isRequired,
|
||||
addItemsToRange: func.isRequired,
|
||||
triggerAssignment: object
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.onAfterOpen = this.onAfterOpen.bind(this)
|
||||
this.closeBtnRef = React.createRef()
|
||||
}
|
||||
|
||||
UNSAFE_componentWillMount() {
|
||||
Modal.setAppElement(this.props.appElement)
|
||||
}
|
||||
|
||||
onAfterOpen() {
|
||||
this.closeBtnRef.current.focus()
|
||||
}
|
||||
|
||||
render() {
|
||||
const target = this.props.target
|
||||
let range = ''
|
||||
|
||||
if (target) {
|
||||
const lowerBound = transformScore(
|
||||
target.get('lower_bound'),
|
||||
this.props.triggerAssignment,
|
||||
false
|
||||
)
|
||||
const upperBound = transformScore(
|
||||
target.get('upper_bound'),
|
||||
this.props.triggerAssignment,
|
||||
true
|
||||
)
|
||||
range = I18n.t('%{upper} to %{lower}', {
|
||||
upper: upperBound,
|
||||
lower: lowerBound
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="ReactModal__Content--canvas"
|
||||
overlayClassName="ReactModal__Overlay--canvas"
|
||||
isOpen={this.props.isOpen}
|
||||
onAfterOpen={this.onAfterOpen}
|
||||
onRequestClose={this.props.onRequestClose}
|
||||
>
|
||||
<div className="ReactModal__Layout cr-assignment-modal">
|
||||
<header className="ReactModal__Header">
|
||||
<div className="ReactModal__Header-Title">
|
||||
<span>
|
||||
{I18n.t('Add Items Into %{scoring_range_title}', {scoring_range_title: range})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ReactModal__Header-Actions">
|
||||
<button
|
||||
ref={this.closeBtnRef}
|
||||
className="Button Button--icon-action"
|
||||
onClick={this.props.onRequestClose}
|
||||
type="button"
|
||||
>
|
||||
<i aria-hidden className="icon-x" />
|
||||
<ScreenReaderContent>{I18n.t('Close')}</ScreenReaderContent>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="ReactModal__Body">
|
||||
<ConnectedAssignmentPicker />
|
||||
</div>
|
||||
<footer className="ReactModal__Footer">
|
||||
<div className="ReactModal__Footer-Actions">
|
||||
<button type="button" className="Button" onClick={this.props.onRequestClose}>
|
||||
{I18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="Button Button--primary"
|
||||
onClick={this.props.addItemsToRange}
|
||||
>
|
||||
{I18n.t('Add Items')}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default AssignmentPickerModal
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {connect} from 'react-redux'
|
||||
import {List, Map} from 'immutable'
|
||||
import _ from 'lodash'
|
||||
|
||||
import * as actions from '../assignment-picker-actions'
|
||||
|
||||
import {ALL_ID} from '../categories'
|
||||
import AssignmentFilter from './assignment-filter'
|
||||
import AssignmentList from './assignment-list'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
|
||||
const {object, string, func} = PropTypes
|
||||
|
||||
export class AssignmentPicker extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
assignments: object.isRequired,
|
||||
disabledAssignments: object,
|
||||
selectedAssignments: object,
|
||||
nameFilter: string,
|
||||
categoryFilter: string,
|
||||
triggerAssignmentId: string,
|
||||
|
||||
// Action Props
|
||||
filterAssignmentsByName: func.isRequired,
|
||||
filterAssignmentsByCategory: func.isRequired,
|
||||
selectAssignmentInPicker: func.isRequired,
|
||||
unselectAssignmentInPicker: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.filterByName = this.filterByName.bind(this)
|
||||
this.filterByCategory = this.filterByCategory.bind(this)
|
||||
this.updateScreenReaderResultCount = _.debounce(this.updateScreenReaderResultCount, 1000)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
filterByName(nameFilter) {
|
||||
this.props.filterAssignmentsByName(nameFilter)
|
||||
}
|
||||
|
||||
filterByCategory(category) {
|
||||
this.props.filterAssignmentsByCategory(category)
|
||||
}
|
||||
|
||||
filterAssignments() {
|
||||
const nameFilter = this.props.nameFilter.toLowerCase()
|
||||
const categoryFilter = this.props.categoryFilter
|
||||
|
||||
const assignments = this.props.assignments.filter(assignment => {
|
||||
const notTrigger = String(assignment.get('id')) !== String(this.props.triggerAssignmentId)
|
||||
const matchesName =
|
||||
!nameFilter ||
|
||||
assignment
|
||||
.get('name')
|
||||
.toLowerCase()
|
||||
.indexOf(nameFilter) !== -1
|
||||
const matchesCategory =
|
||||
!categoryFilter ||
|
||||
categoryFilter === ALL_ID ||
|
||||
categoryFilter === assignment.get('category')
|
||||
return notTrigger && matchesName && matchesCategory
|
||||
})
|
||||
this.updateScreenReaderResultCount(assignments.size, this.getFilterKey())
|
||||
return assignments
|
||||
}
|
||||
|
||||
getFilterKey() {
|
||||
return this.props.nameFilter.toLowerCase() + ':' + this.props.categoryFilter
|
||||
}
|
||||
|
||||
// Update screenreader state on debounce delay to make
|
||||
// more likely that screenreader will not preempt search feedback
|
||||
// for typing feedback
|
||||
updateScreenReaderResultCount(resultCount, key) {
|
||||
if (this.state.key !== key) {
|
||||
this.setState({resultCount, key})
|
||||
}
|
||||
}
|
||||
|
||||
renderScreenReaderResultCount() {
|
||||
const unread = this.state.key !== this.lastKey
|
||||
this.lastKey = this.state.key
|
||||
|
||||
const text = I18n.t(
|
||||
{
|
||||
zero: 'No items found',
|
||||
one: 'One item found',
|
||||
other: '%{count} items found'
|
||||
},
|
||||
{
|
||||
count: this.state.resultCount || 0
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<ScreenReaderContent>
|
||||
{unread ? (
|
||||
<div role="alert" aria-relevant="all" aria-atomic="true">
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</ScreenReaderContent>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const assignments = this.filterAssignments()
|
||||
|
||||
return (
|
||||
<div className="cr-assignments-picker">
|
||||
<AssignmentFilter
|
||||
onNameFilter={this.filterByName}
|
||||
onCategoryFilter={this.filterByCategory}
|
||||
defaultCategoryFilter={ALL_ID}
|
||||
/>
|
||||
<AssignmentList
|
||||
assignments={assignments}
|
||||
disabledAssignments={this.props.disabledAssignments}
|
||||
selectedAssignments={this.props.selectedAssignments}
|
||||
onSelectAssignment={this.props.selectAssignmentInPicker}
|
||||
onUnselectAssignment={this.props.unselectAssignmentInPicker}
|
||||
/>
|
||||
{this.renderScreenReaderResultCount()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedAssignmentPicker = connect(
|
||||
state => ({
|
||||
assignments: state.get('assignments', Map()).toList(),
|
||||
disabledAssignments: state.getIn(['assignment_picker', 'disabled_assignments'], List()),
|
||||
selectedAssignments: state.getIn(['assignment_picker', 'selected_assignments'], List()),
|
||||
nameFilter: state.getIn(['assignment_picker', 'name_filter'], ''),
|
||||
categoryFilter: state.getIn(['assignment_picker', 'category_filter']),
|
||||
triggerAssignmentId: state.getIn(['trigger_assignment', 'id'])
|
||||
}), // mapStateToProps
|
||||
actions // mapActionsToProps
|
||||
)(AssignmentPicker)
|
||||
|
||||
export default ConnectedAssignmentPicker
|
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 classNames from 'classnames'
|
||||
import {DropTarget} from 'react-dnd'
|
||||
|
||||
import Assignment from './assignment-card'
|
||||
import ConditionToggle from './condition-toggle'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
|
||||
const {object, func, string, bool} = PropTypes
|
||||
|
||||
// implements the drop target contract
|
||||
const assignmentTarget = {
|
||||
drop(props, monitor, component) {
|
||||
const item = monitor.getItem()
|
||||
const assg = component.getAssignmentDropTarget()
|
||||
const path = assg !== undefined ? props.path.push(assg) : props.path
|
||||
|
||||
const found = props.setAssignments.find(a => {
|
||||
return a.get('assignment_id') === item.id
|
||||
})
|
||||
|
||||
const isInternal = item.path.pop().equals(props.path)
|
||||
|
||||
// if (assignment isn't already in set OR is moved within the set) AND isn't dropped on itself..
|
||||
if ((!found || isInternal) && !item.path.equals(path)) {
|
||||
props.moveAssignment(item.path, path, item.id)
|
||||
}
|
||||
|
||||
component.resetAssignmentDropTarget()
|
||||
}
|
||||
}
|
||||
|
||||
class AssignmentSet extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
path: object.isRequired,
|
||||
setAssignments: object.isRequired,
|
||||
allAssignments: object.isRequired,
|
||||
removeAssignment: func.isRequired,
|
||||
moveAssignment: func.isRequired /* eslint-disable-line react/no-unused-prop-types */,
|
||||
toggleSetCondition: func.isRequired,
|
||||
showOrToggle: bool,
|
||||
disableSplit: bool,
|
||||
label: string.isRequired,
|
||||
|
||||
// injected by React DnD
|
||||
connectDropTarget: func.isRequired,
|
||||
isOver: bool.isRequired,
|
||||
canDrop: bool.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.state = {
|
||||
dropTarget: undefined
|
||||
}
|
||||
|
||||
this.setAssignmentDropTarget = this.setAssignmentDropTarget.bind(this)
|
||||
this.resetAssignmentDropTarget = this.resetAssignmentDropTarget.bind(this)
|
||||
}
|
||||
|
||||
setAssignmentDropTarget(idx) {
|
||||
this.setState({dropTarget: idx})
|
||||
}
|
||||
|
||||
resetAssignmentDropTarget() {
|
||||
this.setState({dropTarget: undefined})
|
||||
}
|
||||
|
||||
getAssignmentDropTarget() {
|
||||
return this.state.dropTarget
|
||||
}
|
||||
|
||||
renderToggle(path) {
|
||||
const isLastAssignment = path.assignment + 1 === this.props.setAssignments.size
|
||||
|
||||
if (path.assignment === this.state.dropTarget) {
|
||||
return this.renderDragToggle(isLastAssignment)
|
||||
} else if (isLastAssignment && !this.props.showOrToggle) {
|
||||
return null
|
||||
} else {
|
||||
const isAnd = !isLastAssignment
|
||||
return (
|
||||
<ConditionToggle
|
||||
isAnd={isAnd}
|
||||
isDisabled={isAnd && this.props.disableSplit}
|
||||
path={path}
|
||||
handleToggle={this.props.toggleSetCondition}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
renderDragToggle(isLast) {
|
||||
return <ConditionToggle isAnd isFake={isLast} />
|
||||
}
|
||||
|
||||
renderAssignments() {
|
||||
return this.props.setAssignments
|
||||
.map((asg, idx) => {
|
||||
const assignment = this.props.allAssignments.get(asg.get('assignment_id'))
|
||||
|
||||
const setInnerClasses = classNames({
|
||||
'cr-assignment-set__inner': true,
|
||||
'cr-assignment-set__inner__draggedOver': idx === this.state.dropTarget
|
||||
})
|
||||
|
||||
const path = this.props.path.push(idx)
|
||||
|
||||
return (
|
||||
<div key={asg.get('assignment_id')} className={setInnerClasses}>
|
||||
<Assignment
|
||||
path={path}
|
||||
assignment={assignment}
|
||||
removeAssignment={this.props.removeAssignment}
|
||||
onDragOver={this.setAssignmentDropTarget}
|
||||
onDragLeave={this.resetAssignmentDropTarget}
|
||||
/>
|
||||
{this.renderToggle(path)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
render() {
|
||||
const {canDrop, isOver, connectDropTarget} = this.props
|
||||
|
||||
const setClasses = classNames({
|
||||
'cr-assignment-set': true,
|
||||
'cr-assignment-set__empty': this.props.setAssignments.size === 0,
|
||||
'cr-assignment-set__drag-over': isOver,
|
||||
'cr-assignment-set__can-drop': canDrop
|
||||
})
|
||||
|
||||
return connectDropTarget(
|
||||
<div className={setClasses} onDragLeave={this.resetAssignmentDropTarget}>
|
||||
<ScreenReaderContent>
|
||||
<h3>{this.props.label}</h3>
|
||||
</ScreenReaderContent>
|
||||
{this.renderAssignments()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DropTarget('AssignmentCard', assignmentTarget, (connect, monitor) => ({
|
||||
connectDropTarget: connect.dropTarget(),
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop()
|
||||
}))(AssignmentSet)
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 classNames from 'classnames'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
|
||||
const {bool, func, object} = PropTypes
|
||||
|
||||
export default class ConditionToggle extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
isAnd: bool,
|
||||
isFake: bool,
|
||||
isDisabled: bool,
|
||||
path: object,
|
||||
handleToggle: func
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.handleToggle = this.handleToggle.bind(this)
|
||||
}
|
||||
|
||||
renderLabel() {
|
||||
return this.props.isAnd ? I18n.t('#conditional_release.and', '&') : I18n.t('or')
|
||||
}
|
||||
|
||||
renderAriaLabel() {
|
||||
return this.props.isDisabled
|
||||
? I18n.t('Splitting disabled: reached maximum of three assignment groups in a scoring range')
|
||||
: this.props.isAnd
|
||||
? I18n.t('Click to split set here')
|
||||
: I18n.t('Click to merge sets here')
|
||||
}
|
||||
|
||||
handleToggle() {
|
||||
if (this.props.handleToggle) {
|
||||
this.props.handleToggle(this.props.path, this.props.isAnd, this.props.isDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const toggleClasses = classNames({
|
||||
'cr-condition-toggle': true,
|
||||
'cr-condition-toggle__and': this.props.isAnd,
|
||||
'cr-condition-toggle__or': !this.props.isAnd,
|
||||
'cr-condition-toggle__fake': this.props.isFake,
|
||||
'cr-condition-toggle__disabled': this.props.isDisabled
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={toggleClasses}>
|
||||
<button
|
||||
type="button"
|
||||
className="cr-condition-toggle__button"
|
||||
title={this.renderAriaLabel()}
|
||||
aria-label={this.renderAriaLabel()}
|
||||
aria-disabled={this.props.isDisabled}
|
||||
onClick={this.handleToggle}
|
||||
>
|
||||
{this.renderLabel()}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 classNames from 'classnames'
|
||||
import PropTypes from 'prop-types'
|
||||
import shortid from 'shortid'
|
||||
|
||||
import I18n from 'i18n!conditional_release'
|
||||
import GradingTypes from '../grading-types'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import {
|
||||
scoreToPercent,
|
||||
percentToScore,
|
||||
transformScore,
|
||||
getGradingType,
|
||||
isNumeric
|
||||
} from '../score-helpers'
|
||||
|
||||
const {string, func, object, number} = PropTypes
|
||||
|
||||
export default class ScoreInput extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
score: number.isRequired,
|
||||
triggerAssignment: object.isRequired,
|
||||
label: string,
|
||||
error: string,
|
||||
onScoreChanged: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.state = {
|
||||
focused: false,
|
||||
editingValue: null
|
||||
}
|
||||
|
||||
this.shortid = shortid.generate()
|
||||
|
||||
this.focused = this.focused.bind(this)
|
||||
this.blurred = this.blurred.bind(this)
|
||||
this.changed = this.changed.bind(this)
|
||||
}
|
||||
|
||||
focused(e) {
|
||||
// Makes sure cursor appears at the end
|
||||
this.setState({focused: true})
|
||||
this.moveCursorToEnd(e.target)
|
||||
}
|
||||
|
||||
blurred(_e) {
|
||||
this.setState({focused: false})
|
||||
this.setState({editingValue: null})
|
||||
}
|
||||
|
||||
changed(e) {
|
||||
this.setState({editingValue: e.target.value})
|
||||
this.props.onScoreChanged(scoreToPercent(e.target.value, this.props.triggerAssignment))
|
||||
}
|
||||
|
||||
moveCursorToEnd(element) {
|
||||
const strLength = element.value.length
|
||||
element.selectionStart = element.selectionEnd = strLength
|
||||
}
|
||||
|
||||
value() {
|
||||
if (!this.state.focused) {
|
||||
if (this.props.score === '') {
|
||||
return ''
|
||||
}
|
||||
return transformScore(this.props.score, this.props.triggerAssignment, true)
|
||||
} else if (this.state.editingValue) {
|
||||
return this.state.editingValue
|
||||
} else {
|
||||
const currentScore = percentToScore(this.props.score, this.props.triggerAssignment)
|
||||
return isNumeric(currentScore) ? I18n.n(currentScore) : currentScore
|
||||
}
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return !!this.props.error
|
||||
}
|
||||
|
||||
errorMessageId() {
|
||||
return 'error-' + this.shortid
|
||||
}
|
||||
|
||||
errorMessage() {
|
||||
if (this.hasError()) {
|
||||
return (
|
||||
<div className="cr-percent-input__error-holder">
|
||||
<ScreenReaderContent>
|
||||
<span id={this.errorMessageId()}>{this.props.error}</span>
|
||||
</ScreenReaderContent>
|
||||
<div className="cr-percent-input__error ic-Form-message ic-Form-message--error">
|
||||
<div className="ic-Form-message__Layout">
|
||||
<i className="icon-warning" role="presentation" />
|
||||
{this.props.error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const topClasses = {
|
||||
'cr-percent-input': true,
|
||||
'cr-percent-input--error': this.hasError(),
|
||||
'ic-Form-control': true,
|
||||
'ic-Form-control--has-error': this.hasError()
|
||||
}
|
||||
|
||||
const optionalProps = {}
|
||||
if (this.hasError()) {
|
||||
optionalProps['aria-invalid'] = true
|
||||
optionalProps['aria-describedby'] = this.errorMessageId()
|
||||
}
|
||||
|
||||
let srLabel = this.props.label
|
||||
const gradingType = getGradingType(this.props.triggerAssignment)
|
||||
if (gradingType && GradingTypes[gradingType]) {
|
||||
srLabel = I18n.t('%{label}, as %{gradingType}', {
|
||||
label: this.props.label,
|
||||
gradingType: GradingTypes[gradingType].label()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(topClasses)}>
|
||||
<ScreenReaderContent>
|
||||
<label className="cr-percent-input__label" htmlFor={this.shortid}>
|
||||
{srLabel}
|
||||
</label>
|
||||
</ScreenReaderContent>
|
||||
<input
|
||||
className="cr-input cr-percent-input__input"
|
||||
id={this.shortid}
|
||||
type="text"
|
||||
value={this.value()}
|
||||
title={this.props.label}
|
||||
onChange={this.changed}
|
||||
onFocus={this.focused}
|
||||
onBlur={this.blurred}
|
||||
{...optionalProps}
|
||||
/>
|
||||
{this.errorMessage()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import {transformScore} from '../score-helpers'
|
||||
|
||||
const {string, object, bool} = PropTypes
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default class ScoreLabel extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
score: string,
|
||||
label: string,
|
||||
isUpperBound: bool,
|
||||
triggerAssignment: object
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="cr-score-label">
|
||||
<ScreenReaderContent>{this.props.label}</ScreenReaderContent>
|
||||
<span title={this.props.label}>
|
||||
{transformScore(this.props.score, this.props.triggerAssignment, this.props.isUpperBound)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {connect} from 'react-redux'
|
||||
import {List} from 'immutable'
|
||||
|
||||
import ScoreLabel from './score-label'
|
||||
import ScoreInput from './score-input'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import AssignmentSet from './assignment-set'
|
||||
import * as actions from '../actions'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
import {transformScore, getScoringRangeSplitWarning} from '../score-helpers'
|
||||
|
||||
const {object, func, bool} = PropTypes
|
||||
|
||||
const MAX_SETS = 3
|
||||
|
||||
class ScoringRange extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
triggerAssignment: object,
|
||||
range: object.isRequired,
|
||||
path: object.isRequired,
|
||||
assignments: object.isRequired,
|
||||
isTop: bool,
|
||||
isBottom: bool,
|
||||
onScoreChanged: func,
|
||||
onAddItems: func,
|
||||
|
||||
// action props
|
||||
removeAssignment: func.isRequired,
|
||||
mergeAssignmentSets: func.isRequired,
|
||||
splitAssignmentSet: func.isRequired,
|
||||
moveAssignment: func.isRequired,
|
||||
setAriaAlert: func.isRequired,
|
||||
setGlobalWarning: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.titleRef = React.createRef()
|
||||
this.handleAddItems = this.handleAddItems.bind(this)
|
||||
this.removeAssignment = this.removeAssignment.bind(this)
|
||||
this.toggleSetCondition = this.toggleSetCondition.bind(this)
|
||||
}
|
||||
|
||||
handleAddItems() {
|
||||
this.props.onAddItems(this.props.path.range)
|
||||
}
|
||||
|
||||
renderScoreLabel(score, label, isUpperBound) {
|
||||
return (
|
||||
<ScoreLabel
|
||||
score={score}
|
||||
label={label}
|
||||
isUpperBound={isUpperBound}
|
||||
triggerAssignment={this.props.triggerAssignment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
renderUpperBound() {
|
||||
if (this.props.isTop) {
|
||||
return this.renderScoreLabel(this.props.range.get('upper_bound'), I18n.t('Top Bound'), true)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
renderLowerBound() {
|
||||
if (this.props.isBottom) {
|
||||
return this.renderScoreLabel(
|
||||
this.props.range.get('lower_bound'),
|
||||
I18n.t('Lower Bound'),
|
||||
false
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<ScoreInput
|
||||
score={this.props.range.get('lower_bound')}
|
||||
label={I18n.t('Division cutoff %{cutoff_value}', {
|
||||
cutoff_value: this.props.path.range + 1
|
||||
})}
|
||||
error={this.props.range.get('error')}
|
||||
onScoreChanged={this.props.onScoreChanged}
|
||||
triggerAssignment={this.props.triggerAssignment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
toggleSetCondition(path, isAnd, isDisabled) {
|
||||
if (isAnd) {
|
||||
if (isDisabled) {
|
||||
// see clearing method in actors.js
|
||||
this.props.setGlobalWarning(getScoringRangeSplitWarning())
|
||||
} else {
|
||||
this.props.splitAssignmentSet({
|
||||
index: path.range,
|
||||
assignmentSetIndex: path.set,
|
||||
splitIndex: path.assignment + 1
|
||||
})
|
||||
this.props.setAriaAlert(I18n.t('Sets are split, click to merge'))
|
||||
}
|
||||
} else {
|
||||
this.props.mergeAssignmentSets({index: path.range, leftSetIndex: path.set})
|
||||
this.props.setAriaAlert(I18n.t('Sets are merged, click to split'))
|
||||
}
|
||||
}
|
||||
|
||||
removeAssignment(path, asg) {
|
||||
this.props.removeAssignment({path})
|
||||
this.props.setAriaAlert(
|
||||
I18n.t('Removed assignment %{assignment_name}', {assignment_name: asg.get('name')})
|
||||
)
|
||||
setTimeout(() => this.titleRef.current.focus(), 1)
|
||||
}
|
||||
|
||||
renderAssignmentSets() {
|
||||
const path = this.props.path
|
||||
|
||||
return this.props.range
|
||||
.get('assignment_sets', List())
|
||||
.map((set, i, sets) => {
|
||||
return (
|
||||
<AssignmentSet
|
||||
key={set.get('id')}
|
||||
path={path.push(i)}
|
||||
label={I18n.t('Assignment set %{set_index}', {set_index: i + 1})}
|
||||
setAssignments={set.get('assignment_set_associations', List())}
|
||||
allAssignments={this.props.assignments}
|
||||
showOrToggle={i + 1 !== sets.size}
|
||||
toggleSetCondition={this.toggleSetCondition}
|
||||
removeAssignment={this.removeAssignment}
|
||||
moveAssignment={this.props.moveAssignment}
|
||||
disableSplit={sets.size >= MAX_SETS}
|
||||
setGlobalWarning={this.props.setGlobalWarning}
|
||||
/>
|
||||
)
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
|
||||
render() {
|
||||
const upperBound = transformScore(
|
||||
this.props.range.get('upper_bound'),
|
||||
this.props.triggerAssignment,
|
||||
true
|
||||
)
|
||||
const lowerBound = transformScore(
|
||||
this.props.range.get('lower_bound'),
|
||||
this.props.triggerAssignment,
|
||||
false
|
||||
)
|
||||
|
||||
const rangeTitle = I18n.t('Scoring range %{upperBound} to %{lowerBound}', {
|
||||
upperBound,
|
||||
lowerBound
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="cr-scoring-range">
|
||||
<ScreenReaderContent>
|
||||
<h2 ref={this.titleRef}>{rangeTitle}</h2>
|
||||
</ScreenReaderContent>
|
||||
<div className="cr-scoring-range__bounds">
|
||||
<div className="cr-scoring-range__bound cr-scoring-range__upper-bound">
|
||||
{this.renderUpperBound()}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="cr-scoring-range__add-assignment-button"
|
||||
aria-label={I18n.t('Add Items to Score Range')}
|
||||
onClick={this.handleAddItems}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="cr-scoring-range__bound cr-scoring-range__lower-bound">
|
||||
{this.renderLowerBound()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cr-scoring-range__assignments">{this.renderAssignmentSets()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedScoringRange = connect(null, actions)(ScoringRange)
|
||||
|
||||
export default ConnectedScoringRange
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {Provider} from 'react-redux'
|
||||
|
||||
import createReduxStore from './create-redux-store'
|
||||
import EditorView from './editor-view'
|
||||
import createRootReducer from './reducer'
|
||||
import * as actions from './actions'
|
||||
import initActors from './actors'
|
||||
|
||||
class ConditionalReleaseEditor {
|
||||
constructor(options = {}) {
|
||||
this.rootReducer = createRootReducer()
|
||||
this.store = options.store || createReduxStore(this.rootReducer)
|
||||
|
||||
initActors(this.store)
|
||||
|
||||
if (options.courseId) {
|
||||
this.loadCourseId(options.courseId)
|
||||
}
|
||||
|
||||
if (options.assignment) {
|
||||
this.loadAssignment(options.assignment)
|
||||
} else {
|
||||
this.loadDefaultRule()
|
||||
}
|
||||
|
||||
if (options.gradingType) {
|
||||
this.updateAssignment({grading_type: options.gradingType})
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a promise. Requires the assignment id to be set.
|
||||
saveRule() {
|
||||
return this.store.dispatch(actions.commitRule(this.store.getState()))
|
||||
}
|
||||
|
||||
getReducer() {
|
||||
return this.rootReducer
|
||||
}
|
||||
|
||||
attach(targetDomNode, targetRoot = null) {
|
||||
targetRoot = targetRoot || targetDomNode
|
||||
ReactDom.render(
|
||||
<Provider store={this.store}>
|
||||
<EditorView appElement={targetRoot} />
|
||||
</Provider>,
|
||||
targetDomNode
|
||||
)
|
||||
}
|
||||
|
||||
// This does _not_ cause the editor to reload the data for the specified assignment.
|
||||
// Useful if you're creating a new assignment: set the new assignment's id here before saving
|
||||
updateAssignment(assignment) {
|
||||
this.store.dispatch(actions.updateAssignment(assignment))
|
||||
}
|
||||
|
||||
// set the assignment id and load it
|
||||
loadAssignment(newAssignment) {
|
||||
this.updateAssignment(newAssignment)
|
||||
return this.store.dispatch(actions.loadRuleForAssignment(this.store.getState()))
|
||||
}
|
||||
|
||||
loadDefaultRule() {
|
||||
this.store.dispatch(actions.loadDefaultRule(this.store.getState()))
|
||||
}
|
||||
|
||||
loadCourseId(newCourseId) {
|
||||
this.store.dispatch(actions.setCourseId(newCourseId))
|
||||
this.store.dispatch(actions.getAssignments(this.store.getState()))
|
||||
}
|
||||
|
||||
subscribe(callback) {
|
||||
return this.store.subscribe(callback)
|
||||
}
|
||||
|
||||
getErrors() {
|
||||
return this.store
|
||||
.getState()
|
||||
.getIn(['rule', 'scoring_ranges'])
|
||||
.reduce((acc, sr, index) => {
|
||||
if (sr.get('error')) {
|
||||
acc.push({index, error: sr.get('error')})
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
focusOnError() {
|
||||
const errors = this.getErrors()
|
||||
if (errors.length > 0) {
|
||||
const index = errors[0].index
|
||||
document.querySelectorAll('.cr-percent-input__input')[index].focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ConditionalReleaseEditor
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {applyMiddleware, createStore} from 'redux'
|
||||
import thunker from 'redux-thunk'
|
||||
import promiser from 'redux-promise'
|
||||
import {createLogger} from 'redux-logger'
|
||||
|
||||
export const middleware = [thunker, promiser]
|
||||
|
||||
export default function createReduxStore(reducer) {
|
||||
const middlewareWithLogger = [...middleware, createLogger({stateTransformer: s => s.toJS()})]
|
||||
return createStore(reducer, undefined, applyMiddleware(...middlewareWithLogger))
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 axios from 'axios'
|
||||
import parseLinkHeader from 'parse-link-header'
|
||||
|
||||
import categories, {OTHER_ID} from './categories'
|
||||
|
||||
// in general, these methods return promises
|
||||
const CyoeApi = {
|
||||
getRuleForAssignment: state => {
|
||||
const courseId = state.get('course_id')
|
||||
const triggerAssignmentId = state.getIn(['trigger_assignment', 'id'])
|
||||
if (triggerAssignmentId) {
|
||||
return axios
|
||||
.get(`/api/v1/courses/${courseId}/mastery_paths/rules`, {
|
||||
params: {
|
||||
trigger_assignment_id: triggerAssignmentId,
|
||||
include: ['all']
|
||||
}
|
||||
})
|
||||
.then(res => {
|
||||
res.data = res.data.length ? res.data[0] : null
|
||||
return res
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({data: null})
|
||||
}
|
||||
},
|
||||
|
||||
saveRule: state => {
|
||||
const courseId = state.get('course_id')
|
||||
let url = `/api/v1/courses/${courseId}/mastery_paths/rules`
|
||||
|
||||
const data = state.get('rule').toJS()
|
||||
const ruleId = state.getIn(['rule', 'id'])
|
||||
const triggerId = state.getIn(['trigger_assignment', 'id'])
|
||||
|
||||
if (ruleId) {
|
||||
url = `/api/v1/courses/${courseId}/mastery_paths/rules/${ruleId}`
|
||||
return axios.put(url, data)
|
||||
} else if (data.scoring_ranges) {
|
||||
data.trigger_assignment_id = triggerId
|
||||
data.scoring_ranges = data.scoring_ranges.map(range => {
|
||||
delete range.id
|
||||
return range
|
||||
})
|
||||
return axios.post(url, data)
|
||||
}
|
||||
},
|
||||
|
||||
deleteRule: state => {
|
||||
const courseId = state.get('course_id')
|
||||
const ruleId = state.getIn(['rule', 'id'])
|
||||
if (ruleId) {
|
||||
const url = `/api/v1/courses/${courseId}/mastery_paths/rules/${ruleId}`
|
||||
return axios.delete(url)
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
},
|
||||
|
||||
getAssignments: state => {
|
||||
const perPage = 100
|
||||
return CyoeApi._depaginate(
|
||||
`/api/v1/courses/${state.get('course_id')}/assignments?per_page=${perPage}`
|
||||
).then(res => {
|
||||
res.data.forEach(CyoeApi._assignCategory)
|
||||
return res
|
||||
})
|
||||
},
|
||||
|
||||
_assignCategory: asg => {
|
||||
const category = categories.find(cat => {
|
||||
return (
|
||||
asg.submission_types.length &&
|
||||
cat.submission_types &&
|
||||
asg.submission_types.find(sub => cat.submission_types.includes(sub))
|
||||
)
|
||||
})
|
||||
asg.category = category ? category.id : OTHER_ID
|
||||
},
|
||||
|
||||
_depaginate: (url, allResults = []) => {
|
||||
return axios.get(url).then(res => {
|
||||
allResults = allResults.concat(res.data)
|
||||
if (res.headers.link) {
|
||||
const links = parseLinkHeader(res.headers.link)
|
||||
if (links.next) {
|
||||
return CyoeApi._depaginate(links.next.url, allResults)
|
||||
}
|
||||
}
|
||||
res.data = allResults
|
||||
return res
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default CyoeApi
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {connect} from 'react-redux'
|
||||
import Immutable, {Map, List} from 'immutable'
|
||||
import {DragDropContext} from 'react-dnd'
|
||||
import HTML5Backend from 'react-dnd-html5-backend'
|
||||
|
||||
import Path from './assignment-path'
|
||||
import * as actions from './actions'
|
||||
import ScoringRange from './components/scoring-range'
|
||||
import AssignmentPickerModal from './components/assignment-picker-modal'
|
||||
import {ScreenReaderContent} from '@instructure/ui-a11y'
|
||||
import I18n from 'i18n!conditional_release'
|
||||
|
||||
const {object, func} = PropTypes
|
||||
|
||||
class EditorView extends React.Component {
|
||||
static get propTypes() {
|
||||
return {
|
||||
state: object.isRequired,
|
||||
setScoreAtIndex: func.isRequired,
|
||||
appElement: object,
|
||||
|
||||
// action props
|
||||
setAssignmentPickerTarget: func.isRequired,
|
||||
openAssignmentPicker: func.isRequired,
|
||||
closeAssignmentPicker: func.isRequired,
|
||||
addAssignmentsToRangeSet: func.isRequired,
|
||||
clearGlobalWarning: func.isRequired
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.closeAssignmentPicker = this.closeAssignmentPicker.bind(this)
|
||||
this.addItemsToRange = this.addItemsToRange.bind(this)
|
||||
this.setAssignmentPickerTarget = this.setAssignmentPickerTarget.bind(this)
|
||||
}
|
||||
|
||||
setAssignmentPickerTarget(index) {
|
||||
const targetRange = this.props.state.getIn(['rule', 'scoring_ranges', index])
|
||||
const target = Map({
|
||||
rangeIndex: index,
|
||||
setIndex: 0,
|
||||
assignment_set_associations: targetRange.getIn(
|
||||
['assignment_sets', 0, 'assignment_set_associations'],
|
||||
List()
|
||||
),
|
||||
lower_bound: targetRange.get('lower_bound'),
|
||||
upper_bound: targetRange.get('upper_bound')
|
||||
})
|
||||
|
||||
this.props.setAssignmentPickerTarget(target)
|
||||
this.props.openAssignmentPicker()
|
||||
}
|
||||
|
||||
closeAssignmentPicker() {
|
||||
this.props.closeAssignmentPicker()
|
||||
}
|
||||
|
||||
addItemsToRange() {
|
||||
const selected = this.props.state.getIn(['assignment_picker', 'selected_assignments'])
|
||||
const selectedAssignments = selected.map(id => Immutable.Map({assignment_id: id}))
|
||||
|
||||
const target = this.props.state.getIn(['assignment_picker', 'target'])
|
||||
|
||||
this.props.addAssignmentsToRangeSet({
|
||||
index: target.get('rangeIndex'),
|
||||
assignmentSetIndex: target.get('setIndex'),
|
||||
assignment_set_associations: selectedAssignments
|
||||
})
|
||||
|
||||
this.closeAssignmentPicker()
|
||||
}
|
||||
|
||||
createScoreChangedCallback(index) {
|
||||
return this.props.setScoreAtIndex.bind(this, index)
|
||||
}
|
||||
|
||||
renderGlobalError() {
|
||||
const errorText = this.props.state.get('global_error')
|
||||
if (errorText) {
|
||||
return (
|
||||
<p className="cr-editor__global-error">
|
||||
<strong>Error: </strong>
|
||||
{errorText}
|
||||
</p>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
renderRanges(ranges) {
|
||||
return (
|
||||
<div className="cr-editor__scoring-ranges">
|
||||
{ranges.map((range, i) => (
|
||||
<ScoringRange
|
||||
key={range.get('id')}
|
||||
path={new Path(i)}
|
||||
range={range}
|
||||
isTop={i === 0}
|
||||
isBottom={i === ranges.size - 1}
|
||||
onScoreChanged={this.createScoreChangedCallback(i)}
|
||||
onAddItems={this.setAssignmentPickerTarget}
|
||||
triggerAssignment={this.props.state.get('trigger_assignment')}
|
||||
assignments={this.props.state.get('assignments', Immutable.List())}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderEditorContent() {
|
||||
const ranges = this.props.state.getIn(['rule', 'scoring_ranges'])
|
||||
|
||||
if (ranges.size) {
|
||||
return this.renderRanges(ranges)
|
||||
} else {
|
||||
return <p>{I18n.t('Loading..')}</p>
|
||||
}
|
||||
}
|
||||
|
||||
renderAssignmentsModal() {
|
||||
const isModalOpen = this.props.state.getIn(['assignment_picker', 'is_open'], false)
|
||||
const target = this.props.state.getIn(['assignment_picker', 'target'])
|
||||
|
||||
return (
|
||||
<AssignmentPickerModal
|
||||
isOpen={isModalOpen}
|
||||
target={target}
|
||||
onRequestClose={this.closeAssignmentPicker}
|
||||
addItemsToRange={this.addItemsToRange}
|
||||
triggerAssignment={this.props.state.get('trigger_assignment')}
|
||||
appElement={this.props.appElement}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
renderAriaAlert() {
|
||||
return (
|
||||
<ScreenReaderContent>
|
||||
<p role="alert" aria-live="assertive">
|
||||
{this.props.state.get('aria_alert')}
|
||||
</p>
|
||||
</ScreenReaderContent>
|
||||
)
|
||||
}
|
||||
|
||||
renderGlobalWarning() {
|
||||
const warning = this.props.state.get('global_warning')
|
||||
|
||||
if (warning) {
|
||||
return (
|
||||
<div className="ic-flash-warning cr-global-warning">
|
||||
<div className="ic-flash__icon" aria-hidden>
|
||||
<i className="icon-warning" />
|
||||
</div>
|
||||
{warning}
|
||||
<button
|
||||
type="button"
|
||||
className="Button Button--icon-action close_link"
|
||||
onClick={this.props.clearGlobalWarning}
|
||||
>
|
||||
<i className="icon-x" aria-hidden />
|
||||
<ScreenReaderContent>{I18n.t('Close')}</ScreenReaderContent>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="cr-editor">
|
||||
{this.renderAssignmentsModal()}
|
||||
{this.renderAriaAlert()}
|
||||
{this.renderGlobalWarning()}
|
||||
{this.renderGlobalError()}
|
||||
{this.renderEditorContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ConnectedEditorView = connect(
|
||||
state => ({state}), // mapStateToProps
|
||||
{...actions, ...actions.assignmentPicker} // mapActionsToProps
|
||||
)(DragDropContext(HTML5Backend)(EditorView))
|
||||
|
||||
export default ConnectedEditorView
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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!conditional_release'
|
||||
|
||||
const GradingTypes = {
|
||||
points: {
|
||||
label: () => I18n.t('points'),
|
||||
key: 'points'
|
||||
},
|
||||
percent: {
|
||||
label: () => I18n.t('percent'),
|
||||
key: 'percent'
|
||||
},
|
||||
letter_grade: {
|
||||
label: () => I18n.t('letter grade'),
|
||||
key: 'letter_grade'
|
||||
},
|
||||
gpa_scale: {
|
||||
label: () => I18n.t('GPA scale'),
|
||||
key: 'gpa_scale'
|
||||
},
|
||||
other: {
|
||||
label: () => I18n.t('other'),
|
||||
key: 'other'
|
||||
}
|
||||
}
|
||||
|
||||
export default GradingTypes
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
// decorator function for only calling a reducer if the action is successful
|
||||
export const onSuccessOnly = f => {
|
||||
return (state, action) => {
|
||||
if (action.error) return state
|
||||
return f(state, action)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {combineReducers} from 'redux-immutable'
|
||||
import {handleActions} from 'redux-actions'
|
||||
import reduceReducers from 'reduce-reducers'
|
||||
|
||||
import Immutable, {Map} from 'immutable'
|
||||
import {onSuccessOnly} from './reducer-helpers'
|
||||
import * as actions from './actions'
|
||||
import scoringRangesReducer from './reducers/scoring-ranges-reducer'
|
||||
import assignmentPickerReducer from './reducers/assignment-picker-reducer'
|
||||
|
||||
const mergePayload = (state, action) =>
|
||||
state.mergeWith((prev, next) => next || prev, action.payload)
|
||||
|
||||
const createRootReducer = () => {
|
||||
return reduceReducers(
|
||||
combineReducers({
|
||||
// handle piecewise state
|
||||
aria_alert: handleActions(
|
||||
{
|
||||
[actions.SET_ARIA_ALERT]: (state, action) => action.payload,
|
||||
[actions.CLEAR_ARIA_ALERT]: () => '',
|
||||
[actions.SET_GLOBAL_WARNING]: (state, action) => action.payload,
|
||||
[actions.CLEAR_GLOBAL_WARNING]: () => ''
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
global_error: handleActions(
|
||||
{
|
||||
[actions.LOAD_RULE_FOR_ASSIGNMENT]: gotHttpError,
|
||||
[actions.SAVE_RULE]: gotHttpError,
|
||||
[actions.GET_ASSIGNMENTS]: gotHttpError,
|
||||
[actions.DELETE_RULE]: gotHttpError
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
global_warning: handleActions(
|
||||
{
|
||||
[actions.SET_GLOBAL_WARNING]: (state, action) => action.payload,
|
||||
[actions.CLEAR_GLOBAL_WARNING]: () => ''
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
course_id: handleActions(
|
||||
{
|
||||
[actions.SET_COURSE_ID]: (state, action) => action.payload
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
rule: combineReducers({
|
||||
id: handleActions(
|
||||
{
|
||||
[actions.LOAD_RULE_FOR_ASSIGNMENT]: gotRuleSetRuleId,
|
||||
[actions.SAVE_RULE]: savedRuleSetRuleId
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
scoring_ranges: scoringRangesReducer
|
||||
}),
|
||||
|
||||
assignments: handleActions(
|
||||
{
|
||||
[actions.GET_ASSIGNMENTS]: gotAssignments
|
||||
},
|
||||
Map()
|
||||
),
|
||||
|
||||
assignment_picker: assignmentPickerReducer,
|
||||
|
||||
trigger_assignment: handleActions(
|
||||
{
|
||||
[actions.UPDATE_ASSIGNMENT]: mergePayload
|
||||
},
|
||||
Map()
|
||||
),
|
||||
|
||||
received: combineReducers({
|
||||
rule: handleActions(
|
||||
{
|
||||
[actions.LOAD_RULE_FOR_ASSIGNMENT]: () => true
|
||||
},
|
||||
false
|
||||
),
|
||||
assignments: handleActions(
|
||||
{
|
||||
[actions.GET_ASSIGNMENTS]: () => true
|
||||
},
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
export default createRootReducer
|
||||
|
||||
const gotHttpError = (state, action) => {
|
||||
if (!action.error) return ''
|
||||
return `${action.payload.config.method.toUpperCase()} ${action.payload.config.url}: ${
|
||||
action.payload.statusText
|
||||
} / ${action.payload.data && action.payload.data.error}`
|
||||
}
|
||||
|
||||
const gotRuleSetRuleId = onSuccessOnly((state, action) => {
|
||||
if (!action.payload.data) return ''
|
||||
return action.payload.data.id
|
||||
})
|
||||
|
||||
const savedRuleSetRuleId = onSuccessOnly((state, action) => {
|
||||
return action.payload.data.id
|
||||
})
|
||||
|
||||
const gotAssignments = onSuccessOnly((state, action) => {
|
||||
if (!action.payload.data) return Map()
|
||||
|
||||
const assgMap = {}
|
||||
|
||||
action.payload.data.forEach(assg => {
|
||||
assgMap[assg.id] = assg
|
||||
})
|
||||
|
||||
return Immutable.fromJS(assgMap)
|
||||
})
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 {List, Set} from 'immutable'
|
||||
import {combineReducers} from 'redux-immutable'
|
||||
import {handleActions} from 'redux-actions'
|
||||
|
||||
import {ALL_ID} from '../categories'
|
||||
import * as actions from '../assignment-picker-actions'
|
||||
|
||||
const assignmentPickerReducer = combineReducers({
|
||||
is_open: handleActions(
|
||||
{
|
||||
[actions.OPEN_ASSIGNMENT_PICKER]: (_state, _actions) => true,
|
||||
[actions.CLOSE_ASSIGNMENT_PICKER]: (_state, _actions) => false
|
||||
},
|
||||
false
|
||||
),
|
||||
|
||||
target: handleActions(
|
||||
{
|
||||
[actions.SET_ASSIGNMENT_PICKER_TARGET]: (state, action) => action.payload,
|
||||
[actions.CLOSE_ASSIGNMENT_PICKER]: () => null
|
||||
},
|
||||
null
|
||||
),
|
||||
|
||||
disabled_assignments: handleActions(
|
||||
{
|
||||
[actions.SET_ASSIGNMENT_PICKER_TARGET]: (state, action) =>
|
||||
action.payload
|
||||
.get('assignment_set_associations', List())
|
||||
.map(a => a.get('assignment_id'))
|
||||
.toSet(),
|
||||
[actions.CLOSE_ASSIGNMENT_PICKER]: () => Set()
|
||||
},
|
||||
Set()
|
||||
),
|
||||
|
||||
selected_assignments: handleActions(
|
||||
{
|
||||
[actions.SELECT_ASSIGNMENT_IN_PICKER]: (state, action) => state.toSet().add(action.payload),
|
||||
[actions.UNSELECT_ASSIGNMENT_IN_PICKER]: (state, action) =>
|
||||
state.toSet().delete(action.payload),
|
||||
[actions.CLOSE_ASSIGNMENT_PICKER]: () => Set()
|
||||
},
|
||||
Set()
|
||||
),
|
||||
|
||||
name_filter: handleActions(
|
||||
{
|
||||
[actions.FILTER_ASSIGNMENTS_BY_NAME]: (state, action) => action.payload,
|
||||
[actions.CLOSE_ASSIGNMENT_PICKER]: () => ''
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
category_filter: handleActions(
|
||||
{
|
||||
[actions.FILTER_ASSIGNMENTS_BY_CATEGORY]: (state, action) => action.payload,
|
||||
[actions.CLOSE_ASSIGNMENT_PICKER]: () => ALL_ID
|
||||
},
|
||||
ALL_ID
|
||||
)
|
||||
})
|
||||
|
||||
export default assignmentPickerReducer
|
|
@ -0,0 +1,267 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 Immutable, {List, Map} from 'immutable'
|
||||
import {combineReducers} from 'redux-immutable'
|
||||
import {handleActions} from 'redux-actions'
|
||||
import * as actions from '../actions'
|
||||
import {onSuccessOnly} from '../reducer-helpers'
|
||||
|
||||
const DEFAULT_SCORING_RANGES = Immutable.fromJS([
|
||||
{upper_bound: null, lower_bound: '0.7', assignment_sets: [{assignment_set_associations: []}]},
|
||||
{upper_bound: '0.7', lower_bound: '0.4', assignment_sets: [{assignment_set_associations: []}]},
|
||||
{upper_bound: '0.4', lower_bound: null, assignment_sets: [{assignment_set_associations: []}]}
|
||||
])
|
||||
|
||||
const emptyAssignmentSet = () => Map({assignment_set_associations: List()})
|
||||
|
||||
const identity = (dflt = '') => (s, _a) => s || dflt
|
||||
|
||||
const scoringRangesReducer = (state, action) => {
|
||||
if (state === undefined) return List()
|
||||
state = overallScoringRangesReducer(state, action)
|
||||
|
||||
// temporary hack until all actions switch over to Path()
|
||||
const rangeIndex = action.payload
|
||||
? action.payload.index !== undefined
|
||||
? action.payload.index
|
||||
: action.payload.path && action.payload.path.range
|
||||
: undefined
|
||||
|
||||
if (rangeIndex !== undefined) {
|
||||
const selectedScoringRange = state.get(rangeIndex)
|
||||
if (selectedScoringRange) {
|
||||
const newScoringRange = structuredSingleScoringRangeReducer(selectedScoringRange, action)
|
||||
state = state.set(rangeIndex, newScoringRange)
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
export default scoringRangesReducer
|
||||
|
||||
const sortRanges = ranges => {
|
||||
return ranges.sort((a, b) => b.get('lower_bound') - a.get('lower_bound'))
|
||||
}
|
||||
|
||||
const getDefaultScoringRanges = (_state, _action) => DEFAULT_SCORING_RANGES
|
||||
|
||||
const gotRuleSetScoringRanges = onSuccessOnly((state, action) => {
|
||||
if (!action.payload.data) return DEFAULT_SCORING_RANGES
|
||||
const ranges = Immutable.fromJS(action.payload.data.scoring_ranges).map(range => {
|
||||
if (!range.has('assignment_sets') || range.get('assignment_sets').size === 0) {
|
||||
range = range.set('assignment_sets', List([emptyAssignmentSet()]))
|
||||
}
|
||||
|
||||
return range
|
||||
})
|
||||
|
||||
return sortRanges(ranges)
|
||||
})
|
||||
|
||||
const savedRuleSetScoringRanges = onSuccessOnly((state, action) => {
|
||||
const ranges = Immutable.fromJS(action.payload.data.scoring_ranges)
|
||||
return sortRanges(ranges)
|
||||
})
|
||||
|
||||
const juggleBounds = (state, action) => {
|
||||
if (action.payload.index === undefined) return state
|
||||
// the last range's lower bound can't be set
|
||||
if (action.payload.index >= state.size - 1) return state
|
||||
if (action.payload.index < 0) return state
|
||||
state = state.setIn([action.payload.index, 'lower_bound'], action.payload.score)
|
||||
state = state.setIn([action.payload.index + 1, 'upper_bound'], action.payload.score)
|
||||
return state
|
||||
}
|
||||
|
||||
const addAssignments = (state, action) => {
|
||||
const assignments = action.payload.assignment_set_associations
|
||||
|
||||
return state.concat(
|
||||
// check if assignments are immutable, otherwise convert them to immutable
|
||||
Immutable.Iterable.isIterable(assignments) ? assignments : Immutable.fromJS(assignments)
|
||||
)
|
||||
}
|
||||
|
||||
const removeAssignment = (state, action) => {
|
||||
return state.delete(action.payload.path.assignment)
|
||||
}
|
||||
|
||||
const insertAssignment = (state, action) => {
|
||||
const assignment = action.payload.path.assignment
|
||||
return state.insert(
|
||||
assignment !== undefined ? assignment + 1 : state.size,
|
||||
Map({assignment_id: action.payload.assignment})
|
||||
)
|
||||
}
|
||||
|
||||
const updateAssignmentIndex = (state, action) => {
|
||||
const oldIndex = action.payload.path.assignment
|
||||
const newIndex = action.payload.assignmentIndex
|
||||
const assg = state.get(oldIndex)
|
||||
|
||||
return state.delete(oldIndex).insert(newIndex + (newIndex > oldIndex ? 0 : 1), assg)
|
||||
}
|
||||
|
||||
const mergeAssignmentSets = (state, action) => {
|
||||
const leftIndex = action.payload.leftSetIndex
|
||||
const rightIndex = leftIndex + 1
|
||||
|
||||
state = state.updateIn([leftIndex, 'assignment_set_associations'], assignments => {
|
||||
const newAssignments = state.getIn([rightIndex, 'assignment_set_associations'], List())
|
||||
|
||||
newAssignments.forEach(newAssg => {
|
||||
const isFound = assignments.find(
|
||||
assg => assg.get('assignment_id') === newAssg.get('assignment_id')
|
||||
)
|
||||
if (!isFound) assignments = assignments.push(newAssg.delete('id'))
|
||||
})
|
||||
|
||||
return assignments
|
||||
})
|
||||
|
||||
state = state.delete(rightIndex)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
const splitAssignmentSet = (state, action) => {
|
||||
const assignmentSetIndex = action.payload.assignmentSetIndex
|
||||
const splitIndex = action.payload.splitIndex
|
||||
|
||||
const assignments = state.getIn([assignmentSetIndex, 'assignment_set_associations'])
|
||||
|
||||
state = state.updateIn([assignmentSetIndex, 'assignment_set_associations'], assmts => {
|
||||
return assmts.slice(0, splitIndex)
|
||||
})
|
||||
|
||||
state = state.insert(
|
||||
assignmentSetIndex + 1,
|
||||
Map({
|
||||
assignment_set_associations: assignments.slice(splitIndex).map(assg => assg.delete('id'))
|
||||
})
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
const removeDuplicatesFromSet = set => {
|
||||
return set.update('assignment_set_associations', assgs => {
|
||||
const seenIds = []
|
||||
return assgs.filter(asg => {
|
||||
if (seenIds.indexOf(asg.get('assignment_id')) === -1) {
|
||||
seenIds.push(asg.get('assignment_id'))
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const removeEmptySets = sets => {
|
||||
sets = sets.filter(set => {
|
||||
return set.get('assignment_set_associations').size !== 0
|
||||
})
|
||||
|
||||
// make sure there's always at least one set
|
||||
if (sets.size === 0) {
|
||||
sets = sets.push(emptyAssignmentSet())
|
||||
}
|
||||
|
||||
return sets
|
||||
}
|
||||
|
||||
const assignmentSetReducer = (state, action) => {
|
||||
state = overallAssignmentSetReducer(state, action)
|
||||
|
||||
// temporary hack until all actions switch over to Path()
|
||||
const setIndex =
|
||||
action.payload.assignmentSetIndex !== undefined
|
||||
? action.payload.assignmentSetIndex
|
||||
: action.payload.path && action.payload.path.set
|
||||
|
||||
if (setIndex !== undefined) {
|
||||
const selectedAssignmentSet = state.get(setIndex)
|
||||
if (selectedAssignmentSet) {
|
||||
const newAssignmentSet = singleAssignmentSetReducer(selectedAssignmentSet, action)
|
||||
state = state.set(setIndex, newAssignmentSet)
|
||||
}
|
||||
}
|
||||
|
||||
state = state.map(removeDuplicatesFromSet)
|
||||
state = removeEmptySets(state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
const overallAssignmentSetReducer = handleActions(
|
||||
{
|
||||
[actions.MERGE_ASSIGNMENT_SETS]: mergeAssignmentSets,
|
||||
[actions.SPLIT_ASSIGNMENT_SET]: splitAssignmentSet
|
||||
},
|
||||
List(emptyAssignmentSet())
|
||||
)
|
||||
|
||||
const singleAssignmentSetReducer = combineReducers({
|
||||
id: identity(null),
|
||||
created_at: identity(),
|
||||
updated_at: identity(),
|
||||
scoring_range_id: identity(),
|
||||
position: identity(null),
|
||||
|
||||
assignment_set_associations: handleActions(
|
||||
{
|
||||
[actions.ADD_ASSIGNMENTS_TO_RANGE_SET]: addAssignments,
|
||||
[actions.REMOVE_ASSIGNMENT]: removeAssignment,
|
||||
[actions.INSERT_ASSIGNMENT]: insertAssignment,
|
||||
[actions.UPDATE_ASSIGNMENT_INDEX]: updateAssignmentIndex
|
||||
},
|
||||
List()
|
||||
)
|
||||
})
|
||||
|
||||
const overallScoringRangesReducer = handleActions(
|
||||
{
|
||||
[actions.LOAD_RULE_FOR_ASSIGNMENT]: gotRuleSetScoringRanges,
|
||||
[actions.LOAD_DEFAULT_RULE]: getDefaultScoringRanges,
|
||||
[actions.SAVE_RULE]: savedRuleSetScoringRanges,
|
||||
[actions.SET_SCORE_AT_INDEX]: juggleBounds
|
||||
},
|
||||
List()
|
||||
)
|
||||
|
||||
const structuredSingleScoringRangeReducer = combineReducers({
|
||||
error: handleActions(
|
||||
{
|
||||
[actions.SET_ERROR_AT_SCORE_INDEX]: (_s, a) => a.payload.error
|
||||
},
|
||||
''
|
||||
),
|
||||
|
||||
assignment_sets: assignmentSetReducer,
|
||||
|
||||
// prevent warnings from combineReducers about unexpected properties.
|
||||
lower_bound: identity(),
|
||||
upper_bound: identity(),
|
||||
created_at: identity(),
|
||||
updated_at: identity(),
|
||||
id: identity(),
|
||||
rule_id: identity(),
|
||||
position: identity(null)
|
||||
})
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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!conditional_release'
|
||||
|
||||
import GradingTypes from './grading-types'
|
||||
import numberHelper from '../shared/helpers/numberHelper'
|
||||
|
||||
const TEN_E_8 = 10e8
|
||||
|
||||
// stack overflow suggests this implementation
|
||||
export const isNumeric = n => {
|
||||
n = numberHelper.parse(n)
|
||||
return !isNaN(n) && isFinite(n) // eslint-disable-line no-restricted-globals
|
||||
}
|
||||
|
||||
const haveGradingScheme = assignment => {
|
||||
return assignment ? !!assignment.get('grading_scheme') : false
|
||||
}
|
||||
|
||||
export const getGradingType = assignment => {
|
||||
const type = assignment ? assignment.get('grading_type') : GradingTypes.percent.key
|
||||
if (
|
||||
(type === GradingTypes.letter_grade.key || type === GradingTypes.gpa_scale.key) &&
|
||||
!haveGradingScheme(assignment)
|
||||
) {
|
||||
return GradingTypes.percent.key
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
||||
export const percentToScore = (score, assignment) => {
|
||||
const gradingType = getGradingType(assignment)
|
||||
if (gradingType === GradingTypes.points.key) {
|
||||
return percentToPoints(score, assignment)
|
||||
} else if (
|
||||
gradingType === GradingTypes.letter_grade.key ||
|
||||
gradingType === GradingTypes.gpa_scale.key
|
||||
) {
|
||||
return percentToLetterGrade(score, assignment)
|
||||
} else {
|
||||
return percentToExternalPercent(score)
|
||||
}
|
||||
}
|
||||
|
||||
export const scoreToPercent = (score, assignment) => {
|
||||
const gradingType = getGradingType(assignment)
|
||||
if (gradingType === GradingTypes.points.key) {
|
||||
return pointsToPercent(score, assignment)
|
||||
} else if (
|
||||
gradingType === GradingTypes.letter_grade.key ||
|
||||
gradingType === GradingTypes.gpa_scale.key
|
||||
) {
|
||||
return letterGradeToPercent(score, assignment)
|
||||
} else {
|
||||
return externalPercentToPercent(score)
|
||||
}
|
||||
}
|
||||
|
||||
export const transformScore = (score, assignment, isUpperBound) => {
|
||||
// The backend stores nil for the upper and lowerbound scoring types
|
||||
if (!score) {
|
||||
if (isUpperBound) {
|
||||
score = '1'
|
||||
} else {
|
||||
score = '0'
|
||||
}
|
||||
}
|
||||
return formatScore(percentToScore(score, assignment), assignment)
|
||||
}
|
||||
|
||||
const floor8 = r => {
|
||||
return Math.floor(r * TEN_E_8) / TEN_E_8
|
||||
}
|
||||
|
||||
const formatScore = (score, assignment) => {
|
||||
const gradingType = getGradingType(assignment)
|
||||
if (gradingType === GradingTypes.points.key) {
|
||||
return I18n.t('%{score} pts', {score: I18n.n(score)})
|
||||
} else if (
|
||||
gradingType === GradingTypes.letter_grade.key ||
|
||||
gradingType === GradingTypes.gpa_scale.key
|
||||
) {
|
||||
return score
|
||||
} else {
|
||||
return I18n.n(score, {percentage: true})
|
||||
}
|
||||
}
|
||||
|
||||
export const formatReaderOnlyScore = (score, assignment) => {
|
||||
const gradingType = getGradingType(assignment)
|
||||
if (gradingType === GradingTypes.points.key) {
|
||||
return I18n.t('%{score} points', {score: I18n.n(numberHelper.parse(score))})
|
||||
} else if (
|
||||
gradingType === GradingTypes.letter_grade.key ||
|
||||
gradingType === GradingTypes.gpa_scale.key
|
||||
) {
|
||||
return I18n.t('%{score} letter grade', {score})
|
||||
} else {
|
||||
return I18n.t('%{score} percent', {score: I18n.n(numberHelper.parse(score))})
|
||||
}
|
||||
}
|
||||
|
||||
const percentToPoints = (score, assignment) => {
|
||||
if (!isNumeric(score)) {
|
||||
return score
|
||||
}
|
||||
if (score === 0) {
|
||||
return '0'
|
||||
}
|
||||
const percent = numberHelper.parse(score)
|
||||
const pointsPossible = Number(assignment.get('points_possible')) || 100
|
||||
return (percent * pointsPossible).toFixed(2)
|
||||
}
|
||||
|
||||
const pointsToPercent = (score, assignment) => {
|
||||
if (!isNumeric(score)) {
|
||||
return score
|
||||
}
|
||||
if (score === 0) {
|
||||
return '0'
|
||||
}
|
||||
const pointsPossible = Number(assignment.get('points_possible')) || 100
|
||||
return floor8(numberHelper.parse(score) / pointsPossible).toString()
|
||||
}
|
||||
|
||||
const percentToLetterGrade = (score, assignment) => {
|
||||
if (score === '') {
|
||||
return ''
|
||||
}
|
||||
const letterGrade = {letter: null, score: -Infinity}
|
||||
const parsedScore = numberHelper.parse(score)
|
||||
assignment.get('grading_scheme').forEach((v, k) => {
|
||||
v = numberHelper.parse(v)
|
||||
if ((v <= parsedScore && v > letterGrade.score) || (v === 0 && v > parsedScore)) {
|
||||
letterGrade.score = v
|
||||
letterGrade.letter = k
|
||||
}
|
||||
})
|
||||
return letterGrade.letter ? letterGrade.letter : score
|
||||
}
|
||||
|
||||
const letterGradeToPercent = (score, assignment) => {
|
||||
if (score === '') {
|
||||
return ''
|
||||
}
|
||||
const percent = assignment.getIn(['grading_scheme', score.toString().toUpperCase()])
|
||||
if (percent === 0) {
|
||||
return '0'
|
||||
}
|
||||
return percent || score
|
||||
}
|
||||
|
||||
const percentToExternalPercent = score => {
|
||||
if (!isNumeric(score)) {
|
||||
return score
|
||||
}
|
||||
return Math.floor(numberHelper.parse(score) * 100).toString()
|
||||
}
|
||||
|
||||
const externalPercentToPercent = score => {
|
||||
if (!isNumeric(score)) {
|
||||
return score
|
||||
}
|
||||
return (numberHelper.parse(score) / 100.0).toString()
|
||||
}
|
||||
|
||||
export const getScoringRangeSplitWarning = () => {
|
||||
return I18n.t(
|
||||
'Splitting disabled: there can only be a maximum of three assignment groups in a scoring range.'
|
||||
)
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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!conditional_release'
|
||||
import GradingTypes from './grading-types'
|
||||
import numberHelper from '../shared/helpers/numberHelper'
|
||||
|
||||
// stack overflow suggests this implementation
|
||||
const isNumeric = n => {
|
||||
n = numberHelper.parse(n)
|
||||
return !isNaN(n) && isFinite(n) // eslint-disable-line no-restricted-globals
|
||||
}
|
||||
|
||||
const checkBlank = s => {
|
||||
return s === null || s === '' || s.length === 0 ? I18n.t('must not be empty') : null
|
||||
}
|
||||
|
||||
const checkNumeric = s => {
|
||||
return isNumeric(s) ? null : I18n.t('must be a number')
|
||||
}
|
||||
|
||||
const checkBounds = (minScore, maxScore, score) => {
|
||||
score = numberHelper.parse(score)
|
||||
if (score > maxScore) {
|
||||
return I18n.t('number is too large')
|
||||
} else if (score < minScore) {
|
||||
return I18n.t('number is too small')
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const checkInGradingScheme = (gradingScheme, score) => {
|
||||
return isNumeric(score) ? null : I18n.t('must provide valid letter grade')
|
||||
}
|
||||
|
||||
const checkScoreOrder = (scores, previousErrors) => {
|
||||
return scores.map((score, index) => {
|
||||
if (previousErrors.get(index)) {
|
||||
return previousErrors.get(index)
|
||||
}
|
||||
score = numberHelper.parse(score)
|
||||
if (
|
||||
index > 0 &&
|
||||
!previousErrors.get(index - 1) &&
|
||||
score > numberHelper.parse(scores.get(index - 1))
|
||||
) {
|
||||
return I18n.t('these scores are out of order')
|
||||
} else if (
|
||||
index + 1 < scores.size &&
|
||||
!previousErrors.get(index + 1) &&
|
||||
score < numberHelper.parse(scores.get(index + 1))
|
||||
) {
|
||||
return I18n.t('these scores are out of order')
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Rather than check the score at a single index, we
|
||||
// check all the scores on each change, as an ordering
|
||||
// error on one score might be resolved by a change
|
||||
// to another
|
||||
// An alternative approach is to keep track of error types,
|
||||
// so we can clear just ordering errors.
|
||||
export const validateScores = (scores, scoringInfo) => {
|
||||
const checks = [checkBlank]
|
||||
const gradingType = scoringInfo ? scoringInfo.get('grading_type') : null
|
||||
switch (gradingType) {
|
||||
case GradingTypes.letter_grade.key:
|
||||
case GradingTypes.gpa_scale.key:
|
||||
checks.push(
|
||||
checkInGradingScheme.bind(null, scoringInfo.get('grading_scheme')),
|
||||
checkBounds.bind(null, 0, 1.0)
|
||||
)
|
||||
break
|
||||
case GradingTypes.points.key:
|
||||
case GradingTypes.percent.key:
|
||||
default:
|
||||
checks.push(checkNumeric, checkBounds.bind(null, 0, 1.0))
|
||||
break
|
||||
}
|
||||
|
||||
let errors = scores.map(score => {
|
||||
return checks.reduce((error, check) => {
|
||||
return error || check(score)
|
||||
}, null)
|
||||
})
|
||||
|
||||
errors = checkScoreOrder(scores, errors)
|
||||
|
||||
return errors
|
||||
}
|
|
@ -100,17 +100,17 @@ class Editor extends React.Component {
|
|||
return saveObject.promise()
|
||||
}
|
||||
|
||||
loadEditor = () => {
|
||||
loadOldEditor = () => {
|
||||
const url = this.props.env.editor_url
|
||||
$.ajax({
|
||||
url,
|
||||
dataType: 'script',
|
||||
cache: true,
|
||||
success: this.createEditor
|
||||
success: this.createOldEditor
|
||||
})
|
||||
}
|
||||
|
||||
createEditor = () => {
|
||||
createOldEditor = () => {
|
||||
const env = this.props.env
|
||||
const editor = new conditional_release_module.ConditionalReleaseEditor({
|
||||
jwt: env.jwt,
|
||||
|
@ -131,8 +131,29 @@ class Editor extends React.Component {
|
|||
this.setState({editor})
|
||||
}
|
||||
|
||||
createNativeEditor = () => {
|
||||
const env = this.props.env
|
||||
return import('jsx/conditional_release_editor/conditional-release-editor').then(
|
||||
({default: ConditionalReleaseEditor}) => {
|
||||
const editor = new ConditionalReleaseEditor({
|
||||
assignment: env.assignment,
|
||||
courseId: env.course_id
|
||||
})
|
||||
editor.attach(
|
||||
document.getElementById('canvas-conditional-release-editor'),
|
||||
document.getElementById('application')
|
||||
)
|
||||
this.setState({editor})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadEditor()
|
||||
if (this.props.env.native) {
|
||||
this.createNativeEditor()
|
||||
} else {
|
||||
this.loadOldEditor()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -108,6 +108,8 @@ class Assignment < ActiveRecord::Base
|
|||
dependent: :destroy,
|
||||
inverse_of: :assignment
|
||||
|
||||
has_many :conditional_release_associations, class_name: "ConditionalRelease::AssignmentSetAssociation", dependent: :destroy, inverse_of: :assignment
|
||||
|
||||
scope :anonymous, -> { where(anonymous_grading: true) }
|
||||
scope :moderated, -> { where(moderated_grading: true) }
|
||||
scope :auditable, -> { anonymous.or(moderated) }
|
||||
|
|
|
@ -47,8 +47,17 @@ module ConditionalRelease
|
|||
env = {
|
||||
CONDITIONAL_RELEASE_SERVICE_ENABLED: enabled
|
||||
}
|
||||
return env unless enabled && user
|
||||
|
||||
if enabled && user
|
||||
if self.natively_enabled_for_account?(context.root_account)
|
||||
cyoe_env = {native: true}
|
||||
cyoe_env[:assignment] = assignment_attributes(assignment) if assignment
|
||||
if context.is_a?(Course)
|
||||
cyoe_env[:course_id] = context.id
|
||||
cyoe_env[:stats_url] = "/api/v1/courses/#{context.id}/mastery_paths/stats"
|
||||
end
|
||||
# TODO: add rules and whatnot
|
||||
else
|
||||
cyoe_env = {
|
||||
jwt: jwt_for(context, user, domain, session: session, real_user: real_user),
|
||||
assignment: assignment_attributes(assignment),
|
||||
|
@ -61,14 +70,8 @@ module ConditionalRelease
|
|||
|
||||
cyoe_env[:rule] = rule_triggered_by(assignment, user, session) if includes.include? :rule
|
||||
cyoe_env[:active_rules] = active_rules(context, user, session) if includes.include? :active_rules
|
||||
|
||||
new_env = {
|
||||
CONDITIONAL_RELEASE_ENV: cyoe_env
|
||||
}
|
||||
|
||||
env.merge!(new_env)
|
||||
end
|
||||
env
|
||||
env.merge(CONDITIONAL_RELEASE_ENV: cyoe_env)
|
||||
end
|
||||
|
||||
def self.jwt_for(context, user, domain, claims: {}, session: nil, real_user: nil)
|
||||
|
@ -120,12 +123,23 @@ module ConditionalRelease
|
|||
@config ||= DEFAULT_CONFIG.merge(config_file)
|
||||
end
|
||||
|
||||
def self.configured?
|
||||
# whether new accounts will use the ported canvas db and UI instead of provisioning onto the service
|
||||
# can flip this setting on when canvas-side is code-complete but the migration is still pending
|
||||
def self.prefer_native?
|
||||
Setting.get("conditional_release_prefer_native", "false") == "true"
|
||||
end
|
||||
|
||||
# TODO: can remove when all accounts are migrated
|
||||
def self.natively_enabled_for_account?(root_account)
|
||||
!!root_account&.settings&.[](:use_native_conditional_release)
|
||||
end
|
||||
|
||||
def self.service_configured?
|
||||
!!(config[:enabled] && config[:host])
|
||||
end
|
||||
|
||||
def self.enabled_in_context?(context)
|
||||
!!(configured? && context&.feature_enabled?(:conditional_release))
|
||||
!!((service_configured? || natively_enabled_for_account?(context&.root_account)) && context&.feature_enabled?(:conditional_release))
|
||||
end
|
||||
|
||||
def self.protocol
|
||||
|
|
|
@ -39,7 +39,7 @@ module ConditionalRelease
|
|||
end
|
||||
|
||||
def activate!
|
||||
return unless ConditionalRelease::Service.configured?
|
||||
return unless ConditionalRelease::Service.service_configured?
|
||||
|
||||
if @pseudonym.blank? || @token.blank?
|
||||
@token = create_token!
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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 "base/environment";
|
||||
|
||||
@import "pages/conditional_release_editor/assignment-card";
|
||||
@import "pages/conditional_release_editor/assignment-modal";
|
||||
@import "pages/conditional_release_editor/assignment-picker";
|
||||
@import "pages/conditional_release_editor/assignment-set";
|
||||
@import "pages/conditional_release_editor/condition-toggle";
|
||||
@import "pages/conditional_release_editor/editor-view";
|
||||
@import "pages/conditional_release_editor/percent-input";
|
||||
@import "pages/conditional_release_editor/score-label";
|
||||
@import "pages/conditional_release_editor/scoring-range";
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
$checkbox-bg-color: $ic-color-light;
|
||||
$card-fg-color: $ic-color-dark;
|
||||
$icon-fg-color: $ic-color-light;
|
||||
$icon-default-color: $ic-color-dark;
|
||||
$icon-assignment-color: $ic-color-action;
|
||||
$icon-quiz-color: $ic-color-danger;
|
||||
$icon-discussion-color: $ic-color-alert;
|
||||
|
||||
.cr-assignment-card {
|
||||
height: 156px;
|
||||
width: 180px;
|
||||
border: 1px solid $ic-border-color;
|
||||
background: $ic-color-medium-light;
|
||||
position: relative;
|
||||
transition: all .2s;
|
||||
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&.cr-assignment-card__loading {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
&.cr-assignment-card__dragging {
|
||||
opacity: .3;
|
||||
transform: scale(.95);
|
||||
outline: none;
|
||||
|
||||
& + .cr-condition-toggle__and {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cr-assignment-card__content {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 13px 24px 0;
|
||||
word-wrap: break-word;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
|
||||
& + .cr-assignment-options {
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
i[class*="icon-"] {
|
||||
background: $icon-default-color;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
border-radius: 50%;
|
||||
color: $icon-fg-color;
|
||||
float: direction(left);
|
||||
margin-#{direction(right)}: 12px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:before {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
i.icon-assignment {
|
||||
background: $icon-assignment-color;
|
||||
}
|
||||
|
||||
i.icon-quiz {
|
||||
background: $icon-quiz-color;
|
||||
}
|
||||
|
||||
i.icon-discussion {
|
||||
background: $icon-discussion-color;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.cr-assignment-card__points {
|
||||
float: direction(left);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cr-assignment-card__title {
|
||||
font-weight: bold;
|
||||
color: $card-fg-color;
|
||||
margin-top: 15px;
|
||||
font-size: 14px;
|
||||
clear: both;
|
||||
max-height: 84px;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
#{direction(right)}: 0;
|
||||
width: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
label.cr_label__abox {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
.cr-assignment-modal {
|
||||
max-width: 500px;
|
||||
min-width: 500px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: normal;
|
||||
|
||||
.Button--icon-action {
|
||||
margin-#{direction(right)}: 10px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
$bg-color: $ic-color-light;
|
||||
$fg-color: $ic-color-dark;
|
||||
$cbox-color: $ic-color-medium-light;
|
||||
|
||||
.cr-assignments-picker {
|
||||
padding: 0px 10px
|
||||
}
|
||||
|
||||
.cr-assignments-filter {
|
||||
padding-bottom: 3px;
|
||||
|
||||
.cr-assignments-filter__name-filter {
|
||||
width: 55%;
|
||||
padding: 8px;
|
||||
height: 20px;
|
||||
margin-#{direction(right)}: 10px;
|
||||
background-color: $bg-color;
|
||||
border: 1px solid $ic-border-color;
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
transition: border linear 0.2s, box-shadow linear 0.2s;
|
||||
}
|
||||
|
||||
.cr-assignments-filter__name-filter:focus {
|
||||
border-color: var(--ic-brand-primary);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.cr-assignments-filter__category-filter {
|
||||
width: 38%;
|
||||
background: linear-gradient($bg-color, $ic-color-medium-light);
|
||||
border: 1px solid $ic-border-color;
|
||||
background-color: $bg-color;
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
display: inline-block;
|
||||
padding: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.875rem;
|
||||
color: $fg-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.cr-assignments-list {
|
||||
padding-#{direction(left)}: 2px;
|
||||
list-style-type: none;
|
||||
height: 200px;
|
||||
overflow-y: scroll;
|
||||
margin-#{direction(left)}: 0px;
|
||||
}
|
||||
|
||||
.cr-assignments-list__item {
|
||||
list-style-type: none;
|
||||
padding: 0 3px;
|
||||
position: relative;
|
||||
|
||||
input[type='checkbox'] {
|
||||
opacity: 0;
|
||||
margin-#{direction(right)}: 10px;
|
||||
}
|
||||
|
||||
.cr-label__cbox {
|
||||
vertical-align: middle;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cr-assignments-list__item__disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.cr-label__cbox {
|
||||
opacity: .5;
|
||||
text-decoration: line-through;
|
||||
|
||||
&:before {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cr-assignments-list__item__icon {
|
||||
padding: 0 6px 0 9px;
|
||||
}
|
||||
|
||||
input[type='checkbox'] + .cr-label__cbox:before {
|
||||
content: "";
|
||||
transition: border-color 0.2s ease-out, outline-offset 0.2s ease-out, outline-color 0.2s ease-out;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
#{direction(left)}: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
background: url("/images/forms/ic-checkbox-bg.svg") no-repeat center bottom $cbox-color;
|
||||
background-size: 16px 48px;
|
||||
border: 1px solid $ic-border-color;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -12px;
|
||||
}
|
||||
|
||||
input[type='checkbox']:focus + .cr-label__cbox:before {
|
||||
outline: thin dotted $ic-color-dark;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
outline-offset: -2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + .cr-label__cbox:before {
|
||||
background-color: var(--ic-brand-font-color-dark);
|
||||
background-position: center -1px;
|
||||
border-color: var(--ic-brand-font-color-dark);
|
||||
}
|
||||
|
||||
.ic-Label__text {
|
||||
font-weight: 400;
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
.cr-assignment-set {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.cr-assignment-set__inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-#{direction(left)}: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.cr-assignment-set__empty {
|
||||
position: relative;
|
||||
margin: auto;
|
||||
height: 156px;
|
||||
width: 180px;
|
||||
margin-#{direction(right)}: -170px;
|
||||
|
||||
.cr-assignment-set__can-drop {
|
||||
box-shadow: 0 0 10px 0px var(--ic-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.cr-assignment-set__drag-over {
|
||||
box-shadow: 0 0 10px 0 $ic-color-danger;
|
||||
}
|
||||
|
||||
.cr-assignment-set__can-drop {
|
||||
box-shadow: none;
|
||||
|
||||
.cr-assignment-set__inner__draggedOver {
|
||||
|
||||
// card being dragged over..
|
||||
.cr-assignment-card {
|
||||
border-color: var(--ic-link-color);
|
||||
margin-#{direction(right)}: 17px;
|
||||
|
||||
// card dragging over itself
|
||||
&.cr-assignment-card__dragging {
|
||||
margin-#{direction(right)}: 0;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// blue bar after card being dragged over
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 7px;
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
bottom: -6px;
|
||||
#{direction(right)}: -13px;
|
||||
background: var(--ic-link-color);
|
||||
}
|
||||
}
|
||||
|
||||
// toggle button of card being dragged over
|
||||
.cr-condition-toggle__and {
|
||||
position: relative;
|
||||
#{direction(right)}: 6px;
|
||||
|
||||
.cr-condition-toggle__button {
|
||||
border-radius: 3px;
|
||||
border: 2px solid var(--ic-link-color);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
$disabled-text-color: $ic-color-medium;
|
||||
|
||||
.cr-condition-toggle__button {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
z-index: 3;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid $ic-border-color;
|
||||
background: $ic-color-medium-light;
|
||||
transition: .5s margin;
|
||||
|
||||
.cr-condition-toggle__and & {
|
||||
margin: 0 -15px;
|
||||
}
|
||||
|
||||
.cr-condition-toggle__and.cr-condition-toggle__fake & {
|
||||
margin-#{direction(right)}: 50px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cr-condition-toggle__or & {
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.cr-condition-toggle__disabled & {
|
||||
cursor: not-allowed;
|
||||
color: $disabled-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.cr-condition-toggle__or {
|
||||
line-height: 160px;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
$fg-color: $ic-color-dark;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-size: $ic-font-size;
|
||||
font-family: $ic-font-family;
|
||||
color: $fg-color;
|
||||
background: $ic-color-light;
|
||||
}
|
||||
|
||||
.cr-editor {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cr-editor__scoring-ranges {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.cr-input {
|
||||
font-size: $ic-font-size;
|
||||
font-family: $ic-font-family;
|
||||
color: $fg-color;
|
||||
border: 1px solid $ic-border-color;
|
||||
border-radius: 0.25rem;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
.cr-percent-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: flex-start;
|
||||
margin-bottom: 0px;
|
||||
width: 60px;
|
||||
|
||||
.cr-percent-input__input {
|
||||
text-align: center;
|
||||
margin-bottom: 0px;
|
||||
width: 48px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.cr-percent-input__error-holder {
|
||||
height: 0;
|
||||
width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.cr-percent-input__error {
|
||||
.ic-Form-message__Layout {
|
||||
display: flex;
|
||||
padding: 12px 12px 12px 0px;
|
||||
|
||||
* {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
.cr-score-label {
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (C) 2020 - 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/>.
|
||||
*/
|
||||
|
||||
.cr-scoring-range {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 215px;
|
||||
position: relative;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid $ic-border-color;
|
||||
}
|
||||
|
||||
.cr-scoring-range__bounds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 60px;
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cr-scoring-range__assignments {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding-#{direction(left)}: 30px;
|
||||
}
|
||||
|
||||
.cr-scoring-range__add-assignment-button {
|
||||
font-size: 26px;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
border: 2px dashed $ic-border-color;
|
||||
#{direction(left)}: 12px;
|
||||
top: 30px;
|
||||
bottom: 55px;
|
||||
margin: auto;
|
||||
background: transparent;
|
||||
color: var(--ic-brand-font-color-dark);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
}
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
<%
|
||||
js_env :assignment_attempts_enabled => @context.feature_enabled?(:assignment_attempts)
|
||||
css_bundle :assignments, :assignments_edit, :tinymce
|
||||
css_bundle :assignments, :assignments_edit, :conditional_release_editor, :tinymce
|
||||
js_bundle :assignment_edit
|
||||
%>
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
provide :page_title, topic_page_title(@topic)
|
||||
|
||||
js_bundle :discussion_topics_edit
|
||||
css_bundle :tinymce, :grading_standards, :assignments_edit, :discussions_edit
|
||||
css_bundle :tinymce, :grading_standards, :assignments_edit, :discussions_edit, :conditional_release_editor
|
||||
%>
|
||||
|
||||
<% provide :right_side, render(:partial => 'shared/wiki_sidebar') %>
|
||||
|
|
|
@ -64,8 +64,15 @@ module FeatureFlags
|
|||
|
||||
def self.conditional_release_after_state_change_hook(user, context, _old_state, new_state)
|
||||
if %w(on allowed).include?(new_state) && context.is_a?(Account)
|
||||
@service_account = ConditionalRelease::Setup.new(context.id, user.id)
|
||||
@service_account.activate!
|
||||
if ConditionalRelease::Service.prefer_native?
|
||||
context.root_account.tap do |ra|
|
||||
ra.settings[:use_native_conditional_release] = true
|
||||
ra.save!
|
||||
end
|
||||
else
|
||||
@service_account = ConditionalRelease::Setup.new(context.id, user.id)
|
||||
@service_account.activate!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -80,7 +80,7 @@ module ConditionalRelease
|
|||
end
|
||||
|
||||
it 'filters based on assignment id' do
|
||||
json = api_call(:get, @url, @base_params.merge(trigger_assignment: @assignment.id), {}, {}, {:expected_status => 200})
|
||||
json = api_call(:get, @url, @base_params.merge(trigger_assignment_id: @assignment.id), {}, {}, {:expected_status => 200})
|
||||
expect(json.length).to eq 2
|
||||
end
|
||||
|
||||
|
|
|
@ -266,7 +266,7 @@ describe WikiPagesController do
|
|||
|
||||
context "feature enabled" do
|
||||
before do
|
||||
allow(ConditionalRelease::Service).to receive(:configured?).and_return(true)
|
||||
allow(ConditionalRelease::Service).to receive(:service_configured?).and_return(true)
|
||||
@course.enable_feature!(:conditional_release)
|
||||
end
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ const createComponent = submitCallback => {
|
|||
component = TestUtils.renderIntoDocument(
|
||||
<ConditionalRelease.Editor env={assignmentEnv} type="foo" />
|
||||
)
|
||||
component.createEditor()
|
||||
component.createOldEditor()
|
||||
}
|
||||
|
||||
const makePromise = () => {
|
||||
|
|
|
@ -43,17 +43,17 @@ describe ConditionalRelease::Service do
|
|||
context 'configuration' do
|
||||
it 'is not configured by default' do
|
||||
stub_config(nil)
|
||||
expect(Service.configured?).to eq false
|
||||
expect(Service.service_configured?).to eq false
|
||||
end
|
||||
|
||||
it 'requires host to be configured' do
|
||||
stub_config({enabled: true})
|
||||
expect(Service.configured?).to eq false
|
||||
expect(Service.service_configured?).to eq false
|
||||
end
|
||||
|
||||
it 'is configured when enabled with host' do
|
||||
stub_config({enabled: true, host: 'foo'})
|
||||
expect(Service.configured?).to eq true
|
||||
expect(Service.service_configured?).to eq true
|
||||
end
|
||||
|
||||
it 'has a default config' do
|
||||
|
@ -106,12 +106,26 @@ describe ConditionalRelease::Service do
|
|||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq false
|
||||
end
|
||||
|
||||
it 'reports enabled as false if service is disabled' do
|
||||
context = double({feature_enabled?: true})
|
||||
it 'reports enabled as false if service is disabled (and the root account has native config disabled)' do
|
||||
context = course_factory
|
||||
context.enable_feature!(:conditional_release)
|
||||
stub_config({enabled: false})
|
||||
env = Service.env_for(context)
|
||||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq false
|
||||
end
|
||||
|
||||
it 'reports enabled as true if service is disabled (but the root account has native config enabled)' do
|
||||
context = course_factory
|
||||
context.enable_feature!(:conditional_release)
|
||||
context.root_account.tap do |a|
|
||||
a.settings[:use_native_conditional_release] = true
|
||||
a.save!
|
||||
end
|
||||
stub_config({enabled: false})
|
||||
env = Service.env_for(context, User.new)
|
||||
expect(env[:CONDITIONAL_RELEASE_SERVICE_ENABLED]).to eq true
|
||||
expect(env[:CONDITIONAL_RELEASE_ENV][:native]).to eq true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'env_for' do
|
||||
|
|
|
@ -74,13 +74,13 @@ describe ConditionalRelease::Setup do
|
|||
'conditional_release' => Feature.new(feature: 'conditional_release', applies_to: 'Account')
|
||||
})
|
||||
@cyoe_feature = Feature.definitions['conditional_release']
|
||||
allow(service).to receive(:configured?).and_return(true)
|
||||
allow(service).to receive(:service_configured?).and_return(true)
|
||||
@setup = ConditionalRelease::Setup.new(@account.id, @user.id)
|
||||
end
|
||||
|
||||
describe "#activate!" do
|
||||
it "should not run if the Conditional Release service isn't configured" do
|
||||
allow(service).to receive(:configured?).and_return(false)
|
||||
allow(service).to receive(:service_configured?).and_return(false)
|
||||
expect(@setup).to receive(:create_token!).never
|
||||
expect(@setup).to receive(:send_later_enqueue_args).never
|
||||
@setup.activate!
|
||||
|
|
Loading…
Reference in New Issue