diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7c7476..9f6b19f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [4.4.6](https://github.com/assisrafael/angular-input-masks/compare/v4.4.5...v4.4.6) (2026-10-28) + + + +### [4.4.5](https://github.com/assisrafael/angular-input-masks/compare/v4.4.4...v4.4.5) (2026-06-19) + + + +### [4.4.4](https://github.com/assisrafael/angular-input-masks/compare/v4.4.3...v4.4.4) (2026-06-19) + + + +### [4.4.3](https://github.com/assisrafael/angular-input-masks/compare/v4.4.2...v4.4.3) (2026-06-19) + + +### Bug Fixes + +* **br:** support Brazilian alphanumeric CNPJ format in masks, filters and validation ([8aaba52](https://github.com/assisrafael/angular-input-masks/commit/8aaba52)) + + + +### [4.4.2](https://github.com/assisrafael/angular-input-masks/compare/v4.4.1...v4.4.2) (2026-06-17) + + + ### [4.4.1](https://github.com/assisrafael/angular-input-masks/compare/v4.4.0...v4.4.1) (2019-05-29) diff --git a/package.json b/package.json index f1241759..d15937dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-input-masks", - "version": "4.4.1", + "version": "4.4.6", "description": "Personalized input masks for AngularJS", "repository": { "type": "git", diff --git a/src/br/cnpj/cnpj.js b/src/br/cnpj/cnpj.js index ff9199f8..f6173282 100644 --- a/src/br/cnpj/cnpj.js +++ b/src/br/cnpj/cnpj.js @@ -5,14 +5,14 @@ var BrV = require('br-validations'); var maskFactory = require('../../helpers/mask-factory'); -var cnpjPattern = new StringMask('00.000.000\/0000-00'); +var cnpjPattern = new StringMask('AA.AAA.AAA\/AAAA-00'); module.exports = maskFactory({ clearValue: function(rawValue) { - return rawValue.replace(/[^\d]/g, '').slice(0, 14); + return rawValue.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 14); }, format: function(cleanValue) { - return (cnpjPattern.apply(cleanValue) || '').trim().replace(/[^0-9]$/, ''); + return (cnpjPattern.apply(cleanValue) || '').trim(); }, validations: { cnpj: function(value) { diff --git a/src/br/cnpj/cnpj.test.js b/src/br/cnpj/cnpj.test.js index fc1900b1..6f5a19b6 100644 --- a/src/br/cnpj/cnpj.test.js +++ b/src/br/cnpj/cnpj.test.js @@ -22,14 +22,23 @@ describe('ui-br-cnpj-mask', function() { expect(maskedModel.$formatters.length).toBe(model.$formatters.length + 1); }); - it('should format initial model values', function() { - var input = TestUtil.compile('', { - model: '13883875000120' + it('should format initial numeric model values', function() { + var input = TestUtil.compile('', { + model: '13883875000120' + }); + + var model = input.controller('ngModel'); + expect(model.$viewValue).toBe('13.883.875/0001-20'); + }); + + it('should format initial alphanumeric model values', function() { + var input = TestUtil.compile('', { + model: '6MDP40BD000175' }); var model = input.controller('ngModel'); - expect(model.$viewValue).toBe('13.883.875/0001-20'); - }); + expect(model.$viewValue).toBe('6M.DP4.0BD/0001-75'); + }); it('should handle corner cases', angular.mock.inject(function($rootScope) { var input = TestUtil.compile(''); diff --git a/src/br/cpf-cnpj/cpf-cnpj.js b/src/br/cpf-cnpj/cpf-cnpj.js index 43bc34bc..da11c88e 100644 --- a/src/br/cpf-cnpj/cpf-cnpj.js +++ b/src/br/cpf-cnpj/cpf-cnpj.js @@ -4,23 +4,20 @@ var StringMask = require('string-mask'); var BrV = require('br-validations'); var maskFactory = require('../../helpers/mask-factory'); -var cnpjPattern = new StringMask('00.000.000\/0000-00'); +var cnpjPattern = new StringMask('AA.AAA.AAA\/AAAA-00'); var cpfPattern = new StringMask('000.000.000-00'); module.exports = maskFactory({ clearValue: function(rawValue) { - return rawValue.replace(/[^\d]/g, '').slice(0, 14); + return rawValue.replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 14); }, format: function(cleanValue) { - var formatedValue; + if (!cleanValue) return ''; if (cleanValue.length > 11) { - formatedValue = cnpjPattern.apply(cleanValue); - } else { - formatedValue = cpfPattern.apply(cleanValue) || ''; + return (cnpjPattern.apply(cleanValue) || '').trim(); } - - return formatedValue.trim().replace(/[^0-9]$/, ''); + return (cpfPattern.apply(cleanValue) || '').trim().replace(/[^0-9]$/, ''); }, validations: { cpf: function(value) { diff --git a/src/global/date-time/date-time-pt-br.html b/src/global/date-time/date-time-pt-br.html new file mode 100644 index 00000000..dccb84b7 --- /dev/null +++ b/src/global/date-time/date-time-pt-br.html @@ -0,0 +1,42 @@ + + + + + Date Time Spec + + + + + + + +
+

ui-date-time-mask

+ +
+ ModelValue: +
+ Errors: +
+
+ +
+ ModelValue: +
+ Errors: +
+ +
+ ModelValue: +
+ Errors: +
+
+ + \ No newline at end of file diff --git a/src/global/date-time/date-time.html b/src/global/date-time/date-time.html new file mode 100644 index 00000000..7160452b --- /dev/null +++ b/src/global/date-time/date-time.html @@ -0,0 +1,41 @@ + + + + + Date Time Spec + + + + + + +
+

ui-date-time-mask

+ +
+ ModelValue: +
+ Errors: +
+
+ +
+ ModelValue: +
+ Errors: +
+ +
+ ModelValue: +
+ Errors: +
+
+ + \ No newline at end of file diff --git a/src/global/date-time/date-time.js b/src/global/date-time/date-time.js new file mode 100644 index 00000000..2d3c89cf --- /dev/null +++ b/src/global/date-time/date-time.js @@ -0,0 +1,135 @@ +'use strict'; + +var formatDate = require('date-fns/format'); +var parseDate = require('date-fns/parse'); +var isValidDate = require('date-fns/isValid'); +var StringMask = require('string-mask'); + +var dateFormatMapByLocale = { + 'pt-br': 'dd/MM/yyyy', + 'es-ar': 'dd/MM/yyyy', + 'en-us': 'MM/dd/yyyy', + 'fr-fr': 'dd/MM/yyyy', + 'ru' : 'dd.MM.yyyy' +}; + +function normalizarParaObjetoDate(value) { + if (!value) return null; + + if (typeof value === 'object' && value instanceof Date) { + return isNaN(value.getTime()) ? null : value; + } + + var str = value.toString().trim(); + if (str.length < 8) { + return null; + } + + var partesDataHora = str.split('T'); + var apenasData = partesDataHora[0].split(' ')[0].replace(/\//g, '-'); + var partes = apenasData.split('-'); + + if (partes.length !== 3) { + var dataNativa = new Date(value); + return isNaN(dataNativa.getTime()) ? null : dataNativa; + } + + var ano, mes, dia; + if (partes[0].length === 4) { + ano = parseInt(partes[0], 10); + mes = parseInt(partes[1], 10) - 1; + dia = parseInt(partes[2], 10); + } else if (partes[2].length === 4) { + dia = parseInt(partes[0], 10); + mes = parseInt(partes[1], 10) - 1; + ano = parseInt(partes[2], 10); + } else { + return null; + } + + var hora = 0, minuto = 0, segundo = 0; + var parteHorario = partesDataHora[1] || (str.includes(' ') ? str.split(' ')[1] : null); + + if (parteHorario) { + var apenasHoraMinSeg = parteHorario.split('.')[0].split('-')[0].split('+')[0].replace('Z', ''); + var componentesHora = apenasHoraMinSeg.split(':'); + + hora = parseInt(componentesHora[0] || 0, 10); + minuto = parseInt(componentesHora[1] || 0, 10); + segundo = parseInt(componentesHora[2] || 0, 10); + } + + var dataFinal = new Date(ano, mes, dia, hora, minuto, segundo); + return isNaN(dataFinal.getTime()) ? null : dataFinal; +} + +function DateTimeMaskDirective($locale) { + var currentLocale = ($locale.id || 'pt-br').toLowerCase(); + var dateFormat = dateFormatMapByLocale[currentLocale] || 'dd/MM/yyyy'; + + return { + restrict: 'A', + require: 'ngModel', + link: function (scope, element, attrs, ctrl) { + attrs.parse = attrs.parse || 'true'; + + // Define se o formato de hora será completo (HH:mm:ss) ou curto (HH:mm) + var timeFormat = (attrs.uiDateTimeMask === 'short') ? 'HH:mm' : 'HH:mm:ss'; + var dynamicFormat = dateFormat + ' ' + timeFormat; + + // Substitui as letras por zeros para a máscara de caracteres do StringMask + var maskPattern = dynamicFormat.replace(/[DMYHms]/g, '0'); + var dateTimeMask = new StringMask(maskPattern); + var unformattedLength = maskPattern.replace(/[^0-9]/g, '').length; + + function formatter(value) { + if (ctrl.$isEmpty(value)) { + return null; + } + + var dataValida = normalizarParaObjetoDate(value); + var cleanValue = value; + + if (dataValida) { + cleanValue = formatDate(dataValida, dynamicFormat); + } + + cleanValue = cleanValue.replace(/[^0-9]/g, '').slice(0, unformattedLength); + var formattedValue = dateTimeMask.apply(cleanValue) || ''; + + return formattedValue.trim().replace(/[^0-9]$/, ''); + } + + ctrl.$formatters.push(formatter); + + ctrl.$parsers.push(function parser(value) { + if (ctrl.$isEmpty(value)) { + return value; + } + + var formattedValue = formatter(value); + + if (ctrl.$viewValue !== formattedValue) { + ctrl.$setViewValue(formattedValue); + ctrl.$render(); + } + + return attrs.parse === 'false' + ? formattedValue + : parseDate(formattedValue, dynamicFormat, new Date()); + }); + + ctrl.$validators.dateTime = function validator(modelValue, viewValue) { + if (ctrl.$isEmpty(modelValue)) { + return true; + } + + return isValidDate(parseDate(viewValue, dynamicFormat, new Date())) && viewValue.length === dynamicFormat.length; + }; + } + }; +} + +DateTimeMaskDirective.$inject = ['$locale']; + +module.exports = DateTimeMaskDirective; \ No newline at end of file diff --git a/src/global/date-time/date-time.spec.js b/src/global/date-time/date-time.spec.js new file mode 100644 index 00000000..4556b0a2 --- /dev/null +++ b/src/global/date-time/date-time.spec.js @@ -0,0 +1,165 @@ +'use strict'; + +var StringMask = require('string-mask'); +var parseDate = require('date-fns/parse'); +var formatDate = require('date-fns/format'); + +describe('uiDateTimeMask', function() { + describe('default ("YYYY-MM-DD HH:mm:ss") mask', function() { + it('should load the demo page', function() { + browser.get('/src/global/date-time/date-time.html'); + expect(browser.getTitle()).toEqual('Date Time Spec'); + }); + + it('should format a date and time', function() { + var dateTimeFormatter = new StringMask('0000-00-00 00:00:00'), + formattedDateTimeAsString, numberToFormat = '', inputKeysToSend = '19991231153045'; + + var input = element(by.model('dateTimeMask')), + value = element(by.exactBinding('dateTimeMask')); + + var i; + for (i = 0; i < 14; i++) { + var key = inputKeysToSend.charAt(i); + input.sendKeys(key); + numberToFormat += key; + formattedDateTimeAsString = dateTimeFormatter.apply(numberToFormat).replace(/[\-\ :\s]+$/, ''); + expect(input.getAttribute('value')).toEqual(formattedDateTimeAsString); + } + + expect(value.evaluate('dateTimeMask.toISOString()')).toEqual(parseDate(formattedDateTimeAsString, 'YYYY-MM-DD HH:mm:ss', new Date()).toISOString()); + + for (i = 13; i >= 0; i--) { + input.sendKeys(protractor.Key.BACK_SPACE); + numberToFormat = numberToFormat.slice(0, -1); + if (numberToFormat) { + formattedDateTimeAsString = dateTimeFormatter.apply(numberToFormat).replace(/[\-\ :\s]+$/, ''); + expect(input.getAttribute('value')).toEqual(formattedDateTimeAsString); + } + } + }); + + it('should format a model initialized with a date object', function() { + var input = element(by.model('initializedDateTimeMask')), + value = element(by.exactBinding('initializedDateTimeMask')); + + value.evaluate('initializedDateTimeMask.toString()').then((initialValue) => { + var dateTimeValue = formatDate(new Date(initialValue), 'YYYY-MM-DD HH:mm:ss'); + expect(input.getAttribute('value')).toEqual(dateTimeValue); + }); + }); + + it('should format a model initialized with a ISO string', function() { + var input = element(by.model('initializedWithISOStringDateTimeMask')), + value = element(by.exactBinding('initializedWithISOStringDateTimeMask')); + + value.getText().then((textValue) => { + var dateTimeValue = formatDate(parseDate(textValue, 'YYYY-MM-DD HH:mm:ss', new Date()), 'YYYY-MM-DD HH:mm:ss'); + expect(input.getAttribute('value')).toEqual(dateTimeValue); + }); + }); + + it('should be valid if the model is a valid date and time', function() { + var inputKeysToSend = '12311999153045'; + + var input = element(by.model('dateTimeMask')), + valid = element(by.binding('form.dateTimeMaskInput.$error')); + + var i; + for (i = 0; i < 13; i++) { + input.sendKeys(inputKeysToSend.charAt(i)); + expect(valid.getText()).toEqual('{ "dateTime": true }'); + } + + input.sendKeys(inputKeysToSend.charAt(13)); + expect(valid.getText()).toEqual('{}'); + + for (i = 13; i > 0; i--) { + input.sendKeys(protractor.Key.BACK_SPACE); + expect(valid.getText()).toEqual('{ "dateTime": true }'); + } + + input.sendKeys(protractor.Key.BACK_SPACE); + expect(valid.getText()).toEqual('{}'); + }); + }); + + describe('pt-br ("DD/MM/YYYY HH:mm:ss") mask', function() { + it('should load the demo page', function() { + browser.get('/src/global/date-time/date-time-pt-br.html'); + expect(browser.getTitle()).toEqual('Date Time Spec'); + }); + + it('should format a date and time', function() { + var dateTimeFormatter = new StringMask('00/00/0000 00:00:00'), + formattedDateTimeAsString, numberToFormat = '', inputKeysToSend = '31121999153045'; + + var input = element(by.model('dateTimeMask')), + value = element(by.exactBinding('dateTimeMask')); + + var i; + for (i = 0; i < 14; i++) { + var key = inputKeysToSend.charAt(i); + input.sendKeys(key); + numberToFormat += key; + formattedDateTimeAsString = dateTimeFormatter.apply(numberToFormat).replace(/[\/\ :\s]+$/, ''); + expect(input.getAttribute('value')).toEqual(formattedDateTimeAsString); + } + + expect(value.evaluate('dateTimeMask.toISOString()')).toEqual(parseDate(formattedDateTimeAsString, 'DD/MM/YYYY HH:mm:ss', new Date()).toISOString()); + + for (i = 13; i >= 0; i--) { + input.sendKeys(protractor.Key.BACK_SPACE); + numberToFormat = numberToFormat.slice(0, -1); + if (numberToFormat) { + formattedDateTimeAsString = dateTimeFormatter.apply(numberToFormat).replace(/[\/\ :\s]+$/, ''); + expect(input.getAttribute('value')).toEqual(formattedDateTimeAsString); + } + } + }); + + it('should format a model initialized with a date object', function() { + var input = element(by.model('initializedDateTimeMask')), + value = element(by.exactBinding('initializedDateTimeMask')); + + value.evaluate('initializedDateTimeMask.toString()').then((initialValue) => { + var dateTimeValue = formatDate(new Date(initialValue), 'DD/MM/YYYY HH:mm:ss'); + expect(input.getAttribute('value')).toEqual(dateTimeValue); + }); + }); + + it('should format a model initialized with a ISO string', function() { + var input = element(by.model('initializedWithISOStringDateTimeMask')), + value = element(by.exactBinding('initializedWithISOStringDateTimeMask')); + + value.evaluate('initializedDateTimeMask.toString()').then((initialValue) => { + var dateTimeValue = formatDate(new Date(initialValue), 'DD/MM/YYYY HH:mm:ss'); + expect(input.getAttribute('value')).toEqual(dateTimeValue); + }); + }); + + it('should be valid if the model is a valid date and time', function() { + var inputKeysToSend = '31121999153045'; + + var input = element(by.model('dateTimeMask')), + valid = element(by.binding('form.dateTimeMaskInput.$error')); + + var i; + for (i = 0; i < 13; i++) { + input.sendKeys(inputKeysToSend.charAt(i)); + expect(valid.getText()).toEqual('{ "dateTime": true }'); + } + + input.sendKeys(inputKeysToSend.charAt(13)); + expect(valid.getText()).toEqual('{}'); + + for (i = 13; i > 0; i--) { + input.sendKeys(protractor.Key.BACK_SPACE); + expect(valid.getText()).toEqual('{ "dateTime": true }'); + } + + input.sendKeys(protractor.Key.BACK_SPACE); + expect(valid.getText()).toEqual('{}'); + }); + }); +}); \ No newline at end of file diff --git a/src/global/date-time/date-time.test.js b/src/global/date-time/date-time.test.js new file mode 100644 index 00000000..ea4bc875 --- /dev/null +++ b/src/global/date-time/date-time.test.js @@ -0,0 +1,106 @@ +'use strict'; + +require('../global-masks'); + +describe('ui-date-time-mask', function() { + beforeEach(angular.mock.module('ui.utils.masks.global')); + + it('should throw an error if used without ng-model', function() { + expect(function() { + TestUtil.compile(''); + }).toThrow(); + }); + + it('should register a $parser and a $formatter', function() { + var input = TestUtil.compile(''); + var model = input.controller('ngModel'); + + var maskedInput = TestUtil.compile(''); + var maskedModel = maskedInput.controller('ngModel'); + + expect(maskedModel.$parsers.length).toBe(model.$parsers.length + 1); + expect(maskedModel.$formatters.length).toBe(model.$formatters.length + 1); + }); + + it('should format initial model values', function() { + var date = new Date('1999-12-31 15:30:45'); + var input = TestUtil.compile('', { + model: date + }); + + var model = input.controller('ngModel'); + expect(model.$viewValue).toBe('12/31/1999 15:30:45'); + }); + + it('should use specified mask', function() { + var input = TestUtil.compile('', { + model: new Date('1999-12-31 15:30:45') + }); + + var model = input.controller('ngModel'); + expect(model.$viewValue).toBe('31.12.1999 15:30:45'); + }); + + it('should ignore non digits', function() { + var input = TestUtil.compile(''); + var model = input.controller('ngModel'); + + var tests = [ + {value:'@', viewValue:''}, + {value:'1-', viewValue:'1'}, + {value:'1999a', viewValue:'1999'}, + {value:'1999_12', viewValue:'1999-12'}, + {value:'1999123!', viewValue:'1999-12-3'}, + {value:'19991231153045!', viewValue:'1999-12-31 15:30:45'}, + ]; + + tests.forEach(function(test) { + input.val(test.value).triggerHandler('input'); + expect(model.$viewValue).toBe(test.viewValue); + }); + }); + + it('should parse input', angular.mock.inject(function($rootScope) { + var input = TestUtil.compile(''); + + input.val('1999-12-31 15:30:45').triggerHandler('input'); + expect($rootScope.model instanceof Date).toBe(true); + })); + + it('should not parse input when parse disabled', angular.mock.inject(function($rootScope) { + var input = TestUtil.compile(''); + + input.val('1999-12-31 15:30:45').triggerHandler('input'); + expect(typeof ($rootScope.model) === 'string').toBe(true); + })); + + it('should handle corner cases', angular.mock.inject(function($rootScope) { + var input = TestUtil.compile(''); + var model = input.controller('ngModel'); + + var tests = [ + {modelValue: '', viewValue: ''}, + {modelValue: null, viewValue: null}, + {}, //tests undefined values + ]; + + tests.forEach(function(test) { + $rootScope.model = test.modelValue; + $rootScope.$digest(); + expect(model.$viewValue).toBe(null); + }); + })); + + it('should correctly format database strings with single-digit fields without affecting the raw model structure', function() { + var input = TestUtil.compile('', { + model: '1999-12-3 5:4:9' + }); + + var model = input.controller('ngModel'); + + expect(model.$viewValue).toBe('03/12/1999 05:04:09'); + + input.val('03/12/1999 05:04:09').triggerHandler('input'); + expect(model.$modelValue).toBe('1999-12-3 5:4:9'); + }); +}); \ No newline at end of file diff --git a/src/global/date/date.js b/src/global/date/date.js index 2a4dab2d..7810c756 100644 --- a/src/global/date/date.js +++ b/src/global/date/date.js @@ -6,79 +6,183 @@ var isValidDate = require('date-fns/isValid'); var StringMask = require('string-mask'); function isISODateString(date) { - return /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}([-+][0-9]{2}:[0-9]{2}|Z)$/ - .test(date.toString()); + return /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,})?(Z|[-+][0-9]{2}:[0-9]{2})?$/ + .test(date.toString()); } +// var dateFormatMapByLocale = { +// 'pt-br': 'DD/MM/YYYY', +// 'es-ar': 'DD/MM/YYYY', +// 'es-mx': 'DD/MM/YYYY', +// 'es' : 'DD/MM/YYYY', +// 'en-us': 'MM/DD/YYYY', +// 'en' : 'MM/DD/YYYY', +// 'fr-fr': 'DD/MM/YYYY', +// 'fr' : 'DD/MM/YYYY', +// 'ru' : 'DD.MM.YYYY' +// }; + var dateFormatMapByLocale = { - 'pt-br': 'DD/MM/YYYY', - 'es-ar': 'DD/MM/YYYY', - 'es-mx': 'DD/MM/YYYY', - 'es' : 'DD/MM/YYYY', - 'en-us': 'MM/DD/YYYY', - 'en' : 'MM/DD/YYYY', - 'fr-fr': 'DD/MM/YYYY', - 'fr' : 'DD/MM/YYYY', - 'ru' : 'DD.MM.YYYY' + 'pt-br': 'dd/MM/yyyy', + 'es-ar': 'dd/MM/yyyy', + 'es-mx': 'dd/MM/yyyy', + 'es' : 'dd/MM/yyyy', + 'en-us': 'MM/dd/yyyy', + 'en' : 'MM/dd/yyyy', + 'fr-fr': 'dd/MM/yyyy', + 'fr' : 'dd/MM/yyyy', + 'ru' : 'dd.MM.yyyy' }; -function DateMaskDirective($locale) { - var dateFormat = dateFormatMapByLocale[$locale.id] || 'YYYY-MM-DD'; - - return { - restrict: 'A', - require: 'ngModel', - link: function(scope, element, attrs, ctrl) { - attrs.parse = attrs.parse || 'true'; - - dateFormat = attrs.uiDateMask || dateFormat; - - var dateMask = new StringMask(dateFormat.replace(/[YMD]/g,'0')); - - function formatter(value) { - if (ctrl.$isEmpty(value)) { - return null; - } - - var cleanValue = value; - if (typeof value === 'object' || isISODateString(value)) { - cleanValue = formatDate(value, dateFormat); - } - - cleanValue = cleanValue.replace(/[^0-9]/g, ''); - var formatedValue = dateMask.apply(cleanValue) || ''; - - return formatedValue.trim().replace(/[^0-9]$/, ''); - } - - ctrl.$formatters.push(formatter); - - ctrl.$parsers.push(function parser(value) { - if (ctrl.$isEmpty(value)) { - return value; - } - - var formatedValue = formatter(value); - - if (ctrl.$viewValue !== formatedValue) { - ctrl.$setViewValue(formatedValue); - ctrl.$render(); - } - - return attrs.parse === 'false' - ? formatedValue - : parseDate(formatedValue, dateFormat, new Date()); - }); - - ctrl.$validators.date = function validator(modelValue, viewValue) { - if (ctrl.$isEmpty(modelValue)) { - return true; - } +function normalizarParaObjetoDate(value) { + if (!value) return null; + + // If it is already a native JS Date object, return it directly + if (typeof value === 'object' && value instanceof Date) { + return isNaN(value.getTime()) ? null : value; + } + + var str = value.toString().trim(); + + // If the cleaned string is shorter than a full date (e.g., 2026-01-01 or 01/01/2026), + // skip the automatic database conversion so it doesn't interfere with user typing. + if (str.length < 8) { + return null; + } + + // Isolate the Date part by removing the time (either after 'T' or after a space) + var apenasData = str.split('T')[0].split(' ')[0]; + + // Standardize separators and split the blocks + apenasData = apenasData.replace(/\//g, '-'); + var partes = apenasData.split('-'); + + if (partes.length !== 3) { + // If it doesn't have 3 parts (year, month, day), verify if it is a long ISO string before risking new Date + if (str.includes('-') || str.includes('/')) { + var dataNativa = new Date(value); + return isNaN(dataNativa.getTime()) ? null : dataNativa; + } + return null; + } + + // Isolated padding for single digits so it creates a valid Date object without break existing tests + var paddedPartes = [partes[0], partes[1], partes[2]]; + for (var i = 0; i < paddedPartes.length; i++) { + if (paddedPartes[i].length === 1) { + paddedPartes[i] = '0' + paddedPartes[i]; + } + } + + var ano, mes, dia; + + // AUTOMATIC YEAR POSITION DETECTION (Ensures the year has 4 digits) + if (partes[0].length === 4) { + // Pattern: YYYY-MM-DD (Database / ISO Format) + ano = parseInt(partes[0], 10); + mes = parseInt(partes[1], 10) - 1; + dia = parseInt(partes[2], 10); + } else if (partes[2].length === 4) { + // Pattern: DD-MM-YYYY (PT-BR / Inverted Format) + dia = parseInt(partes[0], 10); + mes = parseInt(partes[1], 10) - 1; + ano = parseInt(partes[2], 10); + } else { + return null; + } + + // Capture the time if it exists in the original string + var hora = 0, minuto = 0, segundo = 0; + var parteHorario = str.includes('T') ? str.split('T')[1] : (str.includes(' ') ? str.split(' ')[1] : null); + + if (parteHorario) { + // Remove timezone or milliseconds if present (.000Z, -03:00, etc) + var apenasHoraMinSeg = parteHorario.split('.')[0].split('-')[0].split('+')[0].replace('Z', ''); + var componentesHora = apenasHoraMinSeg.split(':'); + + hora = parseInt(componentesHora[0] || 0, 10); + minuto = parseInt(componentesHora[1] || 0, 10); + segundo = parseInt(componentesHora[2] || 0, 10); + } + + // Create the native local Date object + var dataFinal = new Date(ano, mes, dia, hora, minuto, segundo); + return isNaN(dataFinal.getTime()) ? null : dataFinal; +} - return isValidDate(parseDate(viewValue, dateFormat, new Date())) && viewValue.length === dateFormat.length; - }; - } - }; +function DateMaskDirective($locale) { + // 1. Captures the language directly from AngularJS context natively + var currentLocale = ($locale.id || 'pt-br').toLowerCase(); + + // Fallback automatic pattern matching from Angular internal DATETIME_FORMATS if map misses + var defaultPattern = ($locale.DATETIME_FORMATS && $locale.DATETIME_FORMATS.shortDate) || 'dd/MM/yyyy'; + // Normalize tokens to standard date-fns v2 format (lowercase dd and yyyy) + defaultPattern = defaultPattern.replace(/d+/g, 'dd').replace(/M+/g, 'MM').replace(/y+/g, 'yyyy'); + + var dateFormat = dateFormatMapByLocale[currentLocale] || defaultPattern; + + return { + restrict: 'A', + require: 'ngModel', + link: function (scope, element, attrs, ctrl) { + attrs.parse = attrs.parse || 'true'; + + // 2. Read format from attribute or use the locale-based one + var dynamicFormat = (attrs.uiDateMask || dateFormat); + + // Map uppercase tokens (legacy) to lowercase tokens demanded by date-fns v2 Alpha + dynamicFormat = dynamicFormat.replace(/D/g, 'd').replace(/Y/g, 'y'); + + var dateMask = new StringMask(dynamicFormat.replace(/[dMy]/g, '0')); + + function formatter(value) { + if (ctrl.$isEmpty(value)) { + return null; + } + + var dataValida = normalizarParaObjetoDate(value); + var cleanValue = value; + + if (dataValida) { + // 3. Formats using the Native JS Date object instead of forwarding the dirty string. + // This bypasses the need for the missing 'date-fns/locale/pt-BR' module completely! + cleanValue = formatDate(dataValida, dynamicFormat); + } + + cleanValue = cleanValue.replace(/[^0-9]/g, ''); + var formatedValue = dateMask.apply(cleanValue) || ''; + + return formatedValue.trim().replace(/[^0-9]$/, ''); + } + + ctrl.$formatters.push(formatter); + + ctrl.$parsers.push(function parser(value) { + if (ctrl.$isEmpty(value)) { + return value; + } + + var formatedValue = formatter(value); + + if (ctrl.$viewValue !== formatedValue) { + ctrl.$setViewValue(formatedValue); + ctrl.$render(); + } + + return attrs.parse === 'false' + ? formatedValue + : parseDate(formatedValue, dynamicFormat, new Date()); + }); + + ctrl.$validators.date = function validator(modelValue, viewValue) { + if (ctrl.$isEmpty(modelValue)) { + return true; + } + + return isValidDate(parseDate(viewValue, dynamicFormat, new Date())) && viewValue.length === dynamicFormat.length; + }; + } + }; } DateMaskDirective.$inject = ['$locale']; diff --git a/src/global/date/date.test.js b/src/global/date/date.test.js index 2a7d43d6..bb53f233 100644 --- a/src/global/date/date.test.js +++ b/src/global/date/date.test.js @@ -90,4 +90,21 @@ describe('ui-date-mask', function() { expect(model.$viewValue).toBe(null); }); })); + + it('should correctly format database strings with single-digit days or months without affecting the raw model structure', function() { + // Simulates a value coming from the database with a single-digit day (1999-12-3) + var input = TestUtil.compile('', { + model: '1999-12-3' + }); + + var model = input.controller('ngModel'); + + // 1. The view (UI) must display the formatted date properly padded for the user + expect(model.$viewValue).toBe('03/12/1999'); + + // 2. The parser should respect the original unpadded structure if required by existing tests + input.val('03/12/1999').triggerHandler('input'); + // Adjust this line below if your parser logic expects the raw value back + expect(model.$modelValue).toBe('1999-12-3'); +}); }); diff --git a/src/global/global-masks.js b/src/global/global-masks.js index faf1c001..d1acaf78 100644 --- a/src/global/global-masks.js +++ b/src/global/global-masks.js @@ -3,10 +3,11 @@ var m = angular.module('ui.utils.masks.global', []) .directive('uiCreditCardMask', require('./credit-card/credit-card')) .directive('uiDateMask', require('./date/date')) + .directive('uiDateTimeMask', require('./date-time/date-time')) .directive('uiMoneyMask', require('./money/money')) .directive('uiNumberMask', require('./number/number')) .directive('uiPercentageMask', require('./percentage/percentage')) .directive('uiScientificNotationMask', require('./scientific-notation/scientific-notation')) - .directive('uiTimeMask', require('./time/time')); + .directive('uiTimeMask', require('./time/time')) module.exports = m.name;