/** * * ----------------------------------------------------------------------------- * A form that talks to the backend using AJAX. * > For example usage, take a look at one of the forms generated in a new * > Sails app when using the "Web app" template. * * @type {Component} * * @slot default [form contents] * * @event update:cloudError [:cloud-error.sync="…"] * @event update:syncing [:syncing.sync="…"] * @event update:formErrors [:form-errors.sync="…"] * @event submitted [emitted after the server responds with a 2xx status code] * @event rejected [emitted after the server responds with a non-2xx status code] * ----------------------------------------------------------------------------- */ parasails.registerComponent('ajaxForm', { // ╔═╗╦═╗╔═╗╔═╗╔═╗ // ╠═╝╠╦╝║ ║╠═╝╚═╗ // ╩ ╩╚═╚═╝╩ ╚═╝ // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Note: // Some of these props rely on the `.sync` modifier re-introduced in Vue 2.3.x. // For more info, see: https://vuejs.org/v2/guide/components.html#sync-Modifier // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - props: [ 'syncing',// « 2-way bound (:syncing.sync="…") 'cloudError',// « 2-way bound (:cloud-error.sync="…") 'action', 'formErrors',// « 2-way bound (:form-errors.sync="…") 'formData', 'formRules', 'handleSubmitting',// « alternative for `action` 'handleParsing',// « alternative for `formRules`+`formData`+`formErrors` ], // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ data: function (){ return { //… }; }, // ╦ ╦╔╦╗╔╦╗╦ // ╠═╣ ║ ║║║║ // ╩ ╩ ╩ ╩ ╩╩═╝ template: `
`, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ beforeMount: function() { //… }, mounted: async function (){ if (this.action === undefined && this.handleSubmitting === undefined) { throw new Error('Neither `:action` nor `:handle-submitting` was passed in to , but one or the other must be provided.'); } else if (this.action !== undefined && this.handleSubmitting !== undefined) { throw new Error('Both `:action` AND `:handle-submitting` were passed in to , but only one or the other should be provided.'); } else if (this.action !== undefined && (!_.isString(this.action) || !_.isFunction(Cloud[_.camelCase(this.action)]))) { throw new Error('Invalid `action` in . `action` should be the name of a method on the `Cloud` global. For example: `action="login"` would make this form communicate using `Cloud.login()`, which corresponds to the "login" action on the server.'); } else if (this.action !== undefined && !_.isFunction(Cloud[this.action])) { throw new Error('Unrecognized `action` in . Did you mean to type `action="'+_.camelCase(this.action)+'"`? ( expects `action` to be provided in camelCase format. In other words, to reference the action at "api/controllers/foo/bar/do-something", use `action="doSomething"`.)'); } else if (this.handleSubmitting !== undefined && !_.isFunction(this.handleSubmitting)) { throw new Error('Invalid `:handle-submitting` function passed to . (Any chance you forgot the ":" in front of the prop name?) For example: `:handle-submitting="handleSubmittingSomeForm"`. This function should be an `async function`, and it should either throw a special exit signal or return response data from the server. (If this custom `handleSubmitting` will be doing something more complex than a single request to a server, feel free to return whatever amalgamation of data you wish.)'); } if (this.handleParsing === undefined && this.formData === undefined) { throw new Error('Neither `:form-data` nor `:handle-parsing` was passed in to , but one or the other must be provided.'); } else if (this.handleParsing !== undefined && this.formData !== undefined) { throw new Error('Both `:form-data` AND `:handle-parsing` were passed in to , but only one or the other should be provided.'); } else if (this.handleParsing !== undefined && !_.isFunction(this.handleParsing)) { throw new Error('Invalid `:handle-parsing` function passed to . (Any chance you forgot the ":" in front of the prop name?) For example: `:handle-parsing="handleParsingSomeForm"`. This function should return a dictionary (plain JavaScript object like `{}`) of parsed form data, ready to be sent in a request to the server.'); } else if (this.formData !== undefined && (!_.isObject(this.formData) || _.isFunction(this.formData) || _.isArray(this.formData))) { throw new Error('Invalid `:form-data` passed to . (Any chance you forgot the ":" in front of the prop name?) For example: `:form-data="someFormData"`. This should reference a dictionary (plain JavaScript object like `{}`). Specifically, `:form-data` should only be used in the case where the raw data from the form in the user interface happens to correspond **EXACTLY** with the names and format of the argins that should be sent in a request to the server. (For more nuanced behavior, use `handle-parsing` instead!)'); } if (!this.formData && (this.formRules || this.formErrors)) { throw new Error('If `:form-rules` or `:form-errors.sync` are in use, then `:form-data` must also be passed in. (If the AJAX request doesn\'t need form data, then use an empty dictionary, i.e. `:form-data="{}"`.)'); } else if (this.formRules && !this.formErrors) { throw new Error('If `:form-rules` are provided, then `:form-errors.sync` must also be passed in.'); } if (this.formRules) { var SUPPORTED_RULES = [ 'required', 'isEmail', 'isIn', 'is', 'minLength', 'maxLength', 'sameAs', 'isHalfwayDecentPassword', 'custom' ]; for (let fieldName in this.formRules) { for (let ruleName in this.formRules[fieldName]) { if (_.contains(SUPPORTED_RULES, ruleName)) { // OK. Good enough. // - - - - - - - - - - - - - - - - - - - - - // FUTURE: move rule rhs checks out here // (so error messages from bad usage are // logged sooner) // - - - - - - - - - - - - - - - - - - - - - } else { let kebabRules = _.map(_.clone(SUPPORTED_RULES), (ruleName)=>_.kebabCase(ruleName)); let lowerCaseRules = _.map(_.clone(SUPPORTED_RULES), (ruleName)=>ruleName.toLowerCase()); let ruleIdx = ( _.indexOf(kebabRules, ruleName) === -1 ? _.indexOf(lowerCaseRules, ruleName.toLowerCase()) === -1 ? -1 : _.indexOf(lowerCaseRules, ruleName.toLowerCase()) : _.indexOf(kebabRules, ruleName) ); if (ruleIdx !== -1) { throw new Error('Did you mean `'+SUPPORTED_RULES[ruleIdx]+'`? (note the capitalization)\nYou are seeing this error because encountered an unsupported (but vaguely familiar-looking) client-side validation rule: `'+ruleName+'`.'); } else { throw new Error(' does not support that client-side validation rule (`'+ruleName+'`).\n [?] If you\'re unsure, visit https://sailsjs.com/support'); } } }//∞ }//∞ } // Focus our "focus-first" field, if relevant. // (but not on mobile, because it can get weird) if(typeof bowser !== 'undefined' && !bowser.mobile && this.$find('[focus-first]').length > 0) { this.$focus('[focus-first]'); } }, beforeDestroy: function() { //… }, // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ methods: { keydownMetaEnter: async function() { await this._submit(); }, submit: async function () { await this._submit(); }, // ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗ // ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗ // ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝ _submit: async function () { // Prevent double-posting. if (this.syncing) { return; }//• // Clear the userland "cloudError" prop. this.$emit('update:cloudError', ''); // Determine the argins that will be sent to the server in our request. var argins; if (this.handleParsing) { // Run the provided "handle-parsing" logic. // > This should clear out any pre-existing error messages, perform any additional // > client-side form validation checks, and do any necessary data transformations // > to munge the form data into the format expected by the server. argins = this.handleParsing(); if (argins === undefined) { // If argins came back undefined, then avast. // (This means that parsing the form failed. Submission will not be attempted.) return; } else if (!_.isObject(argins) || _.isArray(argins) || _.isFunction(argins)) { throw new Error('Invalid data returned from custom form parsing logic. (Should return a dictionary of argins, like `{}`.)'); }//• } else if (this.formData) { // Or use the simpler, built-in absorbtion strategy. // > This uses the provided form data as our argins, verbatim. Then it runs // > built-in client-side validation, if configured to do so. argins = this.formData; let formData = this.formData; let formErrors = {}; for (let fieldName in this.formRules) { let fieldValue = formData[fieldName]; for (let ruleName in this.formRules[fieldName]) { let ruleRhs = this.formRules[fieldName][ruleName]; let violation; let isFieldValuePresent = ( fieldValue !== undefined && fieldValue !== '' && !_.isNull(fieldValue) ); if (ruleName === 'required' && (ruleRhs === true || ruleRhs === false)) { // ® Must be defined, non-null, and not the empty string if (ruleRhs === false) { violation = false; } else { violation = ( !isFieldValuePresent ); } } else if (!isFieldValuePresent) { // Do nothing. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // Note: // In order to allow use with optional fields, all rules except for // `required: true` are only actually checked when the field value // is "present" -- i.e. some value other than `null`, `undefined`, // or `''` (empty string). // // > Trying to figure out how to handle a conditionally-requiured // > field that uses one of these validations? For example, a // > "Confirm email" re-entry field for an optional email address? // > Just make `required` validation rule dynamic, and everything // > else will work as expected. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } else if (ruleName === 'isEmail' && (ruleRhs === true || ruleRhs === false)) { // ® Must be an email address (unless falsy) if (ruleRhs === false) { violation = false; } else { violation = ( !parasails.util.isValidEmailAddress(fieldValue) ); } } else if (ruleName === 'isIn' && _.isArray(ruleRhs)) { // ® Must be one of these things violation = ( !_.contains(ruleRhs, fieldValue) ); } else if (ruleName === 'is') { // ® Must be exactly this thing (useful for required checkboxes) violation = ( ruleRhs !== fieldValue ); } else if (ruleName === 'minLength' && _.isNumber(ruleRhs)) { // ® Must consist of at least this many characters violation = ( !_.isString(fieldValue) || fieldValue.length < ruleRhs ); } else if (ruleName === 'maxLength' && _.isNumber(ruleRhs)) { // ® Must consist of no more than this many characters violation = ( !_.isString(fieldValue) || fieldValue.length > ruleRhs ); } else if (ruleName === 'sameAs' && ruleRhs !== '' && _.isString(ruleRhs)) { // ® Must match the value in another field let otherFieldName = ruleRhs; let otherFieldValue = formData[otherFieldName]; violation = ( otherFieldValue !== fieldValue ); } else if (ruleName === 'isHalfwayDecentPassword' && (ruleRhs === true || ruleRhs === false)) { // ® Must be a halfway-decent password // > This is an arbitrary distinction, so change it if you want. // > Just... please use common sense. And try to avoid engaging // > in security theater. if (ruleRhs === false) { violation = false; } else { violation = ( (!_.isString(fieldValue) && !_.isNumber(fieldValue)) || fieldValue.length < 6 ); } } else if (ruleName === 'custom' && _.isFunction(ruleRhs)) { // ® Provided function must return truthy when invoked with the value. try { violation = ( !ruleRhs(fieldValue) ); } catch (err) { console.warn(err); violation = true; } } else { throw new Error('Cannot interpret client-side validation rule (`'+ruleName+'`) because the configuration provided for it is not recognized by .\n [?] If you\'re unsure, visit https://sailsjs.com/support'); } // If a rule violation was detected, then set it as a form error // and break out of the `for` loop to continue on to the next field. // (We only track one form error per field.) if (violation) { formErrors[fieldName] = ruleName; break; }//˚ }//∞ }//∞ // Whether there are any errors or not, update userland "formErrors" prop // so that the markup reflects the new reality (i.e. inline validation errors // either get rendered or go away.) this.$emit('update:formErrors', formErrors); // If there were any form errors, avast. (Submission will not be attempted.) if (Object.keys(formErrors).length > 0) { // In development mode, also log a warning // (so that it's clear what's going on just in case validation // states/messages are not hooked up in the HTML template) if (this._environment !== 'production') { console.warn(` encountered ${Object.keys(formErrors).length} form error${Object.keys(formErrors).length !== 1 ? 's' : ''} when performing client-side validation of "form-data" versus "form-rules". (Note: This warning is only here to assist with debugging-- it will not be displayed in production. If you're unsure, check out https://sailsjs.com/support for more resources.)`, _.cloneDeep(formErrors)); }//fi return; }//• }//fi (determining argins) // Set syncing state to `true` on userland "syncing" prop. this.$emit('update:syncing', true); // Submit the form var failedWithCloudExit; var rawErrorFromCloudSDK; var result; if (this.handleSubmitting) { try { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: Consider cloning the argins ahead of time to prevent accidental mutation of form data. // (but remember argins could contain File instances that might not be clone-able) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - result = await this.handleSubmitting(argins); } catch (err) { rawErrorFromCloudSDK = err; if (_.isString(err) && err !== '') { failedWithCloudExit = err; } else if (_.isError(err) && err.exit) { failedWithCloudExit = err.exit; } else if (_.isObject(err) && !_.isError(err) && !_.isArray(err) && !_.isFunction(err) && Object.keys(err)[0] && _.isString(Object.keys(err)[0])) { failedWithCloudExit = Object.keys(err)[0]; } else { throw err; } } } else { // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - // FUTURE: Potentially filter unused data in argins here before proceeding // (assuming cloudsdk has that information available) // Or better yet, just have `Cloud.*.with()` take care of that automatically. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - result = await Cloud[this.action].with(argins) .tolerate((err)=>{ rawErrorFromCloudSDK = err; failedWithCloudExit = err.exit || 'error'; }); } // When a cloud error occurs, tolerate it, but set the userland "cloudError" // prop accordingly. if (failedWithCloudExit) { this.$emit('update:cloudError', failedWithCloudExit); } // Set syncing state to `false` on userland "syncing" prop. this.$emit('update:syncing', false); // If the server says we were successful, then emit the "submitted" event. if (!failedWithCloudExit) { this.$emit('submitted', result); } else { this.$emit('rejected', rawErrorFromCloudSDK); } }, } });