LatePoliciesTabPanel.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. /*
  2. * Copyright (C) 2017 - present Instructure, Inc.
  3. *
  4. * This file is part of Canvas.
  5. *
  6. * Canvas is free software: you can redistribute it and/or modify it under
  7. * the terms of the GNU Affero General Public License as published by the Free
  8. * Software Foundation, version 3 of the License.
  9. *
  10. * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
  11. * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
  12. * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  13. * details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License along
  16. * with this program. If not, see <http://www.gnu.org/licenses/>.
  17. */
  18. import React from 'react';
  19. import { bool, func, number, shape, string } from 'prop-types';
  20. import Alert from 'instructure-ui/lib/components/Alert';
  21. import ComingSoonContent from 'jsx/gradezilla/default_gradebook/components/ComingSoonContent';
  22. import Container from 'instructure-ui/lib/components/Container';
  23. import FormFieldGroup from 'instructure-ui/lib/components/FormFieldGroup';
  24. import NumberInput from 'instructure-ui/lib/components/NumberInput';
  25. import PresentationContent from 'instructure-ui/lib/components/PresentationContent';
  26. import Spinner from 'instructure-ui/lib/components/Spinner';
  27. import Typography from 'instructure-ui/lib/components/Typography';
  28. import ScreenReaderContent from 'instructure-ui/lib/components/ScreenReaderContent';
  29. import Checkbox from 'instructure-ui/lib/components/Checkbox';
  30. import Select from 'instructure-ui/lib/components/Select';
  31. import Round from 'compiled/util/round';
  32. import NumberHelper from 'jsx/shared/helpers/numberHelper';
  33. import I18n from 'i18n!gradebook';
  34. function isNumeric (input) {
  35. return NumberHelper.validate(input);
  36. }
  37. function isInRange (input) {
  38. const num = NumberHelper.parse(input);
  39. return num >= 0 && num <= 100;
  40. }
  41. function validationError (input) {
  42. if (!isNumeric(input)) {
  43. return 'notNumeric';
  44. } else if (!isInRange(input)) {
  45. return 'outOfRange';
  46. }
  47. return null;
  48. }
  49. function validationErrorMessage (input, validationType) {
  50. const errorMessages = {
  51. missingSubmissionDeduction: {
  52. notNumeric: I18n.t('Missing submission grade must be numeric'),
  53. outOfRange: I18n.t('Missing submission grade must be between 0 and 100')
  54. },
  55. lateSubmissionDeduction: {
  56. notNumeric: I18n.t('Late submission deduction must be numeric'),
  57. outOfRange: I18n.t('Late submission deduction must be between 0 and 100')
  58. },
  59. lateSubmissionMinimumPercent: {
  60. notNumeric: I18n.t('Lowest possible grade must be numeric'),
  61. outOfRange: I18n.t('Lowest possible grade must be between 0 and 100')
  62. }
  63. };
  64. const error = validationError(input);
  65. return errorMessages[validationType][error];
  66. }
  67. function markMissingSubmissionsDefaultValue (missingSubmissionDeduction) {
  68. return Round(100 - missingSubmissionDeduction, 2).toString();
  69. }
  70. function messages (names, validationErrors) {
  71. const errors = names.map(name => validationErrors[name]);
  72. return errors.reduce((acc, error) => (
  73. error ? acc.concat([{ text: error, type: 'error' }]) : acc
  74. ), []);
  75. }
  76. class LatePoliciesTabPanel extends React.Component {
  77. static propTypes = {
  78. latePolicy: shape({
  79. changes: shape({
  80. missingSubmissionDeductionEnabled: bool,
  81. missingSubmissionDeduction: number,
  82. lateSubmissionDeductionEnabled: bool,
  83. lateSubmissionDeduction: number,
  84. lateSubmissionInterval: string,
  85. lateSubmissionMinimumPercent: number
  86. }).isRequired,
  87. validationErrors: shape({
  88. missingSubmissionDeduction: string,
  89. lateSubmissionDeduction: string,
  90. lateSubmissionMinimumPercent: string
  91. }).isRequired,
  92. data: shape({
  93. missingSubmissionDeductionEnabled: bool,
  94. missingSubmissionDeduction: number,
  95. lateSubmissionDeductionEnabled: bool,
  96. lateSubmissionDeduction: number,
  97. lateSubmissionInterval: string,
  98. lateSubmissionMinimumPercentEnabled: bool,
  99. lateSubmissionMinimumPercent: number
  100. })
  101. }).isRequired,
  102. changeLatePolicy: func.isRequired,
  103. locale: string.isRequired,
  104. showContentComingSoon: bool.isRequired,
  105. showAlert: bool.isRequired
  106. };
  107. constructor (props) {
  108. super(props);
  109. this.state = { showAlert: props.showAlert };
  110. this.changeMissingSubmissionDeduction = this.validateAndChangeNumber.bind(this, 'missingSubmissionDeduction');
  111. this.changeLateSubmissionDeduction = this.validateAndChangeNumber.bind(this, 'lateSubmissionDeduction');
  112. this.changeLateSubmissionMinimumPercent = this.validateAndChangeNumber.bind(this, 'lateSubmissionMinimumPercent');
  113. this.missingPolicyMessages = messages.bind(this, ['missingSubmissionDeduction'])
  114. this.latePolicyMessages = messages.bind(this, ['lateSubmissionDeduction', 'lateSubmissionMinimumPercent'])
  115. }
  116. getLatePolicyAttribute = (key) => {
  117. const { changes, data } = this.props.latePolicy;
  118. if (key in changes) {
  119. return changes[key];
  120. }
  121. return data && data[key];
  122. }
  123. changeMissingSubmissionDeductionEnabled = ({ target: { checked } }) => {
  124. const changes = this.calculateChanges({ missingSubmissionDeductionEnabled: checked });
  125. this.props.changeLatePolicy({ ...this.props.latePolicy, changes });
  126. }
  127. changeLateSubmissionDeductionEnabled = ({ target: { checked } }) => {
  128. const updates = { lateSubmissionDeductionEnabled: checked };
  129. if (!checked) {
  130. updates.lateSubmissionMinimumPercentEnabled = false;
  131. } else if (this.getLatePolicyAttribute('lateSubmissionMinimumPercent') > 0) {
  132. updates.lateSubmissionMinimumPercentEnabled = true;
  133. }
  134. this.props.changeLatePolicy({ ...this.props.latePolicy, changes: this.calculateChanges(updates) });
  135. }
  136. validateAndChangeNumber = (name) => {
  137. const inputValue = this[`${name}Input`].value;
  138. const errorMessage = validationErrorMessage(inputValue, name);
  139. if (errorMessage) {
  140. const validationErrors = { ...this.props.latePolicy.validationErrors, [name]: errorMessage };
  141. return this.props.changeLatePolicy({ ...this.props.latePolicy, validationErrors });
  142. }
  143. let newValue = Round(NumberHelper.parse(inputValue), 2);
  144. if (name === 'missingSubmissionDeduction') {
  145. // "Mark missing submission with 40 percent" => missingSubmissionDeduction is 60
  146. newValue = 100 - newValue;
  147. }
  148. return this.changeNumber(name, newValue);
  149. }
  150. changeNumber = (name, value) => {
  151. const changesData = { [name]: value };
  152. if (name === 'lateSubmissionMinimumPercent') {
  153. changesData.lateSubmissionMinimumPercentEnabled = value !== 0;
  154. }
  155. const updates = {
  156. changes: this.calculateChanges(changesData),
  157. validationErrors: { ...this.props.latePolicy.validationErrors }
  158. };
  159. delete updates.validationErrors[name];
  160. this.props.changeLatePolicy({ ...this.props.latePolicy, ...updates });
  161. }
  162. changeLateSubmissionInterval = ({ target: { value } }) => {
  163. const changes = this.calculateChanges({ lateSubmissionInterval: value });
  164. this.props.changeLatePolicy({ ...this.props.latePolicy, changes });
  165. }
  166. calculateChanges = (newData) => {
  167. const changes = { ...this.props.latePolicy.changes };
  168. Object.keys(newData).forEach((key) => {
  169. const initialValue = this.props.latePolicy.data[key];
  170. const newValue = newData[key];
  171. if (initialValue !== newValue) {
  172. changes[key] = newValue;
  173. } else if (key in changes) {
  174. // if the new value and the initial value match, that
  175. // key/val pair should not be tracked as a change
  176. delete changes[key];
  177. }
  178. });
  179. return changes;
  180. }
  181. closeAlert = () => {
  182. this.setState({ showAlert: false }, () => {
  183. this.missingSubmissionCheckbox.focus();
  184. })
  185. }
  186. render () {
  187. if (this.props.showContentComingSoon) {
  188. return (
  189. <div id="LatePoliciesTabPanel__Container-noContent">
  190. <ComingSoonContent />
  191. </div>
  192. );
  193. }
  194. if (!this.props.latePolicy.data) {
  195. return (
  196. <div id="LatePoliciesTabPanel__Container-noContent">
  197. <Spinner title={I18n.t('Loading')} size="large" margin="small" />
  198. </div>
  199. );
  200. }
  201. const { data, validationErrors } = this.props.latePolicy;
  202. const numberInputWidth = "5.5rem";
  203. return (
  204. <div id="LatePoliciesTabPanel__Container">
  205. {this.state.showAlert &&
  206. <Alert
  207. variant="warning"
  208. closeButtonLabel={I18n.t('Close')}
  209. onDismiss={this.closeAlert}
  210. margin="small"
  211. >
  212. {I18n.t('Changing your policy now will affect previously graded submissions.')}
  213. </Alert>
  214. }
  215. <Container as="div" margin="small">
  216. <Checkbox
  217. label={I18n.t('Automatically apply grade for missing submissions')}
  218. defaultChecked={data.missingSubmissionDeductionEnabled}
  219. onChange={this.changeMissingSubmissionDeductionEnabled}
  220. ref={(c) => { this.missingSubmissionCheckbox = c; }}
  221. />
  222. </Container>
  223. <FormFieldGroup
  224. description={<ScreenReaderContent>{I18n.t('Missing policies')}</ScreenReaderContent>}
  225. messages={this.missingPolicyMessages(validationErrors)}
  226. >
  227. <Container as="div" margin="small small small large">
  228. <div style={{ marginLeft: '0.25rem'}}>
  229. <PresentationContent>
  230. <Container as="div" margin="0 0 x-small 0">
  231. <label htmlFor="missing-submission-grade">
  232. <Typography size="small" weight="bold">{I18n.t('Missing submission grade')}</Typography>
  233. </label>
  234. </Container>
  235. </PresentationContent>
  236. <div className="NumberInput__Container">
  237. <NumberInput
  238. id="missing-submission-grade"
  239. locale={this.props.locale}
  240. inputRef={(m) => { this.missingSubmissionDeductionInput = m; }}
  241. label={<ScreenReaderContent>{I18n.t('Missing submission grade percent')}</ScreenReaderContent>}
  242. disabled={!this.getLatePolicyAttribute('missingSubmissionDeductionEnabled')}
  243. defaultValue={markMissingSubmissionsDefaultValue(data.missingSubmissionDeduction)}
  244. onChange={this.changeMissingSubmissionDeduction}
  245. min="0"
  246. max="100"
  247. inline
  248. width={numberInputWidth}
  249. />
  250. <PresentationContent>
  251. <Container as="div" margin="0 small">
  252. <Typography>{I18n.t('%')}</Typography>
  253. </Container>
  254. </PresentationContent>
  255. </div>
  256. </div>
  257. </Container>
  258. </FormFieldGroup>
  259. <PresentationContent><hr /></PresentationContent>
  260. <Container as="div" margin="small">
  261. <Checkbox
  262. label={I18n.t('Automatically apply deduction to late submissions')}
  263. defaultChecked={data.lateSubmissionDeductionEnabled}
  264. onChange={this.changeLateSubmissionDeductionEnabled}
  265. />
  266. </Container>
  267. <FormFieldGroup
  268. description={<ScreenReaderContent>{I18n.t('Late policies')}</ScreenReaderContent>}
  269. messages={this.latePolicyMessages(validationErrors)}
  270. >
  271. <Container as="div" margin="small small small large">
  272. <div style={{ marginLeft: '0.25rem' }}>
  273. <Container display="inline" as="div" margin="0 small 0 0">
  274. <PresentationContent>
  275. <Container as="div" margin="0 0 x-small 0">
  276. <label htmlFor="late-submission-deduction">
  277. <Typography size="small" weight="bold">{I18n.t('Deduct')}</Typography>
  278. </label>
  279. </Container>
  280. </PresentationContent>
  281. <div style={{display: 'flex', alignItems: 'center'}}>
  282. <NumberInput
  283. id="late-submission-deduction"
  284. locale={this.props.locale}
  285. inputRef={(l) => { this.lateSubmissionDeductionInput = l; }}
  286. label={<ScreenReaderContent>{I18n.t('Late submission deduction percent')}</ScreenReaderContent>}
  287. defaultValue={data.lateSubmissionDeduction.toString()}
  288. disabled={!this.getLatePolicyAttribute('lateSubmissionDeductionEnabled')}
  289. onChange={this.changeLateSubmissionDeduction}
  290. min="0"
  291. max="100"
  292. inline
  293. width={numberInputWidth}
  294. />
  295. <PresentationContent>
  296. <Container as="div" margin="0 small">
  297. <Typography>{I18n.t('%')}</Typography>
  298. </Container>
  299. </PresentationContent>
  300. </div>
  301. </Container>
  302. <Container display="inline" as="div" margin="0 0 0 small">
  303. <PresentationContent>
  304. <Container as="div" margin="0 0 x-small 0">
  305. <label htmlFor="late-submission-interval">
  306. <Typography size="small" weight="bold">{I18n.t('For each late')}</Typography>
  307. </label>
  308. </Container>
  309. </PresentationContent>
  310. <Select
  311. id="late-submission-interval"
  312. disabled={!this.getLatePolicyAttribute('lateSubmissionDeductionEnabled')}
  313. label={<ScreenReaderContent>{I18n.t('Late submission deduction interval')}</ScreenReaderContent>}
  314. inline
  315. width="6rem"
  316. defaultValue={data.lateSubmissionInterval}
  317. onChange={this.changeLateSubmissionInterval}
  318. >
  319. <option value="day">{I18n.t('Day')}</option>
  320. <option value="hour" >{I18n.t('Hour')}</option>
  321. </Select>
  322. </Container>
  323. </div>
  324. </Container>
  325. <Container as="div" margin="small small small large">
  326. <div style={{ marginLeft: '0.25rem' }}>
  327. <PresentationContent>
  328. <Container as="div" margin="0 0 x-small 0">
  329. <label htmlFor="late-submission-minimum-percent">
  330. <Typography size="small" weight="bold">{I18n.t('Lowest possible grade')}</Typography>
  331. </label>
  332. </Container>
  333. </PresentationContent>
  334. <div style={{display: 'flex', alignItems: 'center'}}>
  335. <NumberInput
  336. id="late-submission-minimum-percent"
  337. locale={this.props.locale}
  338. inputRef={(l) => { this.lateSubmissionMinimumPercentInput = l; }}
  339. label={<ScreenReaderContent>{I18n.t('Lowest possible grade percent')}</ScreenReaderContent>}
  340. defaultValue={data.lateSubmissionMinimumPercent.toString()}
  341. disabled={!this.getLatePolicyAttribute('lateSubmissionDeductionEnabled')}
  342. onChange={this.changeLateSubmissionMinimumPercent}
  343. min="0"
  344. max="100"
  345. inline
  346. width={numberInputWidth}
  347. />
  348. <PresentationContent>
  349. <Container as="div" margin="0 small">
  350. <Typography>{I18n.t('%')}</Typography>
  351. </Container>
  352. </PresentationContent>
  353. </div>
  354. </div>
  355. </Container>
  356. </FormFieldGroup>
  357. </div>
  358. );
  359. }
  360. }
  361. export default LatePoliciesTabPanel;