diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec8bc9..6c0b4cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [next-version] - next-release-date +### Added +* Localization support. + ### Changed * Switched to Typescript. diff --git a/README.md b/README.md index 3517700..219ac0d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,20 @@ const schema = yup.string().min(6).minUppercase(3).maxRepeating(2).minWords(2) await schema.isValid('Now, THIS is some password.') // => true await schema.isValid('But thiiis is not.') // => false ``` +Localize your error messages: +```js +yup.setLocale({ + string: { + minLowercase: 'Localized message (path=${path};length=${length})', + minUppercase: 'Localized message (path=${path};length=${length})', + minNumbers: 'Localized message (path=${path};length=${length})', + minSymbols: 'Localized message (path=${path};length=${length})', + maxRepeating: 'Localized message (path=${path};length=${length})', + minWords: 'Localized message (path=${path};length=${length})', + }, // when using typescript, you may want to append `as any` to the end + // of this object to avoid type errors. +}) +``` ## API diff --git a/examples/localization.ts b/examples/localization.ts new file mode 100644 index 0000000..3750579 --- /dev/null +++ b/examples/localization.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-console, no-template-curly-in-string */ +import * as yup from 'yup' +import YupPassword from '../src' +YupPassword(yup) + +// StringLocale declaration merging does not seem to +// work, so we have to declare yup password's locale +// overrides as a separate "Record" object. +const locale: Record<string, any> = { + minSymbols: 'This is now localized. path=${path};length=${length}', +} + +yup.setLocale({ + string: { + ...locale, + // Add other messages here + }, +}) + +const schema = yup.object({ + password: yup.string().password(), +}) + +const input = { + password: 'Password1', +} + +schema.validate(input, { abortEarly: false }) + .catch(e => console.error(e.errors)) diff --git a/src/index.ts b/src/index.ts index 366f112..2c3403b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,22 @@ -/* eslint-disable @typescript-eslint/method-signature-style, no-template-curly-in-string */ +/* eslint-disable + @typescript-eslint/restrict-template-expressions, + @typescript-eslint/method-signature-style, + no-template-curly-in-string +*/ -// NOTES. -// 1. error message pluralization will have to go away to support localization (reason: yup limitation) -// 2. (breaking) in a future release setup will be immediately invoked and the export will be removed - -// pluralize -function p (word: string, num: number): string { - return num === 1 ? word : `${word}s` -} - -function isNullOrUndefined (value: any): value is null | undefined { - return value === null || value === undefined -} +import type { Message } from 'yup' declare module 'yup' { + // for declaration merging to work, StringLocale prob needs to be exported from yup + interface StringLocale { + // ..rename params from length to min, max, etc? + minLowercase?: Message<{ length: number }> + minUppercase?: Message<{ length: number }> + minNumbers?: Message<{ length: number }> + minSymbols?: Message<{ length: number }> + maxRepeating?: Message<{ length: number }> + minWords?: Message<{ length: number }> + } interface StringSchema { minLowercase (length?: number, message?: string): StringSchema minUppercase (length?: number, message?: string): StringSchema @@ -25,13 +28,33 @@ declare module 'yup' { } } -export default function setup (yup: typeof import('yup')): void { - yup.addMethod(yup.string, 'minLowercase', function minLowercase (length: number = 1, message?: string) { - const msg = message || '${path} must contain at least ${length} lowercase ' + p('letter', length) +// pluralize +function p (word: string, num: number): string { + return num === 1 ? word : `${word}s` +} + +const messages: Record<string, any> = { + minLowercase: ({ path, length }) => `${path} must contain at least ${length} lowercase ${p('letter', length)}`, + minUppercase: ({ path, length }) => `${path} must contain at least ${length} uppercase ${p('letter', length)}`, + minNumbers: ({ path, length }) => `${path} must contain at least ${length} ${p('number', length)}`, + minSymbols: ({ path, length }) => `${path} must contain at least ${length} ${p('symbol', length)}`, + maxRepeating: ({ path, length }) => `${path} must not contain sequences of more than ${length} repeated characters`, + minWords: ({ path, length }) => `${path} must contain at least ${length} ${p('word', length)}`, +} + +function isNullOrUndefined (value: any): value is null | undefined { + return value === null || value === undefined +} + +export default function setup ({ setLocale, defaultLocale, addMethod, string }: typeof import('yup')): void { + setLocale({ string: messages }) + const locale = defaultLocale.string as typeof messages + + addMethod(string, 'minLowercase', function minLowercase (length: number = 1, message: string = locale?.minLowercase) { return this.test({ + message, name: 'minLowercase', exclusive: true, - message: msg, params: { length }, test (value) { return isNullOrUndefined(value) || (value.match(/[a-z]/g) || []).length >= length @@ -39,12 +62,11 @@ export default function setup (yup: typeof import('yup')): void { }) }) - yup.addMethod(yup.string, 'minUppercase', function minUppercase (length: number = 1, message?: string) { - const msg = message || '${path} must contain at least ${length} uppercase ' + p('letter', length) + addMethod(string, 'minUppercase', function minUppercase (length: number = 1, message: string = locale?.minUppercase) { return this.test({ + message, name: 'minUppercase', exclusive: true, - message: msg, params: { length }, test (value) { return isNullOrUndefined(value) || (value.match(/[A-Z]/g) || []).length >= length @@ -52,12 +74,11 @@ export default function setup (yup: typeof import('yup')): void { }) }) - yup.addMethod(yup.string, 'minNumbers', function minNumbers (length: number = 1, message?: string) { - const msg = message || '${path} must contain at least ${length} ' + p('number', length) + addMethod(string, 'minNumbers', function minNumbers (length: number = 1, message: string = locale?.minNumbers) { return this.test({ + message, name: 'minNumbers', exclusive: true, - message: msg, params: { length }, test (value) { return isNullOrUndefined(value) || (value.match(/[0-9]/g) || []).length >= length @@ -65,12 +86,11 @@ export default function setup (yup: typeof import('yup')): void { }) }) - yup.addMethod(yup.string, 'minSymbols', function minSymbols (length: number = 1, message?: string) { - const msg = message || '${path} must contain at least ${length} ' + p('symbol', length) + addMethod(string, 'minSymbols', function minSymbols (length: number = 1, message: string = locale?.minSymbols) { return this.test({ + message, name: 'minSymbols', exclusive: true, - message: msg, params: { length }, test (value) { return isNullOrUndefined(value) || (value.match(/[^a-zA-Z0-9\s]/g) || []).length >= length @@ -78,12 +98,11 @@ export default function setup (yup: typeof import('yup')): void { }) }) - yup.addMethod(yup.string, 'maxRepeating', function maxRepeating (length: number = 2, message?: string) { - const msg = message || '${path} must not contain sequences of more than ${length} repeated ' + p('character', length) + addMethod(string, 'maxRepeating', function maxRepeating (length: number = 2, message: string = locale?.maxRepeating) { return this.test({ + message, name: 'maxRepeating', exclusive: true, - message: msg, params: { length }, test (value) { return isNullOrUndefined(value) || ! new RegExp(`(.)\\1{${length},}`).test(value) @@ -91,22 +110,19 @@ export default function setup (yup: typeof import('yup')): void { }) }) - yup.addMethod(yup.string, 'minWords', function minWords (length: number = 2, message?: string) { - const msg = message || '${path} must contain at least ${length} ' + p('word', length) - // eslint-disable-next-line prefer-regex-literals - const rx = new RegExp('[a-zA-Z0-9]') + addMethod(string, 'minWords', function minWords (length: number = 2, message: string = locale?.minWords) { return this.test({ + message, name: 'minWords', exclusive: true, - message: msg, params: { length }, test (value) { - return isNullOrUndefined(value) || value.split(' ').filter(v => !! v && rx.test(v)).length >= length + return isNullOrUndefined(value) || value.split(' ').filter(v => !! v && /[a-zA-Z0-9]/.test(v)).length >= length }, }) }) - yup.addMethod(yup.string, 'password', function password () { + addMethod(string, 'password', function password () { return this .min(8) .max(250) diff --git a/tests/locale.spec.ts b/tests/locale.spec.ts new file mode 100644 index 0000000..797fcf4 --- /dev/null +++ b/tests/locale.spec.ts @@ -0,0 +1,118 @@ +/* eslint-disable + no-template-curly-in-string +*/ + +import * as yup from 'yup' +import setup from '../src' +setup(yup) +const schema = yup.string() + +describe('Locale support', () => { + describe('test default message: singular', () => { + test('minLowercase()', async () => { + const errorMessage = await schema.minLowercase(1).validate('A').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 1 lowercase letter') + }) + + test('minUppercase()', async () => { + const errorMessage = await schema.minUppercase(1).validate('a').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 1 uppercase letter') + }) + + test('minNumbers()', async () => { + const errorMessage = await schema.minNumbers(1).validate('a').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 1 number') + }) + + test('minSymbols()', async () => { + const errorMessage = await schema.minSymbols(1).validate('a').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 1 symbol') + }) + + test('maxRepeating()', async () => { + const errorMessage = await schema.maxRepeating(1).validate('aa').catch(e => e.message) + expect(errorMessage).toBe('this must not contain sequences of more than 1 repeated characters') + }) + + test('minWords()', async () => { + const errorMessage = await schema.minWords(1).validate('$').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 1 word') + }) + }) // group + + describe('test default message: plural', () => { + test('minLowercase()', async () => { + const errorMessage = await schema.minLowercase(2).validate('a').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 2 lowercase letters') + }) + + test('minUppercase()', async () => { + const errorMessage = await schema.minUppercase(2).validate('A').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 2 uppercase letters') + }) + + test('minNumbers()', async () => { + const errorMessage = await schema.minNumbers(2).validate('1').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 2 numbers') + }) + + test('minSymbols()', async () => { + const errorMessage = await schema.minSymbols(2).validate('!').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 2 symbols') + }) + + test('maxRepeating()', async () => { + const errorMessage = await schema.maxRepeating(2).validate('aaa').catch(e => e.message) + expect(errorMessage).toBe('this must not contain sequences of more than 2 repeated characters') + }) + + test('minWords()', async () => { + const errorMessage = await schema.minWords(2).validate('a').catch(e => e.message) + expect(errorMessage).toBe('this must contain at least 2 words') + }) + }) // group + + describe('test localized message', () => { + beforeAll(() => { + yup.setLocale({ + string: { + minLowercase: 'name=minLowercase;path=${path};length=${length}', + minUppercase: 'name=minUppercase;path=${path};length=${length}', + minNumbers: 'name=minNumbers;path=${path};length=${length}', + minSymbols: 'name=minSymbols;path=${path};length=${length}', + maxRepeating: 'name=maxRepeating;path=${path};length=${length}', + minWords: 'name=minWords;path=${path};length=${length}', + } as any, + }) + }) + test('minLowercase()', async () => { + const errorMessage = await schema.minLowercase(2).validate('a').catch(e => e.message) + expect(errorMessage).toBe('name=minLowercase;path=this;length=2') + }) + + test('minUppercase()', async () => { + const errorMessage = await schema.minUppercase(2).validate('A').catch(e => e.message) + expect(errorMessage).toBe('name=minUppercase;path=this;length=2') + }) + + test('minNumbers()', async () => { + const errorMessage = await schema.minNumbers(2).validate('1').catch(e => e.message) + expect(errorMessage).toBe('name=minNumbers;path=this;length=2') + }) + + test('minSymbols()', async () => { + const errorMessage = await schema.minSymbols(2).validate('!').catch(e => e.message) + expect(errorMessage).toBe('name=minSymbols;path=this;length=2') + }) + + test('maxRepeating()', async () => { + const errorMessage = await schema.maxRepeating(2).validate('aaa').catch(e => e.message) + expect(errorMessage).toBe('name=maxRepeating;path=this;length=2') + }) + + test('minWords()', async () => { + const errorMessage = await schema.minWords(2).validate('a').catch(e => e.message) + expect(errorMessage).toBe('name=minWords;path=this;length=2') + }) + }) // group +}) // group