Skip to content

Commit

Permalink
fix(table): data source should sort empty values correctly (#8698)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewseguin authored and jelbourn committed Jan 23, 2018
1 parent 12af8f4 commit f213f6c
Show file tree
Hide file tree
Showing 3 changed files with 94 additions and 22 deletions.
10 changes: 9 additions & 1 deletion src/cdk/coercion/number-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,16 @@
export function coerceNumberProperty(value: any): number;
export function coerceNumberProperty<D>(value: any, fallback: D): number | D;
export function coerceNumberProperty(value: any, fallbackValue = 0) {
return _isNumberValue(value) ? Number(value) : fallbackValue;
}

/**
* Whether the provided value is considered a number.
* @docs-private
*/
export function _isNumberValue(value: any): boolean {
// parseFloat(value) handles most of the cases we're interested in (it treats null, empty string,
// and other non-number values as NaN, where Number just uses 0) but it considers the string
// '123hello' to be a valid number. Therefore we also check if Number(value) is NaN.
return isNaN(parseFloat(value as any)) || isNaN(Number(value)) ? fallbackValue : Number(value);
return !isNaN(parseFloat(value as any)) && !isNaN(Number(value));
}
64 changes: 46 additions & 18 deletions src/lib/table/table-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {combineLatest} from 'rxjs/operators/combineLatest';
import {map} from 'rxjs/operators/map';
import {startWith} from 'rxjs/operators/startWith';
import {empty} from 'rxjs/observable/empty';
import {_isNumberValue} from '@angular/cdk/coercion';

/**
* Data source that accepts a client-side data array and includes native support of filtering,
Expand Down Expand Up @@ -88,7 +89,8 @@ export class MatTableDataSource<T> implements DataSource<T> {
private _paginator: MatPaginator|null;

/**
* Data accessor function that is used for accessing data properties for sorting.
* 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.
Expand All @@ -98,21 +100,54 @@ export class MatTableDataSource<T> implements DataSource<T> {
sortingDataAccessor: ((data: T, sortHeaderId: string) => string|number) =
(data: T, sortHeaderId: string): string|number => {
const value: any = data[sortHeaderId];
return _isNumberValue(value) ? Number(value) : value;
}

// If the value is a string and only whitespace, return the value.
// Otherwise +value will convert it to 0.
if (typeof value === 'string' && !value.trim()) {
return value;
}
/**
* 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.
*/
sortData: ((data: T[], sort: MatSort) => T[]) = (data: T[], sort: MatSort): T[] => {
const active = sort.active;
const direction = sort.direction;
if (!active || direction == '') { return data; }

return data.sort((a, b) => {
let valueA = this.sortingDataAccessor(a, active);
let valueB = this.sortingDataAccessor(b, active);

return isNaN(+value) ? value : +value;
// 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 first.
// This avoids inconsistent results when comparing values to undefined/null.
// If neither value exists, return 0 (equal).
let comparatorResult = 0;
if (valueA && valueB) {
// 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) {
comparatorResult = 1;
} else if (valueB) {
comparatorResult = -1;
}

return comparatorResult * (direction == 'asc' ? 1 : -1);
});
}

/**
* 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 overriden for a custom implementation of
* 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.
Expand Down Expand Up @@ -172,7 +207,7 @@ export class MatTableDataSource<T> implements DataSource<T> {
_filterData(data: T[]) {
// If there is a filter string, filter out data that does not contain it.
// Each data object is converted to a string using the function defined by filterTermAccessor.
// May be overriden for customization.
// May be overridden for customization.
this.filteredData =
!this.filter ? data : data.filter(obj => this.filterPredicate(obj, this.filter));

Expand All @@ -188,16 +223,9 @@ export class MatTableDataSource<T> implements DataSource<T> {
*/
_orderData(data: T[]): T[] {
// If there is no active sort or direction, return the data without trying to sort.
if (!this.sort || !this.sort.active || this.sort.direction == '') { return data; }

const active = this.sort.active;
const direction = this.sort.direction;
if (!this.sort) { return data; }

return data.slice().sort((a, b) => {
let valueA = this.sortingDataAccessor(a, active);
let valueB = this.sortingDataAccessor(b, active);
return (valueA < valueB ? -1 : 1) * (direction == 'asc' ? 1 : -1);
});
return this.sortData(data.slice(), this.sort);
}

/**
Expand Down
42 changes: 39 additions & 3 deletions src/lib/table/table.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,42 @@ describe('MatTable', () => {
['a_2', 'b_2', 'c_2'],
['a_3', 'b_3', 'c_3'],
]);

// Expect that empty string row comes before the other values
component.sort.sort(component.sortHeader);
fixture.detectChanges();
expectTableToMatchContent(tableElement, [
['Column A\xa0Sorted by a descending', 'Column B', 'Column C'],
['a_3', 'b_3', 'c_3'],
['a_2', 'b_2', 'c_2'],
['', 'b_1', 'c_1'],
]);
});

it('should by default correctly sort undefined values', () => {
// Activate column A sort
dataSource.data[0].a = undefined;

// Expect that undefined row comes before the other values
component.sort.sort(component.sortHeader);
fixture.detectChanges();
expectTableToMatchContent(tableElement, [
['Column A\xa0Sorted by a ascending', 'Column B', 'Column C'],
['', 'b_1', 'c_1'],
['a_2', 'b_2', 'c_2'],
['a_3', 'b_3', 'c_3'],
]);


// Expect that undefined row comes after the other values
component.sort.sort(component.sortHeader);
fixture.detectChanges();
expectTableToMatchContent(tableElement, [
['Column A\xa0Sorted by a descending', 'Column B', 'Column C'],
['a_3', 'b_3', 'c_3'],
['a_2', 'b_2', 'c_2'],
['', 'b_1', 'c_1'],
]);
});

it('should be able to page the table contents', fakeAsync(() => {
Expand Down Expand Up @@ -243,9 +279,9 @@ describe('MatTable', () => {
});

interface TestData {
a: string;
b: string;
c: string;
a: string|undefined;
b: string|undefined;
c: string|undefined;
}

class FakeDataSource extends DataSource<TestData> {
Expand Down

0 comments on commit f213f6c

Please sign in to comment.