formio-sfds
Version:
form.io templates for the SF Design System
299 lines (258 loc) • 8.93 kB
JavaScript
import defaultTranslations from './i18n'
import { observe } from 'selector-observer'
import { mergeObjects } from './utils'
import buildHooks from './hooks'
import loadTranslations from './i18n/load'
import 'flatpickr/dist/l10n/es'
// import 'flatpickr/dist/l10n/tl'
// import 'flatpickr/dist/l10n/zh'
import 'flatpickr/dist/l10n/zh-tw'
const WRAPPER_CLASS = 'formio-sfds'
const PATCHED = `sfds-patch-${Date.now()}`
let util
const forms = []
export default Formio => {
if (Formio[PATCHED]) {
return
}
const { FormioUtils } = window
util = FormioUtils
patch(Formio)
patchDateTimeSuffix()
Formio[PATCHED] = true
}
function scrollToTop() {
window.scroll(0, 0)
}
// Prevent users from navigating away and losing their entries.
let warnBeforeLeaving = false
function turnWarningOn() {
warnBeforeLeaving = true
}
function turnWarningOff() {
warnBeforeLeaving = false
}
window.addEventListener('beforeunload', (event) => {
if (warnBeforeLeaving) {
// Most browsers will show a default message instead of this one.
event.returnValue = 'Leave site? Changes you made may not be saved.'
}
})
function patch(Formio) {
console.info('Patching Formio.createForm() with SFDS behaviors...')
hook(Formio, 'createForm', async (createForm, args) => {
const [el, resourceOrOptions, options = resourceOrOptions || {}] = args
// get the default language from the element's (inherited) lang property
const language = el.lang || document.documentElement.lang
// use the translations and language as the base, and merge the provided options
const opts = mergeObjects({ i18n: defaultTranslations, language }, options)
if (typeof opts.i18n === 'string') {
const { i18n: translationsURL } = opts
console.info('loading translations form:', translationsURL)
try {
const i18n = await loadTranslations(translationsURL)
console.info('loaded translations:', i18n)
opts.i18n = mergeObjects({}, opts.i18n, i18n)
} catch (error) {
console.warn('Unable to load translations from:', translationsURL, error)
// FIXME: we may want to explicitly *allow* Google Translate (even if
// it's been disabled) for this form if translations fail to load.
// opts.googleTranslate = true
}
}
if (opts.hooks instanceof Object) {
opts.hooks = buildHooks(opts.hooks)
}
let eventHandlers = {}
if (opts.on instanceof Object) {
eventHandlers = buildHooks(opts.on)
}
const rest = resourceOrOptions ? [resourceOrOptions, opts] : [opts]
return createForm(el, ...rest).then(form => {
if (opts.formioSFDSOptOut === true) {
console.log('SFDS form opted out:', opts, el)
return form
}
console.log('SFDS form created!')
form.on('nextPage', scrollToTop)
form.on('prevPage', scrollToTop)
form.on('nextPage', turnWarningOn)
form.on('submit', turnWarningOff)
const { element } = form
element.classList.add('d-flex', 'flex-column-reverse', 'mb-4')
if (opts.googleTranslate === false) {
disableGoogleTranslate(element)
}
let wrapper = element.closest(`.${WRAPPER_CLASS}`)
if (!wrapper) {
// only create a wrapper if it's not already wrapped
wrapper = document.createElement('div')
wrapper.className = WRAPPER_CLASS
element.parentNode.insertBefore(wrapper, element)
wrapper.appendChild(element)
}
// Note: we create a shallow copy of the form model so the .form setter
// will treat it as changed. (form.io showed us this trick!)
const model = { ...form.form }
patchSelectMode(model)
form.form = model
for (const [event, handler] of Object.entries(eventHandlers)) {
form.on(event, handler)
}
if (opts.data) {
form.submission = { data: opts.data }
}
if (opts.prefill) {
console.info('submission before prefill:', form.submission)
let params
switch (opts.prefill) {
case 'url':
params = new URLSearchParams(window.location.search || window.location.hash.substr(1))
break
case 'querystring':
params = new URLSearchParams(window.location.search)
break
case 'hash':
params = new URLSearchParams(window.location.hash.substr(1))
break
default:
if (opts.prefill instanceof URLSearchParams) {
params = opts.prefill
} else {
console.warn('Unrecognized prefill option value: "%s"', opts.prefill)
}
}
if (params) {
const data = {}
for (const [key, value] of params.entries()) {
if (key in form.submission.data) {
data[key] = value
} else {
console.warn('ignoring querystring key "%s": "%s"', key, value)
}
}
console.info('prefill submission data:', data)
form.submission = { data }
}
}
updateLanguage(form)
forms.push(form)
return form
})
})
patchI18nMultipleKeys(Formio)
patchDateTimeLocale(Formio)
// this goes last so that if it fails it doesn't break everything else
patchLanguageObserver()
}
function patchSelectMode (model) {
const selects = util.searchComponents(model.components, { type: 'select' })
for (const component of selects) {
if (component.tags && component.tags.includes('autocomplete')) {
component.customOptions = Object.assign({
shouldSort: true
}, component.customOptions)
} else {
component.widget = 'html5'
}
}
}
function patchLanguageObserver () {
const observer = new window.MutationObserver(mutations => {
for (const form of forms) {
updateLanguage(form)
}
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['lang'],
subtree: true
})
return observer
}
function updateLanguage (form) {
const closestLangElement = form.element.closest('[lang]:not([class*=sfgov-translate-lang-])')
if (closestLangElement) {
form.language = closestLangElement.getAttribute('lang')
}
}
function hook (obj, methodName, wrapper) {
const method = obj[methodName]
obj[methodName] = function (...args) {
return wrapper.call(this, method.bind(this), args)
}
}
function patchI18nMultipleKeys (Formio) {
/*
* Patch the base Component class's t() method to support multiple key
* fallbacks as the first argument. As of 4.10.0-beta.3.1, form.io's
* implementation treats the first argument as a string even if it's an Array,
* which means that in a template, this call:
*
* ```js
* ctx.t(['some.nonexistent.key', ''])
* ```
*
* Will render the string "some.nonexistent.key,". The trailing comma is from
* the array being coerced to a string in the last line here:
*
* <https://github.com/formio/formio.js/blob/58996eac1207803cb597b4ab7c3abc6636078c72/src/components/_classes/component/Component.js#L707-L708>
*/
hook(Formio.Components._components.component.prototype, 't', function (t, [keys, params]) {
if (Array.isArray(keys)) {
const last = keys.length - 1
const fallback = (key, index) => {
const value = t(key, params)
return value === key ? (index === last) ? value : '' : value
}
return keys.reduce((value, key, index) => {
return value || fallback(key, index)
}, '')
} else {
return t(keys, params)
}
})
}
function patchDateTimeSuffix () {
observe('.formio-component-datetime', {
add (el) {
const group = el.querySelector('.input-group')
if (!group) return
const text = group.querySelector('.input-group-append')
if (text) {
text.classList.remove('input-group-append')
text.classList.add('input-group-prepend')
group.insertBefore(text, group.firstChild)
}
}
})
}
function patchDateTimeLocale (Formio) {
hook(Formio.Components.components.datetime.prototype, 'attach', function (attach, args) {
if (this.options.language) {
this.component.widget.locale = getFlatpickrLocale(this.options.language)
}
return attach(...args)
})
observe('.flatpickr-calendar', {
add: disableGoogleTranslate
})
}
function disableGoogleTranslate (el) {
// Google Translate
el.classList.add('notranslate')
// Microsoft, Google, et al; see:
// <https://www.w3.org/International/questions/qa-translate-flag.en>
el.setAttribute('translate', 'no')
}
function getFlatpickrLocale (code) {
if (code in window.flatpickr.l10ns) {
return code
}
// get the language portion of the code, e.g. "zh" from "zh-TW"
const lang = code.split('-')[0]
return {
// Prefer traditional (Taiwan) to simplified (China)
zh: 'zh_tw'
}[lang] || lang
}