diff --git a/libs/angular-accelerator/assets/i18n/de.json b/libs/angular-accelerator/assets/i18n/de.json index 49a2e4bb..1c9bb4fa 100644 --- a/libs/angular-accelerator/assets/i18n/de.json +++ b/libs/angular-accelerator/assets/i18n/de.json @@ -99,6 +99,28 @@ "CUSTOM_GROUP": "Benutzerdefinierte Gruppe", "NO_GROUP_SELECTED": "Keine Gruppe ausgewählt" }, + "OCX_FILTER_VIEW": { + "NO_FILTERS": "Keine Filter ausgewählt", + "RESET_FILTERS_BUTTON": { + "ARIA_LABEL": "Angewandte Filter zurücksetzen", + "DETAIL": "Angewandte Filter zurücksetzen" + }, + "FILTER_YES": "Ja", + "FILTER_NO": "Nein", + "MANAGE_FILTERS_BUTTON": { + "LABEL": "Filter", + "ARIA_LABEL": "Aktive Filter verwalten", + "DETAIL": "Aktive Filter verwalten" + }, + "TABLE": { + "COLUMN_NAME": "Spaltenname", + "VALUE": "Filterwert", + "ACTIONS": "Aktionen", + "REMOVE_FILTER_TITLE": "Filter löschen", + "REMOVE_FILTER_ARIA_LABEL": "Filter löschen" + }, + "PANEL_TITLE": "Filter" + }, "OCX_SEARCH_HEADER": { "TOGGLE_BUTTON": { "SIMPLE": { diff --git a/libs/angular-accelerator/assets/i18n/en.json b/libs/angular-accelerator/assets/i18n/en.json index f31bd1d4..4ff99916 100644 --- a/libs/angular-accelerator/assets/i18n/en.json +++ b/libs/angular-accelerator/assets/i18n/en.json @@ -99,6 +99,28 @@ "CUSTOM_GROUP": "Custom group", "NO_GROUP_SELECTED": "No group selected" }, + "OCX_FILTER_VIEW": { + "NO_FILTERS": "No filters selected", + "RESET_FILTERS_BUTTON": { + "ARIA_LABEL": "Reset applied filters", + "DETAIL": "Reset applied filters" + }, + "FILTER_YES": "Yes", + "FILTER_NO": "No", + "MANAGE_FILTERS_BUTTON": { + "LABEL": "Filters", + "ARIA_LABEL": "Manage active filters", + "DETAIL": "Manage active filters" + }, + "TABLE": { + "COLUMN_NAME": "Column name", + "VALUE": "Filter value", + "ACTIONS": "Actions", + "REMOVE_FILTER_TITLE": "Remove filter", + "REMOVE_FILTER_ARIA_LABEL": "Remove filter" + }, + "PANEL_TITLE": "Filters" + }, "OCX_SEARCH_HEADER": { "TOGGLE_BUTTON": { "SIMPLE": { diff --git a/libs/angular-accelerator/src/index.ts b/libs/angular-accelerator/src/index.ts index dc6d0211..bab2c00d 100644 --- a/libs/angular-accelerator/src/index.ts +++ b/libs/angular-accelerator/src/index.ts @@ -62,3 +62,5 @@ export * from './lib/utils/create-remote-component-translate-loader.utils' export * from './lib/utils/enum-to-dropdown-options.utils' export * from './lib/utils/criteria.utils' export * from './lib/utils/string-and-array-helper-functions.utils' +export * from './lib/utils/template.utils' +export * from './lib/utils/filter.utils' diff --git a/libs/angular-accelerator/src/lib/angular-accelerator-primeng.module.ts b/libs/angular-accelerator/src/lib/angular-accelerator-primeng.module.ts index 26a6e018..22e93c61 100644 --- a/libs/angular-accelerator/src/lib/angular-accelerator-primeng.module.ts +++ b/libs/angular-accelerator/src/lib/angular-accelerator-primeng.module.ts @@ -15,10 +15,14 @@ import { MessageModule } from 'primeng/message' import { SharedModule } from 'primeng/api' import { CheckboxModule } from 'primeng/checkbox' import { FloatLabelModule } from 'primeng/floatlabel' +import { ChipModule } from 'primeng/chip' +import { OverlayPanelModule } from 'primeng/overlaypanel' +import { FocusTrapModule } from 'primeng/focustrap' @NgModule({ imports: [ BreadcrumbModule, + ChipModule, CheckboxModule, DropdownModule, ButtonModule, @@ -33,10 +37,13 @@ import { FloatLabelModule } from 'primeng/floatlabel' SkeletonModule, MessageModule, FloatLabelModule, + OverlayPanelModule, + FocusTrapModule, SharedModule, ], exports: [ BreadcrumbModule, + ChipModule, CheckboxModule, DropdownModule, ButtonModule, @@ -51,6 +58,8 @@ import { FloatLabelModule } from 'primeng/floatlabel' SkeletonModule, MessageModule, FloatLabelModule, + OverlayPanelModule, + FocusTrapModule, SharedModule, ], }) diff --git a/libs/angular-accelerator/src/lib/angular-accelerator.module.ts b/libs/angular-accelerator/src/lib/angular-accelerator.module.ts index de1e8bd1..f8d94fc0 100644 --- a/libs/angular-accelerator/src/lib/angular-accelerator.module.ts +++ b/libs/angular-accelerator/src/lib/angular-accelerator.module.ts @@ -30,6 +30,7 @@ import { TooltipOnOverflowDirective } from './directives/tooltipOnOverflow.direc import { DynamicPipe } from './pipes/dynamic.pipe' import { OcxTimeAgoPipe } from './pipes/ocxtimeago.pipe' import { DynamicLocaleId } from './utils/dynamic-locale-id' +import { FilterViewComponent } from './components/filter-view/filter-view.component' export class AngularAcceleratorMissingTranslationHandler implements MissingTranslationHandler { handle(params: MissingTranslationHandlerParams) { @@ -75,6 +76,7 @@ function appInitializer(userService: UserService) { OcxTimeAgoPipe, AdvancedDirective, TooltipOnOverflowDirective, + FilterViewComponent, ], providers: [ { diff --git a/libs/angular-accelerator/src/lib/components/data-table/data-table.component.html b/libs/angular-accelerator/src/lib/components/data-table/data-table.component.html index df7dc0fc..21b584e0 100644 --- a/libs/angular-accelerator/src/lib/components/data-table/data-table.component.html +++ b/libs/angular-accelerator/src/lib/components/data-table/data-table.component.html @@ -119,6 +119,8 @@ (selectionChange)="onSelectionChange($event)" [selection]="(selectedRows$ | async) ?? []" [scrollable]="true" + scrollHeight="flex" + [style]="tableStyle" paginatorDropdownAppendTo="body" > diff --git a/libs/angular-accelerator/src/lib/components/data-table/data-table.component.ts b/libs/angular-accelerator/src/lib/components/data-table/data-table.component.ts index aff1e5ad..db2e4cc8 100644 --- a/libs/angular-accelerator/src/lib/components/data-table/data-table.component.ts +++ b/libs/angular-accelerator/src/lib/components/data-table/data-table.component.ts @@ -40,6 +40,7 @@ import { ObjectUtils } from '../../utils/objectutils' import { DataSortBase } from '../data-sort-base/data-sort-base' import { MultiSelectItem } from 'primeng/multiselect' import { Filter, FilterType } from '../../model/filter.model' +import { findTemplate } from '../../utils/template.utils' export type Primitive = number | string | boolean | bigint | Date export type Row = { @@ -175,6 +176,7 @@ export class DataTableComponent extends DataSortBase implements OnInit, AfterCon @Input() allowSelectAll = true @Input() paginator = true @Input() page = 0 + @Input() tableStyle: { [klass: string]: any } | undefined @Input() get totalRecordsOnServer(): number | undefined { return this.params['totalRecordsOnServer'] ? Number(this.params['totalRecordsOnServer']) : undefined @@ -786,17 +788,6 @@ export class DataTableComponent extends DataSortBase implements OnInit, AfterCon [TemplateType.FILTERCELL]: this.filterTemplatesData, } - findTemplate(templates: PrimeTemplate[], names: string[]): PrimeTemplate | undefined { - for (let index = 0; index < names.length; index++) { - const name = names[index] - const template = templates.find((template) => template.name === name) - if (template) { - return template - } - } - return undefined - } - getColumnTypeTemplate(templates: PrimeTemplate[], columnType: ColumnType, templateType: TemplateType) { let template: TemplateRef | undefined @@ -847,7 +838,7 @@ export class DataTableComponent extends DataSortBase implements OnInit, AfterCon return ( template ?? - this.findTemplate(templates, this.templatesDataMap[templateType].templateNames[columnType])?.template ?? + findTemplate(templates, this.templatesDataMap[templateType].templateNames[columnType])?.template ?? null ) } @@ -863,7 +854,7 @@ export class DataTableComponent extends DataSortBase implements OnInit, AfterCon ]).pipe( map(([t, vt, pt]) => { const templates = [...(t ?? []), ...(vt ?? []), ...(pt ?? [])] - const columnTemplate = this.findTemplate( + const columnTemplate = findTemplate( templates, templatesData.idSuffix.map((suffix) => column.id + suffix) )?.template diff --git a/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.html b/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.html new file mode 100644 index 00000000..621b23af --- /dev/null +++ b/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.html @@ -0,0 +1,254 @@ + +
+ + + + + + + + + {{ 'OCX_FILTER_VIEW.NO_FILTERS' | translate }} + + + + + + + + + + {{column?.nameKey ?? '' | translate }}: + + + + + + + + + + + + + + +{{filters.length - selectedFilters.length}} + + + + + + + + + + + + + + + + +
+
+ {{'OCX_FILTER_VIEW.PANEL_TITLE' | translate}} +
+ +
+
+ + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + {{'OCX_FILTER_VIEW.FILTER_YES' | translate}} + {{'OCX_FILTER_VIEW.FILTER_NO' | translate}} + + + + {{ resolveFieldData(rowObject, column.id)}} + + + + {{ resolveFieldData(rowObject, column.id) | number }} + + + + + + {{ resolveFieldData(rowObject, column.id) | date: column.dateFormat ?? 'medium' }} + + + + + {{ 'OCX_DATA_TABLE.EDITED' | translate }} {{ resolveFieldData(rowObject, column.id) | timeago }} + + + + + {{ resolveFieldData(rowObject, column.id) | translate }} + diff --git a/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.scss b/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.scss new file mode 100644 index 00000000..c08e70dc --- /dev/null +++ b/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.scss @@ -0,0 +1,6 @@ +.filter-view-focusable:focus { + outline: 1px solid var(--primary-color); + outline-offset: 2px; + box-shadow: none; + border-radius: var(--chip-border-radius); +} \ No newline at end of file diff --git a/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.ts b/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.ts new file mode 100644 index 00000000..b2f3cf52 --- /dev/null +++ b/libs/angular-accelerator/src/lib/components/filter-view/filter-view.component.ts @@ -0,0 +1,294 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + QueryList, + TemplateRef, + ViewChild, + ViewChildren, +} from '@angular/core' +import { Filter, FilterType } from '../../model/filter.model' +import { DataTableColumn } from '../../model/data-table-column.model' +import { BehaviorSubject, Observable, combineLatest, debounceTime, map } from 'rxjs' +import { ColumnType } from '../../model/column-type.model' +import { PrimeTemplate } from 'primeng/api' +import { findTemplate } from '../../utils/template.utils' +import { ObjectUtils } from '../../utils/objectutils' +import { limit } from '../../utils/filter.utils' +import { OverlayPanel } from 'primeng/overlaypanel' +import { Row } from '../data-table/data-table.component' +import { Button } from 'primeng/button' + +export type FilterViewDisplayMode = 'chips' | 'button' +export type FilterViewRowDisplayData = { + id: string + column: string + value: unknown +} +export type FilterViewRowDetailData = FilterViewRowDisplayData & { + columnId: string + columnFilterType: FilterType | undefined +} + +export interface FilterViewComponentState { + filters?: Filter[] +} + +@Component({ + selector: 'ocx-filter-view', + templateUrl: './filter-view.component.html', + styleUrls: ['./filter-view.component.scss'], +}) +export class FilterViewComponent implements OnInit { + ColumnType = ColumnType + FilterType = FilterType + filters$ = new BehaviorSubject([]) + @Input() + get filters(): Filter[] { + return this.filters$.getValue() + } + set filters(value: Filter[]) { + this.filters$.next(value) + } + columns$ = new BehaviorSubject([]) + @Input() + get columns(): DataTableColumn[] { + return this.columns$.getValue() + } + set columns(value: DataTableColumn[]) { + this.columns$.next(value) + const chipObs = value.map((c) => this.getTemplate(c, this.chipTemplateNames, this.chipTemplates, this.chipIdSuffix)) + this.chipTemplates$ = combineLatest(chipObs).pipe( + map((values) => Object.fromEntries(value.map((c, i) => [c.id, values[i]]))) + ) + + const tableTemplateColumns = value.concat(this.columnFilterTableColumns) + this.tableTemplates$ = combineLatest( + tableTemplateColumns.map((c) => + this.getTemplate(c, this.tableTemplateNames, this.tableTemplates, this.tableIdSuffix) + ) + ).pipe(map((values) => Object.fromEntries(tableTemplateColumns.map((c, i) => [c.id, values[i]])))) + } + + columnFilterDataRows$: Observable | undefined + + @Input() displayMode: FilterViewDisplayMode = 'button' + @Input() selectDisplayedChips: (filters: Filter[], columns: DataTableColumn[]) => Filter[] = (filters) => + limit(filters, 3, { reverse: true }) + @Input() chipStyleClass = '' + @Input() tableStyle: { [klass: string]: any } = { 'max-height': '50vh' } + @Input() panelStyle: { [klass: string]: any } = { 'max-width': '90%' } + + @Output() filtered: EventEmitter = new EventEmitter() + @Output() componentStateChanged: EventEmitter = new EventEmitter() + + columnFilterTableColumns: DataTableColumn[] = [ + { + id: 'column', + columnType: ColumnType.TRANSLATION_KEY, + nameKey: 'OCX_FILTER_VIEW.TABLE.COLUMN_NAME', + }, + { id: 'value', columnType: ColumnType.STRING, nameKey: 'OCX_FILTER_VIEW.TABLE.VALUE' }, + { + id: 'actions', + columnType: ColumnType.STRING, + nameKey: 'OCX_FILTER_VIEW.TABLE.ACTIONS', + }, + ] + + ngOnInit(): void { + this.columnFilterDataRows$ = combineLatest([this.filters$, this.columns$]).pipe( + map(([filters, columns]) => { + const columnIds = columns.map((c) => c.id) + return filters + .map((f) => { + const filterColumn = this.getColumnForFilter(f, columns) + if (!filterColumn) return undefined + return { + id: `${f.columnId}-${f.value}`, + column: filterColumn.nameKey, + value: f.value, + columnId: filterColumn.id, + columnFilterType: filterColumn.filterType, + } satisfies FilterViewRowDetailData + }) + .filter((v): v is FilterViewRowDetailData => v !== undefined) + .slice() + .sort((a, b) => columnIds.indexOf(a.columnId) - columnIds.indexOf(b.columnId)) + }) + ) + } + + @ViewChild(OverlayPanel) panel!: OverlayPanel + @ViewChild('manageButton') manageButton!: Button + trigger: HTMLElement | undefined + + fitlerViewNoSelection: TemplateRef | undefined + get _fitlerViewNoSelection(): TemplateRef | undefined { + return this.fitlerViewNoSelection + } + + filterViewChipContent: TemplateRef | undefined + get _filterViewChipContent(): TemplateRef | undefined { + return this.filterViewChipContent + } + + filterViewShowMoreChip: TemplateRef | undefined + get _filterViewShowMoreChip(): TemplateRef | undefined { + return this.filterViewShowMoreChip + } + + defaultTemplates$: BehaviorSubject | undefined> = new BehaviorSubject< + QueryList | undefined + >(undefined) + @ViewChildren(PrimeTemplate) + set defaultTemplates(value: QueryList | undefined) { + this.defaultTemplates$.next(value) + } + + parentTemplates$: BehaviorSubject | null | undefined> = new BehaviorSubject< + QueryList | null | undefined + >(undefined) + @Input() + set templates(value: QueryList | null | undefined) { + this.parentTemplates$.next(value) + value?.forEach((item) => { + switch (item.getType()) { + case 'fitlerViewNoSelection': + this.fitlerViewNoSelection = item.template + break + case 'filterViewChipContent': + this.filterViewChipContent = item.template + break + case 'filterViewShowMoreChip': + this.filterViewShowMoreChip = item.template + break + } + }) + } + + chipTemplates$: Observable | null>> | undefined + tableTemplates$: Observable | null>> | undefined + + chipIdSuffix: Array = ['IdFilterChip', 'IdTableFilterCell', 'IdTableCell'] + chipTemplateNames: Record> = { + [ColumnType.DATE]: ['dateFilterChipValue', 'dateTableFilterCell', 'dateTableCell', 'defaultDateValue'], + [ColumnType.NUMBER]: ['numberFilterChipValue', 'numberTableFilterCell', 'numberTableCell', 'defaultNumberValue'], + [ColumnType.RELATIVE_DATE]: [ + 'relativeDateFilterChipValue', + 'relativeDateTableFilterCell', + 'relativeDateTableCell', + 'defaultRelativeDateValue', + ], + [ColumnType.TRANSLATION_KEY]: [ + 'translationKeyFilterChipValue', + 'translationKeyTableFilterCell', + 'translationKeyTableCell', + 'defaultTranslationKeyValue', + ], + [ColumnType.CUSTOM]: ['customFilterChipValue', 'customTableFilterCell', 'customTableCell', 'defaultCustomValue'], + [ColumnType.STRING]: ['stringFilterChipValue', 'stringTableFilterCell', 'stringTableCell', 'defaultStringValue'], + } + chipTemplates: Record | null>> = {} + + tableIdSuffix: Array = ['IdFilterViewCell', 'IdTableFilterCell', 'IdTableCell'] + tableTemplateNames: Record> = { + [ColumnType.DATE]: ['dateFilterViewCell', 'dateTableFilterCell', 'dateTableCell', 'defaultDateValue'], + [ColumnType.NUMBER]: ['numberFilterViewCell', 'numberTableFilterCell', 'numberTableCell', 'defaultNumberValue'], + [ColumnType.RELATIVE_DATE]: [ + 'relativeDateFilterViewCell', + 'relativeDateTableFilterCell', + 'relativeDateTableCell', + 'defaultRelativeDateValue', + ], + [ColumnType.TRANSLATION_KEY]: [ + 'translationKey', + 'translationKeyTableFilterCell', + 'translationKeyTableCell', + 'defaultTranslationKeyValue', + ], + [ColumnType.CUSTOM]: ['customFilterViewCell', 'customTableFilterCell', 'customTableCell', 'defaultCustomValue'], + [ColumnType.STRING]: ['stringFilterViewCell', 'stringTableFilterCell', 'stringTableCell', 'defaultStringValue'], + } + tableTemplates: Record | null>> = {} + + getTemplate( + column: DataTableColumn, + templateNames: Record>, + templates: Record | null>>, + idSuffix: Array + ): Observable | null> { + if (!templates[column.id]) { + templates[column.id] = combineLatest([this.defaultTemplates$, this.parentTemplates$]).pipe( + map(([dt, t]) => { + const templates = [...(dt ?? []), ...(t ?? [])] + const columnTemplate = findTemplate( + templates, + idSuffix.map((suffix) => column.id + suffix) + )?.template + if (columnTemplate) { + return columnTemplate + } + return findTemplate(templates, templateNames[column.columnType])?.template ?? null + }), + debounceTime(50) + ) + } + return templates[column.id] + } + + onResetFilersClick() { + this.filters = [] + this.filtered.emit([]) + this.componentStateChanged.emit({ + filters: [], + }) + } + + onChipRemove(filter: Filter) { + const filters = this.filters.filter((f) => f.value !== filter.value) + this.filters = filters + this.filtered.emit(filters) + this.componentStateChanged.emit({ + filters: filters, + }) + } + + onFilterDelete(row: Row) { + const filters = this.filters.filter((f) => !(f.columnId === row['columnId'] && f.value === row['value'])) + this.filters = filters + this.filtered.emit(filters) + this.componentStateChanged.emit({ + filters: filters, + }) + } + + focusTrigger() { + if (this.trigger?.id === 'ocxFilterViewShowMore') { + this.trigger?.focus() + } else { + this.manageButton.focus() + } + } + + showPanel(event: any) { + this.trigger = event.srcElement + this.panel.toggle(event) + } + + getColumnForFilter(filter: Filter, columns: DataTableColumn[]) { + return columns.find((c) => c.id === filter.columnId) + } + + resolveFieldData(object: any, key: any) { + return ObjectUtils.resolveFieldData(object, key) + } + + getRowObjectFromFiterData(filter: Filter) { + return { + [filter.columnId]: filter.value, + } + } +} diff --git a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.html b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.html index d76c4320..9a682526 100644 --- a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.html +++ b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.html @@ -1,11 +1,25 @@
- +
+ + +
diff --git a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.spec.ts b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.spec.ts index 7b085bb8..9c55de38 100644 --- a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.spec.ts +++ b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.spec.ts @@ -39,12 +39,19 @@ import { DefaultListItemHarness, InteractiveDataViewHarness, SlotHarness, + FilterViewHarness, } from '../../../../testing' import { DateUtils } from '../../utils/dateutils' import { provideRouter } from '@angular/router' import { SlotService } from '@onecx/angular-remote-components' import { SlotServiceMock } from '@onecx/angular-remote-components/mocks' import { IfPermissionDirective } from '../../directives/if-permission.directive' +import { FilterType } from '../../model/filter.model' +import { FilterViewComponent } from '../filter-view/filter-view.component' +import { AngularAcceleratorPrimeNgModule } from '../../angular-accelerator-primeng.module' +import { PrimeIcons } from 'primeng/api' +import { limit } from '../../utils/filter.utils' +import { DatePipe } from '@angular/common' describe('InteractiveDataViewComponent', () => { const mutationObserverMock = jest.fn(function MutationObserver(callback) { @@ -85,6 +92,7 @@ describe('InteractiveDataViewComponent', () => { startDate: '2023-09-13T09:34:05Z', imagePath: '/path/to/image', testNumber: '1', + testTruthy: 'value', }, { version: 0, @@ -101,6 +109,7 @@ describe('InteractiveDataViewComponent', () => { startDate: '2023-09-12T09:33:53Z', imagePath: '', testNumber: '3.141', + testTruthy: 'value2', }, { version: 0, @@ -133,6 +142,7 @@ describe('InteractiveDataViewComponent', () => { startDate: '2023-09-14T09:34:22Z', imagePath: '', testNumber: '12345.6789', + testTruthy: 'value3', }, { version: 0, @@ -224,6 +234,15 @@ describe('InteractiveDataViewComponent', () => { sortable: true, predefinedGroupKeys: ['PREDEFINED_GROUP.EXTENDED', 'PREDEFINED_GROUP.FULL'], }, + { + columnType: ColumnType.STRING, + id: 'testTruthy', + nameKey: 'COLUMN_HEADER_NAME.TEST_TRUTHY', + filterable: true, + sortable: true, + filterType: FilterType.TRUTHY, + predefinedGroupKeys: ['PREDEFINED_GROUP.EXTENDED', 'PREDEFINED_GROUP.FULL'], + }, ] beforeEach(async () => { @@ -235,6 +254,7 @@ describe('InteractiveDataViewComponent', () => { ColumnGroupSelectionComponent, CustomGroupColumnSelectorComponent, IfPermissionDirective, + FilterViewComponent, ], imports: [ TranslateModule.forRoot(), @@ -243,6 +263,7 @@ describe('InteractiveDataViewComponent', () => { PickListModule, AngularAcceleratorModule, NoopAnimationsModule, + AngularAcceleratorPrimeNgModule, TranslateTestingModule.withTranslations({ en: require('./../../../../assets/i18n/en.json'), de: require('./../../../../assets/i18n/de.json'), @@ -329,6 +350,14 @@ describe('InteractiveDataViewComponent', () => { expect(customGroupColumnSelectorButton).toBeTruthy() }) + it('should load FilterView', async () => { + component.disableFilterView = false + fixture.detectChanges() + + const filterView = await loader.getHarness(FilterViewHarness) + expect(filterView).toBeTruthy() + }) + it('should load DataListGridSortingDropdown', async () => { const dataLayoutSelection = await loader.getHarness(DataLayoutSelectionHarness) const gridLayoutSelectionButton = await dataLayoutSelection.getGridLayoutSelectionButton() @@ -595,6 +624,7 @@ describe('InteractiveDataViewComponent', () => { 'COLUMN_HEADER_NAME.STATUS', 'COLUMN_HEADER_NAME.RESPONSIBLE', 'COLUMN_HEADER_NAME.TEST_NUMBER', + 'COLUMN_HEADER_NAME.TEST_TRUTHY', 'Actions', ] const expectedRowsData = [ @@ -606,6 +636,7 @@ describe('InteractiveDataViewComponent', () => { 'some status', 'someone responsible', '1', + 'value', ], [ 'example', @@ -615,6 +646,7 @@ describe('InteractiveDataViewComponent', () => { 'status example', '', '3.141', + 'value2', ], [ 'name 1', @@ -624,6 +656,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 1', '', '123,456,789', + '', ], [ 'name 2', @@ -633,6 +666,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 2', '', '12,345.679', + 'value3', ], [ 'name 3', @@ -642,6 +676,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 3', '', '7.1', + '', ], ] @@ -671,6 +706,7 @@ describe('InteractiveDataViewComponent', () => { 'some status', 'someone responsible', '1', + 'value', ], [ 'example', @@ -680,6 +716,7 @@ describe('InteractiveDataViewComponent', () => { 'status example', '', '3.141', + 'value2', ], [ 'name 3', @@ -689,6 +726,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 3', '', '7.1', + '', ], [ 'name 2', @@ -698,6 +736,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 2', '', '12,345.679', + 'value3', ], [ 'name 1', @@ -707,6 +746,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 1', '', '123,456,789', + '', ], ] @@ -737,6 +777,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 1', '', '123,456,789', + '', ], [ 'name 2', @@ -746,6 +787,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 2', '', '12,345.679', + 'value3', ], [ 'name 3', @@ -755,6 +797,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 3', '', '7.1', + '', ], [ 'example', @@ -764,6 +807,7 @@ describe('InteractiveDataViewComponent', () => { 'status example', '', '3.141', + 'value2', ], [ 'some name', @@ -773,6 +817,7 @@ describe('InteractiveDataViewComponent', () => { 'some status', 'someone responsible', '1', + 'value', ], ] @@ -804,6 +849,7 @@ describe('InteractiveDataViewComponent', () => { 'some status', 'someone responsible', '1', + 'value', ], [ 'example', @@ -813,6 +859,7 @@ describe('InteractiveDataViewComponent', () => { 'status example', '', '3.141', + 'value2', ], [ 'name 1', @@ -822,6 +869,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 1', '', '123,456,789', + '', ], [ 'name 2', @@ -831,6 +879,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 2', '', '12,345.679', + 'value3', ], [ 'name 3', @@ -840,6 +889,7 @@ describe('InteractiveDataViewComponent', () => { 'status name 3', '', '7.1', + '', ], ] @@ -1131,6 +1181,258 @@ describe('InteractiveDataViewComponent', () => { }) }) + describe('Filter view ', () => { + let dataTable: DataTableHarness | null + let tableHeaders: TableHeaderColumnHarness[] + + let filterViewHarness: FilterViewHarness + + beforeEach(async () => { + component.disableFilterView = false + fixture.detectChanges() + // select FULL group + const columnGroupSelectionDropdown = await loader.getHarness( + PDropdownHarness.with({ inputId: 'columnGroupSelectionDropdown' }) + ) + const dropdownItems = await columnGroupSelectionDropdown.getDropdownItems() + await dropdownItems[2].selectItem() + + const dataView = await loader.getHarness(DataViewHarness) + dataTable = await dataView?.getDataTable() + tableHeaders = (await dataTable?.getHeaderColumns()) ?? [] + + expect(await tableHeaders[2].getText()).toBe('COLUMN_HEADER_NAME.START_DATE') + const startDateFilterMultiSelect = await tableHeaders[2].getFilterMultiSelect() + const startDateAllFilterOptions = await startDateFilterMultiSelect.getAllOptions() + await startDateAllFilterOptions[0].click() + await startDateFilterMultiSelect.close() + + expect(await tableHeaders[0].getText()).toBe('COLUMN_HEADER_NAME.NAME') + const nameFilterMultiSelect = await tableHeaders[0].getFilterMultiSelect() + const nameAllFilterOptions = await nameFilterMultiSelect.getAllOptions() + await nameAllFilterOptions[0].click() + await nameFilterMultiSelect.close() + + expect(await tableHeaders[4].getText()).toBe('COLUMN_HEADER_NAME.STATUS') + const statusFilterMultiSelect = await tableHeaders[4].getFilterMultiSelect() + const statusAllFilterOptions = await statusFilterMultiSelect.getAllOptions() + await statusAllFilterOptions[0].click() + await statusFilterMultiSelect.close() + + expect(await tableHeaders[9].getText()).toBe('COLUMN_HEADER_NAME.TEST_TRUTHY') + const testTruthyFilterMultiSelect = await tableHeaders[9].getFilterMultiSelect() + const testTruthyAllFilterOptions = await testTruthyFilterMultiSelect.getAllOptions() + await testTruthyAllFilterOptions[0].click() + await testTruthyFilterMultiSelect.close() + + filterViewHarness = await loader.getHarness(FilterViewHarness) + }) + + it('should show button by default', async () => { + const filtersButton = await filterViewHarness.getFiltersButton() + expect(filtersButton).toBeTruthy() + expect(await filtersButton?.getLabel()).toBe('Filters') + expect(await filtersButton?.getBadgeValue()).toBe('4') + }) + + describe('chip section', () => { + it('should show chips when specified and breakpoint is not mobile', async () => { + component.filterViewDisplayMode = 'chips' + fixture.detectChanges() + let filtersButton = await filterViewHarness.getFiltersButton() + expect(filtersButton).toBeFalsy() + + let chipResetFiltersButton = await filterViewHarness.getChipsResetFiltersButton() + expect(chipResetFiltersButton).toBeTruthy() + expect(await chipResetFiltersButton?.getIcon()).toBe(PrimeIcons.ERASER) + + let chips = await filterViewHarness.getChips() + expect(chips).toBeTruthy() + expect(chips.length).toBe(4) + + expect(await chips[0].getContent()).toBe('COLUMN_HEADER_NAME.TEST_TRUTHY: Yes') + expect(await chips[1].getContent()).toBe('COLUMN_HEADER_NAME.STATUS: some status') + expect(await chips[2].getContent()).toBe('COLUMN_HEADER_NAME.NAME: some name') + expect(await chips[3].getContent()).toBe('+1') + + const orgMatchMedia = window.matchMedia + window.matchMedia = jest.fn(() => { + return { + matches: true, + } + }) as any + window.dispatchEvent(new Event('resize')) + + fixture.detectChanges() + filtersButton = await filterViewHarness.getFiltersButton() + expect(filtersButton).toBeTruthy() + + chipResetFiltersButton = await filterViewHarness.getChipsResetFiltersButton() + expect(chipResetFiltersButton).toBeFalsy() + + chips = await filterViewHarness.getChips() + expect(chips.length).toBe(0) + + window.matchMedia = orgMatchMedia + }) + + it('should show no filters message when no filters selected', async () => { + component.filters = [] + component.filterViewDisplayMode = 'chips' + fixture.detectChanges() + + const chipResetFiltersButton = await filterViewHarness.getChipsResetFiltersButton() + expect(chipResetFiltersButton).toBeTruthy() + + const chips = await filterViewHarness.getChips() + expect(chips.length).toBe(0) + + const noFilters = await filterViewHarness.getNoFiltersMessage() + expect(noFilters).toBeTruthy() + expect(await noFilters?.getText()).toBe('No filters selected') + }) + + it('should reset filters on reset filters button click', async () => { + component.filterViewDisplayMode = 'chips' + fixture.detectChanges() + + const chips = await filterViewHarness.getChips() + expect(chips.length).toBe(4) + + const chipResetFiltersButton = await filterViewHarness.getChipsResetFiltersButton() + await chipResetFiltersButton?.click() + expect(component.filters).toEqual([]) + const chipsAfterReset = await filterViewHarness.getChips() + expect(chipsAfterReset.length).toBe(0) + }) + + it('should use provided chip selection strategy', async () => { + const datePipe = new DatePipe('en') + component.filterViewDisplayMode = 'chips' + component.selectDisplayedChips = (data) => limit(data, 1, { reverse: false }) + fixture.detectChanges() + + const chips = await filterViewHarness.getChips() + expect(chips.length).toBe(2) + + expect(await chips[0].getContent()).toBe( + 'COLUMN_HEADER_NAME.START_DATE: ' + datePipe.transform('2023-09-13T09:34:05Z', 'medium') + ) + }) + + it('should remove filter on chip removal', async () => { + component.filterViewDisplayMode = 'chips' + fixture.detectChanges() + + const chips = await filterViewHarness.getChips() + expect(chips.length).toBe(4) + expect(component.filters.length).toBe(4) + await chips[0].clickRemove() + + const chipsAfterRemove = await filterViewHarness.getChips() + expect(chipsAfterRemove.length).toBe(3) + expect(component.filters.length).toBe(3) + expect(await chipsAfterRemove[0].getContent()).toBe('COLUMN_HEADER_NAME.STATUS: some status') + }) + + it('should show panel on show more chips click', async () => { + component.filterViewDisplayMode = 'chips' + fixture.detectChanges() + + let dataTable = await filterViewHarness.getDataTable() + expect(dataTable).toBeFalsy() + + const chips = await filterViewHarness.getChips() + expect(chips.length).toBe(4) + expect(await chips[3].getContent()).toBe('+1') + await chips[3].click() + fixture.detectChanges() + + dataTable = await filterViewHarness.getDataTable() + expect(dataTable).toBeTruthy() + }) + }) + + describe('without chips', () => { + it('should show panel on button click', async () => { + let dataTable = await filterViewHarness.getDataTable() + expect(dataTable).toBeFalsy() + + const filtersButton = await filterViewHarness.getFiltersButton() + await filtersButton?.click() + fixture.detectChanges() + + dataTable = await filterViewHarness.getDataTable() + expect(dataTable).toBeTruthy() + }) + }) + + describe('overlay', () => { + it('should show data table with column filters', async () => { + const datePipe = new DatePipe('en') + let dataTable = await filterViewHarness.getDataTable() + expect(dataTable).toBeFalsy() + + const filtersButton = await filterViewHarness.getFiltersButton() + await filtersButton?.click() + fixture.detectChanges() + + dataTable = await filterViewHarness.getDataTable() + expect(dataTable).toBeTruthy() + const headers = await dataTable?.getHeaderColumns() + expect(headers).toBeTruthy() + expect(headers?.length).toBe(3) + expect(await headers![0].getText()).toBe('Column name') + expect(await headers![1].getText()).toBe('Filter value') + expect(await headers![2].getText()).toBe('Actions') + + const rows = await dataTable?.getRows() + expect(rows?.length).toBe(4) + expect(await rows![0].getData()).toEqual(['COLUMN_HEADER_NAME.NAME', 'some name', '']) + expect(await rows![1].getData()).toEqual([ + 'COLUMN_HEADER_NAME.START_DATE', + datePipe.transform('2023-09-13T09:34:05Z', 'medium'), + '', + ]) + expect(await rows![2].getData()).toEqual(['COLUMN_HEADER_NAME.STATUS', 'some status', '']) + expect(await rows![3].getData()).toEqual(['COLUMN_HEADER_NAME.TEST_TRUTHY', 'Yes', '']) + }) + + it('should show reset all filters button above the table', async () => { + const filtersButton = await filterViewHarness.getFiltersButton() + await filtersButton?.click() + fixture.detectChanges() + + const resetButton = await filterViewHarness.getOverlayResetFiltersButton() + expect(resetButton).toBeTruthy() + const dataTable = await filterViewHarness.getDataTable() + expect((await dataTable?.getRows())?.length).toBe(4) + + await resetButton?.click() + const rows = await dataTable?.getRows() + expect(rows?.length).toBe(1) + expect(await rows![0].getData()).toEqual(['No filters selected']) + }) + + it('should show remove filter in action column', async () => { + const filtersButton = await filterViewHarness.getFiltersButton() + await filtersButton?.click() + fixture.detectChanges() + + const dataTable = await filterViewHarness.getDataTable() + let rows = await dataTable?.getRows() + expect(rows?.length).toBe(4) + const buttons = await rows![0].getAllActionButtons() + expect(buttons.length).toBe(1) + await buttons[0].click() + + rows = await dataTable?.getRows() + expect(rows?.length).toBe(3) + expect(component.filters.length).toBe(3) + }) + }) + }) + describe('Grid view ', () => { let dataLayoutSelection: DataLayoutSelectionHarness let dataView: DataViewHarness diff --git a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.stories.ts b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.stories.ts index 92b6ef59..fa0c6a8f 100644 --- a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.stories.ts +++ b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.stories.ts @@ -18,7 +18,10 @@ import { ProgressBarModule } from 'primeng/progressbar' import { SelectButtonModule } from 'primeng/selectbutton' import { FloatLabelModule } from 'primeng/floatlabel' import { TableModule } from 'primeng/table' +import { ChipModule } from 'primeng/chip' +import { OverlayPanelModule } from 'primeng/overlaypanel' import { IfPermissionDirective } from '../../directives/if-permission.directive' +import { IfBreakpointDirective } from '../../directives/if-breakpoint.directive' import { MockAuthModule } from '../../mock-auth/mock-auth.module' import { ColumnType } from '../../model/column-type.model' import { StorybookTranslateModule } from '../../storybook-translate.module' @@ -30,8 +33,16 @@ import { DataListGridComponent } from '../data-list-grid/data-list-grid.componen import { DataTableComponent } from '../data-table/data-table.component' import { DataViewComponent } from '../data-view/data-view.component' import { InteractiveDataViewComponent } from './interactive-data-view.component' +import { SlotService } from '@onecx/angular-remote-components' +import { of } from 'rxjs' +import { Filter, FilterType } from '../../model/filter.model' +import { FilterViewComponent } from '../filter-view/filter-view.component' +import { FocusTrapModule } from 'primeng/focustrap' -type InteractiveDataViewInputTypes = Pick +type InteractiveDataViewInputTypes = Pick< + InteractiveDataViewComponent, + 'data' | 'columns' | 'emptyResultsMessage' | 'disableFilterView' | 'filterViewDisplayMode' +> const InteractiveDataViewComponentSBConfig: Meta = { title: 'InteractiveDataViewComponent', component: InteractiveDataViewComponent, @@ -51,12 +62,21 @@ const InteractiveDataViewComponentSBConfig: Meta = }, }, }, + { + provide: SlotService, + useValue: { + isSomeComponentDefinedForSlot() { + return of(false) + }, + }, + }, ], }), moduleMetadata({ declarations: [ InteractiveDataViewComponent, IfPermissionDirective, + IfBreakpointDirective, CustomGroupColumnSelectorComponent, ColumnGroupSelectionComponent, DataViewComponent, @@ -64,6 +84,7 @@ const InteractiveDataViewComponentSBConfig: Meta = DataLayoutSelectionComponent, DataListGridComponent, DataListGridSortingComponent, + FilterViewComponent, ], imports: [ TableModule, @@ -81,9 +102,15 @@ const InteractiveDataViewComponentSBConfig: Meta = ProgressBarModule, InputTextModule, FloatLabelModule, + OverlayPanelModule, + FocusTrapModule, + ChipModule, ], }), ], + argTypes: { + selectDisplayedChips: { type: 'function', control: false }, + }, } const Template: StoryFn = (args) => ({ props: args, @@ -111,6 +138,16 @@ const defaultComponentArgs: InteractiveDataViewInputTypes = { columnType: ColumnType.STRING, nameKey: 'Available', sortable: false, + filterable: true, + filterType: FilterType.TRUTHY, + predefinedGroupKeys: ['test2'], + }, + { + id: 'date', + columnType: ColumnType.DATE, + nameKey: 'Date', + sortable: false, + filterable: true, predefinedGroupKeys: ['test2'], }, ], @@ -121,23 +158,26 @@ const defaultComponentArgs: InteractiveDataViewInputTypes = { amount: 2, available: false, imagePath: '', + date: new Date(2022, 1, 1, 13, 14, 55, 120), }, { id: 2, product: 'Bananas', amount: 10, - available: true, imagePath: '', + date: new Date(2022, 1, 1, 13, 14, 55, 120), }, { id: 3, product: 'Strawberries', amount: 5, - available: false, imagePath: '', + date: new Date(2022, 1, 3, 13, 14, 55, 120), }, ], emptyResultsMessage: 'No results', + disableFilterView: true, + filterViewDisplayMode: 'button', } export const WithMockData = { @@ -163,10 +203,10 @@ export const WithPageSizes = { }, } -const CustomizedInteractiveDataView: StoryFn = (args) => ({ +const CustomContentInteractiveDataView: StoryFn = (args) => ({ props: args, template: ` - +

{{item.product}}

@@ -185,9 +225,348 @@ const CustomizedInteractiveDataView: StoryFn = (ar `, }) -export const WithCustomTemplates = { - render: CustomizedInteractiveDataView, +export const WithCustomContentTemplates = { + render: CustomContentInteractiveDataView, args: defaultComponentArgs, } +const CustomTableCellsInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + DATE: {{ rowObject[column.id] | date }} + + + NUMBER: {{ rowObject[column.id] }} + + `, +}) + +export const WithCustomTableCellTemplates = { + render: CustomTableCellsInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + }, +} + +const CustomTableFilterCellsInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + STRING FILTER: {{ rowObject[column.id] }} + + + DATE: {{ rowObject[column.id] | date }} + + + DATE FILTER: {{ rowObject[column.id] | date }} + + + NUMBER: {{ rowObject[column.id] }} + + `, +}) + +export const WithCustomTableFilterCellTemplates = { + render: CustomTableFilterCellsInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + }, +} + +const CustomTableColumnCellsInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + STRING FILTER: {{ rowObject[column.id] }} + + + PRODUCT (ID): {{ rowObject[column.id] }} + + + PRODUCT FILTER (ID): {{ rowObject[column.id] }} + + + DATE: {{ rowObject[column.id] | date }} + + + DATE FILTER: {{ rowObject[column.id] | date }} + + + DATE (ID): {{ rowObject[column.id] | date }} + + + DATE FILTER (ID): {{ rowObject[column.id] | date }} + + + NUMBER: {{ rowObject[column.id] }} + + `, +}) + +export const WithCustomTableColumnTemplates = { + render: CustomTableColumnCellsInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + }, +} + +export const WithFilterViewChips = { + render: CustomContentInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + }, +} + +export const WithFilterViewButton = { + render: CustomContentInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + }, +} + +const CustomFilterViewChipsInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + STRING FILTER: {{ rowObject[column.id] }} + + + CHIP: {{ rowObject[column.id] }} + + `, +}) + +export const WithFilterViewCustomChipsTemplates = { + render: CustomFilterViewChipsInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + }, +} + +const CustomFilterViewChipsByColumnInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + STRING FILTER: {{ rowObject[column.id] }} + + + CHIP: {{ rowObject[column.id] }} + + + PRODUCT (ID): {{ rowObject[column.id] }} + + + PRODUCT FILTER (ID): {{ rowObject[column.id] }} + + + PRODUCT CHIP (ID): {{ rowObject[column.id] }} + + `, +}) + +export const WithFilterViewCustomChipsByColumnTemplates = { + render: CustomFilterViewChipsByColumnInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + }, +} + +const CustomFilterViewCellsInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + STRING FILTER: {{ rowObject[column.id] }} + + + STRING FILTER VIEW: {{ rowObject[column.id] }} + + `, +}) + +export const WithFilterViewCustomCellTemplates = { + render: CustomFilterViewCellsInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + }, +} + +const CustomFilterViewCellsByColumnInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + STRING: {{ rowObject[column.id] }} + + + STRING FILTER: {{ rowObject[column.id] }} + + + STRING FILTER VIEW: {{ rowObject[column.id] }} + + + PRODUCT (ID): {{ rowObject[column.id] }} + + + PRODUCT FILTER (ID): {{ rowObject[column.id] }} + + + PRODUCT FILTER VIEW (ID): {{ rowObject[column.id] }} + + `, +}) + +export const WithFilterViewCustomCellByColumnTemplates = { + render: CustomFilterViewCellsByColumnInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + }, +} + +const CustomFilterViewNoFiltersInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + Filter data to display chips + + `, +}) + +export const WithFilterViewCustomNoFiltersTemplate = { + render: CustomFilterViewNoFiltersInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + }, +} + +const CustomFilterViewChipContentInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + {{(column.nameKey | translate).at(0)}} + + + {{filter.value ? 'MY_YES' : 'MY_NO'}} + + + + + + + + + + [P]{{ rowObject[column.id] }} + + + + D: {{ rowObject[column.id] | date }} + + `, +}) + +export const WithFilterViewCustomChipContentTemplate = { + render: CustomFilterViewChipContentInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + }, +} + +const CustomFilterViewShowMoreChipInteractiveDataView: StoryFn = (args) => ({ + props: args, + template: ` + + + + {{value}} + + + `, +}) + +export const WithFilterViewCustomShowMoreChipTemplate = { + render: CustomFilterViewShowMoreChipInteractiveDataView, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + }, +} + +export const WithFilterViewCustomChipSelection = { + render: Template, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'chips', + selectDisplayedChips: (filters: Filter[]) => { + return filters.slice(0, 2).reverse() + }, + }, +} + +export const WithFilterViewCustomStyles = { + render: Template, + args: { + ...defaultComponentArgs, + disableFilterView: false, + filterViewDisplayMode: 'button', + filterViewTableStyle: { 'max-height': '30vh' }, + filterViewPanelStyle: { 'max-width': '80%' }, + }, +} + export default InteractiveDataViewComponentSBConfig diff --git a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.ts b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.ts index c881009f..f651bf89 100644 --- a/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.ts +++ b/libs/angular-accelerator/src/lib/components/interactive-data-view/interactive-data-view.component.ts @@ -42,12 +42,15 @@ import { DataListGridSortingComponentState } from '../data-list-grid-sorting/dat import { Row, Sort } from '../data-table/data-table.component' import { DataViewComponent, DataViewComponentState, RowListGridData } from '../data-view/data-view.component' import { Filter } from '../../model/filter.model' +import { limit } from '../../utils/filter.utils' +import { FilterViewComponentState, FilterViewDisplayMode } from '../filter-view/filter-view.component' export type InteractiveDataViewComponentState = ColumnGroupSelectionComponentState & CustomGroupColumnSelectorComponentState & DataLayoutSelectionComponentState & DataListGridSortingComponentState & - DataViewComponentState + DataViewComponentState & + FilterViewComponentState export interface ColumnGroupData { activeColumns: DataTableColumn[] @@ -74,6 +77,7 @@ export class InteractiveDataViewComponent implements OnInit, AfterContentInit { dataLayoutComponentState$ = new ReplaySubject(1) dataListGridSortingComponentState$ = new ReplaySubject(1) dataViewComponentState$ = new ReplaySubject(1) + filterViewComponentState$ = new ReplaySubject(1) @Input() searchConfigPermission: string | undefined @Input() deletePermission: string | undefined @@ -116,6 +120,13 @@ export class InteractiveDataViewComponent implements OnInit, AfterContentInit { @Input() additionalActions: DataAction[] = [] @Input() listGridPaginator = true @Input() tablePaginator = true + @Input() disableFilterView = true + @Input() filterViewDisplayMode: FilterViewDisplayMode = 'button' + @Input() filterViewChipStyleClass = '' + @Input() filterViewTableStyle: { [klass: string]: any } = { 'max-height': '50vh' } + @Input() filterViewPanelStyle: { [klass: string]: any } = { 'max-width': '90%' } + @Input() selectDisplayedChips: (filters: Filter[], columns: DataTableColumn[]) => Filter[] = (filters) => + limit(filters, 3, { reverse: true }) @Input() page = 0 @Input() selectedRows: Row[] = [] displayedColumnKeys$ = new BehaviorSubject([]) @@ -441,6 +452,7 @@ export class InteractiveDataViewComponent implements OnInit, AfterContentInit { this.dataLayoutComponentState$.pipe(timestamp()), dataListGridSortingComponentState$.pipe(timestamp()), this.dataViewComponentState$.pipe(timestamp()), + this.filterViewComponentState$.pipe(timestamp()), ]) .pipe( map((componentStates) => { diff --git a/libs/angular-accelerator/src/lib/directives/if-breakpoint.directive.ts b/libs/angular-accelerator/src/lib/directives/if-breakpoint.directive.ts index 99997a8e..be10c9b8 100644 --- a/libs/angular-accelerator/src/lib/directives/if-breakpoint.directive.ts +++ b/libs/angular-accelerator/src/lib/directives/if-breakpoint.directive.ts @@ -4,7 +4,15 @@ import { Directive, HostListener, Input, OnInit, Optional, TemplateRef, ViewCont export class IfBreakpointDirective implements OnInit { @Input('ocxIfBreakpoint') breakpoint: 'mobile' | 'desktop' | undefined - constructor(private viewContainer: ViewContainerRef, @Optional() private templateRef?: TemplateRef) {} + @Input() + ocxIfBreakpointElseTemplate: TemplateRef | undefined + + state: 'mobile' | 'desktop' | undefined + + constructor( + private viewContainer: ViewContainerRef, + @Optional() private templateRef?: TemplateRef + ) {} ngOnInit() { this.onResize() @@ -15,12 +23,18 @@ export class IfBreakpointDirective implements OnInit { const mobileBreakpointVar = getComputedStyle(document.documentElement).getPropertyValue('--mobile-break-point') const isMobile = window.matchMedia(`(max-width: ${mobileBreakpointVar})`).matches const isDesktop = !isMobile + const newState = isMobile ? 'mobile' : 'desktop' if ((this.breakpoint === 'mobile' && isMobile) || (this.breakpoint === 'desktop' && isDesktop)) { - if (this.templateRef && !this.viewContainer.length) { + if (this.templateRef && newState !== this.state) { + this.viewContainer.clear() this.viewContainer.createEmbeddedView(this.templateRef) } } else { - this.viewContainer.clear() + if (this.ocxIfBreakpointElseTemplate && newState !== this.state) { + this.viewContainer.clear() + this.viewContainer.createEmbeddedView(this.ocxIfBreakpointElseTemplate) + } } + this.state = newState } } diff --git a/libs/angular-accelerator/src/lib/model/filter.model.ts b/libs/angular-accelerator/src/lib/model/filter.model.ts index c57ce2b4..8a6c34dd 100644 --- a/libs/angular-accelerator/src/lib/model/filter.model.ts +++ b/libs/angular-accelerator/src/lib/model/filter.model.ts @@ -1,3 +1,7 @@ +export interface ColumnFilterDataSelectOptions { + reverse: boolean +} + export type Filter = { columnId: string; value: unknown; filterType?: FilterType } export enum FilterType { diff --git a/libs/angular-accelerator/src/lib/utils/filter.utils.ts b/libs/angular-accelerator/src/lib/utils/filter.utils.ts new file mode 100644 index 00000000..4d754014 --- /dev/null +++ b/libs/angular-accelerator/src/lib/utils/filter.utils.ts @@ -0,0 +1,7 @@ +import { ColumnFilterDataSelectOptions, Filter } from '../model/filter.model' + +export function limit(columnFilterData: Filter[], amount: number, options: ColumnFilterDataSelectOptions): Filter[] { + return options.reverse + ? columnFilterData.slice(-amount, columnFilterData.length).reverse() + : columnFilterData.slice(0, amount) +} diff --git a/libs/angular-accelerator/src/lib/utils/template.utils.ts b/libs/angular-accelerator/src/lib/utils/template.utils.ts new file mode 100644 index 00000000..93dbabf0 --- /dev/null +++ b/libs/angular-accelerator/src/lib/utils/template.utils.ts @@ -0,0 +1,12 @@ +import { PrimeTemplate } from 'primeng/api' + +export function findTemplate(templates: PrimeTemplate[], names: string[]): PrimeTemplate | undefined { + for (let index = 0; index < names.length; index++) { + const name = names[index] + const template = templates.find((template) => template.name === name) + if (template) { + return template + } + } + return undefined +} diff --git a/libs/angular-accelerator/testing/data-table.harness.ts b/libs/angular-accelerator/testing/data-table.harness.ts index 2c1f50ca..0d871a46 100644 --- a/libs/angular-accelerator/testing/data-table.harness.ts +++ b/libs/angular-accelerator/testing/data-table.harness.ts @@ -1,4 +1,10 @@ -import { ContentContainerComponentHarness, TestElement, parallel } from '@angular/cdk/testing' +import { + BaseHarnessFilters, + ContentContainerComponentHarness, + HarnessPredicate, + TestElement, + parallel, +} from '@angular/cdk/testing' import { TableHeaderColumnHarness, TableRowHarness, @@ -6,13 +12,27 @@ import { PTableCheckboxHarness, } from '@onecx/angular-testing' +export interface DataTableHarnessFilters extends BaseHarnessFilters { + id?: string +} + export class DataTableHarness extends ContentContainerComponentHarness { static hostSelector = 'ocx-data-table' + static with(options: DataTableHarnessFilters): HarnessPredicate { + return new HarnessPredicate(DataTableHarness, options).addOption('id', options.id, (harness, id) => + HarnessPredicate.stringMatches(harness.getId(), id) + ) + } + getHeaderColumns = this.locatorForAll(TableHeaderColumnHarness) getRows = this.locatorForAll(TableRowHarness) getPaginator = this.locatorFor(PPaginatorHarness) + async getId(): Promise { + return await (await this.host()).getAttribute('id') + } + async rowSelectionIsEnabled(): Promise { const pTableCheckbox = await this.getHarnessesForCheckboxes('all') return pTableCheckbox.length > 0 diff --git a/libs/angular-accelerator/testing/filter-view.harness.ts b/libs/angular-accelerator/testing/filter-view.harness.ts new file mode 100644 index 00000000..a727b3b7 --- /dev/null +++ b/libs/angular-accelerator/testing/filter-view.harness.ts @@ -0,0 +1,21 @@ +import { ContentContainerComponentHarness } from '@angular/cdk/testing' +import { DataTableHarness } from './data-table.harness' +import { PButtonHarness, PChipHarness, SpanHarness } from '.' + +export class FilterViewHarness extends ContentContainerComponentHarness { + static hostSelector = 'ocx-filter-view' + + getOverlayResetFiltersButton = this.documentRootLocatorFactory().locatorForOptional( + PButtonHarness.with({ id: 'ocxFilterViewOverlayReset' }) + ) + getFiltersButton = this.locatorForOptional(PButtonHarness.with({ id: 'ocxFilterViewManage' })) + getChipsResetFiltersButton = this.locatorForOptional(PButtonHarness.with({ id: 'ocxFilterViewReset' })) + getChips = this.locatorForAll(PChipHarness) + getNoFiltersMessage = this.locatorForOptional(SpanHarness.with({ id: 'ocxFilterViewNoFilters' })) + + async getDataTable() { + return await this.documentRootLocatorFactory().locatorForOptional( + DataTableHarness.with({ id: 'ocxFilterViewDataTable' }) + )() + } +} diff --git a/libs/angular-accelerator/testing/index.ts b/libs/angular-accelerator/testing/index.ts index 03f238b6..b1968f74 100644 --- a/libs/angular-accelerator/testing/index.ts +++ b/libs/angular-accelerator/testing/index.ts @@ -7,6 +7,7 @@ export * from './data-view.harness' export * from './default-grid-item.harness' export * from './default-list-item.harness' export * from './diagram.harness' +export * from './filter-view.harness' export * from './group-by-count-diagram.harness' export * from './interactive-data-view.harness' export * from './more-actions-menu-button.harness' diff --git a/libs/angular-testing/src/index.ts b/libs/angular-testing/src/index.ts index 377f1270..08577358 100644 --- a/libs/angular-testing/src/index.ts +++ b/libs/angular-testing/src/index.ts @@ -5,6 +5,7 @@ export * from './lib/harnesses/primeng/p-button-directive.harness' export * from './lib/harnesses/primeng/p-button.harness' export * from './lib/harnesses/primeng/p-chart.harness' export * from './lib/harnesses/primeng/p-checkbox.harness' +export * from './lib/harnesses/primeng/p-chip.harness' export * from './lib/harnesses/primeng/p-dialog.harness' export * from './lib/harnesses/primeng/p-dropdown.harness' export * from './lib/harnesses/primeng/p-menu-item.harness' diff --git a/libs/angular-testing/src/lib/harnesses/primeng/p-button.harness.ts b/libs/angular-testing/src/lib/harnesses/primeng/p-button.harness.ts index d938ac9c..def0bf18 100644 --- a/libs/angular-testing/src/lib/harnesses/primeng/p-button.harness.ts +++ b/libs/angular-testing/src/lib/harnesses/primeng/p-button.harness.ts @@ -1,4 +1,5 @@ import { BaseHarnessFilters, ComponentHarness, HarnessPredicate } from '@angular/cdk/testing' +import { SpanHarness } from '../span.harness' export interface PButtonHarnessFilters extends BaseHarnessFilters { id?: string @@ -10,6 +11,9 @@ export interface PButtonHarnessFilters extends BaseHarnessFilters { export class PButtonHarness extends ComponentHarness { static hostSelector = 'p-button' + getBadge = this.locatorForOptional(SpanHarness.with({ class: 'p-badge' })) + getLabelSpan = this.locatorForOptional(SpanHarness.without({ classes: ['p-badge', 'p-button-icon'] })) + static with(options: PButtonHarnessFilters): HarnessPredicate { return new HarnessPredicate(PButtonHarness, options) .addOption('id', options.id, (harness, id) => HarnessPredicate.stringMatches(harness.getId(), id)) @@ -26,7 +30,7 @@ export class PButtonHarness extends ComponentHarness { } async getLabel(): Promise { - return await (await this.host()).text() + return (await (await this.getLabelSpan())?.getText()) ?? null } async getIcon(): Promise { @@ -36,4 +40,8 @@ export class PButtonHarness extends ComponentHarness { async click() { await (await this.locatorFor('button')()).click() } + + async getBadgeValue() { + return await (await this.getBadge())?.getText() + } } diff --git a/libs/angular-testing/src/lib/harnesses/primeng/p-chip.harness.ts b/libs/angular-testing/src/lib/harnesses/primeng/p-chip.harness.ts new file mode 100644 index 00000000..d0636c5c --- /dev/null +++ b/libs/angular-testing/src/lib/harnesses/primeng/p-chip.harness.ts @@ -0,0 +1,19 @@ +import { ComponentHarness } from '@angular/cdk/testing' + +export class PChipHarness extends ComponentHarness { + static hostSelector = 'p-chip' + + getRemoveButton = this.locatorForOptional('.pi-chip-remove-icon') + + async getContent() { + return await (await this.host()).text() + } + + async clickRemove() { + await (await this.getRemoveButton())?.click() + } + + async click() { + await (await this.host()).click() + } +} diff --git a/libs/angular-testing/src/lib/harnesses/span.harness.ts b/libs/angular-testing/src/lib/harnesses/span.harness.ts index 91c01f9e..f8519e30 100644 --- a/libs/angular-testing/src/lib/harnesses/span.harness.ts +++ b/libs/angular-testing/src/lib/harnesses/span.harness.ts @@ -1,18 +1,36 @@ import { BaseHarnessFilters, ComponentHarness, HarnessPredicate } from '@angular/cdk/testing' export interface SpanHarnessFilters extends BaseHarnessFilters { + id?: string class?: string + classes?: Array } export class SpanHarness extends ComponentHarness { static hostSelector = 'span' static with(options: SpanHarnessFilters): HarnessPredicate { - return new HarnessPredicate(SpanHarness, options).addOption('class', options.class, (harness, c) => - HarnessPredicate.stringMatches(harness.getByClass(c), c) + return new HarnessPredicate(SpanHarness, options) + .addOption('class', options.class, (harness, c) => HarnessPredicate.stringMatches(harness.getByClass(c), c)) + .addOption('id', options.id, (harness, id) => HarnessPredicate.stringMatches(harness.getId(), id)) + } + + static without(options: SpanHarnessFilters): HarnessPredicate { + return new HarnessPredicate(SpanHarness, options).addOption( + 'classes', + options.classes, + (harness, classes: Array) => { + return Promise.all(classes.map((c) => harness.checkHasClass(c))).then((classContainedArr) => + classContainedArr.every((classContained) => !classContained) + ) + } ) } + async getId(): Promise { + return await (await this.host()).getAttribute('id') + } + async getByClass(c: string): Promise { return (await (await this.host()).hasClass(c)) ? c : '' } @@ -21,7 +39,14 @@ export class SpanHarness extends ComponentHarness { return await (await this.host()).hasClass(value) } + async hasAnyClass(classes: Array) { + const ret: Promise[] = [] + classes.forEach((c) => ret.push(this.checkHasClass(c))) + const res = await Promise.all(ret) + return res.some((res) => res) ? 'true' : 'false' + } + async getText(): Promise { return await (await this.host()).text() } -} \ No newline at end of file +} diff --git a/libs/portal-integration-angular/assets/i18n/de.json b/libs/portal-integration-angular/assets/i18n/de.json index 897a9c91..5d182b8e 100644 --- a/libs/portal-integration-angular/assets/i18n/de.json +++ b/libs/portal-integration-angular/assets/i18n/de.json @@ -137,6 +137,28 @@ "CUSTOM_GROUP": "Benutzerdefinierte Gruppe", "NO_GROUP_SELECTED": "Keine Gruppe ausgewählt" }, + "OCX_FILTER_VIEW": { + "NO_FILTERS": "Keine Filter ausgewählt", + "RESET_FILTERS_BUTTON": { + "ARIA_LABEL": "Angewandte Filter zurücksetzen", + "DETAIL": "Angewandte Filter zurücksetzen" + }, + "FILTER_YES": "Ja", + "FILTER_NO": "Nein", + "MANAGE_FILTERS_BUTTON": { + "LABEL": "Filter", + "ARIA_LABEL": "Aktive Filter verwalten", + "DETAIL": "Aktive Filter verwalten" + }, + "TABLE": { + "COLUMN_NAME": "Spaltenname", + "VALUE": "Filterwert", + "ACTIONS": "Aktionen", + "REMOVE_FILTER_TITLE": "Filter löschen", + "REMOVE_FILTER_ARIA_LABEL": "Filter löschen" + }, + "PANEL_TITLE": "Filter" + }, "OCX_SEARCH_HEADER": { "TOGGLE_BUTTON": { "SIMPLE": { diff --git a/libs/portal-integration-angular/assets/i18n/en.json b/libs/portal-integration-angular/assets/i18n/en.json index b904b231..62dd636b 100644 --- a/libs/portal-integration-angular/assets/i18n/en.json +++ b/libs/portal-integration-angular/assets/i18n/en.json @@ -137,6 +137,28 @@ "CUSTOM_GROUP": "Custom group", "NO_GROUP_SELECTED": "No group selected" }, + "OCX_FILTER_VIEW": { + "NO_FILTERS": "No filters selected", + "RESET_FILTERS_BUTTON": { + "ARIA_LABEL": "Reset applied filters", + "DETAIL": "Reset applied filters" + }, + "FILTER_YES": "Yes", + "FILTER_NO": "No", + "MANAGE_FILTERS_BUTTON": { + "LABEL": "Filters", + "ARIA_LABEL": "Manage active filters", + "DETAIL": "Manage active filters" + }, + "TABLE": { + "COLUMN_NAME": "Column name", + "VALUE": "Filter value", + "ACTIONS": "Actions", + "REMOVE_FILTER_TITLE": "Remove filter", + "REMOVE_FILTER_ARIA_LABEL": "Remove filter" + }, + "PANEL_TITLE": "Filters" + }, "OCX_SEARCH_HEADER": { "TOGGLE_BUTTON": { "SIMPLE": { diff --git a/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/misc/_chip.scss b/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/misc/_chip.scss index 6a2295a8..d34d9907 100644 --- a/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/misc/_chip.scss +++ b/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/misc/_chip.scss @@ -32,7 +32,9 @@ transition: var(--action-icon-transition); &:focus { - @include focused(); + outline: $chipRemoveIconFocusOutline; + outline-offset: $chipRemoveIconFocusOutlineOffset; + box-shadow: none; } } } diff --git a/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/overlay/_overlaypanel.scss b/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/overlay/_overlaypanel.scss index d30b118b..ff56cc86 100644 --- a/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/overlay/_overlaypanel.scss +++ b/libs/portal-layout-styles/src/styles/primeng/sass/theme/designer/components/overlay/_overlaypanel.scss @@ -26,6 +26,12 @@ background: var(--button-hover-bg); color: var(--primary-text-color); } + + &:enabled:focus { + outline: $buttonFocusOutline; + outline-offset: $buttonFocusOutlineOffset; + box-shadow: none; + } } &:after { diff --git a/libs/portal-layout-styles/src/styles/primeng/sass/variables/theme/_theme_light.scss b/libs/portal-layout-styles/src/styles/primeng/sass/variables/theme/_theme_light.scss index 86c6061e..5e56ea14 100644 --- a/libs/portal-layout-styles/src/styles/primeng/sass/variables/theme/_theme_light.scss +++ b/libs/portal-layout-styles/src/styles/primeng/sass/variables/theme/_theme_light.scss @@ -121,6 +121,10 @@ $inputOverlayShadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0 $multiSelectCloseIconFocusOutline: $focusOutlineCustom; $multiSelectCloseIconFocusOutlineOffset: $focusOutlineOffset; +//chip +$chipRemoveIconFocusOutline: $focusOutlineCustom; +$chipRemoveIconFocusOutlineOffset: $focusOutlineOffset; + //password $passwordMeterBg: rgba($primaryColor, 0.32); $passwordWeakBg: #d32f2f;