diff --git a/docs/content-services/components/search-date-range.component.md b/docs/content-services/components/search-date-range.component.md index e85e75eb64..916f6f8b65 100644 --- a/docs/content-services/components/search-date-range.component.md +++ b/docs/content-services/components/search-date-range.component.md @@ -7,7 +7,7 @@ Last reviewed: 2018-06-11 # [Search date range component](../../../lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts "Defined in search-date-range.component.ts") -Implements a date range [widget](../../../lib/testing/src/lib/core/pages/form/widgets/widget.ts) for the [Search Filter component](search-filter.component.md). +Implements a [search widget](../../../lib/content-services/src/lib/search/search-widget.interface.ts) for the [Search Filter component](search-filter.component.md). ![Date Range Widget](../../docassets/images/search-date-range.png) diff --git a/docs/content-services/components/search-datetime-range.component.md b/docs/content-services/components/search-datetime-range.component.md new file mode 100644 index 0000000000..eb86ed18a8 --- /dev/null +++ b/docs/content-services/components/search-datetime-range.component.md @@ -0,0 +1,85 @@ +--- +Title: Search datetime range component +Added: v4.2.0 +Status: Active +Last reviewed: 2020-11-02 +--- + +# [Search datetime range component](../../../lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts "Defined in search-datetime-range.component.ts") + +Implements a [search widget](../../../lib/content-services/src/lib/search/search-widget.interface.ts) for the [Search Filter component](search-filter.component.md). + +![Date Range Widget](../../docassets/images/search-datetime-range.png) + +## Basic usage + +```json +{ + "search": { + "categories": [ + { + "id": "createdDatetimeRange", + "name": "Created Datetime (range)", + "enabled": true, + "component": { + "selector": "datetime-range", + "settings": { + "field": "cm:created" + } + } + } + ] + } +} +``` + +### Settings + +| Name | Type | Description | +| ---- | ---- | ----------- | +| field | string | Field to apply the query to. Required value | +| datetimeFormat | string | Datetime format. Datetime formats used by the datetime picker are [Moment.js](https://momentjs.com/docs/#/parsing/string-format/) instances, so you can use any datetime format supported by Moment. Default is 'DD/MM/YYYY HH:mm'. | +| maxDatetime | string | A fixed datetime that will set the maximum searchable datetime. Default is no maximum. | + +## Details + +This component lets the user select a range between two dates and times based on the particular `field`. +See the [Search filter component](search-filter.component.md) for full details of how to use widgets +in a search query. + +### Custom datetime format + +You can set the datetime range picker to work with any datetime format your app requires. You can use +any datetime format supported by [Moment.js](https://momentjs.com/docs/#/parsing/string-format/) +in the `datetimeFormat` and in the `maxDatetime` setting: + +```json +{ + "search": { + "categories": [ + { + "id": "createdDateTimeRange", + "name": "Created Datetime (range)", + "enabled": true, + "component": { + "selector": "datetime-range", + "settings": { + "field": "cm:created", + "datetimeFormat": "DD-MMM-YY HH:mm:ss", + "maxDatetime": "10-Mar-20 20:00" + } + } + } + ] + } +} +``` + +## See also + +- [Search filter component](search-filter.component.md) +- [Search check list component](search-check-list.component.md) +- [Search number range component](search-number-range.component.md) +- [Search radio component](search-radio.component.md) +- [Search slider component](search-slider.component.md) +- [Search text component](search-text.component.md) diff --git a/docs/docassets/images/search-datetime-range.png b/docs/docassets/images/search-datetime-range.png new file mode 100644 index 0000000000..48de6e731c Binary files /dev/null and b/docs/docassets/images/search-datetime-range.png differ diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.spec.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.spec.ts index eb5d1cac27..bc18274f0b 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.spec.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.spec.ts @@ -40,6 +40,7 @@ describe('ContentNodeSelectorPanelService', () => { const expectedSupportedTypesMap = new Map (); expectedSupportedTypesMap.set('d:text', 'text'); expectedSupportedTypesMap.set('d:date', 'date-range'); + expectedSupportedTypesMap.set('d:datetime', 'datetime-range'); expect(contentNodeSelectorPanelService.modelPropertyTypeToSearchFilterTypeMap).toEqual(expectedSupportedTypesMap); }); diff --git a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.ts b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.ts index 7ccca67a7b..f2b07d1b99 100644 --- a/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.ts +++ b/lib/content-services/src/lib/content-node-selector/content-node-selector-panel.service.ts @@ -23,13 +23,14 @@ import { SearchCategory } from '../search/search-category.interface'; }) export class ContentNodeSelectorPanelService { - propertyTypes = ['d:text', 'd:date']; + propertyTypes = ['d:text', 'd:date', 'd:datetime']; modelPropertyTypeToSearchFilterTypeMap = new Map (); customModels: any[]; constructor() { this.modelPropertyTypeToSearchFilterTypeMap.set(this.propertyTypes[0], 'text'); this.modelPropertyTypeToSearchFilterTypeMap.set(this.propertyTypes[1], 'date-range'); + this.modelPropertyTypeToSearchFilterTypeMap.set(this.propertyTypes[2], 'datetime-range'); } convertCustomModelPropertiesToSearchCategories(): SearchCategory[] { diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index c528088e28..1218374c7f 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -252,7 +252,9 @@ "NO-DAYS": "No days selected.", "INVALID-FORMAT": "Invalid Format", "INVALID-DATE": "Invalid date. The date must be in the format '{{ requiredFormat }}'", - "BEYOND-MAX-DATE": "The date is beyond the maximum date." + "BEYOND-MAX-DATE": "The date is beyond the maximum date.", + "INVALID-DATETIME": "Invalid datetime. The datetime must be in the format '{{ requiredFormat }}'", + "BEYOND-MAX-DATETIME": "The datetime is beyond the maximum datetime." } }, "ICONS": { diff --git a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.html b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.html index d00f35358e..27024c7516 100644 --- a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.html +++ b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.html @@ -12,7 +12,7 @@ - {{ getFromValidationMessage() | translate: { requiredFormat: datePickerDateFormat } }} + {{ getFromValidationMessage() | translate: { requiredFormat: datePickerFormat } }} @@ -30,7 +30,7 @@ - {{ getToValidationMessage() | translate: { requiredFormat: datePickerDateFormat } }} + {{ getToValidationMessage() | translate: { requiredFormat: datePickerFormat } }} diff --git a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.spec.ts b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.spec.ts index bda21cf9c5..6058ee1d68 100644 --- a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.spec.ts @@ -25,182 +25,204 @@ import { TranslateModule } from '@ngx-translate/core'; declare let moment: any; describe('SearchDateRangeComponent', () => { - let fixture: ComponentFixture; - let component: SearchDateRangeComponent; - let adapter: MomentDateAdapter; - const fromDate = '2016-10-16'; - const toDate = '2017-10-16'; - const maxDate = '10-Mar-20'; - const dateFormatFixture = 'DD-MMM-YY'; + let fixture: ComponentFixture; + let component: SearchDateRangeComponent; + let adapter: MomentDateAdapter; + const fromDate = '2016-10-16'; + const toDate = '2017-10-16'; + const maxDate = '10-Mar-20'; + const dateFormatFixture = 'DD-MMM-YY'; - setupTestBed({ - imports: [ - TranslateModule.forRoot(), - ContentTestingModule - ] - }); + setupTestBed({ + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ] + }); - beforeEach(() => { - fixture = TestBed.createComponent(SearchDateRangeComponent); - adapter = fixture.debugElement.injector.get(DateAdapter) as MomentDateAdapter; - component = fixture.componentInstance; - }); + beforeEach(() => { + fixture = TestBed.createComponent(SearchDateRangeComponent); + adapter = fixture.debugElement.injector.get(DateAdapter) as MomentDateAdapter; + component = fixture.componentInstance; + }); - afterEach(() => fixture.destroy()); + afterEach(() => fixture.destroy()); - it('should use moment adapter', () => { - expect(adapter instanceof MomentDateAdapter).toBe(true); - expect(component.datePickerDateFormat).toBe('DD/MM/YYYY'); - }); + it('should use moment adapter', () => { + fixture.detectChanges(); - it('should setup form elements on init', () => { - fixture.detectChanges(); - expect(component.from).toBeDefined(); - expect(component.to).toBeDefined(); - expect(component.form).toBeDefined(); - }); + expect(adapter instanceof MomentDateAdapter).toBe(true); + expect(component.datePickerFormat).toBe('DD/MM/YYYY'); + }); - it('should setup the format of the date from configuration', () => { - component.settings = { field: 'cm:created', dateFormat: dateFormatFixture }; - fixture.detectChanges(); - expect(adapter.overrideDisplayFormat).toBe(dateFormatFixture); - }); + it('should setup form elements on init', () => { + fixture.detectChanges(); - it('should setup form control with formatted valid date on change', () => { - component.settings = { field: 'cm:created', dateFormat: dateFormatFixture }; - fixture.detectChanges(); + expect(component.from).toBeDefined(); + expect(component.to).toBeDefined(); + expect(component.form).toBeDefined(); + }); - const inputString = '20-feb-18'; - const momentFromInput = moment(inputString, dateFormatFixture); - expect(momentFromInput.isValid()).toBeTruthy(); + it('should setup the format of the date from configuration', () => { + component.settings = { field: 'cm:created', dateFormat: dateFormatFixture }; + fixture.detectChanges(); - component.onChangedHandler({ value: inputString }, component.from); - expect(component.from.value.toString()).toEqual(momentFromInput.toString()); - }); + expect(adapter.overrideDisplayFormat).toBe(dateFormatFixture); + }); - it('should NOT setup form control with invalid date on change', () => { - component.settings = { field: 'cm:created', dateFormat: dateFormatFixture }; - fixture.detectChanges(); + it('should setup form control with formatted valid date on change', () => { + component.settings = { field: 'cm:created', dateFormat: dateFormatFixture }; + fixture.detectChanges(); - const inputString = '20.f.18'; - const momentFromInput = moment(inputString, dateFormatFixture); - expect(momentFromInput.isValid()).toBeFalsy(); + const inputString = '20-feb-18'; + const momentFromInput = moment(inputString, dateFormatFixture); - component.onChangedHandler({ value: inputString }, component.from); - expect(component.from.value.toString()).not.toEqual(momentFromInput.toString()); - }); + expect(momentFromInput.isValid()).toBeTruthy(); - it('should reset form', () => { - fixture.detectChanges(); - component.form.setValue({ from: fromDate, to: toDate }); + component.onChangedHandler({ value: inputString }, component.from); - expect(component.from.value).toEqual(fromDate); - expect(component.to.value).toEqual(toDate); + expect(component.from.value.toString()).toEqual(momentFromInput.toString()); + }); - component.reset(); + it('should NOT setup form control with invalid date on change', () => { + component.settings = { field: 'cm:created', dateFormat: dateFormatFixture }; + fixture.detectChanges(); - expect(component.from.value).toEqual(''); - expect(component.to.value).toEqual(''); - expect(component.form.value).toEqual({ from: '', to: '' }); - }); + const inputString = '20.f.18'; + const momentFromInput = moment(inputString, dateFormatFixture); - it('should update query builder on reset', () => { - const context: any = { - queryFragments: { - createdDateRange: 'query' - }, - update() { - } - }; + expect(momentFromInput.isValid()).toBeFalsy(); - component.id = 'createdDateRange'; - component.context = context; + component.onChangedHandler({ value: inputString }, component.from); - spyOn(context, 'update').and.stub(); + expect(component.from.value.toString()).not.toEqual(momentFromInput.toString()); + }); - fixture.detectChanges(); - component.reset(); + it('should reset form', () => { + fixture.detectChanges(); + component.form.setValue({ from: fromDate, to: toDate }); - expect(context.queryFragments.createdDateRange).toEqual(''); - expect(context.update).toHaveBeenCalled(); - }); + expect(component.from.value).toEqual(fromDate); + expect(component.to.value).toEqual(toDate); - it('should update query builder on value changes', () => { - const context: any = { - queryFragments: {}, - update() { - } - }; + component.reset(); - component.id = 'createdDateRange'; - component.context = context; - component.settings = { field: 'cm:created' }; + expect(component.from.value).toEqual(''); + expect(component.to.value).toEqual(''); + expect(component.form.value).toEqual({ from: '', to: '' }); + }); - spyOn(context, 'update').and.stub(); + it('should reset fromMaxDate on reset', () => { + fixture.detectChanges(); + component.fromMaxDate = fromDate; + component.reset(); - fixture.detectChanges(); - component.apply({ - from: fromDate, - to: toDate - }, true); + expect(component.fromMaxDate).toEqual(undefined); + }); - const startDate = moment(fromDate).startOf('day').format(); - const endDate = moment(toDate).endOf('day').format(); + it('should update query builder on reset', () => { + const context: any = { + queryFragments: { + createdDateRange: 'query' + }, + update() { + } + }; - const expectedQuery = `cm:created:['${startDate}' TO '${endDate}']`; - expect(context.queryFragments[component.id]).toEqual(expectedQuery); - expect(context.update).toHaveBeenCalled(); - }); + component.id = 'createdDateRange'; + component.context = context; - it('should show date-format error when Invalid found', async(() => { - fixture.detectChanges(); - const input = fixture.debugElement.nativeElement.querySelector('[data-automation-id="date-range-from-input"]'); - input.value = '10-05-18'; - input.dispatchEvent(new Event('input')); - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(component.getFromValidationMessage()).toEqual('SEARCH.FILTER.VALIDATION.INVALID-DATE'); - }); - })); + spyOn(context, 'update').and.stub(); - it('should not show date-format error when valid found', async(() => { - fixture.detectChanges(); - const input = fixture.debugElement.nativeElement.querySelector('[data-automation-id="date-range-from-input"]'); - input.value = '10/10/2018'; - input.dispatchEvent(new Event('input')); - fixture.detectChanges(); - fixture.whenStable().then(() => { - fixture.detectChanges(); - expect(component.getFromValidationMessage()).toBeFalsy(); - }); - })); + fixture.detectChanges(); + component.reset(); - it('should have no maximum date by default', async(() => { - fixture.detectChanges(); + expect(context.queryFragments.createdDateRange).toEqual(''); + expect(context.update).toHaveBeenCalled(); + }); - expect(fixture.debugElement.nativeElement.querySelector('input[ng-reflect-max]')).toBeNull(); - })); + it('should update query builder on value changes', () => { + const context: any = { + queryFragments: {}, + update() { + } + }; - it('should be able to set a fixed maximum date', async(() => { - component.settings = { field: 'cm:created', dateFormat: dateFormatFixture, maxDate: maxDate }; - fixture.detectChanges(); + component.id = 'createdDateRange'; + component.context = context; + component.settings = { field: 'cm:created' }; - const inputs = fixture.debugElement.nativeElement.querySelectorAll('input[ng-reflect-max="Tue Mar 10 2020 23:59:59 GMT+0"]'); - expect(inputs[0]).toBeDefined(); - expect(inputs[0]).not.toBeNull(); - expect(inputs[1]).toBeDefined(); - expect(inputs[1]).not.toBeNull(); - })); + spyOn(context, 'update').and.stub(); - it('should be able to set the maximum date to today', async(() => { - component.settings = { field: 'cm:created', dateFormat: dateFormatFixture, maxDate: 'today' }; - fixture.detectChanges(); - const today = adapter.today().endOf('day').toString().slice(0, -3); + fixture.detectChanges(); + component.apply({ + from: fromDate, + to: toDate + }, true); - const inputs = fixture.debugElement.nativeElement.querySelectorAll('input[ng-reflect-max="' + today + '"]'); - expect(inputs[0]).toBeDefined(); - expect(inputs[0]).not.toBeNull(); - expect(inputs[1]).toBeDefined(); - expect(inputs[1]).not.toBeNull(); - })); + const startDate = moment(fromDate).startOf('day').format(); + const endDate = moment(toDate).endOf('day').format(); + + const expectedQuery = `cm:created:['${startDate}' TO '${endDate}']`; + + expect(context.queryFragments[component.id]).toEqual(expectedQuery); + expect(context.update).toHaveBeenCalled(); + }); + + it('should show date-format error when Invalid found', async () => { + fixture.detectChanges(); + + const input = fixture.debugElement.nativeElement.querySelector('[data-automation-id="date-range-from-input"]'); + input.value = '10-05-18'; + input.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.getFromValidationMessage()).toEqual('SEARCH.FILTER.VALIDATION.INVALID-DATE'); + }); + + it('should not show date-format error when valid found', async () => { + fixture.detectChanges(); + + const input = fixture.debugElement.nativeElement.querySelector('[data-automation-id="date-range-from-input"]'); + input.value = '10/10/2018'; + input.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.getFromValidationMessage()).toEqual(''); + }); + + it('should have no maximum date by default', async(() => { + fixture.detectChanges(); + + expect(fixture.debugElement.nativeElement.querySelector('input[ng-reflect-max]')).toBeNull(); + })); + + it('should be able to set a fixed maximum date', async () => { + component.settings = { field: 'cm:created', dateFormat: dateFormatFixture, maxDate: maxDate }; + fixture.detectChanges(); + + const inputs = fixture.debugElement.nativeElement.querySelectorAll('input[ng-reflect-max="Tue Mar 10 2020 23:59:59 GMT+0"]'); + + expect(inputs[0]).toBeDefined(); + expect(inputs[0]).not.toBeNull(); + expect(inputs[1]).toBeDefined(); + expect(inputs[1]).not.toBeNull(); + }); + + it('should be able to set the maximum date to today', async () => { + component.settings = { field: 'cm:created', dateFormat: dateFormatFixture, maxDate: 'today' }; + fixture.detectChanges(); + const today = adapter.today().endOf('day').toString().slice(0, -3); + + const inputs = fixture.debugElement.nativeElement.querySelectorAll('input[ng-reflect-max="' + today + '"]'); + + expect(inputs[0]).toBeDefined(); + expect(inputs[0]).not.toBeNull(); + expect(inputs[1]).toBeDefined(); + expect(inputs[1]).not.toBeNull(); + }); }); diff --git a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts index 0e00e54acd..785455151b 100644 --- a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts @@ -28,6 +28,11 @@ import { Moment } from 'moment'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +export interface DateRangeValue { + from: string; + to: string; +} + declare let moment: any; const DEFAULT_FORMAT_DATE: string = 'DD/MM/YYYY'; @@ -37,8 +42,8 @@ const DEFAULT_FORMAT_DATE: string = 'DD/MM/YYYY'; templateUrl: './search-date-range.component.html', styleUrls: ['./search-date-range.component.scss'], providers: [ - {provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE]}, - {provide: MAT_DATE_FORMATS, useValue: MOMENT_DATE_FORMATS} + { provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] }, + { provide: MAT_DATE_FORMATS, useValue: MOMENT_DATE_FORMATS } ], encapsulation: ViewEncapsulation.None, host: { class: 'adf-search-date-range' } @@ -54,7 +59,7 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; - datePickerDateFormat = DEFAULT_FORMAT_DATE; + datePickerFormat: string; maxDate: any; fromMaxDate: any; isActive = false; @@ -82,11 +87,10 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy } ngOnInit() { - if (this.settings) { - this.datePickerDateFormat = this.settings.dateFormat || DEFAULT_FORMAT_DATE; - } - const theCustomDateAdapter = this.dateAdapter; - theCustomDateAdapter.overrideDisplayFormat = this.datePickerDateFormat; + this.datePickerFormat = this.settings?.dateFormat ? this.settings.dateFormat : DEFAULT_FORMAT_DATE; + + const customDateAdapter = this.dateAdapter; + customDateAdapter.overrideDisplayFormat = this.datePickerFormat; this.userPreferencesService .select(UserPreferenceValues.Locale) @@ -107,8 +111,8 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy if (this.startValue) { const splitValue = this.startValue.split('||'); - const fromValue = this.dateAdapter.parse(splitValue[0], this.datePickerDateFormat); - const toValue = this.dateAdapter.parse(splitValue[1], this.datePickerDateFormat); + const fromValue = this.dateAdapter.parse(splitValue[0], this.datePickerFormat); + const toValue = this.dateAdapter.parse(splitValue[1], this.datePickerFormat); this.from = new FormControl(fromValue, validators); this.to = new FormControl(toValue, validators); } else { @@ -145,19 +149,21 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy this.apply(this.form.value, this.form.valid); } - hasValidValue() { + hasValidValue(): boolean { return this.form.valid; } - getCurrentValue() { - return { from : this.dateAdapter.format(this.form.value.from, this.datePickerDateFormat), - to: this.dateAdapter.format(this.form.value.from, this.datePickerDateFormat) }; + getCurrentValue(): DateRangeValue { + return { + from: this.dateAdapter.format(this.form.value.from, this.datePickerFormat), + to: this.dateAdapter.format(this.form.value.from, this.datePickerFormat) + }; } setValue(parsedDate: string) { const splitValue = parsedDate.split('||'); - const fromValue = this.dateAdapter.parse(splitValue[0], this.datePickerDateFormat); - const toValue = this.dateAdapter.parse(splitValue[1], this.datePickerDateFormat); + const fromValue = this.dateAdapter.parse(splitValue[0], this.datePickerFormat); + const toValue = this.dateAdapter.parse(splitValue[1], this.datePickerFormat); this.from.setValue(fromValue); this.from.markAsDirty(); this.from.markAsTouched(); @@ -177,12 +183,13 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy this.context.queryFragments[this.id] = ''; this.context.update(); } + this.setFromMaxDate(); } onChangedHandler(event: any, formControl: FormControl) { const inputValue = event.value; - const formatDate = this.dateAdapter.parse(inputValue, this.datePickerDateFormat); + const formatDate = this.dateAdapter.parse(inputValue, this.datePickerFormat); if (formatDate && formatDate.isValid()) { formControl.setValue(formatDate); } else if (formatDate) { @@ -199,7 +206,7 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy moment.locale(locale); } - hasParseError(formControl) { + hasParseError(formControl): boolean { return formControl.hasError('matDatepickerParse') && formControl.getError('matDatepickerParse').text; } @@ -207,13 +214,7 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy event.srcElement.click(); } - setFromMaxDate(): any { - let maxDate: string; - if (!this.to.value || this.maxDate && (moment(this.maxDate).isBefore(this.to.value))) { - maxDate = this.maxDate; - } else { - maxDate = moment(this.to.value); - } - this.fromMaxDate = maxDate; + setFromMaxDate() { + this.fromMaxDate = (!this.to.value || this.maxDate && (moment(this.maxDate).isBefore(this.to.value))) ? this.maxDate : moment(this.to.value); } } diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.html b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.html new file mode 100644 index 0000000000..161e992e5b --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.html @@ -0,0 +1,45 @@ +
+ + + + + + {{ getFromValidationMessage() | translate: { requiredFormat: datetimePickerFormat } }} + + + + + + + + + {{ getToValidationMessage() | translate: { requiredFormat: datetimePickerFormat } }} + + + +
+ + +
+
diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.scss b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.scss new file mode 100644 index 0000000000..aa124f9143 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.scss @@ -0,0 +1,5 @@ +.adf-search-date-range > form { + display: inline-flex; + flex-direction: column; + width: 100%; +} diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts new file mode 100644 index 0000000000..e01ba41e7f --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.spec.ts @@ -0,0 +1,194 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SearchDatetimeRangeComponent } from './search-datetime-range.component'; +import { setupTestBed } from '@alfresco/adf-core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; +import { TranslateModule } from '@ngx-translate/core'; + +declare let moment: any; + +describe('SearchDatetimeRangeComponent', () => { + let fixture: ComponentFixture; + let component: SearchDatetimeRangeComponent; + const fromDatetime = '2016-10-16 12:30'; + const toDatetime = '2017-10-16 20:00'; + const maxDatetime = '10-Mar-20 20:00'; + const datetimeFormatFixture = 'DD-MMM-YY HH:mm'; + + setupTestBed({ + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchDatetimeRangeComponent); + component = fixture.componentInstance; + }); + + afterEach(() => fixture.destroy()); + + it('should setup form elements on init', () => { + fixture.detectChanges(); + + expect(component.from).toBeDefined(); + expect(component.to).toBeDefined(); + expect(component.form).toBeDefined(); + }); + + it('should setup form control with formatted valid datetime on change', () => { + component.settings = { field: 'cm:created', datetimeFormat: datetimeFormatFixture }; + fixture.detectChanges(); + + const inputString = '20-feb-18 20:00'; + const momentFromInput = moment(inputString, datetimeFormatFixture); + + expect(momentFromInput.isValid()).toBeTruthy(); + + component.onChangedHandler({ value: inputString }, component.from); + + expect(component.from.value.toString()).toEqual(momentFromInput.toString()); + }); + + it('should NOT setup form control with invalid datetime on change', () => { + component.settings = { field: 'cm:created', datetimeFormat: datetimeFormatFixture }; + fixture.detectChanges(); + + const inputString = '2017-10-16 20:f:00'; + const momentFromInput = moment(inputString, datetimeFormatFixture); + + expect(momentFromInput.isValid()).toBeFalsy(); + + component.onChangedHandler({ value: inputString }, component.from); + + expect(component.from.value.toString()).not.toEqual(momentFromInput.toString()); + }); + + it('should reset form', () => { + fixture.detectChanges(); + component.form.setValue({ from: fromDatetime, to: toDatetime }); + + expect(component.from.value).toEqual(fromDatetime); + expect(component.to.value).toEqual(toDatetime); + + component.reset(); + + expect(component.from.value).toEqual(''); + expect(component.to.value).toEqual(''); + expect(component.form.value).toEqual({ from: '', to: '' }); + }); + + it('should reset fromMaxDatetime on reset', () => { + fixture.detectChanges(); + component.fromMaxDatetime = fromDatetime; + component.reset(); + + expect(component.fromMaxDatetime).toEqual(undefined); + }); + + it('should update query builder on reset', () => { + const context: any = { + queryFragments: { + createdDatetimeRange: 'query' + }, + update() { + } + }; + + component.id = 'createdDatetimeRange'; + component.context = context; + + spyOn(context, 'update').and.stub(); + + fixture.detectChanges(); + component.reset(); + + expect(context.queryFragments.createdDatetimeRange).toEqual(''); + expect(context.update).toHaveBeenCalled(); + }); + + it('should update query builder on value changes', () => { + const context: any = { + queryFragments: {}, + update() { + } + }; + + component.id = 'createdDateRange'; + component.context = context; + component.settings = { field: 'cm:created' }; + + spyOn(context, 'update').and.stub(); + + fixture.detectChanges(); + component.apply({ + from: fromDatetime, + to: toDatetime + }, true); + + const startDate = moment(fromDatetime).startOf('minute').format(); + const endDate = moment(toDatetime).endOf('minute').format(); + + const expectedQuery = `cm:created:['${startDate}' TO '${endDate}']`; + + expect(context.queryFragments[component.id]).toEqual(expectedQuery); + expect(context.update).toHaveBeenCalled(); + }); + + it('should show datetime-format error when an invalid datetime is set', async () => { + fixture.detectChanges(); + component.onChangedHandler({ value: '10/14/2020 10:00:00 PM' }, component.from); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.getFromValidationMessage()).toEqual('SEARCH.FILTER.VALIDATION.INVALID-DATETIME'); + }); + + it('should not show datetime-format error when valid found', async () => { + fixture.detectChanges(); + const input = fixture.debugElement.nativeElement.querySelector('[data-automation-id="datetime-range-from-input"]'); + input.value = '10/16/2017 9:00 PM'; + input.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.getFromValidationMessage()).toEqual(''); + }); + + it('should have no maximum datetime by default', async(() => { + fixture.detectChanges(); + + expect(fixture.debugElement.nativeElement.querySelector('input[ng-reflect-max]')).toBeNull(); + })); + + it('should be able to set a fixed maximum datetime', async () => { + component.settings = { field: 'cm:created', datetimeFormat: datetimeFormatFixture, maxDatetime: maxDatetime }; + fixture.detectChanges(); + + const inputs = fixture.debugElement.nativeElement.querySelectorAll('input[ng-reflect-max="Tue Mar 10 2020 20:00:00 GMT+0"]'); + + expect(inputs[0]).toBeDefined(); + expect(inputs[0]).not.toBeNull(); + expect(inputs[1]).toBeDefined(); + expect(inputs[1]).not.toBeNull(); + }); +}); diff --git a/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts new file mode 100644 index 0000000000..afa65a3993 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts @@ -0,0 +1,213 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OnInit, Component, ViewEncapsulation, OnDestroy } from '@angular/core'; +import { FormControl, Validators, FormGroup } from '@angular/forms'; +import { UserPreferencesService, UserPreferenceValues } from '@alfresco/adf-core'; + +import { SearchWidget } from '../../search-widget.interface'; +import { SearchWidgetSettings } from '../../search-widget-settings.interface'; +import { SearchQueryBuilderService } from '../../search-query-builder.service'; +import { LiveErrorStateMatcher } from '../../forms/live-error-state-matcher'; +import { Moment } from 'moment'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { DatetimeAdapter, MAT_DATETIME_FORMATS } from '@mat-datetimepicker/core'; +import { MAT_MOMENT_DATETIME_FORMATS } from '@mat-datetimepicker/moment'; + +export interface DatetimeRangeValue { + from: string; + to: string; +} + +declare let moment: any; + +const DEFAULT_DATETIME_FORMAT: string = 'DD/MM/YYYY HH:mm'; + +@Component({ + selector: 'adf-search-datetime-range', + templateUrl: './search-datetime-range.component.html', + styleUrls: ['./search-datetime-range.component.scss'], + providers: [ + { provide: MAT_DATETIME_FORMATS, useValue: MAT_MOMENT_DATETIME_FORMATS } + ], + encapsulation: ViewEncapsulation.None, + host: { class: 'adf-search-date-range' } +}) +export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDestroy { + + from: FormControl; + to: FormControl; + + form: FormGroup; + matcher = new LiveErrorStateMatcher(); + + id: string; + settings?: SearchWidgetSettings; + context?: SearchQueryBuilderService; + datetimePickerFormat: string; + maxDatetime: any; + fromMaxDatetime: any; + isActive = false; + startValue: any; + + private onDestroy$ = new Subject(); + + constructor(private dateAdapter: DatetimeAdapter, + private userPreferencesService: UserPreferencesService) { + } + + getFromValidationMessage(): string { + return this.from.hasError('invalidOnChange') || this.hasParseError(this.from) ? 'SEARCH.FILTER.VALIDATION.INVALID-DATETIME' : + this.from.hasError('matDatepickerMax') ? 'SEARCH.FILTER.VALIDATION.BEYOND-MAX-DATETIME' : + this.from.hasError('required') ? 'SEARCH.FILTER.VALIDATION.REQUIRED-VALUE' : + ''; + } + + getToValidationMessage(): string { + return this.to.hasError('invalidOnChange') || this.hasParseError(this.to) ? 'SEARCH.FILTER.VALIDATION.INVALID-DATETIME' : + this.to.hasError('matDatepickerMin') ? 'SEARCH.FILTER.VALIDATION.NO-DAYS' : + this.to.hasError('matDatepickerMax') ? 'SEARCH.FILTER.VALIDATION.BEYOND-MAX-DATETIME' : + this.to.hasError('required') ? 'SEARCH.FILTER.VALIDATION.REQUIRED-VALUE' : + ''; + } + + ngOnInit() { + this.datetimePickerFormat = this.settings?.datetimeFormat ? this.settings.datetimeFormat : DEFAULT_DATETIME_FORMAT; + + this.userPreferencesService + .select(UserPreferenceValues.Locale) + .pipe(takeUntil(this.onDestroy$)) + .subscribe(locale => this.setLocale(locale)); + + const validators = Validators.compose([ + Validators.required + ]); + + if (this.settings && this.settings.maxDatetime) { + this.maxDatetime = moment(this.settings.maxDatetime); + } + + if (this.startValue) { + const splitValue = this.startValue.split('||'); + const fromValue = this.dateAdapter.parse(splitValue[0], this.datetimePickerFormat); + const toValue = this.dateAdapter.parse(splitValue[1], this.datetimePickerFormat); + this.from = new FormControl(fromValue, validators); + this.to = new FormControl(toValue, validators); + } else { + this.from = new FormControl('', validators); + this.to = new FormControl('', validators); + } + + this.form = new FormGroup({ + from: this.from, + to: this.to + }); + + this.setFromMaxDatetime(); + } + + ngOnDestroy() { + this.onDestroy$.next(true); + this.onDestroy$.complete(); + } + + apply(model: { from: string, to: string }, isValid: boolean) { + if (isValid && this.id && this.context && this.settings && this.settings.field) { + this.isActive = true; + + const start = moment(model.from).startOf('minute').format(); + const end = moment(model.to).endOf('minute').format(); + + this.context.queryFragments[this.id] = `${this.settings.field}:['${start}' TO '${end}']`; + this.context.update(); + } + } + + submitValues() { + this.apply(this.form.value, this.form.valid); + } + + hasValidValue(): boolean { + return this.form.valid; + } + + getCurrentValue(): DatetimeRangeValue { + return { + from: this.dateAdapter.format(this.form.value.from, this.datetimePickerFormat), + to: this.dateAdapter.format(this.form.value.from, this.datetimePickerFormat) + }; + } + + setValue(parsedDate: string) { + const splitValue = parsedDate.split('||'); + const fromValue = this.dateAdapter.parse(splitValue[0], this.datetimePickerFormat); + const toValue = this.dateAdapter.parse(splitValue[1], this.datetimePickerFormat); + this.from.setValue(fromValue); + this.from.markAsDirty(); + this.from.markAsTouched(); + this.to.setValue(toValue); + this.to.markAsDirty(); + this.to.markAsTouched(); + this.submitValues(); + } + + reset() { + this.isActive = false; + this.form.reset({ + from: '', + to: '' + }); + if (this.id && this.context) { + this.context.queryFragments[this.id] = ''; + this.context.update(); + } + this.setFromMaxDatetime(); + } + + onChangedHandler(event: any, formControl: FormControl) { + + const inputValue = event.value; + const formatDate = this.dateAdapter.parse(inputValue, this.datetimePickerFormat); + if (formatDate && formatDate.isValid()) { + formControl.setValue(formatDate); + } else if (formatDate) { + formControl.setErrors({ + 'invalidOnChange': true + }); + } + + this.setFromMaxDatetime(); + } + + setLocale(locale) { + this.dateAdapter.setLocale(locale); + moment.locale(locale); + } + + hasParseError(formControl): boolean { + return formControl.hasError('matDatepickerParse') && formControl.getError('matDatepickerParse').text; + } + + forcePlaceholder(event: any) { + event.srcElement.click(); + } + + setFromMaxDatetime() { + this.fromMaxDatetime = (!this.to.value || this.maxDatetime && (moment(this.maxDatetime).isBefore(this.to.value))) ? this.maxDatetime : moment(this.to.value); + } +} diff --git a/lib/content-services/src/lib/search/components/search-filter/search-filter.service.ts b/lib/content-services/src/lib/search/components/search-filter/search-filter.service.ts index fc0c0caf29..743a39cbca 100644 --- a/lib/content-services/src/lib/search/components/search-filter/search-filter.service.ts +++ b/lib/content-services/src/lib/search/components/search-filter/search-filter.service.ts @@ -22,6 +22,7 @@ import { SearchSliderComponent } from '../search-slider/search-slider.component' import { SearchNumberRangeComponent } from '../search-number-range/search-number-range.component'; import { SearchCheckListComponent } from '../search-check-list/search-check-list.component'; import { SearchDateRangeComponent } from '../search-date-range/search-date-range.component'; +import { SearchDatetimeRangeComponent } from '../search-datetime-range/search-datetime-range.component'; @Injectable({ providedIn: 'root' @@ -37,7 +38,8 @@ export class SearchFilterService { 'slider': SearchSliderComponent, 'number-range': SearchNumberRangeComponent, 'check-list': SearchCheckListComponent, - 'date-range': SearchDateRangeComponent + 'date-range': SearchDateRangeComponent, + 'datetime-range': SearchDatetimeRangeComponent }; } diff --git a/lib/content-services/src/lib/search/search.module.ts b/lib/content-services/src/lib/search/search.module.ts index 6b6c41dfd7..bc509a9082 100644 --- a/lib/content-services/src/lib/search/search.module.ts +++ b/lib/content-services/src/lib/search/search.module.ts @@ -39,6 +39,7 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker import { SEARCH_QUERY_SERVICE_TOKEN } from './search-query-service.token'; import { SearchQueryBuilderService } from './search-query-builder.service'; import { SearchFilterContainerComponent } from './components/search-filter-container/search-filter-container.component'; +import { SearchDatetimeRangeComponent } from './components/search-datetime-range/search-datetime-range.component'; @NgModule({ imports: [ @@ -62,6 +63,7 @@ import { SearchFilterContainerComponent } from './components/search-filter-conta SearchPanelComponent, SearchCheckListComponent, SearchDateRangeComponent, + SearchDatetimeRangeComponent, SearchSortingPickerComponent, SearchFilterContainerComponent ], @@ -79,6 +81,7 @@ import { SearchFilterContainerComponent } from './components/search-filter-conta SearchPanelComponent, SearchCheckListComponent, SearchDateRangeComponent, + SearchDatetimeRangeComponent, SearchSortingPickerComponent, SearchFilterContainerComponent ],