[ADF-5213] Metadata - select list filter (#5961)

* select filter input component

* include theme

* add filter input

* show input conditionally

* convert value to string for d:int type values

* filter list options pipe

* add components to module

* convert int value to string

* i18n

* update tests

* tests

* update config

* remove unneeded decorator

* fix lint

* update schema

* remove filter pipe

* provide a filtered list
This commit is contained in:
Cilibiu Bogdan 2020-08-10 01:35:46 +03:00 committed by GitHub
parent 7b7c996fab
commit 9b0db0a82e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 392 additions and 13 deletions

View File

@ -1012,7 +1012,8 @@
},
"multi-value-pipe-separator": ", ",
"multi-value-chips": true,
"copy-to-clipboard-action": true
"copy-to-clipboard-action": true,
"selectFilterLimit": 5
},
"sideNav": {
"expandedSidenav": true,

View File

@ -1036,6 +1036,14 @@
"multi-value-chips": {
"description": "Use chips for multi value properties",
"type": "boolean"
},
"copy-to-clipboard-action": {
"description": "Copy property to the clipboard on double click",
"type": "boolean"
},
"selectFilterLimit": {
"description": "Shows a filter if list options exceed a specified number. Default value 5",
"type": "number"
}
}
},

View File

@ -3,6 +3,7 @@
@import './components/card-view-textitem/card-view-textitem.component';
@import './components/card-view-keyvaluepairsitem/card-view-keyvaluepairsitem.component';
@import './components/card-view/card-view.component';
@import './components/card-view-selectitem/select-filter-input/select-filter-input.component';
@import '~@mat-datetimepicker/core/datetimepicker/datetimepicker-theme.scss';
@mixin adf-card-view-module-theme($theme) {
@ -12,4 +13,5 @@
@include adf-card-view-theme($theme);
@include mat-datetimepicker-theme($theme);
@include adf-card-view-array-item-theme($theme);
@include adf-select-filter-input-theme($theme);
}

View File

@ -45,6 +45,7 @@ import { CardViewTextItemComponent } from './components/card-view-textitem/card-
import { CardViewKeyValuePairsItemComponent } from './components/card-view-keyvaluepairsitem/card-view-keyvaluepairsitem.component';
import { CardViewSelectItemComponent } from './components/card-view-selectitem/card-view-selectitem.component';
import { CardViewArrayItemComponent } from './components/card-view-arrayitem/card-view-arrayitem.component';
import { SelectFilterInputComponent } from './components/card-view-selectitem/select-filter-input/select-filter-input.component';
@NgModule({
imports: [
@ -78,7 +79,8 @@ import { CardViewArrayItemComponent } from './components/card-view-arrayitem/car
CardViewSelectItemComponent,
CardViewItemDispatcherComponent,
CardViewContentProxyDirective,
CardViewArrayItemComponent
CardViewArrayItemComponent,
SelectFilterInputComponent
],
exports: [
CardViewComponent,
@ -88,7 +90,8 @@ import { CardViewArrayItemComponent } from './components/card-view-arrayitem/car
CardViewTextItemComponent,
CardViewSelectItemComponent,
CardViewKeyValuePairsItemComponent,
CardViewArrayItemComponent
CardViewArrayItemComponent,
SelectFilterInputComponent
]
})
export class CardViewModule {}

View File

@ -8,10 +8,14 @@
<div *ngIf="isEditable()">
<mat-form-field class="adf-select-item-padding-editable adf-property-value">
<mat-select [(value)]="value"
panelClass="adf-select-filter"
(selectionChange)="onChange($event)"
data-automation-class="select-box">
<adf-select-filter-input *ngIf="showInputFilter" (change)="onFilterInputChange($event)"></adf-select-filter-input>
<mat-option *ngIf="showNoneOption()">{{ 'CORE.CARDVIEW.NONE' | translate }}</mat-option>
<mat-option *ngFor="let option of getOptions() | async"
<mat-option *ngFor="let option of getList() | async"
[value]="option.key">
{{ option.label | translate }}
</mat-option>

View File

