canvas-lms/app/jsx/grading/GradingPeriodSet.js

456 lines
15 KiB
JavaScript

/*
* Copyright (C) 2016 - 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 'jquery'
import _ from 'underscore'
import Button from 'instructure-ui/lib/components/Button'
import axios from 'axios'
import I18n from 'i18n!grading_periods'
import GradingPeriod from 'jsx/grading/AccountGradingPeriod'
import GradingPeriodForm from 'jsx/grading/GradingPeriodForm'
import gradingPeriodsApi from 'compiled/api/gradingPeriodsApi'
import 'jquery.instructure_misc_helpers'
const sortPeriods = function(periods) {
return _.sortBy(periods, "startDate");
};
const anyPeriodsOverlap = function(periods) {
if (_.isEmpty(periods)) {
return false;
}
let firstPeriod = _.first(periods);
let otherPeriods = _.rest(periods);
let overlapping = _.some(otherPeriods, function(otherPeriod) {
return otherPeriod.startDate < firstPeriod.endDate && firstPeriod.startDate < otherPeriod.endDate;
});
return overlapping || anyPeriodsOverlap(otherPeriods);
};
const isValidDate = function(date) {
return Object.prototype.toString.call(date) === "[object Date]" &&
!isNaN(date.getTime());
};
const validatePeriods = function(periods, weighted) {
if (_.any(periods, (period) => { return !(period.title || "").trim() })) {
return [I18n.t('All grading periods must have a title')];
}
if (weighted && _.any(periods, (period) => { return isNaN(period.weight) || period.weight < 0 })) {
return [I18n.t('All weights must be greater than or equal to 0')];
}
let validDates = _.all(periods, (period) => {
return isValidDate(period.startDate) &&
isValidDate(period.endDate) &&
isValidDate(period.closeDate);
});
if (!validDates) {
return [I18n.t('All dates fields must be present and formatted correctly')];
}
let orderedStartAndEndDates = _.all(periods, (period) => {
return period.startDate < period.endDate;
});
if (!orderedStartAndEndDates) {
return [I18n.t('All start dates must be before the end date')];
}
let orderedEndAndCloseDates = _.all(periods, (period) => {
return period.endDate <= period.closeDate;
});
if (!orderedEndAndCloseDates) {
return [I18n.t('All close dates must be on or after the end date')];
}
if (anyPeriodsOverlap(periods)) {
return [I18n.t('Grading periods must not overlap')];
}
};
const isEditingPeriod = function(state) {
return !!state.editPeriod.id;
};
const isActionsDisabled = function(state, props) {
return !!(props.actionsDisabled || isEditingPeriod(state) || state.newPeriod.period);
};
const getShowGradingPeriodRef = function(period) {
return "show-grading-period-" + period.id;
};
const getEditGradingPeriodRef = function(period) {
return "edit-grading-period-" + period.id;
};
const { shape, number, string, array, bool, func } = PropTypes;
let GradingPeriodSet = React.createClass({
propTypes: {
gradingPeriods: array.isRequired,
terms: array.isRequired,
readOnly: bool.isRequired,
expanded: bool,
actionsDisabled: bool,
onEdit: func.isRequired,
onDelete: func.isRequired,
onPeriodsChange: func.isRequired,
onToggleBody: func.isRequired,
set: shape({
id: string.isRequired,
title: string.isRequired,
weighted: bool,
displayTotalsForAllGradingPeriods: bool.isRequired
}).isRequired,
urls: shape({
batchUpdateURL: string.isRequired,
deleteGradingPeriodURL: string.isRequired,
gradingPeriodSetsURL: string.isRequired
}).isRequired,
permissions: shape({
read: bool.isRequired,
create: bool.isRequired,
update: bool.isRequired,
delete: bool.isRequired
}).isRequired
},
getInitialState() {
return {
title: this.props.set.title,
weighted: !!this.props.set.weighted,
displayTotalsForAllGradingPeriods: this.props.set.displayTotalsForAllGradingPeriods,
gradingPeriods: sortPeriods(this.props.gradingPeriods),
newPeriod: {
period: null,
saving: false
},
editPeriod: {
id: null,
saving: false
}
};
},
componentDidUpdate(prevProps, prevState) {
if (prevState.newPeriod.period && !this.state.newPeriod.period) {
this.refs.addPeriodButton.focus();
} else if (isEditingPeriod(prevState) && !isEditingPeriod(this.state)) {
let period = { id: prevState.editPeriod.id };
this.refs[getShowGradingPeriodRef(period)].refs.editButton.focus();
}
},
toggleSetBody() {
if (!isEditingPeriod(this.state)) {
this.props.onToggleBody();
}
},
promptDeleteSet(event) {
event.stopPropagation();
const confirmMessage = I18n.t("Are you sure you want to delete this grading period set?");
if (!window.confirm(confirmMessage)) return null;
const url = this.props.urls.gradingPeriodSetsURL + "/" + this.props.set.id;
axios.delete(url)
.then(() => {
$.flashMessage(I18n.t('The grading period set was deleted'));
this.props.onDelete(this.props.set.id);
})
.catch(() => {
$.flashError(I18n.t("An error occured while deleting the grading period set"));
});
},
setTerms() {
return _.where(this.props.terms, { gradingPeriodGroupId: this.props.set.id });
},
termNames() {
const names = _.pluck(this.setTerms(), "displayName");
if (names.length > 0) {
return I18n.t("Terms: ") + names.join(", ");
} else {
return I18n.t("No Associated Terms");
}
},
editSet(e) {
e.stopPropagation();
this.props.onEdit(this.props.set);
},
changePeriods(periods) {
let sortedPeriods = sortPeriods(periods);
this.setState({ gradingPeriods: sortedPeriods });
this.props.onPeriodsChange(this.props.set.id, sortedPeriods);
},
removeGradingPeriod(idToRemove) {
let periods = _.reject(this.state.gradingPeriods, period => period.id === idToRemove);
this.setState({ gradingPeriods: periods });
},
showNewPeriodForm() {
this.setNewPeriod({ period: {} });
},
saveNewPeriod(period) {
let periods = this.state.gradingPeriods.concat([period]);
let validations = validatePeriods(periods, this.state.weighted);
if (_.isEmpty(validations)) {
this.setNewPeriod({saving: true});
gradingPeriodsApi.batchUpdate(this.props.set.id, periods)
.then((periods) => {
$.flashMessage(I18n.t('All changes were saved'));
this.removeNewPeriodForm();
this.changePeriods(periods);
})
.catch((_) => {
$.flashError(I18n.t('There was a problem saving the grading period'));
this.setNewPeriod({ saving: false });
});
} else {
_.each(validations, function(message) {
$.flashError(message);
});
}
},
removeNewPeriodForm() {
this.setNewPeriod({ saving: false, period: null });
},
setNewPeriod(attr) {
let period = $.extend(true, {}, this.state.newPeriod, attr);
this.setState({ newPeriod: period });
},
editPeriod(period) {
this.setEditPeriod({ id: period.id, saving: false });
},
updatePeriod(period) {
let periods = _.reject(this.state.gradingPeriods, function(_period) {
return period.id === _period.id;
}).concat([period]);
let validations = validatePeriods(periods, this.state.weighted);
if (_.isEmpty(validations)) {
this.setEditPeriod({ saving: true });
gradingPeriodsApi.batchUpdate(this.props.set.id, periods)
.then((periods) => {
$.flashMessage(I18n.t('All changes were saved'));
this.setEditPeriod({ id: null, saving: false });
this.changePeriods(periods);
})
.catch((_) => {
$.flashError(I18n.t('There was a problem saving the grading period'));
this.setNewPeriod({saving: false});
});
} else {
_.each(validations, function(message) {
$.flashError(message);
});
}
},
cancelEditPeriod() {
this.setEditPeriod({ id: null, saving: false });
},
setEditPeriod(attr) {
let period = $.extend(true, {}, this.state.editPeriod, attr);
this.setState({ editPeriod: period });
},
renderEditButton() {
if (!this.props.readOnly && this.props.permissions.update) {
let disabled = isActionsDisabled(this.state, this.props);
return (
<Button
ref="editButton"
variant="icon"
disabled={disabled}
onClick={this.editSet}
title={I18n.t("Edit %{title}", { title: this.props.set.title })}
>
<span className="screenreader-only">
{I18n.t("Edit %{title}", { title: this.props.set.title })}
</span>
<i className="icon-edit"/>
</Button>
);
}
},
renderDeleteButton() {
if (!this.props.readOnly && this.props.permissions.delete) {
let disabled = isActionsDisabled(this.state, this.props);
return (
<Button ref="deleteButton"
variant="icon"
disabled={disabled}
onClick={this.promptDeleteSet}
title={I18n.t("Delete %{title}", { title: this.props.set.title })}>
<span className="screenreader-only">
{I18n.t("Delete %{title}", { title: this.props.set.title })}
</span>
<i className="icon-trash"/>
</Button>
);
}
},
renderEditAndDeleteButtons() {
return (
<div className="ItemGroup__header__admin">
{this.renderEditButton()}
{this.renderDeleteButton()}
</div>
);
},
renderSetBody() {
if (!this.props.expanded) return null;
return (
<div ref="setBody" className="ig-body">
<div className="GradingPeriodList" ref="gradingPeriodList">
{this.renderGradingPeriods()}
</div>
{this.renderNewPeriod()}
</div>
);
},
renderGradingPeriods() {
let actionsDisabled = isActionsDisabled(this.state, this.props);
return _.map(this.state.gradingPeriods, (period) => {
if (period.id === this.state.editPeriod.id) {
return (
<div key = {"edit-grading-period-" + period.id}
className = 'GradingPeriodList__period--editing pad-box'>
<GradingPeriodForm ref = "editPeriodForm"
period = {period}
weighted = {this.state.weighted}
disabled = {this.state.editPeriod.saving}
onSave = {this.updatePeriod}
onCancel = {this.cancelEditPeriod} />
</div>
);
} else {
return (
<GradingPeriod key={"show-grading-period-" + period.id}
ref={getShowGradingPeriodRef(period)}
period={period}
weighted={this.state.weighted}
actionsDisabled={actionsDisabled}
onEdit={this.editPeriod}
readOnly={this.props.readOnly}
onDelete={this.removeGradingPeriod}
deleteGradingPeriodURL={this.props.urls.deleteGradingPeriodURL}
permissions={this.props.permissions} />
);
}
});
},
renderNewPeriod() {
if (this.props.permissions.create && !this.props.readOnly) {
if (this.state.newPeriod.period) {
return this.renderNewPeriodForm();
} else {
return this.renderNewPeriodButton();
}
}
},
renderNewPeriodButton() {
let disabled = isActionsDisabled(this.state, this.props);
return (
<div className='GradingPeriodList__new-period center-xs border-rbl border-round-b'>
<Button variant="link"
ref='addPeriodButton'
disabled={disabled}
aria-label={I18n.t('Add Grading Period')}
onClick={this.showNewPeriodForm}>
<i className='icon-plus GradingPeriodList__new-period__add-icon'/>
&nbsp;
{I18n.t('Grading Period')}
</Button>
</div>
);
},
renderNewPeriodForm() {
return (
<div className='GradingPeriodList__new-period--editing border border-rbl border-round-b pad-box'>
<GradingPeriodForm key = 'new-grading-period'
ref = 'newPeriodForm'
weighted = {this.state.weighted}
disabled = {this.state.newPeriod.saving}
onSave = {this.saveNewPeriod}
onCancel = {this.removeNewPeriodForm} />
</div>
);
},
render() {
const setStateSuffix = this.props.expanded ? "expanded" : "collapsed";
const arrow = this.props.expanded ? "down" : "right";
return (
<div className={"GradingPeriodSet--" + setStateSuffix}>
<div className="ItemGroup__header"
ref="toggleSetBody"
onClick={this.toggleSetBody}>
<div>
<div className="ItemGroup__header__title">
<button className={"Button Button--icon-action GradingPeriodSet__toggle"}
aria-expanded={this.props.expanded}
aria-label={I18n.t('Toggle %{title} grading period visibility', { title: this.props.set.title })}>
<i className={"icon-mini-arrow-" + arrow}/>
</button>
<h2 ref="title" className="GradingPeriodSet__title">
{this.props.set.title}
</h2>
</div>
{this.renderEditAndDeleteButtons()}
</div>
<div className="EnrollmentTerms__list">
{this.termNames()}
</div>
</div>
{this.renderSetBody()}
</div>
);
}
});
export default GradingPeriodSet