Merge branch 'table-component'

This commit is contained in:
Adina Țeudan 2021-09-15 23:48:22 +03:00
commit 35f1a8d19f
39 changed files with 675 additions and 116 deletions

View File

@ -0,0 +1,18 @@
<svg
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.8285 12.0259L16.2427 13.4402L12 17.6828L7.7574 13.4402L9.17161 12.0259L11 13.8544V6.31724H13V13.8544L14.8285 12.0259Z"
fill="currentColor"
/>
<path
clip-rule="evenodd"
d="M19.7782 19.7782C15.4824 24.0739 8.51759 24.0739 4.22183 19.7782C-0.0739417 15.4824 -0.0739417 8.51759 4.22183 4.22183C8.51759 -0.0739419 15.4824 -0.0739419 19.7782 4.22183C24.0739 8.51759 24.0739 15.4824 19.7782 19.7782ZM18.364 18.364C14.8492 21.8787 9.15076 21.8787 5.63604 18.364C2.12132 14.8492 2.12132 9.15076 5.63604 5.63604C9.15076 2.12132 14.8492 2.12132 18.364 5.63604C21.8787 9.15076 21.8787 14.8492 18.364 18.364Z"
fill="currentColor"
fill-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 841 B

View File

@ -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 {

View File

@ -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;

View File

@ -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';

View File

@ -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];

View File

@ -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 {}

View File

@ -0,0 +1,21 @@
<div
[ngStyle]="{
'padding-top': verticalPadding + 'px',
'padding-left': horizontalPadding + 'px',
'padding-right': horizontalPadding + 'px'
}"
class="empty-state"
>
<mat-icon *ngIf="icon" [svgIcon]="icon"></mat-icon>
<div class="ng-content-wrapper heading-l">
<ng-content></ng-content>
</div>
<div class="heading-l">{{ text }}</div>
<iqser-icon-button
(action)="action.emit()"
*ngIf="showButton"
[icon]="buttonIcon"
[label]="buttonLabel"
[type]="iconButtonTypes.primary"
></iqser-icon-button>
</div>

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,2 @@
export * from './empty-state.module';
export * from './empty-state/empty-state.component';

View File

@ -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<HttpErrorResponse | undefined>;
private readonly _errorEvent$ = new BehaviorSubject<HttpErrorResponse | undefined>(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 {

View File

@ -12,6 +12,7 @@ export class IqserIconsModule {
constructor(private readonly _iconRegistry: MatIconRegistry, private readonly _sanitizer: DomSanitizer) {
const icons: Set<string> = new Set([
'arrow-down',
'arrow-down-o',
'check',
'close',
'edit',

View File

@ -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';

View File

@ -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<T extends Listable> extends AutoUnsubscri
readonly entitiesService = this._injector.get<EntitiesService<T>>(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<T>[];
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<T extends Listable> extends AutoUnsubscri
);
}
private get _noContent$(): Observable<boolean> {
return combineLatest([this._noMatch$, this.entitiesService.noData$]).pipe(
map(([noMatch, noData]) => noMatch || noData),
distinctUntilChanged()
);
}
setInitialConfig(): void {
this.sortingService.setSortingOption({
column: this._primaryKey,

View File

@ -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 {}

View File

@ -0,0 +1,2 @@
export * from './listable';
export * from './table-column-config.model';

View File

@ -1,4 +1,5 @@
import { KeysOf } from '../../../utils';
import { KeysOf } from '../../utils';
import { TemplateRef } from '@angular/core';
export interface TableColumnConfig<T> {
readonly label: string;
@ -8,4 +9,8 @@ export interface TableColumnConfig<T> {
readonly rightIcon?: string;
readonly rightIconTooltip?: string;
readonly notTranslatable?: boolean;
readonly width?: string;
readonly template: TemplateRef<unknown>;
readonly extra?: unknown;
last?: boolean;
}

View File

@ -0,0 +1,7 @@
<button (click)="scroll(buttonType.top)" [hidden]="(showScrollUp$ | async) === false" class="scroll-button top pointer">
<mat-icon svgIcon="iqser:arrow-down-o"></mat-icon>
</button>
<button (click)="scroll(buttonType.bottom)" [hidden]="(showScrollDown$ | async) === false" class="scroll-button bottom pointer">
<mat-icon svgIcon="iqser:arrow-down-o"></mat-icon>
</button>

View File

@ -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;
}

View File

@ -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<boolean>;
showScrollDown$?: Observable<boolean>;
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);
}
}
}

View File

@ -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());

View File

@ -0,0 +1 @@
export * from './entities.service';

View File

@ -3,7 +3,7 @@
<span class="all-caps-label">{{ label }}</span>
<mat-icon *ngIf="!!rightIcon" [matTooltip]="rightIconTooltip" [svgIcon]="rightIcon" matTooltipPosition="above"></mat-icon>
<mat-icon *ngIf="!!rightIcon" [matTooltip]="rightIconTooltip | translate" [svgIcon]="rightIcon" matTooltipPosition="above"></mat-icon>
<ng-container *ngIf="sortingService?.sortingOption$ | async as sortingOption">
<div *ngIf="!!sortByKey" [class.force-display]="sortingOption.column === sortByKey" class="sort-arrows-container">

View File

@ -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;
}

View File

@ -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 = <T>(thisArg: TableColumnNameComponent<T>) => !!thisArg.rightIcon;

View File

@ -7,7 +7,7 @@
></iqser-round-checkbox>
<span class="all-caps-label">
{{ tableHeaderLabel | translate: { length: (entitiesService.displayedLength$ | async) } }}
{{ tableHeaderLabel | translate: {length: totalSize || (entitiesService.displayedLength$ | async)} }}
</span>
<ng-container [ngTemplateOutlet]="bulkActions"></ng-container>

View File

@ -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;
}
}
}

