I've been beating my head against this problem for quite a while before I got here. Essentially, I have an Angular Material table that uses animations to create a detail row. When the table sorts, it rearranges the data. Some of the detail rows have a transition to void during that process. Afterwards, the detail rows stop playing the animation, even though the animation events are firing. I suspect that MatSort is breaking the animations somehow, but I'm not sure how.
Angular Material table:
<mat-table matSort
[dataSource]="tableData"
multiTemplateDataRows>
<!-- More Column -->
<ng-container matColumnDef="more">
<mat-header-cell *matHeaderCellDef
translate>
More
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
<p class="fa fa-angle-right" *ngIf="!tableData.checkExpanded(scheduleCourse)"></p>
<p class="fa fa-angle-down" *ngIf="tableData.checkExpanded(scheduleCourse)"></p>
</mat-cell>
</ng-container>
<!-- Meets Column -->
<ng-container matColumnDef="meets">
<mat-header-cell *matHeaderCellDef
mat-sort-header="Meets"
translate>
Meets
<filter [data]="tableData" columnName="Meets" dataType="string"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.Meets}}
</mat-cell>
</ng-container>
<!-- Term Column -->
<ng-container matColumnDef="term">
<mat-header-cell *matHeaderCellDef
mat-sort-header="Term"
translate>
Term
<filter [data]="tableData" columnName="Term" dataType="string"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.Term}}
</mat-cell>
</ng-container>
<!-- Course Name Column -->
<ng-container matColumnDef="course">
<mat-header-cell *matHeaderCellDef
mat-sort-header="Course"
translate>
Course Name
<filter [data]="tableData" columnName="Course" dataType="string"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.Course}}
</mat-cell>
</ng-container>
<!-- Teacher Column -->
<ng-container matColumnDef="teacher">
<mat-header-cell *matHeaderCellDef
mat-sort-header="Teacher"
translate>
Teacher
<filter [data]="tableData" columnName="Teacher" dataType="string"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.Teacher}}
</mat-cell>
</ng-container>
<!-- Room Column -->
<ng-container matColumnDef="room">
<mat-header-cell *matHeaderCellDef
mat-sort-header="Room"
translate>
Room
<filter [data]="tableData" columnName="Room" dataType="string"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.Room}}
</mat-cell>
</ng-container>
<!-- Entry Date Column -->
<ng-container matColumnDef="entry date">
<mat-header-cell *matHeaderCellDef
mat-sort-header="EntryDate"
translate>
Entry Date
<filter [data]="tableData" columnName="EntryDate" dataType="date"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.EntryDate.toString() != junkDate.toString() ? scheduleCourse.EntryDate.toLocaleDateString() : ''}}
</mat-cell>
</ng-container>
<!-- Dropped Date Column -->
<ng-container matColumnDef="dropped date">
<mat-header-cell *matHeaderCellDef
mat-sort-header="DroppedDate"
translate>
Dropped Date
<filter [data]="tableData" columnName="DroppedDate" dataType="date"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.DroppedDate.toString() != junkDate.toString() ? scheduleCourse.DroppedDate.toLocaleDateString() : ''}}
</mat-cell>
</ng-container>
<!-- Team Column -->
<ng-container matColumnDef="team">
<mat-header-cell *matHeaderCellDef
mat-sort-header="TeamCode"
translate>
Team
<filter [data]="tableData" columnName="TeamCode" dataType="string"></filter>
</mat-header-cell>
<mat-cell *matCellDef="let scheduleCourse">
{{scheduleCourse.TeamCode}}
</mat-cell>
</ng-container>
<!-- Expand Row 1 -->
<ng-container matColumnDef="expandedRow">
<td mat-cell
*matCellDef="let scheduleCourse"
[attr.colspan]="columns.length"
style="width: 100%">
<!-- Links and Actions -->
<div class="detailRow">
<div class="detailItem">
<label style="color: #595959" translate>Course-Section</label>
{{scheduleCourse.SubjectCode}}-{{scheduleCourse.Section}}
</div>
<a class="detailItem"
(click)="assignmentClick(scheduleCourse)"
translate>
Assignments
</a>
<a class="detailItem"
(click)="attendanceClick(scheduleCourse)"
translate>
Attendance
</a>
<a class="detailItem"
(click)="emailTeacherClick(scheduleCourse)"
translate>
Email Teacher
</a>
<a class="detailItem"
(click)="gradesClick(scheduleCourse)"
translate>
Grades
</a>
<!-- Menu Button -->
<button class="detailItem"
*ngIf="showProfiles"
style="cursor: pointer; border: none; background-color: inherit;"
[matMenuTriggerFor]="actionMenu"
[matMenuTriggerData]="{'scheduleCourse': scheduleCourse}">
<img src="./assets/images/actions.png"
alt="actions">
</button>
</div>
<!-- School Indicator -->
<div *ngIf="showSchool(scheduleCourse)"
class="detailRow">
<div class="detailItem">
<label style="color: #595959" translate>
School
</label>
{{scheduleCourse.SchoolName}}
</div>
</div>
</td>
</ng-container>
<!-- Row definitions -->
<mat-header-row *matHeaderRowDef="columns"></mat-header-row>
<mat-row *matRowDef="let row; columns: columns;"
matRipple
tabindex="0"
style="cursor: pointer"
[ngStyle]="{'background-color': selectedRow == row ? 'whitesmoke' : ''}"
[ngClass]="{'detailRowOpened': tableData.checkExpanded(row)}"
(click)="tableData.toggleExpanded(row); selectedRow = row;"></mat-row>
<mat-row *matRowDef="let row; columns: ['expandedRow']"
matRipple
(click)="selectedRow = row;"
[ngClass]="{'selectedRow': selectedRow == row}"
(@detailExpand.done)="animation($event)"
[@detailExpand]="tableData.checkExpanded(row) ? 'expanded' : 'collapsed'"
style="overflow: hidden"></mat-row>
</mat-table>
The detailExpand animation:
export const detailExpand = [
trigger('detailExpand', [
state('collapsed', style({
paddingTop: '0px',
height: '0px',
minHeight: '0',
paddingBottom: '0px'
})),
state('expanded', style({
paddingTop: '*',
height: 'auto',
paddingBottom: '25px'
})),
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
])
];
My component, in case you need it:
@Component({
selector: 'student-schedule',
templateUrl: './student-schedule.component.html',
styleUrls: [
'./student-schedule.component.css'
],
animations: [
detailExpand
]
})
export class StudentScheduleComponent implements OnInit, DoCheck, OnDestroy {
// Properties
private _viewOption = 1;
private _includeDropped = false;
schedule: ScheduleCourse[] = [];
subscriptions: Subscription[] = [];
tableData = new TylerMatTableDataSource();
junkDate = System.junkDate;
V10: boolean;
columns = ['more', 'meets', 'term', 'course', 'teacher', 'room', 'entry date', 'dropped date', 'team'];
selectedRow: ScheduleCourse;
expandEmitter = new EventEmitter<boolean>();
tableHeight: number;
minTableWidth: number;
@ViewChild('tableContainer', {read: ElementRef}) tableContainer: ElementRef;
showProfiles: boolean;
studentEnrollment: Enrollment;
_sort: MatSort;
// Class Functions
constructor(
private studentScheduleService: StudentScheduleService,
private loginService: LoginService,
private router: Router,
private dialog: MatDialog,
private studentService: StudentService,
private sendEmailService: SendEmailService
) { }
get viewOption(): number {
return this._viewOption;
}
set viewOption(value: number) {
this._viewOption = value;
this.getSchedule();
}
get includeDropped(): boolean {
return this._includeDropped;
}
set includeDropped(value: boolean) {
this._includeDropped = value;
this.checkColumns();
}
@ViewChild(MatSort) set sort(value: MatSort) {
this._sort = value;
this.tableData.sort = this._sort;
}
get sort(): MatSort {
return this._sort;
}
// Event Functions
ngOnInit() {
// POST: initializes the data
this.V10 = this.loginService.LoginSettings.V10;
this.showProfiles = this.loginService.LoginSettings.ParentPortalCourseScheduleProfiles;
this.checkColumns();
this.subscriptions.push(
this.expandEmitter.subscribe(expand => {
this.tableData.expandAll(expand);
}),
this.studentService.selectedStudentStream$.subscribe(() => {
this.studentEnrollment = this.studentService.studentEnrollment;
this.getSchedule();
})
);
}
ngDoCheck() {
// POST: determines the height and width of the table container
if (this.tableContainer) {
this.tableHeight = System.getTableHeight(this.tableContainer);
}
}
ngOnDestroy() {
// POST: unsubscribes to all observables
this.subscriptions.forEach(subscription => {
subscription.unsubscribe();
});
}
assignmentClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on an assignment link under a course
// POST: routes the user to that assignment page
// TODO: Ensure it links to the proper class
this.router.navigateByUrl('/student360/assignments');
}
attendanceClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on an attendance link under a course
// POST: routes the user to that attendance page
this.router.navigateByUrl('/student360/attendance');
}
emailTeacherClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on an attendance link under a course
// POST: routes the user to the email page
// TODO: Ensure it links to the proper teacher
this.sendEmailService.teacherName = scheduleCourse.TeacherName;
this.sendEmailService.teacherEmailAddress = scheduleCourse.TeacherEmail;
this.router.navigateByUrl('/student360/sendEmail');
}
gradesClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on a grade link under a course
// POST: routes the user to the grade page
this.router.navigateByUrl('/student360/reportcardgrades');
}
courseDescriptionClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on a course description link under a course
// POST: shows a modal for the course's description
this.dialog.open(CourseDescriptionDialogComponent, {
data: {
course: scheduleCourse.Course,
section: scheduleCourse.Section,
teacherName: scheduleCourse.TeacherName,
schoolName: scheduleCourse.SchoolName,
curriculum: scheduleCourse.Curriculum,
description: scheduleCourse.Description
}
});
}
classInformationClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on a class information link under a course
// POST: shows a modal for that class' profile
this.dialog.open(ProfileViewerDialogComponent, {
data: {
courseSSEC_ID: scheduleCourse.Id,
courseName: scheduleCourse.Course,
courseSection: scheduleCourse.Section,
teacherName: scheduleCourse.TeacherName,
school: scheduleCourse.SchoolName
}
});
}
teacherProfileClick(scheduleCourse: ScheduleCourse) {
// PRE: the user clicks on a teacher profile link under a couse
// POST: shows a modal for that teacher's profile
this.dialog.open(ProfileViewerDialogComponent, {
data: {
teacherId: scheduleCourse.TeacherId,
teacherName: scheduleCourse.TeacherName,
school: scheduleCourse.SchoolName
}
});
}
animation(event) {
console.log(event);
}
// Methods
showSchool(scheduleCourse: ScheduleCourse): boolean {
return this.studentEnrollment.SchoolName &&
scheduleCourse.SchoolName &&
this.studentEnrollment.SchoolName.trim().toUpperCase() != scheduleCourse.SchoolName.trim().toUpperCase();
}
getSchedule() {
// POST: obtains the schedule from the server
this.subscriptions.push(
this.studentScheduleService.getStudentSchedule(this.viewOption).subscribe(schedule => {
this.schedule = schedule;
for (let i = 0; i < this.schedule.length; i++) {
this.schedule[i] = System.convert<ScheduleCourse>(this.schedule[i], new ScheduleCourse());
}
this.tableData = new TylerMatTableDataSource(this.schedule);
if (this.sort) {
this.tableData.sort = this.sort;
}
})
);
}
checkColumns() {
// POST: checks the columns for ones that shouldn't be there
// Team is a V9 only column
if (this.V10 && this.columns.includes('team')) {
this.columns.splice(this.columns.indexOf('team'), 1);
} else if (!this.V10 && !this.columns.includes('team')) {
this.columns.push('team'); // Team is always on the end
}
// Entry date and dropped date are only there if include dropped
if (this.includeDropped) {
if (!this.columns.includes('entry date')) {
this.columns.splice(5, 0, 'entry date');
}
if (!this.columns.includes('dropped date')) {
this.columns.splice(6, 0, 'dropped date');
}
this.minTableWidth = 1000;
} else {
if (this.columns.includes('dropped date')) {
this.columns.splice(this.columns.indexOf('dropped date'), 1);
}
if (this.columns.includes('entry date')) {
this.columns.splice(this.columns.indexOf('entry date'), 1);
}
this.minTableWidth = 750;
}
}
}
This is the animation event to void that I'm talking about. After this one, the animation stops working. Also, I've tested to see if I can create a void transition animation, but that animation doesn't play either.
Now, I know that the tableData works properly because the table displays fine. Further, the animations work perfectly before that event is fired from sorting. In fact, the sorting works and the "detailRow.done" event keeps firing even when the animation isn't playing. So, I know it must be something to do with MatSort and Animation interaction: I just don't know what.
Here's what I've tried:
- Removing [ngStyle] and [ngClass]
- Removing the width and height styling on the table and its container
- Removing the ngDoCheck lifecycle hook
- Changing mat-sort-header to use the matColumnDef and making the matColumnDef match the sort property name
- Using a setTimeout to set the sort to the tableData
- "Bouncing" the table in and out of the DOM after the sort changes
- Forcing a renderRows on the table after sort changes
UPDATE 1
I tried reproducing the problem in a stackblitz, but I couldn't do so successfully. It appears that MatSort and Angular Animations play well with each other and that something else is going on here. That gives me some direction.
UPDATE 2
So, I've found the problem, although it's odd that it is a problem. I've extended the MatTableDataSource with a few helper functions, which is where I get the "tableData.checkExpanded" and "tableData.toggleExpanded" functions. When I use an array of booleans from the component to check for expansion, the component works fine. When I used those functions, I end up with this problem. This is the code for that class. I may update the stackblitz to see if I can reproduce it using this.
export class TylerMatTableDataSource extends MatTableDataSource<any>{
filterNumber:number = 0;
filterTestValue:string = '';
filters:FilterModel[] = [];
expandedElements:number[] = [];
constructor(initialData?: any[]){
super(initialData);
this.filterPredicate = this.genericFilter;
}
toggleExpanded(row: any) {
if (row != undefined) {
if(row.detailRow == undefined || row.detailRow == false){
row.detailRow = true;
}
else{
row.detailRow = false;
}
}
}
checkExpanded(row:any):boolean{
if(row.detailRow == undefined){
row.detailRow = false;
}
return row.detailRow;
}
expandAll(expand: boolean) {
this.data.forEach(element => {
element.detailRow = expand;
});
}
}
UPDATE 3
I've updated the stackblitz to demonstrate the problem. Note that this only happens when I use two *ngIf's on the p tags in the 'More' column. If I use interpolation, the error does not occur.