CustomHelpLinkSettings.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. /*
  2. * Copyright (C) 2016 - 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 'compiled/jquery.rails_flash_notifications'
  19. import React from 'react'
  20. import PropTypes from 'prop-types'
  21. import I18n from 'i18n!custom_help_link'
  22. import $ from 'jquery'
  23. import CustomHelpLinkIcons from './CustomHelpLinkIcons'
  24. import CustomHelpLink from './CustomHelpLink'
  25. import CustomHelpLinkForm from './CustomHelpLinkForm'
  26. import CustomHelpLinkMenu from './CustomHelpLinkMenu'
  27. import CustomHelpLinkPropTypes from './CustomHelpLinkPropTypes'
  28. let counter = 0 // counter to ensure unique ids for links
  29. export default class CustomHelpLinkSettings extends React.Component {
  30. static propTypes = {
  31. name: PropTypes.string,
  32. links: PropTypes.arrayOf(CustomHelpLinkPropTypes.link),
  33. defaultLinks: PropTypes.arrayOf(CustomHelpLinkPropTypes.link),
  34. icon: PropTypes.string
  35. }
  36. static defaultProps = {
  37. name: I18n.t('Help'),
  38. icon: 'questionMark',
  39. defaultLinks: [],
  40. links: []
  41. }
  42. constructor(props) {
  43. super(props)
  44. // set ids to the original index so that we can have unique keys
  45. const links = props.links.map(link => {
  46. counter++
  47. return {
  48. ...link,
  49. id: link.id || `link${counter}`,
  50. available_to: link.available_to || [],
  51. state: link.state || 'active'
  52. }
  53. })
  54. this.state = {
  55. links,
  56. editing: null // id of link that is being edited
  57. }
  58. }
  59. getDefaultLinks = () => {
  60. const linkTexts = this.state.links.map(link => link.text)
  61. return this.props.defaultLinks.map(link => ({
  62. ...link,
  63. is_disabled: linkTexts.indexOf(link.text) > -1
  64. }))
  65. }
  66. // define handlers here so that we don't create one for each render
  67. handleMoveUp = link => {
  68. // if we are moving an element to the top slot, focus the previous component
  69. // instead of moving focus forward to the move-down button (see CNVS-35393)
  70. this.move(
  71. link,
  72. -1,
  73. link.index === 1 ? this.focusPreviousComponent : this.focus.bind(this, link.id, 'moveUp')
  74. )
  75. }
  76. handleMoveDown = link => {
  77. this.move(link, 1, this.focus.bind(this, link.id, 'moveDown'))
  78. }
  79. handleEdit = link => {
  80. this.edit(link)
  81. }
  82. handleRemove = link => {
  83. this.remove(link)
  84. }
  85. handleAdd = link => {
  86. this.add(link)
  87. }
  88. handleFormSave = link => {
  89. if (this.validate(link)) {
  90. this.update(link)
  91. }
  92. }
  93. handleFormCancel = link => {
  94. if (link.text) {
  95. this.cancelEdit(link)
  96. } else {
  97. this.remove(link)
  98. }
  99. }
  100. nextFocusable = start => {
  101. const links = this.state.links
  102. const nextIndex = function(i) {
  103. return i > 0 ? i - 1 : null
  104. }
  105. let focusable
  106. let index = nextIndex(start)
  107. while (!focusable && index !== null) {
  108. const id = links[index].id
  109. if (this.links[id].focusable()) {
  110. focusable = id
  111. }
  112. index = nextIndex(index)
  113. }
  114. return focusable
  115. }
  116. focus = (linkId, action) => {
  117. if (linkId) {
  118. const link = this.links[linkId]
  119. link.focus(action)
  120. } else {
  121. this.focusPreviousComponent()
  122. }
  123. }
  124. focusPreviousComponent = () => {
  125. $(
  126. '#custom_help_link_settings input[name="account[settings][help_link_icon]"]:checked'
  127. )[0].focus()
  128. }
  129. cancelEdit = link => {
  130. this.setState(
  131. {
  132. editing: null
  133. },
  134. this.focus.bind(this, link.id, 'edit')
  135. )
  136. }
  137. edit = link => {
  138. this.setState(
  139. {
  140. editing: link.id
  141. },
  142. this.focus.bind(this, link.id)
  143. )
  144. }
  145. add = link => {
  146. counter++
  147. const links = [...this.state.links]
  148. const id = link.id || `link${counter}`
  149. links.splice(0, 0, {
  150. ...link,
  151. state: link.type === 'default' ? link.state : 'new',
  152. id,
  153. type: link.type || 'custom'
  154. })
  155. this.setState(
  156. {
  157. links,
  158. editing: link.type === 'default' ? this.state.editing : id
  159. },
  160. this.focus.bind(this, id)
  161. )
  162. }
  163. update = link => {
  164. const links = [...this.state.links]
  165. links[link.index] = {
  166. ...link,
  167. state: link.text ? 'active' : link.state
  168. }
  169. this.setState(
  170. {
  171. links,
  172. editing: null
  173. },
  174. this.focus.bind(this, link.id, 'edit')
  175. )
  176. }
  177. remove = link => {
  178. const links = [...this.state.links]
  179. const editing = this.state.editing
  180. links.splice(link.index, 1)
  181. this.setState(
  182. {
  183. links,
  184. editing: editing === link.id ? null : editing
  185. },
  186. this.focus.bind(this, this.nextFocusable(link.index), 'remove')
  187. )
  188. }
  189. move = (link, change, callback) => {
  190. const links = [...this.state.links]
  191. links.splice(link.index + change, 0, links.splice(link.index, 1)[0])
  192. this.setState(
  193. {
  194. links
  195. },
  196. callback
  197. )
  198. }
  199. validate = link => {
  200. if (!link.text) {
  201. $.flashError(I18n.t('Please enter a name for this link.'))
  202. return false
  203. } else if (
  204. link.type !== 'default' &&
  205. (!link.url || !/((http|ftp)s?:\/\/)|(tel:)|(mailto:).+/.test(link.url))
  206. ) {
  207. $.flashError(
  208. I18n.t(
  209. 'Please enter a valid URL. Protocol is required (e.g. http://, https://, ftp://, tel:, mailto:).'
  210. )
  211. )
  212. return false
  213. } else if (!link.available_to || link.available_to.length < 1) {
  214. $.flashError(I18n.t('Please select a user role for this link.'))
  215. return false
  216. } else {
  217. return true
  218. }
  219. }
  220. renderForm = link => (
  221. <CustomHelpLinkForm
  222. ref={c => {
  223. this.links[link.id] = c
  224. }}
  225. key={link.id}
  226. link={link}
  227. onSave={this.handleFormSave}
  228. onCancel={this.handleFormCancel}
  229. />
  230. )
  231. renderLink = link => {
  232. const {links} = this.state
  233. const {index, id} = link
  234. return (
  235. <CustomHelpLink
  236. ref={c => {
  237. this.links[link.id] = c
  238. }}
  239. key={id}
  240. link={link}
  241. onMoveUp={index === 0 ? null : this.handleMoveUp}
  242. onMoveDown={index === links.length - 1 ? null : this.handleMoveDown}
  243. onRemove={this.handleRemove}
  244. onEdit={this.handleEdit}
  245. />
  246. )
  247. }
  248. render() {
  249. const {name, icon} = this.props
  250. this.links = {}
  251. return (
  252. <fieldset>
  253. <h2 className="screenreader-only">{I18n.t('Help menu options')}</h2>
  254. <legend>{I18n.t('Help menu options')}</legend>
  255. <div className="ic-Form-group ic-Form-group--horizontal">
  256. <label className="ic-Form-control" htmlFor="account_settings_custom_help_link_name">
  257. <span className="ic-Label">{I18n.t('Name')}</span>
  258. <input
  259. id="account_settings_custom_help_link_name"
  260. type="text"
  261. className="ic-Input"
  262. required
  263. aria-required="true"
  264. name="account[settings][help_link_name]"
  265. defaultValue={name}
  266. />
  267. </label>
  268. <CustomHelpLinkIcons defaultValue={icon} />
  269. <div className="ic-Form-control ic-Form-control--top-align-label">
  270. <span className="ic-Label">{I18n.t('Help menu links')}</span>
  271. <div className="ic-Forms-component">
  272. {this.state.links.length > 0 ? (
  273. <ol className="ic-Sortable-list">
  274. {this.state.links.map((link, index) => {
  275. const linkWithIndex = {
  276. ...link,
  277. index // this is needed for moving up/down
  278. }
  279. return linkWithIndex.id === this.state.editing
  280. ? this.renderForm(linkWithIndex)
  281. : this.renderLink(linkWithIndex)
  282. })}
  283. </ol>
  284. ) : (
  285. <span>
  286. <input type="hidden" name="account[custom_help_links][0][text]" value="" />
  287. <input
  288. type="hidden"
  289. name="account[custom_help_links][0][state]"
  290. value="deleted"
  291. />
  292. </span>
  293. )}
  294. <div className="ic-Sortable-list-add-new">
  295. <CustomHelpLinkMenu
  296. ref={c => {
  297. this.links.addLink = c
  298. }}
  299. links={this.getDefaultLinks()}
  300. onChange={this.handleAdd}
  301. />
  302. </div>
  303. </div>
  304. </div>
  305. </div>
  306. </fieldset>
  307. )
  308. }
  309. }