DueDates.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. /*
  2. * Copyright (C) 2015 - 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 _ from 'underscore'
  19. import React from 'react'
  20. import ReactDOM from 'react-dom'
  21. import PropTypes from 'prop-types'
  22. import DueDateRow from 'jsx/due_dates/DueDateRow'
  23. import DueDateAddRowButton from 'jsx/due_dates/DueDateAddRowButton'
  24. import OverrideStudentStore from 'jsx/due_dates/OverrideStudentStore'
  25. import StudentGroupStore from 'jsx/due_dates/StudentGroupStore'
  26. import TokenActions from 'jsx/due_dates/TokenActions'
  27. import Override from 'compiled/models/AssignmentOverride'
  28. import AssignmentOverrideHelper from 'jsx/gradebook/AssignmentOverrideHelper'
  29. import I18n from 'i18n!assignments'
  30. import $ from 'jquery'
  31. import GradingPeriodsHelper from 'jsx/grading/helpers/GradingPeriodsHelper'
  32. import tz from 'timezone'
  33. import 'compiled/jquery.rails_flash_notifications'
  34. var DueDates = React.createClass({
  35. propTypes: {
  36. overrides: PropTypes.array.isRequired,
  37. syncWithBackbone: PropTypes.func.isRequired,
  38. sections: PropTypes.array.isRequired,
  39. defaultSectionId: PropTypes.string.isRequired,
  40. hasGradingPeriods: PropTypes.bool.isRequired,
  41. gradingPeriods: PropTypes.array.isRequired,
  42. isOnlyVisibleToOverrides: PropTypes.bool.isRequired,
  43. dueAt: function(props) {
  44. const isDate = props['dueAt'] instanceof Date
  45. if (!isDate && props['dueAt'] !== null) {
  46. return new Error('Invalid prop `dueAt` supplied to `DueDates`. Validation failed.')
  47. }
  48. },
  49. dueDatesReadonly: PropTypes.bool,
  50. availabilityDatesReadonly: PropTypes.bool
  51. },
  52. getDefaultProps () {
  53. return {
  54. dueDatesReadonly: false,
  55. availabilityDatesReadonly: false
  56. }
  57. },
  58. // -------------------
  59. // Lifecycle
  60. // -------------------
  61. getInitialState(){
  62. return {
  63. students: {},
  64. sections: {},
  65. noops: {[Override.conditionalRelease.noop_id]: Override.conditionalRelease},
  66. rows: {},
  67. addedRowCount: 0,
  68. defaultSectionId: null,
  69. currentlySearching: false,
  70. allStudentsFetched: false,
  71. selectedGroupSetId: null
  72. }
  73. },
  74. componentDidMount(){
  75. this.setState({
  76. rows: this.rowsFromOverrides(this.props.overrides),
  77. sections: this.formattedSectionHash(this.props.sections),
  78. groups: {},
  79. selectedGroupSetId: this.props.selectedGroupSetId
  80. }, this.fetchAdhocStudents)
  81. OverrideStudentStore.addChangeListener(this.handleStudentStoreChange)
  82. StudentGroupStore.setGroupSetIfNone(this.props.selectedGroupSetId)
  83. StudentGroupStore.addChangeListener(this.handleStudentGroupStoreChange)
  84. StudentGroupStore.fetchGroupsForCourse()
  85. },
  86. fetchAdhocStudents(){
  87. OverrideStudentStore.fetchStudentsByID(this.adhocOverrideStudentIDs())
  88. },
  89. handleStudentStoreChange(){
  90. if( this.isMounted() ){
  91. this.setState({
  92. students: OverrideStudentStore.getStudents(),
  93. currentlySearching: OverrideStudentStore.currentlySearching(),
  94. allStudentsFetched: OverrideStudentStore.allStudentsFetched()
  95. })
  96. }
  97. },
  98. handleStudentGroupStoreChange(){
  99. if( this.isMounted() ){
  100. this.setState({
  101. groups: this.formattedGroupHash(StudentGroupStore.getGroups()),
  102. selectedGroupSetId: StudentGroupStore.getSelectedGroupSetId()
  103. })
  104. }
  105. },
  106. // always keep React Overrides in sync with Backbone Collection
  107. componentWillUpdate(nextProps, nextState){
  108. var updatedOverrides = this.getAllOverrides(nextState.rows)
  109. this.props.syncWithBackbone(updatedOverrides)
  110. },
  111. // --------------------------
  112. // State Change
  113. // --------------------------
  114. replaceRow(rowKey, newOverrides, rowDates){
  115. var tmp = {}
  116. var dates = rowDates || this.datesFromOverride(newOverrides[0])
  117. tmp[rowKey] = {overrides: newOverrides, dates: dates, persisted: false}
  118. var newRows = _.extend(this.state.rows, tmp)
  119. this.setState({rows: newRows})
  120. },
  121. // -------------------
  122. // Helpers
  123. // -------------------
  124. formattedSectionHash(unformattedSections){
  125. var formattedSections = _.map(unformattedSections, this.formatSection)
  126. return _.indexBy(formattedSections, "id")
  127. },
  128. formatSection(section){
  129. return _.extend(section.attributes, {course_section_id: section.id})
  130. },
  131. formattedGroupHash(unformattedGroups){
  132. var formattedGroups = _.map(unformattedGroups, this.formatGroup)
  133. return _.indexBy(formattedGroups, "id")
  134. },
  135. formatGroup(group){
  136. return _.extend(group, {group_id: group.id})
  137. },
  138. getAllOverrides(givenRows){
  139. var rows = givenRows || this.state.rows
  140. return _.chain(rows).
  141. values().
  142. map((row) => {
  143. return _.map(row["overrides"], (override) => {
  144. override.attributes.persisted = row.persisted
  145. return override
  146. })
  147. }).
  148. flatten().
  149. compact().
  150. value()
  151. },
  152. adhocOverrides(){
  153. return _.filter(
  154. this.getAllOverrides(),
  155. (ov) => ov.get("student_ids")
  156. )
  157. },
  158. adhocOverrideStudentIDs(){
  159. return _.chain(this.adhocOverrides()).
  160. map((ov) => ov.get("student_ids")).
  161. flatten().
  162. uniq().
  163. value()
  164. },
  165. datesFromOverride(override){
  166. return {
  167. due_at: (override ? override.get("due_at") : null),
  168. lock_at: (override ? override.get("lock_at") : null),
  169. unlock_at: (override ? override.get("unlock_at") : null)
  170. }
  171. },
  172. groupsForSelectedSet(){
  173. var allGroups = this.state.groups
  174. var setId = this.state.selectedGroupSetId
  175. return _.chain(allGroups)
  176. .filter( function(value, key) {
  177. return value.group_category_id === setId
  178. })
  179. .indexBy("id")
  180. .value()
  181. },
  182. // -------------------
  183. // Row Setup
  184. // -------------------
  185. rowsFromOverrides(overrides){
  186. var overridesByKey = _.groupBy(overrides, (override) => {
  187. override.set("rowKey", override.combinedDates())
  188. return override.get("rowKey")
  189. })
  190. return _.chain(overridesByKey)
  191. .map((overrides, key) => {
  192. var datesForGroup = this.datesFromOverride(overrides[0])
  193. return [key, {overrides: overrides, dates: datesForGroup, persisted: true}]
  194. })
  195. .object()
  196. .value()
  197. },
  198. sortedRowKeys(){
  199. var {datedKeys, numberedKeys} = _.chain(this.state.rows)
  200. .keys()
  201. .groupBy( (key) => {
  202. return key.length > 11 ? "datedKeys" : "numberedKeys"
  203. })
  204. .value()
  205. return _.chain([datedKeys,numberedKeys]).flatten().compact().value()
  206. },
  207. rowRef(rowKey){
  208. return "due_date_row-" + rowKey;
  209. },
  210. // ------------------------
  211. // Adding and Removing Rows
  212. // ------------------------
  213. addRow(){
  214. var newRowCount = this.state.addedRowCount + 1
  215. this.replaceRow(newRowCount, [], {})
  216. this.setState({ addedRowCount: newRowCount }, function() {
  217. this.focusRow(newRowCount);
  218. })
  219. },
  220. removeRow(rowToRemoveKey){
  221. if ( !this.canRemoveRow() ) return
  222. var previousIndex = _.indexOf(this.sortedRowKeys(), rowToRemoveKey);
  223. var newRows = _.omit(this.state.rows, rowToRemoveKey);
  224. this.setState({ rows: newRows }, function() {
  225. var ks = this.sortedRowKeys();
  226. var previousRowKey = ks[previousIndex] || ks[ks.length - 1];
  227. this.focusRow(previousRowKey);
  228. })
  229. },
  230. canRemoveRow(){
  231. return this.sortedRowKeys().length > 1;
  232. },
  233. focusRow(rowKey){
  234. ReactDOM.findDOMNode(this.refs[this.rowRef(rowKey)]).querySelector('input').focus();
  235. },
  236. // --------------------------
  237. // Adding and Removing Tokens
  238. // --------------------------
  239. changeRowToken(addOrRemoveFunction, rowKey, changedToken){
  240. if (!changedToken) return
  241. var row = this.state.rows[rowKey]
  242. var newOverridesForRow = addOrRemoveFunction.call(TokenActions,
  243. changedToken,
  244. row["overrides"],
  245. rowKey,
  246. row["dates"]
  247. )
  248. this.replaceRow(rowKey, newOverridesForRow, row["dates"])
  249. },
  250. handleInteractionStart() {
  251. OverrideStudentStore.fetchStudentsForCourse()
  252. },
  253. handleTokenAdd(rowKey, newToken){
  254. this.changeRowToken(TokenActions.handleTokenAdd, rowKey, newToken)
  255. },
  256. handleTokenRemove(rowKey, tokenToRemove){
  257. this.changeRowToken(TokenActions.handleTokenRemove, rowKey, tokenToRemove)
  258. },
  259. replaceDate(rowKey, dateType, newDate){
  260. var oldOverrides = this.state.rows[rowKey].overrides
  261. var oldDates = this.state.rows[rowKey].dates
  262. var newOverrides = _.map(oldOverrides, (override) => {
  263. override.set(dateType, newDate)
  264. return override
  265. })
  266. var tmp = {}
  267. tmp[dateType] = newDate
  268. var newDates = _.extend(oldDates, tmp)
  269. this.replaceRow(rowKey, newOverrides, newDates)
  270. },
  271. // --------------------------
  272. // Everyone v Everyone Else
  273. // --------------------------
  274. defaultSectionNamer(sectionID){
  275. if (sectionID !== this.props.defaultSectionId) return null
  276. var onlyDefaultSectionChosen = _.isEqual(this.chosenSectionIds(), [sectionID])
  277. var noSectionsChosen = _.isEmpty(this.chosenSectionIds())
  278. var noGroupsChosen = _.isEmpty(this.chosenGroupIds())
  279. var noStudentsChosen = _.isEmpty(this.chosenStudentIds())
  280. var defaultSectionOrNoSectionChosen = onlyDefaultSectionChosen || noSectionsChosen
  281. if ( defaultSectionOrNoSectionChosen && noStudentsChosen && noGroupsChosen) {
  282. return I18n.t("Everyone")
  283. }
  284. return I18n.t("Everyone Else")
  285. },
  286. addStudentIfInClosedPeriod(gradingPeriodsHelper, students, dueDate, studentID) {
  287. const student = this.state.students[studentID]
  288. if (student && gradingPeriodsHelper.isDateInClosedGradingPeriod(dueDate)) {
  289. students = students.concat(student)
  290. }
  291. return students
  292. },
  293. studentsInClosedPeriods() {
  294. const allStudents = _.values(this.state.students)
  295. if (_.isEmpty(allStudents)) return allStudents
  296. const overrides = _.map(this.props.overrides, override => override.attributes)
  297. const assignment = {
  298. due_at: this.props.dueAt,
  299. only_visible_to_overrides: this.props.isOnlyVisibleToOverrides
  300. }
  301. const effectiveDueDates = AssignmentOverrideHelper.effectiveDueDatesForAssignment(assignment, overrides, allStudents)
  302. const gradingPeriodsHelper = new GradingPeriodsHelper(this.props.gradingPeriods)
  303. return _.reduce(effectiveDueDates, this.addStudentIfInClosedPeriod.bind(this, gradingPeriodsHelper), [])
  304. },
  305. // --------------------------
  306. // Filtering Dropdown Opts
  307. // --------------------------
  308. // if a student/section has already been selected
  309. // it is no longer a valid option -> hide it
  310. validDropdownOptions(){
  311. let validStudents = this.valuesWithOmission({object: this.state.students, keysToOmit: this.chosenStudentIds()})
  312. let validGroups = this.valuesWithOmission({object: this.groupsForSelectedSet(), keysToOmit: this.chosenGroupIds()})
  313. let validSections = this.valuesWithOmission({object: this.state.sections, keysToOmit: this.chosenSectionIds()})
  314. let validNoops = this.valuesWithOmission({object: this.state.noops, keysToOmit: this.chosenNoops()})
  315. if (this.props.hasGradingPeriods && !_.contains(ENV.current_user_roles, "admin")) {
  316. ({validStudents, validGroups, validSections} =
  317. this.filterDropdownOptionsForMultipleGradingPeriods(validStudents, validGroups, validSections))
  318. }
  319. return _.union(validStudents, validSections, validGroups, validNoops)
  320. },
  321. extractGroupsAndSectionsFromStudent(groups, toOmit, student) {
  322. _.each(student.group_ids, function(groupID) {
  323. toOmit.groupsToOmit[groupID] = toOmit.groupsToOmit[groupID] || groups[groupID]
  324. })
  325. _.each(student.sections, (sectionID) => {
  326. toOmit.sectionsToOmit[sectionID] = toOmit.sectionsToOmit[sectionID] || this.state.sections[sectionID]
  327. })
  328. return toOmit
  329. },
  330. groupsAndSectionsInClosedPeriods(studentsToOmit) {
  331. const groups = this.groupsForSelectedSet()
  332. const omitted = _.reduce(
  333. studentsToOmit,
  334. this.extractGroupsAndSectionsFromStudent.bind(this, groups),
  335. { groupsToOmit: {}, sectionsToOmit: {} }
  336. )
  337. return {
  338. groupsToOmit: _.values(omitted.groupsToOmit),
  339. sectionsToOmit: _.values(omitted.sectionsToOmit)
  340. }
  341. },
  342. filterDropdownOptionsForMultipleGradingPeriods(students, groups, sections) {
  343. const studentsToOmit = this.studentsInClosedPeriods()
  344. if (_.isEmpty(studentsToOmit)) {
  345. return { validStudents: students, validGroups: groups, validSections: sections }
  346. } else {
  347. const { groupsToOmit, sectionsToOmit } = this.groupsAndSectionsInClosedPeriods(studentsToOmit)
  348. return {
  349. validStudents: _.difference(students, studentsToOmit),
  350. validGroups: _.difference(groups, groupsToOmit),
  351. validSections: _.difference(sections, sectionsToOmit)
  352. }
  353. }
  354. },
  355. chosenIds(idType){
  356. return _.chain(this.getAllOverrides()).
  357. map((ov) => ov.get(idType)).
  358. compact().
  359. value()
  360. },
  361. chosenSectionIds(){
  362. return this.chosenIds("course_section_id")
  363. },
  364. chosenStudentIds(){
  365. return _.flatten(this.chosenIds("student_ids"))
  366. },
  367. chosenGroupIds(){
  368. return this.chosenIds("group_id")
  369. },
  370. chosenNoops(){
  371. return this.chosenIds("noop_id")
  372. },
  373. valuesWithOmission(args){
  374. return _.chain(args["object"]).
  375. omit(args["keysToOmit"]).
  376. values().
  377. value()
  378. },
  379. disableInputs(row) {
  380. const rowIsNewOrUserIsAdmin = !row.persisted || _.contains(ENV.current_user_roles, "admin")
  381. if (!this.props.hasGradingPeriods || rowIsNewOrUserIsAdmin) {
  382. return false
  383. }
  384. const dates = (row.dates || {})
  385. return this.isInClosedGradingPeriod(dates.due_at)
  386. },
  387. isInClosedGradingPeriod(date) {
  388. if (date === undefined) return false
  389. const dueAt = date === null ? null : new Date(date)
  390. return new GradingPeriodsHelper(this.props.gradingPeriods).isDateInClosedGradingPeriod(dueAt)
  391. },
  392. // -------------------
  393. // Rendering
  394. // -------------------
  395. rowsToRender(){
  396. return _.map(this.sortedRowKeys(), (rowKey) => {
  397. var row = this.state.rows[rowKey]
  398. var overrides = row.overrides || []
  399. var dates = row.dates || {}
  400. return (
  401. <DueDateRow
  402. ref = {this.rowRef(rowKey)}
  403. inputsDisabled = {this.disableInputs(row)}
  404. overrides = {overrides}
  405. key = {rowKey}
  406. rowKey = {rowKey}
  407. dates = {dates}
  408. students = {this.state.students}
  409. sections = {this.state.sections}
  410. groups = {this.state.groups}
  411. canDelete = {this.canRemoveRow()}
  412. validDropdownOptions = {this.validDropdownOptions()}
  413. handleDelete = {this.removeRow.bind(this, rowKey)}
  414. handleTokenAdd = {this.handleTokenAdd.bind(this, rowKey)}
  415. handleTokenRemove = {this.handleTokenRemove.bind(this, rowKey)}
  416. defaultSectionNamer = {this.defaultSectionNamer}
  417. replaceDate = {this.replaceDate.bind(this, rowKey)}
  418. currentlySearching = {this.state.currentlySearching}
  419. allStudentsFetched = {this.state.allStudentsFetched}
  420. dueDatesReadonly = {this.props.dueDatesReadonly}
  421. availabilityDatesReadonly = {this.props.availabilityDatesReadonly}
  422. />
  423. )
  424. })
  425. },
  426. render() {
  427. var rowsToRender = this.rowsToRender()
  428. return (
  429. <div className="ContainerDueDate" onMouseEnter={this.handleInteractionStart}>
  430. <div id="bordered-wrapper" className="Container__DueDateRow">
  431. {rowsToRender}
  432. </div>
  433. {
  434. this.props.dueDatesReadonly || this.props.availabilityDatesReadonly
  435. ? null
  436. : <DueDateAddRowButton handleAdd={this.addRow} display={true} />
  437. }
  438. </div>
  439. )
  440. }
  441. })
  442. export default DueDates