#angular #typescript #primeng #primeng-datatable
Вопрос:
У меня возникла проблема, когда при выборе строк с помощью флажка pTableCheckbox, а затем при сортировке или разбиении на страницы выбранные элементы остаются в заданном индексе и изменяют значение. Ожидаемое поведение состоит в том, чтобы сохранить выделение в данной строке.
DynamicTableComponent.html
<ng-container *ngIf="data">
<p-table
styleClass="p-datatable-striped p-datatable-sm"
[autoLayout]="true"
[columns]="cols"
[value]="data"
[paginator]="true"
[rows]="rows"
[showCurrentPageReport]="true"
[globalFilterFields]="filterFields"
[dataKey]="dataKey"
[(selection)]="selectedData"
[resetPageOnSort]="true"
[currentPageReportTemplate]="pageReport"
[rowsPerPageOptions]="pageOptions"
[rowTrackBy]="trackByFn"
editMode="row"
#dynamicTable
>
<ng-template pTemplate="caption">
<div class="p-d-flex">
<p-confirmPopup></p-confirmPopup>
<button
pButton
pRipple
type="button"
label="Save"
class="p-button-success p-mr-2"
*ngIf="editedData.length > 0"
[disabled]="(dynamicTable.editingRowKeys | json) !== '{}'"
(click)="onSave($event)"
></button>
<button
pRipple
pButton
type="button"
label="Clear"
class="p-button-outlined p-mr-2"
icon="pi pi-filter-slash"
(click)="onClear()"
></button>
<ng-container *ngIf="data.length > 0">
<!-- <button
pRipple
pButton
type="button"
label="Import"
class="p-mr-2"
icon="pi pi-upload"
(click)="import.click()"
></button>
<input
hidden
type="file"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
(click)="import.value = ''"
(change)="onImport($event)"
#import
/> -->
<button
type="button"
pButton
pRipple
icon="pi pi-file-o"
(click)="dynamicTable.exportCSV()"
class="p-mr-2"
pTooltip="CSV"
tooltipPosition="bottom"
></button>
<button
type="button"
pButton
pRipple
icon="pi pi-file-excel"
(click)="exportExcel()"
class="p-button-success p-mr-2"
pTooltip="XLS"
tooltipPosition="bottom"
></button>
<button
type="button"
pButton
pRipple
icon="pi pi-filter"
(click)="dynamicTable.exportCSV({ selectionOnly: true })"
class="p-button-info"
pTooltip="Selection CSV"
tooltipPosition="bottom"
></button>
</ng-container>
<span class="p-input-icon-left p-ml-auto">
<i class="pi pi-search"></i>
<input
[(ngModel)]="globalFilter"
pInputText
type="text"
(input)="
dynamicTable.filterGlobal($any($event.target!).value, 'contains')
"
placeholder="Search keyword"
/>
</span>
</div>
</ng-template>
<ng-template pTemplate="header" let-columns>
<tr>
<th style="width: 3rem">
<p-tableHeaderCheckbox></p-tableHeaderCheckbox>
</th>
<ng-container *ngFor="let col of columns">
<th [pSortableColumn]="col.field" [pSortableColumnDisabled]="!col.sortable" *ngIf="col.header">
<div class="p-d-flex p-jc-between p-ai-center">
<span>
{{ col.header }}
<p-sortIcon
[field]="col.field"
></p-sortIcon>
</span>
<p-columnFilter
type="text"
[field]="col.field"
display="menu"
></p-columnFilter>
</div>
</th>
<th *ngIf="!col.header" style="width: 8em"></th>
</ng-container>
</tr>
</ng-template>
<ng-template
pTemplate="body"
let-rowData
let-editing="editing"
let-ri="rowIndex"
let-columns="columns"
>
<tr [pEditableRow]="rowData">
<td>
<p-tableCheckbox [value]="rowData" [index]="ri" [inputId]="rowData[dataKey]"></p-tableCheckbox>
</td>
<ng-container *ngFor="let col of columns">
<ng-container *ngIf="col.type !== 'actions'">
<!-- Displays data for a non-editable field -->
<td *ngIf="!col.editable">{{ rowData[col.field] }}</td>
<!-- Displays data for a editable field -->
<td *ngIf="col.editable">
<p-cellEditor>
<ng-template pTemplate="input" [ngSwitch]="col.type">
<input
class="w-fluid"
pInputText
type="text"
[name]="col.field"
[maxlength]="col.maxLength ?? -1"
[minLength]="col.minLength || 0"
[(ngModel)]="rowData[col.field]"
*ngSwitchCase="'string'"
/>
<p-inputNumber
class="w-fluid"
[name]="col.field"
[useGrouping]="false"
[maxlength]="col.maxLength || -1"
[(ngModel)]="rowData[col.field]"
*ngSwitchCase="'number'"
></p-inputNumber>
</ng-template>
<ng-template pTemplate="output">
{{ rowData[col.field] }}
</ng-template>
</p-cellEditor>
</td>
</ng-container>
<!-- Displays data for edit action buttons -->
<td *ngIf="col.type === 'actions'" style="text-align: center">
<button
*ngIf="!editing"
pButton
pRipple
type="button"
pInitEditableRow
icon="pi pi-pencil"
(click)="onRowEditInit(rowData)"
class="p-button-rounded p-button-text"
></button>
<button
*ngIf="editing"
pButton
pRipple
type="button"
pSaveEditableRow
icon="pi pi-check"
style="margin-right: 0.5em"
(click)="onRowEditSave(rowData, ri)"
class="p-button-rounded p-button-text p-button-success p-mr-2"
></button>
<button
*ngIf="editing"
pButton
pRipple
type="button"
pCancelEditableRow
icon="pi pi-times"
(click)="onRowEditCancel(rowData, ri)"
class="p-button-rounded p-button-text p-button-danger"
></button>
</td>
</ng-container>
</tr>
</ng-template>
<!-- Message displayed if no results are found -->
<ng-template pTemplate="emptymessage">
<tr>
<td [attr.colspan]="cols.length">No results were found.</td>
</tr>
</ng-template>
</p-table>
</ng-container>
<app-import-dialog
[visible]="showImport"
[cols]="cols"
[data]="importedData"
[pageReport]="pageReport"
(dialogClosed)="onDialogCancel()"
(dialogSubmit)="onDialogSubmit($event)"
></app-import-dialog>
Динамический компонент.ts
import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewChild, } from '@angular/core';
import { DTCol } from '@shared/models/dynamic-table-col.model';
import * as FileSaver from 'file-saver';
import { ConfirmationService, MessageService } from 'primeng/api';
import { DialogService } from 'primeng/dynamicdialog';
import { Table } from 'primeng/table';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LocationService } from '@services/location/location.service';
import { LoggingService } from '@services/logging/logging.service';
import { mapExcelToLocations, mapLocationsToExcel, } from '../../util/json/xlsx-mapper';
@Component({
selector: 'app-dynamic-table',
templateUrl: './dynamic-table.component.html',
styleUrls: ['./dynamic-table.component.scss'],
providers: [LocationService, DialogService],
})
export class DynamicTableComponent implements OnInit, OnDestroy {
/**
* ViewChild reference to PrimeNG Table in the Template
* @type {Table}
*/
@ViewChild('dynamicTable') table!: Table;
/**
* Data to be displayed in the table
* @type {any}
*/
@Input() data: any | undefined;
/**
* Columns to be displayed on the table
* @type {DTCol[]}
*/
@Input() cols!: DTCol[];
/**
* Primary key to be used in determining uniqueness of each row.
* Default value is 'id' unless passed in from the parent component.
* @type {string}
*/
@Input() dataKey = 'id';
/**
* Number of rows to display per page by default
* @type {number}
* @default - 15
*/
@Input() rows = 15;
/**
* Paginator status message
* @type {string}
* @default - 'Showing {first} to {last} of {totalRecords} results'
*/
@Input() pageReport = 'Showing {first} to {last} of {totalRecords} results';
/**
* Choices for number of rows displayed per page
* @type {number[]}
* @default - [15, 25, 50]
*/
@Input() pageOptions = [15, 25, 50];
// Rows edited on table
editedData: any[] = [];
// Filterable Fields
filterFields!: string[];
// Keyword Search Input
globalFilter!: string;
// Rows selected on table
selectedData: any[] | undefined;
// Is Dynamic Dialog Visible?
showImport = false;
// Imported Excel Data Rows
importedData: any | undefined;
// Cloned data to check against
private clonedData: { [s: string]: any } = {};
private readonly destroy$ = new Subject<boolean>();
constructor(
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly confirmationService: ConfirmationService,
private readonly locationService: LocationService,
private readonly logger: LoggingService,
private readonly messageService: MessageService
) {
}
ngOnInit(): void {
// Maps col.field values to filterFields array
this.filterFields = this.cols.map((col: any) => {
return col.field;
});
// Removes null or undefined fields
this.filterFields = this.filterFields.filter((col: string) => {
return col;
});
}
ngOnDestroy(): void {
this.destroy$.next(true);
this.destroy$.complete();
}
/**
* Saves edited rows on the table
* @author Stephen Combs <stephen_combs@cinfin.com>
* @param event DOM Event from the 'Save' Button
*/
onSave(event: Event): void {
this.confirmationService.confirm({
target: event.target ?? undefined,
message: 'Are you sure that you want to proceed?',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.locationService.edit({locations: this.editedData})
.pipe(takeUntil(this.destroy$))
.subscribe();
// Reset edited data
this.editedData = [];
// Force change detection to remove save button after successful save
this.changeDetectorRef.detectChanges();
},
reject: () => {
//reject action
this.logger.log('Save Cancelled');
},
});
}
/**
* Clears all filters/sorts currently active on the table
* @author Stephen Combs <stephen_combs@cinfin.com>
*/
onClear(): void {
// Clear table selection
this.table.selection = [];
// Clear Global Filter
this.globalFilter = '';
this.table.filters.global = {
value: null,
matchMode: 'contains',
};
// Reset Table Column Sort/Filters
this.table.reset();
}
/**
* Converts imported .xlsx file into JSON and maps data to table column format.
* @author Stephen Combs <stephen_combs@cinfin.com>
* @param event DOM event from the 'Import' Button
*/
onImport(event: any): void {
import('xlsx').then((xlsx) => {
const reader = new FileReader();
const file = event.target.files[0];
reader.onload = () => {
const data = reader.resu<
const workBook = xlsx.read(data, {type: 'binary'});
let jsonData = workBook.SheetNames.reduce(
(initial: any, name: string) => {
const sheet = workBook.Sheets[name];
initial[name] = xlsx.utils.sheet_to_json(sheet);
return initial;
},
{}
);
switch (this.dataKey) {
case 'xrefId':
jsonData = mapExcelToLocations(jsonData);
break;
default:
jsonData = jsonData.data;
break;
}
if (jsonData.length > 0) {
this.importedData = jsonData;
this.showImport = true;
this.changeDetectorRef.detectChanges();
}
};
// Start callback for FileReader
reader.readAsBinaryString(file);
});
}
/**
* Triggered function for dialogCancel event
* @author Stephen Combs <stephen_combs@cinfin.com>
*/
onDialogCancel(): void {
this.importedData = undefined;
this.showImport = false;
}
/**
* Triggered function for dialogSubmit event.
* Pushes imported data into table data and updates the UI.
* @author Stephen Combs <stephen_combs@cinfin.com>
*/
onDialogSubmit<T>(data: T[]): void {
this.logger.info(`${data.length} Rows of Data were successfully imported!`);
this.messageService.add({
severity: 'success',
summary: 'Import Complete',
detail: `${data.length} Rows of Data were successfully imported!`,
});
// Push each imported row into the table data
data.forEach((row: any) => {
this.data.push(row);
});
// Spread the new results into the table data
this.data = [...this.data];
// Force change detection to update the table in the UI
this.changeDetectorRef.detectChanges();
// Reset importedData array and hide the import dialog
this.importedData = undefined;
this.showImport = false;
}
/**
* Clones table data when a row edit is initiated
* @author Stephen Combs <stephen_combs@cinfin.com>
* @param row row where pencil icon was clicked
*/
onRowEditInit(row: any): void {
this.clonedData[row[this.dataKey]] = {...row};
}
/**
* Adds edited row to array for possible Save
* Filters out previous edit on same row if present
* @author Stephen Combs <stephen_combs@cinfin.com>
* @param row selected row object
* @param index index of selected row
*/
onRowEditSave(row: any, index: number): void {
console.log(row, index);
// Remove row from editedData if altered before
this.editedData = this.editedData.filter((dataRow: any) => {
return dataRow[this.dataKey] !== row[this.dataKey];
});
switch (this.dataKey) {
case 'xrefId':
if (this.data.some((dataRow: any) => dataRow.locationNumber === row.locationNumber amp;amp; dataRow.locationId !== row.locationId amp;amp; dataRow.policy === row.policy) amp;amp; row.locationNumber.trim().length > 0) {
this.messageService.add({
severity: 'error',
summary: 'Edit Failed',
detail: 'Policy cannot have matching Location ID's',
});
this.table.initRowEdit(row);
} else {
if (row.description.trim().length > 0) this.editedData.push(row);
else {
this.messageService.add({
severity: 'error',
summary: 'Edit Failed',
detail: 'Location description can not be blank',
});
this.table.initRowEdit(row);
}
}
break;
default:
this.editedData.push(row);
break;
}
}
/**
* Replaces row data with previous state when edit is cancelled
* @author Stephen Combs <stephen_combs@cinfin.com>
* @param row row where 'X' icon was clicked
* @param index index of row in the dataset
*/
onRowEditCancel(row: any, index: number): void {
this.data[index] = this.clonedData[row[this.dataKey]];
delete this.clonedData[row[this.dataKey]];
}
/**
* Uses xlsx library to setup the Excel Workbook buffer for saving
* @author Stephen Combs <stephen_combs@cinfin.com>
*/
exportExcel(): void {
import('xlsx').then((xlsx) => {
const data =
this.table.selection amp;amp; this.table.selection.length > 0
? this.table.selection
: this.data;
let worksheet;
// Checks to see if there is a specific JSON mapping for the table data displayed
switch (this.dataKey) {
case 'xrefId':
worksheet = xlsx.utils.json_to_sheet(mapLocationsToExcel(data));
break;
default:
worksheet = xlsx.utils.json_to_sheet(data);
break;
}
const workbook = {Sheets: {data: worksheet}, SheetNames: ['data']};
const excelBuffer: any = xlsx.write(workbook, {
bookType: 'xlsx',
type: 'array',
});
this.saveAsExcelFile(excelBuffer, 'data');
});
}
/**
* Saves the Excel Workbook buffer as .xlsx file to the user's machine
* @author Stephen Combs <stephen_combs@cinfin.com>
* @param buffer Excel Workbook buffer
* @param fileName File name to be used in saving
*/
saveAsExcelFile<T extends BlobPart amp; ArrayBuffer>(
buffer: T,
fileName: string
): void {
const EXCEL_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8';
const EXCEL_EXTENSION = '.xlsx';
const data: Blob = new Blob([buffer], {
type: EXCEL_TYPE,
});
FileSaver.saveAs(
data,
fileName '_export_' new Date().getTime() EXCEL_EXTENSION
);
}
trackByFn(index: number): number {
return index;
}
}
I have been studying the PrimeNG documentation which isn’t the most thorough, but I found that the pTableCheckbox has properties: index and inputId. I set these values to the index of the respective row, and the dataKey value of the respective row. These values also change upon paginating/sorting.
Here are screenshots of these issues:
Selection Prior to Paginating/Sorting
Before Paginating/Sorting
After Paginating
After Paginating
After Sorting
After Sorting
Has anyone had a similar issue or found a solution to this problem?
Update
I found that my rowTrackBy function was using the index instead of the dataKey on the row so I switched this. But it didn’t fix the issue entirely because the function has to be formatted as an ES6 Arrow Function. So changing my code to the following fixed my issue.
trackByFn = (index: number, row: any) => {
return row[this.dataKey];
}
Hope this helps someone in the future! Feel free to reach out to me with questions on the PrimeNG library. Have been working with it for a while now and have been through plenty of edge cases like this!