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:
James Williams 2020-06-15 07:39:12 -06:00
parent b2aa50d31f
commit 7b44b5f4ce
51 changed files with 4032 additions and 36 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
})
}

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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>
)
}
}

View File

@ -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>
)
}
}

View File

@ -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>
)
}
}

View File

@ -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

View File

@ -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

View File

@ -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))
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
})

View File

@ -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

View File

@ -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)
})

View File

@ -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.'
)
}

View File

@ -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
}

View File

@ -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() {

View File

@ -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) }

View File

@ -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

View File

@ -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!

View File

@ -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";

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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
%>

View File

@ -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') %>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -44,7 +44,7 @@ const createComponent = submitCallback => {
component = TestUtils.renderIntoDocument(
<ConditionalRelease.Editor env={assignmentEnv} type="foo" />
)
component.createEditor()
component.createOldEditor()
}
const makePromise = () => {

View File

@ -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

View File

@ -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!