Add lazy column for table2 (#78)

This commit is contained in:
Rinat Arsaev 2024-10-18 22:07:43 +09:00 committed by GitHub
parent 2b0d028e67
commit 784928f164
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 374 additions and 300 deletions

View File

@ -5,6 +5,7 @@
- [Prettier Config](/projects/prettier-config)
- [ESLint Config](/projects/eslint-config)
- [CSpell Config](/projects/cspell-config)
- [Docs](/projects/ng-libs-doc)
## 💻 Development with locally built/runnable library

View File

@ -12,7 +12,7 @@ import { getHintText } from '../utils/get-hint-text';
styleUrls: ['./select-field.component.scss'],
providers: createControlProviders(() => SelectFieldComponent),
})
export class SelectFieldComponent<T> extends FormControlSuperclass<T[]> {
export class SelectFieldComponent<T = unknown> extends FormControlSuperclass<T[]> {
@Input() options: Option<T>[] = [];
@Output() searchChange = new EventEmitter<string>();

View File

@ -17,8 +17,7 @@ import { createIntersectionObserver } from '../utils/create-intersection-observe
standalone: true,
})
export class InfinityScrollDirective implements OnInit {
vInfinityScroll = input(true, { transform: booleanAttribute });
vInfinityScrollProgress = input(false, { transform: booleanAttribute });
vInfinityScroll = input(false, { transform: booleanAttribute });
vInfinityScrollMore = output();
constructor(
@ -29,7 +28,7 @@ export class InfinityScrollDirective implements OnInit {
ngOnInit() {
createIntersectionObserver(this.elementRef.nativeElement)
.pipe(
filter(() => !this.vInfinityScrollProgress()),
filter(() => this.vInfinityScroll()),
takeUntilDestroyed(this.dr),
)
.subscribe(() => {

View File

@ -8,8 +8,8 @@ import {
OnInit,
OnDestroy,
OnChanges,
output,
ChangeDetectionStrategy,
model,
} from '@angular/core';
import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatCheckboxModule } from '@angular/material/checkbox';
@ -17,7 +17,7 @@ import { MatTableModule } from '@angular/material/table';
import { combineLatest } from 'rxjs';
import { startWith, map, shareReplay } from 'rxjs/operators';
import { ComponentChanges } from '../../../utils';
import { arrayAttribute, ArrayAttributeTransform, ComponentChanges } from '../../../utils';
import { BaseColumnComponent } from './base-column.component';
@ -30,7 +30,9 @@ import { BaseColumnComponent } from './base-column.component';
<th *matHeaderCellDef [class]="columnClasses" mat-header-cell>
<mat-checkbox
[checked]="selection.hasValue() && (isAllSelected$ | async)"
[disabled]="!!progress() || (filtered() && !(isAllSelected$ | async))"
[disabled]="
!!progress() || !data()?.length || (filtered() && !(isAllSelected$ | async))
"
[indeterminate]="selection.hasValue() && !(isAllSelected$ | async)"
(change)="$event ? toggleAllRows() : null"
>
@ -66,10 +68,10 @@ export class SelectColumnComponent<T>
extends BaseColumnComponent
implements OnInit, OnDestroy, OnChanges
{
selected = input<T[]>([]);
selectedChange = output<T[]>();
data = input<T[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selected = model<any[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data = input<T[], ArrayAttributeTransform<any>>([], { transform: arrayAttribute });
progress = input<boolean | number | null | undefined>(false);
/**
* @deprecated
@ -92,7 +94,7 @@ export class SelectColumnComponent<T>
override ngOnInit() {
super.ngOnInit();
this.selection.changed.pipe(takeUntilDestroyed(this.dr)).subscribe(() => {
this.selectedChange.emit(this.selection.selected);
this.selected.set(this.selection.selected);
});
}

View File

@ -2,21 +2,20 @@
<div class="wrapper">
<v-table-info-bar
[(filter)]="filter"
[count]="(dataSource.data$ | async)?.length"
[dataProgress]="!!(columnsDataProgress$ | async) && !hasMore()"
[filter]="(filter$ | async) ?? ''"
[filteredCount]="count$ | async"
[filteredCount]="displayedCount$ | async"
[hasInputs]="!!tableInputsContent?.nativeElement"
[hasMore]="hasMore()"
[isPreload]="isPreload()"
[noDownload]="noDownload()"
[preloadSize]="maxSize()"
[progress]="progress()"
[selectedCount]="(selected$ | async)?.length"
[selectedCount]="rowSelected()?.length"
[size]="size()"
[standaloneFilter]="standaloneFilter()"
(downloadCsv)="downloadCsv()"
(filterChange)="filter$.next($event)"
(load)="load()"
(preload)="preload()"
>
@ -24,7 +23,10 @@
<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]="!(count$ | async)" [progress]="progress()"></v-no-records>
<v-no-records
[noRecords]="!(displayedCount$ | async)"
[progress]="progress()"
></v-no-records>
<table
#matTable
[dataSource]="dataSource"
@ -32,15 +34,14 @@
[matSortDirection]="sort().direction"
mat-table
matSort
(matSortChange)="this.sortChange.emit($event)"
(matSortChange)="sort.set($event)"
>
@if (rowSelectable()) {
<v-select-column
[data]="$any(displayedData$ | async) ?? []"
[(selected)]="rowSelected"
[data]="displayedData$ | async"
[name]="columnDefs.select"
[progress]="progress()"
[selected]="$any(selected$ | async) ?? []"
(selectedChange)="rowSelectedChange.emit($event)"
></v-select-column>
}
@for (col of displayedNormColumns$ | async; track col; let colIndex = $index) {
@ -59,7 +60,7 @@
>
<th
*matHeaderCellDef
[disabled]="(col.sort | async) === false || hasMore()"
[disabled]="(col.sort | async) === false || hasMore() || progress()"
[mat-sort-header]="col.field"
[ngClass]="columnClasses"
mat-header-cell
@ -68,7 +69,7 @@
</th>
<ng-template let-element let-rowIndex="index" matCellDef>
@let cell = columnsData?.get?.(element)?.[colIndex];
@let cell = columnsData?.get?.(element)?.get(col);
<td
[ngClass]="columnClasses"
[ngStyle]="col?.params?.style"
@ -78,8 +79,11 @@
<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>
@ -94,9 +98,12 @@
<tr
*matFooterRowDef="(hasAutoShowMore$ | async) ? (displayedColumns$ | async) : []"
[ngClass]="{ row__hidden: !(hasAutoShowMore$ | async) }"
[vInfinityScrollProgress]="progress()"
[vInfinityScroll]="
!progress() &&
!(columnsDataProgress$ | async) &&
(filteredSortData$ | async)?.length
"
mat-row
vInfinityScroll
(vInfinityScrollMore)="showMore()"
></tr>
</table>

View File

@ -1,5 +1,5 @@
:host {
min-height: 300px;
min-height: 0;
}
.wrapper {
@ -11,6 +11,7 @@
.card {
width: 100%;
height: 100%;
min-height: 300px;
overflow: auto;
transform: translateZ(0);
@ -46,6 +47,10 @@
}
}
::ng-deep .mat-mdc-footer-cell {
border-bottom: 1px solid;
}
.row__hidden {
display: none;
}

View File

@ -15,6 +15,8 @@ import {
OnInit,
ViewChild,
ContentChild,
model,
ChangeDetectorRef,
} from '@angular/core';
import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton, MatButton } from '@angular/material/button';
@ -31,20 +33,32 @@ import {
forkJoin,
BehaviorSubject,
debounceTime,
share,
first,
merge,
tap,
defer,
} from 'rxjs';
import { shareReplay, map, distinctUntilChanged, delay, filter, startWith } from 'rxjs/operators';
import {
shareReplay,
map,
distinctUntilChanged,
delay,
filter,
startWith,
share,
} from 'rxjs/operators';
import { downloadFile, createCsv } from '../../../../utils';
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 { modelToSubject } from '../../utils/model-to-subject';
import { TableDataSource } from '../../utils/table-data-source';
import { tableToCsvObject } from '../../utils/table-to-csv-object';
import { InfinityScrollDirective } from '../infinity-scroll.directive';
@ -56,17 +70,18 @@ import { TableInputsComponent } from '../table-inputs.component';
import { TableProgressBarComponent } from '../table-progress-bar.component';
import { COLUMN_DEFS } from './consts';
import { TreeData, TreeInlineData } from './tree-data';
import { filterSort, columnsDataToFilterSearchData } from './utils/filter-sort';
import { toObservableColumnsData, toColumnsData } from './utils/to-columns-data';
import { TreeData } from './tree-data';
import { columnsDataToFilterSearchData, filterData, sortData } from './utils/filter-sort';
import {
toObservableColumnsData,
toColumnsData,
DisplayedDataItem,
DisplayedData,
} from './utils/to-columns-data';
export const TABLE_WRAPPER_STYLE = `
display: block;
overflow: auto;
padding: 8px;
margin: -8px;
height: 100%;
`;
const SHORT_DEBOUNCE_TIME_MS = 300;
const DEBOUNCE_TIME_MS = 500;
const DEFAULT_LOADED_LAZY_ROWS_COUNT = 3;
@Component({
standalone: true,
@ -95,12 +110,13 @@ export const TABLE_WRAPPER_STYLE = `
TableInputsComponent,
MatButton,
],
host: { style: TABLE_WRAPPER_STYLE },
})
export class Table2Component<T extends object, C extends object> implements OnInit {
data = input<T[]>();
treeData = input<TreeData<T, C>>();
columns = input<Column2<T>[]>([]);
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 });
@ -108,29 +124,48 @@ export class Table2Component<T extends object, C extends object> implements OnIn
noDownload = input(false, { transform: booleanAttribute });
// Filter
filter = input('', { transform: (v: string | null | undefined) => (v || '').trim() });
filterChange = output<string>();
filter$ = modelToSubject(this.filter, this.filterChange);
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 });
displayedData$ = new BehaviorSubject<T[] | TreeInlineData<T, C>>([]);
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 = input<T[] | TreeInlineData<T, C>>([]);
rowSelectedChange = output<T[] | TreeInlineData<T, C>>();
selected$ = modelToSubject(this.rowSelected, this.rowSelectedChange);
rowSelected = model<DisplayedData<T, C>>([]);
// Sort
sort = input<Sort>(DEFAULT_SORT);
sortChange = output<Sort>();
sort$ = modelToSubject(this.sort, this.sortChange);
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>[]>(() => this.columns().map((c) => new NormColumn(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(
@ -157,30 +192,21 @@ export class Table2Component<T extends object, C extends object> implements OnIn
);
isPreload = signal(false);
loadSize = computed(() => (this.isPreload() ? this.maxSize() : this.size()));
count$ = combineLatest([
this.filter$,
this.displayedData$,
this.dataSource.data$,
this.columnsDataProgress$,
]).pipe(
map(([filter, filtered, source, columnsDataProgress]) =>
filter && !columnsDataProgress ? filtered?.length : source?.length,
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
hasAutoShowMore$ = combineLatest([
toObservable(this.hasMore),
this.dataSource.data$,
this.displayedData$,
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, data, filteredData, size]) =>
(hasMore && filteredData.length === data.length) || filteredData.length > size,
([hasMore, dataCount, displayedDataCount, size]) =>
(hasMore && displayedDataCount !== 0 && displayedDataCount >= dataCount) ||
displayedDataCount > size,
),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@ -202,9 +228,15 @@ export class Table2Component<T extends object, C extends object> implements OnIn
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) => {
@ -215,43 +247,47 @@ export class Table2Component<T extends object, C extends object> implements OnIn
.subscribe((data) => {
this.dataSource.setTreeData(data);
});
const filter$ = this.filter$.pipe(
map((filter) => filter?.trim?.() ?? ''),
distinctUntilChanged(),
debounceTime(500),
share(),
);
toObservable(this.filter, { injector: this.injector })
.pipe(takeUntilDestroyed(this.dr))
.subscribe((filter) => {
this.filter$.next(filter);
});
filter$.pipe(takeUntilDestroyed(this.dr)).subscribe((filter) => {
this.filterChange.emit(filter);
});
combineLatest([
filter$,
this.sort$,
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(
map(([search, sort, source, data, isTreeData, columns]) =>
filterSort({ search, sort, source, data, isTreeData, columns }),
),
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();
});
filter$.pipe(filter(Boolean), takeUntilDestroyed(this.dr)).subscribe(() => {
this.sortChange.emit(DEFAULT_SORT);
});
merge(filter$, this.sort$)
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();
@ -319,8 +355,8 @@ export class Table2Component<T extends object, C extends object> implements OnIn
this.reset();
}
private updateSortFilter(filtered: TreeInlineData<T, C> | T[]) {
this.displayedData$.next(filtered);
private updateSortFilter(filtered: DisplayedData<T, C>) {
this.filteredSortData$.next(filtered);
this.dataSource.sortData = filtered ? () => filtered : sortDataByDefault;
this.dataSource.sort = this.sortComponent;
}
@ -336,4 +372,11 @@ export class Table2Component<T extends object, C extends object> implements OnIn
// 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

@ -56,7 +56,7 @@ function getWeight(
return 0;
}
function filterData<T extends object, C extends object>(
export function filterData<T extends object, C extends object>(
data: FilterSearchData<T, C>,
search: string,
) {
@ -80,7 +80,7 @@ function filterData<T extends object, C extends object>(
.map((v) => v.value);
}
function sortData<T extends object, C extends object>(
export function sortData<T extends object, C extends object>(
source: DisplayedData<T, C>,
data: FilterSearchData<T, C>,
columns: NormColumn<T, C>[],
@ -90,33 +90,13 @@ function sortData<T extends object, C extends object>(
return source;
}
const colIdx = columns.findIndex((c) => c.field === sort.active);
const sortedData = source.sort((a, b) =>
compareDifferentTypes(
(data.get(a)?.byColumns ?? [])[colIdx]?.[0],
(data.get(b)?.byColumns ?? [])[colIdx]?.[0],
),
);
const sortedData = source
.slice()
.sort((a, b) =>
compareDifferentTypes(
(data.get(a)?.byColumns ?? [])[colIdx]?.[0],
(data.get(b)?.byColumns ?? [])[colIdx]?.[0],
),
);
return sort.direction === 'desc' ? sortedData.reverse() : sortedData;
}
export function filterSort<T extends object, C extends object>({
search,
sort,
source,
data,
isTreeData,
columns,
}: {
search: string;
sort: Sort;
source: DisplayedData<T, C>;
data: FilterSearchData<T, C>;
isTreeData: boolean;
columns: NormColumn<T, C>[];
}) {
if (isTreeData) {
return source;
}
const filteredData = search ? filterData(data, search) : source.slice();
return sortData(filteredData, data, columns, sort);
}

View File

@ -3,7 +3,7 @@ import { shareReplay, map } from 'rxjs/operators';
import { Overwrite } from 'utility-types';
import { Value } from '../../../../value';
import { NormColumn } from '../../../types';
import { CellFnArgs, Fn, NormColumn } from '../../../types';
import { TreeInlineDataItem, TreeInlineData } from '../tree-data';
export type DisplayedDataItem<T extends object, C extends object> = TreeInlineDataItem<T, C> | T;
@ -18,7 +18,18 @@ export type ColumnDataItem = {
export type ColumnData = ColumnDataItem[];
type ScanColumnDataItem = Overwrite<ColumnDataItem, { value: Observable<Value | null> }>;
type ScanColumnData = ScanColumnDataItem[];
type ScanColumnData<T extends object, C extends object> = Map<NormColumn<T, C>, ScanColumnDataItem>;
type ColumnDataMap<T extends object, C extends object> = Map<
DisplayedDataItem<T, C>,
ScanColumnData<T, C>
>;
function getValue<T extends object>(
cellFn: Fn<Observable<Value>, CellFnArgs<T>> | undefined,
...[value, idx]: CellFnArgs<T>
) {
return cellFn ? cellFn(value, idx).pipe(toScannedValue) : of(null);
}
function toScannedValue(src$: Observable<Value>) {
return src$.pipe(
@ -30,14 +41,14 @@ function toScannedValue(src$: Observable<Value>) {
}
export function toObservableColumnsData<T extends object, C extends object>(
src$: Observable<{ isTree: boolean; data: DisplayedData<T, C>; cols: NormColumn<T>[] }>,
): Observable<Map<DisplayedDataItem<T, C>, ScanColumnData>> {
src$: Observable<{ isTree: boolean; data: DisplayedData<T, C>; cols: NormColumn<T, C>[] }>,
): Observable<ColumnDataMap<T, C>> {
return src$.pipe(
scan(
(acc, { isTree, data, cols }) => {
const isColsNotChanged = acc.cols === cols;
return {
res: new Map<TreeInlineDataItem<T, C> | T, ScanColumnData>(
res: new Map<TreeInlineDataItem<T, C> | T, ScanColumnData<T, C>>(
isTree
? (data as TreeInlineData<T, C>).map((d, idx) => [
d,
@ -45,26 +56,41 @@ export function toObservableColumnsData<T extends object, C extends object>(
d === acc.data[idx] &&
// This is not the last value, because we need to calculate isNextChild
idx !== acc.data.length - 1
? (acc.res.get(d) as ScanColumnData)
: cols.map((c) => ({
value: (d.child && c.child
? c.child(d.child, idx)
: d.value
? c.cell(d.value, idx)
: of<Value>({ value: '' })
).pipe(toScannedValue),
isChild: !d.value,
isNextChild: !(data as TreeInlineData<T, C>)[idx + 1]
?.value,
})),
? (acc.res.get(d) as ScanColumnData<T, C>)
: new Map(
cols.map((c) => [
c,
{
value:
d.child && c.child
? getValue(c.child, d.child, idx)
: d.value
? getValue(c.cell, d.value, idx)
: of({ value: '' }),
// TODO add support of lazyValue
isChild: !d.value,
isNextChild: !(data as TreeInlineData<T, C>)[
idx + 1
]?.value,
},
]),
),
])
: (data as T[]).map((d, idx) => [
d,
isColsNotChanged && d === acc.data[idx]
? (acc.res.get(d) as ScanColumnData)
: cols.map((c) => ({
value: c.cell(d, idx).pipe(toScannedValue),
})),
? (acc.res.get(d) as ScanColumnData<T, C>)
: new Map(
cols.map((c) => [
c,
{
value: getValue(c.cell, d, idx),
lazyValue: c.lazyCell
? c.lazyCell(d, idx).pipe(toScannedValue)
: undefined,
},
]),
),
]),
),
data,
@ -73,8 +99,8 @@ export function toObservableColumnsData<T extends object, C extends object>(
},
{ data: [], cols: [], res: new Map() } as {
data: DisplayedData<T, C>;
cols: NormColumn<T>[];
res: Map<DisplayedDataItem<T, C>, ScanColumnData>;
cols: NormColumn<T, C>[];
res: ColumnDataMap<T, C>;
},
),
map(({ res }) => res),
@ -82,7 +108,7 @@ export function toObservableColumnsData<T extends object, C extends object>(
}
export function toColumnsData<T extends object, C extends object>(
src$: Observable<Map<DisplayedDataItem<T, C>, ScanColumnData>>,
src$: Observable<ColumnDataMap<T, C>>,
): Observable<Map<DisplayedDataItem<T, C>, ColumnData>> {
return src$.pipe(
switchMap((columnsData) => {
@ -91,7 +117,11 @@ export function toColumnsData<T extends object, C extends object>(
}
return combineLatest(
Array.from(columnsData.values()).map((v) =>
combineLatest(v.map((cell) => timer(0).pipe(switchMap(() => cell.value)))),
combineLatest(
Array.from(v.values()).map((cell) =>
timer(0).pipe(switchMap(() => cell.value)),
),
),
),
).pipe(
map(
@ -99,7 +129,10 @@ export function toColumnsData<T extends object, C extends object>(
new Map(
Array.from(columnsData.entries()).map(([k, v], idx) => [
k,
v.map((cell, colIdx) => ({ ...cell, value: res[idx][colIdx] })),
Array.from(v.values()).map((cell, colIdx) => ({
...cell,
value: res[idx][colIdx],
})),
]),
),
),

View File

@ -4,11 +4,11 @@ import startCase from 'lodash-es/startCase';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { PossiblyAsync, getPossiblyAsyncObservable } from '../../../utils';
import { Async, PossiblyAsync, getPossiblyAsyncObservable } from '../../../utils';
import { Value } from '../../value';
type Fn<R, P extends Array<unknown> = []> = (...args: P) => R;
type PossiblyFn<R, P extends Array<unknown> = []> = Fn<R, P> | R;
export type Fn<R, P extends Array<unknown> = []> = (...args: P) => R;
export type PossiblyFn<R, P extends Array<unknown> = []> = Fn<R, P> | R;
export type CellFnArgs<T extends object> = [data: T, index: number];
@ -21,9 +21,13 @@ export type CellValue = 'string' | Value;
export interface Column2<T extends object, C extends object = object> extends ColumnParams {
field: string;
header?: PossiblyAsync<Partial<Value> | string>;
cell?: PossiblyFn<PossiblyAsync<CellValue>, CellFnArgs<T>>;
lazyCell?: PossiblyFn<Async<CellValue>, CellFnArgs<T>>;
child?: PossiblyFn<PossiblyAsync<CellValue>, CellFnArgs<C>>;
hidden?: PossiblyAsync<boolean>;
sort?: PossiblyAsync<boolean>;
}
@ -53,14 +57,15 @@ export function normalizeCell<T extends object, C extends object>(
export class NormColumn<T extends object, C extends object = object> {
field!: string;
header!: Observable<Value>;
cell!: Fn<Observable<Value>, CellFnArgs<T>>;
cell?: Fn<Observable<Value>, CellFnArgs<T>>;
lazyCell?: Fn<Observable<Value>, CellFnArgs<T>>;
child?: Fn<Observable<Value>, CellFnArgs<C>>;
hidden!: Observable<boolean>;
sort!: Observable<boolean | null>;
params!: ColumnParams;
constructor(
{ field, header, cell, child, hidden, sort, ...params }: Column2<T, C>,
{ field, header, cell, child, hidden, sort, lazyCell, ...params }: Column2<T, C>,
commonParams: ColumnParams = {},
) {
this.field = field ?? (typeof header === 'string' ? header : Math.random());
@ -72,7 +77,12 @@ export class NormColumn<T extends object, C extends object = object> {
: { value: value ?? defaultHeaderValue },
),
);
this.cell = normalizeCell(this.field, cell, !!child);
if (cell || !lazyCell) {
this.cell = normalizeCell(this.field, cell, !!child);
}
if (lazyCell) {
this.lazyCell = normalizeCell(this.field, lazyCell, !!child);
}
if (child) {
this.child = normalizeCell(this.field, child);
}

View File

@ -10,19 +10,20 @@ import { createUniqueColumnDef } from './create-unique-column-def';
export function createColumn<P, A extends object>(
createCell: (cellParams: P, ...args: CellFnArgs<A>) => PossiblyAsync<Value>,
columnObject: Partial<Omit<Column2<object>, 'cell'>> = {},
columnObject: Partial<Column2<A>> = {},
) {
return <T extends A>(
getCellParams: (...args: CellFnArgs<T>) => PossiblyAsync<P>,
column: Partial<Column2<T>> = {},
{ isLazyCell, ...column }: Partial<Column2<T>> & { isLazyCell?: boolean } = {},
): Column2<T> => {
const injector = inject(Injector);
const field = column?.field ?? createUniqueColumnDef(column?.header);
const cellKey = isLazyCell ? 'lazyCell' : 'cell';
return {
field,
...columnObject,
...column,
cell: (...cellArgs) => {
[cellKey]: (...cellArgs: CellFnArgs<T>) => {
const cellValue$ = getPossiblyAsyncObservable(getCellParams(...cellArgs)).pipe(
switchMap((cellParams) =>
getPossiblyAsyncObservable(
@ -32,10 +33,10 @@ export function createColumn<P, A extends object>(
),
),
);
if (column.cell) {
if (column[cellKey]) {
const columnCellValue$ = normalizeCell(
field,
column.cell,
column[cellKey],
!!column?.child,
)(...cellArgs);
return combineLatest([cellValue$, columnCellValue$]).pipe(

View File

@ -1,7 +1,7 @@
import { Observable } from 'rxjs';
export function createIntersectionObserver(el: HTMLElement) {
return new Observable((subscriber) => {
return new Observable<IntersectionObserverEntry>((subscriber) => {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {

View File

@ -1,25 +0,0 @@
import {
inject,
DestroyRef,
OutputEmitterRef,
Injector,
InputSignalWithTransform,
} from '@angular/core';
import { toObservable, outputToObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ReplaySubject, merge } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
export function modelToSubject<T, TransformT>(
input: InputSignalWithTransform<T, TransformT>,
output: OutputEmitterRef<T>,
): ReplaySubject<T> {
const sub$ = new ReplaySubject<T>(1);
const dr = inject(DestroyRef);
const injector = inject(Injector);
merge(toObservable(input, { injector }), outputToObservable(output))
.pipe(distinctUntilChanged(), takeUntilDestroyed(dr))
.subscribe((v) => {
sub$.next(v as T);
});
return sub$;
}

View File

@ -1,4 +1,5 @@
import { TemplateRef } from '@angular/core';
import { Router } from '@angular/router';
import { Color } from '../../../styles';
@ -10,7 +11,7 @@ export type BaseValue<V = unknown> = {
color?: Color;
click?: (event: MouseEvent) => void;
link?: (event: MouseEvent) => void;
link?: (event: MouseEvent) => string | Parameters<Router['navigate']>;
template?: TemplateRef<unknown>;

View File

@ -1,18 +0,0 @@
import { BehaviorSubject } from 'rxjs';
export function createInputSubject<T extends Record<P, V>, P extends string, V>(
target: T,
propertyKey: P,
_initValue: V,
): BehaviorSubject<V> {
const sub$ = new BehaviorSubject<unknown>(target[propertyKey]);
Object.defineProperty(target, propertyKey, {
get: function () {
return sub$.value;
},
set: function (v) {
sub$.next(v);
},
});
return sub$ as never;
}

View File

@ -1,66 +1,67 @@
@if (resValue$ | async; as v) {
@if (v.template) {
<ng-container *ngTemplateOutlet="v.template; context: {}"></ng-container>
} @else {
@if (v.color) {
<v-tag [color]="v.color">
<ng-container *ngTemplateOutlet="tooltipTemplate"></ng-container>
</v-tag>
@if (!value() && lazyValue() && !lazyVisible()) {
<button class="button" mat-icon-button (click)="lazyVisible.set(true)">
<mat-icon>sync</mat-icon>
</button>
} @else {
@if (value$ | async; as v) {
@if (v.template) {
<ng-container *ngTemplateOutlet="v.template; context: {}"></ng-container>
} @else {
<ng-container *ngTemplateOutlet="tooltipTemplate"></ng-container>
}
<ng-template #tooltipTemplate>
@if (v.tooltip) {
<span [matTooltip]="v.tooltip ?? ''" class="tooltip">
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
</span>
@if (v.color) {
<v-tag [color]="v.color">
<ng-container *ngTemplateOutlet="tooltipTemplate"></ng-container>
</v-tag>
} @else {
<span [title]="v.value">
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
</span>
<ng-container *ngTemplateOutlet="tooltipTemplate"></ng-container>
}
</ng-template>
<ng-template #valueTemplate>
@switch (v.type) {
@case ('menu') {
<v-menu-value [value]="v"></v-menu-value>
<ng-template #tooltipTemplate>
@if (v.tooltip) {
<span [matTooltip]="v.tooltip ?? ''" class="tooltip">
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
</span>
} @else {
<span [title]="v.value">
<ng-container *ngTemplateOutlet="valueTemplate"></ng-container>
</span>
}
@default {
@let renderedValue = renderedValue$ | async;
@if (inProgress$ | async) {
<v-content-loading [hiddenText]="renderedValue"></v-content-loading>
} @else if (renderedValue) {
<span
[ngClass]="{ value__click: v.click, value__link: v.link }"
[vHighlightSearch]="highlight"
[vHighlightText]="renderedValue"
vHighlight
(click)="click($event)"
></span>
} @else if (emptySymbol) {
<span style="color: #ccc">{{ emptySymbol }}</span>
</ng-template>
<ng-template #valueTemplate>
@switch (v.type) {
@case ('menu') {
<v-menu-value [value]="v"></v-menu-value>
}
@default {
@let valueText = valueText$ | async;
@if (inProgress$ | async) {
<v-content-loading [hiddenText]="valueText"></v-content-loading>
} @else if (valueText) {
<span
[ngClass]="{ value__click: v.click, value__link: v.link }"
[vHighlightSearch]="highlight"
[vHighlightText]="valueText"
vHighlight
(click)="click($event)"
></span>
} @else if (emptySymbol) {
<span style="color: #ccc">{{ emptySymbol }}</span>
}
}
}
}
</ng-template>
}
</ng-template>
}
@if (v.description) {
<div
[title]="v.description"
[vHighlightSearch]="highlight"
[vHighlightText]="v.description"
class="description mat-caption mat-secondary-text"
vHighlight
></div>
@if (v.description) {
<div
[title]="v.description"
[vHighlightSearch]="highlight"
[vHighlightText]="v.description"
class="description mat-caption mat-secondary-text"
vHighlight
></div>
}
} @else {
<v-content-loading></v-content-loading>
}
<!-- } @else {-->
<!-- <button class="button" mat-icon-button (click)="this.toggleLazyVisible()">-->
<!-- <mat-icon>sync</mat-icon>-->
<!-- </button>-->
<!-- }-->
} @else {
<v-content-loading></v-content-loading>
}

View File

@ -1,5 +1,6 @@
:host {
cursor: default;
text-align: left;
}
::ng-deep .inline {

View File

@ -6,24 +6,24 @@ import {
Injector,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
input,
model,
} from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { combineLatest, switchMap, of, isObservable, BehaviorSubject, Observable } from 'rxjs';
import { map, shareReplay, distinctUntilChanged } from 'rxjs/operators';
import { Router } from '@angular/router';
import { combineLatest, switchMap, of, isObservable, Observable } from 'rxjs';
import { map, shareReplay, first, filter } from 'rxjs/operators';
import { HighlightDirective } from '../../directives';
import { Nil } from '../../utils';
import { ContentLoadingComponent } from '../content-loading';
import { TagModule } from '../tag';
import { MenuValueComponent } from './components/menu-value.component';
import { Value } from './types/value';
import { createInputSubject } from './utils/create-input-subject';
import { valueToString } from './utils/value-to-string';
@Component({
@ -47,11 +47,9 @@ import { valueToString } from './utils/value-to-string';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ValueComponent {
@Input() value: Value | null | undefined = null;
value$ = createInputSubject(this, 'value', this.value);
@Input() lazyValue: Observable<Value> | undefined = undefined;
lazyValue$ = createInputSubject(this, 'lazyValue', this.lazyValue);
value = input<Value | Nil>();
lazyValue = input<Observable<Value> | Nil>();
lazyVisible = model<boolean>(false);
progress = input(false, { transform: booleanAttribute });
@Input({ transform: booleanAttribute }) inline = false;
@ -62,50 +60,45 @@ export class ValueComponent {
@Input() highlight?: string | null;
@Output() lazyVisibleChange = new EventEmitter<boolean>();
resultingValue$ = of(null);
lazyVisible = new BehaviorSubject(false);
isLoaded$ = combineLatest([of(null), this.lazyVisible]).pipe(
map(([lazyValue, lazyVisible]) => !lazyValue || lazyVisible),
distinctUntilChanged(),
shareReplay({ refCount: true, bufferSize: 1 }),
);
resValue$ = combineLatest([this.value$, this.lazyValue$]).pipe(
value$ = combineLatest([toObservable(this.value), toObservable(this.lazyValue)]).pipe(
switchMap(([value, lazyValue]) => (isObservable(lazyValue) ? lazyValue : of(value))),
shareReplay({ refCount: true, bufferSize: 1 }),
);
renderedValue$ = combineLatest([this.resultingValue$, this.resValue$]).pipe(
map(
([resultingValue, resValue]) =>
resultingValue ??
runInInjectionContext(this.injector, () => valueToString(resValue)),
),
valueText$ = this.value$.pipe(
map((value) => runInInjectionContext(this.injector, () => valueToString(value))),
shareReplay({ refCount: true, bufferSize: 1 }),
);
inProgress$ = combineLatest([
toObservable(this.progress),
this.resValue$.pipe(map((v) => v?.inProgress ?? false)),
this.value$.pipe(map((v) => v?.inProgress ?? false)),
]).pipe(
map(([compProgress, valueProgress]) => compProgress || valueProgress),
shareReplay({ refCount: true, bufferSize: 1 }),
);
constructor(private injector: Injector) {}
constructor(
private injector: Injector,
private router: Router,
) {}
toggleLazyVisible() {
this.lazyVisible.next(true);
this.lazyVisibleChange.emit(true);
makeLazyVisible() {
this.lazyVisible.set(true);
}
click(event: MouseEvent) {
runInInjectionContext(this.injector, () => {
if (this.value?.click) {
this.value.click(event);
} else if (typeof this.value?.link === 'function') {
this.value.link(event);
}
this.value$.pipe(first(), filter(Boolean)).subscribe((value) => {
runInInjectionContext(this.injector, () => {
if (value?.click) {
value.click(event);
} else if (typeof value?.link === 'function') {
const linkArgs = value.link(event);
if (typeof linkArgs === 'string') {
this.router.navigateByUrl(linkArgs);
return;
}
this.router.navigate(...linkArgs);
}
});
});
}
}

View File

@ -6,7 +6,7 @@ import { inlineJson } from '../utils';
name: 'inlineJson',
})
export class InlineJsonPipe implements PipeTransform {
transform(value: unknown, maxReadableLever: number | false = 1): unknown {
transform(value: unknown, maxReadableLever: number | false = 1): string {
return inlineJson(value, maxReadableLever === false ? Infinity : maxReadableLever);
}
}

View File

@ -0,0 +1,18 @@
// eslint-disable-next-line @typescript-eslint/naming-convention
export function DebounceTime(ms: number = 500) {
return function (target: object, propertyKey: PropertyKey, descriptor: PropertyDescriptor) {
let timer: number | null = null;
const method = descriptor.value;
descriptor.value = function () {
if (timer !== null) {
window.clearTimeout(timer);
}
timer = window.setTimeout(() => {
// eslint-disable-next-line prefer-rest-params
method.apply(this, arguments);
timer = null;
}, ms);
};
};
}

View File

@ -0,0 +1 @@
export * from './debounce-time';

View File

@ -1,2 +1,3 @@
export * from './is-empty';
export * from './is-empty-primitive';
export * from './types/nil';

View File

@ -0,0 +1 @@
export type Nil = null | undefined;

View File

@ -1 +1,2 @@
export * from './get-enum-keys';
export * from './types/union-enum';

View File

@ -0,0 +1,2 @@
export type StringEnum = string;
export type Enum = StringEnum | number;

View File

@ -0,0 +1,3 @@
import { Enum } from './enum';
export type UnionEnum<T extends Enum> = T | `${T}`;

View File

@ -14,3 +14,5 @@ export * from './compare';
export * from './import';
export * from './file';
export * from './csv';
export * from './transform-attribute';
export * from './decorators';

View File

@ -0,0 +1,10 @@
import { Nil } from '../empty';
export function arrayAttribute<T>(value: ArrayAttributeTransform<T>): T[] {
if (!Array.isArray(value)) {
return [];
}
return value;
}
export type ArrayAttributeTransform<T> = T[] | Nil;

View File

@ -0,0 +1 @@
export * from './array-attribute';