diff --git a/src/assets/icons/arrow-down-o.svg b/src/assets/icons/arrow-down-o.svg new file mode 100644 index 0000000..384b3c9 --- /dev/null +++ b/src/assets/icons/arrow-down-o.svg @@ -0,0 +1,18 @@ + + + + diff --git a/src/lib/common-ui.module.ts b/src/lib/common-ui.module.ts index 3f025d7..62a5f66 100644 --- a/src/lib/common-ui.module.ts +++ b/src/lib/common-ui.module.ts @@ -14,6 +14,7 @@ import { IqserInputsModule } from './inputs'; import { IqserHelpModeModule } from './help-mode'; import { IqserIconsModule } from './icons'; import { IqserButtonsModule } from './buttons'; +import { IqserScrollbarModule } from './scrollbar'; const matModules = [MatIconModule, MatProgressSpinnerModule]; const modules = [ @@ -23,7 +24,8 @@ const modules = [ IqserListingModule, IqserFiltersModule, IqserInputsModule, - IqserHelpModeModule + IqserHelpModeModule, + IqserScrollbarModule ]; const components = [StatusBarComponent, FullPageLoadingIndicatorComponent, FullPageErrorComponent]; const pipes = [SortByPipe, HumanizePipe]; diff --git a/src/lib/icons/icons.module.ts b/src/lib/icons/icons.module.ts index f49d0d1..bc0aa02 100644 --- a/src/lib/icons/icons.module.ts +++ b/src/lib/icons/icons.module.ts @@ -12,6 +12,7 @@ export class IqserIconsModule { constructor(private readonly _iconRegistry: MatIconRegistry, private readonly _sanitizer: DomSanitizer) { const icons: Set = new Set([ 'arrow-down', + 'arrow-down-o', 'check', 'close', 'edit', diff --git a/src/lib/listing/index.ts b/src/lib/listing/index.ts index 004b4bc..29623f2 100644 --- a/src/lib/listing/index.ts +++ b/src/lib/listing/index.ts @@ -1,8 +1,12 @@ -export * from './tables'; -export * from './workflow-listing'; +export * from './models'; +export * from './services'; + +export * from './scroll-button/scroll-button.component'; +export * from './table/table.component'; +export * from './table-column-name/table-column-name.component'; +export * from './table-header/table-header.component'; + +export * from './sync-width.directive'; export * from './listing.module'; -export * from './entities.service'; export * from './listing-component.directive'; -export * from './table-header/table-header.component'; -export * from './models/listable'; diff --git a/src/lib/listing/listing-component.directive.ts b/src/lib/listing/listing-component.directive.ts index c28a074..9c212f5 100644 --- a/src/lib/listing/listing-component.directive.ts +++ b/src/lib/listing/listing-component.directive.ts @@ -5,9 +5,8 @@ import { FilterService } from '../filtering'; import { SortingOrders, SortingService } from '../sorting'; import { AutoUnsubscribe, Bind, KeysOf } from '../utils'; import { SearchService } from '../search'; -import { TableColumnConfig } from './tables'; -import { EntitiesService } from './entities.service'; -import { Listable } from './models/listable'; +import { EntitiesService } from './services'; +import { Listable, TableColumnConfig } from './models'; export const DefaultListingServices = [FilterService, SearchService, EntitiesService, SortingService] as const; diff --git a/src/lib/listing/listing.module.ts b/src/lib/listing/listing.module.ts index 04b5b05..d6b3cda 100644 --- a/src/lib/listing/listing.module.ts +++ b/src/lib/listing/listing.module.ts @@ -1,18 +1,35 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; -import { TablesModule } from './tables'; import { TableHeaderComponent } from './table-header/table-header.component'; -import { WorkflowListingModule } from './workflow-listing'; import { IqserFiltersModule } from '../filtering'; import { IqserInputsModule } from '../inputs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TableColumnNameComponent } from './table-column-name/table-column-name.component'; +import { ScrollButtonComponent } from './scroll-button/scroll-button.component'; +import { TableComponent } from './table/table.component'; +import { SyncWidthDirective } from './sync-width.directive'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { IqserIconsModule } from '../icons'; +import { IqserScrollbarModule } from '../scrollbar'; +import { RouterModule } from '@angular/router'; -const components = [TableHeaderComponent]; -const modules = [TranslateModule, TablesModule, WorkflowListingModule, IqserFiltersModule, IqserInputsModule]; +const matModules = [MatTooltipModule]; +const components = [TableHeaderComponent, TableComponent, TableColumnNameComponent, ScrollButtonComponent]; +const modules = [ + TranslateModule, + IqserFiltersModule, + IqserInputsModule, + IqserIconsModule, + IqserScrollbarModule, + ScrollingModule, + RouterModule +]; +const utils = [SyncWidthDirective]; @NgModule({ - declarations: [...components], - exports: [...components, TablesModule, WorkflowListingModule], - imports: [CommonModule, ...modules] + declarations: [...components, ...utils], + exports: [...components, ...utils], + imports: [CommonModule, ...modules, ...matModules] }) export class IqserListingModule {} diff --git a/src/lib/listing/models/index.ts b/src/lib/listing/models/index.ts new file mode 100644 index 0000000..24d6869 --- /dev/null +++ b/src/lib/listing/models/index.ts @@ -0,0 +1,2 @@ +export * from './listable'; +export * from './table-column-config.model'; diff --git a/src/lib/listing/tables/models/table-column-config.model.ts b/src/lib/listing/models/table-column-config.model.ts similarity index 57% rename from src/lib/listing/tables/models/table-column-config.model.ts rename to src/lib/listing/models/table-column-config.model.ts index 997a7cf..2e9d7dc 100644 --- a/src/lib/listing/tables/models/table-column-config.model.ts +++ b/src/lib/listing/models/table-column-config.model.ts @@ -1,4 +1,5 @@ -import { KeysOf } from '../../../utils'; +import { KeysOf } from '../../utils'; +import { TemplateRef } from '@angular/core'; export interface TableColumnConfig { readonly label: string; @@ -8,4 +9,6 @@ export interface TableColumnConfig { readonly rightIcon?: string; readonly rightIconTooltip?: string; readonly notTranslatable?: boolean; + readonly width?: string; // TODO: make required + readonly template?: TemplateRef; // TODO: make required } diff --git a/src/lib/listing/scroll-button/scroll-button.component.html b/src/lib/listing/scroll-button/scroll-button.component.html new file mode 100644 index 0000000..b04b249 --- /dev/null +++ b/src/lib/listing/scroll-button/scroll-button.component.html @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/listing/scroll-button/scroll-button.component.scss b/src/lib/listing/scroll-button/scroll-button.component.scss new file mode 100644 index 0000000..093aaa9 --- /dev/null +++ b/src/lib/listing/scroll-button/scroll-button.component.scss @@ -0,0 +1,30 @@ +@import '../../../assets/styles/common'; + +.scroll-button { + background-color: $white; + position: absolute; + right: 0; + height: 40px; + width: 44px; + border: none; + border-radius: 8px 0 0 8px; + box-shadow: -1px 1px 5px 0 rgba(40, 50, 65, 0.25); + + &.bottom { + bottom: 30px; + } + + &.top { + top: 100px; + + mat-icon { + transform: rotate(180deg); + } + } +} + +mat-icon { + width: 22px; + height: 22px; + color: $grey-7; +} diff --git a/src/lib/listing/scroll-button/scroll-button.component.ts b/src/lib/listing/scroll-button/scroll-button.component.ts new file mode 100644 index 0000000..09ad966 --- /dev/null +++ b/src/lib/listing/scroll-button/scroll-button.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, HostListener, Input, OnInit } from '@angular/core'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { concatMap, delay, distinctUntilChanged, map, startWith } from 'rxjs/operators'; +import { Observable, of } from 'rxjs'; +import { Required } from '../../utils'; + +const ButtonTypes = { + top: 'top', + bottom: 'bottom' +} as const; + +type ButtonType = keyof typeof ButtonTypes; + +@Component({ + selector: 'iqser-scroll-button', + templateUrl: './scroll-button.component.html', + styleUrls: ['./scroll-button.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ScrollButtonComponent implements OnInit { + readonly buttonType = ButtonTypes; + + @Input() @Required() scrollViewport!: CdkVirtualScrollViewport; + @Input() @Required() itemSize!: number; + + showScrollUp$?: Observable; + showScrollDown$?: Observable; + + ngOnInit(): void { + const scrollSize = () => this.scrollViewport.getDataLength() * this.itemSize; + const scrollIsNeeded = () => this.scrollViewport.getViewportSize() < scrollSize(); + const reachedEnd = (type: ButtonType) => this.scrollViewport.measureScrollOffset(type) === 0; + + const showScrollUp = () => scrollIsNeeded() && !reachedEnd(ButtonTypes.top); + const showScrollDown = () => scrollIsNeeded() && !reachedEnd(ButtonTypes.bottom); + + const scroll$ = this.scrollViewport.elementScrolled().pipe( + startWith(''), + /** Delay first value so that we can wait for items to be rendered in viewport and get correct values */ + concatMap((value, index) => (index === 0 ? of(value).pipe(delay(0)) : of(value))) + ); + this.showScrollUp$ = scroll$.pipe(map(showScrollUp), distinctUntilChanged()); + this.showScrollDown$ = scroll$.pipe(map(showScrollDown), distinctUntilChanged()); + } + + scroll(type: ButtonType): void { + const viewportSize = (this.scrollViewport?.getViewportSize() - this.itemSize) * (type === ButtonTypes.top ? -1 : 1); + const scrollOffset = this.scrollViewport?.measureScrollOffset('top'); + this.scrollViewport?.scrollToOffset(scrollOffset + viewportSize, 'smooth'); + } + + @HostListener('document:keyup', ['$event']) + spaceAndPageDownScroll(event: KeyboardEvent): void { + const target = event.target as EventTarget & { tagName: string }; + if (['Space', 'PageDown'].includes(event.code) && target.tagName === 'BODY') { + this.scroll(ButtonTypes.bottom); + } else if (['PageUp'].includes(event.code) && target.tagName === 'BODY') { + this.scroll(ButtonTypes.top); + } + } +} diff --git a/src/lib/listing/entities.service.ts b/src/lib/listing/services/entities.service.ts similarity index 96% rename from src/lib/listing/entities.service.ts rename to src/lib/listing/services/entities.service.ts index c73f34b..b4edab1 100644 --- a/src/lib/listing/entities.service.ts +++ b/src/lib/listing/services/entities.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, combineLatest, Observable, pipe } from 'rxjs'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { FilterService, getFilteredEntities } from '../filtering'; -import { SearchService } from '../search'; -import { Listable } from './models/listable'; +import { FilterService, getFilteredEntities } from '../../filtering'; +import { SearchService } from '../../search'; +import { Listable } from '../models'; const toLengthValue = (entities: unknown[]) => entities?.length ?? 0; const getLength = pipe(map(toLengthValue), distinctUntilChanged()); diff --git a/src/lib/listing/services/index.ts b/src/lib/listing/services/index.ts new file mode 100644 index 0000000..8d6e94c --- /dev/null +++ b/src/lib/listing/services/index.ts @@ -0,0 +1 @@ +export * from './entities.service'; diff --git a/src/lib/listing/tables/sync-width.directive.ts b/src/lib/listing/sync-width.directive.ts similarity index 100% rename from src/lib/listing/tables/sync-width.directive.ts rename to src/lib/listing/sync-width.directive.ts diff --git a/src/lib/listing/tables/table-column-name/table-column-name.component.html b/src/lib/listing/table-column-name/table-column-name.component.html similarity index 100% rename from src/lib/listing/tables/table-column-name/table-column-name.component.html rename to src/lib/listing/table-column-name/table-column-name.component.html diff --git a/src/lib/listing/tables/table-column-name/table-column-name.component.scss b/src/lib/listing/table-column-name/table-column-name.component.scss similarity index 95% rename from src/lib/listing/tables/table-column-name/table-column-name.component.scss rename to src/lib/listing/table-column-name/table-column-name.component.scss index ed0a5d7..87a10b1 100644 --- a/src/lib/listing/tables/table-column-name/table-column-name.component.scss +++ b/src/lib/listing/table-column-name/table-column-name.component.scss @@ -1,4 +1,4 @@ -@import '../../../../assets/styles/common'; +@import '../../../assets/styles/common'; :host { display: flex; diff --git a/src/lib/listing/tables/table-column-name/table-column-name.component.ts b/src/lib/listing/table-column-name/table-column-name.component.ts similarity index 87% rename from src/lib/listing/tables/table-column-name/table-column-name.component.ts rename to src/lib/listing/table-column-name/table-column-name.component.ts index 887cb14..8977bf2 100644 --- a/src/lib/listing/tables/table-column-name/table-column-name.component.ts +++ b/src/lib/listing/table-column-name/table-column-name.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, Optional } from '@angular/core'; -import { SortingOrders, SortingService } from '../../../sorting'; -import { KeysOf, Required } from '../../../utils'; +import { SortingOrders, SortingService } from '../../sorting'; +import { KeysOf, Required } from '../../utils'; const ifHasRightIcon = (thisArg: TableColumnNameComponent) => !!thisArg.rightIcon; diff --git a/src/lib/listing/table-header/table-header.component.ts b/src/lib/listing/table-header/table-header.component.ts index 86bbb4e..c9d9df7 100644 --- a/src/lib/listing/table-header/table-header.component.ts +++ b/src/lib/listing/table-header/table-header.component.ts @@ -1,9 +1,8 @@ import { ChangeDetectionStrategy, Component, Input, TemplateRef } from '@angular/core'; import { Required } from '../../utils'; import { FilterService } from '../../filtering'; -import { EntitiesService } from '../entities.service'; -import { Listable } from '../models/listable'; -import { TableColumnConfig } from '../tables'; +import { EntitiesService } from '../services'; +import { Listable, TableColumnConfig } from '../models'; export const ListingModes = { list: 'list', diff --git a/src/lib/listing/table/table.component.html b/src/lib/listing/table/table.component.html new file mode 100644 index 0000000..c07daff --- /dev/null +++ b/src/lib/listing/table/table.component.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + +
n
+
+ +
+ +
+ +
+
+
+ + diff --git a/src/lib/listing/table/table.component.scss b/src/lib/listing/table/table.component.scss new file mode 100644 index 0000000..55f6016 --- /dev/null +++ b/src/lib/listing/table/table.component.scss @@ -0,0 +1,121 @@ +//iqser-table-header::ng-deep .header-item { +// padding-right: 16px; +//} +@import '../../../assets/styles/common'; + +cdk-virtual-scroll-viewport { + height: calc(100vh - 50px - 31px - 111px); + overflow-y: hidden !important; + + &.has-scrollbar:hover ::ng-deep.cdk-virtual-scroll-content-wrapper { + grid-template-columns: var(--gridTemplateColumnsHover); + } + + ::ng-deep.cdk-virtual-scroll-content-wrapper { + grid-template-columns: var(--gridTemplateColumns); + display: grid; + + .table-item { + display: contents; + + > div { + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + box-sizing: border-box; + border-bottom: 1px solid $separator; + height: var(--itemSize); + padding: 0 24px 0 var(--paddingLeft); + + &:not(.scrollbar-placeholder):not(.selection-column) { + min-width: 110px; + } + + &.selection-column { + padding-right: 0 !important; + + iqser-round-checkbox .wrapper { + opacity: 0; + transition: opacity 0.2s; + + &.active { + opacity: 1; + } + } + } + } + + .table-item-title { + font-weight: 600; + @include line-clamp(1); + } + + .action-buttons { + position: absolute; + display: none; + right: -11px; + top: 0; + height: 100%; + width: fit-content; + flex-direction: row; + align-items: center; + padding-left: 100px; + padding-right: 24px; + z-index: 1; + background: linear-gradient(to right, rgba(244, 245, 247, 0) 0%, $grey-2 35%); + + mat-icon { + width: 14px; + } + + iqser-circle-button:not(:last-child) { + margin-right: 2px; + } + + &.active { + display: flex; + // compensate for scroll + padding-right: 23px; + } + } + + input, + mat-select { + margin-top: 0; + } + + &:hover { + > div { + background-color: $grey-8; + + &.selection-column iqser-round-checkbox .wrapper { + opacity: 1; + } + } + + .action-buttons { + display: flex; + } + } + } + } + + &:hover { + overflow-y: auto !important; + @include scroll-bar; + + &.has-scrollbar { + .table-item { + .action-buttons { + right: 0; + padding-right: 13px; + } + + .scrollbar-placeholder { + display: none; + } + } + } + } +} diff --git a/src/lib/listing/table/table.component.ts b/src/lib/listing/table/table.component.ts new file mode 100644 index 0000000..8afad4b --- /dev/null +++ b/src/lib/listing/table/table.component.ts @@ -0,0 +1,66 @@ +import { ChangeDetectionStrategy, Component, forwardRef, Inject, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { Required } from '../../utils'; +import { Listable, TableColumnConfig } from '../models'; +import { ListingComponent } from '../listing-component.directive'; + +@Component({ + selector: 'iqser-table', + templateUrl: './table.component.html', + styleUrls: ['./table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TableComponent implements OnInit { + @Input() bulkActions?: TemplateRef; + @Input() actionsTemplate?: TemplateRef; + @Input() @Required() itemSize!: number; + @Input() @Required() tableColumnConfigs!: readonly TableColumnConfig[]; + @Input() @Required() tableHeaderLabel!: string; + @Input() selectionEnabled = false; + @Input() hasScrollButton = false; + @Input() emptyColumnWidth?: string; + @Input() classes?: string; + @Input() routerLinkFn?: (entity: T) => string | string[]; + @ViewChild(CdkVirtualScrollViewport, { static: true }) private readonly _viewport!: CdkVirtualScrollViewport; + + constructor(@Inject(forwardRef(() => ListingComponent)) private _parent: ListingComponent) {} + + get listingComponent(): ListingComponent { + return this._parent; + } + + ngOnInit(): void { + this._setStyles(); + } + + private _setStyles(): void { + const element = this._viewport.elementRef.nativeElement; + this._setColumnsWidth(element); + this._setItemSize(element); + this._setPadding(element); + } + + private _setColumnsWidth(element: HTMLElement) { + let gridTemplateColumnsHover = ''; + if (this.selectionEnabled) { + gridTemplateColumnsHover += 'auto '; + } + for (const config of this.tableColumnConfigs) { + gridTemplateColumnsHover += `${config.width as string} `; // TODO remove cast + } + gridTemplateColumnsHover += this.emptyColumnWidth; + const gridTemplateColumns = gridTemplateColumnsHover + ' 11px'; + + element.style.setProperty('--gridTemplateColumns', gridTemplateColumns); + element.style.setProperty('--gridTemplateColumnsHover', gridTemplateColumnsHover); + } + + private _setItemSize(element: HTMLElement) { + element.style.setProperty('--itemSize', `${this.itemSize}px`); + } + + private _setPadding(element: HTMLElement) { + const paddingLeft = this.selectionEnabled ? 10 : 24; + element.style.setProperty('--paddingLeft', `${paddingLeft}px`); + } +} diff --git a/src/lib/listing/tables/index.ts b/src/lib/listing/tables/index.ts deleted file mode 100644 index 2cad679..0000000 --- a/src/lib/listing/tables/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './tables-module'; -export * from './table-column-name/table-column-name.component'; -export * from './models/table-column-config.model'; -export * from './sync-width.directive'; diff --git a/src/lib/listing/tables/tables-module.ts b/src/lib/listing/tables/tables-module.ts deleted file mode 100644 index 96a9db2..0000000 --- a/src/lib/listing/tables/tables-module.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; -import { TableColumnNameComponent } from './table-column-name/table-column-name.component'; -import { SyncWidthDirective } from './sync-width.directive'; -import { IqserIconsModule } from '../../icons'; - -const matModules = [MatTooltipModule]; -const components = [TableColumnNameComponent]; -const utils = [SyncWidthDirective]; - -@NgModule({ - declarations: [...components, ...utils], - exports: [...components, ...utils], - imports: [CommonModule, TranslateModule, IqserIconsModule, ...matModules] -}) -export class TablesModule {} diff --git a/src/lib/listing/workflow-listing/index.ts b/src/lib/listing/workflow-listing/index.ts deleted file mode 100644 index 87b1b5e..0000000 --- a/src/lib/listing/workflow-listing/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './workflow-listing.module'; diff --git a/src/lib/listing/workflow-listing/workflow-listing.module.ts b/src/lib/listing/workflow-listing/workflow-listing.module.ts deleted file mode 100644 index 66d6d54..0000000 --- a/src/lib/listing/workflow-listing/workflow-listing.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { DragDropModule } from '@angular/cdk/drag-drop'; -import { TranslateModule } from '@ngx-translate/core'; - -const matModules = [DragDropModule]; - -@NgModule({ - declarations: [], - imports: [CommonModule, TranslateModule, ...matModules] -}) -export class WorkflowListingModule {} diff --git a/src/lib/scrollbar/has-scrollbar.directive.ts b/src/lib/scrollbar/has-scrollbar.directive.ts new file mode 100644 index 0000000..c2edf2f --- /dev/null +++ b/src/lib/scrollbar/has-scrollbar.directive.ts @@ -0,0 +1,27 @@ +import { AfterContentChecked, Directive, ElementRef, HostBinding } from '@angular/core'; + +@Directive({ + selector: '[iqserHasScrollbar]', + exportAs: 'iqserHasScrollbar' +}) +export class HasScrollbarDirective implements AfterContentChecked { + @HostBinding('class') class = ''; + + constructor(private readonly _elementRef: ElementRef) {} + + get hasScrollbar(): boolean { + const element = this._elementRef?.nativeElement as HTMLElement; + return element.clientHeight < element.scrollHeight; + } + + ngAfterContentChecked(): void { + this._process(); + } + + _process(): void { + const newClass = this.hasScrollbar ? 'has-scrollbar' : ''; + if (this.class !== newClass) { + this.class = newClass; + } + } +} diff --git a/src/lib/scrollbar/index.ts b/src/lib/scrollbar/index.ts new file mode 100644 index 0000000..19629b5 --- /dev/null +++ b/src/lib/scrollbar/index.ts @@ -0,0 +1,2 @@ +export * from '../scrollbar/has-scrollbar.directive'; +export * from './scrollbar.module'; diff --git a/src/lib/scrollbar/scrollbar.module.ts b/src/lib/scrollbar/scrollbar.module.ts new file mode 100644 index 0000000..5efb8f1 --- /dev/null +++ b/src/lib/scrollbar/scrollbar.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HasScrollbarDirective } from './has-scrollbar.directive'; + +const utils = [HasScrollbarDirective]; + +@NgModule({ + declarations: [...utils], + exports: [...utils], + imports: [CommonModule], + providers: [HasScrollbarDirective] +}) +export class IqserScrollbarModule {}