diff --git a/package-lock.json b/package-lock.json index 0fe6c9eab..c6be9916f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2515,6 +2515,12 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/babel-plugin-transform-remove-console": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", + "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", + "dev": true + }, "node_modules/babel-runtime": { "version": "6.26.0", "dev": true, @@ -7867,6 +7873,7 @@ "@csstools/postcss-sass": "^5.0.1", "@fullhuman/postcss-purgecss": "^4.1.3", "autoprefixer": "^9.8.8", + "babel-plugin-transform-remove-console": "^6.9.4", "browser-sync": "^3.0.2", "chalk": "^4.1.2", "dart-sass": "^1.25.0", @@ -9686,6 +9693,12 @@ "@babel/helper-define-polyfill-provider": "^0.3.1" } }, + "babel-plugin-transform-remove-console": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", + "integrity": "sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==", + "dev": true + }, "babel-runtime": { "version": "6.26.0", "dev": true, @@ -12611,6 +12624,7 @@ "@csstools/postcss-sass": "^5.0.1", "@fullhuman/postcss-purgecss": "^4.1.3", "autoprefixer": "^9.8.8", + "babel-plugin-transform-remove-console": "*", "browser-sync": "^3.0.2", "chalk": "^4.1.2", "dart-sass": "^1.25.0", diff --git a/web/themes/custom/sfgovpl/babel.config.js b/web/themes/custom/sfgovpl/babel.config.js index 9c8d9a181..9caf631e0 100644 --- a/web/themes/custom/sfgovpl/babel.config.js +++ b/web/themes/custom/sfgovpl/babel.config.js @@ -1,4 +1,12 @@ +const PROD = process.env.NODE_ENV === 'production' +const plugins = PROD + ? [ + ['transform-remove-console', { exclude: ['warn', 'error'] }] + ] + : [] + module.exports = { extends: '../../../../babel.config.js', - comments: false + comments: false, + plugins } diff --git a/web/themes/custom/sfgovpl/package.json b/web/themes/custom/sfgovpl/package.json index 7c7b25325..9b2e49487 100644 --- a/web/themes/custom/sfgovpl/package.json +++ b/web/themes/custom/sfgovpl/package.json @@ -26,6 +26,7 @@ "@csstools/postcss-sass": "^5.0.1", "@fullhuman/postcss-purgecss": "^4.1.3", "autoprefixer": "^9.8.8", + "babel-plugin-transform-remove-console": "^6.9.4", "browser-sync": "^3.0.2", "chalk": "^4.1.2", "dart-sass": "^1.25.0", diff --git a/web/themes/custom/sfgovpl/sfgovpl.libraries.yml b/web/themes/custom/sfgovpl/sfgovpl.libraries.yml index 69e5b26a0..2106dafee 100755 --- a/web/themes/custom/sfgovpl/sfgovpl.libraries.yml +++ b/web/themes/custom/sfgovpl/sfgovpl.libraries.yml @@ -123,7 +123,7 @@ sfgov-campaign: sfgov-person: js: - src/js/profile.js: {} + dist/js/profile.js: {} dependencies: - core/jquery @@ -142,7 +142,7 @@ filters: sfgov-toc: js: - src/js/toc.js: {} + dist/js/toc.js: {} dependencies: - core/jquery - core/drupal @@ -156,7 +156,7 @@ sfgov-powerbi: sfgov-data-story: js: - src/js/data-story.js: {} + dist/js/data-story.js: {} dependencies: - core/jquery - core/drupal diff --git a/web/themes/custom/sfgovpl/src/js/formio-form-page.js b/web/themes/custom/sfgovpl/src/js/formio-form-page.js index f1bc008e6..d75fb6e78 100644 --- a/web/themes/custom/sfgovpl/src/js/formio-form-page.js +++ b/web/themes/custom/sfgovpl/src/js/formio-form-page.js @@ -1,140 +1,252 @@ +/* eslint-disable no-console */ /* eslint brace-style: ['error', '1tbs'] */ ;(function () { // eslint-disable-line no-extra-semi + const { Formio, gtag } = window + const MEASURE_PARAMS = {} + const USE_GA = false + + /** + * Create a formio.js plugin with hooks for different types of requests: + * (see: https://help.form.io/developers/fetch-plugin-api) + */ + Formio.registerPlugin({ + // requests made before the form is loaded, including the form schema + wrapStaticRequestPromise: wrapRequest, + // requests made once the form is loaded, except file uploads + wrapRequestPromise: wrapRequest, + // file upload (and download) requests + wrapFileRequestPromise: wrapFileRequest, + // finally, hook into every fetch response so that we can raise more + // meaningful errors when they fail + requestResponse: wrapResponseError + }, 'sfgov.measurement') + const el = document.getElementById('formio-form') const confirmationURL = el.getAttribute('data-confirmation-url') const options = safeJSONParse(el.getAttribute('data-options')) || {} options.i18n = safeJSONParse(el.getAttribute('data-translations')) || {} - let url = el.getAttribute('data-source') + const formURL = el.getAttribute('data-source') + let url = formURL const params = new URL(location).searchParams const submissionId = params.get('submission') if (submissionId) { url += `/submission/${submissionId}` } - // set these vars for all future measurements - measure({ - 'form.url': url - }) + // set these parameters for all future measurements + measureParams({ form_url: url }) measure('create') // eslint-disable-next-line no-undef Formio.createForm(el, url, options) .then(form => { // include form language in future measurements - measure({ - 'form.language': form.language, - 'form.page': 0 - }) + measureParams({ form_language: form.language }) measure('load') - form.on('nextPage', event => { - measure('nextPage') - measure({ 'form.page': event.page }) - }) - form.on('prevPage', event => { - measure('prevPage') - measure({ 'form.page': event.page }) - }) + /** + * Define form event handlers in a mapping so that we don't have to call + * form.on('event', handler) for each one, and so that we can check in the + * onAny() callback for whether there's an explicit handler for the event + */ + const handlers = { + nextPage (event) { + measure('next_page', { form_page: event.page }) + }, - form.on('saveDraft', submission => { - measure('saveDraft') - }) + prevPage (event) { + measure('prev_page', { form_page: event.page }) + }, - form.on('submit', (submission, saved) => { - measure('submit', { - 'submission.state': submission.state - }) - }) + // the signature of this event is different from nextPage + prevPage: + // https://github.com/formio/formio.js/blob/4.19.x/src/Wizard.js#L406 + wizardPageSelected (page, index) { + measure('select_page', { form_page: index }) + }, - // What to do when the submit begins. - form.on('submitDone', submission => { - measure('submitDone') - // we want to navigate to the confirmation page only on final submission. - // saving a draft also triggers the submitDone event, but we want to keep - // the user on the current page in that case. - if (confirmationURL && submission.state !== 'draft') { - const formLanguageMap = { - zh: 'zh-hant', - 'zh-TW': 'zh-hant' - } - // see: for more details - let lang = form.language - lang = formLanguageMap[lang] || lang - const actualUrl = lang && lang !== 'en' - ? confirmationURL.replace('{lang}', lang) - : confirmationURL.replace('{lang}/', '') - measure('redirect', { - reason: 'confirmation', - url: actualUrl - }) - window.location = actualUrl - } - }) + submit (submission) { + measure('submit') + }, + + saveDraft () { + measure('save_draft') + }, - // measure file uploads - form.on('fileUploadingStart', () => measure('fileUploadStart')) - form.on('fileUploadingEnd', () => measure('fileUploadEnd')) - - const IGNORE_ERROR_TYPES = [ - // these are component-level validation errors that fire every time a - // component is marked as invalid, dispatched for each component - // individually, and for text inputs on each keystroke - 'componentError', - 'componentChange' - ] - - form.onAny((type, event, ...rest) => { - // remove the 'formio.' prefix - const subtype = type.replace('formio.', '') - if (IGNORE_ERROR_TYPES.includes(subtype)) { - // do nothing - } else if (subtype.match(/error/i)) { - if (Array.isArray(event)) { - // console.debug('validation errors', event) - } else { - measure('error', { - errorType: type, - message: getErrorMessage(event) + submitDone (submission) { + measure('submit_done') + + // we want to navigate to the confirmation page only on final submission. + // saving a draft also triggers the submitDone event, but we want to keep + // the user on the current page in that case. + if (confirmationURL && submission.state !== 'draft') { + const formLanguageMap = { + zh: 'zh-hant', + 'zh-TW': 'zh-hant' + } + // see: for more details + let lang = form.language + lang = formLanguageMap[lang] || lang + const actualUrl = lang && lang !== 'en' + ? confirmationURL.replace('{lang}', lang) + : confirmationURL.replace('{lang}/', '') + measure('redirect', { + redirect_reason: 'confirmation', + redirect_url: actualUrl }) + window.location = actualUrl } + }, + + // 'error' events are validation errors + error (event) { + measure('validation_error', { + validation_count: Array.isArray(event) ? event.length : 1, + validation_message: Array.isArray(event) + ? event.map(err => err.message).join('; ') + : event.message + }) } - /** - * Uncomment this for local development to see messages for events that - * haven't been handled explicitly - */ - // if (![type, subtype].some(t => form.events.listeners(t).length)) { - // console.debug('ignoring event', type, ...rest) - // } - }) + } + + for (const [type, handler] of Object.entries(handlers)) { + form.on(type, handler) + } + + /** + * Uncomment this for local development to see messages for events that + * haven't been handled explicitly + */ + // form.onAny((type, event, ...rest) => { + // if (!handlers[type.replace(/^formio\./, '')]) { + // console.debug('unhandled event: "%s":', type, event, ...rest) + // } + // }) }) .catch(error => { - measure('error', { - message: error.message, - stack: error.stack - }) + measure('error', getFormErrorVars(error)) }) // add an event and/or measurement variables to the GA data layer - function measure (event, vars) { - if (typeof event === 'object') { - vars = event - } else if (typeof event === 'string') { - vars = Object.assign( - { event: `form.${event}` }, - vars - ) + function measure (event, params) { + event = `form_${event}` + params = Object.assign({}, MEASURE_PARAMS, params) + console.debug('measure', event, params) + if (USE_GA) { + gtag('event', event, params) + } else { + window.dataLayer.push({ event, ...params }) + } + } + + function measureParams (params) { + Object.assign(MEASURE_PARAMS, params) + } + + /** + * Attempt to turn a formio.js error or error message into useful measurement + * variables. + * + * @param {any} error + * @returns {Map} + */ + function getFormErrorVars (error) { + if (error instanceof Error) { + return { + error_message: error.message, + error_stack: error.stack + } + } else if (!error || error === 'Invalid alias') { + return { + error_message: `Form schema failed to load (${JSON.stringify(error)})` + } + } + return { error_message: error.message || error } + } + + /** + * formio.js does some funky stuff with failed responses that obfuscate the + * cause of the failure. Throwing an error with a meaningful message here + * causes the request promise to reject, in which case Formio.request() + * includes the thrown error's message in the following: + * + * `Could not connect to API server (${err.message}): ${url}` + * https://github.com/formio/formio.js/blob/4.20.x/src/Formio.js#L1064 + * + * This text is then displayed as-is on the page, rather than the text of the + * response, which is not typically useful: "Invalid alias" for forms that + * have moved, or possibly an empty string if the request was blocked at the + * network level. + * + * @param {Response} response + * @param {any} _Formio + * @param {Map} data + * @returns {void} + */ + function wrapResponseError (response) { + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`) } - console.debug('measure', vars) - window.dataLayer.push(vars) + return response } - function getErrorMessage (error) { - return typeof error === 'string' - ? error - : Array.isArray(error) - ? `${error.length} validation error${(error.length === 1 ? '' : 's')}` - : error instanceof Object ? error.message : null + /** + * @typedef {function} RequestWrapper + * @param {Promise} promise + * @param {Map} requestArgs + * @returns {Promise} + */ + + /** @type {RequestWrapper} */ + function wrapRequest (promise, { url, method, type }) { + const t = Date.now() + const vars = { + request_url: url, + request_method: method, + request_type: type + } + measure('request_start', vars) + return promise + .then(value => { + measure('request_end', { ...vars, request_time: Date.now() - t }) + return value + }, error => { + measure('request_error', { + ...vars, + request_time: Date.now() - t + }) + throw error || `Unable to load URL: ${url}` + }) + } + + /** + * File requests have different data in their requestArgs object, + * and we want to dispatch different events for these. + * + * @type {RequestWrapper} + */ + function wrapFileRequest (promise, { file, fileName, provider }) { + const t = Date.now() + const { size, type } = file || {} + const vars = { + file_size: size, + file_type: type, + file_name: fileName, + file_provider: provider + } + measure('file_upload_start', vars) + return promise + .then(value => { + measure('file_upload_end', { ...vars, request_time: Date.now() - t }) + return value + }, error => { + measure('file_upload_error', { + ...vars, + error: error.message, + request_time: Date.now() - t + }) + throw error + }) } function safeJSONParse (str) {