Merge branch 'table-component'
This commit is contained in:
commit
35f1a8d19f
18
src/assets/icons/arrow-down-o.svg
Normal file
18
src/assets/icons/arrow-down-o.svg
Normal 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 |
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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];
|
||||
|
||||
15
src/lib/empty-states/empty-state.module.ts
Normal file
15
src/lib/empty-states/empty-state.module.ts
Normal 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 {}
|
||||
21
src/lib/empty-states/empty-state/empty-state.component.html
Normal file
21
src/lib/empty-states/empty-state/empty-state.component.html
Normal 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>
|
||||
27
src/lib/empty-states/empty-state/empty-state.component.scss
Normal file
27
src/lib/empty-states/empty-state/empty-state.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/lib/empty-states/empty-state/empty-state.component.ts
Normal file
26
src/lib/empty-states/empty-state/empty-state.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
src/lib/empty-states/index.ts
Normal file
2
src/lib/empty-states/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './empty-state.module';
|
||||
export * from './empty-state/empty-state.component';
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
2
src/lib/listing/models/index.ts
Normal file
2
src/lib/listing/models/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './listable';
|
||||
export * from './table-column-config.model';
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
30
src/lib/listing/scroll-button/scroll-button.component.scss
Normal file
30
src/lib/listing/scroll-button/scroll-button.component.scss
Normal 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;
|
||||
}
|
||||
61
src/lib/listing/scroll-button/scroll-button.component.ts
Normal file
61
src/lib/listing/scroll-button/scroll-button.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
1
src/lib/listing/services/index.ts
Normal file
1
src/lib/listing/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './entities.service';
|
||||
@ -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">
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
|
||||
50
src/lib/listing/table/table.component.html
Normal file
50
src/lib/listing/table/table.component.html
Normal 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>
|
||||
129
src/lib/listing/table/table.component.scss
Normal file
129
src/lib/listing/table/table.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/lib/listing/table/table.component.ts
Normal file
116
src/lib/listing/table/table.component.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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 {}
|
||||
@ -1 +0,0 @@
|
||||
export * from './workflow-listing.module';
|
||||
@ -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 {}
|
||||
27
src/lib/scrollbar/has-scrollbar.directive.ts
Normal file
27
src/lib/scrollbar/has-scrollbar.directive.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/lib/scrollbar/index.ts
Normal file
2
src/lib/scrollbar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from '../scrollbar/has-scrollbar.directive';
|
||||
export * from './scrollbar.module';
|
||||
13
src/lib/scrollbar/scrollbar.module.ts
Normal file
13
src/lib/scrollbar/scrollbar.module.ts
Normal 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 {}
|
||||
Loading…
x
Reference in New Issue
Block a user