Skip to content

feat(sort): allowing sorting by multiple columns #13538

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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