[SM-74] TableDataSource for sorting (#4079)

* Initial draft of a table data source

* Improve table data source

* Migrate projects table for demo

* Update existing tables

* Fix access selector

* remove sortDirection from custom fn

* a11y improvements

* update icons; make button full width

* update storybook docs

* apply code review changes

* fix: add table body to projects list

* Fix error on create secret. Fix project list setting projects on getter. Copy table data on set. Fix documentation

* Change signature to protected, rename method to not start with underscore

* add hover and focus effects

Co-authored-by: William Martin <contact@willmartian.com>
This commit is contained in:
Oscar Hinton 2023-01-12 23:06:58 +01:00 committed by GitHub
parent 23897ae5fb
commit 344a054ba2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 557 additions and 33 deletions

View File

@ -40,7 +40,7 @@
<th bitCell style="width: 50px"></th>
</tr>
</ng-container>
<ng-container body formArrayName="items">
<ng-template body formArrayName="items">
<tr
bitRow
*ngFor="let item of selectionList.selectedItems; let i = index"
@ -134,5 +134,5 @@
<tr *ngIf="selectionList.selectedItems.length == 0">
<td bitCell>{{ emptySelectionText }}</td>
</tr>
</ng-container>
</ng-template>
</bit-table>

View File

@ -75,7 +75,7 @@
<th bitCell>{{ "event" | i18n }}</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr bitRow *ngFor="let e of events" alignContent="top">
<td bitCell class="tw-whitespace-nowrap">{{ e.date | date: "medium" }}</td>
<td bitCell>
@ -86,7 +86,7 @@
</td>
<td bitCell [innerHTML]="e.message"></td>
</tr>
</ng-container>
</ng-template>
</bit-table>
<button
#moreBtn

View File

@ -16,12 +16,12 @@
<th bitCell>{{ "error" | i18n }}</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr bitRow *ngFor="let detail of data.details">
<td bitCell>{{ detail.name }}</td>
<td bitCell>{{ detail.errorMessage }}</td>
</tr>
</ng-container>
</ng-template>
</bit-table>
</div>

View File

@ -11,7 +11,7 @@
</button>
</sm-no-items>
<bit-table *ngIf="projects?.length >= 1">
<bit-table *ngIf="projects?.length >= 1" [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell class="tw-w-0">
@ -25,8 +25,8 @@
{{ "all" | i18n }}
</label>
</th>
<th bitCell colspan="2">{{ "name" | i18n }}</th>
<th bitCell>{{ "lastEdited" | i18n }}</th>
<th bitCell colspan="2" bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0">
<button
bitIconButton="bwi-ellipsis-v"
@ -38,8 +38,8 @@
</th>
</tr>
</ng-container>
<ng-container body>
<tr bitRow *ngFor="let project of projects; index as i">
<ng-template body let-rows$>
<tr bitRow *ngFor="let project of rows$ | async">
<td bitCell>
<input
type="checkbox"
@ -78,7 +78,7 @@
</button>
</bit-menu>
</tr>
</ng-container>
</ng-template>
</bit-table>
<bit-menu #tableMenu>

View File

@ -2,6 +2,8 @@ import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { TableDataSource } from "@bitwarden/components";
import { ProjectListView } from "../../models/view/project-list.view";
@Component({
@ -9,6 +11,8 @@ import { ProjectListView } from "../../models/view/project-list.view";
templateUrl: "./projects-list.component.html",
})
export class ProjectsListComponent implements OnDestroy {
protected dataSource = new TableDataSource<ProjectListView>();
@Input()
get projects(): ProjectListView[] {
return this._projects;
@ -16,6 +20,7 @@ export class ProjectsListComponent implements OnDestroy {
set projects(projects: ProjectListView[]) {
this.selection.clear();
this._projects = projects;
this.dataSource.data = projects;
}
private _projects: ProjectListView[];

View File

@ -44,7 +44,7 @@
<th bitCell></th>
</tr>
</ng-container>
<ng-container body *ngIf="selectedProjects != null">
<ng-template body *ngIf="selectedProjects != null">
<tr bitRow *ngFor="let e of selectedProjects">
<td bitCell>{{ e.name }}</td>
<td bitCell class="tw-w-0">
@ -57,7 +57,7 @@
></button>
</td>
</tr>
</ng-container>
</ng-template>
</bit-table>
</bit-tab>
</bit-tab-group>

View File

@ -39,7 +39,7 @@
</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr bitRow *ngFor="let token of tokens">
<td bitCell>
<input
@ -73,5 +73,5 @@
</button>
</bit-menu>
</tr>
</ng-container>
</ng-template>
</bit-table>

View File

@ -29,13 +29,13 @@
<th bitCell>{{ "permissions" | i18n }}</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr>
<!-- TODO once access is implement display selected access -->
<td bitCell>example</td>
<td bitCell>example</td>
</tr>
</ng-container>
</ng-template>
</bit-table>
</div>
<div bitDialogFooter class="tw-flex tw-gap-2">

View File

@ -39,7 +39,7 @@
</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr bitRow *ngFor="let serviceAccount of serviceAccounts">
<td bitCell>
<input
@ -83,7 +83,7 @@
</button>
</bit-menu>
</tr>
</ng-container>
</ng-template>
</bit-table>
<bit-menu #tableMenu>

View File

@ -39,7 +39,7 @@
</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr bitRow *ngFor="let secret of secrets">
<td bitCell>
<input
@ -96,7 +96,7 @@
</button>
</bit-menu>
</tr>
</ng-container>
</ng-template>
</bit-table>
<bit-menu #tableMenu>

View File

@ -1,4 +1,4 @@
import { Meta, Story, Source } from "@storybook/addon-docs";
import { Meta, Story } from "@storybook/addon-docs";
<Meta title="Documentation/Button" />

View File

@ -0,0 +1,104 @@
import { Meta, Story, Source } from "@storybook/addon-docs";
<Meta title="Documentation/Table" />
# Table
## Overview
All tables should have a visible horizontal header and label for each column.
<Story id="component-library-table--default" />
The below code is the absolute minimum required to create a table. However we stronly advice you to
use the `dataSource` input to provide a data source for your table. This allows you to easily sort
data.
```html
<bit-table>
<ng-container header>
<tr>
<th bitCell>Header 1</th>
<th bitCell>Header 2</th>
<th bitCell>Header 3</th>
</tr>
</ng-container>
<ng-template body>
<tr bitRow>
<td bitCell>Cell 1</td>
<td bitCell>Cell 2</td>
<td bitCell>Cell 3</td>
</tr>
</ng-template>
</bit-table>
```
## Data Source
Bitwarden provides a data source for tables that can be used in place of a traditional data array.
The `TableDataSource` implements sorting and will in the future also support filtering. This allows
the `bitTable` component to focus on rendering while offloading the data management to the data
source.
```ts
// External data source
const data: T[];
const dataSource = new TableDataSource<T>();
dataSource.data = data;
```
We use the `dataSource` as an input to the `bit-table` component, and access the rows to render
within the `ng-template`which provides access to the rows using `let-rows$`.
<Source id="component-library-table--data-source" />
### Sortable
We provide a simple component for displaying sortable column headers. The `bitSortable` component
wires up to the `TableDataSource` and will automatically sort the data when clicked and display
an indicator for which column is currently sorted. The dafault sorting can be specified by setting
the `default`.
```html
<th bitCell bitSortable="id" default>Id</th>
<th bitCell bitSortable="name" default>Name</th>
```
It's also possible to define a custom sorting function by setting the `fn` input.
```ts
const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
```
### Virtual Scrolling
It's heavily adviced to use virtual scrolling if you expect the table to have any significant amount
of data. This is easily done by wrapping the table in the `cdk-virtual-scroll-viewport` component,
specify a `itemSize`, set `scrollWindow` to `true` and replace `*ngFor` with `*cdkVirtualFor`.
```html
<cdk-virtual-scroll-viewport scrollWindow itemSize="47">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell bitSortable="id" default>Id</th>
<th bitCell bitSortable="name">Name</th>
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
</tr>
</ng-container>
<ng-template let-rows$>
<tr bitRow *cdkVirtualFor="let r of rows$">
<td bitCell>{{ r.id }}</td>
<td bitCell>{{ r.name }}</td>
<td bitCell>{{ r.other }}</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
```
## Accessibility
- Always incude a row or column header with your table; this allows screen readers to better contextualize the data
- Avoid spanning data across cells

View File

@ -1 +1,2 @@
export * from "./table.module";
export * from "./table-data-source";

View File

@ -0,0 +1,125 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, HostBinding, Input, OnInit } from "@angular/core";
import type { SortFn } from "./table-data-source";
import { TableComponent } from "./table.component";
@Component({
selector: "th[bitSortable]",
template: `
<button
class="tw-group"
[ngClass]="classList"
[attr.aria-pressed]="isActive"
(click)="setActive()"
>
<ng-content></ng-content>
<i class="bwi tw-ml-2" [ngClass]="icon"></i>
</button>
`,
})
export class SortableComponent implements OnInit {
/**
* Mark the column as sortable and specify the key to sort by
*/
@Input() bitSortable: string;
private _default: boolean;
/**
* Mark the column as the default sort column
*/
@Input() set default(value: boolean | "") {
this._default = coerceBooleanProperty(value);
}
/**
* Custom sorting function
*
* @example
* fn = (a, b) => a.name.localeCompare(b.name)
*/
@Input() fn: SortFn;
constructor(private table: TableComponent) {}
ngOnInit(): void {
if (this._default && !this.isActive) {
this.setActive();
}
}
@HostBinding("attr.aria-sort") get ariaSort() {
if (!this.isActive) {
return undefined;
}
return this.sort.direction === "asc" ? "ascending" : "descending";
}
protected setActive() {
if (this.table.dataSource) {
const direction = this.isActive && this.direction === "asc" ? "desc" : "asc";
this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn };
}
}
private get sort() {
return this.table.dataSource?.sort;
}
get isActive() {
return this.sort?.column === this.bitSortable;
}
get direction() {
return this.sort?.direction;
}
get icon() {
if (!this.isActive) {
return "bwi-chevron-up tw-opacity-0 group-hover:tw-opacity-100 group-focus-visible:tw-opacity-100";
}
return this.direction === "asc" ? "bwi-chevron-up" : "bwi-angle-down";
}
get classList() {
return [
// Offset to border and padding
"-tw-m-1.5",
// Below is copied from BitIconButtonComponent
"tw-font-semibold",
"tw-border",
"tw-border-solid",
"tw-rounded",
"tw-transition",
"hover:tw-no-underline",
"focus:tw-outline-none",
"tw-bg-transparent",
"!tw-text-muted",
"tw-border-transparent",
"hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent",
// Workaround for box-shadow with transparent offset issue:
// https://github.com/tailwindlabs/tailwindcss/issues/3595
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
"tw-relative",
"before:tw-content-['']",
"before:tw-block",
"before:tw-absolute",
"before:-tw-inset-[3px]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring",
"before:tw-ring-transparent",
"focus-visible:tw-z-10",
];
}
}

View File

@ -0,0 +1,164 @@
import { _isNumberValue } from "@angular/cdk/coercion";
import { DataSource } from "@angular/cdk/collections";
import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs";
export type SortDirection = "asc" | "desc";
export type SortFn = (a: any, b: any) => number;
export type Sort = {
column?: string;
direction: SortDirection;
fn?: SortFn;
};
// Loosely based on CDK TableDataSource
// https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
export class TableDataSource<T> extends DataSource<T> {
private readonly _data: BehaviorSubject<T[]>;
private readonly _sort: BehaviorSubject<Sort>;
private readonly _renderData = new BehaviorSubject<T[]>([]);
private _renderChangesSubscription: Subscription | null = null;
constructor() {
super();
this._data = new BehaviorSubject([]);
this._sort = new BehaviorSubject({ direction: "asc" });
}
get data() {
return this._data.value;
}
set data(data: T[]) {
this._data.next(data ? [...data] : []);
}
set sort(sort: Sort) {
this._sort.next(sort);
}
get sort() {
return this._sort.value;
}
connect(): Observable<readonly T[]> {
if (!this._renderChangesSubscription) {
this.updateChangeSubscription();
}
return this._renderData;
}
disconnect(): void {
this._renderChangesSubscription?.unsubscribe();
this._renderChangesSubscription = null;
}
private updateChangeSubscription() {
const orderedData = combineLatest([this._data, this._sort]).pipe(
map(([data]) => this.orderData(data))
);
this._renderChangesSubscription?.unsubscribe();
this._renderChangesSubscription = orderedData.subscribe((data) => this._renderData.next(data));
}
private orderData(data: T[]): T[] {
if (!this.sort) {
return data;
}
return this.sortData(data, this.sort);
}
/**
* Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
* License: MIT
* Copyright (c) 2022 Google LLC.
*
* Data accessor function that is used for accessing data properties for sorting through
* the default sortData function.
* This default function assumes that the sort header IDs (which defaults to the column name)
* matches the data's properties (e.g. column Xyz represents data['Xyz']).
* May be set to a custom function for different behavior.
* @param data Data object that is being accessed.
* @param sortHeaderId The name of the column that represents the data.
*/
protected sortingDataAccessor(data: T, sortHeaderId: string): string | number {
const value = (data as unknown as Record<string, any>)[sortHeaderId];
if (_isNumberValue(value)) {
const numberValue = Number(value);
return numberValue < Number.MAX_SAFE_INTEGER ? numberValue : value;
}
return value;
}
/**
* Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts
* License: MIT
* Copyright (c) 2022 Google LLC.
*
* Gets a sorted copy of the data array based on the state of the MatSort. Called
* after changes are made to the filtered data or when sort changes are emitted from MatSort.
* By default, the function retrieves the active sort and its direction and compares data
* by retrieving data using the sortingDataAccessor. May be overridden for a custom implementation
* of data ordering.
* @param data The array of data that should be sorted.
* @param sort The connected MatSort that holds the current sort state.
*/
protected sortData(data: T[], sort: Sort): T[] {
const column = sort.column;
const direction = sort.direction;
if (!column) {
return data;
}
return data.sort((a, b) => {
// If a custom sort function is provided, use it instead of the default.
if (sort.fn) {
return sort.fn(a, b) * (direction === "asc" ? 1 : -1);
}
let valueA = this.sortingDataAccessor(a, column);
let valueB = this.sortingDataAccessor(b, column);
// If there are data in the column that can be converted to a number,
// it must be ensured that the rest of the data
// is of the same type so as not to order incorrectly.
const valueAType = typeof valueA;
const valueBType = typeof valueB;
if (valueAType !== valueBType) {
if (valueAType === "number") {
valueA += "";
}
if (valueBType === "number") {
valueB += "";
}
}
// If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if
// one value exists while the other doesn't. In this case, existing value should come last.
// This avoids inconsistent results when comparing values to undefined/null.
// If neither value exists, return 0 (equal).
let comparatorResult = 0;
if (valueA != null && valueB != null) {
// Check if one value is greater than the other; if equal, comparatorResult should remain 0.
if (valueA > valueB) {
comparatorResult = 1;
} else if (valueA < valueB) {
comparatorResult = -1;
}
} else if (valueA != null) {
comparatorResult = 1;
} else if (valueB != null) {
comparatorResult = -1;
}
return comparatorResult * (direction === "asc" ? 1 : -1);
});
}
}

View File

@ -5,6 +5,8 @@
<ng-content select="[header]"></ng-content>
</thead>
<tbody>
<ng-content select="[body]"></ng-content>
<ng-container
*ngTemplateOutlet="templateVariable.template; context: { $implicit: rows }"
></ng-container>
</tbody>
</table>

View File

@ -1,7 +1,50 @@
import { Component } from "@angular/core";
import { isDataSource } from "@angular/cdk/collections";
import {
AfterContentChecked,
Component,
ContentChild,
Directive,
Input,
OnDestroy,
TemplateRef,
} from "@angular/core";
import { Observable } from "rxjs";
import { TableDataSource } from "./table-data-source";
@Directive({
selector: "ng-template[body]",
})
export class TableBodyDirective {
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
constructor(public readonly template: TemplateRef<any>) {}
}
@Component({
selector: "bit-table",
templateUrl: "./table.component.html",
})
export class TableComponent {}
export class TableComponent implements OnDestroy, AfterContentChecked {
@Input() dataSource: TableDataSource<any>;
@ContentChild(TableBodyDirective) templateVariable: TableBodyDirective;
protected rows: Observable<readonly any[]>;
private _initialized = false;
ngAfterContentChecked(): void {
if (!this._initialized && isDataSource(this.dataSource)) {
this._initialized = true;
const dataStream = this.dataSource.connect();
this.rows = dataStream;
}
}
ngOnDestroy(): void {
if (isDataSource(this.dataSource)) {
this.dataSource.disconnect();
}
}
}

View File

@ -3,11 +3,18 @@ import { NgModule } from "@angular/core";
import { CellDirective } from "./cell.directive";
import { RowDirective } from "./row.directive";
import { TableComponent } from "./table.component";
import { SortableComponent } from "./sortable.component";
import { TableBodyDirective, TableComponent } from "./table.component";
@NgModule({
imports: [CommonModule],
declarations: [TableComponent, CellDirective, RowDirective],
exports: [TableComponent, CellDirective, RowDirective],
declarations: [
TableComponent,
CellDirective,
RowDirective,
SortableComponent,
TableBodyDirective,
],
exports: [TableComponent, CellDirective, RowDirective, SortableComponent, TableBodyDirective],
})
export class TableModule {}

View File

@ -1,12 +1,14 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { TableDataSource } from "./table-data-source";
import { TableModule } from "./table.module";
export default {
title: "Component Library/Table",
decorators: [
moduleMetadata({
imports: [TableModule],
imports: [TableModule, ScrollingModule],
}),
],
argTypes: {
@ -34,7 +36,7 @@ const Template: Story = (args) => ({
<th bitCell>Header 3</th>
</tr>
</ng-container>
<ng-container body>
<ng-template body>
<tr bitRow [alignContent]="alignRowContent">
<td bitCell>Cell 1</td>
<td bitCell>Cell 2 <br> Multiline Cell</td>
@ -50,9 +52,8 @@ const Template: Story = (args) => ({
<td bitCell>Cell 8</td>
<td bitCell>Cell 9</td>
</tr>
</ng-container>
</ng-template>
</bit-table>
`,
});
@ -60,3 +61,75 @@ export const Default = Template.bind({});
Default.args = {
alignRowContent: "baseline",
};
const data = new TableDataSource<{ id: number; name: string; other: string }>();
data.data = [...Array(5).keys()].map((i) => ({
id: i,
name: `name-${i}`,
other: `other-${i}`,
}));
const DataSourceTemplate: Story = (args) => ({
props: {
dataSource: data,
sortFn: (a: any, b: any) => a.id - b.id,
},
template: `
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell bitSortable="id" default>Id</th>
<th bitCell bitSortable="name">Name</th>
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let r of rows$ | async">
<td bitCell>{{ r.id }}</td>
<td bitCell>{{ r.name }}</td>
<td bitCell>{{ r.other }}</td>
</tr>
</ng-template>
</bit-table>
`,
});
export const DataSource = DataSourceTemplate.bind({});
const data2 = new TableDataSource<{ id: number; name: string; other: string }>();
data2.data = [...Array(100).keys()].map((i) => ({
id: i,
name: `name-${i}`,
other: `other-${i}`,
}));
const ScrollableTemplate: Story = (args) => ({
props: {
dataSource: data2,
sortFn: (a: any, b: any) => a.id - b.id,
},
template: `
<cdk-virtual-scroll-viewport scrollWindow itemSize="47">
<bit-table [dataSource]="dataSource">
<ng-container header>
<tr>
<th bitCell bitSortable="id" default>Id</th>
<th bitCell bitSortable="name">Name</th>
<th bitCell bitSortable="other" [fn]="sortFn">Other</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *cdkVirtualFor="let r of rows$">
<td bitCell>{{ r.id }}</td>
<td bitCell>{{ r.name }}</td>
<td bitCell>{{ r.other }}</td>
</tr>
</ng-template>
</bit-table>
</cdk-virtual-scroll-viewport>
`,
});
export const Scrollable = ScrollableTemplate.bind({});