@ -24,12 +24,13 @@ import { setupTestBed } from '../../../testing/setup-test-bed';
import { CoreTestingModule } from '../../../testing/core.testing.module';
import { of } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { AppConfigService } from '../../../app-config/app-config.service';
describe('CardViewSelectItemComponent', () => {
let fixture: ComponentFixture<CardViewSelectItemComponent>;
let component: CardViewSelectItemComponent;
let overlayContainer: OverlayContainer;
let appConfig: AppConfigService;
const mockData = [{ key: 'one', label: 'One' }, { key: 'two', label: 'Two' }, { key: 'three', label: 'Three' }];
const mockDefaultProps = {
label: 'Select box label',
@ -50,6 +51,7 @@ describe('CardViewSelectItemComponent', () => {
fixture = TestBed.createComponent(CardViewSelectItemComponent);
component = fixture.componentInstance;
overlayContainer = TestBed.inject(OverlayContainer);
appConfig = TestBed.inject(AppConfigService);
component.property = new CardViewSelectItemModel(mockDefaultProps);
});
@ -144,4 +146,79 @@ describe('CardViewSelectItemComponent', () => {
expect(label).toBeNull();
});
});
describe('Filter', () => {
it('should render a list of filtered options', () => {
appConfig.config['content-metadata'] = {
selectFilterLimit: 0
};
let optionsElement: any[];
component.property = new CardViewSelectItemModel({
...mockDefaultProps,
editable: true
});
component.editable = true;
component.displayNoneOption = false;
component.ngOnChanges();
fixture.detectChanges();
const selectBox = fixture.debugElement.query(By.css('.mat-select-trigger'));
selectBox.triggerEventHandler('click', {});
fixture.detectChanges();
optionsElement = Array.from(overlayContainer.getContainerElement().querySelectorAll('mat-option'));
expect(optionsElement.length).toBe(3);
const filterInput = fixture.debugElement.query(By.css('.adf-select-filter-input input'));
filterInput.nativeElement.value = mockData[0].label;
filterInput.nativeElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
optionsElement = Array.from(overlayContainer.getContainerElement().querySelectorAll('mat-option'));
expect(optionsElement.length).toBe(1);
expect(optionsElement[0].innerText).toEqual(mockData[0].label);
});
it('should hide filter if options are less then limit', () => {
appConfig.config['content-metadata'] = {
selectFilterLimit: mockData.length + 1
};
component.property = new CardViewSelectItemModel({
...mockDefaultProps,
editable: true
});
component.editable = true;
component.displayNoneOption = false;
component.ngOnChanges();
fixture.detectChanges();
const selectBox = fixture.debugElement.query(By.css('.mat-select-trigger'));
selectBox.triggerEventHandler('click', {});
fixture.detectChanges();
const filterInput = fixture.debugElement.query(By.css('.adf-select-filter-input'));
expect(filterInput).toBe(null);
});
it('should show filter if options are greater then limit', () => {
appConfig.config['content-metadata'] = {
selectFilterLimit: mockData.length - 1
};
component.property = new CardViewSelectItemModel({
...mockDefaultProps,
editable: true
});
component.editable = true;
component.displayNoneOption = false;
component.ngOnChanges();
fixture.detectChanges();
const selectBox = fixture.debugElement.query(By.css('.mat-select-trigger'));
selectBox.triggerEventHandler('click', {});
fixture.detectChanges();
const filterInput = fixture.debugElement.query(By.css('.adf-select-filter-input'));
expect(filterInput).not.toBe(null);
});
});
});

View File

