diff --git a/src/lib/sort/multi-sort.spec.ts b/src/lib/sort/multi-sort.spec.ts new file mode 100644 index 000000000000..1397912d05f4 --- /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, inject, TestBed} 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..caaffea4f3f1 --- /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 { + getMultiSortInvalidDirectionError, +} 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 getMultiSortInvalidDirectionError(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-errors.ts b/src/lib/sort/sort-errors.ts index 27a1cc94d460..adba7eadfe47 100644 --- a/src/lib/sort/sort-errors.ts +++ b/src/lib/sort/sort-errors.ts @@ -25,3 +25,11 @@ export function getSortHeaderMissingIdError(): 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} are 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..fdfab61e5564 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: 0; + margin-left: 13px; +} diff --git a/src/lib/sort/sort-header.ts b/src/lib/sort/sort-header.ts index 7cd2db0cc31d..f35f30585ecd 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'; @@ -58,6 +59,16 @@ interface MatSortHeaderColumnDef { name: string; } +/** + * Header display strategy that is different based on type of sort being used. + */ +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. @@ -135,10 +146,17 @@ 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, @Inject('MAT_SORT_HEADER_COLUMN_DEF') @Optional() public _columnDef: MatSortHeaderColumnDef) { // Note that we use a string token for the `_columnDef`, because the value is provided both by @@ -147,10 +165,18 @@ export class MatSortHeader extends _MatSortHeaderMixinBase // of this single reference. 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()) { @@ -243,8 +269,7 @@ export class MatSortHeader extends _MatSortHeaderMixinBase /** Whether this MatSortHeader is currently sorted in either ascending or descending order. */ _isSorted() { - 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). */ @@ -269,9 +294,11 @@ 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 = this._sortHeaderStrategy.getDirection(this._sort, this.id); + } else { + this._arrowDirection = (this.start || this._sort.start); + } } _isDisabled() { @@ -286,7 +313,60 @@ export class MatSortHeader extends _MatSortHeaderMixinBase */ _getAriaSortAttribute() { if (!this._isSorted()) { return null; } + const direction = this._sortHeaderStrategy.getDirection(this._sort, this.id); + return 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 { + return this._sortHeaderStrategy.getSortCounter(this._sort, this.id, this.getSortCounter); + } +} + +/** + * Strategy used when MatSort is used + */ +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 ''; + } +} + +/** + * Strategy used when MatMultiSort is used + */ +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 = sortHeader.active.indexOf(id); + if (index === -1) { + return ''; + } - return this._sort.direction == 'asc' ? 'ascending' : 'descending'; + 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.ts b/src/lib/sort/sort.ts index 2c4167b80b98..2586acb8217b 100644 --- a/src/lib/sort/sort.ts +++ b/src/lib/sort/sort.ts @@ -56,7 +56,33 @@ export interface Sort { // Boilerplate for applying mixins to MatSort. /** @docs-private */ -export class MatSortBase {} +export class MatSortBase { + /** Collection of all registered sortables that this directive manages. */ + sortables = new Map(); + + /** + * 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,8 +94,7 @@ 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(); @@ -106,37 +131,14 @@ export class MatSort extends _MatSortMixinBase /** 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.active != sortable.id) { - this.active = sortable.id; - this.direction = sortable.start ? sortable.start : this.start; - } 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}); } diff --git a/src/lib/table/table-data-source.ts b/src/lib/table/table-data-source.ts index 976420fa32d1..2f94be543d50 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,35 +130,57 @@ 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[] => { - const active = sort.active; - const direction = sort.direction; - if (!active || direction == '') { return data; } + 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; } + + 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 b6fd1ebbf6bb..cbb4e8872f71 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, @@ -113,6 +114,34 @@ describe('MatTable', () => { ]); }); + 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); fixture.detectChanges(); @@ -729,6 +758,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-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. diff --git a/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.css b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.css new file mode 100644 index 000000000000..1922e7ffa3ad --- /dev/null +++ b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.css @@ -0,0 +1,3 @@ +table { + width: 100%; +} 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 new file mode 100644 index 000000000000..e98ebcd74916 --- /dev/null +++ b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. {{element.position}} Name {{element.name}} Weight {{element.weight}} Symbol {{element.symbol}} Type {{row.type}}
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 new file mode 100644 index 000000000000..6981b39e0a50 --- /dev/null +++ b/src/material-examples/table-multi-column-sort/table-multi-column-sort-example.ts @@ -0,0 +1,60 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {MatMultiSort, MatTableDataSource} from '@angular/material'; + +export interface PeriodicElement { + name: string; + position: number; + weight: number; + symbol: string; + type: string; +} + +const ELEMENT_DATA: PeriodicElement[] = [ + {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'} +]; + +/** + * @title Table with multi column sorting + */ +@Component({ + selector: 'table-multi-column-sort-example', + styleUrls: ['table-multi-column-sort-example.css'], + templateUrl: 'table-multi-column-sort-example.html', +}) +export class TableMultiColumnSortExample implements OnInit { + displayedColumns: string[] = ['position', 'name', 'weight', 'symbol', 'type']; + dataSource = new MatTableDataSource(ELEMENT_DATA); + + @ViewChild(MatMultiSort) sort: MatMultiSort; + + ngOnInit() { + this.dataSource.sort = this.sort; + } + + getSortCounter(position: number, count: number): string { + if (count < 2) { + return ''; + } + + return (position + 1).toString(); + } +}