From 42ba475c377bf4de4f707414af3b0b411ae5ee8e Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 14 Feb 2023 11:38:53 +0100 Subject: [PATCH] [SM-497] Add filtering capabilities to the table datasource (#4717) * Add filtering capabilities to the table datasource --- .../projects-list/projects-list.component.ts | 5 ++ .../projects/projects/projects.component.html | 3 +- .../projects/projects/projects.component.ts | 3 +- .../components/src/table/table-data-source.ts | 65 +++++++++++++++++-- libs/components/src/table/table.stories.ts | 35 ++++++++++ 5 files changed, 104 insertions(+), 7 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts index a81aea7057..869895150a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects-list/projects-list.component.ts @@ -24,6 +24,11 @@ export class ProjectsListComponent implements OnDestroy { } private _projects: ProjectListView[]; + @Input() + set search(search: string) { + this.dataSource.filter = search; + } + @Output() editProjectEvent = new EventEmitter(); @Output() deleteProjectEvent = new EventEmitter(); @Output() onProjectCheckedEvent = new EventEmitter(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html index 2e6c092caa..bc0b331d7d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.html @@ -1,5 +1,5 @@ - + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts index 1d36212171..440e88864b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -21,7 +21,8 @@ import { ProjectService } from "../project.service"; templateUrl: "./projects.component.html", }) export class ProjectsComponent implements OnInit { - projects$: Observable; + protected projects$: Observable; + protected search: string; private organizationId: string; diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts index d5f4473a8f..b7bb4d1644 100644 --- a/libs/components/src/table/table-data-source.ts +++ b/libs/components/src/table/table-data-source.ts @@ -15,6 +15,7 @@ export type Sort = { export class TableDataSource extends DataSource { private readonly _data: BehaviorSubject; private readonly _sort: BehaviorSubject; + private readonly _filter = new BehaviorSubject(""); private readonly _renderData = new BehaviorSubject([]); private _renderChangesSubscription: Subscription | null = null; @@ -41,6 +42,14 @@ export class TableDataSource extends DataSource { return this._sort.value; } + get filter() { + return this._filter.value; + } + + set filter(filter: string) { + this._filter.next(filter); + } + connect(): Observable { if (!this._renderChangesSubscription) { this.updateChangeSubscription(); @@ -55,20 +64,32 @@ export class TableDataSource extends DataSource { } private updateChangeSubscription() { - const orderedData = combineLatest([this._data, this._sort]).pipe( - map(([data]) => this.orderData(data)) + const filteredData = combineLatest([this._data, this._filter]).pipe( + map(([data, filter]) => this.filterData(data, filter)) + ); + + const orderedData = combineLatest([filteredData, this._sort]).pipe( + map(([data, sort]) => this.orderData(data, sort)) ); this._renderChangesSubscription?.unsubscribe(); this._renderChangesSubscription = orderedData.subscribe((data) => this._renderData.next(data)); } - private orderData(data: T[]): T[] { - if (!this.sort) { + private filterData(data: T[], filter: string): T[] { + if (filter == null || filter == "") { return data; } - return this.sortData(data, this.sort); + return data.filter((obj) => this.filterPredicate(obj, filter)); + } + + private orderData(data: T[], sort: Sort): T[] { + if (!sort) { + return data; + } + + return this.sortData(data, sort); } /** @@ -161,4 +182,38 @@ export class TableDataSource extends DataSource { return comparatorResult * (direction === "asc" ? 1 : -1); }); } + + /** + * Copied from https://github.com/angular/components/blob/main/src/material/table/table-data-source.ts + * License: MIT + * Copyright (c) 2022 Google LLC. + * + * Checks if a data object matches the data source's filter string. By default, each data object + * is converted to a string of its properties and returns true if the filter has + * at least one occurrence in that string. By default, the filter string has its whitespace + * trimmed and the match is case-insensitive. May be overridden for a custom implementation of + * filter matching. + * @param data Data object used to check against the filter. + * @param filter Filter string that has been set on the data source. + * @returns Whether the filter matches against the data + */ + protected filterPredicate(data: T, filter: string): boolean { + // Transform the data into a lowercase string of all property values. + const dataStr = Object.keys(data as unknown as Record) + .reduce((currentTerm: string, key: string) => { + // Use an obscure Unicode character to delimit the words in the concatenated string. + // This avoids matches where the values of two columns combined will match the user's query + // (e.g. `Flute` and `Stop` will match `Test`). The character is intended to be something + // that has a very low chance of being typed in by somebody in a text field. This one in + // particular is "White up-pointing triangle with dot" from + // https://en.wikipedia.org/wiki/List_of_Unicode_characters + return currentTerm + (data as unknown as Record)[key] + "◬"; + }, "") + .toLowerCase(); + + // Transform the filter by converting it to lowercase and removing whitespace. + const transformedFilter = filter.trim().toLowerCase(); + + return dataStr.indexOf(transformedFilter) != -1; + } } diff --git a/libs/components/src/table/table.stories.ts b/libs/components/src/table/table.stories.ts index 60e80117be..76723f23d1 100644 --- a/libs/components/src/table/table.stories.ts +++ b/libs/components/src/table/table.stories.ts @@ -1,6 +1,8 @@ import { ScrollingModule } from "@angular/cdk/scrolling"; import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { countries } from "../form/countries"; + import { TableDataSource } from "./table-data-source"; import { TableModule } from "./table.module"; @@ -133,3 +135,36 @@ const ScrollableTemplate: Story = (args) => ({ }); export const Scrollable = ScrollableTemplate.bind({}); + +const data3 = new TableDataSource<{ value: string; name: string }>(); + +// Chromatic has a max page size, lowering the number of entries to ensure we don't hit it +data3.data = countries.slice(0, 100); + +const FilterableTemplate: Story = (args) => ({ + props: { + dataSource: data3, + sortFn: (a: any, b: any) => a.id - b.id, + }, + template: ` + + + + + + Name + Value + + + + + {{ r.name }} + {{ r.value }} + + + + + `, +}); + +export const Filterable = FilterableTemplate.bind({});