Skip to content

Commit

Permalink
feat(components/sort): enable multi-sort
Browse files Browse the repository at this point in the history
Adds multi-sorting capability to MatSort, enables sorting on multiple table columns at once.
Feature is activated by using matSortMultiple property on table tag.

BREAKING CHANGE:

`MatSort.active` and `MatSort.direction` wont be source of truth for current sorted but as a
reference for the default sorted column and its direction. So any inner usage of it should instead
reference `MatSort.isActive` and `MatSort.getCurrentSortDirection` respectively.
For user code, we should use `MatSort.sortState` that holds the complete sort state.

Fixes angular#24102
  • Loading branch information
imerljak committed Dec 14, 2021
1 parent 97ec228 commit 9b55d29
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 76 deletions.
Expand Up @@ -2,6 +2,10 @@ table {
width: 100%;
}

button {
margin: 0 8px 8px 0;
}

th.mat-sort-header-sorted {
color: black;
}
@@ -1,4 +1,6 @@
<table mat-table [dataSource]="dataSource" matSort (matSortChange)="announceSortChange($event)"
<div><button mat-raised-button (click)="multiSort = !multiSort"> Multiple: {{multiSort}} </button></div>
<table mat-table [dataSource]="dataSource" matSortActive="position" matSortDirection="asc" matSort
[matSortMultiple]="multiSort" (matSortChange)="announceSortChange($event)"
class="mat-elevation-z8">

<!-- Position Column -->
Expand Down
Expand Up @@ -35,6 +35,8 @@ export class TableSortingExample implements AfterViewInit {
displayedColumns: string[] = ['position', 'name', 'weight', 'symbol'];
dataSource = new MatTableDataSource(ELEMENT_DATA);

multiSort = false;

constructor(private _liveAnnouncer: LiveAnnouncer) {}

@ViewChild(MatSort) sort: MatSort;
Expand Down
11 changes: 7 additions & 4 deletions src/material/sort/sort-header.ts
Expand Up @@ -293,9 +293,10 @@ export class MatSortHeader

/** Whether this MatSortHeader is currently sorted in either ascending or descending order. */
_isSorted() {
const currentSortDirection = this._sort.getCurrentSortDirection(this.id);
return (
this._sort.active == this.id &&
(this._sort.direction === 'asc' || this._sort.direction === 'desc')
this._sort.isActive(this.id) &&
(currentSortDirection === 'asc' || currentSortDirection === 'desc')
);
}

Expand All @@ -321,7 +322,9 @@ export class MatSortHeader
* only be changed once the arrow displays again (hint or activation).
*/
_updateArrowDirection() {
this._arrowDirection = this._isSorted() ? this._sort.direction : this.start || this._sort.start;
this._arrowDirection = this._isSorted()
? this._sort.getCurrentSortDirection(this.id)
: this.start || this._sort.start;
}

_isDisabled() {
Expand All @@ -339,7 +342,7 @@ export class MatSortHeader
return 'none';
}

return this._sort.direction == 'asc' ? 'ascending' : 'descending';
return this._sort.getCurrentSortDirection(this.id) == 'asc' ? 'ascending' : 'descending';
}

/** Whether the arrow inside the sort header should be rendered. */
Expand Down
57 changes: 37 additions & 20 deletions src/material/sort/sort.spec.ts
Expand Up @@ -235,51 +235,51 @@ describe('MatSort', () => {
const container = fixture.nativeElement.querySelector('#defaultA .mat-sort-header-container');

component.sort('defaultA');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('asc');
expect(container.getAttribute('tabindex')).toBe('0');

component.disabledColumnSort = true;
fixture.detectChanges();

component.sort('defaultA');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('asc');
expect(container.getAttribute('tabindex')).toBeFalsy();
});

it('should allow for the cycling the sort direction to be disabled for all columns', () => {
const container = fixture.nativeElement.querySelector('#defaultA .mat-sort-header-container');

component.sort('defaultA');
expect(component.matSort.active).toBe('defaultA');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.isActive('defaultA')).toBeTruthy();
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('asc');
expect(container.getAttribute('tabindex')).toBe('0');

component.disableAllSort = true;
fixture.detectChanges();

component.sort('defaultA');
expect(component.matSort.active).toBe('defaultA');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.isActive('defaultA')).toBeTruthy();
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('asc');
expect(container.getAttribute('tabindex')).toBeFalsy();