@ -15,20 +15,23 @@
* limitations under the License.
*/
import { Component, Input, OnChanges } from '@angular/core';
import { Component, Input, OnChanges, OnDestroy } from '@angular/core';
import { CardViewSelectItemModel } from '../../models/card-view-selectitem.model';
import { CardViewUpdateService } from '../../services/card-view-update.service';
import { Observable } from 'rxjs';
import { Observable, Subject } from 'rxjs';
import { CardViewSelectItemOption } from '../../interfaces/card-view.interfaces';
import { MatSelectChange } from '@angular/material/select';
import { BaseCardView } from '../base-card-view';
import { AppConfigService } from '../../../app-config/app-config.service';
import { takeUntil, map } from 'rxjs/operators';
@Component({
selector: 'adf-card-view-selectitem',
templateUrl: './card-view-selectitem.component.html',
styleUrls: ['./card-view-selectitem.component.scss']
})
export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItemModel<string>> implements OnChanges {
export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItemModel<string>> implements OnChanges, OnDestroy {
static HIDE_FILTER_LIMIT = 5;
@Input() editable: boolean = false;
@ -41,13 +44,29 @@ export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItem
displayEmpty: boolean = true;
value: string;
filter: string = '';
showInputFilter: boolean = false;
constructor(cardViewUpdateService: CardViewUpdateService) {
private onDestroy$ = new Subject<void>();
constructor(cardViewUpdateService: CardViewUpdateService, private appConfig: AppConfigService) {
super(cardViewUpdateService);
}
ngOnChanges(): void {
this.value = this.property.value;
this.value = this.property.value?.toString();
}
ngOnInit() {
this.getOptions()
.pipe(takeUntil(this.onDestroy$))
.subscribe((options: CardViewSelectItemOption<string>[]) => {
this.showInputFilter = options.length > this.optionsLimit;
});
}
onFilterInputChange(value: string) {
this.filter = value;
}
isEditable(): boolean {
@ -58,6 +77,16 @@ export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItem
return this.options$ || this.property.options$;
}
getList(): Observable<CardViewSelectItemOption<string>[]> {
return this.getOptions()
.pipe(
map((items: CardViewSelectItemOption<string>[]) => items.filter(
(item: CardViewSelectItemOption<string>) =>
item.label.toLowerCase().includes(this.filter.toLowerCase()))),
takeUntil(this.onDestroy$)
);
}
onChange(event: MatSelectChange): void {
const selectedOption = event.value !== undefined ? event.value : null;
this.cardViewUpdateService.update(<CardViewSelectItemModel<string>> { ...this.property }, selectedOption);
@ -71,4 +100,13 @@ export class CardViewSelectItemComponent extends BaseCardView<CardViewSelectItem
get showProperty(): boolean {
return this.displayEmpty || !this.property.isEmpty();
}
ngOnDestroy() {
this.onDestroy$.next();
this.onDestroy$.complete();
}
private get optionsLimit(): number {
return this.appConfig.get<number>('content-metadata.selectFilterLimit', CardViewSelectItemComponent.HIDE_FILTER_LIMIT);
}
}

View File

@ -0,0 +1,25 @@
<div class="adf-select-filter-input-container">
<mat-form-field>
<input matInput
autocomplete="off"
(keydown)="handleKeydown($event)"
[placeholder]="'SELECT_FILTER.INPUT.PLACEHOLDER' | translate"
#selectFilterInput
[ngModel]="term"
(ngModelChange)="onModelChange($event)"
[attr.aria-label]="'SELECT_FILTER.INPUT.ARIA_LABEL' | translate"
(change)="$event.stopPropagation()"
/>
<button mat-button
matSuffix
mat-icon-button
[attr.aria-label]="'SELECT_FILTER.BUTTON.ARIA_LABEL' | translate"
*ngIf="term"
(keydown.enter)="reset($event)"
(click)="reset()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</div>

View File

@ -0,0 +1,35 @@
@mixin adf-select-filter-input-theme($theme) {
$mat-select-panel-max-height: 256px !default;
$select-filter-height: 4em !default;
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
.adf-select-filter-input {
height: $select-filter-height;
display: flex;
.adf-select-filter-input-container {
position: absolute;
top: 0;
width: 100%;
display: flex;
z-index: 100;
font-size: 14px;
color: mat-color($foreground, text, 0.87);
line-height: 3em;
height: $select-filter-height;
padding: 5px 16px 0;
background: mat-color($background, card);
}
.mat-form-field {
width: 100%;
}
}
.mat-select-panel.adf-select-filter {
transform: none !important;
overflow-x: hidden !important;
max-height: calc(#{$mat-select-panel-max-height} + #{$select-filter-height});
}
}

View File

@ -0,0 +1,89 @@
/*!
* @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 { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { setupTestBed } from '../../../../testing/setup-test-bed';
import { CoreTestingModule } from '../../../../testing/core.testing.module';
import { TranslateModule } from '@ngx-translate/core';
import { SelectFilterInputComponent } from './select-filter-input.component';
import { MatSelect } from '@angular/material/select';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ESCAPE } from '@angular/cdk/keycodes';
describe('SelectFilterInputComponent', () => {
let fixture: ComponentFixture<SelectFilterInputComponent>;
let component: SelectFilterInputComponent;
let matSelect: MatSelect;
setupTestBed({
imports: [
TranslateModule.forRoot(),
CoreTestingModule,
NoopAnimationsModule
],
providers: [ MatSelect ]
});
beforeEach(() => {
fixture = TestBed.createComponent(SelectFilterInputComponent);
component = fixture.componentInstance;
matSelect = TestBed.inject(MatSelect);
fixture.detectChanges();
});
it('should focus input on initialization', async(() => {
spyOn(component.selectFilterInput.nativeElement, 'focus');
matSelect.openedChange.next(true);
fixture.detectChanges();
expect(component.selectFilterInput.nativeElement.focus).toHaveBeenCalled();
}));
it('should clear search term on close', async(() => {
component.onModelChange('some-search-term');
expect(component.term).toBe('some-search-term');
matSelect.openedChange.next(false);
fixture.detectChanges();
expect(component.term).toBe('');
}));
it('should emit event when value changes', async(() => {
spyOn(component.change, 'next');
component.onModelChange('some-search-term');
expect(component.change.next).toHaveBeenCalledWith('some-search-term');
}));
it('should reset value on reset() event', () => {
component.onModelChange('some-search-term');
expect(component.term).toBe('some-search-term');
component.reset();
expect(component.term).toBe('');
});
it('should reset value on Escape event', () => {
component.onModelChange('some-search-term');
expect(component.term).toBe('some-search-term');
component.selectFilterInput.nativeElement.dispatchEvent(new KeyboardEvent('keydown', {'keyCode': ESCAPE} as any));
fixture.detectChanges();
expect(component.term).toBe('');
});
});

View File

@ -0,0 +1,86 @@
/*!
* @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 { Component, ViewEncapsulation, ViewChild, ElementRef, OnDestroy, Inject, Output, EventEmitter } from '@angular/core';
import { ESCAPE, TAB } from '@angular/cdk/keycodes';
import { MatSelect } from '@angular/material/select';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'adf-select-filter-input',
templateUrl: './select-filter-input.component.html',
styleUrls: ['./select-filter-input.component.scss'],
host: { 'class': 'adf-select-filter-input' },
encapsulation: ViewEncapsulation.None
})
export class SelectFilterInputComponent implements OnDestroy {
@ViewChild('selectFilterInput', { read: ElementRef, static: false }) selectFilterInput: ElementRef;
@Output() change = new EventEmitter<string>();
term = '';
private onDestroy$ = new Subject<void>();
constructor(@Inject(MatSelect) private matSelect: MatSelect) {}
onModelChange(value: string) {
this.change.next(value);
}
ngOnInit() {
this.change
.pipe(takeUntil(this.onDestroy$))
.subscribe((val: string) => this.term = val );
this.matSelect.openedChange
.pipe(takeUntil(this.onDestroy$))
.subscribe((isOpened: boolean) => {
if (isOpened) {
this.selectFilterInput.nativeElement.focus();
} else {
this.change.next('');
}
});
}
reset(event?: KeyboardEvent) {
if (event) {
event.stopPropagation();
}
this.change.next('');
this.selectFilterInput.nativeElement.focus();
}
handleKeydown($event: KeyboardEvent) {
if (this.term) {
if ($event.keyCode === ESCAPE) {
event.stopPropagation();
this.change.next('');
}
if (($event.target as HTMLInputElement).tagName === 'INPUT' && $event.keyCode === TAB) {
event.stopPropagation();
}
}
}
ngOnDestroy() {
this.onDestroy$.next();
this.onDestroy$.complete();
}
}

View File

@ -23,5 +23,6 @@ export * from './card-view-item-dispatcher/card-view-item-dispatcher.component';
export * from './card-view-mapitem/card-view-mapitem.component';
export * from './card-view-textitem/card-view-textitem.component';
export * from './card-view-selectitem/card-view-selectitem.component';
export * from './card-view-selectitem/select-filter-input/select-filter-input.component';
export * from './card-view-keyvaluepairsitem/card-view-keyvaluepairsitem.component';
export * from './card-view-arrayitem/card-view-arrayitem.component';

View File

@ -35,7 +35,7 @@ export class CardViewSelectItemModel<T> extends CardViewBaseItemModel implements
get displayValue() {
return this.options$.pipe(
switchMap((options) => {
const option = options.find((o) => o.key === this.value);
const option = options.find((o) => o.key === this.value?.toString());
return of(option ? option.label : '');
})
);

View File

@ -23,7 +23,8 @@ export {
CardViewTextItemComponent,
CardViewSelectItemComponent,
CardViewKeyValuePairsItemComponent,
CardViewArrayItemComponent
CardViewArrayItemComponent,
SelectFilterInputComponent
} from './components/card-view.components';
export * from './interfaces/card-view.interfaces';

View File

@ -509,5 +509,14 @@
"CLIPBOARD": {
"CLICK_TO_COPY": "Click to copy",
"SUCCESS_COPY": "Text copied to clipboard"
},
"SELECT_FILTER": {
"INPUT": {
"PLACEHOLDER": "Search",
"ARIA_LABEL": "Search options"
},
"BUTTON": {
"ARIA_LABEL": "Clear"
}
}
}