DueDateTokenWrapper.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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 PropTypes from 'prop-types'
  21. import ReactModal from 'react-modal'
  22. import OverrideStudentStore from 'jsx/due_dates/OverrideStudentStore'
  23. import Override from 'compiled/models/AssignmentOverride'
  24. import TokenInput, {Option as ComboboxOption} from 'react-tokeninput'
  25. import I18n from 'i18n!assignments'
  26. import $ from 'jquery'
  27. import SearchHelpers from 'jsx/shared/helpers/searchHelpers'
  28. import DisabledTokenInput from 'jsx/due_dates/DisabledTokenInput'
  29. var DueDateWrapperConsts = {
  30. MINIMUM_SEARCH_LENGTH: 3,
  31. MAXIMUM_STUDENTS_TO_SHOW: 7,
  32. MAXIMUM_GROUPS_TO_SHOW: 5,
  33. MAXIMUM_SECTIONS_TO_SHOW: 3,
  34. MS_TO_DEBOUNCE_SEARCH: 800,
  35. }
  36. var DueDateTokenWrapper = React.createClass({
  37. propTypes: {
  38. tokens: PropTypes.array.isRequired,
  39. handleTokenAdd: PropTypes.func.isRequired,
  40. handleTokenRemove: PropTypes.func.isRequired,
  41. potentialOptions: PropTypes.array.isRequired,
  42. rowKey: PropTypes.string.isRequired,
  43. defaultSectionNamer: PropTypes.func.isRequired,
  44. currentlySearching: PropTypes.bool.isRequired,
  45. allStudentsFetched: PropTypes.bool.isRequired,
  46. disabled: PropTypes.bool.isRequired
  47. },
  48. MINIMUM_SEARCH_LENGTH: DueDateWrapperConsts.MINIMUM_SEARCH_LENGTH,
  49. MAXIMUM_STUDENTS_TO_SHOW: DueDateWrapperConsts.MAXIMUM_STUDENTS_TO_SHOW,
  50. MAXIMUM_SECTIONS_TO_SHOW: DueDateWrapperConsts.MAXIMUM_SECTIONS_TO_SHOW,
  51. MAXIMUM_GROUPS_TO_SHOW: DueDateWrapperConsts.MAXIMUM_GROUPS_TO_SHOW,
  52. MS_TO_DEBOUNCE_SEARCH: DueDateWrapperConsts.MS_TO_DEBOUNCE_SEARCH,
  53. // This is useful for testing to make it so the debounce is not used
  54. // during testing or any other time when that might be a problem.
  55. removeTimingSafeties(){
  56. this.safetiesOff = true;
  57. },
  58. // -------------------
  59. // Lifecycle
  60. // -------------------
  61. getInitialState() {
  62. return {
  63. userInput: "",
  64. currentlyTyping: false
  65. }
  66. },
  67. // -------------------
  68. // Actions
  69. // -------------------
  70. handleFocus() {
  71. // TODO: once react supports onFocusIn, remove this stuff and just
  72. // do it on DueDates' top-level <div /> like we do for onMouseEnter
  73. OverrideStudentStore.fetchStudentsForCourse()
  74. },
  75. handleInput(userInput) {
  76. if (this.props.disabled) return;
  77. this.setState(
  78. { userInput: userInput, currentlyTyping: true },function(){
  79. if (this.safetiesOff) {
  80. this.fetchStudents()
  81. } else {
  82. this.safeFetchStudents()
  83. }
  84. }
  85. )
  86. },
  87. safeFetchStudents: _.debounce( function() {
  88. this.fetchStudents()
  89. }, DueDateWrapperConsts.MS_TO_DEBOUNCE_SEARCH
  90. ),
  91. fetchStudents(){
  92. if( this.isMounted() ){
  93. this.setState({currentlyTyping: false})
  94. }
  95. if ($.trim(this.state.userInput) !== '' && this.state.userInput.length >= this.MINIMUM_SEARCH_LENGTH) {
  96. OverrideStudentStore.fetchStudentsByName($.trim(this.state.userInput))
  97. }
  98. },
  99. handleTokenAdd(value, option) {
  100. if (this.props.disabled) return;
  101. var token = this.findMatchingOption(value, option)
  102. this.props.handleTokenAdd(token)
  103. this.clearUserInput()
  104. },
  105. overrideTokenAriaLabel(tokenName) {
  106. return I18n.t('Currently assigned to %{tokenName}, click to remove', {tokenName: tokenName});
  107. },
  108. handleTokenRemove(token) {
  109. if (this.props.disabled) return;
  110. this.props.handleTokenRemove(token)
  111. },
  112. suppressKeys(e){
  113. var code = e.keyCode || e.which
  114. if (code === 13) {
  115. e.preventDefault()
  116. }
  117. },
  118. clearUserInput(){
  119. this.setState({userInput: ""})
  120. },
  121. // -------------------
  122. // Helpers
  123. // -------------------
  124. findMatchingOption(name, option){
  125. if(option){
  126. // Selection was made from dropdown, find by unique attributes
  127. return _.findWhere(this.props.potentialOptions, option.props.set_props)
  128. } else {
  129. // Search for best matching name
  130. return this.sortedMatches(name)[0]
  131. }
  132. },
  133. sortedMatches(userInput){
  134. var optsByMatch = _.groupBy(this.props.potentialOptions, (dropdownObj) => {
  135. if (SearchHelpers.exactMatchRegex(userInput).test(dropdownObj.name)) { return "exact" }
  136. if (SearchHelpers.startOfStringRegex(userInput).test(dropdownObj.name)) { return "start" }
  137. if (SearchHelpers.substringMatchRegex(userInput).test(dropdownObj.name)) { return "substring" }
  138. });
  139. return _.union(
  140. optsByMatch.exact, optsByMatch.start, optsByMatch.substring
  141. );
  142. },
  143. filteredTags() {
  144. if (this.state.userInput === '') return this.props.potentialOptions
  145. return this.sortedMatches(this.state.userInput)
  146. },
  147. filteredTagsForType(type){
  148. var groupedTags = this.groupByTagType(this.filteredTags())
  149. return groupedTags && groupedTags[type] || []
  150. },
  151. groupByTagType(options){
  152. return _.groupBy(options, (opt) => {
  153. if (opt["course_section_id"]) {
  154. return "course_section"
  155. } else if (opt["group_id"]) {
  156. return "group"
  157. } else if (opt["noop_id"]){
  158. return "noop"
  159. } else {
  160. return "student"
  161. }
  162. })
  163. },
  164. userSearchingThisInput(){
  165. return this.state.userInput && $.trim(this.state.userInput) !== ""
  166. },
  167. // -------------------
  168. // Rendering
  169. // -------------------
  170. rowIdentifier(){
  171. // identifying for validations
  172. return "tokenInputFor" + this.props.rowKey
  173. },
  174. currentlySearching(){
  175. if(this.props.allStudentsFetched || $.trim(this.state.userInput) === ''){
  176. return false
  177. }
  178. return this.props.currentlySearching || this.state.currentlyTyping
  179. },
  180. // ---- options ----
  181. optionsForMenu() {
  182. var options = this.promptText() ?
  183. _.union([this.promptOption()], this.optionsForAllTypes()) :
  184. this.optionsForAllTypes()
  185. return options
  186. },
  187. optionsForAllTypes(){
  188. return _.union(
  189. this.conditionalReleaseOptions(),
  190. this.sectionOptions(),
  191. this.groupOptions(),
  192. this.studentOptions()
  193. )
  194. },
  195. studentOptions(){
  196. return this.optionsForType("student")
  197. },
  198. groupOptions(){
  199. return this.optionsForType("group")
  200. },
  201. sectionOptions(){
  202. return this.optionsForType("course_section")
  203. },
  204. conditionalReleaseOptions(){
  205. if (!ENV.CONDITIONAL_RELEASE_SERVICE_ENABLED) return []
  206. var selectable = _.contains(this.filteredTagsForType('noop'), Override.conditionalRelease)
  207. return selectable ? [this.headerOption("conditional_release", Override.conditionalRelease)] : []
  208. },
  209. optionsForType(optionType){
  210. var header = this.headerOption(optionType)
  211. var options = this.selectableOptions(optionType)
  212. return _.any(options) ? _.union([header], options) : []
  213. },
  214. headerOption(heading, set){
  215. var headerText = {
  216. "student": I18n.t("Student"),
  217. "course_section": I18n.t("Course Section"),
  218. "group": I18n.t("Group"),
  219. "conditional_release": I18n.t("Mastery Paths"),
  220. }[heading]
  221. const canSelect = heading === 'conditional_release'
  222. return (
  223. <ComboboxOption
  224. isFocusable={canSelect}
  225. className="ic-tokeninput-header"
  226. value={heading}
  227. key={heading}
  228. set_props={set}
  229. >
  230. {headerText}
  231. </ComboboxOption>
  232. )
  233. },
  234. selectableOptions(type){
  235. var numberToShow = {
  236. "student": this.MAXIMUM_STUDENTS_TO_SHOW,
  237. "course_section": this.MAXIMUM_SECTIONS_TO_SHOW,
  238. "group": this.MAXIMUM_GROUPS_TO_SHOW,
  239. }[type] || 0
  240. return _.chain(this.filteredTagsForType(type))
  241. .take(numberToShow)
  242. .map((set, index) => this.selectableOption(set, index))
  243. .value()
  244. },
  245. selectableOption(set, index){
  246. var displayName = set.name || this.props.defaultSectionNamer(set.course_section_id)
  247. return <ComboboxOption key={set.key || `${displayName}-${index}`} value={set.name} set_props={set}>
  248. {displayName}
  249. </ComboboxOption>
  250. },
  251. // ---- prompt ----
  252. promptOption(){
  253. return (
  254. <ComboboxOption value={this.promptText()} key={"promptText"}>
  255. <i>{this.promptText()}</i>
  256. {this.throbber()}
  257. </ComboboxOption>
  258. )
  259. },
  260. promptText(){
  261. if (this.currentlySearching()){
  262. return I18n.t("Searching")
  263. }
  264. if(this.state.userInput.length < this.MINIMUM_SEARCH_LENGTH && !this.props.allStudentsFetched || this.hidingValidMatches()){
  265. return I18n.t("Continue typing to find additional sections or students.")
  266. }
  267. if(_.isEmpty(this.filteredTags())){
  268. return I18n.t("No results found")
  269. }
  270. },
  271. throbber(){
  272. if(this.currentlySearching() && this.userSearchingThisInput()){
  273. return (
  274. <div className="tokenInputThrobber"/>
  275. )
  276. }
  277. },
  278. hidingValidMatches(){
  279. var allSectionTags = this.filteredTagsForType("course_section")
  280. var hidingSections = allSectionTags && allSectionTags.length > this.MAXIMUM_SECTIONS_TO_SHOW
  281. var allStudentTags = this.filteredTagsForType("student")
  282. var hidingStudents = allStudentTags && allStudentTags.length > this.MAXIMUM_STUDENTS_TO_SHOW
  283. var allGroupTags = this.filteredTagsForType("group")
  284. var hidingGroups = allGroupTags && allGroupTags.length > this.MAXIMUM_GROUPS_TO_SHOW
  285. return hidingSections || hidingStudents || hidingGroups
  286. },
  287. renderTokenInput() {
  288. if (this.props.disabled) {
  289. return <DisabledTokenInput tokens={_.pluck(this.props.tokens, "name")} ref="DisabledTokenInput"/>;
  290. }
  291. const ariaLabel = I18n.t(
  292. 'Add students by searching by name, course section or group.' +
  293. ' After entering text, navigate results by using the down arrow key.' +
  294. ' Select a result by using the Enter key.'
  295. );
  296. return (
  297. <div>
  298. <div id="ic-tokeninput-description"
  299. className = "screenreader-only">
  300. { I18n.t('Use this list to remove assigned students. Add new students with combo box after list.') }
  301. </div>
  302. <TokenInput
  303. menuContent = {this.optionsForMenu()}
  304. selected = {this.props.tokens}
  305. onFocus = {this.handleFocus}
  306. onInput = {this.handleInput}
  307. onSelect = {this.handleTokenAdd}
  308. tokenAriaFunc = {this.overrideTokenAriaLabel}
  309. onRemove = {this.handleTokenRemove}
  310. combobox-aria-label = {ariaLabel}
  311. value = {true}
  312. showListOnFocus = {!this.props.disabled}
  313. ref = "TokenInput"
  314. />
  315. </div>
  316. );
  317. },
  318. // ---- render ----
  319. render() {
  320. return (
  321. <div className = "ic-Form-control"
  322. data-row-identifier = {this.rowIdentifier()}
  323. onKeyDown = {this.suppressKeys}>
  324. <div id = "assign-to-label"
  325. className = "ic-Label"
  326. title = 'Assign to'
  327. aria-label = 'Assign to'>
  328. {I18n.t("Assign to")}
  329. </div>
  330. {this.renderTokenInput()}
  331. </div>
  332. )
  333. }
  334. })
  335. export default DueDateTokenWrapper