diff --git a/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.html b/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.html index 1d74f0703..1909f6157 100644 --- a/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.html +++ b/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.html @@ -2,11 +2,21 @@ @switch (section.content) { @case ('simple table') { + + + + } + @case ('deprecated simple table') { diff --git a/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.ts b/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.ts index 8bc00407f..a3fed6158 100644 --- a/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.ts +++ b/apps/demo-app/src/app/content/ui-components/table-content/table-content.component.ts @@ -1,19 +1,30 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, +} from '@angular/core'; import { SinglePanelExampleDisplayComponent } from '../../../platform/single-panel-example-display/single-panel-example-display.component'; import { ContentContainerComponent } from '../../content-container/content-container.component'; import { TableExampleComponent } from './table-example/table-example.component'; +import { TanstackExampleComponent } from './tanstack-example/tanstack-example.component'; @Component({ selector: 'app-table-content', imports: [ CommonModule, SinglePanelExampleDisplayComponent, - TableExampleComponent, + TanstackExampleComponent, ContentContainerComponent, + TableExampleComponent, ], templateUrl: './table-content.component.html', - styleUrls: ['../../examples.scss', './table-content.component.scss'], + styleUrls: [ + '../../examples.scss', + '../../api-documentation.scss', + './table-content.component.scss', + ], changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, }) export class TableContentComponent {} diff --git a/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.html b/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.html index 0f9d1efb2..ddd30645d 100644 --- a/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.html +++ b/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.html @@ -1 +1,47 @@ - +@if (dataSource.columns$ | async; as columns) { + + @for (column of columns; track column.id) { + + @if (column.sortable) { + + } @else { + + } + + + } + + +
+
+ {{ column.label }} + {{ sortIcon }} +
+ {{ column.label }} + + {{ element | getValueByKey: column.key }} +
+} diff --git a/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.scss b/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.scss index e69de29bb..381f55b6b 100644 --- a/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.scss +++ b/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.scss @@ -0,0 +1,69 @@ +@use 'vars' as *; +@use 'functions' as *; +@use 'colors'; +$icon-width: 0.9rem; +$icon-left-margin: 0.2rem; +$icon-right-margin: 0.4rem; + +.header-cell-sort { + display: flex; + align-items: flex-end; + justify-content: flex-end; + &:hover { + cursor: pointer; + } +} + +.material-symbols-outlined { + display: flex; + justify-content: center; + width: $icon-width; + height: 1.2rem; + font-size: 1.25rem; + margin-left: $icon-left-margin; + margin-right: $icon-right-margin; + opacity: 0.25; + transition: all 150ms ease-in-out; + + &:hover { + opacity: 0.75; + } + + &.actively-sorted { + opacity: 1; + } +} + +.desc { + transform: rotate(180deg); +} + +.table-cell { + text-align: right; + + &.sorted-cell { + padding-right: $icon-left-margin + $icon-width + $icon-right-margin; + } +} + +.table-container { + border-spacing: 0; + td:last-child { + &.left { + padding-right: 0; + } + } + + th:last-child { + &.left { + padding-right: 0; + } + } + + th { + vertical-align: bottom; + &.sorted-header { + padding-right: 0; + } + } +} diff --git a/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.ts b/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.ts index 64a2897be..b04acc942 100644 --- a/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.ts +++ b/apps/demo-app/src/app/content/ui-components/table-content/table-example/table-example.component.ts @@ -1,33 +1,121 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { TableColumn, TableModule } from '@hsi/ui-components'; -import { BehaviorSubject } from 'rxjs'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, +} from '@angular/core'; +import { GetValueByKeyPipe } from '@hsi/app-dev-kit'; +import { + HsiUiTableDataSource, + TableColumnsBuilder, + TableModule, +} from '@hsi/ui-components'; +import { of } from 'rxjs'; +enum ColumnNames { + fruit = 'Fruit', + colorName = 'Color', + inventory = 'Inventory', + price = 'Sell price', +} + +enum FruitInfo { + fruit = 'fruit', + colorName = 'color', + inventory = 'metrics.inventory', + price = 'metrics.price', +} + +class FruitType { + fruit: string; + color: string; + metrics: { + inventory: number; + price: number; + }; +} + +/** + * @deprecated This component is deprecated. See `tanstack-example.component.ts` for the recommended implementation. + */ @Component({ selector: 'app-table-example', - imports: [CommonModule, TableModule], + standalone: true, + imports: [CommonModule, TableModule, GetValueByKeyPipe], templateUrl: './table-example.component.html', - styleUrl: './table-example.component.scss', + styleUrls: ['../../../examples.scss', './table-example.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class TableExampleComponent { - config$ = new BehaviorSubject({ - data: [ - { fruit: 'apple', color: 'red' }, - { fruit: 'orange', color: 'orange' }, - { fruit: 'banana', color: 'yellow' }, - ], - columns: [ - new TableColumn<{ fruit: string; color: string }>({ - label: 'Fruit', - getFormattedValue: (x) => x.fruit, - sortable: true, - }), - new TableColumn<{ fruit: string; color: string }>({ - label: 'Color', - getFormattedValue: (x) => x.color, - sortable: true, - }), - ], - }); +export class TableExampleComponent implements OnInit { + @Input() sortIcon: string = 'arrow_upward'; + dataSource: HsiUiTableDataSource; + + ngOnInit(): void { + this.setTableData(); + } + + setTableData() { + const initData$ = of([ + { + fruit: 'lemon', + color: 'yellow', + metrics: { inventory: 10, price: 1.2 }, + }, + { + fruit: 'mango', + color: 'orange', + metrics: { inventory: 5, price: 2.5 }, + }, + { + fruit: 'avocado', + color: 'green', + metrics: { inventory: 20, price: 3.0 }, + }, + { + fruit: 'apple', + color: 'red', + metrics: { inventory: 15, price: 1.5 }, + }, + { + fruit: 'orange', + color: 'orange', + metrics: { inventory: 20, price: 1.8 }, + }, + { + fruit: 'banana', + color: 'yellow', + metrics: { inventory: 5, price: 1.0 }, + }, + ]); + const initColumns$ = of( + new TableColumnsBuilder() + .addColumn( + (column) => + column + .label(ColumnNames.fruit) + .displayKey(FruitInfo.fruit) + .ascendingSortFunction((a, b) => a.fruit.localeCompare(b.fruit)) + .sortOrder(1) + .sortable(true) + .sortDirection('asc') // initial sort direction + ) + .addColumn((column) => + column + .displayKey(FruitInfo.colorName) + .label(ColumnNames.colorName) + .sortable(true) + .ascendingSortFunction((a, b) => a.color.localeCompare(b.color)) + ) + .addColumn((column) => + column + .displayKey(FruitInfo.inventory) + .label(ColumnNames.inventory) + .sortable(true) + .getSortValue((x) => x.metrics.inventory) + ) + .getConfig() + ); + this.dataSource = new HsiUiTableDataSource(initData$, initColumns$); + } } diff --git a/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.html b/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.html new file mode 100644 index 000000000..ca6a919eb --- /dev/null +++ b/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.html @@ -0,0 +1,90 @@ +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + @if (!header.isPlaceholder) { + @if (header.column.getCanSort()) { + + } @else { + + } + } + } + + } + + + + + + +
+ +
+ {{ headerCell }} + {{ sortIcon }} +
+
+
+ +
+
+
+ + + + + + + + + + + + {{ cell.getValue() }} + +
+
diff --git a/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.scss b/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.scss new file mode 100644 index 000000000..5d074679d --- /dev/null +++ b/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.scss @@ -0,0 +1,81 @@ +@use 'vars' as *; +@use 'functions' as *; +@use 'colors'; +$icon-width: 0.9rem; +$icon-left-margin: 0.2rem; +$icon-right-margin: 0.4rem; + +.header-cell-sort { + display: flex; + &:hover { + cursor: pointer; + } + &.left { + align-items: flex-start; + justify-content: flex-start; + } + &.right { + align-items: flex-end; + justify-content: flex-end; + } +} + +.material-symbols-outlined { + display: flex; + justify-content: center; + width: $icon-width; + height: 1.2rem; + font-size: 1.25rem; + margin-left: $icon-left-margin; + margin-right: $icon-right-margin; + opacity: 0.25; + transition: all 150ms ease-in-out; + + &:hover { + opacity: 0.75; + } + + &.actively-sorted { + opacity: 1; + } +} + +.desc { + transform: rotate(180deg); +} + +.right { + text-align: right; +} + +.left { + text-align: left; +} + +.table-cell { + &.sorted-cell { + padding-right: $icon-left-margin + $icon-width + $icon-right-margin; + } +} + +.table-container { + border-spacing: 0; + td:last-child { + &.left { + padding-right: 0; + } + } + + th:last-child { + &.left { + padding-right: 0; + } + } + + th { + vertical-align: bottom; + &.sorted-header { + padding-right: 0; + } + } +} diff --git a/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.ts b/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.ts new file mode 100644 index 000000000..9ee27e729 --- /dev/null +++ b/apps/demo-app/src/app/content/ui-components/table-content/tanstack-example/tanstack-example.component.ts @@ -0,0 +1,183 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, + signal, +} from '@angular/core'; +import { + ChartConfig, + LinesConfig, + VicChartConfigBuilder, + VicChartModule, + VicLinesConfigBuilder, + VicLinesModule, + VicXQuantitativeAxisConfig, + VicXQuantitativeAxisConfigBuilder, + VicXyAxisModule, + VicYQuantitativeAxisConfig, + VicYQuantitativeAxisConfigBuilder, +} from '@hsi/viz-components'; +import { + ColumnDef, + createAngularTable, + FlexRenderDirective, + getCoreRowModel, + getSortedRowModel, + SortingState, +} from '@tanstack/angular-table'; + +type PerformanceReview = { + year: number; + hrAppraisal: number; + employeeAppraisal: number; +}; +type Person = { + firstName: string; + lastName: string; + age: number; + performance: PerformanceReview[]; +}; + +type PersonWithCharts = Person & { + chartConfig: LinesConfig; +}; + +const defaultData: Person[] = [ + { + firstName: 'first', + lastName: 'last', + age: 106, + performance: [ + { year: 2020, hrAppraisal: 9, employeeAppraisal: 4 }, + { year: 2023, hrAppraisal: 10, employeeAppraisal: 2 }, + { year: 2025, hrAppraisal: 10, employeeAppraisal: -10 }, + ], + }, + { + firstName: 'person', + lastName: 'name', + age: 45, + performance: [ + { year: 2020, hrAppraisal: 8, employeeAppraisal: 9 }, + { year: 2023, hrAppraisal: 10, employeeAppraisal: 10 }, + { year: 2025, hrAppraisal: 0, employeeAppraisal: 10 }, + ], + }, + { + firstName: 'another', + lastName: 'name', + age: 40, + performance: [ + { year: 2020, hrAppraisal: 8, employeeAppraisal: 5 }, + { year: 2023, hrAppraisal: 6, employeeAppraisal: 3 }, + { year: 2025, hrAppraisal: 7, employeeAppraisal: 1 }, + ], + }, +]; + +const defaultColumns: ColumnDef[] = [ + { + accessorKey: 'firstName', + cell: (info) => info.getValue(), + footer: (info) => info.column.id, + enableSorting: false, + }, + { + accessorFn: (row) => row.lastName, + id: 'lastName', + cell: (info) => `${info.getValue()}`, + header: () => `Last Name`, + footer: (info) => info.column.id, + enableSorting: false, + }, + { + accessorKey: 'age', + header: () => 'Age', + footer: (info) => info.column.id, + sortingFn: 'basic', + enableSorting: true, + }, + { + accessorKey: 'chartConfig', + header: () => `performance appraisal`, + footer: (info) => info.column.id, + }, +]; + +@Component({ + selector: 'app-tanstack-example', + standalone: true, + imports: [ + FlexRenderDirective, + CommonModule, + VicChartModule, + VicLinesModule, + VicXyAxisModule, + ], + providers: [ + VicChartConfigBuilder, + VicLinesConfigBuilder, + VicYQuantitativeAxisConfigBuilder, + VicXQuantitativeAxisConfigBuilder, + ], + templateUrl: './tanstack-example.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./tanstack-example.component.scss'], +}) +export class TanstackExampleComponent implements OnInit { + @Input() sortIcon: string = 'arrow_upward'; + readonly sorting = signal([ + { + id: 'age', + desc: false, + }, + ]); + + data = signal(null); + + table = createAngularTable(() => ({ + data: this.data(), + columns: defaultColumns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + state: { + sorting: this.sorting(), + }, + debugAll: true, + enableSorting: true, + onSortingChange: (updater) => { + if (updater instanceof Function) { + this.sorting.update(updater); + } else { + this.sorting.set(updater); + } + }, + })); + + chartConfig: ChartConfig; + xAxisQuantitativeConfig: VicXQuantitativeAxisConfig; + yAxisConfig: VicYQuantitativeAxisConfig; + + ngOnInit(): void { + this.data.set(this.addChartsToData(defaultData)); + this.chartConfig = new VicChartConfigBuilder().getConfig(); + this.xAxisQuantitativeConfig = new VicXQuantitativeAxisConfigBuilder() + .ticks((ticks) => ticks.format('%Y')) + .getConfig(); + this.yAxisConfig = new VicYQuantitativeAxisConfigBuilder().getConfig(); + } + + addChartsToData(data: Person[]): PersonWithCharts[] { + return data.map((person) => { + const chartConfig = new VicLinesConfigBuilder() + .data(person.performance) + .xDate((xDate) => xDate.valueAccessor((d) => new Date(d.year))) + .y((yValue) => yValue.valueAccessor((d) => d.employeeAppraisal)) + .pointMarkers((markers) => markers.radius(2).growByOnHover(3)) + .getConfig(); + return { ...person, chartConfig }; + }); + } +} diff --git a/apps/demo-app/src/assets/content.yaml b/apps/demo-app/src/assets/content.yaml index d4bca5b29..8b3037754 100644 --- a/apps/demo-app/src/assets/content.yaml +++ b/apps/demo-app/src/assets/content.yaml @@ -24,7 +24,9 @@ ui-components: title: UI Components items: overview: overview.md - table: table.md + table: + tanstack-table: table.md + deprecated-simple-table: deprecated-table.md combobox: overview: combobox.md filtering-options: filtering-options.md diff --git a/apps/demo-app/src/assets/ui-components/content/deprecated-table.md b/apps/demo-app/src/assets/ui-components/content/deprecated-table.md new file mode 100644 index 000000000..501e328be --- /dev/null +++ b/apps/demo-app/src/assets/ui-components/content/deprecated-table.md @@ -0,0 +1,515 @@ +# Table Component (Deprecated) + +## Deprecation Notice + +We have moved away from providing our own table, preferring to use the Tanstack Table library +instead. Tanstack's headless UI approach allows for styling flexibility while letting us take +advantage of open-source solutions to filtering, pagination, sorting, and more. + +## Overview + +HSI UI Components provides a set of Angular components and configuration classes that can be used to +create tables. These components can be imported via the `TableModule`. + +The HSI UI Components table uses the +[Angular Material CDK Table](https://material.angular.io/cdk/table/overview) as a reference. + +## Composing a Table + +A table is minimally composed of the following HTML components: + +- `table` — A component that is an outer wrapper for other components in the table. +- `th` — A component that represents a header cell in the table. +- `td` — A component that represents a data cell in the table. +- `tr` — A component that represents a row of cells in the table. + +In addition, the table must also be given data through an `HsiUiTableDataSource` instance. + +The following is a minimal implementation: + +```custom-angular +deprecated simple table +``` + +```ts +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { GetValueByKeyPipe } from '@hsi/app-dev-kit'; +import { HsiUiTableDataSource, TableColumnsBuilder, TableModule } from '@hsi/ui-components'; +import { of } from 'rxjs'; + +enum ColumnNames { + fruit = 'Fruit', + colorName = 'Color', + inventory = 'Inventory', + price = 'Sell price', +} + +enum FruitInfo { + fruit = 'fruit', + colorName = 'color', + inventory = 'metrics.inventory', + price = 'metrics.price', +} + +class FruitType { + fruit: string; + color: string; + metrics: { + inventory: number; + price: number; + }; +} + +@Component({ + selector: 'app-table-example', + standalone: true, + imports: [CommonModule, TableModule, GetValueByKeyPipe], + templateUrl: './table-example.component.html', + styleUrls: ['../../../examples.scss', './table-example.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TableExampleComponent implements OnInit { + @Input() sortIcon: string = 'arrow_upward'; + dataSource: HsiUiTableDataSource; + + ngOnInit(): void { + this.setTableData(); + } + + setTableData() { + const initData$ = of([ + { + fruit: 'lemon', + color: 'yellow', + metrics: { inventory: 10, price: 1.2 }, + }, + { + fruit: 'mango', + color: 'orange', + metrics: { inventory: 5, price: 2.5 }, + }, + { + fruit: 'avocado', + color: 'green', + metrics: { inventory: 20, price: 3.0 }, + }, + { + fruit: 'apple', + color: 'red', + metrics: { inventory: 15, price: 1.5 }, + }, + { + fruit: 'orange', + color: 'orange', + metrics: { inventory: 20, price: 1.8 }, + }, + { + fruit: 'banana', + color: 'yellow', + metrics: { inventory: 5, price: 1.0 }, + }, + ]); + const initColumns$ = of( + new TableColumnsBuilder() + .addColumn( + (column) => + column + .label(ColumnNames.fruit) + .displayKey(FruitInfo.fruit) + .ascendingSortFunction((a, b) => a.fruit.localeCompare(b.fruit)) + .sortOrder(1) + .sortable(true) + .sortDirection('asc') // initial sort direction + ) + .addColumn((column) => + column + .displayKey(FruitInfo.colorName) + .label(ColumnNames.colorName) + .sortable(true) + .ascendingSortFunction((a, b) => a.color.localeCompare(b.color)) + ) + .addColumn((column) => + column + .displayKey(FruitInfo.inventory) + .label(ColumnNames.inventory) + .sortable(true) + .getSortValue((x) => x.metrics.inventory) + ) + .getConfig() + ); + this.dataSource = new HsiUiTableDataSource(initData$, initColumns$); + } +} +``` + +```html +@if (dataSource.columns$ | async; as columns) { + + @for (column of columns; track column.id) { + + @if (column.sortable) { + + } @else { + + } + + + } + + +
+
+ {{ column.label }} + {{ sortIcon }} +
{{ column.label }} + {{ element | getValueByKey: column.key }} +
+} +``` + +## Features + +## Configuration + +To provide a column configuration for the `HsiUiTableDataSource`, you can use the +`TableColumnsBuilder`. + +**Required imports from @hsi/ui-components** + +```ts +import { + HsiUiTableDataSource, + TableColumn, + TableColumnsBuilder, + TableModule, +} from '@hsi/ui-components'; +... +@Component({ + ... + imports: [ + TableModule + ... + ], + ... +}) +``` + +**Minimal example of creating a `HsiUiTableDataSource`** + +```ts +... +dataSource: HsiUiTableDataSource<>; +data$: Observable = of([ + { fruit: 'lemon', color: 'yellow' }, + { fruit: 'mango', color: 'orange' }, + ]); +columns$: Observable[];> = of( + new TableColumnsBuilder<{ fruit: string; color: string }>() + .addColumn((column) => + column + .label(ColumnNames.fruit) + ) + .addColumn((column) => + column + .label(ColumnNames.color) + ) + .getConfig()); +... +this.dataSource = new HsiUiTableDataSource(this.data$, this.columns$); +``` + +### Handling tiebreaks + +Tiebreaks can be handled through setting a `sortOrder` on the `TableColumn` objects. When data in +cells of the column currently being sorted are of the same sort value, cell data from the inactive +columns are then compared to determine an ordering of the rows. + +### `TableColumnsBuilder` Methods + +#### Required Methods + +There are no required methods for `TableColumnsBuilder`. + +#### Optional Methods + +The `TableColumm` class contains data that states whether or not the column is sortable, and, if so, +how to sort the column. + +The following methods can be called on `TableColumnsBuilder` to create a list of validated +`TableColumn`s. + +```builder-method +name: addColumn +description: 'Specifies a new column to be added to the end of the list of columns.' +params: + - name: applyToColumn + type: '(column: TableColumnBuilder) => TableColumnBuilder' + description: + - 'Table column configuration for the column to be added, with all desired builder methods applied. See required methods below in the `TableColumnBuilder` section.' +``` + +```builder-method +name: getConfig +description: 'Validates and builds the configuration object for the table columns that can be passed to `HsiUiTableDataSource`.' +params: +- '' +``` + +### `TableColumnBuilder` Methods + +#### Required Methods + +```builder-method +name: id +description: 'Sets the id of the table column.' +params: + - name: id + type: string + description: + - 'The assigned id of the table column.' +``` + +```builder-method +name: displayKey +description: 'Sets the property key in the datum that is to be displayed in the table column. See the `metrics.cost` display key is set in the example below.' +params: + - name: key + type: string + description: + - 'The display key of the table column. Nested object data can be accessed using dot notation (e.g. `metrics.cost`, `metrics.inventory`).' +``` + +```ts +// Datum +fruits = [{ + fruit: 'apple', + color: 'green', + metrics: { + cost: 21, + quantity: 2 + }, + ... +}] + +// Builder +builder = new TableColumnsBuilder<{ +fruit: string; +color: string; +metrics: { + inventory: number; + price: number; +} +}>() +... + .addColumn( + (column) => + column + .label('Cost') + .displayKey('metrics.cost') + ) + ... +``` + +#### Optional Methods + +```builder-method +name: label +description: 'Sets the label of the table column. Useful for storing the desired header text for the column.' +params: + - name: label + type: string + description: + - 'The label to be used by the table column' +``` + +```builder-method +name: getSortValue +description: 'Specifies how to extract the datum property to be sorted on for cells in the table column.' +params: + - name: getSortValue + type: '(x: Datum) => TableValue | null' + description: + - 'A function to extract the datum property to be sorted on for cells in the table column, or `null` to not set this property.' +``` + +```builder-method +name: ascendingSortFunction +description: 'Specifies how datum are to be sorted in ascending order for the table column. If not provided, the column will use `d3.ascending` on the getSortValue output.' +params: + - name: ascendingSortFunction + type: '(a: Datum, b: Datum) => number | null' + description: + - 'The function that sorts datum in ascending order for the table column.' +``` + +```builder-method +name: sortDirection +description: Sets the direction the table column is sorted in. +params: + - name: sortDirection + type: SortDirectionType + description: + - 'The sort direction of the table column.' +``` + +```builder-method +name: sortable +description: 'Sets whether or not the table column can be sorted.' +params: + - name: sortable + type: boolean + description: + - 'Whether the column can be sorted.' +``` + +```builder-method +name: sortOrder +description: 'Sets the order in which the table column is to be sorted by in the case of tiebreaks' +params: + - name: sortOrder + type: number + description: + - 'The sort order of the table column.' +``` + +### Accessing data values using `GetValueByKeyPipe` + +In order to access data from the `Datum` objects in HTML, import the `GetValueByKeyPipe` from the +`AppDevKitModule`. + +```ts +import { GetValueByKeyPipe } from '@hsi/app-dev-kit'; +... +@Component({ + selector: 'app-table-example', + standalone: true, + imports: [CommonModule, TableModule, GetValueByKeyPipe], + templateUrl: './table-example.component.html', + styleUrls: ['../../../examples.scss', './table-example.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +... +``` + +The appropriate display key should then be passed to the pipe in the HTML table implementation like +so: + +```html + + ... + + ... +
{{ element | getValueByKey: column.key }}
+``` + +## Customizing with icons + +Icons of the user's choice can also be included like so: + +In the `th` element: + +```html + + {{ sortIcon }} + {{ column.label }} +``` + +Example CSS code for styling icons in a table: + +```scss +@use 'vars' as *; +@use 'functions' as *; +@use 'colors'; +$icon-width: 0.9rem; +$icon-left-margin: 0.2rem; +$icon-right-margin: 0.4rem; + +.header-cell-sort { + display: flex; + align-items: flex-end; + justify-content: flex-end; + &:hover { + cursor: pointer; + } +} + +.material-symbols-outlined { + display: flex; + justify-content: center; + width: $icon-width; + height: 1.2rem; + font-size: 1.25rem; + margin-left: $icon-left-margin; + margin-right: $icon-right-margin; + opacity: 0.25; + transition: all 150ms ease-in-out; + + &:hover { + opacity: 0.75; + } + + &.actively-sorted { + opacity: 1; + } +} + +.desc { + transform: rotate(180deg); +} + +.table-cell { + text-align: right; + + &.sorted-cell { + padding-right: $icon-left-margin + $icon-width + $icon-right-margin; + } +} + +.table-container { + border-spacing: 0; + td:last-child { + &.left { + padding-right: 0; + } + } + + th:last-child { + &.left { + padding-right: 0; + } + } + + th { + vertical-align: bottom; + &.sorted-header { + padding-right: 0; + } + } +} +``` diff --git a/apps/demo-app/src/assets/ui-components/content/table.md b/apps/demo-app/src/assets/ui-components/content/table.md index d081b077d..e69de29bb 100644 --- a/apps/demo-app/src/assets/ui-components/content/table.md +++ b/apps/demo-app/src/assets/ui-components/content/table.md @@ -1,5 +0,0 @@ -# Table Component - -```custom-angular -simple table -``` diff --git a/apps/demo-app/src/assets/ui-components/documentation/documentation-directory.yaml b/apps/demo-app/src/assets/ui-components/documentation/documentation-directory.yaml index cb5fb671a..e99a3e2bd 100644 --- a/apps/demo-app/src/assets/ui-components/documentation/documentation-directory.yaml +++ b/apps/demo-app/src/assets/ui-components/documentation/documentation-directory.yaml @@ -14,10 +14,9 @@ directory: directory-item: 'interfaces/HsiUiDirectoryItem.html' directory-selection: 'interfaces/HsiUiDirectorySelection.html' table: - table: 'components/TableComponent.html' table-config: 'classes/HsiUiTableConfig.html' + table-data-source: 'classes/HsiUiTableDataSource.html' table-column: 'classes/TableColumn.html' - single-sort-header: 'components/SingleSortHeaderComponent.html' tabs: service: 'injectables/TabsService.html' tabs: 'components/TabsComponent.html' diff --git a/libs/app-dev-kit/src/lib/core/pipes/get-value-by-key.pipe.ts b/libs/app-dev-kit/src/lib/core/pipes/get-value-by-key.pipe.ts new file mode 100644 index 000000000..5989ac0da --- /dev/null +++ b/libs/app-dev-kit/src/lib/core/pipes/get-value-by-key.pipe.ts @@ -0,0 +1,28 @@ +import { Pipe, PipeTransform } from '@angular/core'; +/** + * Retrieves the key in the object. + * @deprecated This pipe is deprecated. There is no longer a need for a + * custom key accessor pipe since we are now using Tanstack library + * for our table implementation. See `tanstack-example.component.ts` in `ui-components` for + * the recommended table implementation. + */ +@Pipe({ + name: 'getValueByKey', + standalone: true, +}) +export class GetValueByKeyPipe implements PipeTransform { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transform(element: any, key: string): any { + if (!element || !key) { + throw new Error('Invalid input: element and key are required'); + } + key.split('.').forEach((k) => { + if (element && typeof element === 'object') { + element = element[k]; + } else { + throw new Error(`Subkey "${k}" not found in the object`); + } + }); + return element; + } +} diff --git a/libs/app-dev-kit/src/lib/core/pipes/index.ts b/libs/app-dev-kit/src/lib/core/pipes/index.ts new file mode 100644 index 000000000..af68a92b4 --- /dev/null +++ b/libs/app-dev-kit/src/lib/core/pipes/index.ts @@ -0,0 +1 @@ +export * from './get-value-by-key.pipe'; diff --git a/libs/app-dev-kit/src/public-api.ts b/libs/app-dev-kit/src/public-api.ts index ac728af75..b40d20a31 100644 --- a/libs/app-dev-kit/src/public-api.ts +++ b/libs/app-dev-kit/src/public-api.ts @@ -1,5 +1,6 @@ export * from './lib/assets'; export * from './lib/content-parsing'; +export * from './lib/core/pipes/index'; export * from './lib/core/utilities/index'; export * from './lib/documentation-display'; export * from './lib/ng-utilities'; diff --git a/libs/ui-components/src/lib/table/index.ts b/libs/ui-components/src/lib/table/index.ts index 88817cae2..d1d410ebe 100644 --- a/libs/ui-components/src/lib/table/index.ts +++ b/libs/ui-components/src/lib/table/index.ts @@ -1,4 +1,5 @@ export * from './table-column'; -export * from './table.component'; +export * from './table-columns.builder'; export * from './table.config'; +export * from './table.data-source'; export * from './table.module'; diff --git a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.html b/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.html deleted file mode 100644 index d415661a6..000000000 --- a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.html +++ /dev/null @@ -1,9 +0,0 @@ -
- {{ column.label }} - -
diff --git a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.scss b/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.spec.ts b/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.spec.ts deleted file mode 100644 index 593f888ec..000000000 --- a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SortDirection, TableColumn } from '../table-column'; -import { TableComponent } from '../table.component'; -import { SingleSortHeaderComponent } from './single-sort-header.component'; - -describe('SingleSortHeaderComponent', () => { - let component: SingleSortHeaderComponent; - let fixture: ComponentFixture>; - let column: TableColumn<{ name: string }>; - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [SingleSortHeaderComponent], - providers: [TableComponent], - }); - fixture = TestBed.createComponent(SingleSortHeaderComponent); - component = fixture.componentInstance; - column = new TableColumn<{ name: string }>({ - getFormattedValue: (x) => x.name, - sortDirection: SortDirection.asc, - label: 'name', - }); - component.column = column; - component.sortIcon = 'sortIcon'; - }); - - describe('getColumnSortClasses', () => { - it('should return sort classes - case: is actively sorted', () => { - component.column = new TableColumn<{ name: string }>({ - label: 'test', - getFormattedValue: (x) => x.name, - sortDirection: SortDirection.asc, - activelySorted: true, - }); - const classes = component.getColumnSortClasses(); - expect(classes).toEqual([ - 'material-symbols-outlined', - 'asc', - 'actively-sorted', - ]); - }); - it('should return sort classes - case: is not actively sorted', () => { - component.column = { - name: 'test', - sortDirection: SortDirection.asc, - activelySorted: false, - } as any; - const classes = component.getColumnSortClasses(); - expect(classes).toEqual(['material-symbols-outlined', 'asc']); - }); - }); -}); diff --git a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.ts b/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.ts deleted file mode 100644 index 0b100d702..000000000 --- a/libs/ui-components/src/lib/table/single-sort-header/single-sort-header.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable @angular-eslint/prefer-standalone */ -import { Component, Input } from '@angular/core'; -import { TableColumn } from '../table-column'; - -@Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: '[hsi-ui-single-sort-header]', - templateUrl: './single-sort-header.component.html', - styleUrls: ['./single-sort-header.component.scss'], - standalone: false, -}) -export class SingleSortHeaderComponent { - @Input() column: TableColumn; - @Input() sortIcon: string; - - getColumnSortClasses(): string[] { - const baseClasses = [ - 'material-symbols-outlined', - this.column.sortDirection, - ]; - return this.column.activelySorted - ? baseClasses.concat('actively-sorted') - : baseClasses; - } -} diff --git a/libs/ui-components/src/lib/table/table-column-builder.ts b/libs/ui-components/src/lib/table/table-column-builder.ts new file mode 100644 index 000000000..a7b077012 --- /dev/null +++ b/libs/ui-components/src/lib/table/table-column-builder.ts @@ -0,0 +1,175 @@ +import { + SortDirection, + SortDirectionType, + TableColumn, + TableValue, +} from './table-column'; + +const DEFAULT = { + _sortable: false, + _sortedOnInit: false, + _sortOrder: Number.MAX_SAFE_INTEGER, + _sortDirection: SortDirection.asc, +}; + +/** + * An internal builder class for a single table column. + * @deprecated This class is deprecated. See `tanstack-example.component.ts` for the recommended implementation. + */ +export class TableColumnBuilder { + private _id: string; + private _key: string; + private _label: string; + private _getSortValue: (x: Datum) => TableValue; + private _ascendingSortFunction: (a: Datum, b: Datum) => number; + private _sortDirection: SortDirectionType; + private _sortable: boolean; + private _sortOrder: number; + + constructor() { + Object.assign(this, DEFAULT); + } + + /** + * REQUIRED. Determines the id of the table column. + * + * @param id A string to use as the id of the table column. + */ + id(id: string): this { + this._id = id; + return this; + } + /** + * OPTIONAL. Determines the label of the table column. + * + * @param label A string to use as the label of the table column. + */ + label(label: string): this { + this._label = label; + return this; + } + + /** + * REQUIRED. Determines the property key in the datum that + * is to be displayed in the table column. + * + * @param key A string to use as the display key of the table column. Nested object data + * can be accessed using dot notation (e.g. `user.name`). + */ + displayKey(key: string): this { + this._key = key; + return this; + } + + /** + * OPTIONAL. Specifies how to extract the datum property to be sorted + * on for cells in the table column. + * + * @param getSortValue A function to extract the datum property to be + * sorted on for cells in the table column, or `null` to not set this property. + */ + getSortValue(getSortValue: null): this; + getSortValue(getSortValue: (x: Datum) => TableValue): this; + getSortValue(getSortValue: (x: Datum) => TableValue | null): this { + if (getSortValue === null) { + this._getSortValue = undefined; + return this; + } + this._getSortValue = getSortValue; + return this; + } + + /** + * OPTIONAL. Specifies how datum are to be sorted in ascending order for the table column. + * If not provided, the column will use `d3.ascending` on the getSortValue output. + * + * @param ascendingSortFunction A function to sort datum in ascending order for the table column, + * or `null` to not set this property. + */ + ascendingSortFunction(ascendingSortFunction: null): this; + ascendingSortFunction( + ascendingSortFunction: (a: Datum, b: Datum) => number + ): this; + ascendingSortFunction( + ascendingSortFunction: (a: Datum, b: Datum) => number | null + ): this { + if (ascendingSortFunction === null) { + this._ascendingSortFunction = undefined; + return this; + } + this._ascendingSortFunction = ascendingSortFunction; + return this; + } + + /** + * OPTIONAL. Determines the direction to start sorting the column in. + * + * @param sortDirection A SortDirectionType to use as the sort direction of the table column. + * If not called, the default value is `SortDirection.asc`. + */ + sortDirection(sortDirection: SortDirectionType): this { + this._sortDirection = sortDirection; + return this; + } + + /** + * OPTIONAL. Determines whether the column is sortable. + * + * @param sortable A boolean to use as the sortable property of the table column. + * If not called, the default value is `false`. + */ + sortable(sortable: boolean): this { + this._sortable = sortable; + return this; + } + + /** + * OPTIONAL. Determines the sort order of the table column. + * + * @param sortOrder A number to use as the sort order of the table column. + * + * If not called, the default value is `Number.MAX_SAFE_INTEGER`. + */ + sortOrder(sortOrder: number): this { + this._sortOrder = sortOrder; + return this; + } + + /** + * @internal Not meant to be called by consumers of the library. + * + * @param columnName A user-intelligible name for the column being built. Used for error messages. Should be title cased. + */ + _build(columnName): TableColumn { + this.validateTableColumn(columnName); + return new TableColumn({ + id: this._id, + label: this._label, + key: this._key, + getSortValue: this._getSortValue, + ascendingSortFunction: this._ascendingSortFunction, + sortDirection: this._sortDirection, + sortable: this._sortable, + sortOrder: this._sortOrder, + }); + } + + /** + * Validates the table column properties before initializing the TableColumn instance. + * + * @param columnName A user-intelligible name for the column being built. Used for error messages. Should be title cased. + */ + protected validateTableColumn(columnName: string): void { + if (!this._id || !this._key) { + throw new Error(`ColumnBuilder: ${columnName}. ID and key are required.`); + } + if ( + this._sortable && + !(this._ascendingSortFunction || this._getSortValue) + ) { + throw new Error( + `ColumnBuilder: ${columnName}. Sortable columns must have at least one of the following fields: ascendingSortFunction or getSortValue.` + ); + } + } +} diff --git a/libs/ui-components/src/lib/table/table-column.ts b/libs/ui-components/src/lib/table/table-column.ts index aeed14625..5eb470cb0 100644 --- a/libs/ui-components/src/lib/table/table-column.ts +++ b/libs/ui-components/src/lib/table/table-column.ts @@ -8,36 +8,40 @@ export enum SortDirection { export type SortDirectionType = keyof typeof SortDirection; export type TableValue = string | number | boolean | Date; -export type TableCellAlignment = 'left' | 'center' | 'right'; +/** + * @deprecated This class is deprecated. See use of `ColumnDef` in `tanstack-example.component.ts` for the recommended implementation. + */ export class TableColumn { /** - * The label of the column. Used in the table header. + * The unique id of the column. * */ - label: string; + readonly id: string; /** - * Function to extract the value to be sorted on from the datum. - * If not provided, the formatted value will be used for sorting. + * The unique key of the column. Used for sorting and accessing the column data. + * */ - getSortValue: (x: Datum) => TableValue; + readonly key: string; /** - * Function to format the value for display in the table. - */ - getFormattedValue: (x: Datum) => string; - /** - * Function to determine the alignment of the cell content. + * The label of the column. Used in the table header. + * This field is required when using the `single-sort-header` component. */ - getAlignment: (x: Datum) => TableCellAlignment; + label: string; /** - * Width of the column. Can be a percentage or pixel value. + * Function to extract the value to be sorted on from the datum. + * If not provided, the formatted value will be used for sorting. */ - width: string; + getSortValue: (x: Datum) => TableValue; /** * Function to determine the sort order of the column. * If not provided, sort with use d3.ascending on the getSortValue or getFormattedValue. */ ascendingSortFunction: (a: Datum, b: Datum) => number; - sortDirection: SortDirectionType; + /** + * The direction to start sorting this column in. + * @default SortDirection.asc + */ + sortDirection: SortDirectionType = SortDirection.asc; /** * Whether the column is sortable. */ @@ -48,15 +52,14 @@ export class TableColumn { * Sorting tiebreaks are determined by increasing sortOrder number. **/ sortOrder: number = Number.MAX_SAFE_INTEGER; + readonly initialSortDirection: SortDirectionType; /** - * Whether the column is a row header. + * Whether the column data has been sorted since initialization. */ - isRowHeader = false; - readonly initialSortDirection: SortDirectionType; - + sortedOnInit = false; constructor(init?: Partial>) { this.sortDirection = SortDirection.asc; - this.getAlignment = () => 'left'; + // this.getAlignment = () => 'left'; safeAssign(this, init); this.initialSortDirection = this.sortDirection; if (this.ascendingSortFunction === undefined) { @@ -65,7 +68,7 @@ export class TableColumn { } defaultSort(a: Datum, b: Datum): number { - const accessor = this.getSortValue || this.getFormattedValue; + const accessor = this.getSortValue; return ascending(accessor(a), accessor(b)); } } diff --git a/libs/ui-components/src/lib/table/table-columns.builder.ts b/libs/ui-components/src/lib/table/table-columns.builder.ts new file mode 100644 index 000000000..342fe973d --- /dev/null +++ b/libs/ui-components/src/lib/table/table-columns.builder.ts @@ -0,0 +1,40 @@ +import { TableColumn } from './table-column'; +import { TableColumnBuilder } from './table-column-builder'; + +/** + * User-facing builder class for a list of table columns. + * @deprecated This class is deprecated. See `tanstack-example.component.ts` for the recommended implementation. + */ +export class TableColumnsBuilder { + private _columnBuilders: TableColumnBuilder[] = []; + + /** + * REQUIRED. Specifies a new column to be added to the end of the list of columns. + * + * @param columnBuilder The builder for the table column to add. + */ + addColumn( + applyToColumn: ( + column: TableColumnBuilder + ) => TableColumnBuilder + ): this { + this._columnBuilders.push(applyToColumn(new TableColumnBuilder())); + return this; + } + + /** + * REQUIRED. Validates and builds the configuration object for the table columns that can be passed to `HsiUiTableDataSource`. + * + * The user must call this at the end of the chain of methods to build the configuration object. + */ + getConfig(): TableColumn[] { + this.validateColumns(); + return this._columnBuilders.map((column, i) => + column.id(i.toString())._build(i) + ); + } + + private validateColumns(): void { + // Validation logic here + } +} diff --git a/libs/ui-components/src/lib/table/table.component.html b/libs/ui-components/src/lib/table/table.component.html deleted file mode 100644 index b755c2b2f..000000000 --- a/libs/ui-components/src/lib/table/table.component.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - -
- {{ column.getFormattedValue(element) }} - - {{ column.label }} - - {{ column.getFormattedValue(element) }} -
diff --git a/libs/ui-components/src/lib/table/table.component.scss b/libs/ui-components/src/lib/table/table.component.scss deleted file mode 100644 index 935ed9e94..000000000 --- a/libs/ui-components/src/lib/table/table.component.scss +++ /dev/null @@ -1,77 +0,0 @@ -$icon-width: 0.9rem; -$icon-left-margin: 0.2rem; -$icon-right-margin: 0.4rem; -.table-container { - border-spacing: 0; - - td:last-child { - &.left { - padding-right: 0; - } - } - - th:last-child { - &.left { - padding-right: 0; - } - } - - th { - vertical-align: bottom; - &.sorted-header { - padding-right: 0; - } - } - - .header-cell-sort { - display: flex; - align-items: flex-end; - &:hover { - cursor: pointer; - } - } - - .material-symbols-outlined { - display: flex; - justify-content: center; - width: $icon-width; - height: 1.2rem; - font-size: 1.25rem; - margin-left: $icon-left-margin; - margin-right: $icon-right-margin; - opacity: 0.25; - transition: all 150ms ease-in-out; - - &:hover { - opacity: 0.75; - } - - &.actively-sorted { - opacity: 1; - } - } - - .desc { - transform: rotate(180deg); - } - - .left { - text-align: left; - } - - .right { - text-align: right; - - .header-cell-sort { - justify-content: flex-end; - } - - &.sorted-cell { - padding-right: $icon-left-margin + $icon-width + $icon-right-margin; - } - } - - .center { - text-align: center; - } -} diff --git a/libs/ui-components/src/lib/table/table.component.spec.ts b/libs/ui-components/src/lib/table/table.component.spec.ts deleted file mode 100644 index ed257f72f..000000000 --- a/libs/ui-components/src/lib/table/table.component.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, tap } from 'rxjs'; -import { TestScheduler } from 'rxjs/testing'; -import { SortDirection, TableColumn } from './table-column'; -import { TableComponent } from './table.component'; - -describe('TableComponent', () => { - let component: TableComponent; - let fixture: ComponentFixture>; - let testScheduler: TestScheduler; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [TableComponent], - }); - fixture = TestBed.createComponent(TableComponent); - testScheduler = new TestScheduler((actual, expected) => { - return expect(actual).toEqual(expected); - }); - component = fixture.componentInstance; - }); - - describe('ngOnInit', () => { - beforeEach(() => { - spyOn(component, 'setTableData'); - spyOn(component, 'setTableHeaders'); - spyOn(component, 'validateRowHeaders'); - }); - it('should call setTableData', () => { - component.ngOnInit(); - expect(component.setTableData).toHaveBeenCalled(); - }); - it('should call setTableHeaders', () => { - component.ngOnInit(); - expect(component.setTableHeaders).toHaveBeenCalled(); - }); - it('should call validateRowHeaders', () => { - component.ngOnInit(); - expect(component.validateRowHeaders).toHaveBeenCalled(); - }); - }); - - describe('sortTableByColumn', () => { - it('should emit column', () => { - spyOn(component.sort, 'next'); - const column = { name: 'test', sort: SortDirection.asc }; - component.sortTableByColumn(column as any); - expect(component.sort.next).toHaveBeenCalledWith(column as any); - }); - }); - - describe('integration test: single sort column', () => { - it('correctly sorts data', () => { - const originalData = [ - { name: 'a', tiebreak: 0 }, - { name: 'a', tiebreak: 1 }, - { name: 'b', tiebreak: 0 }, - { name: 'd', tiebreak: 1 }, - { name: 'd', tiebreak: 0 }, - ]; - const chosenColumn = new TableColumn<{ - name: string; - tiebreak: number; - }>({ - getSortValue: (x) => x.name, - sortDirection: SortDirection.asc, - label: 'name', - sortOrder: 1, - }); - const columns = [ - chosenColumn, - new TableColumn<{ - name: string; - tiebreak: number; - }>({ - getSortValue: (x) => x.tiebreak, - sortDirection: SortDirection.asc, - sortOrder: 2, - }), - ]; - component.config$ = of({ data: originalData, columns: columns }); - component.ngOnInit(); - - const ascData = [ - { name: 'a', tiebreak: 0 }, - { name: 'a', tiebreak: 1 }, - { name: 'b', tiebreak: 0 }, - { name: 'd', tiebreak: 0 }, - { name: 'd', tiebreak: 1 }, - ]; - - const descData = [ - { name: 'd', tiebreak: 0 }, - { name: 'd', tiebreak: 1 }, - { name: 'b', tiebreak: 0 }, - { name: 'a', tiebreak: 0 }, - { name: 'a', tiebreak: 1 }, - ]; - - const expectedMarbles = 'abc'; - const triggerMarbles = ' -xy|'; - const expectedValues = { - a: ascData, // correctly emits sorted data on first emission - b: descData, // flips correctly - c: ascData, // flops correctly - }; - const triggerValues = { - x: () => component.sortTableByColumn(chosenColumn), - y: () => component.sortTableByColumn(chosenColumn), - }; - - testScheduler.run(({ expectObservable, cold }) => { - expectObservable(component.data$).toBe(expectedMarbles, expectedValues); - expectObservable( - cold(triggerMarbles, triggerValues).pipe(tap((fn) => fn())) - ); - }); - }); - }); -}); diff --git a/libs/ui-components/src/lib/table/table.component.ts b/libs/ui-components/src/lib/table/table.component.ts deleted file mode 100644 index a02d1b5b8..000000000 --- a/libs/ui-components/src/lib/table/table.component.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - Input, - OnInit, - ViewEncapsulation, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { min } from 'd3'; -import { isEqual } from 'lodash-es'; -import { - BehaviorSubject, - Observable, - distinctUntilChanged, - filter, - map, - merge, - scan, - shareReplay, - withLatestFrom, -} from 'rxjs'; -import { SortDirection, TableColumn } from './table-column'; -import { HsiUiTableConfig } from './table.config'; - -@Component({ - selector: 'hsi-ui-table', - templateUrl: './table.component.html', - styleUrls: ['./table.component.scss'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - // eslint-disable-next-line @angular-eslint/prefer-standalone - standalone: false, -}) -export class TableComponent implements OnInit { - @Input() config$: Observable>; - @Input() sortIcon = 'arrow_upward'; - data$: Observable; - columns$: Observable[]>; - tableHeaders$: Observable; - sort: BehaviorSubject> = new BehaviorSubject(null); - sort$: Observable> = this.sort.asObservable(); - - constructor(private destroyRef: DestroyRef) {} - - ngOnInit(): void { - this.setTableData(); - this.setTableHeaders(); - this.validateRowHeaders(); - } - - setTableData(): void { - const config$ = this.config$.pipe( - withLatestFrom(this.sort$), - map(([config, sort]) => () => { - const activeSortColumn = - sort || this.getMinSortOrderColumn(config.columns); - const columns = this.getColumnsWithNewSortApplied( - activeSortColumn, - config.columns, - false - ); - return { - data: this.sortData(config.data, activeSortColumn, columns), - columns, - }; - }) - ); - - const sort$ = this.sort$.pipe( - filter((sort) => sort !== null), - map((sort) => (sortedConfig: HsiUiTableConfig) => { - const columns = this.getColumnsWithNewSortApplied( - sort, - sortedConfig.columns - ); - return { - data: this.sortData(sortedConfig.data, sort, columns), - columns, - }; - }) - ); - const sortedConfig$ = merge(config$, sort$).pipe( - scan((sortedConfig, changeFn) => changeFn(sortedConfig), { - data: [], - columns: [], - }), - shareReplay(1) // do not remove sort toggle will be called twice - ); - - this.data$ = sortedConfig$.pipe( - map((x) => x.data), - shareReplay(1) - ); - this.columns$ = sortedConfig$.pipe( - map((x) => x.columns), - shareReplay(1) - ); - } - - getMinSortOrderColumn(columns: TableColumn[]): TableColumn { - const minSortOrder = min(columns, (x) => x.sortOrder); - return columns.find((x) => x.sortOrder === minSortOrder); - } - - getColumnsWithNewSortApplied( - activeSortColumn: TableColumn, - columns: TableColumn[], - toggleSortDirection = true - ): TableColumn[] { - const columnsWithSortDir = columns.map((x) => { - if (x.label === activeSortColumn.label) { - if (toggleSortDirection) { - x.sortDirection = - x.sortDirection === SortDirection.asc - ? SortDirection.desc - : SortDirection.asc; - } - x.activelySorted = true; - } else { - if (toggleSortDirection) { - x.sortDirection = x.initialSortDirection; - } - x.activelySorted = false; - } - return x; - }); - return columnsWithSortDir; - } - - setTableHeaders(): void { - this.tableHeaders$ = this.columns$.pipe( - map((columns) => columns.map((x) => x.label)), - distinctUntilChanged((a, b) => isEqual(a, b)), - shareReplay(1) - ); - } - - validateRowHeaders(): void { - this.columns$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((columns) => { - const rowHeaders = columns.filter((x) => x.isRowHeader); - if (rowHeaders.length > 1) { - throw new Error( - 'Table can only have one row header column. Please update your column config.' - ); - } - }); - } - - sortTableByColumn(column: TableColumn) { - this.sort.next(column); - } - - sortData( - data: Datum[], - primaryColumnSort: TableColumn, - columns: TableColumn[] - ): Datum[] { - const sortedColumns = columns.slice().sort((columnA, columnB) => { - return columnA.label === primaryColumnSort.label - ? -1 - : columnB.label === primaryColumnSort.label - ? 1 - : columnA.sortOrder - columnB.sortOrder; - }); - - const sortedData = data.slice().sort((a, b) => { - for (const column of sortedColumns) { - let returnValue = column.ascendingSortFunction(a, b); - if (column.sortDirection === SortDirection.desc) { - returnValue *= -1; - } - if (returnValue !== 0) return returnValue; - } - return 0; - }); - return sortedData; - } - - columnTrackingFunction(_: number, column: TableColumn) { - return column.label; - } -} diff --git a/libs/ui-components/src/lib/table/table.data-source.spec.ts b/libs/ui-components/src/lib/table/table.data-source.spec.ts new file mode 100644 index 000000000..95a13a276 --- /dev/null +++ b/libs/ui-components/src/lib/table/table.data-source.spec.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { TestBed } from '@angular/core/testing'; +import { of, tap } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; +import { SortDirection, TableColumn } from './table-column'; +import { HsiUiTableDataSource } from './table.data-source'; + +describe('DataSource', () => { + let testScheduler: TestScheduler; + + beforeEach(() => { + TestBed.configureTestingModule({}); + testScheduler = new TestScheduler((actual, expected) => { + return expect(actual).toEqual(expected); + }); + }); + + describe('integration test: single sort column', () => { + it('correctly sorts data', () => { + const originalData = [ + { name: 'a', tiebreak: 0 }, + { name: 'a', tiebreak: 1 }, + { name: 'b', tiebreak: 0 }, + { name: 'd', tiebreak: 1 }, + { name: 'd', tiebreak: 0 }, + ]; + const chosenColumn = new TableColumn<{ + name: string; + tiebreak: number; + }>({ + getSortValue: (x) => x.name, + sortDirection: SortDirection.asc, + sortable: true, + id: 'name', + sortOrder: 1, + }); + const dataSource = new HsiUiTableDataSource( + of(originalData), + of([ + chosenColumn, + new TableColumn<{ + name: string; + tiebreak: number; + }>({ + getSortValue: (x) => x.tiebreak, + sortOrder: 2, + }), + ]) + ); + const connection$ = dataSource.connect(); + const ascData = [ + { name: 'a', tiebreak: 0 }, + { name: 'a', tiebreak: 1 }, + { name: 'b', tiebreak: 0 }, + { name: 'd', tiebreak: 0 }, + { name: 'd', tiebreak: 1 }, + ]; + + const descData = [ + { name: 'd', tiebreak: 0 }, + { name: 'd', tiebreak: 1 }, + { name: 'b', tiebreak: 0 }, + { name: 'a', tiebreak: 0 }, + { name: 'a', tiebreak: 1 }, + ]; + + const expectedMarbles = 'abc'; + const triggerMarbles = ' -xy|'; + const expectedValues = { + a: originalData, // correctly emits non-sorted data on first emission + b: ascData, // sorts correctly + c: descData, // flips correctly + }; + const triggerValues = { + x: () => dataSource.sort(chosenColumn), + y: () => dataSource.sort(chosenColumn), + }; + testScheduler.run(({ expectObservable, cold }) => { + expectObservable(connection$).toBe(expectedMarbles, expectedValues); + expectObservable( + cold(triggerMarbles, triggerValues).pipe(tap((fn) => fn())) + ); + }); + }); + }); +}); diff --git a/libs/ui-components/src/lib/table/table.data-source.ts b/libs/ui-components/src/lib/table/table.data-source.ts new file mode 100644 index 000000000..102087e6f --- /dev/null +++ b/libs/ui-components/src/lib/table/table.data-source.ts @@ -0,0 +1,146 @@ +import { DataSource } from '@angular/cdk/collections'; +import { + BehaviorSubject, + Observable, + combineLatest, + filter, + map, + merge, + scan, + shareReplay, + withLatestFrom, +} from 'rxjs'; +import { SortDirection, TableColumn } from './table-column'; + +/** + * @deprecated This class is deprecated. See `tanstack-example.component.ts` for the recommended implementation. + */ +export class HsiUiTableDataSource extends DataSource { + private sortedData$: Observable; + + private sortColId = new BehaviorSubject(null); + private sortColId$ = this.sortColId.asObservable(); + + columns$: Observable[]>; + columnIds$: Observable; + + // TODO: plan sort column directive + constructor( + inputData$: Observable, + inputColumns$: Observable[]> + ) { + super(); + this.columns$ = inputColumns$.pipe(shareReplay(1)); + this.columnIds$ = this.columns$.pipe( + map((columns) => columns.map((x) => x.id)) + ); + const config$ = combineLatest([inputData$, this.columns$]).pipe( + withLatestFrom(this.sortColId$), + map(([[data, cols], sortId]) => () => { + const columns = this.getColumnsWithNewSortApplied(sortId, cols, false); + return { + data: sortId ? this.sortData(data, sortId, columns) : data, + columns, + }; + }) + ); + + const sort$ = this.sortColId$.pipe( + filter((sortId) => sortId !== null), + map( + (sortId) => + (sortedConfig: { data: Datum[]; columns: TableColumn[] }) => { + const columns = this.getColumnsWithNewSortApplied( + sortId, + sortedConfig.columns, + true + ); + return { + data: this.sortData(sortedConfig.data, sortId, columns), + columns: columns, + }; + } + ) + ); + + const sortedConfig$ = merge(config$, sort$).pipe( + scan((sortedConfig, changeFn) => changeFn(sortedConfig), { + data: [], + columns: [], + }), + shareReplay(1) // do not remove sort toggle will be called twice + ); + + this.sortedData$ = sortedConfig$.pipe( + map((x) => x.data), + shareReplay(1) + ); + this.columns$ = sortedConfig$.pipe( + map((x) => x.columns), + shareReplay(1) + ); + } + + sortData( + data: Datum[], + primaryColumnSortId: string, + columns: TableColumn[] + ): Datum[] { + const sortedColumns = columns.slice().sort((columnA, columnB) => { + return columnA.id === primaryColumnSortId + ? -1 + : columnB.id === primaryColumnSortId + ? 1 + : columnA.sortOrder - columnB.sortOrder; + }); + + const sortedData = data.slice().sort((a, b) => { + for (const column of sortedColumns) { + let returnValue = column.ascendingSortFunction(a, b); + if (column.sortDirection === SortDirection.desc) { + returnValue *= -1; + } + if (returnValue !== 0) return returnValue; + } + return 0; + }); + return sortedData; + } + + getColumnsWithNewSortApplied( + activeSortColumnId: string, + columns: TableColumn[], + toggleSortDirection = true + ): TableColumn[] { + const columnsWithSortDir = columns.map((x) => { + if (x.id === activeSortColumnId) { + if (toggleSortDirection && x.sortedOnInit) { + x.sortDirection = + x.sortDirection === SortDirection.asc + ? SortDirection.desc + : SortDirection.asc; + } else if (toggleSortDirection) { + x.sortedOnInit = true; + } + x.activelySorted = true; + } else { + if (toggleSortDirection) { + x.sortDirection = x.initialSortDirection; + } + x.activelySorted = false; + } + return x; + }); + return columnsWithSortDir; + } + + sort(column: TableColumn) { + this.sortColId.next(column.id); + } + + disconnect(): void {} + + connect(): Observable { + return this.sortedData$; + } +} diff --git a/libs/ui-components/src/lib/table/table.module.ts b/libs/ui-components/src/lib/table/table.module.ts index e4d256bc8..82e31a749 100644 --- a/libs/ui-components/src/lib/table/table.module.ts +++ b/libs/ui-components/src/lib/table/table.module.ts @@ -2,12 +2,9 @@ import { CdkTableModule } from '@angular/cdk/table'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; -import { SingleSortHeaderComponent } from './single-sort-header/single-sort-header.component'; -import { TableComponent } from './table.component'; @NgModule({ - declarations: [TableComponent, SingleSortHeaderComponent], imports: [CommonModule, CdkTableModule, MatIconModule], - exports: [TableComponent], + exports: [CdkTableModule], }) export class TableModule {} diff --git a/package-lock.json b/package-lock.json index b4ab1c4f0..e48d21518 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@angular/platform-browser": "19.2.2", "@angular/platform-browser-dynamic": "19.2.2", "@angular/router": "19.2.2", + "@tanstack/angular-table": "^8.21.3", "copyfiles": "^2.4.1", "csstype": "^3.1.3", "d3": "^7.8.5", @@ -13362,6 +13363,39 @@ "node": ">=10" } }, + "node_modules/@tanstack/angular-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/angular-table/-/angular-table-8.21.3.tgz", + "integrity": "sha512-8VqEGObnTBNJm3qQSPy+WEGqjXDLesEPSnBTEHdbHFh1rMP1pQsgI85Dwy8OX4kB82wIDvJuw4LceGIXZEbotA==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@angular/core": ">=17" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@thednp/event-listener": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@thednp/event-listener/-/event-listener-2.0.8.tgz", diff --git a/package.json b/package.json index 29556d30c..34c5bce02 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@angular/platform-browser": "19.2.2", "@angular/platform-browser-dynamic": "19.2.2", "@angular/router": "19.2.2", + "@tanstack/angular-table": "^8.21.3", "copyfiles": "^2.4.1", "csstype": "^3.1.3", "d3": "^7.8.5",