component.sort('defaultB');
expect(component.matSort.active).toBe('defaultA');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.isActive('defaultA')).toBeTruthy();
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('asc');
expect(container.getAttribute('tabindex')).toBeFalsy();
});

it('should reset sort direction when a different column is sorted', () => {
component.sort('defaultA');
expect(component.matSort.active).toBe('defaultA');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.isActive('defaultA')).toBeTruthy();
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('asc');

component.sort('defaultA');
expect(component.matSort.active).toBe('defaultA');
expect(component.matSort.direction).toBe('desc');
expect(component.matSort.isActive('defaultA')).toBeTruthy();
expect(component.matSort.getCurrentSortDirection('defaultA')).toBe('desc');

component.sort('defaultB');
expect(component.matSort.active).toBe('defaultB');
expect(component.matSort.direction).toBe('asc');
expect(component.matSort.isActive('defaultB')).toBeTruthy();
expect(component.matSort.getCurrentSortDirection('defaultB')).toBe('asc');
});

it(
Expand Down Expand Up @@ -439,6 +439,24 @@ describe('MatSort', () => {
descriptionElement = document.getElementById(descriptionId);
expect(descriptionElement?.textContent).toBe('Sort 2nd column');
});

it('should be able to store sorting for multiple columns when using multiSort', () => {
component.matSort.multiple = true;

component.start = 'asc';
testSingleColumnSortDirectionSequence(fixture, ['asc', 'desc', ''], 'defaultA');
testSingleColumnSortDirectionSequence(fixture, ['asc', 'desc', ''], 'defaultB');

expect(component.matSort.sortState.size).toBe(2);

const defaultAState = component.matSort.sortState.get('defaultA');
expect(defaultAState).toBeTruthy();
expect(defaultAState?.direction).toBe(component.start);

const defaultBState = component.matSort.sortState.get('defaultB');
expect(defaultBState).toBeTruthy();
expect(defaultBState?.direction).toBe(component.start);
});
});

