1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 15:23:33 +00:00

[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
19 changed files with 557 additions and 33 deletions

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({});