diff --git a/package-lock.json b/package-lock.json index a00913dd..b7e6d935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10169,10 +10169,9 @@ } }, "date-fns": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", - "dev": true + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", + "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==" }, "date-format": { "version": "3.0.0", @@ -15535,6 +15534,14 @@ "cli-cursor": "^2.1.0", "date-fns": "^1.27.2", "figures": "^2.0.0" + }, + "dependencies": { + "date-fns": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", + "dev": true + } } }, "load-json-file": { diff --git a/package.json b/package.json index 6ed7eaea..bb86c984 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@angular/router": "~10.0.5", "ngx-mat-select-search": "^3.0.1", "nouislider": "^14.0.2", + "date-fns": "2.16.1", "rxjs": "~6.5.5", "tslib": "^2.0.0", "zone.js": "~0.10.2" diff --git a/projects/components/date-time-input/index.ts b/projects/components/date-time-input/index.ts new file mode 100644 index 00000000..c74f953a --- /dev/null +++ b/projects/components/date-time-input/index.ts @@ -0,0 +1,3 @@ +// export what ./public_api exports so we can import with the lib name like this: +// import { ModuleA } from 'libname' +export * from './public_api'; diff --git a/projects/components/date-time-input/package.json b/projects/components/date-time-input/package.json new file mode 100644 index 00000000..dedb72ce --- /dev/null +++ b/projects/components/date-time-input/package.json @@ -0,0 +1,7 @@ +{ + "ngPackage": { + "lib": { + "entryFile": "index.ts" + } + } +} diff --git a/projects/components/date-time-input/public_api.ts b/projects/components/date-time-input/public_api.ts new file mode 100644 index 00000000..8d7567fe --- /dev/null +++ b/projects/components/date-time-input/public_api.ts @@ -0,0 +1,2 @@ +export { PsDateTimeInputComponent as DateTimeComponent } from './src/date-time-input.component'; +export { PsDateTimeInputModule } from './src/date-time-input.module'; diff --git a/projects/components/date-time-input/src/date-time-input.component.html b/projects/components/date-time-input/src/date-time-input.component.html new file mode 100644 index 00000000..f81eb13f --- /dev/null +++ b/projects/components/date-time-input/src/date-time-input.component.html @@ -0,0 +1,29 @@ +
+ + +
+ +
+ + diff --git a/projects/components/date-time-input/src/date-time-input.component.scss b/projects/components/date-time-input/src/date-time-input.component.scss new file mode 100644 index 00000000..71f3f25e --- /dev/null +++ b/projects/components/date-time-input/src/date-time-input.component.scss @@ -0,0 +1,11 @@ +.ps-date-time__grid-container { + display: grid; + grid-template-columns: 2fr min-content minmax(min-content, 1fr); + grid-gap: 1px; + margin-bottom: -0.7em; + margin-top: -0.7em; +} + +.mat-form-field-suffix { + top: 0; +} diff --git a/projects/components/date-time-input/src/date-time-input.component.spec.ts b/projects/components/date-time-input/src/date-time-input.component.spec.ts new file mode 100644 index 00000000..24cf597d --- /dev/null +++ b/projects/components/date-time-input/src/date-time-input.component.spec.ts @@ -0,0 +1,104 @@ +import { Platform } from '@angular/cdk/platform'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { getLocaleFirstDayOfWeek } from '@angular/common'; +import { Component, Inject, Injectable, LOCALE_ID, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DateAdapter, MatDateFormats, MAT_DATE_FORMATS, NativeDateAdapter } from '@angular/material/core'; +import { parseHumanInput } from '@prosoft/components/utils'; +import { PsDateTimeInputComponent } from './date-time-input.component'; +import { PsDateTimeInputModule } from './date-time-input.module'; +import { PsDateTimeInputHarness } from './testing/date-time-input.harness'; + +export const TEST_DATE_FORMATS: MatDateFormats = { + parse: { + dateInput: null, + }, + display: { + dateInput: { year: 'numeric', month: '2-digit', day: '2-digit' }, + monthYearLabel: { year: 'numeric', month: 'short' }, + dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' }, + monthYearA11yLabel: { year: 'numeric', month: 'long' }, + }, +}; + +// extend NativeDateAdapter's format method to specify the date format. +@Injectable({ providedIn: 'root' }) +export class TestDateTimeAdapter extends NativeDateAdapter { + constructor(@Inject(LOCALE_ID) _locale: string, platform: Platform) { + super(_locale, platform); + } + + public sameDate(a: any, b: any) { + return !a && !b ? false : super.sameDate(a, b); + } + + public getFirstDayOfWeek(): number { + return getLocaleFirstDayOfWeek(this.locale); + } + + // If required extend other NativeDateAdapter methods. + public parse(value: any): Date | null { + return parseHumanInput(value); + } + + public getIs24Hours(): boolean { + return new Date(79200000).toLocaleTimeString(this.locale).indexOf('11') === -1; + } +} + +@Component({ + selector: 'ps-test-component', + template: ` `, +}) +export class TestDataSourceComponent { + public disabled = false; + @ViewChild(PsDateTimeInputComponent) public dateTimeInputComponent: PsDateTimeInputComponent; +} + +describe('DateTimeInputComponent', () => { + let fixture: ComponentFixture; + let component: TestDataSourceComponent; + let loader: HarnessLoader; + let dateTimeInput: PsDateTimeInputHarness; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PsDateTimeInputModule], + declarations: [TestDataSourceComponent], + providers: [ + { provide: DateAdapter, useClass: TestDateTimeAdapter }, + { provide: MAT_DATE_FORMATS, useValue: TEST_DATE_FORMATS }, + ], + }); + fixture = TestBed.createComponent(TestDataSourceComponent); + component = fixture.componentInstance; + expect(component).toBeDefined(); + + loader = TestbedHarnessEnvironment.loader(fixture); + dateTimeInput = await loader.getHarness(PsDateTimeInputHarness); + }); + + it('Should be disabled', async () => { + expect(await dateTimeInput.isDisabled()).toEqual(false); + component.disabled = true; + expect(await dateTimeInput.isDisabled()).toEqual(true); + }); + + it('Should return valid date', () => { + component.dateTimeInputComponent.datum = new Date(); + component.dateTimeInputComponent.uhrzeit = '11:11'; + const value = component.dateTimeInputComponent.convertToDate(); + + const expectedValue = new Date(); + expectedValue.setHours(11, 11); + expect(value.getDate()).toEqual(expectedValue.getDate()); + }); + + it('Should return invalid Date', () => { + component.dateTimeInputComponent.datum = new Date(); + component.dateTimeInputComponent.uhrzeit = null; + const value = component.dateTimeInputComponent.convertToDate(); + + expect(value.getTime()).toEqual(NaN); + }); +}); diff --git a/projects/components/date-time-input/src/date-time-input.component.ts b/projects/components/date-time-input/src/date-time-input.component.ts new file mode 100644 index 00000000..4460ba36 --- /dev/null +++ b/projects/components/date-time-input/src/date-time-input.component.ts @@ -0,0 +1,255 @@ +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + Input, + OnDestroy, + OnInit, + Optional, + Self, + ViewChild, +} from '@angular/core'; +import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm } from '@angular/forms'; +import { CanUpdateErrorStateCtor, ErrorStateMatcher, mixinErrorState } from '@angular/material/core'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { isValid } from '@prosoft/components/utils'; +import { Subject } from 'rxjs'; + +export class PsDateTimeInputBase { + constructor( + public _defaultErrorStateMatcher: ErrorStateMatcher, + public _parentForm: NgForm, + public _parentFormGroup: FormGroupDirective, + public ngControl: NgControl + ) {} +} + +let nextUniqueId = 0; + +export const _PsDateTimeMixinBase: CanUpdateErrorStateCtor & typeof PsDateTimeInputBase = mixinErrorState(PsDateTimeInputBase); + +@Component({ + selector: 'ps-date-time-input', + templateUrl: './date-time-input.component.html', + styleUrls: ['./date-time-input.component.scss'], + providers: [{ provide: MatFormFieldControl, useExisting: PsDateTimeInputComponent }], + changeDetection: ChangeDetectionStrategy.OnPush, + // tslint:disable-next-line: no-host-metadata-property + host: { + // Native input properties that are overwritten by Angular inputs need to be synced with + // the native input element. Otherwise property bindings for those don't work. + '[attr.id]': 'id', + '[attr.placeholder]': 'placeholder', + '[attr.disabled]': 'disabled', + '[attr.required]': 'required', + '[attr.readonly]': 'readonly || null', + '[attr.aria-describedby]': '_ariaDescribedby || null', + '[attr.aria-invalid]': 'errorState', + '[attr.aria-required]': 'required.toString()', + }, +}) +export class PsDateTimeInputComponent extends _PsDateTimeMixinBase + implements MatFormFieldControl, OnDestroy, ControlValueAccessor, DoCheck, OnInit { + @Input() + public shouldLabelFloat = true; + public stateChanges = new Subject(); + public errorState: boolean; + public controlType = 'ps-date-time-input'; + public placeholder: string; + + public readonly = false; + public get empty(): boolean { + return !this.value; + } + public required = false; + /** An object used to control when error messages are shown. */ + @Input() public errorStateMatcher: ErrorStateMatcher; + @Input() + public get disabled(): boolean { + if (this.ngControl && this.ngControl.disabled !== null) { + return this.ngControl.disabled; + } + return this._disabled; + } + public set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + this.dateInputEl.nativeElement.disabled = value; + this.timeInputEl.nativeElement.disabled = value; + this.stateChanges.next(); + this.cd.markForCheck(); + } + public autofilled?: boolean; + + public datum: Date; + public uhrzeit: string; + + public get focused(): boolean { + return this._focused; + } + + @Input() + public get id(): string { + return this._id; + } + public set id(value: string) { + this._id = value || this._uid; + } + + public get value(): Date | null { + return this._value; + } + public set value(value: Date | null) { + this._value = value; + this.stateChanges.next(); + } + + public _ariaDescribedby: string; + + @ViewChild('dateInput', { static: true }) public dateInputEl: ElementRef; + @ViewChild('timeInput', { static: true }) public timeInputEl: ElementRef; + protected _id: string; + protected _disabled = false; + + protected _uid = `ps-date-time-input-${nextUniqueId++}`; + + private _value: Date | null = null; + private _focused = false; + + private _changeFn: (value: Date) => void; + private _touchedFn: () => void; + + constructor( + @Optional() + @Self() + public ngControl: NgControl, + @Optional() _parentForm: NgForm, + @Optional() _parentFormGroup: FormGroupDirective, + _defaultErrorStateMatcher: ErrorStateMatcher, + private cd: ChangeDetectorRef + ) { + super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl); + if (this.ngControl != null) { + this.ngControl.valueAccessor = this; + } + } + public ngOnInit(): void { + // this._id = value || this._uid; wird gesetzt + this.id = this.id; + } + public writeValue(newValue: Date | null): void { + this.patchDateValue(newValue); + } + public setDisabledState?(isDisabled: boolean): void { + this.disabled = isDisabled; + } + public registerOnChange(fn: (value: Date) => void): void { + this._changeFn = fn; + } + + public registerOnTouched(fn: () => void): void { + this._touchedFn = fn; + } + + public ngDoCheck() { + if (this.ngControl) { + // We need to re-evaluate this on every change detection cycle, because there are some + // error triggers that we can't subscribe to (e.g. parent form submissions). This means + // that whatever logic is in here has to be super lean or we risk destroying the performance. + this.updateErrorState(); + } + } + + public setDescribedByIds(ids: string[]): void { + this._ariaDescribedby = ids.join(' '); + } + public onContainerClick(_event: MouseEvent): void { + // funktioniert nicht, fokus liegt dann auf der ganzen component und man kann in die zeit nur reintabben + // this.dateInputEl.nativeElement.focus(); + } + + public ngOnDestroy() { + this.stateChanges.complete(); + } + + public onBlur() { + this._focused = false; + this.stateChanges.next(); + } + + public patchDateValue(newValue: Date | null) { + if (newValue !== null && !isValid(newValue)) { + return; + } + + if (newValue !== null) { + newValue = new Date(newValue); + } + const uhrzeit = + newValue !== null + ? (newValue.getHours() < 10 ? '0' : '') + + newValue.getHours() + + ':' + + (newValue.getMinutes() < 10 ? '0' : '') + + newValue.getMinutes() + : null; + + this._value = newValue; + this.datum = newValue; + this.uhrzeit = uhrzeit; + + this.cd.markForCheck(); + this.stateChanges.next(); + } + + public clearInput() { + this.datum = null; + this.uhrzeit = null; + this.cd.markForCheck(); + } + + public onFocus() { + this._focused = true; + this.stateChanges.next(); + this._touchedFn(); + } + + public onChanged() { + this.updateValue(); + } + + public updateValue() { + this._value = this.convertToDate(); + this._changeFn(this._value); + } + + public convertToDate() { + const datum = new Date(this.datum); + const uhrzeit = this.uhrzeit; + const uhrzeitIsValid = /^([0-1][0-9]|2[0-3]):([0-5][0-9])$/.test(uhrzeit); + + if (!datum && this.dateInputEl.nativeElement.value) { + return new Date(NaN); + } + + if (!datum && !uhrzeit) { + return null; + } + + if (!uhrzeitIsValid || !isValid(datum)) { + return new Date(NaN); + } + + if (uhrzeit && datum) { + const uhrzeitSplit = uhrzeit.split(':'); + datum.setHours(parseInt(uhrzeitSplit[0], 10), parseInt(uhrzeitSplit[1], 10)); + this.stateChanges.next(); + this.cd.markForCheck(); + return datum; + } + + return new Date(NaN); + } +} diff --git a/projects/components/date-time-input/src/date-time-input.module.ts b/projects/components/date-time-input/src/date-time-input.module.ts new file mode 100644 index 00000000..1e65e678 --- /dev/null +++ b/projects/components/date-time-input/src/date-time-input.module.ts @@ -0,0 +1,16 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatInputModule } from '@angular/material/input'; + +import { PsDateTimeInputComponent } from './date-time-input.component'; + +export const dateTimeModuleImports = [CommonModule, MatDatepickerModule, FormsModule, MatInputModule]; + +@NgModule({ + imports: dateTimeModuleImports, + declarations: [PsDateTimeInputComponent], + exports: [PsDateTimeInputComponent], +}) +export class PsDateTimeInputModule {} diff --git a/projects/components/date-time-input/src/testing/date-time-input.harness.ts b/projects/components/date-time-input/src/testing/date-time-input.harness.ts new file mode 100644 index 00000000..3e825347 --- /dev/null +++ b/projects/components/date-time-input/src/testing/date-time-input.harness.ts @@ -0,0 +1,30 @@ +// tslint:disable: member-ordering +import { BaseHarnessFilters, HarnessPredicate } from '@angular/cdk/testing'; +import { MatFormFieldControlHarness } from '@angular/material/form-field/testing/control'; + +/** A set of criteria that can be used to filter a list of `MatSelectHarness` instances. */ +// tslint:disable-next-line: no-empty-interface +export interface PsDateTimeInputHarnessFilters extends BaseHarnessFilters {} + +/** Harness for interacting with a standard mat-select in tests. */ +export class PsDateTimeInputHarness extends MatFormFieldControlHarness { + static hostSelector = 'ps-date-time-input'; + + _dateInput = this.locatorFor('.ps-date-time-picker__date-input'); + _timeInput = this.locatorFor('.ps-date-time-picker__time-input'); + + /** + * Gets a `HarnessPredicate` that can be used to search for a `PsDateTimeInputHarness` that meets + * certain criteria. + * @param options Options for filtering which select instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with(options: PsDateTimeInputHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(PsDateTimeInputHarness, options); + } + + /** Gets a boolean promise indicating if the select is disabled. */ + async isDisabled(): Promise { + return (await this._dateInput()).getProperty('disabled') && (await this._timeInput()).getProperty('disabled'); + } +} diff --git a/projects/components/utils/public_api.ts b/projects/components/utils/public_api.ts index dddfb110..c23fa41e 100644 --- a/projects/components/utils/public_api.ts +++ b/projects/components/utils/public_api.ts @@ -1,2 +1,4 @@ -export { objectToKeyValueArray } from './src/object'; +// tslint:disable:no-export-all +export * from './src/date-fns'; export { isInViewport } from './src/dom'; +export { objectToKeyValueArray } from './src/object'; diff --git a/projects/components/utils/src/date-fns.spec.ts b/projects/components/utils/src/date-fns.spec.ts new file mode 100644 index 00000000..7480e54a --- /dev/null +++ b/projects/components/utils/src/date-fns.spec.ts @@ -0,0 +1,146 @@ +import { INVALID_DATE, isAfterOrEqual, isBeforeOrEqual, parseHumanInput } from './date-fns'; + +describe('isBeforeOrEqual', () => { + it('should work', () => { + const before = new Date(2019, 0, 2, 13, 12, 10); + const date = new Date(2019, 0, 2, 13, 12, 11); + const equal = new Date(2019, 0, 2, 13, 12, 11); + const after = new Date(2019, 0, 2, 13, 12, 12); + + expect(isBeforeOrEqual(before, date)).toBe(true); + expect(isBeforeOrEqual(equal, date)).toBe(true); + expect(isBeforeOrEqual(date, date)).toBe(true); + expect(isBeforeOrEqual(after, date)).toBe(false); + }); +}); + +describe('isAfterOrEqual', () => { + it('should work', () => { + const before = new Date(2019, 0, 2, 13, 12, 10); + const date = new Date(2019, 0, 2, 13, 12, 11); + const equal = new Date(2019, 0, 2, 13, 12, 11); + const after = new Date(2019, 0, 2, 13, 12, 12); + + expect(isAfterOrEqual(before, date)).toBe(false); + expect(isAfterOrEqual(equal, date)).toBe(true); + expect(isAfterOrEqual(date, date)).toBe(true); + expect(isAfterOrEqual(after, date)).toBe(true); + }); +}); + +describe('parseHumanInput', () => { + beforeEach(() => { + (parseHumanInput as any).currentYear = 2020; + }); + + function buildDate(y: number, m: number, d: number) { + return new Date(y, m - 1, d); + } + + it('should accept 6 digits as ddmmyy, check for bounds and expand year', () => { + expect(parseHumanInput('101192')).toEqual(buildDate(1992, 11, 10)); + expect(parseHumanInput('010516')).toEqual(buildDate(2016, 5, 1)); + + // month out of bounds + expect(parseHumanInput('011316')).toBe(INVALID_DATE); + + // day out of bounds + expect(parseHumanInput('320516')).toBe(INVALID_DATE); + + // up to 10 years in the future -> use this century + expect(parseHumanInput('010520')).toEqual(buildDate(2020, 5, 1)); + expect(parseHumanInput('010525')).toEqual(buildDate(2025, 5, 1)); + expect(parseHumanInput('010530')).toEqual(buildDate(2030, 5, 1)); + + // year more than 10 years in the future -> use last century + expect(parseHumanInput('010531')).toEqual(buildDate(1931, 5, 1)); + expect(parseHumanInput('010540')).toEqual(buildDate(1940, 5, 1)); + expect(parseHumanInput('010550')).toEqual(buildDate(1950, 5, 1)); + expect(parseHumanInput('010560')).toEqual(buildDate(1960, 5, 1)); + expect(parseHumanInput('010570')).toEqual(buildDate(1970, 5, 1)); + expect(parseHumanInput('010580')).toEqual(buildDate(1980, 5, 1)); + expect(parseHumanInput('010590')).toEqual(buildDate(1990, 5, 1)); + expect(parseHumanInput('010599')).toEqual(buildDate(1999, 5, 1)); + + // test with different currentYear + (parseHumanInput as any).currentYear = 2010; + expect(parseHumanInput('010510')).toEqual(buildDate(2010, 5, 1)); + expect(parseHumanInput('010521')).toEqual(buildDate(1921, 5, 1)); + }); + + it("should accept 7 digits as ddmmyyy, check for bounds but don't expand year", () => { + expect(parseHumanInput('1011992')).toEqual(buildDate(992, 11, 10)); + expect(parseHumanInput('0105016')).toEqual(buildDate(16, 5, 1)); + + // month out of bounds + expect(parseHumanInput('0113016')).toBe(INVALID_DATE); + + // day out of bounds + expect(parseHumanInput('3205016')).toBe(INVALID_DATE); + }); + + it('should accept 8 digits as ddmmyyyy and check for bounds', () => { + expect(parseHumanInput('10111992')).toEqual(buildDate(1992, 11, 10)); + expect(parseHumanInput('01052016')).toEqual(buildDate(2016, 5, 1)); + + // month out of bounds + expect(parseHumanInput('0113016')).toBe(INVALID_DATE); + + // day out of bounds + expect(parseHumanInput('3205016')).toBe(INVALID_DATE); + }); + + it("should accept '.' separated date", () => { + expect(parseHumanInput('01.05.20')).toEqual(buildDate(2020, 5, 1)); + expect(parseHumanInput('01.05.530')).toEqual(buildDate(530, 5, 1)); + expect(parseHumanInput('01.05.1870')).toEqual(buildDate(1870, 5, 1)); + + // month out of bounds + expect(parseHumanInput('01.13.16')).toBe(INVALID_DATE); + + // day out of bounds + expect(parseHumanInput('32.05.16')).toBe(INVALID_DATE); + + // up to 10 years in the future -> use this century + expect(parseHumanInput('01.05.30')).toEqual(buildDate(2030, 5, 1)); + + // year more than 10 years in the future -> use last century + expect(parseHumanInput('01.05.31')).toEqual(buildDate(1931, 5, 1)); + }); + + it("should accept '/' separated date", () => { + expect(parseHumanInput('01/05/20')).toEqual(buildDate(2020, 5, 1)); + expect(parseHumanInput('01/05/530')).toEqual(buildDate(530, 5, 1)); + expect(parseHumanInput('01/05/1870')).toEqual(buildDate(1870, 5, 1)); + + // month out of bounds + expect(parseHumanInput('01/13/16')).toBe(INVALID_DATE); + + // day out of bounds + expect(parseHumanInput('32/05/16')).toBe(INVALID_DATE); + + // up to 10 years in the future -> use this century + expect(parseHumanInput('01/05/30')).toEqual(buildDate(2030, 5, 1)); + + // year more than 10 years in the future -> use last century + expect(parseHumanInput('01/05/31')).toEqual(buildDate(1931, 5, 1)); + }); + + it("should accept ' ' separated date", () => { + expect(parseHumanInput('01 05 20')).toEqual(buildDate(2020, 5, 1)); + expect(parseHumanInput('01 05 530')).toEqual(buildDate(530, 5, 1)); + expect(parseHumanInput('01 05 1870')).toEqual(buildDate(1870, 5, 1)); + + // month out of bounds + expect(parseHumanInput('01 13 16')).toBe(INVALID_DATE); + + // day out of bounds + expect(parseHumanInput('32 05 16')).toBe(INVALID_DATE); + + // up to 10 years in the future -> use this century + expect(parseHumanInput('01 05 30')).toEqual(buildDate(2030, 5, 1)); + + // year more than 10 years in the future -> use last century + expect(parseHumanInput('01 05 31')).toEqual(buildDate(1931, 5, 1)); + }); +}); diff --git a/projects/components/utils/src/date-fns.ts b/projects/components/utils/src/date-fns.ts new file mode 100644 index 00000000..d971ed5b --- /dev/null +++ b/projects/components/utils/src/date-fns.ts @@ -0,0 +1,538 @@ +import { + differenceInCalendarWeeks as differenceInCalendarWeeksDateFns, + eachWeekOfInterval as eachWeekOfIntervalDateFns, + endOfWeek as endOfWeekDateFns, + format as formatDateFns, + formatDistance as formatDistanceDateFns, + formatDistanceStrict as formatDistanceStrictDateFns, + formatDistanceToNow as formatDistanceToNowDateFns, + formatRelative as formatRelativeDateFns, + getWeek as getWeekDateFns, + getWeekOfMonth as getWeekOfMonthDateFns, + getWeeksInMonth as getWeeksInMonthDateFns, + getWeekYear as getWeekYearDateFns, + isAfter as isAfterDateFns, + isBefore as isBeforeDateFns, + isEqual as isEqualDateFns, + isSameWeek as isSameWeekDateFns, + isThisWeek as isThisWeekDateFns, + lastDayOfWeek as lastDayOfWeekDateFns, + parse as parseDateFns, + setDay as setDayDateFns, + setWeek as setWeekDateFns, + setWeekYear as setWeekYearDateFns, + startOfWeek as startOfWeekDateFns, + startOfWeekYear as startOfWeekYearDateFns, +} from 'date-fns'; +import { de, enGB } from 'date-fns/locale'; + +// ===================================== +// = Default Locale Helper +// ===================================== + +let defaultLocale: Locale = de; + +export function getLocale(localeId?: string) { + if (!localeId) { + return defaultLocale; + } + return localeId.startsWith('de') ? de : enGB; +} + +export function setDefaultLocale(localeId: string) { + defaultLocale = getLocale(localeId); +} + +function buildOptions(options: T): T { + return { locale: getLocale(), ...(options || ({} as any)) }; +} + +// ===================================== +// = New Date Functions +// ===================================== + +export const INVALID_DATE = new Date(NaN); + +export function isBeforeOrEqual(date: Date | number, dateToCompare: Date | number): boolean { + return isBeforeDateFns(date, dateToCompare) || isEqualDateFns(date, dateToCompare); +} + +export function isAfterOrEqual(date: Date | number, dateToCompare: Date | number): boolean { + return isAfterDateFns(date, dateToCompare) || isEqualDateFns(date, dateToCompare); +} + +export function parseHumanInput(value: any): Date | null { + // We have no way using the native JS Date to set the parse format or locale, so we ignore these + // parameters. + if (typeof value === 'number') { + return new Date(value); + } + if (!value) { + return null; + } + + const stringValue = `${value}`.trim(); + const dateParserExact = /^(\d+)[\.\/ ](\d+)[\.\/ ](\d+)$/; + const dateParserFuzzy = /^(\d{2})(\d{2})(\d{2,4})$/; + const match = stringValue.match(dateParserExact) || stringValue.match(dateParserFuzzy); + if (match) { + const day = +match[1]; + if (day > 31) { + return INVALID_DATE; + } + + const month = +match[2] - 1; + if (month > 11) { + return INVALID_DATE; + } + + let year; + if (match[3].length <= 2) { + year = +match[3]; + + const currentYear = parseHumanInput.currentYear; + const currentCentury = Math.round(currentYear / 100) * 100; + const difference = year - (currentYear - currentCentury); + year = year + currentCentury; + // Wenn es bei gleichem Jahrhundert 10 Jahre in der Zukunft wäre, nehmen wir das letzte Jahrhundert. + if (difference > 10) { + year = year - 100; + } + } else { + year = +match[3]; + } + return new Date(year, month, day); + } + + return INVALID_DATE; +} +parseHumanInput.currentYear = new Date().getFullYear(); + +// ===================================== +// = Option Wrappers +// ===================================== + +export function differenceInCalendarWeeks( + dateLeft: Date | number, + dateRight: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): number { + return differenceInCalendarWeeksDateFns(dateLeft, dateRight, buildOptions(options)); +} + +export function eachWeekOfInterval( + interval: Interval, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): Date[] { + return eachWeekOfIntervalDateFns(interval, buildOptions(options)); +} + +export function endOfWeek( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): Date { + return endOfWeekDateFns(date, buildOptions(options)); +} + +export function format( + date: Date | number, + // tslint:disable-next-line: no-shadowed-variable + format: string, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: number; + useAdditionalWeekYearTokens?: boolean; + useAdditionalDayOfYearTokens?: boolean; + } +): string { + return formatDateFns(date, format, buildOptions(options)); +} + +export function formatDistance( + date: Date | number, + baseDate: Date | number, + options?: { + includeSeconds?: boolean; + addSuffix?: boolean; + locale?: Locale; + } +): string { + return formatDistanceDateFns(date, baseDate, buildOptions(options)); +} + +export function formatDistanceStrict( + date: Date | number, + baseDate: Date | number, + options?: { + addSuffix?: boolean; + unit?: 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year'; + roundingMethod?: 'floor' | 'ceil' | 'round'; + locale?: Locale; + } +): string { + return formatDistanceStrictDateFns(date, baseDate, buildOptions(options)); +} + +export function formatDistanceToNow( + date: Date | number, + options?: { + includeSeconds?: boolean; + addSuffix?: boolean; + locale?: Locale; + } +): string { + return formatDistanceToNowDateFns(date, buildOptions(options)); +} + +export function formatRelative( + date: Date | number, + baseDate: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): string { + return formatRelativeDateFns(date, baseDate, buildOptions(options)); +} + +export function getWeek( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7; + } +): number { + return getWeekDateFns(date, buildOptions(options)); +} + +export function getWeekOfMonth( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): number { + return getWeekOfMonthDateFns(date, buildOptions(options)); +} + +export function getWeeksInMonth( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): number { + return getWeeksInMonthDateFns(date, buildOptions(options)); +} + +export function getWeekYear( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7; + } +): number { + return getWeekYearDateFns(date, buildOptions(options)); +} + +export function isSameWeek( + dateLeft: Date | number, + dateRight: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): boolean { + return isSameWeekDateFns(dateLeft, dateRight, buildOptions(options)); +} + +export function isThisWeek( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): boolean { + return isThisWeekDateFns(date, buildOptions(options)); +} + +export function lastDayOfWeek( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): Date { + return lastDayOfWeekDateFns(date, buildOptions(options)); +} + +export function parse( + dateString: string, + formatString: string, + backupDate: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7; + useAdditionalWeekYearTokens?: boolean; + useAdditionalDayOfYearTokens?: boolean; + } +): Date { + return parseDateFns(dateString, formatString, backupDate, buildOptions(options)); +} + +export function setDay( + date: Date | number, + day: number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): Date { + return setDayDateFns(date, day, buildOptions(options)); +} + +export function setWeek( + date: Date | number, + week: number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7; + } +): Date { + return setWeekDateFns(date, week, buildOptions(options)); +} + +export function setWeekYear( + date: Date | number, + week: number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7; + } +): Date { + return setWeekYearDateFns(date, week, buildOptions(options)); +} + +export function startOfWeek( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + } +): Date { + return startOfWeekDateFns(date, buildOptions(options)); +} + +export function startOfWeekYear( + date: Date | number, + options?: { + locale?: Locale; + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + firstWeekContainsDate?: 1 | 2 | 3 | 4 | 5 | 6 | 7; + } +): Date { + return startOfWeekYearDateFns(date, buildOptions(options)); +} + +// ===================================== +// = Reexports +// ===================================== + +export { + Interval, + Locale, + addBusinessDays, + addDays, + addHours, + addISOWeekYears, + addMilliseconds, + addMinutes, + addMonths, + addQuarters, + addSeconds, + addWeeks, + addYears, + areIntervalsOverlapping, + closestIndexTo, + closestTo, + compareAsc, + compareDesc, + differenceInBusinessDays, + differenceInCalendarDays, + differenceInCalendarISOWeekYears, + differenceInCalendarISOWeeks, + differenceInCalendarMonths, + differenceInCalendarQuarters, + // differenceInCalendarWeeks, + differenceInCalendarYears, + differenceInDays, + differenceInHours, + differenceInISOWeekYears, + differenceInMilliseconds, + differenceInMinutes, + differenceInMonths, + differenceInQuarters, + differenceInSeconds, + differenceInWeeks, + differenceInYears, + eachDayOfInterval, + // eachWeekOfInterval, + eachWeekendOfInterval, + eachWeekendOfMonth, + eachWeekendOfYear, + endOfDay, + endOfDecade, + endOfHour, + endOfISOWeek, + endOfISOWeekYear, + endOfMinute, + endOfMonth, + endOfQuarter, + endOfSecond, + endOfToday, + endOfTomorrow, + // endOfWeek, + endOfYear, + endOfYesterday, + // format, + // formatDistance, + // formatDistanceStrict, + // formatDistanceToNow, + // formatRelative, + fromUnixTime, + getDate, + getDay, + getDayOfYear, + getDaysInMonth, + getDaysInYear, + getDecade, + getHours, + getISODay, + getISOWeek, + getISOWeekYear, + getISOWeeksInYear, + getMilliseconds, + getMinutes, + getMonth, + getOverlappingDaysInIntervals, + getQuarter, + getSeconds, + getTime, + getUnixTime, + // getWeek, + // getWeekOfMonth, + // getWeekYear, + // getWeeksInMonth, + getYear, + isAfter, + isBefore, + isDate, + isEqual, + isFirstDayOfMonth, + isFriday, + isFuture, + isLastDayOfMonth, + isLeapYear, + isMonday, + isPast, + isSameDay, + isSameHour, + isSameISOWeek, + isSameISOWeekYear, + isSameMinute, + isSameMonth, + isSameQuarter, + isSameSecond, + // isSameWeek, + isSameYear, + isSaturday, + isSunday, + isThisHour, + isThisISOWeek, + isThisMinute, + isThisMonth, + isThisQuarter, + isThisSecond, + // isThisWeek, + isThisYear, + isThursday, + isToday, + isTomorrow, + isTuesday, + isValid, + isWednesday, + isWeekend, + isWithinInterval, + isYesterday, + lastDayOfDecade, + lastDayOfISOWeek, + lastDayOfISOWeekYear, + lastDayOfMonth, + lastDayOfQuarter, + // lastDayOfWeek, + lastDayOfYear, + lightFormat, + max, + maxTime, + min, + minTime, + // parse, + parseISO, + roundToNearestMinutes, + setDate, + // setDay, + setDayOfYear, + setHours, + setISODay, + setISOWeek, + setISOWeekYear, + setMilliseconds, + setMinutes, + setMonth, + setQuarter, + setSeconds, + // setWeek, + // setWeekYear, + setYear, + startOfDay, + startOfDecade, + startOfHour, + startOfISOWeek, + startOfISOWeekYear, + startOfMinute, + startOfMonth, + startOfQuarter, + startOfSecond, + startOfToday, + startOfTomorrow, + // startOfWeek, + // startOfWeekYear, + startOfYear, + startOfYesterday, + subDays, + subHours, + subISOWeekYears, + subMilliseconds, + subMinutes, + subMonths, + subQuarters, + subSeconds, + subWeeks, + subYears, + toDate, +} from 'date-fns'; diff --git a/projects/prosoft-components-demo/src/app/app.component.html b/projects/prosoft-components-demo/src/app/app.component.html index 1629e090..5289394c 100644 --- a/projects/prosoft-components-demo/src/app/app.component.html +++ b/projects/prosoft-components-demo/src/app/app.component.html @@ -11,6 +11,7 @@ Select Number Input + DateTime Input Slider Flip Container diff --git a/projects/prosoft-components-demo/src/app/app.module.ts b/projects/prosoft-components-demo/src/app/app.module.ts index 9ce2cc10..6bce9a8f 100644 --- a/projects/prosoft-components-demo/src/app/app.module.ts +++ b/projects/prosoft-components-demo/src/app/app.module.ts @@ -52,6 +52,10 @@ import { AppComponent } from './app.component'; path: 'number-input', loadChildren: () => import('./number-input-demo/number-input-demo.module').then((m) => m.NumberInputDemoModule), }, + { + path: 'date-time-input', + loadChildren: () => import('./date-time-input-demo/date-time-input-demo.module').then((m) => m.DateTimeInputDemoModule), + }, { path: 'form', loadChildren: () => import('./form-demo/form-demo.module').then((m) => m.FormDemoModule), diff --git a/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.component.ts b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.component.ts new file mode 100644 index 00000000..90c21f14 --- /dev/null +++ b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; + +@Component({ + selector: 'app-date-time-input-demo', + template: ` +
+ + + Select date-time + +
+
Selected value: {{ form.getRawValue().myDateTime }}
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DateTimeInputDemoComponent { + public form = new FormGroup({ + myDateTime: new FormControl(null), + }); +} diff --git a/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.module.ts b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.module.ts new file mode 100644 index 00000000..6d8ff7ed --- /dev/null +++ b/projects/prosoft-components-demo/src/app/date-time-input-demo/date-time-input-demo.module.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { ErrorStateMatcher, MatNativeDateModule } from '@angular/material/core'; +import { MatInputModule } from '@angular/material/input'; +import { RouterModule } from '@angular/router'; +import { PsDateTimeInputModule } from '@prosoft/components/date-time-input'; +import { PsFormBaseModule } from '@prosoft/components/form-base'; +import { PsFormFieldModule } from '@prosoft/components/form-field'; +import { DemoPsFormsService } from '../common/demo-ps-form-service'; +import { InvalidErrorStateMatcher } from '../common/invalid-error-state-matcher'; +import { DateTimeInputDemoComponent } from './date-time-input-demo.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: DateTimeInputDemoComponent, + }, + ]), + CommonModule, + FormsModule, + ReactiveFormsModule, + + PsDateTimeInputModule, + + PsFormBaseModule.forRoot(DemoPsFormsService), + PsFormFieldModule, + MatCardModule, + MatButtonModule, + MatCheckboxModule, + MatInputModule, + MatNativeDateModule, + ], + declarations: [DateTimeInputDemoComponent], + providers: [{ provide: ErrorStateMatcher, useClass: InvalidErrorStateMatcher }], +}) +export class DateTimeInputDemoModule {}