diff --git a/README.md b/README.md index 3e668ab..ea4f6b3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Vue Formulario is a library, based on Vue For ## Examples -Every form control have to rendered inside FormularioInput component. This component provides `id` and `context` in v-slot props. Control should use `context.model` as v-model and `context.blurHandler` as handler for `blur` event (it is necessary for validation when property `errorBehavior` is `blur`). Errors list for field can be accessed through `context.allErrors`. +Every form control have to rendered inside FormularioInput component. This component provides `id` and `context` in v-slot props. Control should use `context.model` as v-model and `context.blurHandler` as handler for `blur` event (it is necessary for validation when property `errorBehavior` is `blur`). Errors object list for field can be accessed through `context.allErrors`. Each error is an object with fields message (translated message), rule (rule name) and context. The example below creates the authorization form from data: ```json @@ -39,7 +39,7 @@ The example below creates the authorization form from data: v-for="(error, index) in vSlot.context.allErrors" :key="index" > - {{ error }} + {{ error.message }} diff --git a/package.json b/package.json index 3ef165c..d398994 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "dependencies": { "is-plain-object": "^3.0.0", "is-url": "^1.2.4", - "nanoid": "^2.1.11" + "nanoid": "^2.1.11", + "vue-i18n": "^8.17.7" } } diff --git a/src/Formulario.js b/src/Formulario.js index 75eb746..58b6780 100644 --- a/src/Formulario.js +++ b/src/Formulario.js @@ -2,6 +2,7 @@ import library from './libs/library' import rules from './libs/rules' import mimes from './libs/mimes' import FileUpload from './FileUpload' +import RuleValidationMessages from './RuleValidationMessages' import { arrayify, parseLocale, has } from './libs/utils' import isPlainObject from 'is-plain-object' import fauxUploader from './libs/faux-uploader' @@ -33,7 +34,8 @@ class Formulario { fileUrlKey: 'url', uploadJustCompleteDuration: 1000, errorHandler: (err) => err, - plugins: [], + plugins: [ RuleValidationMessages ], + validationMessages: {}, idPrefix: 'formulario-' } this.registry = new Map() @@ -145,26 +147,15 @@ class Formulario { return { ...this.options.rules, ...rules } } - /** - * Attempt to get the vue-i18n configured locale. - */ - i18n (vm) { - if (vm.$i18n) { - switch (typeof vm.$i18n.locale) { - case 'string': - return vm.$i18n.locale - case 'function': - return vm.$i18n.locale() - } - } - return false - } - /** * Get the validation message for a particular error. */ validationMessage (rule, validationContext, vm) { - return rule + if (this.options.validationMessages.hasOwnProperty(rule)) { + return this.options.validationMessages[rule](vm, validationContext) + } else { + return this.options.validationMessages['default'](vm, validationContext) + } } /** diff --git a/src/FormularioInput.vue b/src/FormularioInput.vue index d960471..2df8c4d 100644 --- a/src/FormularioInput.vue +++ b/src/FormularioInput.vue @@ -231,7 +231,7 @@ export default { name: this.context.name }, ...args) res = (res instanceof Promise) ? res : Promise.resolve(res) - return res.then(result => result ? false : this.getMessage(ruleName, args)) + return res.then(result => result ? false : this.getMessageObject(ruleName, args)) } return new Promise(resolve => { @@ -266,14 +266,21 @@ export default { } } }, - getMessage (ruleName, args) { - return this.getMessageFunc(ruleName)({ + getMessageObject (ruleName, args) { + let context = { args, name: this.mergedValidationName, value: this.context.model, vm: this, formValues: this.getFormValues() - }) + }; + let message = this.getMessageFunc(ruleName)(context); + + return { + message: message, + rule: ruleName, + context: context + } }, getMessageFunc (ruleName) { ruleName = snakeToCamel(ruleName) @@ -303,7 +310,7 @@ export default { getErrorObject () { return { name: this.context.nameOrFallback || this.context.name, - errors: this.validationErrors.filter(s => typeof s === 'string'), + errors: this.validationErrors.filter(s => typeof s === 'object'), hasErrors: !!this.validationErrors.length } }, diff --git a/src/RuleValidationMessages.js b/src/RuleValidationMessages.js new file mode 100644 index 0000000..39b1e8f --- /dev/null +++ b/src/RuleValidationMessages.js @@ -0,0 +1,223 @@ +/** + * This is an object of functions that each produce valid responses. There's no + * need for these to be 1-1 with english, feel free to change the wording or + * use/not use any of the variables available in the object or the + * arguments for the message to make the most sense in your language and culture. + * + * The validation context object includes the following properties: + * { + * args // Array of rule arguments: between:5,10 (args are ['5', '10']) + * name: // The validation name to be used + * value: // The value of the field (do not mutate!), + * vm: the // FormulateInput instance this belongs to, + * formValues: // If wrapped in a FormulateForm, the value of other form fields. + * } + */ +const validationMessages = { + /** + * The default render method for error messages. + */ + default: function (vm, context) { + return vm.$t(`validation.default`, context) + }, + + /** + * Valid accepted value. + */ + accepted: function (vm, context) { + return vm.$t(`validation.accepted`, context) + }, + + /** + * The date is not after. + */ + after: function (vm, context) { + if (Array.isArray(context.args) && context.args.length) { + context['compare'] = context.args[0] + return vm.$t(`validation.after.compare`, context) + } + + return vm.$t(`validation.after.default`, context) + }, + + /** + * The value is not a letter. + */ + alpha: function (vm, context) { + return vm.$t(`validation.alpha`, context) + }, + + /** + * Rule: checks if the value is alpha numeric + */ + alphanumeric: function (vm, context) { + return vm.$t(`validation.alphanumeric`, context) + }, + + /** + * The date is not before. + */ + before: function (vm, context) { + if (Array.isArray(context.args) && context.args.length) { + context['compare'] = context.args[0] + return vm.$t(`validation.before.compare`, context) + } + + return vm.$t(`validation.before.default`, context) + }, + + /** + * The value is not between two numbers or lengths + */ + between: function (vm, context) { + context['from'] = context.args[0] + context['to'] = context.args[1] + + const force = Array.isArray(context.args) && context.args[2] ? context.args[2] : false + if ((!isNaN(value) && force !== 'length') || force === 'value') { + return vm.$t(`validation.between.force`, context) + } + + return vm.$t(`validation.between.default`, context) + }, + + /** + * The confirmation field does not match + */ + confirm: function (vm, context) { + return vm.$t(`validation.confirm`, context) + }, + + /** + * Is not a valid date. + */ + date: function (vm, context) { + if (Array.isArray(context.args) && context.args.length) { + context['format'] = context.args[0] + return vm.$t(`validation.date.format`, context) + } + + return vm.$t(`validation.date.default`, context) + }, + + /** + * Is not a valid email address. + */ + email: function (vm, context) { + return vm.$t(`validation.email.default`, context) + }, + + /** + * Ends with specified value + */ + endsWith: function (vm, context) { + return vm.$t(`validation.endsWith.default`, context) + }, + + /** + * Value is an allowed value. + */ + in: function (vm, context) { + if (typeof context.value === 'string' && context.value) { + return vm.$t(`validation.in.string`, context) + } + + return vm.$t(`validation.in.default`, context) + }, + + /** + * Value is not a match. + */ + matches: function (vm, context) { + return vm.$t(`validation.matches.default`, context) + }, + + /** + * The maximum value allowed. + */ + max: function (vm, context) { + context['maximum'] = context.args[0] + + if (Array.isArray(context.value)) { + return vm.$t(`validation.max.array`, context) + } + const force = Array.isArray(context.args) && context.args[1] ? context.args[1] : false + if ((!isNaN(context.value) && force !== 'length') || force === 'value') { + return vm.$t(`validation.max.force`, context) + } + return vm.$t(`validation.max.default`, context) + }, + + /** + * The (field-level) error message for mime errors. + */ + mime: function (vm, context) { + context['types'] = context.args[0] + if (context['types']) { + return vm.$t(`validation.mime.default`, context) + } else { + return vm.$t(`validation.mime.no_formats_allowed`, context) + } + }, + + /** + * The maximum value allowed. + */ + min: function (vm, context) { + context['minimum'] = context.args[0] + + if (Array.isArray(context.value)) { + return vm.$t(`validation.min.array`, context) + } + const force = Array.isArray(context.args) && context.args[1] ? context.args[1] : false + if ((!isNaN(context.value) && force !== 'length') || force === 'value') { + return vm.$t(`validation.min.force`, context) + } + return vm.$t(`validation.min.default`, context) + }, + + /** + * The field is not an allowed value + */ + not: function (vm, context) { + return vm.$t(`validation.not.default`, context) + }, + + /** + * The field is not a number + */ + number: function (vm, context) { + return vm.$t(`validation.number.default`, context) + }, + + /** + * Required field. + */ + required: function (vm, context) { + return vm.$t(`validation.required.default`, context) + }, + + /** + * Starts with specified value + */ + startsWith: function (vm, context) { + return vm.$t(`validation.startsWith.default`, context) + }, + + /** + * Value is not a url. + */ + url: function (vm, context) { + return vm.$t(`validation.url.default`, context) + } +} + +/** + * This creates a vue-formulario plugin that can be imported and used on each + * project. + */ +export default function (instance) { + instance.extend({ + validationMessages: validationMessages + }) +} \ No newline at end of file diff --git a/src/libs/context.js b/src/libs/context.js index 443e6c6..41be401 100644 --- a/src/libs/context.js +++ b/src/libs/context.js @@ -166,13 +166,17 @@ function createOptionList (options) { * These are errors we that have been explicity passed to us. */ function explicitErrors () { - return arrayify(this.errors) + let result = arrayify(this.errors) .concat(this.localErrors) .concat(arrayify(this.error)) + result = result.map(message => ({'message': message, 'rule': null, 'context': null})) + + return result; } /** * The merged errors computed property. + * Each error is an object with fields message (translated message), rule (rule name) and context */ function allErrors () { return this.explicitErrors diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index 1ef9b20..01d2644 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -6,7 +6,19 @@ import FormSubmission from '../../src/FormSubmission.js' import FormularioForm from '@/FormularioForm.vue' import FormularioInput from '@/FormularioInput.vue' -Vue.use(Formulario) +function validationMessages (instance) { + instance.extend({ + validationMessages: { + required: () => 'required', + 'in': () => 'in', + min: () => 'min', + } + }) +} + +Vue.use(Formulario, { + plugins: [validationMessages] +}) describe('FormularioForm', () => { it('render a form DOM element', () => { @@ -354,7 +366,7 @@ describe('FormularioForm', () => { slots: { default: ` - {{ error }} + {{ error.message }} ` } @@ -362,8 +374,11 @@ describe('FormularioForm', () => { await wrapper.vm.$nextTick() expect(wrapper.find('span').exists()).toBe(true) + expect(wrapper.find('span').text()).toEqual('This field has an error') }) + return + it('is able to display multiple errors on multiple elements', async () => { const wrapper = mount({ template: ` diff --git a/test/unit/FormularioInput.test.js b/test/unit/FormularioInput.test.js index 1cff883..0903401 100644 --- a/test/unit/FormularioInput.test.js +++ b/test/unit/FormularioInput.test.js @@ -1,4 +1,5 @@ import Vue from 'vue' +import VueI18n from 'vue-i18n' import flushPromises from 'flush-promises' import { mount, createLocalVue } from '@vue/test-utils' import Formulario from '@/Formulario.js' @@ -7,7 +8,19 @@ import FormularioInput from '@/FormularioInput.vue' const globalRule = jest.fn((context) => { return false }) +function validationMessages (instance) { + instance.extend({ + validationMessages: { + required: () => 'required', + 'in': () => 'in', + min: () => 'min', + globalRule: () => 'globalRule', + } + }) +} + Vue.use(Formulario, { + plugins: [validationMessages], rules: { globalRule } @@ -24,7 +37,7 @@ describe('FormularioInput', () => { value: 'other value' }, scopedSlots: { - default: `
{{ error }}
` + default: `
{{ error.message }}
` } }) await flushPromises() @@ -41,7 +54,7 @@ describe('FormularioInput', () => { value: 'other value' }, scopedSlots: { - default: `
{{ error }}
` + default: `
{{ error.message }}
` } }) await flushPromises() @@ -63,7 +76,7 @@ describe('FormularioInput', () => { value: 'bar' }, scopedSlots: { - default: `
{{ error }}
` + default: `
{{ error.message }}
` } }) await flushPromises() @@ -85,7 +98,7 @@ describe('FormularioInput', () => { value: 'bar' }, scopedSlots: { - default: `
{{ error }}
` + default: `
{{ error.message }}
` } }) await flushPromises() @@ -118,7 +131,11 @@ describe('FormularioInput', () => { expect(errorObject).toEqual({ name: 'testinput', errors: [ - expect.any(String) + { + message: expect.any(String), + rule: expect.stringContaining('required'), + context: expect.any(Object) + } ], hasErrors: true }) @@ -227,7 +244,7 @@ describe('FormularioInput', () => { default: `
- {{ error }} + {{ error.message }}
` } @@ -246,7 +263,7 @@ describe('FormularioInput', () => { slots: { default: ` - {{ error }} + {{ error.message }} ` } diff --git a/yarn.lock b/yarn.lock index 9cd0f7e..28baa44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10422,6 +10422,11 @@ vue-hot-reload-api@^2.3.0: resolved "https://registry.yarnpkg.com/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz#532955cc1eb208a3d990b3a9f9a70574657e08f2" integrity sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog== +vue-i18n@^8.17.7: + version "8.17.7" + resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-8.17.7.tgz#d87e653e815a07f86e2c2dfe35261e6ea105c38a" + integrity sha512-7IQJ+72IIIxfR6Mt+X6EDmMP1i5oETFpc0FttnWKA9cgacf1DAlyho1aTItekG+AkbNs6nz4q3sYrXaPdC0irA== + vue-jest@^3.0.5: version "3.0.5" resolved "https://registry.yarnpkg.com/vue-jest/-/vue-jest-3.0.5.tgz#d6f124b542dcbff207bf9296c19413f4c40b70c9"