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/assets/styles/_inputs.scss b/src/assets/styles/_inputs.scss index b6fbdb3..02ed6c8 100644 --- a/src/assets/styles/_inputs.scss +++ b/src/assets/styles/_inputs.scss @@ -195,7 +195,7 @@ form .iqser-input-group:not(first-of-type) { .mat-datepicker-toggle { position: absolute; right: 0; - top: 4px; + top: 1px; color: $accent; &.mat-datepicker-toggle-active { diff --git a/src/assets/styles/_tables.scss b/src/assets/styles/_tables.scss index 1579698..db1da5e 100644 --- a/src/assets/styles/_tables.scss +++ b/src/assets/styles/_tables.scss @@ -1,53 +1,3 @@ -@import 'variables'; - -.table-header { - display: flex; - border-bottom: 1px solid $separator; - - &.no-data:not([synced='true']) { - padding-left: 30px; - } - - iqser-table-column-name:last-of-type { - > div { - padding-right: 13px; - } - } - - &.selection-enabled iqser-table-column-name > div { - padding-left: 10px; - } -} - -.header-item { - background-color: $btn-bg; - height: 50px; - padding: 0 24px; - display: flex; - align-items: center; - z-index: 1; - border-bottom: 1px solid $separator; - box-sizing: border-box; - - > *:not(:last-child) { - margin-right: 10px; - } - - .actions { - display: flex; - align-items: center; - justify-content: flex-end; - - > *:not(:last-child) { - margin-right: 16px; - } - } - - &.selection-enabled { - padding-left: 10px; - } -} - .scrollbar-placeholder { width: 11px; padding: 0 !important; diff --git a/src/index.ts b/src/index.ts index 4583469..0a56bbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/misc'; export * from './lib/loading'; export * from './lib/error'; export * from './lib/search'; +export * from './lib/empty-states'; diff --git a/src/lib/common-ui.module.ts b/src/lib/common-ui.module.ts index 3f025d7..d255dc3 100644 --- a/src/lib/common-ui.module.ts +++ b/src/lib/common-ui.module.ts @@ -14,6 +14,8 @@ import { IqserInputsModule } from './inputs'; import { IqserHelpModeModule } from './help-mode'; import { IqserIconsModule } from './icons'; import { IqserButtonsModule } from './buttons'; +import { IqserScrollbarModule } from './scrollbar'; +import { IqserEmptyStatesModule } from './empty-states'; const matModules = [MatIconModule, MatProgressSpinnerModule]; const modules = [ @@ -23,7 +25,9 @@ const modules = [ IqserListingModule, IqserFiltersModule, IqserInputsModule, - IqserHelpModeModule + IqserHelpModeModule, + IqserScrollbarModule, + IqserEmptyStatesModule ]; const components = [StatusBarComponent, FullPageLoadingIndicatorComponent, FullPageErrorComponent]; const pipes = [SortByPipe, HumanizePipe]; diff --git a/src/lib/empty-states/empty-state.module.ts b/src/lib/empty-states/empty-state.module.ts new file mode 100644 index 0000000..0e352a1 --- /dev/null +++ b/src/lib/empty-states/empty-state.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IqserIconsModule } from '../icons'; +import { EmptyStateComponent } from './empty-state/empty-state.component'; +import { IqserButtonsModule } from '../buttons'; + +const modules = [IqserIconsModule, IqserButtonsModule]; +const components = [EmptyStateComponent]; + +@NgModule({ + declarations: [...components], + imports: [CommonModule, ...modules], + exports: [...components] +}) +export class IqserEmptyStatesModule {} diff --git a/src/lib/empty-states/empty-state/empty-state.component.html b/src/lib/empty-states/empty-state/empty-state.component.html new file mode 100644 index 0000000..cc9bca5 --- /dev/null +++ b/src/lib/empty-states/empty-state/empty-state.component.html @@ -0,0 +1,21 @@ +
+ +
+ +
+
{{ text }}
+ +
diff --git a/src/lib/empty-states/empty-state/empty-state.component.scss b/src/lib/empty-states/empty-state/empty-state.component.scss new file mode 100644 index 0000000..042e4c1 --- /dev/null +++ b/src/lib/empty-states/empty-state/empty-state.component.scss @@ -0,0 +1,27 @@ +@import '../../../assets/styles/common'; + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + > mat-icon { + height: 60px; + width: 60px; + opacity: 0.1; + } + + .heading-l { + color: $grey-7; + } + + > .heading-l, + iqser-icon-button { + margin-top: 24px; + } + + .ng-content-wrapper:not(:empty) + .heading-l { + display: none; + } +} diff --git a/src/lib/empty-states/empty-state/empty-state.component.ts b/src/lib/empty-states/empty-state/empty-state.component.ts new file mode 100644 index 0000000..56a99e1 --- /dev/null +++ b/src/lib/empty-states/empty-state/empty-state.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { IconButtonTypes } from '../../buttons'; +import { Required } from '../../utils'; + +@Component({ + selector: 'iqser-empty-state', + templateUrl: './empty-state.component.html', + styleUrls: ['./empty-state.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EmptyStateComponent implements OnInit { + readonly iconButtonTypes = IconButtonTypes; + + @Input() @Required() text!: string; + @Input() icon?: string; + @Input() showButton = true; + @Input() buttonIcon = 'red:plus'; + @Input() buttonLabel?: string; + @Input() horizontalPadding = 100; + @Input() verticalPadding = 120; + @Output() readonly action = new EventEmitter(); + + ngOnInit(): void { + this.showButton = this.showButton && this.action.observers.length > 0; + } +} diff --git a/src/lib/empty-states/index.ts b/src/lib/empty-states/index.ts new file mode 100644 index 0000000..987730b --- /dev/null +++ b/src/lib/empty-states/index.ts @@ -0,0 +1,2 @@ +export * from './empty-state.module'; +export * from './empty-state/empty-state.component'; diff --git a/src/lib/error/error.service.ts b/src/lib/error/error.service.ts index f23ab9d..2f2e878 100644 --- a/src/lib/error/error.service.ts +++ b/src/lib/error/error.service.ts @@ -2,14 +2,19 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; import { LoadingService } from '../loading'; +import { filter } from 'rxjs/operators'; +import { NavigationStart, Router } from '@angular/router'; @Injectable({ providedIn: 'root' }) export class ErrorService { readonly error$: Observable; private readonly _errorEvent$ = new BehaviorSubject(undefined); - constructor(private readonly _loadingService: LoadingService) { + constructor(private readonly _loadingService: LoadingService, private readonly _router: Router) { this.error$ = this._errorEvent$.asObservable(); + _router.events.pipe(filter(event => event instanceof NavigationStart)).subscribe(() => { + this.clear(); + }); } set(error: HttpErrorResponse): void { diff --git a/src/lib/icons/icons.module.ts b/src/lib/icons/icons.module.ts index 9c3f444..8c39496 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..bc20fae 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; @@ -19,9 +18,14 @@ export abstract class ListingComponent extends AutoUnsubscri readonly entitiesService = this._injector.get>(EntitiesService); readonly noMatch$ = this._noMatch$; + readonly noContent$ = this._noContent$; readonly sortedDisplayedEntities$ = this._sortedDisplayedEntities$; + readonly routerLinkFn?: (entity: T) => string | string[]; + // TODO: These should be somewhere in table listing, not generic listing abstract readonly tableColumnConfigs: readonly TableColumnConfig[]; + abstract readonly tableHeaderLabel: string; + /** * Key used in the *trackBy* function with **ngFor* or **cdkVirtualFor* * and in the default sorting and as the search field @@ -51,6 +55,13 @@ export abstract class ListingComponent extends AutoUnsubscri ); } + private get _noContent$(): Observable { + return combineLatest([this._noMatch$, this.entitiesService.noData$]).pipe( + map(([noMatch, noData]) => noMatch || noData), + distinctUntilChanged() + ); + } + setInitialConfig(): void { this.sortingService.setSortingOption({ column: this._primaryKey, diff --git a/src/lib/listing/listing.module.ts b/src/lib/listing/listing.module.ts index 04b5b05..6f4172b 100644 --- a/src/lib/listing/listing.module.ts +++ b/src/lib/listing/listing.module.ts @@ -1,18 +1,37 @@ 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'; +import { IqserEmptyStatesModule } from '../empty-states'; -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, + IqserEmptyStatesModule, + 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..34679b8 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,8 @@ export interface TableColumnConfig { readonly rightIcon?: string; readonly rightIconTooltip?: string; readonly notTranslatable?: boolean; + readonly width?: string; + readonly template: TemplateRef; + readonly extra?: unknown; + last?: boolean; } 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 91% 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 index b9331ee..745d01d 100644 --- 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 @@ -3,7 +3,7 @@ {{ label }} - +
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 83% 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..731b910 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; @@ -10,7 +10,7 @@ display: flex; width: 100%; line-height: 11px; - padding: 0 24px; + padding: 0 10px; > mat-icon { width: 10px; @@ -24,6 +24,14 @@ } } + &:first-child > div { + padding: 0 24px; + } + + &:last-of-type > div { + padding: 0 13px 0 10px; + } + .flex-end { min-width: 58px; } 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.html b/src/lib/listing/table-header/table-header.component.html index d695cf3..5660a7c 100644 --- a/src/lib/listing/table-header/table-header.component.html +++ b/src/lib/listing/table-header/table-header.component.html @@ -7,7 +7,7 @@ > - {{ tableHeaderLabel | translate: { length: (entitiesService.displayedLength$ | async) } }} + {{ tableHeaderLabel | translate: {length: totalSize || (entitiesService.displayedLength$ | async)} }} diff --git a/src/lib/listing/table-header/table-header.component.scss b/src/lib/listing/table-header/table-header.component.scss index e69de29..106f35c 100644 --- a/src/lib/listing/table-header/table-header.component.scss +++ b/src/lib/listing/table-header/table-header.component.scss @@ -0,0 +1,39 @@ +@import '../../../assets/styles/common'; + +.table-header { + display: flex; + border-bottom: 1px solid $separator; + + &.no-data.selection-enabled:not([synced='true']) { + padding-left: 30px; + } +} + +.header-item { + background-color: $btn-bg; + height: 50px; + display: flex; + align-items: center; + z-index: 1; + border-bottom: 1px solid $separator; + box-sizing: border-box; + padding: 0 24px; + + &.selection-enabled { + padding: 0 24px 0 10px; + } + + > *:not(:last-child) { + margin-right: 10px; + } + + .actions { + display: flex; + align-items: center; + justify-content: flex-end; + + > *:not(:last-child) { + margin-right: 16px; + } + } +} diff --git a/src/lib/listing/table-header/table-header.component.ts b/src/lib/listing/table-header/table-header.component.ts index 86bbb4e..69e28aa 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', @@ -24,6 +23,7 @@ export class TableHeaderComponent { @Input() hasEmptyColumn = false; @Input() selectionEnabled = false; @Input() mode: ListingMode = ListingModes.list; + @Input() totalSize?: number; @Input() bulkActions?: TemplateRef; constructor(readonly entitiesService: EntitiesService, readonly filterService: FilterService) {} diff --git a/src/lib/listing/table/table.component.html b/src/lib/listing/table/table.component.html new file mode 100644 index 0000000..90e9d9d --- /dev/null +++ b/src/lib/listing/table/table.component.html @@ -0,0 +1,50 @@ + + + + + + + + + +
+
+ +
+ + + + + +
+ +
+ +
+
+
+ + diff --git a/src/lib/listing/table/table.component.scss b/src/lib/listing/table/table.component.scss new file mode 100644 index 0000000..b6c834f --- /dev/null +++ b/src/lib/listing/table/table.component.scss @@ -0,0 +1,129 @@ +@import '../../../assets/styles/common'; + +:host cdk-virtual-scroll-viewport { + height: calc(100vh - 50px - 31px - 111px); + overflow-y: hidden !important; + + &.no-data { + display: none; + } + + &.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 10px; + + &.cell:first-of-type { + padding: 0 24px; + } + + &.cell:last-of-type { + padding: 0 13px 0 10px; + } + + &:not(.scrollbar-placeholder):not(.selection-column) { + min-width: 110px; + } + + &.center { + align-items: center; + justify-content: center; + } + + &.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: 21px; + 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; + } + } + + 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..41225a3 --- /dev/null +++ b/src/lib/listing/table/table.component.ts @@ -0,0 +1,116 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + forwardRef, + Inject, + Input, + OnInit, + Output, + TemplateRef, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { Required } from '../../utils'; +import { Listable, TableColumnConfig } from '../models'; +import { ListingComponent } from '../listing-component.directive'; + +const SCROLLBAR_WIDTH = 11; + +@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() headerTemplate?: TemplateRef; + @Input() @Required() itemSize!: number; + @Input() selectionEnabled = false; + @Input() hasScrollButton = false; + @Input() emptyColumnWidth?: string; + @Input() totalSize?: number; + @Input() classes?: string; + @Input() noDataText?: string; + @Input() noDataIcon?: string; + @Input() noDataButtonIcon?: string; + @Input() noDataButtonLabel?: string; + @Input() showNoDataButton = false; + @Output() readonly noDataAction = new EventEmitter(); + @Input() noMatchText?: string; + @Input() tableItemClasses?: { [key: string]: (e: T) => boolean }; + @Input() itemMouseEnterFn?: (entity: T) => void; + @Input() itemMouseLeaveFn?: (entity: T) => void; + routerLinkFn?: (entity: T) => string | string[]; + tableColumnConfigs!: readonly TableColumnConfig[]; + tableHeaderLabel!: string; + @ViewChild(CdkVirtualScrollViewport, { static: true }) readonly scrollViewport!: CdkVirtualScrollViewport; + + constructor( + @Inject(forwardRef(() => ListingComponent)) private _parent: ListingComponent, + private readonly _hostRef: ViewContainerRef + ) {} + + get listingComponent(): ListingComponent { + return this._parent; + } + + ngOnInit(): void { + this.tableColumnConfigs = []>this.listingComponent.tableColumnConfigs; + this.tableHeaderLabel = this.listingComponent.tableHeaderLabel; + this.routerLinkFn = <((entity: T) => string | string[]) | undefined>this.listingComponent.routerLinkFn; + this.listingComponent.noContent$.subscribe(() => { + setTimeout(() => { + this.scrollViewport.checkViewportSize(); + }, 0); + }); + + this._patchConfig(); + this._setStyles(); + } + + getTableItemClasses(entity: T): { [key: string]: boolean } { + const classes: { [key: string]: boolean } = { + 'table-item': true, + pointer: !!this.routerLinkFn && this.routerLinkFn(entity).length > 0 + }; + for (const key in this.tableItemClasses) { + if (Object.prototype.hasOwnProperty.call(this.tableItemClasses, key)) { + classes[key] = this.tableItemClasses[key](entity); + } + } + return classes; + } + + private _patchConfig() { + this.tableColumnConfigs[this.tableColumnConfigs.length - 1].last = true; + } + + private _setStyles(): void { + const element = this._hostRef.element.nativeElement as HTMLElement; + this._setColumnsWidth(element); + this._setItemSize(element); + } + + private _setColumnsWidth(element: HTMLElement) { + let gridTemplateColumnsHover = ''; + if (this.selectionEnabled) { + gridTemplateColumnsHover += '30px '; + } + for (const config of this.tableColumnConfigs) { + gridTemplateColumnsHover += `${config.width || '1fr'} `; + } + gridTemplateColumnsHover += this.emptyColumnWidth || ''; + const gridTemplateColumns = `${gridTemplateColumnsHover} ${SCROLLBAR_WIDTH}px`; + + element.style.setProperty('--gridTemplateColumns', gridTemplateColumns); + element.style.setProperty('--gridTemplateColumnsHover', gridTemplateColumnsHover); + } + + private _setItemSize(element: HTMLElement) { + element.style.setProperty('--itemSize', `${this.itemSize}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 {}