1319 lines
41 KiB
JavaScript
1319 lines
41 KiB
JavaScript
/*
|
|
* Copyright (C) 2011 - present Instructure, Inc.
|
|
*
|
|
* This file is part of Canvas.
|
|
*
|
|
* Canvas is free software: you can redistribute it and/or modify it under
|
|
* the terms of the GNU Affero General Public License as published by the Free
|
|
* Software Foundation, version 3 of the License.
|
|
*
|
|
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
* details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License along
|
|
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {send} from 'jsx/shared/rce/RceCommandShim'
|
|
import INST from './INST'
|
|
import I18n from 'i18n!instructure'
|
|
import $ from 'jquery'
|
|
import _ from 'underscore'
|
|
import FakeXHR from 'compiled/xhr/FakeXHR'
|
|
import authenticity_token from 'compiled/behaviors/authenticity_token'
|
|
import htmlEscape from './str/htmlEscape'
|
|
import './jquery.ajaxJSON' /* ajaxJSON, defaultAjaxError */
|
|
import './jquery.disableWhileLoading'
|
|
import {trackEvent} from 'jquery.google-analytics'
|
|
import './jquery.instructure_date_and_time' /* date_field, time_field, datetime_field */
|
|
import './jquery.instructure_misc_helpers' /* /\$\.uniq/ */
|
|
import 'compiled/jquery.rails_flash_notifications'
|
|
import './vendor/jquery.scrollTo'
|
|
|
|
// Intercepts the default form submission process. Uses the form tag's
|
|
// current action and method attributes to know where to submit to.
|
|
// NOTE: because IE only allows form methods to be "POST" or "GET",
|
|
// we can't set the form to "PUT" or "DELETE" as cleanly as we'd like.
|
|
// I'm following the Rails convention, and adding a _method input
|
|
// if one doesn't already exist, and then setting that input's value
|
|
// to the method type. formSubmit checks this value first, then
|
|
// the checks form.data('method') and finally the form's method
|
|
// attribute.
|
|
// Options:
|
|
// validation options -- formSubmit calls validateForm before
|
|
// submitting, so you can pass in validation options to
|
|
// formSubmit and it will validate first.
|
|
// noSubmit: Option to call everything normally until the actual request,
|
|
// then just calls success with the processed data
|
|
// processData: formSubmit by default just calls $.fn.getFormData.
|
|
// if you need additional data in the form submission, add
|
|
// it here and return the new object.
|
|
// beforeSubmit: called right before the request is sent. Useful
|
|
// for hiding forms, adding ajax loader icons, etc.
|
|
// success: called on success
|
|
// error: Called on error. The response from the server will also
|
|
// be used to populate error boxes on form elements. If the form
|
|
// no longer exists and no error method is provided, the default
|
|
// error method for Instructure is called... actually
|
|
// it will always be called when you're in development environment.
|
|
// fileUpload: Either a boolean or a function. If it is true or
|
|
// returns true, then it's assumed this is a file upload request
|
|
// and we use the iframe trick to submit the form.
|
|
// onSubmit: A callback which will receive 1. a deferred object
|
|
// encompassing the request(s) triggered by the submit action and 2. the
|
|
// formData being posted
|
|
$.fn.formSubmit = function(options) {
|
|
$(this).markRequired(options)
|
|
this.submit(function(event) {
|
|
const $form = $(this) // this is to handle if bind to a template element, then it gets cloned the original this would not be the same as the this inside of here.
|
|
// disableWhileLoading might need to wrap this, so we don't want to modify the original
|
|
let onSubmit = options.onSubmit
|
|
if ($form.data('submitting')) {
|
|
return
|
|
}
|
|
$form.data('trigger_event', event)
|
|
$form.hideErrors()
|
|
let error = false
|
|
const result = $form.validateForm(options)
|
|
if (!result) {
|
|
return false
|
|
}
|
|
// retrieve form data
|
|
let formData = $form.getFormData(options)
|
|
if (options.processData && $.isFunction(options.processData)) {
|
|
let newData = null
|
|
try {
|
|
newData = options.processData.call($form, formData)
|
|
} catch (e) {
|
|
error = e
|
|
if (INST && INST.environment !== 'production') throw error
|
|
}
|
|
if (newData === false) {
|
|
return false
|
|
} else if (newData) {
|
|
formData = newData
|
|
}
|
|
}
|
|
let method =
|
|
$form.data('method') || $form.find("input[name='_method']").val() || $form.attr('method'),
|
|
formId = $form.attr('id'),
|
|
action = $form.attr('action'),
|
|
submitParam = null
|
|
if ($.isFunction(options.beforeSubmit)) {
|
|
submitParam = null
|
|
try {
|
|
submitParam = options.beforeSubmit.call($form, formData)
|
|
} catch (e) {
|
|
error = e
|
|
if (INST && INST.environment !== 'production') throw error
|
|
}
|
|
if (submitParam === false) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (options.disableWhileLoading) {
|
|
const oldOnSubmit = onSubmit
|
|
onSubmit = function(loadingPromise) {
|
|
if (options.disableWhileLoading === 'spin_on_success') {
|
|
// turn it into a false promise, i.e. never resolve
|
|
const origPromise = loadingPromise
|
|
loadingPromise = $.Deferred()
|
|
origPromise.fail(() => {
|
|
loadingPromise.reject()
|
|
})
|
|
}
|
|
$form.disableWhileLoading(loadingPromise)
|
|
if (oldOnSubmit) oldOnSubmit.apply(this, arguments)
|
|
}
|
|
}
|
|
|
|
if (onSubmit) {
|
|
var loadingPromise = $.Deferred(),
|
|
oldHandlers = {}
|
|
onSubmit.call(this, loadingPromise, formData)
|
|
$.each(['success', 'error'], function(i, successOrError) {
|
|
oldHandlers[successOrError] = options[successOrError]
|
|
options[successOrError] = function() {
|
|
loadingPromise[successOrError === 'success' ? 'resolve' : 'reject'].apply(
|
|
loadingPromise,
|
|
arguments
|
|
)
|
|
if ($.isFunction(oldHandlers[successOrError])) {
|
|
return oldHandlers[successOrError].apply(this, arguments)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
let doUploadFile = options.fileUpload
|
|
if ($.isFunction(options.fileUpload)) {
|
|
try {
|
|
doUploadFile = options.fileUpload.call($form, formData)
|
|
} catch (e) {
|
|
error = e
|
|
}
|
|
}
|
|
if (doUploadFile && options.fileUploadOptions) {
|
|
$.extend(options, options.fileUploadOptions)
|
|
}
|
|
if ($form.attr('action')) {
|
|
action = $form.attr('action')
|
|
}
|
|
if (error && !options.preventDegradeToFormSubmit) {
|
|
if (loadingPromise) loadingPromise.reject()
|
|
return
|
|
}
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
const xhrSuccess = function(data, request) {
|
|
if ($.isFunction(options.success)) {
|
|
options.success.call($form, data, submitParam, request)
|
|
}
|
|
}
|
|
const xhrError = function(data, request) {
|
|
let $formObj = $form,
|
|
needValidForm = true
|
|
if ($.isFunction(options.error)) {
|
|
const $obj = options.error.call($form, data.errors || data, submitParam, request) // data is null?
|
|
if ($obj) $formObj = $obj
|
|
needValidForm = false
|
|
}
|
|
if ($formObj.parents('html').get(0) == $('html').get(0) && options.formErrors !== false) {
|
|
if ($.isFunction(options.errorFormatter)) data = options.errorFormatter(data.errors || data)
|
|
$formObj.formErrors(data, options)
|
|
} else if (needValidForm) {
|
|
$.ajaxJSON.unhandledXHRs.push(request)
|
|
}
|
|
}
|
|
|
|
if (options.noSubmit) {
|
|
xhrSuccess.call(this, formData, {})
|
|
} else if (doUploadFile && options.preparedFileUpload && options.context_code) {
|
|
$.ajaxJSONPreparedFiles.call(this, {
|
|
handle_files: options.upload_only ? xhrSuccess : options.handle_files,
|
|
single_file: options.singleFile,
|
|
context_code: $.isFunction(options.context_code)
|
|
? options.context_code.call($form)
|
|
: options.context_code,
|
|
asset_string: options.asset_string,
|
|
intent: options.intent,
|
|
folder_id: $.isFunction(options.folder_id)
|
|
? options.folder_id.call($form)
|
|
: options.folder_id,
|
|
file_elements: $form.find("input[type='file']:visible"),
|
|
files: $.isFunction(options.files) ? options.files.call($form) : options.files,
|
|
url: options.upload_only ? null : action,
|
|
method: options.method,
|
|
uploadDataUrl: options.uploadDataUrl,
|
|
formData,
|
|
formDataTarget: options.formDataTarget,
|
|
success: xhrSuccess,
|
|
error: xhrError
|
|
})
|
|
} else if (doUploadFile && $.handlesHTML5Files && $form.hasClass('handlingHTML5Files')) {
|
|
const args = $.extend({}, formData)
|
|
$form.find("input[type='file']").each(function() {
|
|
const $input = $(this),
|
|
file_list = $input.data('file_list')
|
|
if (file_list && file_list instanceof FileList) {
|
|
args[$input.attr('name')] = file_list
|
|
}
|
|
})
|
|
$.toMultipartForm(args, params => {
|
|
$.sendFormAsBinary({
|
|
url: action,
|
|
body: params.body,
|
|
content_type: params.content_type,
|
|
form_data: params.form_data,
|
|
method,
|
|
success: xhrSuccess,
|
|
error: xhrError
|
|
})
|
|
})
|
|
} else if (doUploadFile) {
|
|
const id = _.uniqueId(formId + '_'),
|
|
$frame = $(
|
|
"<div style='display: none;' id='box_" +
|
|
htmlEscape(id) +
|
|
"'><iframe id='frame_" +
|
|
htmlEscape(id) +
|
|
"' name='frame_" +
|
|
htmlEscape(id) +
|
|
"' src='about:blank' onload='$(\"#frame_" +
|
|
htmlEscape(id) +
|
|
'").triggerHandler("form_response_loaded");\'></iframe>'
|
|
)
|
|
.appendTo('body')
|
|
.find('#frame_' + id),
|
|
formMethod = method,
|
|
priorTarget = $form.attr('target'),
|
|
priorEnctype = $form.attr('ENCTYPE'),
|
|
request = new FakeXHR()
|
|
$form.attr({
|
|
method,
|
|
action,
|
|
ENCTYPE: 'multipart/form-data',
|
|
encoding: 'multipart/form-data',
|
|
target: 'frame_' + id
|
|
})
|
|
// TODO: remove me once we stop proxying file uploads and/or
|
|
// explicitly calling $.ajaxJSONFiles
|
|
if (options.onlyGivenParameters) {
|
|
$form.find("input[name='_method']").remove()
|
|
$form.find("input[name='authenticity_token']").remove()
|
|
}
|
|
$.ajaxJSON.storeRequest(request, action, method, formData)
|
|
|
|
$frame.bind('form_response_loaded', function() {
|
|
const i = $frame[0],
|
|
doc = i.contentDocument || i.contentWindow.document
|
|
if (doc.location.href == 'about:blank') return
|
|
|
|
request.setResponse($(doc).text())
|
|
if ($.httpSuccess(request)) {
|
|
xhrSuccess.call(this, request.response, request)
|
|
} else {
|
|
xhrError.call(this, request.response, request)
|
|
$.fn.defaultAjaxError.func.call($.fn.defaultAjaxError.object, null, request, '0', null)
|
|
}
|
|
setTimeout(() => {
|
|
$form.attr({
|
|
ENCTYPE: priorEnctype,
|
|
encoding: priorEnctype,
|
|
target: priorTarget
|
|
})
|
|
$('#box_' + id).remove()
|
|
}, 5000)
|
|
})
|
|
$form
|
|
.data('submitting', true)
|
|
.submit()
|
|
.data('submitting', false)
|
|
} else {
|
|
$.ajaxJSON(action, method, formData, xhrSuccess, xhrError)
|
|
}
|
|
})
|
|
return this
|
|
}
|
|
|
|
$.ajaxJSONPreparedFiles = function(options) {
|
|
const list = []
|
|
const $this = this
|
|
const pre_list = options.files || options.file_elements || []
|
|
for (let idx = 0; idx < pre_list.length; idx++) {
|
|
const item = pre_list[idx]
|
|
item.name = (item.value || item.name).split(/(\/|\\)/).pop()
|
|
list.push(item)
|
|
}
|
|
const attachments = []
|
|
const ready = function() {
|
|
let data = options.formDataTarget == 'url' ? options.formData : {}
|
|
if (options.handle_files) {
|
|
let result = attachments
|
|
if (options.single_file) {
|
|
result = attachments[0]
|
|
}
|
|
data = options.handle_files.call(this, result, data)
|
|
}
|
|
if (options.url && options.success && data !== false) {
|
|
$.ajaxJSON(options.url, options.method, data, options.success, options.error)
|
|
}
|
|
}
|
|
const uploadUrl = options.uploadDataUrl || '/files/pending'
|
|
const uploadFile = function(parameters, file) {
|
|
// we want the s3 success url in the preflight response, not embedded in
|
|
// the upload_url. the latter doesn't work with the new ajax mechanism
|
|
parameters.no_redirect = true
|
|
file = file.files[0]
|
|
import('jsx/shared/upload_file')
|
|
.then(({uploadFile}) =>
|
|
uploadFile(uploadUrl, parameters, file, undefined, options.onProgress)
|
|
)
|
|
.then(data => {
|
|
attachments.push(data)
|
|
next.call($this)
|
|
})
|
|
.catch(error => {
|
|
;(options.upload_error || options.error).call($this, error)
|
|
})
|
|
}
|
|
var next = function() {
|
|
const item = list.shift()
|
|
if (item) {
|
|
const attrs = $.extend(
|
|
{
|
|
name: item.name,
|
|
on_duplicate: 'rename',
|
|
no_redirect: true,
|
|
'attachment[folder_id]': options.folder_id,
|
|
'attachment[intent]': options.intent,
|
|
'attachment[asset_string]': options.asset_string,
|
|
'attachment[filename]': item.name,
|
|
'attachment[size]': item.size,
|
|
'attachment[context_code]': options.context_code,
|
|
'attachment[on_duplicate]': 'rename'
|
|
},
|
|
options.formDataTarget == 'uploadDataUrl' ? options.formData : {}
|
|
)
|
|
if (item.files.length === 1) {
|
|
attrs['attachment[content_type]'] = item.files[0].type
|
|
}
|
|
uploadFile.call($this, attrs, item)
|
|
} else {
|
|
ready.call($this)
|
|
}
|
|
}
|
|
next.call($this)
|
|
}
|
|
|
|
$.ajaxJSONFiles = function(url, submit_type, formData, files, success, error, options) {
|
|
const $newForm = $(document.createElement('form'))
|
|
$newForm.attr('action', url).attr('method', submit_type)
|
|
// TODO: remove me once we stop proxying file uploads
|
|
formData.authenticity_token = authenticity_token()
|
|
const fileNames = {}
|
|
files.each(function() {
|
|
fileNames[$(this).attr('name')] = true
|
|
})
|
|
for (const idx in formData) {
|
|
if (!fileNames[idx]) {
|
|
const $input = $(document.createElement('input'))
|
|
$input
|
|
.attr('type', 'hidden')
|
|
.attr('name', idx)
|
|
.attr('value', formData[idx])
|
|
$newForm.append($input)
|
|
}
|
|
}
|
|
files.each(function() {
|
|
const $newFile = $(this).clone(true)
|
|
$(this).after($newFile)
|
|
$newForm.append($(this))
|
|
$(this).removeAttr('id')
|
|
})
|
|
$('body').append($newForm.hide())
|
|
$newForm.formSubmit({
|
|
fileUpload: true,
|
|
success,
|
|
onlyGivenParameters: options ? options.onlyGivenParameters : false,
|
|
error
|
|
})
|
|
$newForm.submit()
|
|
}
|
|
|
|
$.handlesHTML5Files = !!(window.File && window.FileReader && window.FileList && XMLHttpRequest)
|
|
if ($.handlesHTML5Files) {
|
|
$("input[type='file']").live('change', function(event) {
|
|
const file_list = this.files
|
|
if (file_list) {
|
|
$(this).data('file_list', file_list)
|
|
$(this)
|
|
.parents('form')
|
|
.addClass('handlingHTML5Files')
|
|
}
|
|
})
|
|
}
|
|
$.ajaxFileUpload = function(options) {
|
|
// TODO: remove me once we stop proxying file uploads
|
|
options.data.authenticity_token = authenticity_token()
|
|
$.toMultipartForm(options.data, function(params) {
|
|
$.sendFormAsBinary(
|
|
{
|
|
url: options.url,
|
|
body: params.body,
|
|
content_type: params.content_type,
|
|
form_data: params.form_data,
|
|
method: options.method,
|
|
success(data) {
|
|
if (options.success && $.isFunction(options.success)) {
|
|
options.success.call(this, data)
|
|
}
|
|
},
|
|
progress(data) {
|
|
if (options.progress && $.isFunction(options.progress)) {
|
|
options.progress.call(this, data)
|
|
}
|
|
},
|
|
error(data, request) {
|
|
// error function
|
|
if (options.error && $.isFunction(options.error)) {
|
|
data = data || {}
|
|
const $obj = options.error.call(this, data.errors || data)
|
|
} else {
|
|
$.ajaxJSON.unhandledXHRs.push(request)
|
|
}
|
|
}
|
|
},
|
|
options.binary === false
|
|
)
|
|
})
|
|
}
|
|
|
|
$.httpSuccess = function(r) {
|
|
try {
|
|
return (
|
|
(!r.status && location.protocol == 'file:') ||
|
|
(r.status >= 200 && r.status < 300) ||
|
|
r.status == 304 ||
|
|
(jQuery.browser.safari && r.status == undefined)
|
|
)
|
|
} catch (e) {}
|
|
|
|
return false
|
|
}
|
|
|
|
$.sendFormAsBinary = function(options, not_binary) {
|
|
const body = options.body
|
|
const url = options.url
|
|
const method = options.method
|
|
const xhr = new XMLHttpRequest()
|
|
if (xhr.upload) {
|
|
xhr.upload.addEventListener(
|
|
'progress',
|
|
function(event) {
|
|
if (options.progress && $.isFunction(options.progress)) {
|
|
options.progress.call(this, event)
|
|
}
|
|
},
|
|
false
|
|
)
|
|
xhr.upload.addEventListener(
|
|
'error',
|
|
function(event) {
|
|
if (options.error && $.isFunction(options.error)) {
|
|
options.error.call(this, 'uploading error', xhr, event)
|
|
}
|
|
},
|
|
false
|
|
)
|
|
xhr.upload.addEventListener(
|
|
'abort',
|
|
function(event) {
|
|
if (options.error && $.isFunction(options.error)) {
|
|
options.error.call(this, 'aborted by the user', xhr, event)
|
|
}
|
|
},
|
|
false
|
|
)
|
|
}
|
|
xhr.onreadystatechange = function(event) {
|
|
if (xhr.readyState == 4) {
|
|
let json = null
|
|
try {
|
|
json = $.parseJSON(xhr.responseText)
|
|
} catch (e) {}
|
|
if ($.httpSuccess(xhr)) {
|
|
if (json && !json.errors) {
|
|
if (options.success && $.isFunction(options.success)) {
|
|
options.success.call(this, json, xhr, event)
|
|
}
|
|
} else if (options.error && $.isFunction(options.error)) {
|
|
options.error.call(this, json || xhr.responseText, xhr, event)
|
|
}
|
|
} else if (options.error && $.isFunction(options.error)) {
|
|
options.error.call(this, json || xhr.responseText, xhr, event)
|
|
}
|
|
}
|
|
}
|
|
xhr.open(method, url)
|
|
xhr.setRequestHeader('Accept', 'application/json, text/javascript, */*')
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest')
|
|
if (options.form_data) {
|
|
xhr.send(options.form_data)
|
|
} else {
|
|
xhr.overrideMimeType(options.content_type || 'multipart/form-data')
|
|
|
|
xhr.setRequestHeader('Content-Type', options.content_type || 'multipart/form-data')
|
|
xhr.setRequestHeader('Content-Length', body.length)
|
|
if (not_binary) {
|
|
xhr.send(body)
|
|
} else if (!xhr.sendAsBinary) {
|
|
console.log('xhr.sendAsBinary not supported')
|
|
} else {
|
|
xhr.sendAsBinary(body)
|
|
}
|
|
}
|
|
}
|
|
|
|
$.fileData = function(file_object) {
|
|
return {
|
|
name: file_object.name || file_object.fileName,
|
|
size: file_object.size || file_object.fileSize,
|
|
type: file_object.type,
|
|
forced_type: file_object.type || 'application/octet-stream'
|
|
}
|
|
}
|
|
|
|
$.toMultipartForm = function(params, callback) {
|
|
let boundary = '-----AaB03x' + _.uniqueId(),
|
|
result = {content_type: 'multipart/form-data; boundary=' + boundary},
|
|
body = '--' + boundary + '\r\n',
|
|
paramsList = [],
|
|
hasFakeFile = false
|
|
|
|
for (var idx in params) {
|
|
paramsList.push([idx, params[idx]])
|
|
if (params[idx] && params[idx].fake_file) {
|
|
hasFakeFile = true
|
|
}
|
|
}
|
|
if (window.FormData && !hasFakeFile) {
|
|
const fd = new FormData()
|
|
// xsslint xssable.receiver.whitelist fd
|
|
for (var idx in params) {
|
|
let param = params[idx]
|
|
if (window.FileList && param instanceof FileList) {
|
|
param = param[0]
|
|
}
|
|
if (param instanceof Array) {
|
|
for (let i = 0; i < param.length; i++) {
|
|
fd.append(idx, param[i])
|
|
}
|
|
} else {
|
|
fd.append(idx, param)
|
|
}
|
|
}
|
|
result.form_data = fd
|
|
callback(result)
|
|
return
|
|
}
|
|
function sanitizeQuotedString(text) {
|
|
return text.replace(/\"/g, '')
|
|
}
|
|
function finished() {
|
|
result.body = body.substring(0, body.length - 2) + '--'
|
|
callback(result)
|
|
}
|
|
function nextParam() {
|
|
if (paramsList.length === 0) {
|
|
finished()
|
|
return
|
|
}
|
|
let param = paramsList.shift(),
|
|
name = param[0],
|
|
value = param[1]
|
|
|
|
if (window.FileList && value instanceof FileList) {
|
|
value = value[0]
|
|
}
|
|
if (window.FileList && value instanceof FileList) {
|
|
const innerBoundary = '-----BbC04y' + _.uniqueId(),
|
|
fileList = []
|
|
body +=
|
|
'Content-Disposition: form-data; name="' +
|
|
sanitizeQuotedString(name) +
|
|
'\r\n' +
|
|
'Content-Type: multipart/mixed; boundary=' +
|
|
innerBoundary +
|
|
'\r\n\r\n'
|
|
for (const jdx in value) {
|
|
fileList.push(value)
|
|
}
|
|
const finishedFiles = function() {
|
|
body += '--' + innerBoundary + '--\r\n' + '--' + boundary + '\r\n'
|
|
nextParam()
|
|
}
|
|
var nextFile = function() {
|
|
if (fileList.length === 0) {
|
|
finishedFiles()
|
|
return
|
|
}
|
|
const file = fileList.shift(),
|
|
fileData = $.fileData(file),
|
|
reader = new FileReader()
|
|
|
|
reader.onloadend = function() {
|
|
body +=
|
|
'--' +
|
|
innerBoundary +
|
|
'\r\n' +
|
|
'Content-Disposition: file; filename="' +
|
|
sanitizeQuotedString(fileData.name) +
|
|
'"\r\n' +
|
|
'Content-Type: ' +
|
|
fileData.forced_type +
|
|
'\r\n' +
|
|
'Content-Transfer-Encoding: binary\r\n' +
|
|
'\r\n' +
|
|
reader.result
|
|
nextFile()
|
|
}
|
|
reader.readAsBinaryString(file)
|
|
}
|
|
nextFile()
|
|
} else if (window.File && value instanceof File) {
|
|
const fileData = $.fileData(value),
|
|
reader = new FileReader()
|
|
reader.onloadend = function() {
|
|
body +=
|
|
'Content-Disposition: file; name="' +
|
|
sanitizeQuotedString(name) +
|
|
'"; filename="' +
|
|
fileData.name +
|
|
'"\r\n' +
|
|
'Content-Type: ' +
|
|
fileData.forced_type +
|
|
'\r\n' +
|
|
'Content-Transfer-Encoding: binary\r\n' +
|
|
'\r\n' +
|
|
reader.result +
|
|
'\r\n--' +
|
|
boundary +
|
|
'\r\n'
|
|
nextParam()
|
|
}
|
|
reader.readAsBinaryString(value)
|
|
} else if (value && value.fake_file) {
|
|
body +=
|
|
'Content-Disposition: file; name="' +
|
|
sanitizeQuotedString(name) +
|
|
'"; filename="' +
|
|
value.name +
|
|
'"\r\n' +
|
|
'Content-Type: ' +
|
|
value.content_type +
|
|
'\r\n' +
|
|
'Content-Transfer-Encoding: binary\r\n' +
|
|
'\r\n' +
|
|
value.content +
|
|
'\r\n--' +
|
|
boundary +
|
|
'\r\n'
|
|
nextParam()
|
|
} else {
|
|
body +=
|
|
'Content-Disposition: form-data; name="' +
|
|
sanitizeQuotedString(name) +
|
|
'"\r\n' +
|
|
'\r\n' +
|
|
(value || '').toString() +
|
|
'\r\n' +
|
|
'--' +
|
|
boundary +
|
|
'\r\n'
|
|
nextParam()
|
|
}
|
|
}
|
|
nextParam()
|
|
}
|
|
|
|
// Fills the selected form object with the collected data values.
|
|
// Handles select boxes, check boxes and radios as well.
|
|
// object_name: Name of the object form form elements. So if
|
|
// I provide the data {good: true, bad: false} and
|
|
// options.object_name == "assignment", then it will fill
|
|
// form elements "good" and "assignment[good]" with true
|
|
// and "bad" and "assignment[bad]" with false.
|
|
// call_change: Specifies whether to trigger the onchange event
|
|
// for form elements that are set.
|
|
$.fn.fillFormData = function(data, opts) {
|
|
if (this.length) {
|
|
data = data || []
|
|
const options = $.extend({}, $.fn.fillFormData.defaults, opts)
|
|
|
|
if (options.object_name) {
|
|
data = $._addObjectName(data, options.object_name, true)
|
|
}
|
|
this.find(':input').each(function() {
|
|
const $obj = $(this)
|
|
const name = $obj.attr('name')
|
|
const inputType = $obj.attr('type')
|
|
if (name in data) {
|
|
if (name) {
|
|
if (inputType == 'hidden' && $obj.next('input:checkbox').attr('name') == name) {
|
|
// do nothing
|
|
} else if (inputType != 'checkbox' && inputType != 'radio') {
|
|
let val = data[name]
|
|
if (typeof val === 'undefined' || val === null) {
|
|
val = ''
|
|
}
|
|
$obj.val(val.toString())
|
|
} else if ($obj.val() == data[name]) {
|
|
$obj.attr('checked', true)
|
|
} else {
|
|
$obj.attr('checked', false)
|
|
}
|
|
if ($obj && $obj.change && options.call_change) {
|
|
$obj.change()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
return this
|
|
}
|
|
$.fn.fillFormData.defaults = {object_name: null, call_change: true}
|
|
// Pulls out the selected and entered values on a given form.
|
|
// object_name: see fillFormData above. If object_name == "assignment"
|
|
// and the form has an element named "assignment[good]" then
|
|
// the result will include both "assignment[good]" and "good"
|
|
// values: specify the set of values to retrieve (if they exist)
|
|
// by default retrieves all it can find.
|
|
$.fn.getFormData = function(options) {
|
|
var options = $.extend({}, $.fn.getFormData.defaults, options),
|
|
result = {},
|
|
$form = this
|
|
$form
|
|
.find(':input')
|
|
.not(':button')
|
|
.each(function() {
|
|
const $input = $(this),
|
|
inputType = $input.attr('type')
|
|
if ((inputType == 'radio' || inputType == 'checkbox') && !$input.attr('checked')) return
|
|
let val = $input.val()
|
|
if ($input.hasClass('datetime_field_enabled')) {
|
|
val = $input.data('iso8601')
|
|
}
|
|
try {
|
|
if ($input.data('rich_text')) {
|
|
val = send($input, 'get_code', false)
|
|
}
|
|
} catch (e) {}
|
|
const attr = $input.prop('name') || ''
|
|
const multiValue = attr.match(/\[\]$/)
|
|
if (inputType == 'hidden' && !multiValue) {
|
|
if (
|
|
$form
|
|
.find("[name='" + attr + "']")
|
|
.filter(
|
|
'textarea,:radio:checked,:checkbox:checked,:text,:password,select,:hidden'
|
|
)[0] != $input[0]
|
|
) {
|
|
return
|
|
}
|
|
}
|
|
if (
|
|
attr &&
|
|
attr !== '' &&
|
|
(inputType == 'checkbox' || typeof result[attr] === 'undefined' || multiValue)
|
|
) {
|
|
if (!options.values || $.inArray(attr, options.values) != -1) {
|
|
if (multiValue) {
|
|
result[attr] = result[attr] || []
|
|
result[attr].push(val)
|
|
} else {
|
|
result[attr] = val
|
|
}
|
|
}
|
|
}
|
|
const lastAttr = attr
|
|
})
|
|
if (options.object_name) {
|
|
result = $._stripObjectName(result, options.object_name, true)
|
|
}
|
|
return result
|
|
}
|
|
$.fn.getFormData.defaults = {object_name: null}
|
|
|
|
// Used internally to prepend object_name to data key names
|
|
// Supports nested names, e.g.
|
|
// assignment[id] => discussion_topic[assignment][id]
|
|
$._addObjectName = function(data, object_name, include_original) {
|
|
if (!data) {
|
|
return data
|
|
}
|
|
let new_result = {}
|
|
if (data instanceof Array) {
|
|
new_result = []
|
|
}
|
|
let original_name, new_name, first_bracket
|
|
|
|
for (const i in data) {
|
|
if (data instanceof Array) {
|
|
original_name = data[i]
|
|
} else {
|
|
original_name = i
|
|
}
|
|
|
|
first_bracket = original_name.indexOf('[')
|
|
if (first_bracket >= 0) {
|
|
new_name =
|
|
object_name +
|
|
'[' +
|
|
original_name.substring(0, first_bracket) +
|
|
']' +
|
|
original_name.substring(first_bracket)
|
|
} else {
|
|
new_name = object_name + '[' + original_name + ']'
|
|
}
|
|
if (typeof original_name === 'string' && original_name.indexOf('=') === 0) {
|
|
new_name = original_name.substring(1)
|
|
original_name = new_name
|
|
}
|
|
|
|
if (data instanceof Array) {
|
|
new_result.push(new_name)
|
|
if (include_original) {
|
|
new_result.push(original_name)
|
|
}
|
|
} else {
|
|
new_result[new_name] = data[i]
|
|
if (include_original) {
|
|
new_result[original_name] = data[i]
|
|
}
|
|
}
|
|
}
|
|
return new_result
|
|
}
|
|
// Used internally to strip object_name from data key names
|
|
// Supports nested names, e.g.
|
|
// discussion_topic[assignment][id] => assignment[id]
|
|
$._stripObjectName = function(data, object_name, include_original) {
|
|
let new_result = {}
|
|
let short_name
|
|
if (data instanceof Array) {
|
|
new_result = []
|
|
}
|
|
for (const i in data) {
|
|
var original_name, found
|
|
if (data instanceof Array) {
|
|
original_name = data[i]
|
|
} else {
|
|
original_name = i
|
|
}
|
|
|
|
if ((found = original_name.indexOf(object_name + '[') === 0)) {
|
|
short_name = original_name.replace(object_name + '[', '')
|
|
const closing = short_name.indexOf(']')
|
|
short_name = short_name.substring(0, closing) + short_name.substring(closing + 1)
|
|
if (data instanceof Array) {
|
|
new_result.push(short_name)
|
|
} else {
|
|
new_result[short_name] = data[i]
|
|
}
|
|
}
|
|
|
|
if (!found || include_original) {
|
|
if (data instanceof Array) {
|
|
new_result.push(data[i])
|
|
} else {
|
|
new_result[i] = data[i]
|
|
}
|
|
}
|
|
}
|
|
return new_result
|
|
}
|
|
|
|
// Validated the selected form. Pops up little error messages
|
|
// next to form elements that have errors.
|
|
// object_name: specify to make error checking easier. If object_name == "assignment"
|
|
// and required included "good", then "assignment[good]" is required. Only
|
|
// useful if all validations use the given object_name
|
|
// required: a list of strings, elements that are required
|
|
// dates: list of strings, elements that must be blank or a valid date
|
|
// times: list of strings, elements that must be blank or a valid time
|
|
// numbers: list of strings, elements that must be blank or a valid number
|
|
// property_validations: hash, where key names are form element names
|
|
// and key values are functions to call on the given data. The function
|
|
// should return nothing if valid, an error message for display otherwise.
|
|
// labels: map of element names to labels to be used in error reporting. The validation
|
|
// will attempt to determine the appropriate label via HTML <label for="..."> elements
|
|
// if not specified
|
|
$.fn.validateForm = function(options) {
|
|
if (this.length === 0) {
|
|
return false
|
|
}
|
|
var options = $.extend({}, $.fn.validateForm.defaults, options),
|
|
$form = this,
|
|
errors = {},
|
|
data = options.data || $form.getFormData(options)
|
|
|
|
if (options.object_name) {
|
|
options.required = $._addObjectName(options.required, options.object_name)
|
|
options.date_fields = $._addObjectName(options.date_fields, options.object_name)
|
|
options.dates = $._addObjectName(options.dates, options.object_name)
|
|
options.times = $._addObjectName(options.times, options.object_name)
|
|
options.numbers = $._addObjectName(options.numbers, options.object_name)
|
|
options.property_validations = $._addObjectName(
|
|
options.property_validations,
|
|
options.object_name
|
|
)
|
|
}
|
|
if (options.required) {
|
|
const required = _.result(options, 'required')
|
|
$.each(required, (i, name) => {
|
|
if (!data[name]) {
|
|
if (!errors[name]) {
|
|
errors[name] = []
|
|
}
|
|
let fieldPrompt = options.labels && options.labels[name]
|
|
fieldPrompt = fieldPrompt || $form.getFieldLabelString(name)
|
|
|
|
errors[name].push(
|
|
I18n.t('errors.required', 'Required field') + (fieldPrompt ? ': ' + fieldPrompt : '')
|
|
)
|
|
}
|
|
})
|
|
}
|
|
if (options.date_fields) {
|
|
$.each(options.date_fields, (i, name) => {
|
|
const $item = $form.find("input[name='" + name + "']").filter('.datetime_field_enabled')
|
|
if ($item.length && $item.data('invalid')) {
|
|
if (!errors[name]) {
|
|
errors[name] = []
|
|
}
|
|
errors[name].push(I18n.t('errors.invalid_datetime', 'Invalid date/time value'))
|
|
}
|
|
})
|
|
}
|
|
if (options.numbers) {
|
|
$.each(options.numbers, (i, name) => {
|
|
const val = parseFloat(data[name])
|
|
if (isNaN(val)) {
|
|
if (!errors[name]) {
|
|
errors[name] = []
|
|
}
|
|
errors[name].push(I18n.t('errors.invalid_number', 'This should be a number.'))
|
|
}
|
|
})
|
|
}
|
|
if (options.property_validations) {
|
|
$.each(options.property_validations, (name, validation) => {
|
|
if ($.isFunction(validation)) {
|
|
let result = validation.call($form, data[name], data)
|
|
if (result) {
|
|
if (typeof result !== 'string') {
|
|
result = I18n.t('errors.invalid_entry_for_field', 'Invalid entry: %{field}', {
|
|
field: name
|
|
})
|
|
}
|
|
if (!errors[name]) {
|
|
errors[name] = []
|
|
}
|
|
errors[name].push(result)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
let hasErrors = false
|
|
for (const err in errors) {
|
|
hasErrors = true
|
|
break
|
|
}
|
|
if (hasErrors) {
|
|
$form.formErrors(errors, options)
|
|
trackEvent(
|
|
'Form Errors',
|
|
this.attr('id') || this.attr('class') || document.title,
|
|
JSON.stringify(errors)
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
$.fn.validateForm.defaults = {object_name: null, required: null, dates: null, times: null}
|
|
// Takes in an errors object and creates little pop-up message boxes over
|
|
// each errored form field displaying the error text. Still needs some
|
|
// css lovin'.
|
|
$.fn.formErrors = function(data_errors, options) {
|
|
if (this.length === 0) {
|
|
return
|
|
}
|
|
const $form = this
|
|
const errors = {}
|
|
const elementErrors = []
|
|
if (data_errors && data_errors.errors) {
|
|
data_errors = data_errors.errors
|
|
}
|
|
if (typeof data_errors === 'string') {
|
|
data_errors = {base: data_errors}
|
|
}
|
|
$.each(data_errors, (i, val) => {
|
|
if (typeof val === 'string') {
|
|
var newval = []
|
|
newval.push(val)
|
|
val = newval
|
|
} else if (
|
|
typeof i === 'number' &&
|
|
val.length == 2 &&
|
|
val[0] instanceof jQuery &&
|
|
typeof val[1] === 'string'
|
|
) {
|
|
elementErrors.push(val)
|
|
return
|
|
} else if (typeof i === 'number' && val.length == 2 && typeof val[1] === 'string') {
|
|
newval = []
|
|
newval.push(val[1])
|
|
i = val[0]
|
|
val = newval
|
|
} else {
|
|
try {
|
|
newval = []
|
|
for (const idx in val) {
|
|
if (typeof val[idx] === 'object' && val[idx].message) {
|
|
newval.push(val[idx].message.toString())
|
|
} else {
|
|
newval.push(val[idx].toString())
|
|
}
|
|
}
|
|
val = newval
|
|
} catch (e) {
|
|
val = val.toString()
|
|
}
|
|
}
|
|
if ($form.find(":input[name='" + i + "'],:input[name*='[" + i + "]']").length > 0) {
|
|
$.each(val, (idx, msg) => {
|
|
if (!errors[i]) {
|
|
errors[i] = htmlEscape(msg)
|
|
} else {
|
|
errors[i] += '<br/>' + htmlEscape(msg)
|
|
}
|
|
})
|
|
} else {
|
|
$.each(val, (idx, msg) => {
|
|
if (!errors.general) {
|
|
errors.general = htmlEscape(msg)
|
|
} else {
|
|
errors.general += '<br/>' + htmlEscape(msg)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
let hasErrors = false
|
|
let highestTop = 0
|
|
let lastField = null
|
|
const currentTop = $(document).scrollTop()
|
|
const errorDetails = {}
|
|
$('#aria_alerts').empty()
|
|
$.each(errors, (name, msg) => {
|
|
let $obj = $form
|
|
.find(":input[name='" + name + "'],:input[name*='[" + name + "]']")
|
|
.filter(':visible')
|
|
.first()
|
|
if (!$obj || $obj.length === 0) {
|
|
const $hiddenInput = $form
|
|
.find("[name='" + name + "'],[name*='[" + name + "]']")
|
|
.filter(':not(:visible)')
|
|
.first()
|
|
if ($hiddenInput && $hiddenInput.length > 0) {
|
|
$obj = $hiddenInput.prev()
|
|
}
|
|
}
|
|
if (!$obj || $obj.length === 0 || name == 'general') {
|
|
$obj = $form
|
|
}
|
|
if ($obj[0].tagName == 'TEXTAREA' && $obj.next('.mceEditor').length) {
|
|
$obj = $obj.next().find('.mceIframeContainer')
|
|
}
|
|
errorDetails[name] = {object: $obj, message: msg}
|
|
hasErrors = true
|
|
const offset = $obj.errorBox($.raw(msg)).offset()
|
|
if (offset.top > highestTop) {
|
|
highestTop = offset.top
|
|
}
|
|
lastField = $obj
|
|
})
|
|
if (lastField) {
|
|
lastField.focus()
|
|
}
|
|
for (let idx = 0, l = elementErrors.length; idx < l; idx++) {
|
|
const $obj = elementErrors[idx][0]
|
|
const msg = elementErrors[idx][1]
|
|
hasErrors = true
|
|
const offset = $obj.errorBox(msg).offset()
|
|
if (offset.top > highestTop) {
|
|
highestTop = offset.top
|
|
}
|
|
}
|
|
if (hasErrors) {
|
|
if (options && options.onFormError) options.onFormError.call($form, errorDetails)
|
|
$('html,body').scrollTo({top: highestTop, left: 0})
|
|
}
|
|
return this
|
|
}
|
|
|
|
// Pops up a small box containing the given message. The box is connected to the given form element, and will
|
|
// go away when the element is selected.
|
|
$.fn.errorBox = function(message, scroll) {
|
|
if (this.length) {
|
|
const $obj = this,
|
|
$oldBox = $obj.data('associated_error_box')
|
|
if ($oldBox) {
|
|
$oldBox.remove()
|
|
}
|
|
let $template = $('#error_box_template')
|
|
if (!$template.length) {
|
|
$template = $(
|
|
"<div id='error_box_template' class='error_box errorBox' style=''>" +
|
|
"<div class='error_text' style=''></div>" +
|
|
"<img src='/images/error_bottom.png' class='error_bottom'/>" +
|
|
'</div>'
|
|
).appendTo('body')
|
|
}
|
|
$.screenReaderFlashError(message)
|
|
|
|
const $box = $template
|
|
.clone(true)
|
|
.attr('id', '')
|
|
.css('zIndex', $obj.zIndex() + 1)
|
|
.appendTo('body')
|
|
|
|
// If our message happens to be a safe string, parse it as such. Otherwise, clean it up. //
|
|
$box.find('.error_text').html(htmlEscape(message))
|
|
|
|
const offset = $obj.offset()
|
|
const height = $box.outerHeight()
|
|
let objLeftIndent = Math.round($obj.outerWidth() / 5)
|
|
if ($obj[0].tagName == 'FORM') {
|
|
objLeftIndent = Math.min(objLeftIndent, 50)
|
|
}
|
|
$box
|
|
.hide()
|
|
.css({
|
|
top: offset.top - height + 2,
|
|
left: offset.left + objLeftIndent
|
|
})
|
|
.fadeIn('fast')
|
|
|
|
const cleanup = function() {
|
|
const $screenReaderErrors = $('#flash_screenreader_holder').find('span')
|
|
const srError = _.find($screenReaderErrors, node => $(node).text() == $box.text())
|
|
$box.remove()
|
|
if (srError) {
|
|
$(srError).remove()
|
|
}
|
|
$obj.removeData('associated_error_box')
|
|
$obj.removeData('associated_error_object')
|
|
}
|
|
|
|
const fade = function() {
|
|
$box.stop(true, true).fadeOut('slow', cleanup)
|
|
}
|
|
|
|
$obj
|
|
.data({
|
|
associated_error_box: $box,
|
|
associated_error_object: $obj
|
|
})
|
|
.click(fade)
|
|
.keypress(fade)
|
|
|
|
$box.click(function() {
|
|
$(this).fadeOut('fast', cleanup)
|
|
})
|
|
|
|
$.fn.errorBox.errorBoxes.push($obj)
|
|
if (!$.fn.errorBox.isBeingAdjusted) {
|
|
$.moveErrorBoxes()
|
|
}
|
|
if (scroll) {
|
|
$('html,body').scrollTo($box)
|
|
}
|
|
return $box
|
|
}
|
|
}
|
|
$.fn.errorBox.errorBoxes = []
|
|
$.moveErrorBoxes = function() {
|
|
const list = []
|
|
const prevList = $.fn.errorBox.errorBoxes
|
|
// ember does silly things with arrays
|
|
// so this for loop was changed from a for-in
|
|
// to how you see it below.
|
|
// That way, canvas doesn't blow up in some places
|
|
// ... at least not because of this
|
|
for (let idx = 0; idx < prevList.length; idx++) {
|
|
const $obj = prevList[idx],
|
|
$box = $obj.data('associated_error_box')
|
|
if ($box && $box.length && $box[0].parentNode) {
|
|
list.push($obj)
|
|
if ($obj.filter(':visible').length) {
|
|
const offset = $obj.offset()
|
|
const height = $box.outerHeight()
|
|
let objLeftIndent = Math.round($obj.outerWidth() / 5)
|
|
if ($obj[0].tagName == 'FORM') {
|
|
objLeftIndent = Math.min(objLeftIndent, 50)
|
|
}
|
|
$box
|
|
.css({
|
|
top: offset.top - height + 2,
|
|
left: offset.left + objLeftIndent
|
|
})
|
|
.show()
|
|
} else {
|
|
$box.hide()
|
|
}
|
|
}
|
|
}
|
|
$.fn.errorBox.errorBoxes = list
|
|
if (list.length) {
|
|
$.fn.errorBox.isBeingAdjusted = setTimeout($.moveErrorBoxes, 500)
|
|
} else {
|
|
delete $.fn.errorBox.isBeingAdjusted
|
|
}
|
|
}
|
|
// Hides all error boxes for the given form element and its input elements.
|
|
$.fn.hideErrors = function(options) {
|
|
if (this.length) {
|
|
const $oldBox = this.data('associated_error_box')
|
|
const $screenReaderErrors = $('#flash_screenreader_holder').find('span')
|
|
if ($oldBox) {
|
|
$oldBox.remove()
|
|
this.data('associated_error_box', null)
|
|
}
|
|
this.find(':input').each(function() {
|
|
const $obj = $(this),
|
|
$oldBox = $obj.data('associated_error_box')
|
|
if ($oldBox) {
|
|
$oldBox.remove()
|
|
$obj.data('associated_error_box', null)
|
|
const srError = _.find($screenReaderErrors, node => $(node).text() == $oldBox.text())
|
|
if (srError) {
|
|
$(srError).remove()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
return this
|
|
}
|
|
|
|
$.fn.markRequired = function(options) {
|
|
if (!options.required) {
|
|
return
|
|
}
|
|
let required = options.required
|
|
if (options.object_name) {
|
|
required = $._addObjectName(required, options.object_name)
|
|
}
|
|
const $form = $(this)
|
|
$.each(required, function(i, name) {
|
|
const field = $form.find('[name="' + name + '"]')
|
|
if (!field.length) {
|
|
return
|
|
}
|
|
field.attr({'aria-required': 'true'})
|
|
field.each(function() {
|
|
if (!this.id) {
|
|
return
|
|
}
|
|
const label = $('label[for="' + this.id + '"]')
|
|
if (!label.length) {
|
|
return
|
|
}
|
|
// Added the if statement to prevent the JS from adding the asterisk to the forgot password placeholder.
|
|
if (this.id != 'pseudonym_session_unique_id_forgot') {
|
|
label.append(
|
|
$('<span aria-hidden="true" />')
|
|
.text('*')
|
|
.attr('title', I18n.t('errors.field_is_required', 'This field is required'))
|
|
)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
$.fn.getFieldLabelString = function(name) {
|
|
const field = $(this).find('[name="' + name + '"]')
|
|
if (!field.length || !field[0].id) {
|
|
return
|
|
}
|
|
const label = $('label[for="' + field[0].id + '"]')
|
|
if (!label.length) {
|
|
return
|
|
}
|
|
return label[0].firstChild.textContent
|
|
}
|