pTableCheckbox изменяет значение при разбиении на страницы/сортировке (пример)

#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!