Skip to content

Commit f213f6c

Browse files
andrewseguinjelbourn
authored andcommitted
fix(table): data source should sort empty values correctly (#8698)
1 parent 12af8f4 commit f213f6c

File tree

3 files changed

+94
-22
lines changed

3 files changed

+94
-22
lines changed

src/cdk/coercion/number-property.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@
1010
export function coerceNumberProperty(value: any): number;
1111
export function coerceNumberProperty<D>(value: any, fallback: D): number | D;
1212
export function coerceNumberProperty(value: any, fallbackValue = 0) {
13+
return _isNumberValue(value) ? Number(value) : fallbackValue;
14+
}
15+
16+
/**
17+
* Whether the provided value is considered a number.
18+
* @docs-private
19+
*/
20+
export function _isNumberValue(value: any): boolean {
1321
// parseFloat(value) handles most of the cases we're interested in (it treats null, empty string,
1422
// and other non-number values as NaN, where Number just uses 0) but it considers the string
1523
// '123hello' to be a valid number. Therefore we also check if Number(value) is NaN.
16-
return isNaN(parseFloat(value as any)) || isNaN(Number(value)) ? fallbackValue : Number(value);
24+
return !isNaN(parseFloat(value as any)) && !isNaN(Number(value));
1725
}

src/lib/table/table-data-source.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {combineLatest} from 'rxjs/operators/combineLatest';
1515
import {map} from 'rxjs/operators/map';
1616
import {startWith} from 'rxjs/operators/startWith';
1717
import {empty} from 'rxjs/observable/empty';
18+
import {_isNumberValue} from '@angular/cdk/coercion';
1819

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

9091
/**
91-
* Data accessor function that is used for accessing data properties for sorting.
92+
* Data accessor function that is used for accessing data properties for sorting through
93+
* the default sortData function.
9294
* This default function assumes that the sort header IDs (which defaults to the column name)
9395
* matches the data's properties (e.g. column Xyz represents data['Xyz']).
9496
* May be set to a custom function for different behavior.
@@ -98,21 +100,54 @@ export class MatTableDataSource<T> implements DataSource<T> {
98100
sortingDataAccessor: ((data: T, sortHeaderId: string) => string|number) =
99101
(data: T, sortHeaderId: string): string|number => {
100102
const value: any = data[sortHeaderId];
103+
return _isNumberValue(value) ? Number(value) : value;
104+
}
101105

102-
// If the value is a string and only whitespace, return the value.
103-
// Otherwise +value will convert it to 0.
104-
if (typeof value === 'string' && !value.trim()) {
105-
return value;
106-
}
106+
/**
107+
* Gets a sorted copy of the data array based on the state of the MatSort. Called
108+
* after changes are made to the filtered data or when sort changes are emitted from MatSort.
109+
* By default, the function retrieves the active sort and its direction and compares data
110+
* by retrieving data using the sortingDataAccessor. May be overridden for a custom implementation
111+
* of data ordering.
112+
* @param data The array of data that should be sorted.
113+
* @param sort The connected MatSort that holds the current sort state.
114+
*/
115+
sortData: ((data: T[], sort: MatSort) => T[]) = (data: T[], sort: MatSort): T[] => {
116+
const active = sort.active;
117+
const direction = sort.direction;
118+
if (!active || direction == '') { return data; }
119+
120+
return data.sort((a, b) => {
121+
let valueA = this.sortingDataAccessor(a, active);
122+
let valueB = this.sortingDataAccessor(b, active);
107123

108-
return isNaN(+value) ? value : +value;
124+
// If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if
125+
// one value exists while the other doesn't. In this case, existing value should come first.
126+
// This avoids inconsistent results when comparing values to undefined/null.
127+
// If neither value exists, return 0 (equal).
128+
let comparatorResult = 0;
129+
if (valueA && valueB) {
130+
// Check if one value is greater than the other; if equal, comparatorResult should remain 0.
131+
if (valueA > valueB) {
132+
comparatorResult = 1;
133+
} else if (valueA < valueB) {
134+
comparatorResult = -1;
135+
}
136+
} else if (valueA) {
137+
comparatorResult = 1;
138+
} else if (valueB) {
139+
comparatorResult = -1;
140+
}
141+
142+
return comparatorResult * (direction == 'asc' ? 1 : -1);
143+
});
109144
}
110145

111146
/**
112147
* Checks if a data object matches the data source's filter string. By default, each data object
113148
* is converted to a string of its properties and returns true if the filter has
114149
* at least one occurrence in that string. By default, the filter string has its whitespace
115-
* trimmed and the match is case-insensitive. May be overriden for a custom implementation of
150+
* trimmed and the match is case-insensitive. May be overridden for a custom implementation of
116151
* filter matching.
117152
* @param data Data object used to check against the filter.
118153
* @param filter Filter string that has been set on the data source.
@@ -172,7 +207,7 @@ export class MatTableDataSource<T> implements DataSource<T> {
172207
_filterData(data: T[]) {
173208
// If there is a filter string, filter out data that does not contain it.
174209
// Each data object is converted to a string using the function defined by filterTermAccessor.
175-
// May be overriden for customization.
210+
// May be overridden for customization.
176211
this.filteredData =
177212
!this.filter ? data : data.filter(obj => this.filterPredicate(obj, this.filter));
178213

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

196-
return data.slice().sort((a, b) => {
197-
let valueA = this.sortingDataAccessor(a, active);
198-
let valueB = this.sortingDataAccessor(b, active);
199-
return (valueA < valueB ? -1 : 1) * (direction == 'asc' ? 1 : -1);
200-
});
228+
return this.sortData(data.slice(), this.sort);
201229
}
202230

203231
/**

src/lib/table/table.spec.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,42 @@ describe('MatTable', () => {
209209
['a_2', 'b_2', 'c_2'],
210210
['a_3', 'b_3', 'c_3'],
211211
]);
212+
213+
// Expect that empty string row comes before the other values
214+
component.sort.sort(component.sortHeader);
215+
fixture.detectChanges();
216+
expectTableToMatchContent(tableElement, [
217+
['Column A\xa0Sorted by a descending', 'Column B', 'Column C'],
218+
['a_3', 'b_3', 'c_3'],
219+
['a_2', 'b_2', 'c_2'],
220+
['', 'b_1', 'c_1'],
221+
]);
222+
});
223+
224+
it('should by default correctly sort undefined values', () => {
225+
// Activate column A sort
226+
dataSource.data[0].a = undefined;
227+
228+
// Expect that undefined row comes before the other values
229+
component.sort.sort(component.sortHeader);
230+
fixture.detectChanges();
231+
expectTableToMatchContent(tableElement, [
232+
['Column A\xa0Sorted by a ascending', 'Column B', 'Column C'],
233+
['', 'b_1', 'c_1'],
234+
['a_2', 'b_2', 'c_2'],
235+
['a_3', 'b_3', 'c_3'],
236+
]);
237+
238+
239+
// Expect that undefined row comes after the other values
240+
component.sort.sort(component.sortHeader);
241+
fixture.detectChanges();
242+
expectTableToMatchContent(tableElement, [
243+
['Column A\xa0Sorted by a descending', 'Column B', 'Column C'],
244+
['a_3', 'b_3', 'c_3'],
245+
['a_2', 'b_2', 'c_2'],
246+
['', 'b_1', 'c_1'],
247+
]);
212248
});
213249

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

245281
interface TestData {
246-
a: string;
247-
b: string;
248-
c: string;
282+
a: string|undefined;
283+
b: string|undefined;
284+
c: string|undefined;
249285
}
250286

251287
class FakeDataSource extends DataSource<TestData> {

0 commit comments

Comments
 (0)