Skip to content
214 changes: 214 additions & 0 deletions src/lib/sort/multi-sort.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SimpleMatSortApp>;

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<SimpleMatSortApp>,
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: `
<div matMultiSort
[matSortActive]="active"
[matSortDisabled]="disableAllSort"
[matSortStart]="start"
[matSortDirection]="direction"
(matSortChange)="latestSortEvent = $event">
<div id="defaultA"
#defaultA
mat-sort-header="defaultA"
[disabled]="disabledColumnSort">
A
</div>
<div id="defaultB"
#defaultB
mat-sort-header="defaultB">
B
</div>
<div id="overrideStart"
#overrideStart
mat-sort-header="overrideStart" start="desc">
D
</div>
<div id="overrideDisableClear"
#overrideDisableClear
mat-sort-header="overrideDisableClear"
disableClear>
E
</div>
</div>
`
})
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<HTMLElement>) { }

sort(id: SimpleMatSortAppColumnIds) {
this.dispatchMouseEvent(id, 'click');
}

dispatchMouseEvent(id: SimpleMatSortAppColumnIds, event: string) {
const sortElement = this.elementRef.nativeElement.querySelector(`#${id}`)!;
dispatchMouseEvent(sortElement, event);
}
}
145 changes: 145 additions & 0 deletions src/lib/sort/multi-sort.ts
Original file line number Diff line number Diff line change
@@ -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<void>();

/**
* 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<MultiSort> = new EventEmitter<MultiSort>();

/** 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;
}
1 change: 1 addition & 0 deletions src/lib/sort/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 8 additions & 0 deletions src/lib/sort/sort-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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').`);
}
5 changes: 4 additions & 1 deletion src/lib/sort/sort-header.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
<div class="mat-sort-header-pointer-right" [@rightPointer]="_getArrowDirectionState()"></div>
<div class="mat-sort-header-pointer-middle"></div>
</div>
</div>
<div class="mat-sort-header-counter">
{{ _getSortCounter() }}
</div>
</div>
</div>
6 changes: 6 additions & 0 deletions src/lib/sort/sort-header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading