From 075c91d272720507ba6904a33d3f9f7c054705a3 Mon Sep 17 00:00:00 2001 From: roll314 Date: Fri, 7 Jun 2019 15:29:55 +0300 Subject: [PATCH] feat(datepicker): date format finding (#133) --- packages/mosaic-dev/datepicker/module.ts | 6 +- .../adapter/moment-date-adapter.spec.ts | 119 ++++++++++++++ .../adapter/moment-date-adapter.ts | 152 +++++++++++++++++- 3 files changed, 270 insertions(+), 7 deletions(-) diff --git a/packages/mosaic-dev/datepicker/module.ts b/packages/mosaic-dev/datepicker/module.ts index 4b00ecf92..858eb63ee 100644 --- a/packages/mosaic-dev/datepicker/module.ts +++ b/packages/mosaic-dev/datepicker/module.ts @@ -7,6 +7,7 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { DateAdapter, MC_DATE_LOCALE } from '@ptsecurity/cdk/datetime'; import { + MC_MOMENT_DATE_ADAPTER_OPTIONS, McMomentDateModule, MomentDateAdapter } from '@ptsecurity/mosaic-moment-adapter/adapter'; @@ -33,8 +34,9 @@ const moment = _rollupMoment || _moment; styleUrls: ['./styles.scss'], encapsulation: ViewEncapsulation.None, providers: [ - {provide: MC_DATE_LOCALE, useValue: 'ru'}, - {provide: DateAdapter, useClass: MomentDateAdapter, deps: [MC_DATE_LOCALE]} + { provide: MC_DATE_LOCALE, useValue: 'ru' }, + { provide: MC_MOMENT_DATE_ADAPTER_OPTIONS, useValue: { findDateFormat: true } }, + { provide: DateAdapter, useClass: MomentDateAdapter, deps: [ MC_DATE_LOCALE, MC_MOMENT_DATE_ADAPTER_OPTIONS ] } ] }) export class DemoComponent { diff --git a/packages/mosaic-moment-adapter/adapter/moment-date-adapter.spec.ts b/packages/mosaic-moment-adapter/adapter/moment-date-adapter.spec.ts index c85905c5a..54a26051e 100644 --- a/packages/mosaic-moment-adapter/adapter/moment-date-adapter.spec.ts +++ b/packages/mosaic-moment-adapter/adapter/moment-date-adapter.spec.ts @@ -7,6 +7,7 @@ import { MC_DATE_LOCALE } from '@ptsecurity/cdk/datetime'; import * as moment from 'moment'; +// tslint:disable-next-line:no-duplicate-imports import { Moment } from 'moment'; import { MC_MOMENT_DATE_ADAPTER_OPTIONS, MomentDateModule } from './index'; @@ -364,7 +365,125 @@ describe('MomentDateAdapter', () => { expect(adapter.addCalendarMonths(moment(), 1).locale()).toBe('ja'); expect(adapter.addCalendarYears(moment(), 1).locale()).toBe('ja'); }); +}); + +describe('MomentDateAdapter findDateFormat = true', () => { + let adapter: MomentDateAdapter; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MomentDateModule], + providers: [{ + provide: MC_MOMENT_DATE_ADAPTER_OPTIONS, + useValue: {findDateFormat: true} + }] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter], (dateAdapter: MomentDateAdapter) => { + moment.locale('en'); + adapter = dateAdapter; + adapter.setLocale('en'); + })); + + it('should parse ISO', () => { + adapter.setLocale('ru'); + const utcDate = new Date(2019, 5, 3, 14, 50, 30); + utcDate.setMinutes(utcDate.getMinutes() - utcDate.getTimezoneOffset()); + expect(adapter.parse('2019-06-03T14:50:30.000Z', '')!.toDate()) + .toEqual(utcDate); + }); + + it('should parse dashed date', () => { + adapter.setLocale('ru'); + // finishing year + expect(adapter.parse('03-06-2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + expect(adapter.parse('03-06-19', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + // leading year + expect(adapter.parse('2019-06-03', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + adapter.setLocale('en'); + // finishing year + expect(adapter.parse('03-06-2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + // short year + expect(adapter.parse('03-06-19', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + // leading year + expect(adapter.parse('2019-06-03', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + }); + + it('should parse slashed date', () => { + adapter.setLocale('ru'); + expect(adapter.parse('03/06/2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + // short year + expect(adapter.parse('03/06/19', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + adapter.setLocale('en'); + // US by default + expect(adapter.parse('03/06/2019', '')!.toDate()) + .toEqual(new Date(2019, 2, 6)); + + // short year + expect(adapter.parse('03/06/19', '')!.toDate()) + .toEqual(new Date(2019, 2, 6)); + + // month order guessing + expect(adapter.parse('23/06/2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 23)); + }); + + it('should parse doted date', () => { + adapter.setLocale('ru'); + expect(adapter.parse('03.06.2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + expect(adapter.parse('03.06.19', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + adapter.setLocale('en'); + expect(adapter.parse('03.06.2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + expect(adapter.parse('03.06.19', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + }); + + it('should parse long formatted date', () => { + adapter.setLocale('ru'); + expect(adapter.parse('3 июня 2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + expect(adapter.parse('6 фев 2019', '')!.toDate()) + .toEqual(new Date(2019, 1, 6)); + + adapter.setLocale('en'); + expect(adapter.parse('June 3rd 2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + expect(adapter.parse('Feb 6th 2019', '')!.toDate()) + .toEqual(new Date(2019, 1, 6)); + + expect(adapter.parse('3 June 2019', '')!.toDate()) + .toEqual(new Date(2019, 5, 3)); + + expect(adapter.parse('6 Feb 2019', '')!.toDate()) + .toEqual(new Date(2019, 1, 6)); + }); + + it('should parse unix timestamp', () => { + adapter.setLocale('ru'); + const utcDate = new Date(2019, 5, 3, 14, 50, 30); + utcDate.setMinutes(utcDate.getMinutes() - utcDate.getTimezoneOffset()); + expect(adapter.parse('1559573430', '')!.toDate()) + .toEqual(utcDate); + }); }); describe('MomentDateAdapter with MC_DATE_LOCALE override', () => { diff --git a/packages/mosaic-moment-adapter/adapter/moment-date-adapter.ts b/packages/mosaic-moment-adapter/adapter/moment-date-adapter.ts index 17288fe2a..62f71fb64 100644 --- a/packages/mosaic-moment-adapter/adapter/moment-date-adapter.ts +++ b/packages/mosaic-moment-adapter/adapter/moment-date-adapter.ts @@ -32,6 +32,11 @@ export interface IMcMomentDateAdapterOptions { * {@default false} */ useUtc: boolean; + /** + * whether should parse method try guess date format + * {@default false} + */ + findDateFormat: boolean; } /** InjectionToken for moment date adapter to configure options. */ @@ -45,7 +50,8 @@ export const MC_MOMENT_DATE_ADAPTER_OPTIONS = new InjectionToken { } parse(value: any, parseFormat: string | string[]): Moment | null { - // tslint:disable:triple-equals - if (value && typeof value == 'string') { - return this.createMoment(value, parseFormat, this.locale); + if (value) { + if (value && typeof value === 'string') { + if (this.options && this.options.findDateFormat) { + return this.findFormat(value); + } + + return parseFormat + ? this.createMoment(value, parseFormat, this.locale) + : this.createMoment(value).locale(this.locale); + } + + return this.createMoment(value).locale(this.locale); } - return value ? this.createMoment(value).locale(this.locale) : null; + return null; } format(date: Moment, displayFormat: string): string { @@ -457,4 +472,131 @@ export class MomentDateAdapter extends DateAdapter { private configureTranslator(locale: string): void { this.messageformat = new MessageFormat(locale); } + + private isNumeric(value: any): boolean { + return !isNaN(parseFloat(value)) && isFinite(value); + } + + private findFormat(value: string): Moment | null { + if (!value) { + return null; + } + + // default test - iso + const isoDate = this.createMoment(value, moment.ISO_8601, this.locale); + + if (isoDate.isValid()) { + return isoDate; + } + + if (this.isNumeric(value)) { + // unix time sec + return this.createMoment(value, 'X', this.locale); + } + + // long months naming: D MMM YYYY, MMM Do YYYY with short case support + if ( + /^\d{1,2}\s\S+\s(\d{2}|\d{4})$/.test(value.trim()) || + /^\S+\s\d{1,2}[a-z]{2}\s(\d{2}|\d{4})$/.test(value.trim()) + ) { + return this.parseWithSpace(value); + } + + // slash notation: DD/MM/YYYY, MM/DD/YYYY with short case support + if (/^\d{1,2}\/\d{1,2}\/(\d{2}|\d{4})$/.test(value)) { + return this.parseWithSlash(value); + } + + // dash notation: DD-MM-YYYY, YYYY-DD-MM with short case support + if (/(^(\d{1,2}|\d{4})-\d{1,2}-\d{1,2}$)|(^\d{1,2}-\d{1,2}-(\d{2}|\d{4})$)/.test(value)) { + return this.parseWithDash(value); + } + + // dot notation: DD.MM.YYYY with short case support + if (/^\d{1,2}\.\d{1,2}\.(\d{2}|\d{4})$/.test(value)) { + return this.parseWithDot(value); + } + + return null; + } + + private parseWithSpace(value: string): Moment | null { + switch (this.locale) { + case 'ru': + return this.createMoment(value, 'DD MMMM YYYY', this.locale); + case 'en': + // 16 Feb 2019 vs Feb 16th 2019, covers Feb and February cases + if (this.isNumeric(value[0])) { + return this.createMoment(value, 'D MMMM YYYY', this.locale); + } + + return this.createMoment(value, 'MMMM Do YYYY', this.locale); + default: + throw new Error(`Locale ${this.locale} is not supported`); + } + } + + private parseWithSlash(value: string): Moment | null { + switch (this.locale) { + case 'ru': + return this.createMoment(value, 'DD/MM/YYYY', this.locale); + // todo do we use generalized locales? en vs en-US; until not we try to guess + case 'en': + // US vs UK + const parts = value.split('/'); + const datePartsCount = 3; + if (parts.length !== datePartsCount) { + return null; + } + + const firstPart = parts[0].trim(); + const secondPart = parts[1].trim(); + + if (!this.isNumeric(firstPart) || !this.isNumeric(secondPart)) { + return null; + } + + const monthsInYears = 12; + + const canFirstBeMonth = +firstPart <= monthsInYears; + const canSecondByMonth = +secondPart <= monthsInYears; + + // first two parts cannot be month + if (!canFirstBeMonth && !canSecondByMonth) { + return null; + } + + const canDetermineWhereMonth = canFirstBeMonth && canSecondByMonth; + + if (canDetermineWhereMonth) { + // use US format by default + return this.createMoment(value, 'MM/DD/YYYY', this.locale); + } + + return canFirstBeMonth && !canSecondByMonth + ? this.createMoment(value, 'MM/DD/YYYY', this.locale) + : this.createMoment(value, 'DD/MM/YYYY', this.locale); + default: + throw new Error(`Locale ${this.locale} is not supported`); + } + } + + private parseWithDash(value: string): Moment | null { + // leading year vs finishing year + const parts = value.split('-'); + if (parts[0].length === 0) { + return null; + } + + const maxDayOrMonthCharsCount = 2; + + return parts[0].length <= maxDayOrMonthCharsCount + ? this.createMoment(value, 'DD-MM-YYYY', this.locale) + : this.createMoment(value, 'YYYY-MM-DD', this.locale); + } + + private parseWithDot(value: string): Moment | null { + // covers two cases YYYY and YY (for current year) + return this.createMoment(value, 'DD.MM.YYYY', this.locale); + } }