From 308f8d102c6ca258e361d98dc70f1ba40953180d Mon Sep 17 00:00:00 2001 From: Marcin Date: Wed, 10 Oct 2018 17:13:47 +0100 Subject: [PATCH 1/6] feat(sort): allowing sorting by multiple columns Extended the Sort interface for active to allow string array and SortDirection to allow dictionary object. Adding matMultiColumn property to MatSort that will allow multi column sorting. Updating MatTableDataSource to handle multi column sorting. Adding a counter for sort-header that shows sorting precedence. Because of extended interface of MatSort, properties active and direction now need to be cast to string for existing usages. Fixes #7226. --- .../table/custom-table/custom-table.html | 27 ++++++ .../table/custom-table/custom-table.ts | 5 +- .../data-input-table/data-input-table.ts | 3 +- src/demo-app/table/element-data.ts | 41 ++++----- src/lib/sort/sort-errors.ts | 7 +- src/lib/sort/sort-header.html | 5 +- src/lib/sort/sort-header.scss | 6 ++ src/lib/sort/sort-header.ts | 33 +++++++- src/lib/sort/sort.spec.ts | 83 +++++++++++++++++++ src/lib/sort/sort.ts | 77 +++++++++++++---- src/lib/table/table-data-source.ts | 63 +++++++++----- src/lib/table/table.spec.ts | 67 +++++++++++---- .../table-http/table-http-example.ts | 2 +- 13 files changed, 337 insertions(+), 82 deletions(-) diff --git a/src/demo-app/table/custom-table/custom-table.html b/src/demo-app/table/custom-table/custom-table.html index ee3e1474883d..412b173c83ad 100644 --- a/src/demo-app/table/custom-table/custom-table.html +++ b/src/demo-app/table/custom-table/custom-table.html @@ -13,6 +13,8 @@

MatTable with Simple Columns

+ + @@ -34,7 +36,32 @@

Wrapper Table

+ + + + + + +

MatTable with Simple Columns and multi-column sorting

