EMP-55: Add table2 drag drop (#79)
Some checks are pending
Main / Publish (push) Waiting to run

This commit is contained in:
Rinat Arsaev 2024-10-25 04:11:01 +07:00 committed by GitHub
parent 784928f164
commit 3a051b855b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 627 additions and 1951 deletions

View File

@ -2,31 +2,6 @@
@import '../../styles/utils/get-color';
@mixin theme($theme) {
.pinned-right {
border-left-color: get-color($theme, neutral, 300) !important;
}
.pinned-left {
border-right-color: get-color($theme, neutral, 300) !important;
}
.v-table-cell {
.link {
.value {
color: get-color($theme, primary);
}
}
.value-link {
color: get-color($theme, primary);
}
}
.v-table-filter {
@include mat.form-field-density(-5);
}
// Table 2
.column {
&,
&__sticky-start,

View File

@ -1,26 +0,0 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatSortHeader } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { BaseColumnComponent } from './base-column.component';
@Component({
standalone: true,
selector: 'v-score-column',
template: `
<ng-container [matColumnDef]="name()">
<th *matHeaderCellDef [mat-sort-header]="name()" mat-header-cell style="display: none">
Score
</th>
<td *matCellDef="let row; let index = index" mat-cell style="display: none">
{{ scores().get(row)?.score | number: '1.2-2' : 'en' }}
</td>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, MatTableModule, MatSortHeader],
})
export class ScoreColumnComponent<T extends object> extends BaseColumnComponent {
scores = input<Map<T, { score: number }>>(new Map());
}

View File

@ -1,9 +0,0 @@
<button
[disabled]="progress"
class="show-more"
mat-button
matTooltip="Show {{ pageSize }} more (currently shown {{ shown }})"
(click)="more.emit()"
>
{{ progress ? 'Loading...' : 'Show more' }}
</button>

View File

@ -1,23 +0,0 @@
import { Component, EventEmitter, Input, Output, booleanAttribute } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
@Component({
standalone: true,
selector: 'v-show-more-button',
templateUrl: './show-more-button.component.html',
styleUrls: ['./show-more-button.component.scss'],
imports: [MatTooltip, MatButton],
})
export class ShowMoreButtonComponent {
@Input({ transform: booleanAttribute }) progress = false;
@Input() displayedPages = 0;
@Input() pageSize = 0;
@Input() length = 0;
@Output() more = new EventEmitter<void>();
get shown() {
return Math.min(this.length, this.displayedPages * this.pageSize);
}
}

View File

@ -1,12 +1,16 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActionsModule } from '../../actions';
@Component({
selector: 'v-table-actions',
standalone: true,
template: `
<v-actions>
<ng-content></ng-content>
</v-actions>
`,
imports: [ActionsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableActionsComponent {}

View File

@ -1,121 +0,0 @@
@if (colDef.lazy && !lazyVisible && !preloadLazy) {
<button
class="button"
mat-icon-button
(click)="lazyVisible = true; preloadedLazyChange.emit(true)"
>
<mat-icon>sync</mat-icon>
</button>
}
<ng-container
*ngTemplateOutlet="preloadLazy || !colDef.lazy || lazyVisible ? content : null"
></ng-container>
<ng-template #content>
<div class="wrapper">
@if (rowData | vSelect: colDef.tooltip : '' : [colDef]; as tooltip) {
<div [mtxTooltip]="tooltipTpl" class="tooltip" mtxTooltipPosition="right">
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
</div>
<ng-container *ngTemplateOutlet="descriptionTemplate"></ng-container>
<ng-template #tooltipTpl>
<pre>{{ tooltip | json }}</pre>
</ng-template>
} @else {
@if (colDef.link?.(rowData, index); as link) {
<a [routerLink]="link" [target]="colDef?.linkParameters?.target" class="link">
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
</a>
<ng-container *ngTemplateOutlet="descriptionTemplate"></ng-container>
} @else {
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
<ng-container *ngTemplateOutlet="descriptionTemplate"></ng-container>
}
}
</div>
<ng-template #valueTemplate>
<ng-template
#value
[ngTemplateOutlet]="value"
[ngTemplateOutletContext]="{
value: rowData | vSelect: colDef.formatter ?? colDef.field : '' : [index, colDef],
}"
let-value="value"
>
<div
[ngClass]="{ 'value-link': !!colDef.click }"
[title]="value"
class="value"
(click)="colDef.click?.(rowData, index)"
>
@if (colDef.type === 'datetime') {
{{ value | date: 'dd.MM.yyyy HH:mm:ss' : '+0000' }}
}
@if (colDef.type === 'tag') {
@if (colDef.typeParameters.tags[value] ?? {}; as tag) {
@if (
tag.label ??
(rowData | vSelect: colDef.typeParameters.label : value : [colDef]);
as tagLabel
) {
<v-tag [color]="tag.color">
{{ tagLabel }}
</v-tag>
}
}
}
@if (colDef.type === 'boolean') {
@if (value === true || value === false) {
<v-tag [color]="value ? 'success' : 'warn'">
{{ value ? 'Yes' : 'No' }}
</v-tag>
}
}
@if (colDef.type === 'currency') {
{{
value
| amountCurrency
: (rowData
| vSelect
: colDef?.typeParameters?.currencyCode
: ''
: [colDef])
: 'long'
: (rowData
| vSelect: colDef?.typeParameters?.exponent : 2 : [colDef])
: !colDef?.typeParameters?.isMinor
}}
}
@if (colDef.type === 'menu') {
<button [matMenuTriggerFor]="menu" class="button" mat-icon-button>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
@for (item of colDef.typeParameters.items; track item; let index = $index) {
<button
[disabled]="!!item?.disabled?.(rowData, index)"
mat-menu-item
(click)="item.click(rowData, index)"
>
{{ getLabel(item.label, index) }}
</button>
}
</mat-menu>
}
@if (!colDef.type) {
{{ value }}
}
</div>
</ng-template>
</ng-template>
<ng-template #descriptionTemplate>
<div
*ngIf="rowData | vSelect: colDef.description : '' : [colDef] as description"
[title]="description"
class="description mat-caption mat-secondary-text"
>
{{ description }}
</div>
</ng-template>
</ng-template>

View File

@ -1,41 +0,0 @@
.wrapper {
& > * {
white-space: nowrap;
}
.value,
.description {
overflow: hidden;
text-overflow: ellipsis;
}
.value-link {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.tooltip {
text-decoration: underline;
text-decoration-style: dotted;
.value {
cursor: default;
}
}
.link {
cursor: pointer;
text-decoration: none;
.value:hover {
text-decoration: underline;
}
}
}
.button {
margin: -2.5px 0;
}

View File

@ -1,35 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
Input,
booleanAttribute,
Output,
EventEmitter,
} from '@angular/core';
import { ColumnFn, ColumnObject } from '../../types';
@Component({
selector: 'v-table-cell',
templateUrl: './table-cell.component.html',
styleUrls: ['./table-cell.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableCellComponent<T extends object> {
@HostBinding('class.v-table-cell') hostClass: boolean = true;
@Input() rowData!: T;
@Input() colDef!: ColumnObject<T>;
@Input() index!: number;
@Input({ transform: booleanAttribute }) preloadLazy = false;
@Output() preloadedLazyChange = new EventEmitter<boolean>();
lazyVisible = false;
getLabel(label: string | ColumnFn<T, string>, index: number) {
return typeof label === 'string' ? label : label(this.rowData, index);
}
}

View File

@ -26,28 +26,36 @@
</v-tag>
</div>
<div class="details">
<button
[disabled]="progress()"
mat-icon-button
matTooltip="Reload {{ size() }} elements"
(click)="load.emit()"
>
<mat-icon>refresh</mat-icon>
</button>
<button
[disabled]="progress() || !hasMore()"
mat-icon-button
matTooltip="Preload {{ preloadSize() }}{{
isPreload() && hasMore() ? ' more' : ''
}} elements"
(click)="preload.emit()"
>
<mat-icon>
{{
hasMore() ? (isPreload() ? 'downloading' : 'download') : 'download_done'
}}
</mat-icon>
</button>
@if (hasLoad()) {
<button
[disabled]="progress()"
mat-icon-button
matTooltip="Reload {{ size() }} elements"
(click)="load.emit()"
>
<mat-icon>refresh</mat-icon>
</button>
}
@if (hasLoad() || hasMore()) {
<button
[disabled]="progress() || !hasMore()"
mat-icon-button
matTooltip="Preload {{ preloadSize() }}{{
isPreload() && hasMore() ? ' more' : ''
}} elements"
(click)="preload.emit()"
>
<mat-icon>
{{
hasMore()
? isPreload()
? 'downloading'
: 'download'
: 'download_done'
}}
</mat-icon>
</button>
}
@if (!noDownload()) {
<button
[disabled]="progress() || !count()"

View File

@ -39,6 +39,7 @@ import { TagModule } from '../../../tag';
export class TableInfoBarComponent implements OnInit {
progress = input(false, { transform: booleanAttribute });
hasMore = input(false, { transform: booleanAttribute });
hasLoad = input(false, { transform: booleanAttribute });
isPreload = input(false, { transform: booleanAttribute });
noDownload = input(false, { transform: booleanAttribute });
dataProgress = input(false, { transform: booleanAttribute });

View File

@ -1,5 +0,0 @@
import { createUniqueColumnDef } from '../../utils/create-unique-column-def';
export const COLUMN_DEFS = {
select: createUniqueColumnDef('select'),
};

View File

@ -1,2 +0,0 @@
export * from './table2.component';
export * from './tree-data';

View File

@ -1,111 +0,0 @@
@let columnsData = columnsData$$ | async;
<div class="wrapper">
<v-table-info-bar
[(filter)]="filter"
[count]="(dataSource.data$ | async)?.length"
[dataProgress]="!!(columnsDataProgress$ | async) && !hasMore()"
[filteredCount]="displayedCount$ | async"
[hasInputs]="!!tableInputsContent?.nativeElement"
[hasMore]="hasMore()"
[isPreload]="isPreload()"
[noDownload]="noDownload()"
[preloadSize]="maxSize()"
[progress]="progress()"
[selectedCount]="rowSelected()?.length"
[size]="size()"
[standaloneFilter]="standaloneFilter()"
(downloadCsv)="downloadCsv()"
(load)="load()"
(preload)="preload()"
>
<ng-content select="v-table-actions"></ng-content>
<v-table-inputs><ng-content select="v-table-inputs"></ng-content></v-table-inputs>
</v-table-info-bar>
<mat-card #scrollViewport class="card">
<v-no-records
[noRecords]="!(displayedCount$ | async)"
[progress]="progress()"
></v-no-records>
<table
#matTable
[dataSource]="dataSource"
[matSortActive]="(columnsDataProgress$ | async) || progress() ? '' : sort().active"
[matSortDirection]="sort().direction"
mat-table
matSort
(matSortChange)="sort.set($event)"
>
@if (rowSelectable()) {
<v-select-column
[(selected)]="rowSelected"
[data]="displayedData$ | async"
[name]="columnDefs.select"
[progress]="progress()"
></v-select-column>
}
@for (col of displayedNormColumns$ | async; track col; let colIndex = $index) {
@let stickyStart = col.params.sticky === 'start';
@let stickyEnd = col.params.sticky === 'end';
@let columnClasses =
{
column: true,
'column__sticky-start': stickyStart,
'column__sticky-end': stickyEnd,
};
<ng-container
[matColumnDef]="col.field"
[sticky]="stickyStart"
[stickyEnd]="stickyEnd"
>
<th
*matHeaderCellDef
[disabled]="(col.sort | async) === false || hasMore() || progress()"
[mat-sort-header]="col.field"
[ngClass]="columnClasses"
mat-header-cell
>
<v-value [value]="col?.header | async" emptySymbol="" inline></v-value>
</th>
<ng-template let-element let-rowIndex="index" matCellDef>
@let cell = columnsData?.get?.(element)?.get(col);
<td
[ngClass]="columnClasses"
[ngStyle]="col?.params?.style"
[style.border-bottom]="cell?.isNextChild && !col.child ? 'none' : ''"
mat-cell
>
<v-value
[emptySymbol]="!(cell?.isChild && !col?.child)"
[highlight]="filter$ | async"
[lazyValue]="cell?.lazyValue"
[lazyVisible]="loadedLazyItems.has(element)"
[value]="cell?.value | async"
inline
(lazyVisibleChange)="loadedLazyItems.set(element, true)"
></v-value>
</td>
</ng-template>
<td *matFooterCellDef [ngClass]="columnClasses" mat-footer-cell>
<v-content-loading></v-content-loading>
</td>
</ng-container>
}
<tr *matHeaderRowDef="displayedColumns$ | async; sticky: true" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns$ | async" mat-row></tr>
<tr
*matFooterRowDef="(hasAutoShowMore$ | async) ? (displayedColumns$ | async) : []"
[ngClass]="{ row__hidden: !(hasAutoShowMore$ | async) }"
[vInfinityScroll]="
!progress() &&
!(columnsDataProgress$ | async) &&
(filteredSortData$ | async)?.length
"
mat-row
(vInfinityScrollMore)="showMore()"
></tr>
</table>
</mat-card>
</div>

View File

@ -1,56 +0,0 @@
:host {
min-height: 0;
}
.wrapper {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
.card {
width: 100%;
height: 100%;
min-height: 300px;
overflow: auto;
transform: translateZ(0);
::ng-deep .mdc-data-table__row:last-child .mat-mdc-cell {
border-bottom-color: var(
--mat-table-row-item-outline-color,
rgba(0, 0, 0, 0.12)
) !important;
border-bottom-width: var(--mat-table-row-item-outline-width, 1px) !important;
border-bottom-style: solid !important;
}
}
.show-more-button {
margin-top: 8px;
}
}
.column {
max-width: max(20px, 30vw);
&__sticky-start,
&__sticky-end {
width: 0;
}
&__sticky-start {
border-right: 1px solid;
}
&__sticky-end {
border-left: 1px solid;
}
}
::ng-deep .mat-mdc-footer-cell {
border-bottom: 1px solid;
}
.row__hidden {
display: none;
}

View File

@ -1,382 +0,0 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
computed,
DestroyRef,
booleanAttribute,
numberAttribute,
signal,
output,
Injector,
ElementRef,
runInInjectionContext,
OnInit,
ViewChild,
ContentChild,
model,
ChangeDetectorRef,
} from '@angular/core';
import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton, MatButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatSort, MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule, MatTable } from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import {
combineLatest,
Observable,
switchMap,
take,
forkJoin,
BehaviorSubject,
debounceTime,
first,
merge,
tap,
defer,
} from 'rxjs';
import {
shareReplay,
map,
distinctUntilChanged,
delay,
filter,
startWith,
share,
} from 'rxjs/operators';
import {
downloadFile,
createCsv,
arrayAttribute,
ArrayAttributeTransform,
} from '../../../../utils';
import { ContentLoadingComponent } from '../../../content-loading';
import { ProgressModule } from '../../../progress';
import { ValueComponent, ValueListComponent } from '../../../value';
import { sortDataByDefault, DEFAULT_SORT } from '../../consts';
import { Column2, UpdateOptions, NormColumn } from '../../types';
import { TableDataSource } from '../../utils/table-data-source';
import { tableToCsvObject } from '../../utils/table-to-csv-object';
import { InfinityScrollDirective } from '../infinity-scroll.directive';
import { NoRecordsComponent } from '../no-records.component';
import { SelectColumnComponent } from '../select-column.component';
import { ShowMoreButtonComponent } from '../show-more-button/show-more-button.component';
import { TableInfoBarComponent } from '../table-info-bar/table-info-bar.component';
import { TableInputsComponent } from '../table-inputs.component';
import { TableProgressBarComponent } from '../table-progress-bar.component';
import { COLUMN_DEFS } from './consts';
import { TreeData } from './tree-data';
import { columnsDataToFilterSearchData, filterData, sortData } from './utils/filter-sort';
import {
toObservableColumnsData,
toColumnsData,
DisplayedDataItem,
DisplayedData,
} from './utils/to-columns-data';
const SHORT_DEBOUNCE_TIME_MS = 300;
const DEBOUNCE_TIME_MS = 500;
const DEFAULT_LOADED_LAZY_ROWS_COUNT = 3;
@Component({
standalone: true,
selector: 'v-table2',
templateUrl: './table2.component.html',
styleUrls: ['./table2.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
MatTableModule,
MatCardModule,
ValueComponent,
TableProgressBarComponent,
NoRecordsComponent,
TableInfoBarComponent,
ShowMoreButtonComponent,
ContentLoadingComponent,
MatIcon,
MatTooltip,
MatIconButton,
InfinityScrollDirective,
ValueListComponent,
SelectColumnComponent,
ProgressModule,
MatSortModule,
TableInputsComponent,
MatButton,
],
})
export class Table2Component<T extends object, C extends object> implements OnInit {
data = input<T[]>();
treeData = input<TreeData<T, C>>();
columns = input<Column2<T, C>[], ArrayAttributeTransform<Column2<T, C>>>([], {
transform: arrayAttribute,
});
progress = input(false, { transform: Boolean });
hasMore = input(false, { transform: booleanAttribute });
size = input(25, { transform: numberAttribute });
maxSize = input(1000, { transform: numberAttribute });
noDownload = input(false, { transform: booleanAttribute });
// Filter
filter = model('');
filter$ = toObservable(this.filter).pipe(
map((v) => (v || '').trim()),
distinctUntilChanged(),
debounceTime(DEBOUNCE_TIME_MS),
shareReplay({ refCount: true, bufferSize: 1 }),
);
standaloneFilter = input(false, { transform: booleanAttribute });
externalFilter = input(false, { transform: booleanAttribute });
filteredSortData$ = new BehaviorSubject<DisplayedData<T, C> | null>(null);
displayedData$ = combineLatest([
defer(() => this.dataSource.data$),
this.filteredSortData$.pipe(distinctUntilChanged()),
defer(() => this.columnsDataProgress$),
]).pipe(
map(
([data, filteredSortData, columnsDataProgress]) =>
(filteredSortData && !columnsDataProgress ? filteredSortData : data) || [],
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
displayedCount$ = this.displayedData$.pipe(
map((data) => data.length),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
// Select
rowSelectable = input(false, { transform: booleanAttribute });
rowSelected = model<DisplayedData<T, C>>([]);
// Sort
sort = model<Sort>(DEFAULT_SORT);
@ViewChild(MatSort) sortComponent!: MatSort;
update = output<UpdateOptions>();
more = output<UpdateOptions>();
loadedLazyItems = new WeakMap<DisplayedDataItem<T, C>, boolean>();
dataSource = new TableDataSource<T, C>();
normColumns = computed<NormColumn<T, C>[]>(() => this.columns().map((c) => new NormColumn(c)));
displayedNormColumns$ = toObservable(this.normColumns).pipe(
switchMap((cols) =>
combineLatest(cols.map((c) => c.hidden)).pipe(
map((c) => cols.filter((_, idx) => !c[idx])),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
columnsData$$ = combineLatest({
isTree: this.dataSource.isTreeData$,
data: this.dataSource.data$,
cols: toObservable(this.normColumns),
}).pipe(toObservableColumnsData, shareReplay({ refCount: true, bufferSize: 1 }));
columnsDataProgress$ = new BehaviorSubject(false);
columnsData$ = this.columnsData$$.pipe(
tap(() => {
this.columnsDataProgress$.next(true);
}),
toColumnsData,
tap(() => {
this.columnsDataProgress$.next(false);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
isPreload = signal(false);
loadSize = computed(() => (this.isPreload() ? this.maxSize() : this.size()));
hasAutoShowMore$ = combineLatest([
toObservable(this.hasMore),
this.dataSource.data$.pipe(map((d) => d?.length)),
this.displayedCount$,
this.dataSource.paginator.page.pipe(
startWith(null),
map(() => this.dataSource.paginator.pageSize),
),
]).pipe(
map(
([hasMore, dataCount, displayedDataCount, size]) =>
(hasMore && displayedDataCount !== 0 && displayedDataCount >= dataCount) ||
displayedDataCount > size,
),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
displayedColumns$ = combineLatest([
this.displayedNormColumns$,
toObservable(this.rowSelectable),
]).pipe(
map(([normColumns, rowSelectable]) => [
...(rowSelectable ? [this.columnDefs.select] : []),
...normColumns.map((c) => c.field),
]),
);
columnDefs = COLUMN_DEFS;
@ViewChild('scrollViewport', { read: ElementRef }) scrollViewport!: ElementRef;
@ViewChild('matTable', { static: false }) table!: MatTable<T>;
@ContentChild(TableInputsComponent, { read: ElementRef }) tableInputsContent!: ElementRef;
constructor(
private dr: DestroyRef,
private injector: Injector,
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
const sort$ = toObservable(this.sort, { injector: this.injector }).pipe(
distinctUntilChanged(),
share(),
);
toObservable(this.data, { injector: this.injector })
.pipe(filter(Boolean), takeUntilDestroyed(this.dr))
.subscribe((data) => {
this.dataSource.setData(data);
});
toObservable(this.treeData, { injector: this.injector })
.pipe(filter(Boolean), takeUntilDestroyed(this.dr))
.subscribe((data) => {
this.dataSource.setTreeData(data);
});
combineLatest([
this.filter$,
sort$,
this.dataSource.data$,
runInInjectionContext(this.injector, () =>
this.columnsData$.pipe(columnsDataToFilterSearchData),
),
this.dataSource.isTreeData$,
toObservable(this.normColumns, { injector: this.injector }),
toObservable(this.externalFilter, { injector: this.injector }),
])
.pipe(
tap(() => {
this.filteredSortData$.next(null);
}),
debounceTime(SHORT_DEBOUNCE_TIME_MS),
map(([search, sort, source, data, isTreeData, columns, isExternalFilter]) => {
if (isTreeData) {
return source;
}
const filteredData =
!isExternalFilter && search ? filterData(data, search) : source;
return sortData(filteredData, data, columns, sort);
}),
// distinctUntilChanged(isEqual),
takeUntilDestroyed(this.dr),
)
.subscribe((filtered) => {
this.updateSortFilter(filtered);
this.updateLoadedLazyItems(filtered);
this.cdr.markForCheck();
});
merge(
this.filter$.pipe(filter(Boolean)),
toObservable(this.hasMore, { injector: this.injector }).pipe(filter(Boolean)),
)
.pipe(takeUntilDestroyed(this.dr))
.subscribe(() => {
this.sort.set(DEFAULT_SORT);
});
merge(this.filter$, sort$)
.pipe(takeUntilDestroyed(this.dr))
.subscribe(() => {
this.reset();
});
// TODO: 2, 3 column is torn away from the previous one, fixed by calling update
this.dataSource.data$
.pipe(
filter((v) => !!v?.length),
first(),
delay(100),
takeUntilDestroyed(this.dr),
)
.subscribe(() => {
this.table.updateStickyColumnStyles();
});
}
load() {
if (this.isPreload()) {
this.isPreload.set(false);
}
this.reload();
}
preload() {
if (!this.isPreload()) {
this.isPreload.set(true);
this.reload();
} else if (this.hasMore()) {
this.more.emit({ size: this.loadSize() });
}
}
showMore() {
this.dataSource.paginator.more();
if (this.hasMore() && this.dataSource.paginator.pageSize > this.dataSource.data.length) {
this.more.emit({ size: this.loadSize() });
}
this.refreshTable();
}
downloadCsv() {
this.generateCsvData()
.pipe(takeUntilDestroyed(this.dr))
.subscribe((csvData) => {
downloadFile(csvData, 'csv');
});
}
private generateCsvData(): Observable<string> {
return combineLatest([
this.displayedNormColumns$.pipe(
switchMap((cols) => forkJoin(cols.map((c) => c.header.pipe(take(1))))),
),
this.columnsData$.pipe(take(1)),
]).pipe(
map(([cols, data]) =>
createCsv(runInInjectionContext(this.injector, () => tableToCsvObject(cols, data))),
),
);
}
private reload() {
this.update.emit({ size: this.loadSize() });
this.reset();
}
private updateSortFilter(filtered: DisplayedData<T, C>) {
this.filteredSortData$.next(filtered);
this.dataSource.sortData = filtered ? () => filtered : sortDataByDefault;
this.dataSource.sort = this.sortComponent;
}
private reset() {
this.scrollViewport?.nativeElement?.scrollTo?.(0, 0);
this.dataSource.paginator.reload();
this.refreshTable();
}
// TODO: Refresh table when pagination is updated
private refreshTable() {
// eslint-disable-next-line no-self-assign
this.dataSource.data = this.dataSource.data;
}
private updateLoadedLazyItems(items: DisplayedData<T, C>) {
const lazyLoadedItems = items.slice(0, DEFAULT_LOADED_LAZY_ROWS_COUNT);
for (const item of lazyLoadedItems) {
this.loadedLazyItems.set(item, true);
}
}
}

View File

@ -1,6 +1,11 @@
import { Sort, MatSort } from '@angular/material/sort';
import { Sort } from '@angular/material/sort';
import { createUniqueColumnDef } from './utils/create-unique-column-def';
export const COMPLETE_MISMATCH_SCORE = 1;
export const DEFAULT_SORT: Sort = { active: '', direction: '' };
export const DEFAULT_DEBOUNCE_TIME_MS = 250;
export const sortDataByDefault: <T>(data: T[], sort: MatSort) => T[] = (data) => data;
export const DEBOUNCE_TIME_MS = 500;
export const DEFAULT_LOADED_LAZY_ROWS_COUNT = 3;
export const COLUMN_DEFS = {
select: createUniqueColumnDef('select'),
drag: createUniqueColumnDef('drag'),
};

View File

@ -1,11 +1,10 @@
export * from './table.component';
export * from './table.module';
export * from './utils/create-columns-objects';
export * from './utils/correct-priorities';
export * from './utils/create-column';
export * from './utils/cached-head-map';
export * from './components/table2';
export * from './table.component';
export * from './components/table-actions.component';
export * from './components/table-inputs.component';
export * from './types';
export * from './presets';
export * from './tree-data';

View File

@ -0,0 +1,23 @@
import { MenuValue } from '../../value/components/menu-value.component';
// TODO: hack
// eslint-disable-next-line
import { Column } from '../types/column2';
import { createColumn } from '../utils/create-column';
export const createMenuColumn = createColumn(
(params: MenuValue['params']) => {
return {
type: 'menu',
params,
};
},
{
field: 'menu',
header: '',
sticky: 'end',
style: {
padding: 0,
width: '0',
},
},
);

View File

@ -1,38 +0,0 @@
import { MenuValue } from '../../value/components/menu-value.component';
import { MenuColumn } from '../types';
import { createColumn } from '../utils/create-column';
import { createUniqueColumnDef } from '../utils/create-unique-column-def';
export function createOperationColumn<T extends object>(
items: MenuColumn<T>['typeParameters']['items'],
other?: Partial<Omit<MenuColumn<T>, 'items'>>,
): MenuColumn<T> {
return {
typeParameters: { ...(other?.typeParameters ?? {}), items },
field: createUniqueColumnDef('operation'),
header: '',
pinned: 'right',
width: '0',
type: 'menu',
...other,
};
}
// Table 2 Menu Column
export const createMenuColumn = createColumn(
(params: MenuValue['params']) => {
return {
type: 'menu',
params,
};
},
{
field: 'menu',
header: '',
sticky: 'end',
style: {
padding: 0,
width: '0',
},
},
);

View File

@ -1 +1 @@
export * from './create-operation-column';
export * from './create-menu-column';

View File

@ -1,301 +1,128 @@
<div class="table">
@if (!noFilter && (standaloneFilter || noActions)) {
<div class="toolbar">
<ng-content select="v-table-inputs"></ng-content>
<mat-form-field class="v-table-filter-standalone">
<mat-label>{{ externalFilter() ? 'Search' : 'Filter' }}</mat-label>
<input [formControl]="filterControl" matInput />
<div matSuffix style="white-space: nowrap">
<ng-container *ngTemplateOutlet="exactButtonTpl"></ng-container>
<button
[disabled]="!filterControl.value"
mat-icon-button
(click)="filterControl.setValue('')"
>
<mat-icon>clear</mat-icon>
</button>
</div>
</mat-form-field>
@if (noActions && update.observed) {
<div style="flex: 0; white-space: nowrap; padding-top: 12px">
<ng-container *ngTemplateOutlet="updateButtonsTpl"></ng-container>
</div>
}
</div>
}
@if (!noActions) {
<v-actions>
<v-actions>
<div style="display: flex">
<ng-container
*ngTemplateOutlet="update.observed ? updateButtonsTpl : null"
></ng-container>
@if (!noFilter && !standaloneFilter) {
<div style="margin: -6px 0">
<ng-container *ngTemplateOutlet="exactButtonTpl"></ng-container>
</div>
}
</div>
@if (!noFilter && !standaloneFilter) {
<div style="display: flex">
<v-input-field
[formControl]="filterControl"
appearance="outline"
class="v-table-filter"
label="Filter"
></v-input-field>
@if (filterControl.value) {
<button
mat-icon-button
style="margin: -6px 0"
(click)="filterControl.setValue('')"
>
<mat-icon>clear</mat-icon>
</button>
}
</div>
}
</v-actions>
<div>
<ng-content select="v-table-actions"></ng-content>
</div>
</v-actions>
}
<div class="table-card-details">
<div class="details">
<div>
Quantity:
{{
progress
? '...'
: data()?.length
? data().length + (hasMore ? ' (more available)' : ' (all)')
: '0'
}}
@if (filterControl.value && !externalFilter()) {
| Filtered: {{ filteredDataLength ?? '...' }}
}
@if (selected?.length) {
|
<b>Selected: {{ selected.length }}</b>
}
</div>
</div>
<mat-card class="table-card">
@if (progress || (!externalFilter() && (filterProgress$ | async))) {
<mat-progress-bar class="progress-bar" mode="indeterminate"></mat-progress-bar>
}
<table
#table
[cdkDropListDisabled]="dragDisabled"
[dataSource]="dataSource"
[matSortActive]="sort.active"
[matSortDirection]="sort.direction"
cdkDropList
cdkDropListData="dataSource"
mat-table
matSort
(cdkDropListDropped)="drop($event)"
(matSortChange)="sortChanged($event)"
>
<ng-container [matColumnDef]="dragColumnDef" [sticky]="true">
<th *matHeaderCellDef mat-header-cell></th>
<td *matCellDef="let row; let index = index" mat-cell>
<div class="position">
<mat-icon class="dragCursor" (mousedown)="dragDisabled = false"
>reorder</mat-icon
>
<!-- <span>{{ index + 1 }}</span>-->
</div>
</td>
</ng-container>
<v-score-column [name]="scoreColumnDef" [scores]="scores"></v-score-column>
@if (rowSelectable) {
<v-select-column
[data]="data()"
[filtered]="!!filterControl.value"
[name]="selectColumnDef"
[progress]="progress"
[selected]="rowSelected"
(selectedChange)="rowSelectedChange.emit($event); selected = $event"
></v-select-column>
}
@for (col of columnsObjects.values(); track col) {
<ng-container
[matColumnDef]="col.field"
[sticky]="col.pinned === 'left'"
[stickyEnd]="col.pinned === 'right'"
>
@if (col.sortable) {
<th
*matHeaderCellDef
[mat-sort-header]="col.field"
[ngClass]="{
'pinned-left': col.pinned === 'left',
'pinned-right': col.pinned === 'right',
}"
[ngStyle]="{
width: col.width,
'min-width': col.minWidth,
'max-width': col.maxWidth,
}"
mat-header-cell
>
{{ col.header }}
</th>
} @else {
<th
*matHeaderCellDef
[ngClass]="{
'pinned-left': col.pinned === 'left',
'pinned-right': col.pinned === 'right',
}"
[ngStyle]="{
width: col.width,
'min-width': col.minWidth,
'max-width': col.maxWidth,
}"
mat-header-cell
>
{{ col.header }}
</th>
}
<td
*matCellDef="let row; let index = index"
[ngClass]="{
'pinned-left': col.pinned === 'left',
'pinned-right': col.pinned === 'right',
td: true,
}"
[ngStyle]="{
width: col.width,
'min-width': col.minWidth,
'max-width': col.maxWidth,
}"
mat-cell
style="vertical-align: top; padding: 6px 16px"
@let columnsData = columnsData$$ | async;
<div class="wrapper">
<v-table-info-bar
[(filter)]="filter"
[count]="(dataSource.data$ | async)?.length"
[dataProgress]="!!(columnsDataProgress$ | async) && !hasMore()"
[filteredCount]="displayedCount$ | async"
[hasInputs]="!!tableInputsContent?.nativeElement"
[hasLoad]="update$.observed"
[hasMore]="hasMore()"
[isPreload]="isPreload()"
[noDownload]="noDownload()"
[preloadSize]="maxSize()"
[progress]="progress()"
[selectedCount]="rowSelected()?.length"
[size]="size()"
[standaloneFilter]="standaloneFilter()"
(downloadCsv)="downloadCsv()"
(load)="load()"
(preload)="preload()"
>
<ng-content select="v-table-actions"></ng-content>
<v-table-inputs><ng-content select="v-table-inputs"></ng-content></v-table-inputs>
</v-table-info-bar>
<mat-card #scrollViewport class="card">
<v-no-records
[noRecords]="!(displayedCount$ | async)"
[progress]="progress()"
></v-no-records>
<table
#matTable
[cdkDropListDisabled]="dragDisabled"
[dataSource]="dataSource"
[matSortActive]="(columnsDataProgress$ | async) || progress() ? '' : sort().active"
[matSortDirection]="sort().direction"
cdkDropList
cdkDropListData="dataSource"
mat-table
matSort
(cdkDropListDropped)="drop($event)"
(matSortChange)="sort.set($event)"
>
<ng-container [matColumnDef]="columnDefs.drag" [sticky]="true">
<th *matHeaderCellDef mat-header-cell></th>
<td *matCellDef="let row; let index = index" mat-cell>
<div class="position">
<mat-icon class="dragCursor" (mousedown)="dragDisabled = false"
>reorder</mat-icon
>
<div style="min-height: 40px; display: flex; align-items: center">
<div>
@if (!col.cellTemplate && !cellTemplate[col.field]) {
<v-table-cell
[colDef]="col"
[index]="index"
[preloadLazy]="
index < preloadedLazyRowsCount ||
!!preloadedLazyCells.get(row)
"
[rowData]="row"
(preloadedLazyChange)="
preloadedLazyCells.set(row, $event)
"
></v-table-cell>
} @else {
<ng-container
*ngTemplateOutlet="
col.cellTemplate ?? cellTemplate[col.field] ?? null;
context: {
colDef: col,
index: index,
rowData: row,
value:
row
| vSelect
: col.formatter ?? col.field
: ''
: [index, col],
}
"
></ng-container>
}
</div>
</div>
</td>
</ng-container>
}
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
[cdkDragData]="row"
cdkDrag
mat-row
></tr>
<ng-container [matColumnDef]="noRecordsColumnDef">
<td
*matFooterCellDef
[colSpan]="displayedColumns.length"
class="no-records mat-body-1"
mat-footer-cell
<!-- <span>{{ index + 1 }}</span>-->
</div>
</td>
</ng-container>
@if (rowSelectable()) {
<v-select-column
[(selected)]="rowSelected"
[data]="displayedData$ | async"
[name]="columnDefs.select"
[progress]="progress()"
></v-select-column>
}
@for (col of displayedNormColumns$ | async; track col; let colIndex = $index) {
@let stickyStart = col.params.sticky === 'start';
@let stickyEnd = col.params.sticky === 'end';
@let columnClasses =
{
column: true,
'column__sticky-start': stickyStart,
'column__sticky-end': stickyEnd,
};
<ng-container
[matColumnDef]="col.field"
[sticky]="stickyStart"
[stickyEnd]="stickyEnd"
>
<th
*matHeaderCellDef
[disabled]="(col.sort | async) === false || hasMore() || progress()"
[mat-sort-header]="col.field"
[ngClass]="columnClasses"
mat-header-cell
>
{{
progress
? 'Loading...'
: !externalFilter() && (filterProgress$ | async)
? 'Filtering...'
: filteredDataLength === 0
? 'No records found'
: 'No records'
}}
<v-value [value]="col?.header | async" emptySymbol="" inline></v-value>
</th>
<ng-template let-element let-rowIndex="index" matCellDef>
@let cell = columnsData?.get?.(element)?.get(col);
<td
[ngClass]="columnClasses"
[ngStyle]="col?.params?.style"
[style.border-bottom]="cell?.isNextChild && !col.child ? 'none' : ''"
mat-cell
>
<v-value
[emptySymbol]="!(cell?.isChild && !col?.child)"
[highlight]="filter$ | async"
[lazyValue]="cell?.lazyValue"
[lazyVisible]="loadedLazyItems.has(element)"
[value]="cell?.value | async"
inline
(lazyVisibleChange)="loadedLazyItems.set(element, true)"
></v-value>
</td>
</ng-template>
<td *matFooterCellDef [ngClass]="columnClasses" mat-footer-cell>
<v-content-loading></v-content-loading>
</td>
</ng-container>
}
<tr
*matFooterRowDef="isNoRecords ? [noRecordsColumnDef] : []"
[style.display]="isNoRecords ? 'table-row' : 'none'"
mat-footer-row
></tr>
</table>
</mat-card>
</div>
@if (hasShowMore) {
<v-show-more-button
[displayedPages]="displayedPages"
[length]="data()?.length ?? 0"
[pageSize]="size"
[progress]="!!progress"
(more)="showMore()"
></v-show-more-button>
}
<tr *matHeaderRowDef="displayedColumns$ | async; sticky: true" mat-header-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns$ | async"
[cdkDragData]="row"
cdkDrag
mat-row
></tr>
<tr
*matFooterRowDef="displayedColumns$ | async"
[ngClass]="{ row__hidden: !(hasAutoShowMore$ | async) || !tableRow }"
[vInfinityScroll]="!progress()"
mat-footer-row
(vInfinityScrollMore)="showMore()"
></tr>
</table>
</mat-card>
</div>
<ng-template #updateButtonsTpl>
<button
[disabled]="!!progress"
mat-icon-button
matTooltip="Reload {{ size }} elements"
style="margin: -6px 0"
(click)="load()"
>
<mat-icon>refresh</mat-icon>
</button>
<button
[color]="isPreload && hasMore ? 'accent' : undefined"
[disabled]="!!progress || !hasMore"
mat-icon-button
matTooltip="Preload {{ preloadSize }}{{ isPreload && hasMore ? ' more' : '' }} elements"
style="margin: -6px 0"
(click)="preload()"
>
<mat-icon>{{
hasMore || progress
? isPreload && hasMore
? 'downloading'
: 'download'
: 'download_done'
}}</mat-icon>
</button>
</ng-template>
<ng-template #exactButtonTpl>
@if (!externalFilter()) {
<v-switch-button
[formControl]="exactFilterControl"
[states]="[
{ label: 'partial match search', icon: 'remove_moderator' },
{ label: 'full match search', icon: 'policy' },
]"
></v-switch-button>
}
</ng-template>

View File

@ -1,3 +1,72 @@
:host {
min-height: 0;
}
.wrapper {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: max-content;
gap: 24px;
height: 100%;
.card {
width: 100%;
height: 100%;
min-height: 300px;
overflow: auto;
transform: translateZ(0);
::ng-deep .mdc-data-table__row:last-child .mat-mdc-cell {
border-bottom-color: var(
--mat-table-row-item-outline-color,
rgba(0, 0, 0, 0.12)
) !important;
border-bottom-width: var(--mat-table-row-item-outline-width, 1px) !important;
border-bottom-style: solid !important;
}
}
}
table,
::ng-deep .cdk-drag-preview {
::ng-deep .cdk-drag-placeholder {
background: #eee;
}
.position {
display: flex;
align-items: center;
gap: 8px;
.dragCursor {
cursor: move;
}
}
.column {
max-width: max(20px, 30vw);
&__sticky-start,
&__sticky-end {
width: 0;
}
&__sticky-start {
border-right: 1px solid;
}
&__sticky-end {
border-left: 1px solid;
}
}
.row__hidden {
display: none;
}
}
// Drag
::ng-deep .cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
@ -18,94 +87,8 @@
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.table {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
}
.table,
::ng-deep .cdk-drag-preview {
::ng-deep .cdk-drag-placeholder {
background: #eee;
}
.position {
display: flex;
align-items: center;
gap: 8px;
.dragCursor {
cursor: move;
}
}
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
::ng-deep & > * {
flex: 1;
flex-basis: 200px;
}
}
.v-table-filter,
.v-table-filter-standalone {
::ng-deep .mat-mdc-form-field-subscript-wrapper {
height: 0;
}
}
.table-card-details {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
.details {
display: flex;
justify-content: space-between;
& > *,
mat-icon {
font-size: 12px;
color: rgba(0, 0, 0, 0.54);
}
.action {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
}
.table-card {
width: 100%;
overflow: auto;
.progress-bar {
position: absolute;
z-index: 999;
}
.pinned-right,
.pinned-left {
width: 0;
}
.pinned-right {
border-left: 1px solid;
}
.pinned-left {
border-right: 1px solid;
}
.no-records {
text-align: center;
height: 52px * 3;
}
}
::ng-deep td {
border: none !important;
}
}

View File

@ -1,506 +1,428 @@
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ContentChild,
DestroyRef,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
ViewChild,
numberAttribute,
booleanAttribute,
OnDestroy,
input,
computed,
DestroyRef,
booleanAttribute,
numberAttribute,
signal,
output,
Injector,
ElementRef,
runInInjectionContext,
OnInit,
ViewChild,
ContentChild,
model,
ChangeDetectorRef,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource, MatTable } from '@angular/material/table';
// eslint-disable-next-line @typescript-eslint/naming-convention
import Fuse from 'fuse.js';
import { toObservable, takeUntilDestroyed, outputFromObservable } from '@angular/core/rxjs-interop';
import { MatIconButton, MatButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { MatSort, MatSortModule, Sort } from '@angular/material/sort';
import { MatTableModule, MatTable, MatRow } from '@angular/material/table';
import { MatTooltip } from '@angular/material/tooltip';
import {
combineLatest,
map,
of,
take,
debounceTime,
Observable,
switchMap,
take,
forkJoin,
BehaviorSubject,
debounceTime,
first,
merge,
tap,
Observable,
catchError,
defer,
Subject,
} from 'rxjs';
import { distinctUntilChanged, startWith } from 'rxjs/operators';
import { QueryParamsService, QueryParamsNamespace } from '../../services';
import { Progressable } from '../../types/progressable';
import {
compareDifferentTypes,
ComponentChanges,
select,
getPossiblyAsyncObservable,
} from '../../utils';
shareReplay,
map,
distinctUntilChanged,
delay,
filter,
startWith,
share,
} from 'rxjs/operators';
import { TableActionsComponent } from './components/table-actions.component';
import { downloadFile, createCsv, arrayAttribute, ArrayAttributeTransform } from '../../utils';
import { ContentLoadingComponent } from '../content-loading';
import { ProgressModule } from '../progress';
import { ValueComponent, ValueListComponent } from '../value';
import { InfinityScrollDirective } from './components/infinity-scroll.directive';
import { NoRecordsComponent } from './components/no-records.component';
import { SelectColumnComponent } from './components/select-column.component';
import { TableInfoBarComponent } from './components/table-info-bar/table-info-bar.component';
import { TableInputsComponent } from './components/table-inputs.component';
import { TableProgressBarComponent } from './components/table-progress-bar.component';
import {
DEFAULT_DEBOUNCE_TIME_MS,
DEBOUNCE_TIME_MS,
DEFAULT_LOADED_LAZY_ROWS_COUNT,
DEFAULT_SORT,
COMPLETE_MISMATCH_SCORE,
sortDataByDefault,
COLUMN_DEFS,
} from './consts';
import { Column, ColumnObject, UpdateOptions, DragDrop } from './types';
import { createColumnsObjects } from './utils/create-columns-objects';
import { createUniqueColumnDef } from './utils/create-unique-column-def';
import { OnePageTableDataSourcePaginator } from './utils/one-page-table-data-source-paginator';
import { TreeData } from './tree-data';
import { Column, UpdateOptions, NormColumn, DragDrop } from './types';
import { columnsDataToFilterSearchData, filterData, sortData } from './utils/filter-sort';
import { TableDataSource } from './utils/table-data-source';
import { tableToCsvObject } from './utils/table-to-csv-object';
import {
toObservableColumnsData,
toColumnsData,
DisplayedDataItem,
DisplayedData,
} from './utils/to-columns-data';
@Component({
standalone: true,
selector: 'v-table',
templateUrl: './table.component.html',
styleUrls: ['./table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
MatTableModule,
MatCardModule,
ValueComponent,
TableProgressBarComponent,
NoRecordsComponent,
TableInfoBarComponent,
ContentLoadingComponent,
MatIcon,
MatTooltip,
MatIconButton,
InfinityScrollDirective,
ValueListComponent,
SelectColumnComponent,
ProgressModule,
MatSortModule,
TableInputsComponent,
MatButton,
CdkDrag,
CdkDropList,
],
})
export class TableComponent<T extends object>
implements OnInit, Progressable, OnChanges, AfterViewInit, OnDestroy
{
data = input<T[]>([]);
@Input() columns!: Column<T>[];
@Input() cellTemplate: Record<ColumnObject<T>['field'], ColumnObject<T>['cellTemplate']> = {};
@Input() progress?: boolean | number | null = false;
@Input() preloadedLazyRowsCount = 3;
@Input({ transform: numberAttribute }) size: number = 25;
@Input() preloadSize: number = 1000;
@Input() name?: string;
@Input({ transform: booleanAttribute }) hasMore: boolean = false;
@Output() update = new EventEmitter<UpdateOptions>();
@Output() more = new EventEmitter<UpdateOptions>();
// Actions
@Input({ transform: booleanAttribute }) noActions: boolean = false;
@ContentChild(TableActionsComponent) actions!: TableActionsComponent;
@ContentChild(TableInputsComponent) inputs!: TableInputsComponent;
// Sort
@Input() sort: Sort = DEFAULT_SORT;
@Output() sortChange = new EventEmitter<Sort>();
@Input({ transform: booleanAttribute }) sortOnFront: boolean = false;
@ViewChild(MatSort) sortComponent!: MatSort;
// Select
@Input({ transform: booleanAttribute }) rowSelectable: boolean = false;
@Input() rowSelected!: T[];
@Output() rowSelectedChange = new EventEmitter<T[]>();
selectColumnDef = createUniqueColumnDef('select');
selected: T[] = [];
export class TableComponent<T extends object, C extends object> implements OnInit {
data = input<T[]>();
treeData = input<TreeData<T, C>>();
columns = input<Column<T, C>[], ArrayAttributeTransform<Column<T, C>>>([], {
transform: arrayAttribute,
});
progress = input(false, { transform: Boolean });
hasMore = input(false, { transform: booleanAttribute });
size = input(25, { transform: numberAttribute });
maxSize = input(1000, { transform: numberAttribute });
noDownload = input(false, { transform: booleanAttribute });
// Filter
@Input({ transform: booleanAttribute }) noFilter: boolean = false;
@Input({ transform: booleanAttribute }) standaloneFilter: boolean = false;
filter = model('');
filter$ = toObservable(this.filter).pipe(
map((v) => (v || '').trim()),
distinctUntilChanged(),
debounceTime(DEBOUNCE_TIME_MS),
shareReplay({ refCount: true, bufferSize: 1 }),
);
standaloneFilter = input(false, { transform: booleanAttribute });
externalFilter = input(false, { transform: booleanAttribute });
@Input() filter = '';
// TODO: filter by rendered column fields, it will be useful if you save the render in memory
@Input() filterByColumns?: string[];
@Output() filterChange = new EventEmitter<string>();
filterControl = new FormControl('');
exactFilterControl = new FormControl(1);
scores = new Map<T, { score: number }>();
filteredDataLength?: number;
filterProgress$ = new BehaviorSubject(false);
filteredSortData$ = new BehaviorSubject<DisplayedData<T, C> | null>(null);
displayedData$ = combineLatest([
defer(() => this.dataSource.data$),
this.filteredSortData$.pipe(distinctUntilChanged()),
defer(() => this.columnsDataProgress$),
]).pipe(
map(
([data, filteredSortData, columnsDataProgress]) =>
(filteredSortData && !columnsDataProgress ? filteredSortData : data) || [],
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
displayedCount$ = this.displayedData$.pipe(
map((data) => data.length),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@Input({
// Select
rowSelectable = input(false, { transform: booleanAttribute });
rowSelected = model<DisplayedData<T, C>>([]);
// Sort
sort = model<Sort>(DEFAULT_SORT);
@ViewChild(MatSort) sortComponent!: MatSort;
// Drag & drop
rowDragDrop = input(false, {
transform: (v: boolean | string[]) => {
if (Array.isArray(v)) {
return v;
}
return booleanAttribute(v);
},
})
rowDragDrop: boolean | string[] = false;
@Output() rowDropped = new EventEmitter<DragDrop<T>>();
});
rowDropped = output<DragDrop<DisplayedDataItem<T, C>>>();
dragDisabled = true;
columnsObjects = new Map<ColumnObject<T>['field'], ColumnObject<T>>([]);
update$ = new Subject<UpdateOptions>();
update = outputFromObservable(this.update$);
more = output<UpdateOptions>();
isPreload = false;
loadedLazyItems = new WeakMap<DisplayedDataItem<T, C>, boolean>();
dataSource = new MatTableDataSource<T>();
dataSource = new TableDataSource<T, C>();
normColumns = computed<NormColumn<T, C>[]>(() => this.columns().map((c) => new NormColumn(c)));
displayedNormColumns$ = toObservable(this.normColumns).pipe(
switchMap((cols) =>
combineLatest(cols.map((c) => c.hidden)).pipe(
map((c) => cols.filter((_, idx) => !c[idx])),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
columnsData$$ = combineLatest({
isTree: this.dataSource.isTreeData$,
data: this.dataSource.data$,
cols: toObservable(this.normColumns),
}).pipe(toObservableColumnsData, shareReplay({ refCount: true, bufferSize: 1 }));
columnsDataProgress$ = new BehaviorSubject(false);
columnsData$ = this.columnsData$$.pipe(
tap(() => {
this.columnsDataProgress$.next(true);
}),
toColumnsData,
tap(() => {
this.columnsDataProgress$.next(false);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
isPreload = signal(false);
loadSize = computed(() => (this.isPreload() ? this.maxSize() : this.size()));
hasAutoShowMore$ = combineLatest([
toObservable(this.hasMore),
this.dataSource.data$.pipe(map((d) => d?.length)),
this.displayedCount$,
this.dataSource.paginator.page.pipe(
startWith(null),
map(() => this.dataSource.paginator.pageSize),
),
]).pipe(
map(
([hasMore, dataCount, displayedDataCount, size]) =>
(hasMore && displayedDataCount !== 0 && displayedDataCount >= dataCount) ||
displayedDataCount > size,
),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
displayedColumns: string[] = [];
displayedColumns$ = combineLatest([
this.displayedNormColumns$,
toObservable(this.rowSelectable),
toObservable(this.sort),
toObservable(this.rowDragDrop),
]).pipe(
map(([normColumns, rowSelectable, sort, rowDragDrop]) => [
...((
Array.isArray(rowDragDrop)
? sort?.direction && rowDragDrop.includes(sort?.active)
: rowDragDrop
)
? [this.columnDefs.drag]
: []),
...(rowSelectable ? [this.columnDefs.select] : []),
...normColumns.map((c) => c.field),
]),
shareReplay({ refCount: true, bufferSize: 1 }),
);
columnDefs = COLUMN_DEFS;
scoreColumnDef = createUniqueColumnDef('score');
noRecordsColumnDef = createUniqueColumnDef('no-records');
dragColumnDef = createUniqueColumnDef('drag');
preloadedLazyCells = new Map<T, boolean>();
@ViewChild('table', { static: true }) table!: MatTable<T>;
get displayedPages() {
return this.paginator.displayedPages;
}
get currentSize() {
return this.isPreload ? this.preloadSize : this.size;
}
get hasShowMore() {
return (
this.hasMore ||
(this.filteredDataLength ?? this.data()?.length) > this.size * this.displayedPages
);
}
get isNoRecords() {
return !this.data()?.length || this.filteredDataLength === 0;
}
private paginator!: OnePageTableDataSourcePaginator;
private qp?: QueryParamsNamespace<{ filter: string; exact?: boolean }>;
@ViewChild('scrollViewport', { read: ElementRef }) scrollViewport!: ElementRef;
@ViewChild('matTable', { static: false }) table!: MatTable<T>;
@ContentChild(TableInputsComponent, { read: ElementRef }) tableInputsContent!: ElementRef;
@ViewChild(MatRow, { static: false }) tableRow!: ElementRef;
constructor(
private destroyRef: DestroyRef,
private queryParamsService: QueryParamsService,
private dr: DestroyRef,
private injector: Injector,
) {
this.updatePaginator();
}
private cdr: ChangeDetectorRef,
) {}
ngOnInit() {
const startValue = this.filterControl.value;
const filter$ = this.filterControl.valueChanges.pipe(
...((startValue ? [startWith(startValue)] : []) as []),
map((value) => (value || '').trim()),
const sort$ = toObservable(this.sort, { injector: this.injector }).pipe(
distinctUntilChanged(),
debounceTime(DEFAULT_DEBOUNCE_TIME_MS),
share(),
);
filter$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((filter) => {
this.filterChange.emit(filter);
this.qp?.patch?.({ filter });
});
const exactFilter$ = this.exactFilterControl.valueChanges.pipe(
startWith(this.exactFilterControl.value),
map(Boolean),
distinctUntilChanged(),
);
exactFilter$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((exact) => {
this.qp?.patch?.({ exact });
});
toObservable(this.data, { injector: this.injector })
.pipe(filter(Boolean), takeUntilDestroyed(this.dr))
.subscribe((data) => {
this.dataSource.setData(data);
});
toObservable(this.treeData, { injector: this.injector })
.pipe(filter(Boolean), takeUntilDestroyed(this.dr))
.subscribe((data) => {
this.dataSource.setTreeData(data);
});
combineLatest([
filter$,
exactFilter$,
toObservable(this.data, { injector: this.injector }),
this.filter$,
sort$,
this.dataSource.data$,
runInInjectionContext(this.injector, () =>
this.columnsData$.pipe(columnsDataToFilterSearchData),
),
this.dataSource.isTreeData$,
toObservable(this.normColumns, { injector: this.injector }),
toObservable(this.externalFilter, { injector: this.injector }),
])
.pipe(
tap(() => {
this.filterProgress$.next(true);
delete this.filteredDataLength;
}),
debounceTime(DEFAULT_DEBOUNCE_TIME_MS),
switchMap(([filter, exact]): Observable<[Map<T, { score: number }>, string]> => {
if (!filter || this.externalFilter() || !this.data()?.length) {
return of([new Map(), filter]);
map(([search, sort, source, data, isTreeData, columns, isExternalFilter]) => {
if (isTreeData) {
return source;
}
const cols = this.filterByColumns
? this.filterByColumns.map(
(c) => this.columnsObjects.get(c) as ColumnObject<T>,
)
: Array.from(this.columnsObjects.values());
// TODO: Refactor
return (
cols.length
? forkJoin([
forkJoin(
this.data().map((sourceValue, index) =>
combineLatest(
cols.map((colDef) =>
colDef.lazy
? of('')
: getPossiblyAsyncObservable(
select(
sourceValue,
colDef.formatter ?? colDef.field,
'',
[index, colDef] as never,
),
).pipe(catchError(() => of(''))),
),
).pipe(take(1)),
),
),
forkJoin(
this.data().map((sourceValue, index) =>
combineLatest(
cols.map((colDef) =>
colDef.description && !colDef.lazy
? getPossiblyAsyncObservable(
select(
sourceValue,
colDef.description,
'',
[index, colDef] as never,
),
).pipe(catchError(() => of('')))
: of(''),
),
).pipe(take(1)),
),
),
])
: of([[] as unknown[], [] as unknown[]])
).pipe(
map(([formattedValues, formattedDescription]) => {
const fuseData = this.data().map((item, idx) => ({
// TODO: add weights
value: JSON.stringify(item),
formattedValue: JSON.stringify(formattedValues[idx]), // TODO: split columns
formattedDescription: JSON.stringify(formattedDescription[idx]),
}));
const fuse = new Fuse(fuseData, {
keys: Object.keys(fuseData[0]),
includeScore: true,
includeMatches: true,
findAllMatches: true,
ignoreLocation: true,
threshold: exact ? 0 : 0.6,
});
const filterResult = fuse.search(filter);
return [
new Map(
filterResult.map(({ refIndex, score }) => [
this.data()[refIndex],
{ score: score ?? COMPLETE_MISMATCH_SCORE },
]),
),
filter,
];
}),
);
const filteredData =
!isExternalFilter && search ? filterData(data, search) : source;
return sortData(filteredData, data, columns, sort);
}),
takeUntilDestroyed(this.destroyRef),
takeUntilDestroyed(this.dr),
)
.subscribe(([scores, filter]) => {
this.scores = scores;
this.sortChanged(
filter && !this.externalFilter()
? { active: this.scoreColumnDef, direction: 'asc' }
: this.sortComponent.active === this.scoreColumnDef
? DEFAULT_SORT
: this.sort,
);
this.filterProgress$.next(false);
.subscribe((filtered) => {
this.updateSortFilter(filtered);
this.updateLoadedLazyItems(filtered);
this.cdr.markForCheck();
});
merge(
this.filter$.pipe(filter(Boolean)),
toObservable(this.hasMore, { injector: this.injector }).pipe(filter(Boolean)),
)
.pipe(takeUntilDestroyed(this.dr))
.subscribe(() => {
this.sort.set(DEFAULT_SORT);
});
merge(this.filter$, sort$)
.pipe(takeUntilDestroyed(this.dr))
.subscribe(() => {
this.reset();
});
// TODO: 2, 3 column is torn away from the previous one, fixed by calling update
this.dataSource.data$
.pipe(
filter((v) => !!v?.length),
first(),
delay(100),
takeUntilDestroyed(this.dr),
)
.subscribe(() => {
this.table.updateStickyColumnStyles();
});
}
ngAfterViewInit() {
this.dataSource.sort = this.sortComponent;
this.updateSort();
}
ngOnChanges(changes: ComponentChanges<TableComponent<T>>) {
if (changes.columns) {
this.updateColumns();
load() {
if (this.isPreload()) {
this.isPreload.set(false);
}
if (changes.columns || changes.rowSelectable || changes.rowDragDrop) {
this.updateDisplayedColumns();
}
if (changes.data) {
this.dataSource.data = this.data();
this.preloadedLazyCells = new Map();
}
if (this.dataSource.sort && changes.sort) {
this.updateSort();
}
if (changes.size) {
this.updatePaginator();
}
if (changes.filter) {
this.filterControl.setValue(this.filter ?? '');
}
if (changes.name && this.name) {
if (this.qp) {
this.qp.destroy();
}
this.qp = this.queryParamsService.createNamespace(this.name);
const filter = this.qp.params?.filter ?? '';
if (filter) {
this.filterControl.patchValue(filter);
}
const exact = this.qp.params?.exact ?? this.exactFilterControl.value;
if (exact !== this.exactFilterControl.value) {
this.exactFilterControl.setValue(1);
}
}
if (changes.sortOnFront || changes.data) {
this.tryFrontSort();
}
}
ngOnDestroy() {
this.qp?.destroy?.();
}
updateColumns(columns: ColumnObject<T>[] = createColumnsObjects(this.columns)) {
this.columnsObjects = new Map((columns || []).map((c) => [c.field, c]));
}
load(isPreload = false) {
if (this.isPreload !== isPreload) {
this.isPreload = isPreload;
}
this.update.emit({ size: this.currentSize });
this.paginator.reload();
this.reload();
}
preload() {
if (this.isPreload && this.hasMore) {
this.more.emit({ size: this.currentSize });
return;
if (!this.isPreload()) {
this.isPreload.set(true);
this.reload();
} else if (this.hasMore()) {
this.more.emit({ size: this.loadSize() });
}
this.load(true);
}
showMore() {
this.paginator.more();
if (this.hasMore && this.displayedPages * this.size > this.data()?.length) {
this.more.emit({ size: this.currentSize });
this.dataSource.paginator.more();
if (this.hasMore() && this.dataSource.paginator.pageSize > this.dataSource.data.length) {
this.more.emit({ size: this.loadSize() });
}
this.refreshTable();
}
sortChanged(sort: Sort) {
this.sortChange.emit(sort);
this.tryFrontSort(sort);
this.updateDisplayedColumns();
downloadCsv() {
this.generateCsvData()
.pipe(takeUntilDestroyed(this.dr))
.subscribe((csvData) => {
downloadFile(csvData, 'csv');
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
drop(event: CdkDragDrop<any>) {
this.dragDisabled = true;
const item = event.item.data;
const { previousIndex, currentIndex } = event;
const previousData = this.dataSource.sortData(this.data(), this.sortComponent);
const currentData = previousData.slice();
let currentDataIndex = 0;
if (previousIndex > currentIndex) {
currentData.splice(previousIndex, 1);
currentData.splice(currentIndex, 0, event.item.data);
currentDataIndex = currentIndex;
} else {
currentData.splice(currentIndex, 0, event.item.data);
currentData.splice(previousIndex, 1);
currentDataIndex = currentIndex - 1;
}
this.rowDropped.emit({
previousIndex,
currentIndex,
item,
previousData,
currentData,
currentDataIndex,
sort: this?.sortComponent,
this.filteredSortData$.pipe(filter(Boolean), first()).subscribe((data) => {
const item = event.item.data;
const { previousIndex, currentIndex } = event;
const previousData = data;
const currentData = previousData.slice();
let currentDataIndex = 0;
if (previousIndex > currentIndex) {
currentData.splice(previousIndex, 1);
currentData.splice(currentIndex, 0, event.item.data);
currentDataIndex = currentIndex;
} else {
currentData.splice(currentIndex, 0, event.item.data);
currentData.splice(previousIndex, 1);
currentDataIndex = currentIndex - 1;
}
this.rowDropped.emit({
previousIndex,
currentIndex,
item,
previousData,
currentData,
currentDataIndex,
sort: this?.sortComponent,
});
});
}
private tryFrontSort({ active, direction }: Partial<Sort> = this.sortComponent || {}) {
const data = this.data();
if (!data?.length || !active || !direction) {
this.updateDataSourceSort();
return;
}
if (active === this.scoreColumnDef && this.filterControl.value) {
let sortedData = data
.filter(
(data) =>
(this.scores.get(data)?.score ?? COMPLETE_MISMATCH_SCORE) <
COMPLETE_MISMATCH_SCORE,
)
.sort(
(a, b) =>
(this.scores.get(a)?.score ?? COMPLETE_MISMATCH_SCORE) -
(this.scores.get(b)?.score ?? COMPLETE_MISMATCH_SCORE),
);
if (direction === 'desc') {
sortedData = sortedData.reverse();
}
this.filteredDataLength = sortedData.length;
this.updateDataSourceSort(sortedData);
return;
}
if (!this.sortOnFront) {
this.updateDataSourceSort();
return;
}
if (this.filterControl.value) {
this.filterControl.setValue('');
}
const colDef = this.columnsObjects.get(active);
if (!colDef) {
this.updateDataSourceSort();
return;
}
combineLatest(
data.map((sourceValue, index) =>
getPossiblyAsyncObservable(
select(sourceValue, colDef.formatter ?? colDef.field, '', [
index,
colDef,
] as never),
).pipe(map((value) => ({ value, sourceValue }))),
private generateCsvData(): Observable<string> {
return combineLatest([
this.displayedNormColumns$.pipe(
switchMap((cols) => forkJoin(cols.map((c) => c.header.pipe(take(1))))),
),
)
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe((loadedData) => {
let sortedData = loadedData
.sort((a, b) => compareDifferentTypes(a.value, b.value))
.map((v) => v.sourceValue);
if (direction === 'desc') {
sortedData = sortedData.reverse();
}
this.updateDataSourceSort(sortedData);
});
this.columnsData$.pipe(take(1)),
]).pipe(
map(([cols, data]) =>
createCsv(runInInjectionContext(this.injector, () => tableToCsvObject(cols, data))),
),
);
}
private updateDataSourceSort(sortedData?: T[]) {
this.dataSource.sortData = sortedData ? () => sortedData : sortDataByDefault;
// TODO: hack for update
private reload() {
this.update$.next({ size: this.loadSize() });
this.reset();
}
private updateSortFilter(filtered: DisplayedData<T, C>) {
this.filteredSortData$.next(filtered);
this.dataSource.sortData = () => filtered;
this.dataSource.sort = this.sortComponent;
}
private updatePaginator() {
this.paginator = new OnePageTableDataSourcePaginator(this.size);
this.dataSource.paginator = this.paginator as never;
private reset() {
(this.scrollViewport?.nativeElement as HTMLElement)?.scrollTo?.({ top: 0 });
this.dataSource.paginator.reload();
this.refreshTable();
}
private updateSort() {
this.sortComponent.active = this.sort.active;
this.sortComponent.direction = this.sort.direction;
this.tryFrontSort();
// TODO: Refresh table when pagination is updated
private refreshTable() {
// eslint-disable-next-line no-self-assign
this.dataSource.data = this.dataSource.data;
}
private updateDisplayedColumns() {
this.displayedColumns = [
...((
Array.isArray(this.rowDragDrop)
? (this.sortComponent?.direction ?? this.sort?.direction) &&
this.rowDragDrop.includes(this.sortComponent?.active ?? this.sort?.active)
: this.rowDragDrop
)
? [this.dragColumnDef]
: []),
this.scoreColumnDef,
...(this.rowSelectable ? [this.selectColumnDef] : []),
...Array.from(this.columnsObjects.values())
.filter((c) => !c.hide)
.map((c) => c.field),
];
private updateLoadedLazyItems(items: DisplayedData<T, C>) {
const lazyLoadedItems = items.slice(0, DEFAULT_LOADED_LAZY_ROWS_COUNT);
for (const item of lazyLoadedItems) {
this.loadedLazyItems.set(item, true);
}
}
}

View File

@ -1,71 +1,11 @@
import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { MtxTooltip } from '@ng-matero/extensions/tooltip';
import { PipesModule } from '../../pipes';
import { ActionsModule } from '../actions';
import { InputFieldModule } from '../input-field';
import { SwitchButtonModule } from '../switch-button';
import { TagModule } from '../tag';
import { ScoreColumnComponent } from './components/score-column.component';
import { SelectColumnComponent } from './components/select-column.component';
import { ShowMoreButtonComponent } from './components/show-more-button/show-more-button.component';
import { TableActionsComponent } from './components/table-actions.component';
import { TableCellComponent } from './components/table-cell/table-cell.component';
import { TableInputsComponent } from './components/table-inputs.component';
import { Table2Component } from './components/table2';
import { TableComponent } from './table.component';
@NgModule({
imports: [
CommonModule,
MatTableModule,
MatCardModule,
MatProgressSpinnerModule,
MatInputModule,
MatSelectModule,
MatIconModule,
MatMenuModule,
MatButtonModule,
ActionsModule,
MatTooltipModule,
MatChipsModule,
PipesModule,
TagModule,
RouterLink,
MatProgressBarModule,
MatCheckboxModule,
MatSortModule,
InputFieldModule,
ReactiveFormsModule,
SwitchButtonModule,
CdkDrag,
CdkDropList,
SelectColumnComponent,
ScoreColumnComponent,
MtxTooltip,
Table2Component,
ShowMoreButtonComponent,
TableInputsComponent,
],
declarations: [TableComponent, TableActionsComponent, TableCellComponent],
exports: [TableComponent, TableActionsComponent, TableInputsComponent, Table2Component],
imports: [TableActionsComponent, TableComponent, TableInputsComponent],
exports: [TableActionsComponent, TableInputsComponent, TableComponent],
})
export class TableModule {}

View File

@ -1,45 +0,0 @@
import { TemplateRef } from '@angular/core';
import { Observable } from 'rxjs';
import { SelectFn } from '../../../utils';
import { ColumnObject } from './column';
export type ColumnPinValue = 'left' | 'right' | null;
export type FormatterFn<TObject extends object, TResult = unknown> = SelectFn<
TObject,
TResult,
[index: number, colDef: ColumnObject<TObject>]
>;
export type ColumnFn<TObject extends object, TResult = unknown> = (
rowData: TObject,
index: number,
) => TResult;
export interface BaseColumn<T extends object> {
field: string;
header?: string | Observable<string>;
hide?: boolean;
pinned?: ColumnPinValue;
width?: string;
minWidth?: string;
maxWidth?: string;
sortable?: boolean | string;
cellTemplate?: TemplateRef<unknown>;
formatter?: FormatterFn<T>;
description?: FormatterFn<T>;
tooltip?: FormatterFn<T>;
link?: ColumnFn<T, string>;
linkParameters?: { target?: '_blank' };
click?: ColumnFn<T, void>;
lazy?: boolean;
// TODO: Need to delete
type?: void;
}

View File

@ -1,5 +0,0 @@
import { BaseColumn } from './base-column';
import { TypedColumns } from './typed-column';
export type ColumnObject<T extends object> = BaseColumn<T> | TypedColumns<T>;
export type Column<T extends object> = ColumnObject<T> | string;

View File

@ -19,7 +19,7 @@ interface ColumnParams {
export type CellValue = 'string' | Value;
export interface Column2<T extends object, C extends object = object> extends ColumnParams {
export interface Column<T extends object, C extends object = object> extends ColumnParams {
field: string;
header?: PossiblyAsync<Partial<Value> | string>;
@ -38,7 +38,7 @@ export function normalizePossiblyFn<R, P extends Array<unknown>>(fn: PossiblyFn<
export function normalizeCell<T extends object, C extends object>(
field: string,
cell: Column2<T, C>['cell'],
cell: Column<T, C>['cell'],
hasChild: boolean = false,
): Fn<Observable<Value>, CellFnArgs<T>> {
const cellFn = normalizePossiblyFn(cell);
@ -65,7 +65,7 @@ export class NormColumn<T extends object, C extends object = object> {
params!: ColumnParams;
constructor(
{ field, header, cell, child, hidden, sort, lazyCell, ...params }: Column2<T, C>,
{ field, header, cell, child, hidden, sort, lazyCell, ...params }: Column<T, C>,
commonParams: ColumnParams = {},
) {
this.field = field ?? (typeof header === 'string' ? header : Math.random());

View File

@ -1,6 +1,3 @@
export * from './base-column';
export * from './typed-column';
export * from './column';
export * from './column2';
export * from './update-options';
export * from './drag-drop';

View File

@ -1,44 +0,0 @@
import { OmitByValueExact } from 'utility-types';
import { Color } from '../../../styles';
import { BaseColumn, ColumnFn, FormatterFn } from './base-column';
export type TypedColumn<
T extends object,
TType extends string,
TTypeParameters extends object = never,
> = Omit<BaseColumn<T>, 'type'> &
OmitByValueExact<{ type: TType; typeParameters: TTypeParameters }, never>;
export type MenuColumn<T extends object> = TypedColumn<
T,
'menu',
{
items: {
label: string | ColumnFn<T, string>;
click: ColumnFn<T, void>;
disabled?: ColumnFn<T, boolean>;
}[];
}
>;
export type TagColumn<T extends object, TTag extends PropertyKey = PropertyKey> = TypedColumn<
T,
'tag',
{
label?: FormatterFn<T>;
tags: Record<TTag, { label?: string; color?: Color }>;
}
>;
export type CurrencyColumn<T extends object> = TypedColumn<
T,
'currency',
{ currencyCode: FormatterFn<T>; isMinor?: boolean; exponent?: FormatterFn<T> }
>;
export type TypedColumns<T extends object> =
| TypedColumn<T, 'datetime'>
| CurrencyColumn<T>
| TagColumn<T>
| MenuColumn<T>
| TypedColumn<T, 'boolean'>;

View File

@ -4,18 +4,18 @@ import { map } from 'rxjs/operators';
import { PossiblyAsync, getPossiblyAsyncObservable } from '../../../utils';
import { Value } from '../../value';
import { Column2, CellFnArgs, normalizeCell } from '../types';
import { Column, CellFnArgs, normalizeCell } from '../types';
import { createUniqueColumnDef } from './create-unique-column-def';
export function createColumn<P, A extends object>(
createCell: (cellParams: P, ...args: CellFnArgs<A>) => PossiblyAsync<Value>,
columnObject: Partial<Column2<A>> = {},
columnObject: Partial<Column<A>> = {},
) {
return <T extends A>(
getCellParams: (...args: CellFnArgs<T>) => PossiblyAsync<P>,
{ isLazyCell, ...column }: Partial<Column2<T>> & { isLazyCell?: boolean } = {},
): Column2<T> => {
{ isLazyCell, ...column }: Partial<Column<T>> & { isLazyCell?: boolean } = {},
): Column<T> => {
const injector = inject(Injector);
const field = column?.field ?? createUniqueColumnDef(column?.header);
const cellKey = isLazyCell ? 'lazyCell' : 'cell';

View File

@ -1,17 +0,0 @@
import isNil from 'lodash-es/isNil';
import isObject from 'lodash-es/isObject';
import startCase from 'lodash-es/startCase';
import { Column, ColumnObject } from '../types';
export function createColumnObject<T extends object>(col: Column<T>): ColumnObject<T> {
const extCol: ColumnObject<T> = isObject(col) ? col : { field: col };
if (isNil(extCol.header)) {
extCol.header = startCase(String(extCol.field.split('.').at(-1)));
}
return extCol;
}
export function createColumnsObjects<T extends object>(columns: Column<T>[]): ColumnObject<T>[] {
return columns?.map((col) => createColumnObject(col)) || [];
}

View File

@ -3,12 +3,12 @@ import { Sort } from '@angular/material/sort';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { compareDifferentTypes } from '../../../../../utils';
import { valueToString } from '../../../../value/utils/value-to-string';
import { NormColumn } from '../../../types';
import { normalizeString } from '../../../utils/normalize-string';
import { compareDifferentTypes } from '../../../utils';
import { valueToString } from '../../value/utils/value-to-string';
import { NormColumn } from '../types';
import { DisplayedData, DisplayedDataItem, ColumnData } from '../utils/to-columns-data';
import { DisplayedData, DisplayedDataItem, ColumnData } from './to-columns-data';
import { normalizeString } from './normalize-string';
export type FilterSearchData<T extends object, C extends object> = Map<
DisplayedDataItem<T, C>,

View File

@ -1,50 +0,0 @@
import { EventEmitter } from '@angular/core';
import { PageEvent, MatPaginator } from '@angular/material/paginator';
import { BehaviorSubject } from 'rxjs';
export class OnePageTableDataSourcePaginator implements Partial<MatPaginator> {
pageIndex = 0;
pageSize = 0;
page = new EventEmitter<PageEvent>();
initialized = new BehaviorSubject(undefined);
get length() {
return this.pageSize;
}
set length(v: number) {}
get displayedPages() {
return this.pageSize / this.partSize;
}
private partSize!: number;
constructor(partSize?: number) {
this.setSize(partSize);
}
setSize(partSize = 25) {
if (partSize !== this.partSize) {
this.pageSize = this.partSize = partSize;
this.reload();
}
}
reload() {
this.pageSize = this.partSize;
this.update();
}
more() {
this.pageSize += this.partSize;
this.update();
}
private update() {
this.page.next({
pageIndex: this.pageIndex,
pageSize: this.pageSize,
length: this.length,
});
}
}

View File

@ -10,7 +10,7 @@ import {
TreeData,
TreeInlineDataItem,
treeDataItemToInlineDataItem,
} from '../components/table2/tree-data';
} from '../tree-data';
import { cachedHeadMap } from './cached-head-map';

View File

@ -1,7 +1,7 @@
import { Value } from '../../value';
import { unknownToString } from '../../value/utils/unknown-to-string';
import { valueToString } from '../../value/utils/value-to-string';
import { DisplayedDataItem, ColumnData } from '../components/table2/utils/to-columns-data';
import { DisplayedDataItem, ColumnData } from '../utils/to-columns-data';
export function tableToCsvObject<T extends object, C extends object>(
cols: Value[],

View File

@ -1,10 +1,11 @@
import { isEqual } from 'lodash-es';
import { Observable, scan, of, switchMap, combineLatest, timer } from 'rxjs';
import { shareReplay, map } from 'rxjs/operators';
import { shareReplay, map, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { Overwrite } from 'utility-types';
import { Value } from '../../../../value';
import { CellFnArgs, Fn, NormColumn } from '../../../types';
import { Value } from '../../value';
import { TreeInlineDataItem, TreeInlineData } from '../tree-data';
import { CellFnArgs, Fn, NormColumn } from '../types';
export type DisplayedDataItem<T extends object, C extends object> = TreeInlineDataItem<T, C> | T;
export type DisplayedData<T extends object, C extends object> = TreeInlineData<T, C> | T[];
@ -119,11 +120,15 @@ export function toColumnsData<T extends object, C extends object>(
Array.from(columnsData.values()).map((v) =>
combineLatest(
Array.from(v.values()).map((cell) =>
timer(0).pipe(switchMap(() => cell.value)),
timer(0).pipe(
switchMap(() => cell.value),
distinctUntilChanged(isEqual),
),
),
),
).pipe(debounceTime(0)),
),
).pipe(
debounceTime(0),
map(
(res) =>
new Map(