import { SelectionModel } from '@angular/cdk/collections';
import { httpResource } from '@angular/common/http';
import { Component, computed, Directive, effect, ErrorHandler, inject, input, output, viewChild } from '@angular/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { HraCommonModule } from '@hra-ui/common';
import { injectAssetUrlResolver } from '@hra-ui/common/url';
import { ButtonsModule } from '@hra-ui/design-system/buttons';
import { TextHyperlinkDirective } from '@hra-ui/design-system/buttons/text-hyperlink';
import { IconsModule } from '@hra-ui/design-system/icons';
import { ScrollingModule } from '@hra-ui/design-system/scrolling';
import { SnackbarService } from '@hra-ui/design-system/snackbar';
import { PlainTooltipDirective } from '@hra-ui/design-system/tooltips/plain-tooltip';
import saveAs from 'file-saver';
import { MarkdownModule } from 'ngx-markdown';
import { NgScrollbar } from 'ngx-scrollbar';
import { parse } from 'papaparse';
import {
IconColumnType,
LinkColumnType,
MarkdownColumnType,
MenuButtonColumnType,
MenuOptionsType,
NumericColumnType,
TableColumn,
TableColumnType,
TableColumnWithType,
TableRow,
TableVariant,
TextColumnType,
} from '../types/page-table.schema';
type RowElementContext<T, CT extends TableColumnType> = {
$implicit: T;
element: TableRow;
column: TableColumnWithType<CT>;
};
@Directive({
selector: 'ng-template[hraTextRowElement]',
})
export class TextRowElementDirective {
static ngTemplateContextGuard(
_dir: TextRowElementDirective,
_ctx: unknown,
): _ctx is RowElementContext<string, TextColumnType> {
return true;
}
}
@Directive({
selector: 'ng-template[hraLinkRowElement]',
})
export class LinkRowElementDirective {
static ngTemplateContextGuard(
_dir: LinkRowElementDirective,
_ctx: unknown,
): _ctx is RowElementContext<string, LinkColumnType> {
return true;
}
}
@Directive({
selector: 'ng-template[hraMarkdownRowElement]',
})
export class MarkdownRowElementDirective {
static ngTemplateContextGuard(
_dir: MarkdownRowElementDirective,
_ctx: unknown,
): _ctx is RowElementContext<string, MarkdownColumnType> {
return true;
}
}
@Directive({
selector: 'ng-template[hraIconRowElement]',
})
export class IconRowElementDirective {
static ngTemplateContextGuard(
_dir: IconRowElementDirective,
_ctx: unknown,
): _ctx is RowElementContext<string, IconColumnType> {
return true;
}
}
@Directive({
selector: 'ng-template[hraMenuButtonRowElement]',
})
export class MenuButtonRowElementDirective {
static ngTemplateContextGuard(
_dir: MenuButtonRowElementDirective,
_ctx: unknown,
): _ctx is RowElementContext<string, MenuButtonColumnType> {
return true;
}
}
@Directive({
selector: 'ng-template[hraNumericRowElement]',
})
export class NumericRowElementDirective {
static ngTemplateContextGuard(
_dir: NumericRowElementDirective,
_ctx: unknown,
): _ctx is RowElementContext<string, NumericColumnType> {
return true;
}
}
@Component({
selector: 'hra-table',
imports: [
HraCommonModule,
MarkdownModule,
MatMenuModule,
MatSortModule,
MatTableModule,
ScrollingModule,
MatCheckboxModule,
TextHyperlinkDirective,
LinkRowElementDirective,
TextRowElementDirective,
MarkdownRowElementDirective,
MenuButtonRowElementDirective,
NumericRowElementDirective,
PlainTooltipDirective,
IconsModule,
ButtonsModule,
],
templateUrl: 'table.component.html',
styleUrl: 'table.component.scss',
host: {
'[class]': '"hra-table-style-" + style()',
'[class.sortable]': 'enableSort()',
'[class.vertical-dividers]': 'verticalDividers()',
},
})
export class TableComponent<T = TableRow> {
readonly csvUrl = input<string>();
readonly columns = input<TableColumn[]>();
readonly rows = input<T[]>();
readonly style = input<TableVariant>('alternating');
readonly enableSort = input<boolean>(false);
readonly verticalDividers = input<boolean>(false);
readonly enableRowSelection = input<boolean>(false);
readonly hideHeaders = input<boolean>(false);
readonly selectionChange = output<T[]>();
readonly routeClicked = output<string>();
readonly downloadHovered = output<string>();
readonly scrollbar = viewChild.required<NgScrollbar>('scrollbar');
private readonly sort = viewChild.required(MatSort);
readonly selection = new SelectionModel<TableRow>(true, []);
private readonly errorHandler = inject(ErrorHandler);
private readonly resolveAssetUrl = injectAssetUrlResolver();
readonly snackbar = inject(SnackbarService);
private readonly csv = httpResource.text<T[]>(
() => {
const url = this.csvUrl();
return url ? this.resolveAssetUrl(url) : url;
},
{
defaultValue: [],
parse: (data) => {
const result = parse<T>(data, { header: true, dynamicTyping: true, skipEmptyLines: true });
if (result.errors.length > 0) {
this.errorHandler.handleError(result.errors);
}
return result.data;
},
},
);
protected readonly _rows = computed(() => this.rows() ?? this.csv.value());
protected readonly _columns = computed(() => this.columns() ?? this.inferColumns(this._rows()));
protected readonly columnIds = computed(() => {
const columns = this._columns().map((col) => col.column);
return this.enableRowSelection() ? ['select', ...columns] : columns;
});
protected readonly totals = computed(() => {
const result = new Map<TableColumn, number>();
for (const col of this._columns()) {
const options = this.getColumnType(col);
if (options.type === 'numeric' && options.computeTotal) {
const total = this._rows().reduce((acc, row) => {
const value = Number(row[col.column as keyof T]);
return acc + (Number.isNaN(value) ? 0 : value);
}, 0);
result.set(col, total);
}
}
return result;
});
protected readonly dataSource = new MatTableDataSource<T>([]);
constructor() {
effect(() => {
this.dataSource.data = this._rows();
});
effect(() => {
this.dataSource.sort = this.sort();
});
}
getColumnType(column: TableColumn): TableColumnType {
return typeof column.type === 'string' ? ({ type: column.type } as TableColumnType) : column.type;
}
private inferColumns(data: T[]): TableColumn[] {
if (data.length === 0) {
return [];
}
const item = data[0];
const columns: TableColumn[] = [];
for (const key in item) {
const type = typeof item[key] === 'number' ? 'numeric' : 'text';
columns.push({ column: key, label: key, type });
}
return columns;
}
isAllSelected(): boolean {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.data.length;
return numSelected === numRows;
}
toggleAllRows(): void {
if (this.isAllSelected()) {
this.selection.clear();
} else {
this.selection.select(...(this.dataSource.data as TableRow[]));
}
this.selectionChange.emit(this.selection.selected as T[]);
}
toggleRow(row: TableRow): void {
this.selection.toggle(row as TableRow);
this.selectionChange.emit(this.selection.selected as T[]);
}
getMenuOptions(options: string | number | boolean | MenuOptionsType[]): MenuOptionsType[] {
return options as MenuOptionsType[];
}
routeClick(url: string | number | boolean | (string | number | boolean)[]): void {
this.routeClicked.emit(url as string);
}
downloadButtonHover(id: string | number | boolean | (string | number | boolean)[]): void {
this.downloadHovered.emit(id as string);
}
download(url: string): void {
saveAs(url, url.split('/').pop());
}
scrollToTop(): void {
this.scrollbar().scrollTo({ top: 0, duration: 0 });
}
}