+ + + + + + + + + + + + + + +
+
diff --git a/src/demo-app/table/custom-table/custom-table.ts b/src/demo-app/table/custom-table/custom-table.ts index ee83e4b5651d..9e755debd064 100644 --- a/src/demo-app/table/custom-table/custom-table.ts +++ b/src/demo-app/table/custom-table/custom-table.ts @@ -18,16 +18,19 @@ import {Element, ELEMENT_DATA} from '../element-data'; styleUrls: ['custom-table.css'] }) export class CustomTableDemo implements OnInit { - columnsToDisplay = ['name', 'weight', 'symbol', 'position']; + columnsToDisplay = ['name', 'weight', 'symbol', 'position', 'type']; simpleTableDataSource = new MatTableDataSource(ELEMENT_DATA); wrapperTableDataSource = new MatTableDataSource(ELEMENT_DATA); + simpleTableDataSourceMultiSorting = new MatTableDataSource(ELEMENT_DATA); getWeight = (data: Element) => '~' + data.weight; @ViewChild('simpleTableSort') simpleTableSort: MatSort; @ViewChild('wrapperTableSort') wrapperTableSort: MatSort; + @ViewChild('simpleTableMultiSort') simpleTableMultiSort: MatSort; ngOnInit() { this.simpleTableDataSource.sort = this.simpleTableSort; this.wrapperTableDataSource.sort = this.wrapperTableSort; + this.simpleTableDataSourceMultiSorting.sort = this.simpleTableMultiSort; } } diff --git a/src/demo-app/table/data-input-table/data-input-table.ts b/src/demo-app/table/data-input-table/data-input-table.ts index 6589a4f36446..2dc9553bb7c1 100644 --- a/src/demo-app/table/data-input-table/data-input-table.ts +++ b/src/demo-app/table/data-input-table/data-input-table.ts @@ -56,7 +56,8 @@ export class DataInputTableDemo { name: 'new', weight: Math.floor(Math.random() * 25), symbol: 'New', - position: Math.floor(Math.random() * 25) + position: Math.floor(Math.random() * 25), + type: 'new' }); this.matTableDataSource.data = this.data; diff --git a/src/demo-app/table/element-data.ts b/src/demo-app/table/element-data.ts index 8c6394ad4a10..f3109baf3c7a 100644 --- a/src/demo-app/table/element-data.ts +++ b/src/demo-app/table/element-data.ts @@ -11,27 +11,28 @@ export interface Element { name: string; weight: number; symbol: string; + type: string; } export const ELEMENT_DATA: Element[] = [ - {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, - {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, - {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, - {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, - {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, - {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, - {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, - {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, - {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, - {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, - {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na'}, - {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg'}, - {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al'}, - {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si'}, - {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P'}, - {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S'}, - {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl'}, - {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar'}, - {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K'}, - {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca'}, + {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H', type: 'nonmetal'}, + {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He', type: 'noble gas'}, + {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li', type: 'alkali metal'}, + {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be', type: 'alkaline earth metal'}, + {position: 5, name: 'Boron', weight: 10.811, symbol: 'B', type: 'metalloid'}, + {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C', type: 'nonmetal'}, + {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N', type: 'nonmetal'}, + {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O', type: 'nonmetal'}, + {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F', type: 'nonmetal'}, + {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne', type: 'noble gas'}, + {position: 11, name: 'Sodium', weight: 22.9897, symbol: 'Na', type: 'alkali metal'}, + {position: 12, name: 'Magnesium', weight: 24.305, symbol: 'Mg', type: 'alkaline earth metal'}, + {position: 13, name: 'Aluminum', weight: 26.9815, symbol: 'Al', type: 'post-transition metal'}, + {position: 14, name: 'Silicon', weight: 28.0855, symbol: 'Si', type: 'metalloid'}, + {position: 15, name: 'Phosphorus', weight: 30.9738, symbol: 'P', type: 'nonmetal'}, + {position: 16, name: 'Sulfur', weight: 32.065, symbol: 'S', type: 'nonmetal'}, + {position: 17, name: 'Chlorine', weight: 35.453, symbol: 'Cl', type: 'nonmetal'}, + {position: 18, name: 'Argon', weight: 39.948, symbol: 'Ar', type: 'noble gas'}, + {position: 19, name: 'Potassium', weight: 39.0983, symbol: 'K', type: 'alkali metal'}, + {position: 20, name: 'Calcium', weight: 40.078, symbol: 'Ca', type: 'alkaline earth metal'}, ]; diff --git a/src/lib/sort/sort-errors.ts b/src/lib/sort/sort-errors.ts index 27a1cc94d460..dd642e621d55 100644 --- a/src/lib/sort/sort-errors.ts +++ b/src/lib/sort/sort-errors.ts @@ -22,6 +22,9 @@ export function getSortHeaderMissingIdError(): Error { } /** @docs-private */ -export function getSortInvalidDirectionError(direction: string): Error { - return Error(`${direction} is not a valid sort direction ('asc' or 'desc').`); +export function getSortInvalidDirectionError(direction: string | { [id: string]: string }): Error { + let values = typeof direction === 'object' ? + Object.keys(direction).map((id) => direction[id]) : + direction; + return Error(`${values} is not a valid sort direction ('asc' or 'desc').`); } diff --git a/src/lib/sort/sort-header.html b/src/lib/sort/sort-header.html index 223d565ba0ec..8befc2c865d4 100644 --- a/src/lib/sort/sort-header.html +++ b/src/lib/sort/sort-header.html @@ -22,5 +22,8 @@
- +
+ {{ _getSortCounter() }} +
+ diff --git a/src/lib/sort/sort-header.scss b/src/lib/sort/sort-header.scss index da17a33f59e2..be0ccbf87d96 100644 --- a/src/lib/sort/sort-header.scss +++ b/src/lib/sort/sort-header.scss @@ -119,3 +119,9 @@ $mat-sort-header-arrow-hint-opacity: 0.38; transform-origin: left; right: 0; } + +.mat-sort-header-counter { + position: absolute; + margin-top: 0px; + margin-left: 13px; +} \ No newline at end of file diff --git a/src/lib/sort/sort-header.ts b/src/lib/sort/sort-header.ts index a6d93b0277e6..8a3195e1e054 100644 --- a/src/lib/sort/sort-header.ts +++ b/src/lib/sort/sort-header.ts @@ -234,8 +234,13 @@ export class MatSortHeader extends _MatSortHeaderMixinBase /** Whether this MatSortHeader is currently sorted in either ascending or descending order. */ _isSorted() { + const isMultisort = Array.isArray(this._sort.active); + if (isMultisort) { + return this._sort.active.indexOf(this.id) > -1 && typeof this._sort.direction == 'object' && + (this._sort.direction[this.id] === 'asc' || this._sort.direction[this.id] === 'desc'); + } return this._sort.active == this.id && - (this._sort.direction === 'asc' || this._sort.direction === 'desc'); + (this._sort.direction === 'asc' || this._sort.direction === 'desc'); } /** Returns the animation state for the arrow direction (indicator and pointers). */ @@ -260,9 +265,13 @@ export class MatSortHeader extends _MatSortHeaderMixinBase * only be changed once the arrow displays again (hint or activation). */ _updateArrowDirection() { - this._arrowDirection = this._isSorted() ? - this._sort.direction : - (this.start || this._sort.start); + if (this._isSorted()) { + this._arrowDirection = (typeof this._sort.direction === 'object' ? + this._sort.direction[this.id] : + this._sort.direction); + } else { + this._arrowDirection = (this.start || this._sort.start); + } } _isDisabled() { @@ -280,4 +289,20 @@ export class MatSortHeader extends _MatSortHeaderMixinBase return this._sort.direction == 'asc' ? 'ascending' : 'descending'; } + + /** + * Gets the sort counter that will display whenever multisort is enabled. It shows the order + * in which sort is applied, whenever there are multiple columns being used for sorting. + */ + _getSortCounter(): string { + const isMultisort = Array.isArray(this._sort.active); + if (!isMultisort || this._sort.active.length < 2) { + return ''; + } + const index = this._sort.active.indexOf(this.id); + if (index === -1) { + return ''; + } + return (index + 1).toString(); + } } diff --git a/src/lib/sort/sort.spec.ts b/src/lib/sort/sort.spec.ts index ffe8e34ef1ad..51641ec32b7e 100644 --- a/src/lib/sort/sort.spec.ts +++ b/src/lib/sort/sort.spec.ts @@ -271,6 +271,35 @@ describe('MatSort', () => { expect(component.matSort.direction).toBe('asc'); }); + it('when multicolumn sort is enabled should preserve sorting state for previous columns', () => { + component.multiColumn = true; + + // Detect any changes that were made in preparation for this test + fixture.detectChanges(); + + // Reset the sort to make sure there are no side affects from previous tests + component.matSort.active = ''; + component.matSort.direction = {}; + + const expectedDirections: {[id: string]: SortDirection } = { + overrideDisableClear: 'asc', + defaultA: 'desc', + defaultB: 'asc', + overrideStart: 'desc' + }; + + const expectedColumns = ['overrideDisableClear', 'defaultA', 'defaultB', 'overrideStart']; + + component.sort('overrideDisableClear'); + component.sort('defaultA'); + component.sort('defaultA'); + component.sort('defaultB'); + component.sort('overrideStart'); + + expect(component.matSort.active).toEqual(expectedColumns); + expect(component.matSort.direction).toEqual(expectedDirections); + }); + it('should throw an error if an MatSortable is not contained within an MatSort directive', () => { expect(() => TestBed.createComponent(MatSortHeaderMissingMatSortApp).detectChanges()) .toThrowError(wrappedErrorMessage(getSortHeaderNotContainedWithinSortError())); @@ -302,6 +331,13 @@ describe('MatSort', () => { fixture, ['asc', 'desc'], 'overrideDisableClear'); }); + it('should allow sorting by multiple columns', () => { + component.multiColumn = true; + + testMultiColumnSortDirectionSequence( + fixture, ['defaultA', 'defaultB']); + }); + it('should apply the aria-labels to the button', () => { const button = fixture.nativeElement.querySelector('#defaultA button'); expect(button.getAttribute('aria-label')).toBe('Change sorting for defaultA'); @@ -421,6 +457,51 @@ function testSingleColumnSortDirectionSequence(fixture: ComponentFixture, + ids: SimpleMatSortAppColumnIds[]) { + const expectedSequence: SortDirection[] = ['asc', 'desc']; + + // Detect any changes that were made in preparation for this sort sequence + fixture.detectChanges(); + + // Reset the sort to make sure there are no side affects from previous tests + const component = fixture.componentInstance; + component.matSort.active = ''; + component.matSort.direction = {}; + + ids.forEach(id => { + // Run through the sequence to confirm the order + let actualSequence = expectedSequence.map(() => { + component.sort(id); + + // Check that the sort event's active sort is consistent with the MatSort + expect(component.matSort.active).toContain(id); + expect(component.latestSortEvent.active).toContain(id); + + // Check that the sort event's direction is consistent with the MatSort + expect(component.matSort.direction).toBe(component.latestSortEvent.direction); + return getDirection(component, id); + }); + expect(actualSequence).toEqual(expectedSequence); + + // Expect that performing one more sort will clear the sort. + component.sort(id); + expect(component.matSort.active).not.toContain(id); + expect(component.latestSortEvent.active).not.toContain(id); + expect(getDirection(component, id)).toBe(''); + }); +} + +function getDirection(component: SimpleMatSortApp, id: string) { + let direction = component.matSort.direction as { [id: string]: SortDirection }; + return direction[id]; +} + /** Column IDs of the SimpleMatSortApp for typing of function params in the component (e.g. sort) */ type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'overrideDisableClear'; @@ -432,6 +513,7 @@ type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'ov [matSortStart]="start" [matSortDirection]="direction" [matSortDisableClear]="disableClear" + [matMultiColumn]="multiColumn" (matSortChange)="latestSortEvent = $event">
(); - /** The id of the most recently sorted MatSortable. */ - @Input('matSortActive') active: string; + /** + * The id of the most recently sorted MatSortable or an array of + * active sort properties if multicolumn is enabled + */ + @Input('matSortActive') active: string | string[]; /** * The direction to set when an MatSortable is initially sorted. @@ -83,16 +86,32 @@ export class MatSort extends _MatSortMixinBase */ @Input('matSortStart') start: 'asc' | 'desc' = 'asc'; - /** The sort direction of the currently active MatSortable. */ + /** + * The sort direction of the currently active MatSortable. If multicolumn sort is enabled + * this will contain a dictionary of sort directions for active MatSortables. + */ @Input('matSortDirection') - get direction(): SortDirection { return this._direction; } - set direction(direction: SortDirection) { - if (isDevMode() && direction && direction !== 'asc' && direction !== 'desc') { + get direction(): SortDirection | { [id: string]: SortDirection } { return this._direction; } + set direction(direction: SortDirection | { [id: string]: SortDirection }) { + if (isDevMode() && direction && !this.isSortDirectionValid(direction)) { throw getSortInvalidDirectionError(direction); } this._direction = direction; } - private _direction: SortDirection = ''; + private _direction: SortDirection | { [id: string]: SortDirection } = ''; + + isSortDirectionValid(direction: SortDirection | { [id: string]: SortDirection }): boolean { + if (this.multiColumn) { + return typeof direction === 'object' && + Object.keys(direction).every((id) => this.isIndividualSortDirectionValid(direction[id])); + } else { + return typeof direction === 'string' && this.isIndividualSortDirectionValid(direction); + } + } + + isIndividualSortDirectionValid(direction: string): boolean { + return !direction || direction === 'asc' || direction === 'desc'; + } /** * Whether to disable the user from clearing the sort by finishing the sort direction cycle. @@ -103,6 +122,11 @@ export class MatSort extends _MatSortMixinBase set disableClear(v: boolean) { this._disableClear = coerceBooleanProperty(v); } private _disableClear: boolean; + /** + * The direction to set when an MatSortable is initially sorted. + */ + @Input('matMultiColumn') multiColumn: boolean = false; + /** Event emitted when the user changes either the active sort or sort direction. */ @Output('matSortChange') readonly sortChange: EventEmitter = new EventEmitter(); @@ -131,11 +155,33 @@ export class MatSort extends _MatSortMixinBase /** Sets the active sort id and determines the new sort direction. */ sort(sortable: MatSortable): void { - if (this.active != sortable.id) { - this.active = sortable.id; - this.direction = sortable.start ? sortable.start : this.start; + if (this.multiColumn) { + if (typeof this.direction !== 'object') { + this.direction = {}; + } + + if (!Array.isArray(this.active)) { + this.active = [sortable.id]; + this.direction[sortable.id] = sortable.start ? sortable.start : this.start; + } else { + const index = this.active.indexOf(sortable.id); + if (index === -1) { + this.active.push(sortable.id); + this.direction[sortable.id] = sortable.start ? sortable.start : this.start; + } else { + this.direction[sortable.id] = this.getNextSortDirection(sortable); + if (!this.direction[sortable.id]) { + this.active.splice(index, 1); + } + } + } } else { - this.direction = this.getNextSortDirection(sortable); + if (this.active != sortable.id) { + this.active = sortable.id; + this.direction = sortable.start ? sortable.start : this.start; + } else { + this.direction = this.getNextSortDirection(sortable); + } } this.sortChange.emit({active: this.active, direction: this.direction}); @@ -150,7 +196,10 @@ export class MatSort extends _MatSortMixinBase let sortDirectionCycle = getSortDirectionCycle(sortable.start || this.start, disableClear); // Get and return the next direction in the cycle - let nextDirectionIndex = sortDirectionCycle.indexOf(this.direction) + 1; + let direction = typeof this.direction === 'object' ? + this.direction[sortable.id] : + this.direction; + let nextDirectionIndex = sortDirectionCycle.indexOf(direction) + 1; if (nextDirectionIndex >= sortDirectionCycle.length) { nextDirectionIndex = 0; } return sortDirectionCycle[nextDirectionIndex]; } diff --git a/src/lib/table/table-data-source.ts b/src/lib/table/table-data-source.ts index d04f7248603e..bccced1c34b1 100644 --- a/src/lib/table/table-data-source.ts +++ b/src/lib/table/table-data-source.ts @@ -131,34 +131,55 @@ export class MatTableDataSource extends DataSource { * @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; } + + const active = Array.isArray(sort.active) ? sort.active : [sort.active]; + if (!active[0]) { return data; } + + const direction = typeof sort.direction !== 'object' ? + { [active[0]]: sort.direction } : + sort.direction; + if (!direction) { return data; } return data.sort((a, b) => { - let valueA = this.sortingDataAccessor(a, active); - let valueB = this.sortingDataAccessor(b, active); - - // 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 != 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; + // Get effective sort value after comparing all sorted properties, if values were equal for + // previous propery then compare the next pair + return active.reduce((previous, sortId) => { + if (previous !== 0) { + return previous; } - } else if (valueA != null) { + + let valueA = this.sortingDataAccessor(a, sortId); + let valueB = this.sortingDataAccessor(b, sortId); + + return this.compareValues(valueA, valueB, direction[sortId]); + }, 0); + }); + } + + // 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). + compareValues(valueA: string | number, valueB: string | number, direction: string) { + let comparatorResult = 0; + if (direction == '') { + return comparatorResult; + } + + 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 (valueB != null) { + } else if (valueA < valueB) { comparatorResult = -1; } + } else if (valueA != null) { + comparatorResult = 1; + } else if (valueB != null) { + comparatorResult = -1; + } - return comparatorResult * (direction == 'asc' ? 1 : -1); - }); + return comparatorResult * (direction == 'asc' ? 1 : -1); } /** diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index d8a8c8b0c086..9069c7bfcb11 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -99,19 +99,24 @@ describe('MatTable', () => { ]); }); - it('should render with MatTableDataSource and sort', () => { - let fixture = TestBed.createComponent(MatTableWithSortApp); - fixture.detectChanges(); + const multiColumnStates = [false, true]; + for (let i = 0; i < multiColumnStates.length; i++) { + it('should render with MatTableDataSource and sort' + (multiColumnStates[i] ? + 'with matMultiColumn' : ''), () => { + let fixture = TestBed.createComponent(MatTableWithSortApp); + fixture.componentInstance.multiColumnSort = multiColumnStates[i]; + fixture.detectChanges(); - const tableElement = fixture.nativeElement.querySelector('.mat-table')!; - const data = fixture.componentInstance.dataSource!.data; - expectTableToMatchContent(tableElement, [ - ['Column A', 'Column B', 'Column C'], - [data[0].a, data[0].b, data[0].c], - [data[1].a, data[1].b, data[1].c], - [data[2].a, data[2].b, data[2].c], - ]); - }); + const tableElement = fixture.nativeElement.querySelector('.mat-table')!; + const data = fixture.componentInstance.dataSource!.data; + expectTableToMatchContent(tableElement, [ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + [data[1].a, data[1].b, data[1].c], + [data[2].a, data[2].b, data[2].c], + ]); + }); + } it('should render with MatTableDataSource and pagination', () => { let fixture = TestBed.createComponent(MatTableWithPaginatorApp); @@ -450,6 +455,31 @@ describe('MatTable', () => { ]); }); + it('should sort by multiple columns', () => { + component.multiColumnSort = true; + fixture.detectChanges(); + + dataSource.data[0].a = 0; + dataSource.data[1].a = -1; + dataSource.data[2].a = 0; + + dataSource.data[0].b = 'f'; + dataSource.data[1].b = 'c'; + dataSource.data[2].b = 'b'; + + // Sort by column A and then by column B + component.sort.sort(component.sortHeader); + component.sort.sort(component.sortHeader2); + fixture.detectChanges(); + expectTableToMatchContent(tableElement, [ + ['Column A1', 'Column B2', 'Column C'], // Sorting precedence is added to the header + ['-1', 'c', 'c_2'], + ['0', 'b', 'c_3'], + ['0', 'f', 'c_1'], + ['Footer A', 'Footer B', 'Footer C'], + ]); + }); + }); }); @@ -611,15 +641,15 @@ class MatTableWithWhenRowApp { @Component({ template: ` - + - Column A + Column A {{row.a}} Footer A - Column B + Column B {{row.b}} Footer B @@ -642,11 +672,13 @@ class ArrayDataSourceMatTableApp implements OnInit { underlyingDataSource = new FakeDataSource(); dataSource = new MatTableDataSource(); columnsToRender = ['column_a', 'column_b', 'column_c']; + multiColumnSort = false; @ViewChild(MatTable) table: MatTable; @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; - @ViewChild(MatSortHeader) sortHeader: MatSortHeader; + @ViewChild('aSort') sortHeader: MatSortHeader; + @ViewChild('bSort') sortHeader2: MatSortHeader; constructor() { this.underlyingDataSource.data = []; @@ -670,7 +702,7 @@ class ArrayDataSourceMatTableApp implements OnInit { @Component({ template: ` - + Column A {{row.a}} @@ -695,6 +727,7 @@ class MatTableWithSortApp implements OnInit { underlyingDataSource = new FakeDataSource(); dataSource = new MatTableDataSource(); columnsToRender = ['column_a', 'column_b', 'column_c']; + multiColumnSort = false; @ViewChild(MatTable) table: MatTable; @ViewChild(MatSort) sort: MatSort; diff --git a/src/material-examples/table-http/table-http-example.ts b/src/material-examples/table-http/table-http-example.ts index aeab64373a45..bcb8ed0cba34 100644 --- a/src/material-examples/table-http/table-http-example.ts +++ b/src/material-examples/table-http/table-http-example.ts @@ -38,7 +38,7 @@ export class TableHttpExample implements OnInit { switchMap(() => { this.isLoadingResults = true; return this.exampleDatabase!.getRepoIssues( - this.sort.active, this.sort.direction, this.paginator.pageIndex); + this.sort.active as string, this.sort.direction as string, this.paginator.pageIndex); }), map(data => { // Flip flag to show that loading has finished. From f5b72c01666393e0542c5166c0fa37a594ceab50 Mon Sep 17 00:00:00 2001 From: Marcin Date: Thu, 11 Oct 2018 09:13:05 +0100 Subject: [PATCH 2/6] fix(sort) fix for sort-header stylesheet so stylelint would pass --- src/lib/sort/sort-header.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/sort/sort-header.scss b/src/lib/sort/sort-header.scss index be0ccbf87d96..fdfab61e5564 100644 --- a/src/lib/sort/sort-header.scss +++ b/src/lib/sort/sort-header.scss @@ -122,6 +122,6 @@ $mat-sort-header-arrow-hint-opacity: 0.38; .mat-sort-header-counter { position: absolute; - margin-top: 0px; + margin-top: 0; margin-left: 13px; -} \ No newline at end of file +} From 3a8b1b16f12ecf63f764cf52b7ed3539e267e25e Mon Sep 17 00:00:00 2001 From: Marcin Date: Thu, 11 Oct 2018 10:40:49 +0100 Subject: [PATCH 3/6] fix(sort) fix for multiColumn property comment --- src/lib/sort/sort.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/sort/sort.ts b/src/lib/sort/sort.ts index 2572cc8fa1a8..c342d3553e06 100644 --- a/src/lib/sort/sort.ts +++ b/src/lib/sort/sort.ts @@ -123,7 +123,7 @@ export class MatSort extends _MatSortMixinBase private _disableClear: boolean; /** - * The direction to set when an MatSortable is initially sorted. + * Whether to enable sorting by multiple columns. */ @Input('matMultiColumn') multiColumn: boolean = false; From 510b66380a182ae754b99d27e4f1c7ffa13a6ff3 Mon Sep 17 00:00:00 2001 From: Marcin Date: Tue, 30 Oct 2018 14:25:37 +0000 Subject: [PATCH 4/6] feat(sort): allowing sorting by multiple columns Creating separate directive for MatMultiSort. Moving common code to MatSortBase. Creating SortHeaderStrategy to keep the logic for simple and multi column sort consolidated. Making setting getSortCounter optional. --- src/lib/sort/multi-sort.spec.ts | 214 ++++++++++++++++++ src/lib/sort/multi-sort.ts | 145 ++++++++++++ src/lib/sort/public-api.ts | 1 + src/lib/sort/sort-header.ts | 87 +++++-- src/lib/sort/sort-module.ts | 5 +- src/lib/sort/sort.spec.ts | 83 ------- src/lib/sort/sort.ts | 123 ++++------ src/lib/table/table-data-source.ts | 11 +- src/lib/table/table.spec.ts | 151 +++++++----- .../table-multi-column-sort-example.html | 15 +- .../table-multi-column-sort-example.ts | 14 +- 11 files changed, 594 insertions(+), 255 deletions(-) create mode 100644 src/lib/sort/multi-sort.spec.ts create mode 100644 src/lib/sort/multi-sort.ts diff --git a/src/lib/sort/multi-sort.spec.ts b/src/lib/sort/multi-sort.spec.ts new file mode 100644 index 000000000000..e888d05b652f --- /dev/null +++ b/src/lib/sort/multi-sort.spec.ts @@ -0,0 +1,214 @@ +import {CdkTableModule} from '@angular/cdk/table'; +import { + dispatchMouseEvent +} from '@angular/cdk/testing'; +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatTableModule} from '../table/index'; +import { + MatMultiSort, + MatSortHeader, + MatSortHeaderIntl, + MatSortModule, + MultiSort, + SortDirection +} from './index'; + +describe('MatMultiSort', () => { + let fixture: ComponentFixture; + + let component: SimpleMatSortApp; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatSortModule, MatTableModule, CdkTableModule, NoopAnimationsModule], + declarations: [ + SimpleMatSortApp + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleMatSortApp); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('when multicolumn sort is enabled should preserve sorting state for previous columns', () => { + // Detect any changes that were made in preparation for this test + fixture.detectChanges(); + + // Reset the sort to make sure there are no side affects from previous tests + component.matSort.active = []; + component.matSort.direction = {}; + + const expectedDirections: {[id: string]: SortDirection } = { + overrideDisableClear: 'asc', + defaultA: 'desc', + defaultB: 'asc', + overrideStart: 'desc' + }; + + const expectedColumns = ['overrideDisableClear', 'defaultA', 'defaultB', 'overrideStart']; + + component.sort('overrideDisableClear'); + component.sort('defaultA'); + component.sort('defaultA'); + component.sort('defaultB'); + component.sort('overrideStart'); + + expect(component.matSort.active).toEqual(expectedColumns); + expect(component.matSort.direction).toEqual(expectedDirections); + }); + + it('should allow sorting by multiple columns', () => { + + testMultiColumnSortDirectionSequence( + fixture, ['defaultA', 'defaultB']); + }); + + it('should apply the aria-labels to the button', () => { + const button = fixture.nativeElement.querySelector('#defaultA button'); + expect(button.getAttribute('aria-label')).toBe('Change sorting for defaultA'); + }); + + it('should apply the aria-sort label to the header when sorted', () => { + const sortHeaderElement = fixture.nativeElement.querySelector('#defaultA'); + expect(sortHeaderElement.getAttribute('aria-sort')).toBe(null); + + component.sort('defaultA'); + fixture.detectChanges(); + expect(sortHeaderElement.getAttribute('aria-sort')).toBe('ascending'); + + component.sort('defaultA'); + fixture.detectChanges(); + expect(sortHeaderElement.getAttribute('aria-sort')).toBe('descending'); + + component.sort('defaultA'); + fixture.detectChanges(); + expect(sortHeaderElement.getAttribute('aria-sort')).toBe(null); + }); + + it('should re-render when the i18n labels have changed', + inject([MatSortHeaderIntl], (intl: MatSortHeaderIntl) => { + const header = fixture.debugElement.query(By.directive(MatSortHeader)).nativeElement; + const button = header.querySelector('.mat-sort-header-button'); + + intl.sortButtonLabel = () => 'Sort all of the things'; + intl.changes.next(); + fixture.detectChanges(); + + expect(button.getAttribute('aria-label')).toBe('Sort all of the things'); + }) + ); +}); + +/** + * Performs a sequence of sorting on a multiple columns to see if the sort directions are + * consistent with expectations. Detects any changes in the fixture to reflect any changes in + * the inputs and resets the MatSort to remove any side effects from previous tests. + */ +function testMultiColumnSortDirectionSequence(fixture: ComponentFixture, + ids: SimpleMatSortAppColumnIds[]) { + const expectedSequence: SortDirection[] = ['asc', 'desc']; + + // Detect any changes that were made in preparation for this sort sequence + fixture.detectChanges(); + + // Reset the sort to make sure there are no side affects from previous tests + const component = fixture.componentInstance; + component.matSort.active = []; + component.matSort.direction = {}; + + ids.forEach(id => { + // Run through the sequence to confirm the order + let actualSequence = expectedSequence.map(() => { + component.sort(id); + + // Check that the sort event's active sort is consistent with the MatSort + expect(component.matSort.active).toContain(id); + expect(component.latestSortEvent.active).toContain(id); + + // Check that the sort event's direction is consistent with the MatSort + expect(component.matSort.direction).toBe(component.latestSortEvent.direction); + return getDirection(component, id); + }); + expect(actualSequence).toEqual(expectedSequence); + + // Expect that performing one more sort will clear the sort. + component.sort(id); + expect(component.matSort.active).not.toContain(id); + expect(component.latestSortEvent.active).not.toContain(id); + expect(getDirection(component, id)).toBe(''); + }); +} + +function getDirection(component: SimpleMatSortApp, id: string) { + let direction = component.matSort.direction as { [id: string]: SortDirection }; + return direction[id]; +} + +/** Column IDs of the SimpleMatSortApp for typing of function params in the component (e.g. sort) */ +type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'overrideDisableClear'; + +@Component({ + template: ` +
+
+ A +
+
+ B +
+
+ D +
+
+ E +
+
+ ` +}) +class SimpleMatSortApp { + latestSortEvent: MultiSort; + + active: string; + start: SortDirection = 'asc'; + direction: { [id: string]: SortDirection } = {}; + disabledColumnSort = false; + disableAllSort = false; + + @ViewChild(MatMultiSort) matSort: MatMultiSort; + @ViewChild('defaultA') defaultA: MatSortHeader; + @ViewChild('defaultB') defaultB: MatSortHeader; + @ViewChild('overrideStart') overrideStart: MatSortHeader; + @ViewChild('overrideDisableClear') overrideDisableClear: MatSortHeader; + + constructor (public elementRef: ElementRef) { } + + sort(id: SimpleMatSortAppColumnIds) { + this.dispatchMouseEvent(id, 'click'); + } + + dispatchMouseEvent(id: SimpleMatSortAppColumnIds, event: string) { + const sortElement = this.elementRef.nativeElement.querySelector(`#${id}`)!; + dispatchMouseEvent(sortElement, event); + } +} diff --git a/src/lib/sort/multi-sort.ts b/src/lib/sort/multi-sort.ts new file mode 100644 index 000000000000..1751c8467e56 --- /dev/null +++ b/src/lib/sort/multi-sort.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + EventEmitter, + Input, + isDevMode, + OnChanges, + OnDestroy, + OnInit, + Output, +} from '@angular/core'; +import { + CanDisable, + HasInitialized +} from '@angular/material/core'; +import {Subject} from 'rxjs'; +import {SortDirection} from './sort-direction'; +import { + getSortInvalidDirectionError, +} from './sort-errors'; +import { + MatSortable, + _MatSortMixinBase +} from './sort'; + +/** The current sort state. */ +export interface MultiSort { + /** The id of the column being sorted. */ + active: string[]; + + /** The sort direction. */ + direction: { [id: string]: SortDirection }; +} + +/** Container for MatSortables to manage the sort state and provide default sort parameters. */ +@Directive({ + selector: '[matMultiSort]', + exportAs: 'matMultiSort', + inputs: ['disabled: matSortDisabled'] +}) +export class MatMultiSort extends _MatSortMixinBase + implements CanDisable, HasInitialized, OnChanges, OnDestroy, OnInit { + + /** Used to notify any child components listening to state changes. */ + readonly _stateChanges = new Subject(); + + /** + * The array of active sort ids. Order defines sorting precedence. + */ + @Input('matSortActive') active: string[]; + + /** + * The direction to set when an MatSortable is initially sorted. + * May be overriden by the MatSortable's sort start. + */ + @Input('matSortStart') start: 'asc' | 'desc' = 'asc'; + + /** + * The sort direction of the currently active MatSortable. If multicolumn sort is enabled + * this will contain a dictionary of sort directions for active MatSortables. + */ + @Input('matSortDirection') + get direction(): { [id: string]: SortDirection } { return this._direction; } + set direction(direction: { [id: string]: SortDirection }) { + if (isDevMode() && direction && !this.isSortDirectionValid(direction)) { + throw getSortInvalidDirectionError(direction); + } + this._direction = direction; + } + private _direction: { [id: string]: SortDirection } = {}; + + isSortDirectionValid(direction: { [id: string]: SortDirection }): boolean { + return Object.keys(direction).every((id) => this.isIndividualSortDirectionValid(direction[id])); + } + + isIndividualSortDirectionValid(direction: string): boolean { + return !direction || direction === 'asc' || direction === 'desc'; + } + + /** Event emitted when the user changes either the active sort or sort direction. */ + @Output('matSortChange') + readonly sortChange: EventEmitter = new EventEmitter(); + + /** Sets the active sort id and determines the new sort direction. */ + sort(sortable: MatSortable): void { + if (!Array.isArray(this.active)) { + this.active = [sortable.id]; + this.direction[sortable.id] = sortable.start ? sortable.start : this.start; + } else { + const index = this.active.indexOf(sortable.id); + if (index === -1) { + this.active.push(sortable.id); + this.direction[sortable.id] = sortable.start ? sortable.start : this.start; + } else { + this.direction[sortable.id] = this.getNextSortDirection(sortable); + if (!this.direction[sortable.id]) { + this.active.splice(index, 1); + } + } + } + this.sortChange.emit({active: this.active, direction: this.direction}); + } + + /** Returns the next sort direction of the active sortable, checking for potential overrides. */ + getNextSortDirection(sortable: MatSortable): SortDirection { + if (!sortable) { return ''; } + + // Get the sort direction cycle with the potential sortable overrides. + let sortDirectionCycle = getSortDirectionCycle(sortable.start || this.start); + + // Get and return the next direction in the cycle + let direction = this.direction[sortable.id]; + let nextDirectionIndex = sortDirectionCycle.indexOf(direction) + 1; + if (nextDirectionIndex >= sortDirectionCycle.length) { nextDirectionIndex = 0; } + return sortDirectionCycle[nextDirectionIndex]; + } + + ngOnInit() { + this._markInitialized(); + } + + ngOnChanges() { + this._stateChanges.next(); + } + + ngOnDestroy() { + this._stateChanges.complete(); + } +} + +/** Returns the sort direction cycle to use given the provided parameters of order and clear. */ +function getSortDirectionCycle(start: 'asc' | 'desc'): SortDirection[] { + let sortOrder: SortDirection[] = ['asc', 'desc']; + if (start == 'desc') { sortOrder.reverse(); } + sortOrder.push(''); + + return sortOrder; +} diff --git a/src/lib/sort/public-api.ts b/src/lib/sort/public-api.ts index a62a44f74bd3..5414d135586c 100644 --- a/src/lib/sort/public-api.ts +++ b/src/lib/sort/public-api.ts @@ -11,4 +11,5 @@ export * from './sort-direction'; export * from './sort-header'; export * from './sort-header-intl'; export * from './sort'; +export * from './multi-sort'; export * from './sort-animations'; diff --git a/src/lib/sort/sort-header.ts b/src/lib/sort/sort-header.ts index 8a3195e1e054..5e2146bad7c8 100644 --- a/src/lib/sort/sort-header.ts +++ b/src/lib/sort/sort-header.ts @@ -21,6 +21,7 @@ import { import {CanDisable, CanDisableCtor, mixinDisabled} from '@angular/material/core'; import {merge, Subscription} from 'rxjs'; import {MatSort, MatSortable} from './sort'; +import {MatMultiSort} from './multi-sort'; import {matSortAnimations} from './sort-animations'; import {SortDirection} from './sort-direction'; import {getSortHeaderNotContainedWithinSortError} from './sort-errors'; @@ -53,6 +54,18 @@ export interface ArrowViewStateTransition { toState: ArrowViewState; } +/** + * States describing the arrow's animated position (animating fromState to toState). + * If the fromState is not defined, there will be no animated transition to the toState. + * @docs-private + */ +export interface SortHeaderStrategy { + isSorted(sortHeader: MatSort | MatMultiSort, id: string): boolean; + getDirection(sortHeader: MatSort | MatMultiSort, id: string): SortDirection; + getSortCounter(sortHeader: MatSort | MatMultiSort, id: string, + getSortCounter: ((position: number, count: number) => string)): string; +} + /** * Applies sorting behavior (click to change sort) and styles to an element, including an * arrow to display the current sort direction. @@ -130,18 +143,33 @@ export class MatSortHeader extends _MatSortHeaderMixinBase get disableClear(): boolean { return this._disableClear; } set disableClear(v) { this._disableClear = coerceBooleanProperty(v); } private _disableClear: boolean; + private _sort: MatSort|MatMultiSort; + + private _sortHeaderStrategy: SortHeaderStrategy; + + @Input() + getSortCounter: ((position: number, count: number) => string); constructor(public _intl: MatSortHeaderIntl, changeDetectorRef: ChangeDetectorRef, - @Optional() public _sort: MatSort, + @Optional() public _simpleSort: MatSort, + @Optional() public _multiSort: MatMultiSort, @Optional() public _cdkColumnDef: CdkColumnDef) { super(); - if (!_sort) { + if (_simpleSort) { + this._sort = _simpleSort; + this._sortHeaderStrategy = new SimpleSortStrategy(); + } else if (_multiSort) { + this._sort = _multiSort; + this._sortHeaderStrategy = new MultiSortStrategy(); + } else { throw getSortHeaderNotContainedWithinSortError(); } + let _sort = this._sort; + this._rerenderSubscription = merge(_sort.sortChange, _sort._stateChanges, _intl.changes) .subscribe(() => { if (this._isSorted()) { @@ -234,13 +262,7 @@ export class MatSortHeader extends _MatSortHeaderMixinBase /** Whether this MatSortHeader is currently sorted in either ascending or descending order. */ _isSorted() { - const isMultisort = Array.isArray(this._sort.active); - if (isMultisort) { - return this._sort.active.indexOf(this.id) > -1 && typeof this._sort.direction == 'object' && - (this._sort.direction[this.id] === 'asc' || this._sort.direction[this.id] === 'desc'); - } - return this._sort.active == this.id && - (this._sort.direction === 'asc' || this._sort.direction === 'desc'); + return this._sortHeaderStrategy.isSorted(this._sort, this.id); } /** Returns the animation state for the arrow direction (indicator and pointers). */ @@ -266,9 +288,7 @@ export class MatSortHeader extends _MatSortHeaderMixinBase */ _updateArrowDirection() { if (this._isSorted()) { - this._arrowDirection = (typeof this._sort.direction === 'object' ? - this._sort.direction[this.id] : - this._sort.direction); + this._arrowDirection = this._sortHeaderStrategy.getDirection(this._sort, this.id); } else { this._arrowDirection = (this.start || this._sort.start); } @@ -286,8 +306,8 @@ export class MatSortHeader extends _MatSortHeaderMixinBase */ _getAriaSortAttribute() { if (!this._isSorted()) { return null; } - - return this._sort.direction == 'asc' ? 'ascending' : 'descending'; + const direction = this._sortHeaderStrategy.getDirection(this._sort, this.id); + return direction == 'asc' ? 'ascending' : 'descending'; } /** @@ -295,14 +315,45 @@ export class MatSortHeader extends _MatSortHeaderMixinBase * in which sort is applied, whenever there are multiple columns being used for sorting. */ _getSortCounter(): string { - const isMultisort = Array.isArray(this._sort.active); - if (!isMultisort || this._sort.active.length < 2) { + return this._sortHeaderStrategy.getSortCounter(this._sort, this.id, this.getSortCounter); + } +} + +class SimpleSortStrategy implements SortHeaderStrategy { + isSorted(sortHeader: MatSort, id: string) { + return sortHeader.active == id && + (sortHeader.direction === 'asc' || sortHeader.direction === 'desc'); + } + + getDirection(sortHeader: MatSort): SortDirection { + return sortHeader.direction; + } + + getSortCounter(): string { + return ''; + } +} + +class MultiSortStrategy implements SortHeaderStrategy { + isSorted(sortHeader: MatMultiSort, id: string) { + return sortHeader.active && sortHeader.active.indexOf(id) > -1 && + (sortHeader.direction[id] === 'asc' || sortHeader.direction[id] === 'desc'); + } + + getDirection(sortHeader: MatMultiSort, id: string): SortDirection { + return sortHeader.direction[id]; + } + + getSortCounter(sortHeader: MatMultiSort, id: string, + getSortCounter: ((position: number, count: number) => string)): string { + if (!getSortCounter || !sortHeader.active) { return ''; } - const index = this._sort.active.indexOf(this.id); + const index = sortHeader.active.indexOf(id); if (index === -1) { return ''; } - return (index + 1).toString(); + + return getSortCounter(index, sortHeader.active.length); } } diff --git a/src/lib/sort/sort-module.ts b/src/lib/sort/sort-module.ts index dc8151dda729..c02d7d79e967 100644 --- a/src/lib/sort/sort-module.ts +++ b/src/lib/sort/sort-module.ts @@ -9,14 +9,15 @@ import {NgModule} from '@angular/core'; import {MatSortHeader} from './sort-header'; import {MatSort} from './sort'; +import {MatMultiSort} from './multi-sort'; import {MAT_SORT_HEADER_INTL_PROVIDER} from './sort-header-intl'; import {CommonModule} from '@angular/common'; @NgModule({ imports: [CommonModule], - exports: [MatSort, MatSortHeader], - declarations: [MatSort, MatSortHeader], + exports: [MatSort, MatMultiSort, MatSortHeader], + declarations: [MatSort, MatMultiSort, MatSortHeader], providers: [MAT_SORT_HEADER_INTL_PROVIDER] }) export class MatSortModule {} diff --git a/src/lib/sort/sort.spec.ts b/src/lib/sort/sort.spec.ts index 51641ec32b7e..ffe8e34ef1ad 100644 --- a/src/lib/sort/sort.spec.ts +++ b/src/lib/sort/sort.spec.ts @@ -271,35 +271,6 @@ describe('MatSort', () => { expect(component.matSort.direction).toBe('asc'); }); - it('when multicolumn sort is enabled should preserve sorting state for previous columns', () => { - component.multiColumn = true; - - // Detect any changes that were made in preparation for this test - fixture.detectChanges(); - - // Reset the sort to make sure there are no side affects from previous tests - component.matSort.active = ''; - component.matSort.direction = {}; - - const expectedDirections: {[id: string]: SortDirection } = { - overrideDisableClear: 'asc', - defaultA: 'desc', - defaultB: 'asc', - overrideStart: 'desc' - }; - - const expectedColumns = ['overrideDisableClear', 'defaultA', 'defaultB', 'overrideStart']; - - component.sort('overrideDisableClear'); - component.sort('defaultA'); - component.sort('defaultA'); - component.sort('defaultB'); - component.sort('overrideStart'); - - expect(component.matSort.active).toEqual(expectedColumns); - expect(component.matSort.direction).toEqual(expectedDirections); - }); - it('should throw an error if an MatSortable is not contained within an MatSort directive', () => { expect(() => TestBed.createComponent(MatSortHeaderMissingMatSortApp).detectChanges()) .toThrowError(wrappedErrorMessage(getSortHeaderNotContainedWithinSortError())); @@ -331,13 +302,6 @@ describe('MatSort', () => { fixture, ['asc', 'desc'], 'overrideDisableClear'); }); - it('should allow sorting by multiple columns', () => { - component.multiColumn = true; - - testMultiColumnSortDirectionSequence( - fixture, ['defaultA', 'defaultB']); - }); - it('should apply the aria-labels to the button', () => { const button = fixture.nativeElement.querySelector('#defaultA button'); expect(button.getAttribute('aria-label')).toBe('Change sorting for defaultA'); @@ -457,51 +421,6 @@ function testSingleColumnSortDirectionSequence(fixture: ComponentFixture, - ids: SimpleMatSortAppColumnIds[]) { - const expectedSequence: SortDirection[] = ['asc', 'desc']; - - // Detect any changes that were made in preparation for this sort sequence - fixture.detectChanges(); - - // Reset the sort to make sure there are no side affects from previous tests - const component = fixture.componentInstance; - component.matSort.active = ''; - component.matSort.direction = {}; - - ids.forEach(id => { - // Run through the sequence to confirm the order - let actualSequence = expectedSequence.map(() => { - component.sort(id); - - // Check that the sort event's active sort is consistent with the MatSort - expect(component.matSort.active).toContain(id); - expect(component.latestSortEvent.active).toContain(id); - - // Check that the sort event's direction is consistent with the MatSort - expect(component.matSort.direction).toBe(component.latestSortEvent.direction); - return getDirection(component, id); - }); - expect(actualSequence).toEqual(expectedSequence); - - // Expect that performing one more sort will clear the sort. - component.sort(id); - expect(component.matSort.active).not.toContain(id); - expect(component.latestSortEvent.active).not.toContain(id); - expect(getDirection(component, id)).toBe(''); - }); -} - -function getDirection(component: SimpleMatSortApp, id: string) { - let direction = component.matSort.direction as { [id: string]: SortDirection }; - return direction[id]; -} - /** Column IDs of the SimpleMatSortApp for typing of function params in the component (e.g. sort) */ type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'overrideDisableClear'; @@ -513,7 +432,6 @@ type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'ov [matSortStart]="start" [matSortDirection]="direction" [matSortDisableClear]="disableClear" - [matMultiColumn]="multiColumn" (matSortChange)="latestSortEvent = $event">
(); + + /** + * Register function to be used by the contained MatSortables. Adds the MatSortable to the + * collection of MatSortables. + */ + register(sortable: MatSortable): void { + if (!sortable.id) { + throw getSortHeaderMissingIdError(); + } + + if (this.sortables.has(sortable.id)) { + throw getSortDuplicateSortableIdError(sortable.id); + } + this.sortables.set(sortable.id, sortable); + } + + /** + * Unregister function to be used by the contained MatSortables. Removes the MatSortable from the + * collection of contained MatSortables. + */ + deregister(sortable: MatSortable): void { + this.sortables.delete(sortable.id); + } +} export const _MatSortMixinBase: HasInitializedCtor & CanDisableCtor & typeof MatSortBase = mixinInitialized(mixinDisabled(MatSortBase)); @@ -68,17 +94,13 @@ export const _MatSortMixinBase: HasInitializedCtor & CanDisableCtor & typeof Mat }) export class MatSort extends _MatSortMixinBase implements CanDisable, HasInitialized, OnChanges, OnDestroy, OnInit { - /** Collection of all registered sortables that this directive manages. */ - sortables = new Map(); + /** Used to notify any child components listening to state changes. */ readonly _stateChanges = new Subject(); - /** - * The id of the most recently sorted MatSortable or an array of - * active sort properties if multicolumn is enabled - */ - @Input('matSortActive') active: string | string[]; + /** The id of the most recently sorted MatSortable. */ + @Input('matSortActive') active: string; /** * The direction to set when an MatSortable is initially sorted. @@ -86,32 +108,16 @@ export class MatSort extends _MatSortMixinBase */ @Input('matSortStart') start: 'asc' | 'desc' = 'asc'; - /** - * The sort direction of the currently active MatSortable. If multicolumn sort is enabled - * this will contain a dictionary of sort directions for active MatSortables. - */ + /** The sort direction of the currently active MatSortable. */ @Input('matSortDirection') - get direction(): SortDirection | { [id: string]: SortDirection } { return this._direction; } - set direction(direction: SortDirection | { [id: string]: SortDirection }) { - if (isDevMode() && direction && !this.isSortDirectionValid(direction)) { + get direction(): SortDirection { return this._direction; } + set direction(direction: SortDirection) { + if (isDevMode() && direction && direction !== 'asc' && direction !== 'desc') { throw getSortInvalidDirectionError(direction); } this._direction = direction; } - private _direction: SortDirection | { [id: string]: SortDirection } = ''; - - isSortDirectionValid(direction: SortDirection | { [id: string]: SortDirection }): boolean { - if (this.multiColumn) { - return typeof direction === 'object' && - Object.keys(direction).every((id) => this.isIndividualSortDirectionValid(direction[id])); - } else { - return typeof direction === 'string' && this.isIndividualSortDirectionValid(direction); - } - } - - isIndividualSortDirectionValid(direction: string): boolean { - return !direction || direction === 'asc' || direction === 'desc'; - } + private _direction: SortDirection = ''; /** * Whether to disable the user from clearing the sort by finishing the sort direction cycle. @@ -122,67 +128,17 @@ export class MatSort extends _MatSortMixinBase set disableClear(v: boolean) { this._disableClear = coerceBooleanProperty(v); } private _disableClear: boolean; - /** - * Whether to enable sorting by multiple columns. - */ - @Input('matMultiColumn') multiColumn: boolean = false; - /** Event emitted when the user changes either the active sort or sort direction. */ @Output('matSortChange') readonly sortChange: EventEmitter = new EventEmitter(); - /** - * Register function to be used by the contained MatSortables. Adds the MatSortable to the - * collection of MatSortables. - */ - register(sortable: MatSortable): void { - if (!sortable.id) { - throw getSortHeaderMissingIdError(); - } - - if (this.sortables.has(sortable.id)) { - throw getSortDuplicateSortableIdError(sortable.id); - } - this.sortables.set(sortable.id, sortable); - } - - /** - * Unregister function to be used by the contained MatSortables. Removes the MatSortable from the - * collection of contained MatSortables. - */ - deregister(sortable: MatSortable): void { - this.sortables.delete(sortable.id); - } - /** Sets the active sort id and determines the new sort direction. */ sort(sortable: MatSortable): void { - if (this.multiColumn) { - if (typeof this.direction !== 'object') { - this.direction = {}; - } - - if (!Array.isArray(this.active)) { - this.active = [sortable.id]; - this.direction[sortable.id] = sortable.start ? sortable.start : this.start; - } else { - const index = this.active.indexOf(sortable.id); - if (index === -1) { - this.active.push(sortable.id); - this.direction[sortable.id] = sortable.start ? sortable.start : this.start; - } else { - this.direction[sortable.id] = this.getNextSortDirection(sortable); - if (!this.direction[sortable.id]) { - this.active.splice(index, 1); - } - } - } - } else { if (this.active != sortable.id) { this.active = sortable.id; this.direction = sortable.start ? sortable.start : this.start; } else { this.direction = this.getNextSortDirection(sortable); } - } this.sortChange.emit({active: this.active, direction: this.direction}); } @@ -196,10 +152,7 @@ export class MatSort extends _MatSortMixinBase let sortDirectionCycle = getSortDirectionCycle(sortable.start || this.start, disableClear); // Get and return the next direction in the cycle - let direction = typeof this.direction === 'object' ? - this.direction[sortable.id] : - this.direction; - let nextDirectionIndex = sortDirectionCycle.indexOf(direction) + 1; + let nextDirectionIndex = sortDirectionCycle.indexOf(this.direction) + 1; if (nextDirectionIndex >= sortDirectionCycle.length) { nextDirectionIndex = 0; } return sortDirectionCycle[nextDirectionIndex]; } diff --git a/src/lib/table/table-data-source.ts b/src/lib/table/table-data-source.ts index bccced1c34b1..adf7b322d05f 100644 --- a/src/lib/table/table-data-source.ts +++ b/src/lib/table/table-data-source.ts @@ -17,7 +17,7 @@ import { Subscription } from 'rxjs'; import {MatPaginator, PageEvent} from '@angular/material/paginator'; -import {MatSort, Sort} from '@angular/material/sort'; +import {MatSort, MatMultiSort, Sort} from '@angular/material/sort'; import {map} from 'rxjs/operators'; /** @@ -73,12 +73,12 @@ export class MatTableDataSource extends DataSource { * Instance of the MatSort directive used by the table to control its sorting. Sort changes * emitted by the MatSort will trigger an update to the table's rendered data. */ - get sort(): MatSort | null { return this._sort; } - set sort(sort: MatSort|null) { + get sort(): MatSort | MatMultiSort | null { return this._sort; } + set sort(sort: MatSort|MatMultiSort|null) { this._sort = sort; this._updateChangeSubscription(); } - private _sort: MatSort|null; + private _sort: MatSort|MatMultiSort|null; /** * Instance of the MatPaginator component used by the table to control what page of the data is @@ -130,7 +130,8 @@ export class MatTableDataSource extends DataSource { * @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[] => { + sortData: ((data: T[], sort: MatSort | MatMultiSort) => T[]) = + (data: T[], sort: MatSort | MatMultiSort): T[] => { const active = Array.isArray(sort.active) ? sort.active : [sort.active]; if (!active[0]) { return data; } diff --git a/src/lib/table/table.spec.ts b/src/lib/table/table.spec.ts index 9069c7bfcb11..dec6c71fdf47 100644 --- a/src/lib/table/table.spec.ts +++ b/src/lib/table/table.spec.ts @@ -11,7 +11,7 @@ import { import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {BehaviorSubject, Observable} from 'rxjs'; import {MatPaginator, MatPaginatorModule} from '../paginator/index'; -import {MatSort, MatSortHeader, MatSortModule} from '../sort/index'; +import {MatSort, MatMultiSort, MatSortHeader, MatSortModule} from '../sort/index'; import {MatTableModule} from './index'; import {MatTable} from './table'; import {MatTableDataSource} from './table-data-source'; @@ -27,6 +27,7 @@ describe('MatTable', () => { ArrayDataSourceMatTableApp, NativeHtmlTableApp, MatTableWithSortApp, + MatTableWithMultiSortApp, MatTableWithPaginatorApp, StickyTableApp, TableWithNgContainerRow, @@ -99,24 +100,47 @@ describe('MatTable', () => { ]); }); - const multiColumnStates = [false, true]; - for (let i = 0; i < multiColumnStates.length; i++) { - it('should render with MatTableDataSource and sort' + (multiColumnStates[i] ? - 'with matMultiColumn' : ''), () => { - let fixture = TestBed.createComponent(MatTableWithSortApp); - fixture.componentInstance.multiColumnSort = multiColumnStates[i]; - fixture.detectChanges(); + it('should render with MatTableDataSource and sort', () => { + let fixture = TestBed.createComponent(MatTableWithSortApp); + fixture.detectChanges(); - const tableElement = fixture.nativeElement.querySelector('.mat-table')!; - const data = fixture.componentInstance.dataSource!.data; - expectTableToMatchContent(tableElement, [ - ['Column A', 'Column B', 'Column C'], - [data[0].a, data[0].b, data[0].c], - [data[1].a, data[1].b, data[1].c], - [data[2].a, data[2].b, data[2].c], - ]); - }); - } + const tableElement = fixture.nativeElement.querySelector('.mat-table')!; + const data = fixture.componentInstance.dataSource!.data; + expectTableToMatchContent(tableElement, [ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + [data[1].a, data[1].b, data[1].c], + [data[2].a, data[2].b, data[2].c], + ]); + }); + + it('should sort by multiple columns', () => { + let fixture = TestBed.createComponent(MatTableWithMultiSortApp); + fixture.detectChanges(); + + const component = fixture.componentInstance; + const data = component.dataSource!.data; + const tableElement = fixture.nativeElement.querySelector('.mat-table')!; + + data[0].a = 0; + data[1].a = -1; + data[2].a = 0; + + data[0].b = 'f'; + data[1].b = 'c'; + data[2].b = 'b'; + + // Sort by column A and then by column B + component.sort.sort(component.sortHeader1); + component.sort.sort(component.sortHeader2); + fixture.detectChanges(); + expectTableToMatchContent(tableElement, [ + ['Column A', 'Column B', 'Column C'], // Sorting precedence is added to the header + ['-1', 'c', 'c_2'], + ['0', 'b', 'c_3'], + ['0', 'f', 'c_1'] + ]); + }); it('should render with MatTableDataSource and pagination', () => { let fixture = TestBed.createComponent(MatTableWithPaginatorApp); @@ -455,31 +479,6 @@ describe('MatTable', () => { ]); }); - it('should sort by multiple columns', () => { - component.multiColumnSort = true; - fixture.detectChanges(); - - dataSource.data[0].a = 0; - dataSource.data[1].a = -1; - dataSource.data[2].a = 0; - - dataSource.data[0].b = 'f'; - dataSource.data[1].b = 'c'; - dataSource.data[2].b = 'b'; - - // Sort by column A and then by column B - component.sort.sort(component.sortHeader); - component.sort.sort(component.sortHeader2); - fixture.detectChanges(); - expectTableToMatchContent(tableElement, [ - ['Column A1', 'Column B2', 'Column C'], // Sorting precedence is added to the header - ['-1', 'c', 'c_2'], - ['0', 'b', 'c_3'], - ['0', 'f', 'c_1'], - ['Footer A', 'Footer B', 'Footer C'], - ]); - }); - }); }); @@ -641,15 +640,15 @@ class MatTableWithWhenRowApp { @Component({ template: ` - + - Column A + Column A {{row.a}} Footer A - Column B + Column B {{row.b}} Footer B @@ -672,13 +671,11 @@ class ArrayDataSourceMatTableApp implements OnInit { underlyingDataSource = new FakeDataSource(); dataSource = new MatTableDataSource(); columnsToRender = ['column_a', 'column_b', 'column_c']; - multiColumnSort = false; @ViewChild(MatTable) table: MatTable; @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; - @ViewChild('aSort') sortHeader: MatSortHeader; - @ViewChild('bSort') sortHeader2: MatSortHeader; + @ViewChild(MatSortHeader) sortHeader: MatSortHeader; constructor() { this.underlyingDataSource.data = []; @@ -702,7 +699,7 @@ class ArrayDataSourceMatTableApp implements OnInit { @Component({ template: ` - + Column A {{row.a}} @@ -727,7 +724,6 @@ class MatTableWithSortApp implements OnInit { underlyingDataSource = new FakeDataSource(); dataSource = new MatTableDataSource(); columnsToRender = ['column_a', 'column_b', 'column_c']; - multiColumnSort = false; @ViewChild(MatTable) table: MatTable; @ViewChild(MatSort) sort: MatSort; @@ -750,6 +746,59 @@ class MatTableWithSortApp implements OnInit { } } +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column B + {{row.b}} + + + + Column C + {{row.c}} + + + + + + ` +}) +class MatTableWithMultiSortApp implements OnInit { + underlyingDataSource = new FakeDataSource(); + dataSource = new MatTableDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; + + @ViewChild(MatTable) table: MatTable; + @ViewChild(MatMultiSort) sort: MatMultiSort; + + @ViewChild('aSort') sortHeader1: MatSortHeader; + @ViewChild('bSort') sortHeader2: MatSortHeader; + @ViewChild('cSort') sortHeader3: MatSortHeader; + + constructor() { + this.underlyingDataSource.data = []; + + // Add three rows of data + this.underlyingDataSource.addData(); + this.underlyingDataSource.addData(); + this.underlyingDataSource.addData(); + + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } + + ngOnInit() { + this.dataSource!.sort = this.sort; + } +} + @Component({ template: ` diff --git a/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.html b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.html index 5ef4e5d08524..e98ebcd74916 100644 --- a/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.html +++ b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.html @@ -1,33 +1,32 @@ - +
- + - + - + - + - + - + diff --git a/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.ts b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.ts index e1324885a8c1..6981b39e0a50 100644 --- a/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.ts +++ b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.ts @@ -1,5 +1,5 @@ import {Component, OnInit, ViewChild} from '@angular/core'; -import {MatSort, MatTableDataSource} from '@angular/material'; +import {MatMultiSort, MatTableDataSource} from '@angular/material'; export interface PeriodicElement { name: string; @@ -33,7 +33,7 @@ const ELEMENT_DATA: PeriodicElement[] = [ ]; /** - * @title Table with multi column sorting. + * @title Table with multi column sorting */ @Component({ selector: 'table-multi-column-sort-example', @@ -44,9 +44,17 @@ export class TableMultiColumnSortExample implements OnInit { displayedColumns: string[] = ['position', 'name', 'weight', 'symbol', 'type']; dataSource = new MatTableDataSource(ELEMENT_DATA); - @ViewChild(MatSort) sort: MatSort; + @ViewChild(MatMultiSort) sort: MatMultiSort; ngOnInit() { this.dataSource.sort = this.sort; } + + getSortCounter(position: number, count: number): string { + if (count < 2) { + return ''; + } + + return (position + 1).toString(); + } } From 954492425020ed713216f60aac09fb1dc805be37 Mon Sep 17 00:00:00 2001 From: Marcin Date: Tue, 30 Oct 2018 16:05:27 +0000 Subject: [PATCH 5/6] fix(sort) removing unused imports for multi-sort-header spec --- src/lib/sort/multi-sort.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/sort/multi-sort.spec.ts b/src/lib/sort/multi-sort.spec.ts index e888d05b652f..1397912d05f4 100644 --- a/src/lib/sort/multi-sort.spec.ts +++ b/src/lib/sort/multi-sort.spec.ts @@ -3,7 +3,7 @@ import { dispatchMouseEvent } from '@angular/cdk/testing'; import {Component, ElementRef, ViewChild} from '@angular/core'; -import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MatTableModule} from '../table/index'; From c731715cd1287c4f33135c537da283491833ffab Mon Sep 17 00:00:00 2001 From: Marcin Date: Wed, 31 Oct 2018 12:23:42 +0000 Subject: [PATCH 6/6] feat(sort): Changing comments for SortHeaderStrategy and spiting error for multi-sort --- src/lib/sort/multi-sort.ts | 4 ++-- src/lib/sort/sort-errors.ts | 9 +++++++-- src/lib/sort/sort-header.ts | 10 +++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/lib/sort/multi-sort.ts b/src/lib/sort/multi-sort.ts index 1751c8467e56..caaffea4f3f1 100644 --- a/src/lib/sort/multi-sort.ts +++ b/src/lib/sort/multi-sort.ts @@ -23,7 +23,7 @@ import { import {Subject} from 'rxjs'; import {SortDirection} from './sort-direction'; import { - getSortInvalidDirectionError, + getMultiSortInvalidDirectionError, } from './sort-errors'; import { MatSortable, @@ -70,7 +70,7 @@ export class MatMultiSort extends _MatSortMixinBase get direction(): { [id: string]: SortDirection } { return this._direction; } set direction(direction: { [id: string]: SortDirection }) { if (isDevMode() && direction && !this.isSortDirectionValid(direction)) { - throw getSortInvalidDirectionError(direction); + throw getMultiSortInvalidDirectionError(direction); } this._direction = direction; } diff --git a/src/lib/sort/sort-errors.ts b/src/lib/sort/sort-errors.ts index dd642e621d55..adba7eadfe47 100644 --- a/src/lib/sort/sort-errors.ts +++ b/src/lib/sort/sort-errors.ts @@ -22,9 +22,14 @@ export function getSortHeaderMissingIdError(): Error { } /** @docs-private */ -export function getSortInvalidDirectionError(direction: string | { [id: string]: string }): Error { +export function getSortInvalidDirectionError(direction: string): Error { + return Error(`${direction} is not a valid sort direction ('asc' or 'desc').`); +} + +/** @docs-private */ +export function getMultiSortInvalidDirectionError(direction: { [id: string]: string }): Error { let values = typeof direction === 'object' ? Object.keys(direction).map((id) => direction[id]) : direction; - return Error(`${values} is not a valid sort direction ('asc' or 'desc').`); + return Error(`${values} are not a valid sort direction ('asc' or 'desc').`); } diff --git a/src/lib/sort/sort-header.ts b/src/lib/sort/sort-header.ts index db2244a1fe4d..f35f30585ecd 100644 --- a/src/lib/sort/sort-header.ts +++ b/src/lib/sort/sort-header.ts @@ -60,9 +60,7 @@ interface MatSortHeaderColumnDef { } /** - * States describing the arrow's animated position (animating fromState to toState). - * If the fromState is not defined, there will be no animated transition to the toState. - * @docs-private + * Header display strategy that is different based on type of sort being used. */ export interface SortHeaderStrategy { isSorted(sortHeader: MatSort | MatMultiSort, id: string): boolean; @@ -328,6 +326,9 @@ export class MatSortHeader extends _MatSortHeaderMixinBase } } +/** + * Strategy used when MatSort is used + */ class SimpleSortStrategy implements SortHeaderStrategy { isSorted(sortHeader: MatSort, id: string) { return sortHeader.active == id && @@ -343,6 +344,9 @@ class SimpleSortStrategy implements SortHeaderStrategy { } } +/** + * Strategy used when MatMultiSort is used + */ class MultiSortStrategy implements SortHeaderStrategy { isSorted(sortHeader: MatMultiSort, id: string) { return sortHeader.active && sortHeader.active.indexOf(id) > -1 &&
No. No. {{element.position}} Name Name {{element.name}} Weight Weight {{element.weight}} Symbol Symbol {{element.symbol}} Type Type {{row.type}}