diff --git a/projects/demo-integrations/src/tests/kit/date/date-segments-zero-padding.cy.ts b/projects/demo-integrations/src/tests/kit/date/date-segments-zero-padding.cy.ts new file mode 100644 index 000000000..d9f635db5 --- /dev/null +++ b/projects/demo-integrations/src/tests/kit/date/date-segments-zero-padding.cy.ts @@ -0,0 +1,99 @@ +import {DemoPath} from '@demo/constants'; + +import {BROWSER_SUPPORTS_REAL_EVENTS} from '../../../support/constants'; + +describe('Date | Date segments zero padding (pads digits with zero if date segment exceeds its max possible value)', () => { + describe('[mode]="dd.mm.yyyy"', () => { + beforeEach(() => { + cy.visit(`/${DemoPath.Date}/API?mode=dd%2Fmm%2Fyyyy&dateSeparator=.`); + cy.get('#demo-content input') + .should('be.visible') + .first() + .focus() + .as('input'); + }); + + describe('pads digit > 3 with zero for days', () => { + [0, 1, 2, 3].forEach(digit => { + it(`Type ${digit} => ${digit}`, () => { + cy.get('@input') + .type(`${digit}`) + .should('have.value', `${digit}`) + .should('have.prop', 'selectionStart', 1) + .should('have.prop', 'selectionEnd', 1); + }); + }); + + [4, 5, 6, 7, 8, 9].forEach(digit => { + it(`Type ${digit} => 0${digit}`, () => { + cy.get('@input') + .type(`${digit}`) + .should('have.value', `0${digit}`) + .should('have.prop', 'selectionStart', `0${digit}`.length) + .should('have.prop', 'selectionEnd', `0${digit}`.length); + }); + }); + + it( + '|11|.11.2011 => Type 7 => 07.|11.2011', + BROWSER_SUPPORTS_REAL_EVENTS, + () => { + cy.get('@input') + .type('11.11.2011') + .type('{moveToStart}') + .realPress(['Shift', 'ArrowRight', 'ArrowRight']); + + cy.get('@input') + .should('have.prop', 'selectionStart', 0) + .should('have.prop', 'selectionEnd', '07'.length) + .type('7') + .should('have.value', '07.11.2011') + .should('have.prop', 'selectionStart', '07.'.length) + .should('have.prop', 'selectionEnd', '07.'.length); + }, + ); + }); + + describe('pads digit > 1 with zero for months', () => { + [0, 1].forEach(digit => { + it(`Type 01.${digit} => 01.${digit}`, () => { + cy.get('@input') + .type(`01${digit}`) + .should('have.value', `01.${digit}`) + .should('have.prop', 'selectionStart', `01.${digit}`.length) + .should('have.prop', 'selectionEnd', `01.${digit}`.length); + }); + }); + + [2, 3, 4, 5, 6, 7, 8, 9].forEach(digit => { + it(`Type 01.${digit} => 01.0${digit}`, () => { + cy.get('@input') + .type(`01${digit}`) + .should('have.value', `01.0${digit}`) + .should('have.prop', 'selectionStart', `01.0${digit}`.length) + .should('have.prop', 'selectionEnd', `01.0${digit}`.length); + }); + }); + + it( + '11.|11|.2011 => Type 2 => 11.02.|2011', + BROWSER_SUPPORTS_REAL_EVENTS, + () => { + cy.get('@input') + .type('11.11.2011') + .type('{moveToEnd}') + .type('{leftArrow}'.repeat('.2011'.length)) + .realPress(['Shift', 'ArrowLeft', 'ArrowLeft']); + + cy.get('@input') + .should('have.prop', 'selectionStart', '11.'.length) + .should('have.prop', 'selectionEnd', '11.11'.length) + .type('2') + .should('have.value', '11.02.2011') + .should('have.prop', 'selectionStart', '01.02.'.length) + .should('have.prop', 'selectionEnd', '01.02.'.length); + }, + ); + }); + }); +}); diff --git a/projects/kit/src/lib/constants/date-segment-max-values.ts b/projects/kit/src/lib/constants/date-segment-max-values.ts new file mode 100644 index 000000000..c9abe317d --- /dev/null +++ b/projects/kit/src/lib/constants/date-segment-max-values.ts @@ -0,0 +1,7 @@ +import {MaskitoDateSegments} from '../types'; + +export const DATE_SEGMENTS_MAX_VALUES: MaskitoDateSegments = { + day: 31, + month: 12, + year: 9999, +}; diff --git a/projects/kit/src/lib/constants/index.ts b/projects/kit/src/lib/constants/index.ts index 6dbfaa79a..3082dc33e 100644 --- a/projects/kit/src/lib/constants/index.ts +++ b/projects/kit/src/lib/constants/index.ts @@ -1,3 +1,4 @@ +export * from './date-segment-max-values'; export * from './default-decimal-pseudo-separators'; export * from './default-min-max-dates'; export * from './default-time-segment-max-values'; diff --git a/projects/kit/src/lib/masks/date-range/date-range-mask.ts b/projects/kit/src/lib/masks/date-range/date-range-mask.ts index 3b2d3c74a..7626c699b 100644 --- a/projects/kit/src/lib/masks/date-range/date-range-mask.ts +++ b/projects/kit/src/lib/masks/date-range/date-range-mask.ts @@ -2,12 +2,14 @@ import {MASKITO_DEFAULT_OPTIONS, MaskitoOptions} from '@maskito/core'; import {CHAR_EN_DASH, CHAR_NO_BREAK_SPACE} from '../../constants'; import { + createDateSegmentsZeroPaddingPostprocessor, createMinMaxDatePostprocessor, createValidDatePreprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; import {MaskitoDateMode, MaskitoDateSegments} from '../../types'; +import {parseDateRangeString} from '../../utils'; import {createMinMaxRangeLengthPostprocessor} from './processors/min-max-range-length-postprocessor'; import {createPseudoRangeSeparatorPreprocessor} from './processors/pseudo-range-separator-preprocessor'; import {createSwapDatesPostprocessor} from './processors/swap-dates-postprocessor'; @@ -53,6 +55,27 @@ export function maskitoDateRangeOptionsGenerator({ }), ], postprocessors: [ + createDateSegmentsZeroPaddingPostprocessor({ + dateModeTemplate, + dateSegmentSeparator: dateSeparator, + splitFn: value => ({ + dateStrings: parseDateRangeString( + value, + dateModeTemplate, + rangeSeparator, + ), + }), + uniteFn: (validatedDateStrings, initialValue) => + validatedDateStrings.reduce( + (acc, dateString, dateIndex) => + acc + + dateString + + (!dateIndex && initialValue.includes(rangeSeparator) + ? rangeSeparator + : ''), + '', + ), + }), createMinMaxDatePostprocessor({ min, max, diff --git a/projects/kit/src/lib/masks/date-range/tests/date-segments-zero-padding.spec.ts b/projects/kit/src/lib/masks/date-range/tests/date-segments-zero-padding.spec.ts new file mode 100644 index 000000000..bd22e8a73 --- /dev/null +++ b/projects/kit/src/lib/masks/date-range/tests/date-segments-zero-padding.spec.ts @@ -0,0 +1,66 @@ +import {MASKITO_DEFAULT_OPTIONS, MaskitoOptions, maskitoTransform} from '@maskito/core'; +import {maskitoDateRangeOptionsGenerator} from '@maskito/kit'; + +describe('DateRange (maskitoTransform) | Date segments zero padding', () => { + describe('[mode]="yyyy/mm/dd"', () => { + let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; + + beforeEach(() => { + options = maskitoDateRangeOptionsGenerator({ + mode: 'yyyy/mm/dd', + dateSeparator: '/', + rangeSeparator: '-', + }); + }); + + describe('pads digits with zero if date segment exceeds its max possible value', () => { + describe('pads digit > 1 with zero for months', () => { + [0, 1].forEach(digit => { + it(`1234/${digit} => 1234/${digit}`, () => { + expect(maskitoTransform(`1234${digit}`, options)).toBe( + `1234/${digit}`, + ); + expect( + maskitoTransform(`1234/01/01-1234/${digit}`, options), + ).toBe(`1234/01/01-1234/${digit}`); + }); + }); + + [2, 3, 4, 5, 6, 7, 8, 9].forEach(digit => { + it(`1234/${digit} => 1234/0${digit}`, () => { + expect(maskitoTransform(`1234${digit}`, options)).toBe( + `1234/0${digit}`, + ); + expect( + maskitoTransform(`1234/01/01-1234/${digit}`, options), + ).toBe(`1234/01/01-1234/0${digit}`); + }); + }); + }); + + describe('pads digit > 3 with zero for days', () => { + [0, 1, 2, 3].forEach(digit => { + it(`1234/12/${digit} => 1234/12/${digit}`, () => { + expect(maskitoTransform(`123412${digit}`, options)).toBe( + `1234/12/${digit}`, + ); + expect( + maskitoTransform(`1234/01/01-1234/12/${digit}`, options), + ).toBe(`1234/01/01-1234/12/${digit}`); + }); + }); + + [4, 5, 6, 7, 8, 9].forEach(digit => { + it(`1234/12/${digit} => 1234/12/0${digit}`, () => { + expect(maskitoTransform(`123412${digit}`, options)).toBe( + `1234/12/0${digit}`, + ); + expect( + maskitoTransform(`1234/01/01-1234/12/${digit}`, options), + ).toBe(`1234/01/01-1234/12/0${digit}`); + }); + }); + }); + }); + }); +}); diff --git a/projects/kit/src/lib/masks/date-time/date-time-mask.ts b/projects/kit/src/lib/masks/date-time/date-time-mask.ts index d36f51a64..298e5de99 100644 --- a/projects/kit/src/lib/masks/date-time/date-time-mask.ts +++ b/projects/kit/src/lib/masks/date-time/date-time-mask.ts @@ -2,6 +2,7 @@ import {MASKITO_DEFAULT_OPTIONS, MaskitoOptions} from '@maskito/core'; import {TIME_FIXED_CHARACTERS} from '../../constants'; import { + createDateSegmentsZeroPaddingPostprocessor, createZeroPlaceholdersPreprocessor, normalizeDatePreprocessor, } from '../../processors'; @@ -9,6 +10,7 @@ import {MaskitoDateMode, MaskitoTimeMode} from '../../types'; import {DATE_TIME_SEPARATOR} from './constants'; import {createMinMaxDateTimePostprocessor} from './postprocessors'; import {createValidDateTimePreprocessor} from './preprocessors'; +import {parseDateTimeString} from './utils'; export function maskitoDateTimeOptionsGenerator({ dateMode, @@ -49,6 +51,23 @@ export function maskitoDateTimeOptionsGenerator({ }), ], postprocessors: [ + createDateSegmentsZeroPaddingPostprocessor({ + dateModeTemplate, + dateSegmentSeparator: dateSeparator, + splitFn: value => { + const [dateString, timeString] = parseDateTimeString( + value, + dateModeTemplate, + ); + + return {dateStrings: [dateString], restPart: timeString}; + }, + uniteFn: ([validatedDateString], initialValue) => + validatedDateString + + (initialValue.includes(DATE_TIME_SEPARATOR) + ? DATE_TIME_SEPARATOR + : ''), + }), createMinMaxDateTimePostprocessor({ min, max, diff --git a/projects/kit/src/lib/masks/date-time/tests/date-segments-zero-padding.spec.ts b/projects/kit/src/lib/masks/date-time/tests/date-segments-zero-padding.spec.ts new file mode 100644 index 000000000..217bb6981 --- /dev/null +++ b/projects/kit/src/lib/masks/date-time/tests/date-segments-zero-padding.spec.ts @@ -0,0 +1,50 @@ +import {MASKITO_DEFAULT_OPTIONS, MaskitoOptions, maskitoTransform} from '@maskito/core'; +import {maskitoDateTimeOptionsGenerator} from '@maskito/kit'; + +describe('DateTime (maskitoTransform) | Date segments zero padding', () => { + describe('[dateMode]="dd/mm/yyyy" & [timeMode]="HH:MM:SS.MSS"', () => { + let options: MaskitoOptions = MASKITO_DEFAULT_OPTIONS; + + beforeEach(() => { + options = maskitoDateTimeOptionsGenerator({ + dateMode: 'dd/mm/yyyy', + timeMode: 'HH:MM:SS.MSS', + dateSeparator: '/', + }); + }); + + describe('pads digits with zero if date segment exceeds its max possible value', () => { + describe('pads digit > 1 with zero for months', () => { + [0, 1].forEach(digit => { + it(`01/${digit} => 01/${digit}`, () => { + expect(maskitoTransform(`01${digit}`, options)).toBe( + `01/${digit}`, + ); + }); + }); + + [2, 3, 4, 5, 6, 7, 8, 9].forEach(digit => { + it(`01/${digit} => 01/0${digit}`, () => { + expect(maskitoTransform(`01${digit}`, options)).toBe( + `01/0${digit}`, + ); + }); + }); + }); + + describe('pads digit > 3 with zero for days', () => { + [0, 1, 2, 3].forEach(digit => { + it(`${digit} => ${digit}`, () => { + expect(maskitoTransform(`${digit}`, options)).toBe(`${digit}`); + }); + }); + + [4, 5, 6, 7, 8, 9].forEach(digit => { + it(`${digit} => 0${digit}`, () => { + expect(maskitoTransform(`${digit}`, options)).toBe(`0${digit}`); + }); + }); + }); + }); + }); +}); diff --git a/projects/kit/src/lib/masks/date/date-mask.ts b/projects/kit/src/lib/masks/date/date-mask.ts index 64b75a07b..2b197b3b6 100644 --- a/projects/kit/src/lib/masks/date/date-mask.ts +++ b/projects/kit/src/lib/masks/date/date-mask.ts @@ -1,6 +1,7 @@ import {MASKITO_DEFAULT_OPTIONS, MaskitoOptions} from '@maskito/core'; import { + createDateSegmentsZeroPaddingPostprocessor, createMinMaxDatePostprocessor, createValidDatePreprocessor, createZeroPlaceholdersPreprocessor, @@ -39,6 +40,12 @@ export function maskitoDateOptionsGenerator({ }), ], postprocessors: [ + createDateSegmentsZeroPaddingPostprocessor({ + dateModeTemplate, + dateSegmentSeparator: separator, + splitFn: value => ({dateStrings: [value]}), + uniteFn: ([dateString]) => dateString, + }), createMinMaxDatePostprocessor({ min, max, diff --git a/projects/kit/src/lib/masks/date/tests/date-mask.spec.ts b/projects/kit/src/lib/masks/date/tests/date-mask.spec.ts index 21b4bf84b..583468df4 100644 --- a/projects/kit/src/lib/masks/date/tests/date-mask.spec.ts +++ b/projects/kit/src/lib/masks/date/tests/date-mask.spec.ts @@ -17,9 +17,54 @@ describe('Date (maskitoTransform)', () => { }); }); - // TODO: fix this bug later - xit('pads digit > 1 with zero for months (12345 => 1234/05)', () => { - expect(maskitoTransform('12345', options)).toBe('1234/05'); + describe('pads digits with zero if date segment exceeds its max possible value', () => { + describe('pads digit > 1 with zero for months', () => { + [0, 1].forEach(digit => { + it(`1234/${digit} => 1234/${digit}`, () => { + expect(maskitoTransform(`1234${digit}`, options)).toBe( + `1234/${digit}`, + ); + expect(maskitoTransform(`1234/${digit}`, options)).toBe( + `1234/${digit}`, + ); + }); + }); + + [2, 3, 4, 5, 6, 7, 8, 9].forEach(digit => { + it(`1234/${digit} => 1234/0${digit}`, () => { + expect(maskitoTransform(`1234${digit}`, options)).toBe( + `1234/0${digit}`, + ); + expect(maskitoTransform(`1234/${digit}`, options)).toBe( + `1234/0${digit}`, + ); + }); + }); + }); + + describe('pads digit > 3 with zero for days', () => { + [0, 1, 2, 3].forEach(digit => { + it(`1234/12/${digit} => 1234/12/${digit}`, () => { + expect(maskitoTransform(`123412${digit}`, options)).toBe( + `1234/12/${digit}`, + ); + expect(maskitoTransform(`1234/12/${digit}`, options)).toBe( + `1234/12/${digit}`, + ); + }); + }); + + [4, 5, 6, 7, 8, 9].forEach(digit => { + it(`1234/12/${digit} => 1234/12/0${digit}`, () => { + expect(maskitoTransform(`123412${digit}`, options)).toBe( + `1234/12/0${digit}`, + ); + expect(maskitoTransform(`1234/12/${digit}`, options)).toBe( + `1234/12/0${digit}`, + ); + }); + }); + }); }); // TODO: https://github.com/taiga-family/maskito/pull/907 diff --git a/projects/kit/src/lib/processors/date-segments-zero-padding-postprocessor.ts b/projects/kit/src/lib/processors/date-segments-zero-padding-postprocessor.ts new file mode 100644 index 000000000..e3d915f53 --- /dev/null +++ b/projects/kit/src/lib/processors/date-segments-zero-padding-postprocessor.ts @@ -0,0 +1,71 @@ +import {MaskitoPostprocessor} from '@maskito/core'; + +import {DATE_SEGMENTS_MAX_VALUES} from '../constants'; +import {MaskitoDateSegments} from '../types'; +import {padWithZeroesUntilValid, parseDateString, toDateString} from '../utils'; + +export function createDateSegmentsZeroPaddingPostprocessor({ + dateModeTemplate, + dateSegmentSeparator, + splitFn, + uniteFn, +}: { + dateModeTemplate: string; + dateSegmentSeparator: string; + splitFn: (value: string) => {dateStrings: string[]; restPart?: string}; + uniteFn: (validatedDateStrings: string[], initialValue: string) => string; +}): MaskitoPostprocessor { + return ({value, selection}) => { + const [from, to] = selection; + const {dateStrings, restPart = ''} = splitFn(value); + const validatedDateStrings: string[] = []; + let caretShift = 0; + + dateStrings.forEach(dateString => { + const parsedDate = parseDateString(dateString, dateModeTemplate); + const dateSegments = Object.entries(parsedDate) as Array< + [keyof MaskitoDateSegments, string] + >; + + const validatedDateSegments = dateSegments.reduce( + (acc, [segmentName, segmentValue]) => { + const {validatedSegmentValue, prefixedZeroesCount} = + padWithZeroesUntilValid( + segmentValue, + `${DATE_SEGMENTS_MAX_VALUES[segmentName]}`, + ); + + caretShift += prefixedZeroesCount; + + return {...acc, [segmentName]: validatedSegmentValue}; + }, + {}, + ); + + validatedDateStrings.push( + toDateString(validatedDateSegments, dateModeTemplate), + ); + }); + + const validatedValue = + uniteFn(validatedDateStrings, value) + + (dateStrings[dateStrings.length - 1]?.endsWith(dateSegmentSeparator) + ? dateSegmentSeparator + : '') + + restPart; + + if (caretShift && validatedValue[to + 1] === dateSegmentSeparator) { + /** + * If `caretShift` > 0, it means that time segment was padded with zero. + * It is only possible if any character insertion happens. + * If caret is before `dateSegmentSeparator` => it should be moved after `dateSegmentSeparator`. + */ + caretShift++; + } + + return { + selection: [from + caretShift, to + caretShift], + value: validatedValue, + }; + }; +} diff --git a/projects/kit/src/lib/processors/index.ts b/projects/kit/src/lib/processors/index.ts index 3c4f699e2..fe814800c 100644 --- a/projects/kit/src/lib/processors/index.ts +++ b/projects/kit/src/lib/processors/index.ts @@ -1,3 +1,4 @@ +export {createDateSegmentsZeroPaddingPostprocessor} from './date-segments-zero-padding-postprocessor'; export {createMinMaxDatePostprocessor} from './min-max-date-postprocessor'; export {normalizeDatePreprocessor} from './normalize-date-preprocessor'; export {maskitoPostfixPostprocessorGenerator} from './postfix-postprocessor'; diff --git a/projects/kit/src/lib/utils/date/tests/parse-date-range-string.spec.ts b/projects/kit/src/lib/utils/date/tests/parse-date-range-string.spec.ts index 180e370e3..c806ed9b7 100644 --- a/projects/kit/src/lib/utils/date/tests/parse-date-range-string.spec.ts +++ b/projects/kit/src/lib/utils/date/tests/parse-date-range-string.spec.ts @@ -2,6 +2,11 @@ import {parseDateRangeString} from '../parse-date-range-string'; describe('parseDateRangeString', () => { const tests = [ + ['', []], + ['1', ['1']], + ['13', ['13']], + ['13.', ['13.']], + ['13.0', ['13.0']], ['13.02', ['13.02']], ['13.02.', ['13.02.']], ['13.02.2023', ['13.02.2023']], diff --git a/projects/kit/src/lib/utils/date/validate-date-string.ts b/projects/kit/src/lib/utils/date/validate-date-string.ts index c5c012b00..2a53394f5 100644 --- a/projects/kit/src/lib/utils/date/validate-date-string.ts +++ b/projects/kit/src/lib/utils/date/validate-date-string.ts @@ -1,15 +1,9 @@ +import {DATE_SEGMENTS_MAX_VALUES} from '../../constants'; import {MaskitoDateSegments} from '../../types'; -import {padWithZeroesUntilValid} from '../pad-with-zeroes-until-valid'; import {getDateSegmentValueLength} from './date-segment-value-length'; import {parseDateString} from './parse-date-string'; import {toDateString} from './to-date-string'; -const dateMaxValues: MaskitoDateSegments = { - day: 31, - month: 12, - year: 9999, -}; - export function validateDateString({ dateString, dateModeTemplate, @@ -27,11 +21,9 @@ export function validateDateString({ >; const validatedDateSegments: Partial = {}; - let paddedZeroes = 0; - for (const [segmentName, segmentValue] of dateSegments) { const validatedDate = toDateString(validatedDateSegments, dateModeTemplate); - const maxSegmentValue = dateMaxValues[segmentName]; + const maxSegmentValue = DATE_SEGMENTS_MAX_VALUES[segmentName]; const fantomSeparator = validatedDate.length && 1; @@ -53,14 +45,7 @@ export function validateDateString({ return {validatedDateString: '', updatedSelection: [from, to]}; // prevent changes } - const {validatedSegmentValue, prefixedZeroesCount} = padWithZeroesUntilValid( - segmentValue, - `${maxSegmentValue}`, - ); - - paddedZeroes += prefixedZeroesCount; - - validatedDateSegments[segmentName] = validatedSegmentValue; + validatedDateSegments[segmentName] = segmentValue; } const validatedDateString = toDateString(validatedDateSegments, dateModeTemplate); @@ -69,8 +54,8 @@ export function validateDateString({ return { validatedDateString, updatedSelection: [ - from + paddedZeroes + addedDateSegmentSeparators, - to + paddedZeroes + addedDateSegmentSeparators, + from + addedDateSegmentSeparators, + to + addedDateSegmentSeparators, ], }; }