View File

@ -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<T extends Listable> {
@Input() hasEmptyColumn = false;
@Input() selectionEnabled = false;
@Input() mode: ListingMode = ListingModes.list;
@Input() totalSize?: number;
@Input() bulkActions?: TemplateRef<unknown>;
constructor(readonly entitiesService: EntitiesService<T>, readonly filterService: FilterService) {}

View File

@ -0,0 +1,50 @@
<iqser-table-header
[bulkActions]="bulkActions"
[hasEmptyColumn]="!!emptyColumnWidth"
[selectionEnabled]="selectionEnabled"
[tableColumnConfigs]="tableColumnConfigs"
[tableHeaderLabel]="tableHeaderLabel"
[totalSize]="totalSize"
>
<ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
</iqser-table-header>
<iqser-empty-state
(action)="noDataAction.emit()"
*ngIf="listingComponent.entitiesService.noData$ | async"
[buttonIcon]="noDataButtonIcon"
[buttonLabel]="noDataButtonLabel"
[icon]="noDataIcon"
[showButton]="showNoDataButton"
[text]="noDataText"
></iqser-empty-state>
<iqser-empty-state *ngIf="listingComponent.noMatch$ | async" [text]="noMatchText"></iqser-empty-state>
<cdk-virtual-scroll-viewport [class.no-data]="listingComponent.noContent$ | async"
[itemSize]="itemSize"
iqserHasScrollbar>
<div
(mouseenter)="itemMouseEnterFn && itemMouseEnterFn(entity)"
(mouseleave)="itemMouseLeaveFn && itemMouseLeaveFn(entity)"
*cdkVirtualFor="let entity of listingComponent.sortedDisplayedEntities$ | async; trackBy: listingComponent.trackByPrimaryKey"
[ngClass]="getTableItemClasses(entity)"
[routerLink]="routerLinkFn && routerLinkFn(entity)"
>
<div (click)="listingComponent.toggleEntitySelected($event, entity)" *ngIf="selectionEnabled" class="selection-column">
<iqser-round-checkbox [active]="listingComponent.isSelected(entity)"></iqser-round-checkbox>
</div>
<ng-container *ngFor="let column of tableColumnConfigs">
<ng-container *ngTemplateOutlet="column.template; context: { entity: entity, extra: column.extra }"></ng-container>
</ng-container>
<div *ngIf="!!actionsTemplate" class="actions-container">
<ng-container *ngTemplateOutlet="actionsTemplate; context: { entity: entity }"></ng-container>
</div>
<div class="scrollbar-placeholder"></div>
</div>
</cdk-virtual-scroll-viewport>
<iqser-scroll-button *ngIf="hasScrollButton" [itemSize]="itemSize" [scrollViewport]="scrollViewport"></iqser-scroll-button>

View File

@ -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;
}
}
}
}
}

View File

@ -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<T extends Listable> implements OnInit {
@Input() bulkActions?: TemplateRef<unknown>;
@Input() actionsTemplate?: TemplateRef<unknown>;
@Input() headerTemplate?: TemplateRef<unknown>;
@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<void>();
@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<T>[];
tableHeaderLabel!: string;
@ViewChild(CdkVirtualScrollViewport, { static: true }) readonly scrollViewport!: CdkVirtualScrollViewport;
constructor(
@Inject(forwardRef(() => ListingComponent)) private _parent: ListingComponent<T>,
private readonly _hostRef: ViewContainerRef
) {}
get listingComponent(): ListingComponent<T> {
return this._parent;
}
ngOnInit(): void {
this.tableColumnConfigs = <TableColumnConfig<T>[]>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`);
}
}

View File

@ -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';

View File

@ -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 {}

View File

@ -1 +0,0 @@
export * from './workflow-listing.module';

View File

@ -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 {}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,2 @@
export * from '../scrollbar/has-scrollbar.directive';
export * from './scrollbar.module';

View File

@ -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 {}