ThemeEditor.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  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 I18n from 'i18n!theme_editor'
  19. import React from 'react'
  20. import PropTypes from 'prop-types'
  21. import $ from 'jquery'
  22. import _ from 'underscore'
  23. import htmlEscape from 'str/htmlEscape'
  24. import preventDefault from 'compiled/fn/preventDefault'
  25. import Progress from 'compiled/models/Progress'
  26. import customTypes from './PropTypes'
  27. import submitHtmlForm from './submitHtmlForm'
  28. import SaveThemeButton from './SaveThemeButton'
  29. import ThemeEditorAccordion from './ThemeEditorAccordion'
  30. import ThemeEditorFileUpload from './ThemeEditorFileUpload'
  31. import ThemeEditorModal from './ThemeEditorModal'
  32. /*eslint no-alert:0*/
  33. const TABS = [
  34. {
  35. id: 'te-editor',
  36. label: I18n.t('Edit'),
  37. value: 'edit',
  38. selected: true
  39. },
  40. {
  41. id: 'te-upload',
  42. label: I18n.t('Upload'),
  43. value: 'upload',
  44. selected: false
  45. }
  46. ]
  47. function findVarDef (variableSchema, variableName) {
  48. for (let i = 0; i < variableSchema.length; i++) {
  49. for (let j = 0; j < variableSchema[i].variables.length; j++) {
  50. let varDef = variableSchema[i].variables[j]
  51. if (varDef.variable_name === variableName){
  52. return varDef
  53. }
  54. }
  55. }
  56. }
  57. function readSharedBrandConfigBeingEditedFromStorage() {
  58. try {
  59. const stored = sessionStorage.getItem('sharedBrandConfigBeingEdited')
  60. if (stored) return JSON.parse(stored)
  61. } catch (e) {
  62. console.error('Error reading sharedBrandConfigBeingEdited from sessionStore:', e)
  63. }
  64. }
  65. const notComplete = (progress) => progress.completion !== 100
  66. export default React.createClass({
  67. displayName: 'ThemeEditor',
  68. propTypes: {
  69. brandConfig: customTypes.brandConfig,
  70. hasUnsavedChanges: PropTypes.bool.isRequired,
  71. variableSchema: customTypes.variableSchema,
  72. allowGlobalIncludes: PropTypes.bool,
  73. accountID: PropTypes.string,
  74. useHighContrast: PropTypes.bool,
  75. },
  76. getInitialState() {
  77. return {
  78. changedValues: {},
  79. showProgressModal: false,
  80. progress: 0,
  81. sharedBrandConfigBeingEdited: readSharedBrandConfigBeingEditedFromStorage(),
  82. showSubAccountProgress: false,
  83. activeSubAccountProgresses: []
  84. }
  85. },
  86. changeSomething(variableName, newValue, isInvalid) {
  87. const changedValues = {
  88. ...this.state.changedValues,
  89. [variableName]: { val: newValue, invalid: isInvalid },
  90. }
  91. this.setState({changedValues})
  92. },
  93. invalidForm() {
  94. return Object.keys(this.state.changedValues).some((key) => {
  95. return this.state.changedValues[key].invalid
  96. })
  97. },
  98. somethingHasChanged() {
  99. return _.any(this.state.changedValues, (change, key) => {
  100. // null means revert an unsaved change (should revert to saved brand config or fallback to default and not flag as a change)
  101. // '' means clear a brand config value (should set to schema default and flag as a change)
  102. return change.val === '' || (change.val !== this.getDefault(key) && change.val !== null)
  103. })
  104. },
  105. displayedMatchesSaved() {
  106. return this.state.sharedBrandConfigBeingEdited &&
  107. this.state.sharedBrandConfigBeingEdited.brand_config_md5 === this.props.brandConfig.md5
  108. },
  109. getDisplayValue(variableName, opts) {
  110. let val
  111. // try getting the modified value first, unless we're skipping it
  112. if (!opts || !opts.ignoreChanges) val = this.getChangedValue(variableName)
  113. // try getting the value from the active brand config next, but
  114. // distinguish "wasn't changed" from "was changed to '', meaning we want
  115. // to remove the brand config's value"
  116. if (!val && val !== '') val = this.getBrandConfig(variableName)
  117. // finally, resort to the default (which may recurse into looking up
  118. // another variable)
  119. if (!val) val = this.getSchemaDefault(variableName, opts)
  120. return val
  121. },
  122. getChangedValue(variableName) {
  123. return this.state.changedValues[variableName] && this.state.changedValues[variableName].val
  124. },
  125. getDefault(variableName) {
  126. return this.getDisplayValue(variableName, {ignoreChanges: true})
  127. },
  128. getBrandConfig(variableName) {
  129. return this.props.brandConfig[variableName] || this.props.brandConfig.variables[variableName]
  130. },
  131. getSchemaDefault(variableName, opts) {
  132. const varDef = findVarDef(this.props.variableSchema, variableName)
  133. const val = varDef ? varDef.default : null
  134. if (val && val[0] === '$') return this.getDisplayValue(val.slice(1), opts)
  135. return val
  136. },
  137. updateSharedBrandConfigBeingEdited (updatedSharedConfig) {
  138. sessionStorage.setItem('sharedBrandConfigBeingEdited', JSON.stringify(updatedSharedConfig))
  139. this.setState({sharedBrandConfigBeingEdited: updatedSharedConfig})
  140. },
  141. handleCancelClicked() {
  142. if (this.somethingHasChanged() || !this.displayedMatchesSaved()) {
  143. const msg = I18n.t('You are about to lose any unsaved changes.\n\n' +
  144. 'Would you still like to proceed?')
  145. if (!window.confirm(msg)) return
  146. }
  147. sessionStorage.removeItem('sharedBrandConfigBeingEdited')
  148. submitHtmlForm('/accounts/'+this.props.accountID+'/brand_configs', 'DELETE')
  149. },
  150. saveToSession(md5) {
  151. submitHtmlForm('/accounts/'+this.props.accountID+'/brand_configs/save_to_user_session', 'POST', md5)
  152. },
  153. handleFormSubmit() {
  154. let newMd5
  155. this.setState({showProgressModal: true})
  156. $.ajax({
  157. url: '/accounts/'+this.props.accountID+'/brand_configs',
  158. type: 'POST',
  159. data: new FormData(this.refs.ThemeEditorForm.getDOMNode()),
  160. processData: false,
  161. contentType: false,
  162. dataType: "json"
  163. })
  164. .pipe((resp) => {
  165. newMd5 = resp.brand_config.md5
  166. if (resp.progress) {
  167. return new Progress(resp.progress).poll().progress(this.onProgress)
  168. }
  169. })
  170. .pipe(() => this.saveToSession(newMd5))
  171. .fail(() => {
  172. window.alert(I18n.t('An error occurred trying to generate this theme, please try again.'))
  173. this.setState({showProgressModal: false})
  174. })
  175. },
  176. onProgress(data) {
  177. this.setState({progress: data.completion})
  178. },
  179. handleApplyClicked() {
  180. const msg = I18n.t('This will apply this theme to your entire account. Would you like to proceed?')
  181. if (window.confirm(msg)) {
  182. this.kickOffSubAcountCompilation()
  183. }
  184. },
  185. redirectToAccount() {
  186. window.location.replace("/accounts/"+this.props.accountID+"/brand_configs?theme_applied=1")
  187. },
  188. kickOffSubAcountCompilation() {
  189. this.setState({isApplying: true})
  190. $.ajax({
  191. url: '/accounts/'+this.props.accountID+'/brand_configs/save_to_account',
  192. type: 'POST',
  193. data: new FormData(this.refs.ThemeEditorForm.getDOMNode()),
  194. processData: false,
  195. contentType: false,
  196. dataType: "json"
  197. })
  198. .pipe((resp) => {
  199. if (!resp.subAccountProgresses || _.isEmpty(resp.subAccountProgresses)) {
  200. this.redirectToAccount()
  201. } else {
  202. this.openSubAccountProgressModal()
  203. this.filterAndSetActive(resp.subAccountProgresses)
  204. return resp.subAccountProgresses.map( (prog) => {
  205. return new Progress(prog).poll().progress(this.onSubAccountProgress)
  206. })
  207. }
  208. })
  209. .fail(() => {
  210. this.setState({isApplying: false})
  211. window.alert(I18n.t('An error occurred trying to apply this theme, please try again.'))
  212. })
  213. },
  214. onSubAccountProgress(data) {
  215. const newSubAccountProgs = _.map(this.state.activeSubAccountProgresses, (progress) => {
  216. return progress.tag == data.tag ? data : progress
  217. })
  218. this.filterAndSetActive(newSubAccountProgs)
  219. if ( _.isEmpty(this.state.activeSubAccountProgresses)) {
  220. this.closeSubAccountProgressModal()
  221. this.redirectToAccount()
  222. }
  223. },
  224. filterAndSetActive(progresses) {
  225. this.setState({activeSubAccountProgresses: progresses.filter(notComplete)})
  226. },
  227. openSubAccountProgressModal() {
  228. this.setState({ showSubAccountProgress: true })
  229. },
  230. closeSubAccountProgressModal() {
  231. this.setState({ showSubAccountProgress: false })
  232. },
  233. renderTabInputs() {
  234. return this.props.allowGlobalIncludes ? TABS.map( (tab) => {
  235. return (
  236. <input type="radio"
  237. id={tab.id}
  238. key={tab.id}
  239. name="te-action"
  240. defaultValue={tab.value}
  241. className="Theme__editor-tabs_input"
  242. defaultChecked={tab.selected} />
  243. )
  244. }) : null
  245. },
  246. renderTabLabels() {
  247. return this.props.allowGlobalIncludes ? TABS.map( (tab) => {
  248. return (
  249. <label
  250. htmlFor={tab.id}
  251. key={`${tab.id}-tab`}
  252. className="Theme__editor-tabs_item"
  253. id={`${tab.id}-tab`}>
  254. {tab.label}
  255. </label>
  256. )
  257. }) : null
  258. },
  259. render() {
  260. let tooltipForWhyApplyIsDisabled = null
  261. if (this.somethingHasChanged()) {
  262. tooltipForWhyApplyIsDisabled = I18n.t('You need to "Preview Changes" before you can apply this to your account')
  263. } else if (this.props.brandConfig.md5 && !this.displayedMatchesSaved()) {
  264. tooltipForWhyApplyIsDisabled = I18n.t('You need to "Save" before applying to this account')
  265. } else if (this.state.isApplying) {
  266. tooltipForWhyApplyIsDisabled = I18n.t('Applying, please be patient')
  267. }
  268. return (
  269. <div id="main" className="ic-Layout-columns">
  270. { this.props.useHighContrast &&
  271. <div role="alert" className="ic-flash-static ic-flash-error">
  272. <h4 className="ic-flash__headline">
  273. <div className="ic-flash__icon" aria-hidden="true">
  274. <i className="icon-warning"></i>
  275. </div>
  276. {I18n.t('You will not be able to preview your changes')}
  277. </h4>
  278. <p
  279. className="ic-flash__text"
  280. dangerouslySetInnerHTML={{
  281. __html:
  282. I18n.t('To preview Theme Editor branding, you will need to *turn off High Contrast UI*.', {
  283. wrappers: ['<a href="/profile/settings">$1</a>']
  284. })
  285. }}
  286. />
  287. </div>
  288. }
  289. <form
  290. ref="ThemeEditorForm"
  291. onSubmit={preventDefault(this.handleFormSubmit)}
  292. encType="multipart/form-data"
  293. acceptCharset="UTF-8"
  294. action="'/accounts/'+this.props.accountID+'/brand_configs"
  295. method="POST"
  296. className="Theme__container">
  297. <input name="utf8" type="hidden" value="✓" />
  298. <input name="authenticity_token" type="hidden" value={$.cookie('_csrf_token')} />
  299. <header className={`Theme__header ${!this.props.hasUnsavedChanges && 'Theme__header--is-active-theme'}`}>
  300. <h1 className="screenreader-only">{I18n.t('Theme Editor')}</h1>
  301. <div className="Theme__header-layout">
  302. <div className="Theme__header-primary">
  303. {/* HIDE THIS BUTTON IF THEME IS ACTIVE THEME */}
  304. {/* IF CHANGES ARE MADE, THIS BUTTON IS DISABLED UNTIL THEY ARE SAVED */}
  305. { this.props.hasUnsavedChanges ? (
  306. <span
  307. data-tooltip="right"
  308. title={tooltipForWhyApplyIsDisabled}
  309. >
  310. <button
  311. type="button"
  312. className="Button Button--success"
  313. disabled={!!tooltipForWhyApplyIsDisabled}
  314. onClick={this.handleApplyClicked}
  315. >
  316. {I18n.t('Apply theme')}
  317. </button>
  318. </span>
  319. ) : null}
  320. <h2 className="Theme__header-theme-name">
  321. {this.props.hasUnsavedChanges || this.somethingHasChanged() ?
  322. null
  323. :
  324. <i className="icon-check"/>
  325. }
  326. &nbsp;&nbsp;
  327. {this.state.sharedBrandConfigBeingEdited ? this.state.sharedBrandConfigBeingEdited.name : null }
  328. </h2>
  329. </div>
  330. <div className="Theme__header-secondary">
  331. <SaveThemeButton
  332. userNeedsToPreviewFirst={this.somethingHasChanged()}
  333. sharedBrandConfigBeingEdited={this.state.sharedBrandConfigBeingEdited}
  334. accountID={this.props.accountID}
  335. brandConfigMd5={this.props.brandConfig.md5}
  336. onSave={this.updateSharedBrandConfigBeingEdited}
  337. />
  338. &nbsp;
  339. <button type="button" className="Button" onClick={this.handleCancelClicked}>
  340. {I18n.t('Exit')}
  341. </button>
  342. </div>
  343. </div>
  344. </header>
  345. <div className={`Theme__layout ${!this.props.hasUnsavedChanges && 'Theme__layout--is-active-theme'}`} >
  346. <div className="Theme__editor">
  347. <div className="Theme__editor-tabs">
  348. { this.renderTabInputs() }
  349. <div className="Theme__editor-tab-label-layout">
  350. { this.renderTabLabels() }
  351. </div>
  352. <div id="te-editor-panel" className="Theme__editor-tabs_panel">
  353. <ThemeEditorAccordion
  354. variableSchema={this.props.variableSchema}
  355. brandConfigVariables={this.props.brandConfig.variables}
  356. getDisplayValue={this.getDisplayValue}
  357. changedValues={this.state.changedValues}
  358. changeSomething={this.changeSomething}
  359. />
  360. </div>
  361. { this.props.allowGlobalIncludes ?
  362. <div id="te-upload-panel" className="Theme__editor-tabs_panel">
  363. <div className="Theme__editor-upload-overrides">
  364. <div className="Theme__editor-upload-warning">
  365. <div className="Theme__editor-upload-warning_icon">
  366. <i className="icon-warning" />
  367. </div>
  368. <div>
  369. <p className="Theme__editor-upload-warning_text-emphasis">
  370. {I18n.t('Custom CSS and Javascript may cause accessibility issues or conflicts with future Canvas updates!')}
  371. </p>
  372. <p
  373. dangerouslySetInnerHTML={{
  374. __html:
  375. I18n.t('Before implementing custom CSS or Javascript, please refer to *our documentation*.', {
  376. wrappers: ['<a href="https://community.canvaslms.com/docs/DOC-3010" target="_blank">$1</a>']
  377. })
  378. }}
  379. />
  380. </div>
  381. </div>
  382. <div className="Theme__editor-upload-overrides_header">
  383. { I18n.t('File(s) will be included on all pages in the Canvas desktop application.') }
  384. </div>
  385. <div className="Theme__editor-upload-overrides_form">
  386. <ThemeEditorFileUpload
  387. label={I18n.t('CSS file')}
  388. accept=".css"
  389. name="css_overrides"
  390. currentValue={this.props.brandConfig.css_overrides}
  391. userInput={this.state.changedValues.css_overrides}
  392. onChange={this.changeSomething.bind(null, 'css_overrides')}
  393. />
  394. <ThemeEditorFileUpload
  395. label={I18n.t('JavaScript file')}
  396. accept=".js"
  397. name="js_overrides"
  398. currentValue={this.props.brandConfig.js_overrides}
  399. userInput={this.state.changedValues.js_overrides}
  400. onChange={this.changeSomething.bind(null, 'js_overrides')}
  401. />
  402. </div>
  403. </div>
  404. <div className="Theme__editor-upload-overrides">
  405. <div className="Theme__editor-upload-overrides_header">
  406. { I18n.t('File(s) will be included when user content is displayed within the Canvas iOS or Android apps, and in third-party apps built on our API.') }
  407. </div>
  408. <div className="Theme__editor-upload-overrides_form">
  409. <ThemeEditorFileUpload
  410. label={I18n.t('Mobile app CSS file')}
  411. accept=".css"
  412. name="mobile_css_overrides"
  413. currentValue={this.props.brandConfig.mobile_css_overrides}
  414. userInput={this.state.changedValues.mobile_css_overrides}
  415. onChange={this.changeSomething.bind(null, 'mobile_css_overrides')}
  416. />
  417. <ThemeEditorFileUpload
  418. label={I18n.t('Mobile app JavaScript file')}
  419. accept=".js"
  420. name="mobile_js_overrides"
  421. currentValue={this.props.brandConfig.mobile_js_overrides}
  422. userInput={this.state.changedValues.mobile_js_overrides}
  423. onChange={this.changeSomething.bind(null, 'mobile_js_overrides')}
  424. />
  425. </div>
  426. </div>
  427. </div>
  428. : null}
  429. </div>
  430. </div>
  431. <div className="Theme__preview">
  432. { this.somethingHasChanged() ?
  433. <div className="Theme__preview-overlay">
  434. <button
  435. type="submit"
  436. className="Button Button--primary Button--large"
  437. disabled={this.invalidForm()}>
  438. <i className="icon-refresh" />
  439. <span className="Theme__preview-button-text">
  440. {I18n.t('Preview Your Changes')}
  441. </span>
  442. </button>
  443. </div>
  444. : null }
  445. <iframe id="previewIframe" ref="previewIframe" src={"/accounts/"+this.props.accountID+"/theme-preview/?editing_brand_config=1"} title={I18n.t('Preview')} />
  446. </div>
  447. </div>
  448. {/* Workaround to avoid corrupted XHR2 request body in IE10 / IE11,
  449. needs to be last element in <form>. see:
  450. https://blog.yorkxin.org/posts/2014/02/06/ajax-with-formdata-is-broken-on-ie10-ie11/ */}
  451. <input type="hidden" name="_workaround_for_IE_10_and_11_formdata_bug" />
  452. </form>
  453. <ThemeEditorModal
  454. showProgressModal={this.state.showProgressModal}
  455. showSubAccountProgress={this.state.showSubAccountProgress}
  456. activeSubAccountProgresses={this.state.activeSubAccountProgresses}
  457. progress={this.state.progress}
  458. />
  459. </div>
  460. )
  461. }
  462. })