describe('with default options', () => {
Expand Down Expand Up @@ -494,26 +512,25 @@ function testSingleColumnSortDirectionSequence(

// Reset the sort to make sure there are no side affects from previous tests
const component = fixture.componentInstance;
component.matSort.active = '';
component.matSort.direction = '';

// Run through the sequence to confirm the order
const actualSequence = expectedSequence.map(() => {
component.sort(id);

// Check that the sort event's active sort is consistent with the MatSort
expect(component.matSort.active).toBe(id);
expect(component.matSort.isActive(id)).toBeTruthy();
expect(component.latestSortEvent.active).toBe(id);

// Check that the sort event's direction is consistent with the MatSort
expect(component.matSort.direction).toBe(component.latestSortEvent.direction);
return component.matSort.direction;
const direction = component.matSort.getCurrentSortDirection(id);
expect(direction).toBe(component.latestSortEvent.direction);
return direction;
});
expect(actualSequence).toEqual(expectedSequence);

// Expect that performing one more sort will loop it back to the beginning.
component.sort(id);
expect(component.matSort.direction).toBe(expectedSequence[0]);
expect(component.matSort.getCurrentSortDirection(id)).toBe(expectedSequence[0]);
}

/** Column IDs of the SimpleMatSortApp for typing of function params in the component (e.g. sort) */
Expand Down
80 changes: 72 additions & 8 deletions src/material/sort/sort.ts
Expand Up @@ -18,6 +18,7 @@ import {
OnInit,
Optional,
Output,
SimpleChanges,
} from '@angular/core';
import {CanDisable, HasInitialized, mixinDisabled, mixinInitialized} from '@angular/material/core';
import {Subject} from 'rxjs';
Expand Down Expand Up @@ -78,6 +79,9 @@ export class MatSort
/** Collection of all registered sortables that this directive manages. */
sortables = new Map<string, MatSortable>();

// Holds the sort state of any MatSortable registered within this directive
sortState = new Map<string, Sort>();

/** Used to notify any child components listening to state changes. */
readonly _stateChanges = new Subject<void>();

Expand All @@ -90,7 +94,20 @@ export class MatSort
*/
@Input('matSortStart') start: 'asc' | 'desc' = 'asc';

/** The sort direction of the currently active MatSortable. */
/**
* If MatSort should enable multiple active MatSortables or not.
* Defaults to false.
*/
@Input('matSortMultiple')
get multiple(): boolean {
return this._multiple;
}
set multiple(v: any) {
this._multiple = coerceBooleanProperty(v);
}
private _multiple = false;

/** The sort direction of the most recently sorted MatSortable. */
@Input('matSortDirection')
get direction(): SortDirection {
return this._direction;
Expand Down Expand Up @@ -160,14 +177,40 @@ export class MatSort

/** 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;
let sortableDirection;
if (!this.isActive(sortable.id)) {
sortableDirection = sortable.start ? sortable.start : this.start;
} else {
this.direction = this.getNextSortDirection(sortable);
sortableDirection = this.getNextSortDirection(sortable);
}

this.sortChange.emit({active: this.active, direction: this.direction});
const currentSort: Sort = {
active: sortable.id,
direction: sortableDirection,
};

// We should keep only one entry on sortState when single sorting.
if (!this.multiple) {
this.sortState.clear();
}

this.sortState.set(sortable.id, currentSort);
this.sortChange.emit(currentSort);
}

/**
* Checks wether the provided MatSortable is currently active (has sorting) on this MatSort.
*/
isActive(id: string): boolean {
return this.sortState.has(id);
}

/**
* Returns the current sort direction of the supplied sortable,
* default to unsorted if provided an inactive MatSortable ]
*/
getCurrentSortDirection(id: string): SortDirection {
return this.sortState.get(id)?.direction || '';
}

/** Returns the next sort direction of the active sortable, checking for potential overrides. */
Expand All @@ -176,13 +219,15 @@ export class MatSort
return '';
}

const currentSortableDirection = this.getCurrentSortDirection(sortable.id);

// Get the sort direction cycle with the potential sortable overrides.
const disableClear =
sortable?.disableClear ?? this.disableClear ?? !!this._defaultOptions?.disableClear;
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 nextDirectionIndex = sortDirectionCycle.indexOf(currentSortableDirection) + 1;
if (nextDirectionIndex >= sortDirectionCycle.length) {
nextDirectionIndex = 0;
}
Expand All @@ -193,7 +238,26 @@ export class MatSort
this._markInitialized();
}

ngOnChanges() {
ngOnChanges(changes: SimpleChanges) {
// We should update sortState with the current active and direction @Input values
// whenever they change to allow programatically sorting by this column.
if (changes['active'] || changes['direction']) {
const activeValue = changes['active']?.currentValue || this.active;
const activeDirection = changes['direction']?.currentValue || this.direction || this.start;

// Handle possibly deactivating sort on active.
if (!activeValue || activeValue === '') {
this.sortState.delete(activeValue);
} else {
const sort: Sort = {
active: activeValue,
direction: activeDirection || this.start,
};

this.sortState.set(sort.active, sort);
}
}

this._stateChanges.next();
}

Expand Down
10 changes: 8 additions & 2 deletions src/material/table/table-data-source.spec.ts
Expand Up @@ -36,7 +36,11 @@ describe('MatTableDataSource', () => {
const data = values.map(v => ({'prop': v}));

// Set the active sort to be on the "prop" key
sort.active = 'prop';
// sort.active = 'prop';

fixture.componentInstance.active = 'prop';

fixture.detectChanges();

const reversedData = data.slice().reverse();
const sortedData = dataSource.sortData(reversedData, sort);
Expand Down Expand Up @@ -80,8 +84,10 @@ describe('MatTableDataSource', () => {
});

@Component({
template: `<div matSort matSortDirection="asc"></div>`,
template: `<div matSort matSortDirection="asc" [matSortActive]="active" ></div>`,
})
class MatSortApp {
@ViewChild(MatSort) sort: MatSort;

active: string = '';
}

0 comments on commit 9b55d29

Please sign in to comment.