File

src/app/core/common-components/entities-table/entities-table.component.ts

Description

A simple display component (no logic and transformations) to display a table of entities.

Implements

AfterContentInit

Metadata

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor(entityFormService: EntityFormService, formDialog: FormDialogService, router: Router, filterService: FilterService, schemaService: EntitySchemaService)
Parameters :
Name Type Optional
entityFormService EntityFormService No
formDialog FormDialogService No
router Router No
filterService FilterService No
schemaService EntitySchemaService No

Inputs

clickMode
Type : "popup" | "navigate" | "popup-details" | "none"
Default value : "popup"

The action the system triggers when a user clicks on an entry (row):

  • popup: open dialog with simplified form with the given fields only
  • navigate: route the app to the details view of the entity
  • popup-details: open dialog with the full EntityDetails view
  • none: do not trigger any automatic action
columnsToDisplay
Type : string[]

Manually define the columns to be shown.

customColumns
Type : ColumnConfig[]

Additional or overwritten field configurations for columns

editable
Type : boolean

INLINE EDIT User can switch a row into edit mode to change and save field values directly from within the table

entityType
Type : EntityConstructor<T>
filter
Type : DataFilter<T>

Adds a filter for the displayed data. Only data, that passes the filter will be shown in the table.

filterFreetext
Type : string
getBackgroundColor
Type : function
Default value : (rec: T) => rec.getColor()

function returns the background color for each row

newRecordFactory
Type : function

factory method to create a new instance of the displayed Entity type used when the user adds a new entity to the list.

records
Type : T[]
selectable
Type : boolean

BULK SELECT User can use checkboxes to select multiple rows, so that parent components can execute bulk actions on them.

selectedRecords
Type : T[]
Default value : []
showInactive
Type : boolean

FILTER ARCHIVED RECORDS User can hide / show inactive records through a toggle

sortBy
Type : Sort

how to sort data by default during initialization

Outputs

entityClick
Type : EventEmitter

Emits the entity being clicked in the table - or the newly created entity from the "create" button.

filteredRecordsChange
Type : EventEmitter

output the currently displayed records, whenever filters for the user change

selectedRecordsChange
Type : EventEmitter<T[]>

outputs an event containing an array of currently selected records (checkmarked by the user) Checkboxes to select rows are only displayed if you set "selectable" also.

showInactiveChange
Type : EventEmitter

Methods

addActiveInactiveFilter
addActiveInactiveFilter(filter: DataFilter<T>)
Parameters :
Name Type Optional
filter DataFilter<T> No
Returns : void
isAllSelected
isAllSelected()
Returns : boolean
isIndeterminate
isIndeterminate()
Returns : boolean
onRowClick
onRowClick(row: TableRow<T>, event: MouseEvent)

Show one record's details in a modal dialog (if configured).

Parameters :
Name Type Optional Description
row TableRow<T> No

The entity whose details should be displayed.

event MouseEvent No
Returns : void
onRowMouseDown
onRowMouseDown(event: MouseEvent, row: TableRow<T>)
Parameters :
Name Type Optional
event MouseEvent No
row TableRow<T> No
Returns : void
onRowSelect
onRowSelect(event: MatCheckboxChange, row: TableRow<T>)
Parameters :
Name Type Optional
event MatCheckboxChange No
row TableRow<T> No
Returns : void
selectAllRows
selectAllRows(event: MatCheckboxChange)
Parameters :
Name Type Optional
event MatCheckboxChange No
Returns : void
selectRow
selectRow(row: TableRow<T>, checked: boolean)
Parameters :
Name Type Optional
row TableRow<T> No
checked boolean No
Returns : void
showEntity
showEntity(entity: T)
Parameters :
Name Type Optional
entity T No
Returns : void

Properties

_columns
Type : FormFieldConfig[]
Default value : []
_columnsToDisplay
Type : string[]
Default value : []
_customColumns
Type : FormFieldConfig[]
_editable
Type : boolean
Default value : true
_entityType
Type : EntityConstructor<T>
_filter
Type : DataFilter<T>
Default value : {}
_records
Type : T[]
Default value : []
_selectable
Type : boolean
Default value : false
_showInactive
Type : boolean
Default value : false
_sortBy
Type : Sort
Readonly ACTIONCOLUMN_EDIT
Type : string
Default value : "__edit"
Readonly ACTIONCOLUMN_SELECT
Type : string
Default value : "__select"
idForSavingPagination
Type : string
isLoading
Type : boolean
Default value : true
projectedColumns
Type : QueryList<MatColumnDef>
Decorators :
@ContentChildren(MatColumnDef)
recordsDataSource
Type : MatTableDataSource<TableRow<T>>

data displayed in the template's table

table
Type : MatTable<T>
Decorators :
@ViewChild(MatTable, {static: true})

Accessors

records
setrecords(value: T[])
Parameters :
Name Type Optional
value T[] No
Returns : void
customColumns
setcustomColumns(value: ColumnConfig[])

Additional or overwritten field configurations for columns

Parameters :
Name Type Optional
value ColumnConfig[] No
Returns : void
columnsToDisplay
setcolumnsToDisplay(value: string[])

Manually define the columns to be shown.

Parameters :
Name Type Optional
value string[] No
Returns : void
entityType
setentityType(value: EntityConstructor<T>)
Parameters :
Name Type Optional
value EntityConstructor<T> No
Returns : void
sortBy
setsortBy(value: Sort)

how to sort data by default during initialization

Parameters :
Name Type Optional
value Sort No
Returns : void
sort
setsort(sort: MatSort)
Parameters :
Name Type Optional
sort MatSort No
Returns : void
filter
setfilter(value: DataFilter<T>)

Adds a filter for the displayed data. Only data, that passes the filter will be shown in the table.

Parameters :
Name Type Optional
value DataFilter<T> No
Returns : void
filterFreetext
setfilterFreetext(value: string)
Parameters :
Name Type Optional
value string No
Returns : void
selectable
setselectable(v: boolean)

BULK SELECT User can use checkboxes to select multiple rows, so that parent components can execute bulk actions on them.

Parameters :
Name Type Optional
v boolean No
Returns : void
editable
seteditable(v: boolean)

INLINE EDIT User can switch a row into edit mode to change and save field values directly from within the table

Parameters :
Name Type Optional
v boolean No
Returns : void
showInactive
setshowInactive(value: boolean)

FILTER ARCHIVED RECORDS User can hide / show inactive records through a toggle

Parameters :
Name Type Optional
value boolean No
Returns : void
import {
  AfterContentInit,
  Component,
  ContentChildren,
  EventEmitter,
  Input,
  Output,
  QueryList,
  ViewChild,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { EntityFieldEditComponent } from "../entity-field-edit/entity-field-edit.component";
import { EntityFieldLabelComponent } from "../entity-field-label/entity-field-label.component";
import { EntityFieldViewComponent } from "../entity-field-view/entity-field-view.component";
import { ListPaginatorComponent } from "./list-paginator/list-paginator.component";
import {
  MatCheckboxChange,
  MatCheckboxModule,
} from "@angular/material/checkbox";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import {
  MatSort,
  MatSortModule,
  Sort,
  SortDirection,
} from "@angular/material/sort";
import {
  MatColumnDef,
  MatTable,
  MatTableDataSource,
  MatTableModule,
} from "@angular/material/table";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import {
  ColumnConfig,
  FormFieldConfig,
  toFormFieldConfig,
} from "../entity-form/FormConfig";
import { EntityFormService } from "../entity-form/entity-form.service";
import { tableSort } from "./table-sort/table-sort";
import { UntilDestroy } from "@ngneat/until-destroy";
import { entityFilterPredicate } from "../../filter/filter-generator/filter-predicate";
import { FormDialogService } from "../../form-dialog/form-dialog.service";
import { Router } from "@angular/router";
import { FilterService } from "../../filter/filter.service";
import { DataFilter } from "../../filter/filters/filters";
import { EntityInlineEditActionsComponent } from "./entity-inline-edit-actions/entity-inline-edit-actions.component";
import { EntityCreateButtonComponent } from "../entity-create-button/entity-create-button.component";
import { DateDatatype } from "../../basic-datatypes/date/date.datatype";
import { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { EntityDatatype } from "../../basic-datatypes/entity/entity.datatype";
import { TableRow } from "./table-row";

/**
 * A simple display component (no logic and transformations) to display a table of entities.
 */
@UntilDestroy()
@Component({
  selector: "app-entities-table",
  imports: [
    CommonModule,
    EntityFieldEditComponent,
    EntityFieldLabelComponent,
    EntityFieldViewComponent,
    ListPaginatorComponent,
    MatCheckboxModule,
    MatProgressBarModule,
    MatSlideToggleModule,
    MatSortModule,
    MatTableModule,
    EntityInlineEditActionsComponent,
    EntityCreateButtonComponent,
  ],
  templateUrl: "./entities-table.component.html",
  styleUrl: "./entities-table.component.scss",
})
export class EntitiesTableComponent<T extends Entity>
  implements AfterContentInit
{
  @Input() set records(value: T[]) {
    if (!value) {
      return;
    }
    this._records = value;

    this.updateFilteredData();
    this.isLoading = false;
  }

  private lastSelectedIndex: number = null;
  private lastSelection: boolean = null;
  _records: T[] = [];
  /** data displayed in the template's table */
  recordsDataSource: MatTableDataSource<TableRow<T>>;
  isLoading: boolean = true;

  @ViewChild(MatTable, { static: true }) table: MatTable<T>;
  @ContentChildren(MatColumnDef) projectedColumns: QueryList<MatColumnDef>;

  ngAfterContentInit() {
    // dynamically add columns from content-projection (https://stackoverflow.com/a/58017564/1473411)
    this.projectedColumns.forEach((columnDef) =>
      this.table.addColumnDef(columnDef),
    );
  }

  /**
   * Additional or overwritten field configurations for columns
   * @param value
   */
  @Input() set customColumns(value: ColumnConfig[]) {
    this._customColumns = (value ?? []).map((c) =>
      this._entityType
        ? this.entityFormService.extendFormFieldConfig(c, this._entityType)
        : toFormFieldConfig(c),
    );
    const entityColumns = this._entityType?.schema
      ? [...this._entityType.schema.entries()].map(
          ([id, field]) => ({ ...field, id }) as FormFieldConfig,
        )
      : [];

    this._columns = [
      ...entityColumns.filter(
        // if there is a customColumn for a field from entity config, don't add the base schema field
        (c) => !this._customColumns.some((customCol) => customCol.id === c.id),
      ),
      ...this._customColumns,
    ];
    this._columns.forEach((c) => this.disableSortingHeaderForAdvancedFields(c));

    if (!this.columnsToDisplay) {
      this.columnsToDisplay = this._customColumns
        .filter((c) => !c.hideFromTable)
        .map((c) => c.id);
    }

    this.idForSavingPagination = this._customColumns
      .map((col) => col.id)
      .join("");
  }

  _customColumns: FormFieldConfig[];
  _columns: FormFieldConfig[] = [];

  /**
   * Manually define the columns to be shown.
   *
   * @param value
   */
  @Input() set columnsToDisplay(value: string[]) {
    if (!value || value.length === 0) {
      value = (this._customColumns ?? this._columns).map((c) => c.id);
    }
    value = value.filter((c) => !c.startsWith("__")); // remove internal action columns

    const cols = [];
    if (this._selectable) {
      cols.push(this.ACTIONCOLUMN_SELECT);
    }
    if (this._editable) {
      cols.push(this.ACTIONCOLUMN_EDIT);
    }
    cols.push(...value);
    this._columnsToDisplay = cols;

    if (this.sortIsInferred) {
      this.sortBy = this.inferDefaultSort();
      this.sortIsInferred = true;
    }
  }

  _columnsToDisplay: string[] = [];

  @Input() set entityType(value: EntityConstructor<T>) {
    this._entityType = value;
    this.customColumns = this._customColumns;
  }

  _entityType: EntityConstructor<T>;

  /** how to sort data by default during initialization */
  @Input() set sortBy(value: Sort) {
    if (!value) {
      return;
    }

    this._sortBy = value;
    this.sortIsInferred = false;
  }

  _sortBy: Sort;

  @ViewChild(MatSort, { static: false }) set sort(sort: MatSort) {
    this.recordsDataSource.sort = sort;
  }

  private sortIsInferred: boolean = true;

  /**
   * Adds a filter for the displayed data.
   * Only data, that passes the filter will be shown in the table.
   */
  @Input() set filter(value: DataFilter<T>) {
    this._filter = value ?? {};
    this.updateFilteredData();
  }

  _filter: DataFilter<T> = {};
  /** output the currently displayed records, whenever filters for the user change */
  @Output() filteredRecordsChange = new EventEmitter<T[]>(true);

  private updateFilteredData() {
    this.addActiveInactiveFilter(this._filter);
    const filterPredicate = this.filterService.getFilterPredicate(this._filter);
    const filteredData = this._records.filter(filterPredicate);
    this.recordsDataSource.data = filteredData.map((record) => ({ record }));

    this.filteredRecordsChange.emit(filteredData);
  }

  @Input() set filterFreetext(value: string) {
    this.recordsDataSource.filter = value;
  }

  /** function returns the background color for each row*/
  @Input() getBackgroundColor?: (rec: T) => string = (rec: T) => rec.getColor();
  idForSavingPagination: string;

  /**
   * The action the system triggers when a user clicks on an entry (row):
   * - popup: open dialog with simplified form with the given fields only
   * - navigate: route the app to the details view of the entity
   * - popup-details: open dialog with the full EntityDetails view
   * - none: do not trigger any automatic action
   */
  @Input() clickMode: "popup" | "navigate" | "popup-details" | "none" = "popup";

  /**
   * Emits the entity being clicked in the table - or the newly created entity from the "create" button.
   */
  @Output() entityClick = new EventEmitter<T>();

  /**
   * BULK SELECT
   * User can use checkboxes to select multiple rows, so that parent components can execute bulk actions on them.
   */
  @Input() set selectable(v: boolean) {
    this._selectable = v;
    this.columnsToDisplay = this._columnsToDisplay;
  }

  _selectable: boolean = false;

  readonly ACTIONCOLUMN_SELECT = "__select";

  /**
   * outputs an event containing an array of currently selected records (checkmarked by the user)
   * Checkboxes to select rows are only displayed if you set "selectable" also.
   */
  @Output() selectedRecordsChange: EventEmitter<T[]> = new EventEmitter<T[]>();
  @Input() selectedRecords: T[] = [];

  selectRow(row: TableRow<T>, checked: boolean) {
    if (checked) {
      this.selectedRecords.push(row.record);
    } else {
      const index = this.selectedRecords.indexOf(row.record);
      if (index > -1) {
        this.selectedRecords.splice(index, 1);
      }
    }
    this.selectedRecordsChange.emit(this.selectedRecords);
  }

  /**
   * INLINE EDIT
   * User can switch a row into edit mode to change and save field values directly from within the table
   */
  @Input() set editable(v: boolean) {
    this._editable = v;
    this.columnsToDisplay = this._columnsToDisplay;
  }

  _editable: boolean = true;
  readonly ACTIONCOLUMN_EDIT = "__edit";
  /**
   * factory method to create a new instance of the displayed Entity type
   * used when the user adds a new entity to the list.
   */
  @Input() newRecordFactory: () => T;

  /**
   * Show one record's details in a modal dialog (if configured).
   * @param row The entity whose details should be displayed.
   */
  onRowClick(row: TableRow<T>, event: MouseEvent) {
    const targetElement = event.target as HTMLElement;

    // Check if the clicked element has the 'clickable' class
    if (targetElement && targetElement.closest(".clickable")) {
      return;
    }
    if (row.formGroup && !row.formGroup.disabled) {
      return;
    }
    if (this._selectable) {
      this.selectRow(row, !this.selectedRecords?.includes(row.record));
      return;
    }
    this.showEntity(row.record);
    this.entityClick.emit(row.record);
  }

  onRowMouseDown(event: MouseEvent, row: TableRow<T>) {
    if (!this._selectable) {
      this.onRowClick(row, event);
      return;
    }

    // Find the index of the row in the sorted and filtered data
    const sortedData = this.recordsDataSource.sortData(
      this.recordsDataSource.data,
      this.recordsDataSource.sort,
    );
    const currentIndex = sortedData.indexOf(row);

    const isCheckboxClick =
      event.target instanceof HTMLInputElement &&
      event.target.type === "checkbox";

    if (event.shiftKey && this.lastSelectedIndex !== null) {
      const start = Math.min(this.lastSelectedIndex, currentIndex);
      const end = Math.max(this.lastSelectedIndex, currentIndex);
      const shouldCheck =
        this.lastSelection !== null
          ? !this.lastSelection
          : !this.selectedRecords.includes(row.record);

      for (let i = start; i <= end; i++) {
        const rowToSelect = sortedData[i];
        const isSelected = this.selectedRecords.includes(rowToSelect.record);

        if (shouldCheck && !isSelected) {
          this.selectedRecords.push(rowToSelect.record);
        } else if (!shouldCheck && isSelected) {
          this.selectedRecords = this.selectedRecords.filter(
            (record) => record !== rowToSelect.record,
          );
        }
      }
      this.selectedRecordsChange.emit(this.selectedRecords);
    } else {
      const isSelected = this.selectedRecords.includes(row.record);
      this.selectRow(row, !isSelected);
      this.lastSelectedIndex = currentIndex;
      this.lastSelection = isSelected;
    }

    if (isCheckboxClick) {
      this.onRowClick(row, event);
    }
  }

  onRowSelect(event: MatCheckboxChange, row: TableRow<T>) {
    this.selectRow(row, event.checked);
  }

  selectAllRows(event: MatCheckboxChange) {
    if (event.checked) {
      this.selectedRecords = this.recordsDataSource.data.map(
        (row) => row.record,
      );
    } else {
      this.selectedRecords = [];
    }
    this.selectedRecordsChange.emit(this.selectedRecords);
  }

  isAllSelected() {
    return this.selectedRecords.length === this.recordsDataSource.data.length;
  }

  isIndeterminate() {
    return this.selectedRecords.length > 0 && !this.isAllSelected();
  }

  showEntity(entity: T) {
    switch (this.clickMode) {
      case "popup":
        this.formDialog.openFormPopup(entity, this._customColumns);
        break;
      case "popup-details":
        this.formDialog.openView(entity, "EntityDetails");
        break;
      case "navigate":
        this.router.navigate([
          entity.getConstructor().route,
          entity.isNew ? "new" : entity.getId(true),
        ]);
        break;
    }
  }

  constructor(
    private entityFormService: EntityFormService,
    private formDialog: FormDialogService,
    private router: Router,
    private filterService: FilterService,
    private schemaService: EntitySchemaService,
  ) {
    this.recordsDataSource = this.createDataSource();
  }

  private createDataSource() {
    const dataSource = new MatTableDataSource<TableRow<T>>();
    dataSource.sortData = (data, sort) =>
      tableSort(data, {
        active: sort.active as keyof Entity | "",
        direction: sort.direction,
      });
    dataSource.filterPredicate = (data, filter) =>
      entityFilterPredicate(data.record, filter);
    return dataSource;
  }

  private inferDefaultSort(): Sort {
    // initial sorting by first column, ensure that not the 'action' column is used
    const sortBy = this._columnsToDisplay.filter((c) => !c.startsWith("__"))[0];
    const sortByColumn = this._columns.find((c) => c.id === sortBy);

    let sortDirection: SortDirection = "asc";
    if (
      sortByColumn?.viewComponent === "DisplayDate" ||
      sortByColumn?.viewComponent === "DisplayMonth" ||
      this.schemaService.getDatatypeOrDefault(sortByColumn?.dataType) instanceof
        DateDatatype
    ) {
      // flip default sort order for dates (latest first)
      sortDirection = "desc";
    }

    return sortBy ? { active: sortBy, direction: sortDirection } : undefined;
  }

  /**
   * Advanced fields like entity references cannot be sorted sensibly yet - disable sort for them.
   * @param c
   * @private
   */
  private disableSortingHeaderForAdvancedFields(c: FormFieldConfig) {
    if (c.viewComponent === "DisplayAge") {
      // we have implemented support for age specifically
      return;
    }

    // if no dataType is defined, these are dynamic, display-only components
    if (c.isArray || c.dataType === EntityDatatype.dataType || !c.dataType) {
      c.noSorting = true;
    }
  }

  /**
   * FILTER ARCHIVED RECORDS
   * User can hide / show inactive records through a toggle
   */
  @Input() set showInactive(value: boolean) {
    if (value === this._showInactive) {
      return;
    }

    this._showInactive = value;
    this.updateFilteredData();
    this.showInactiveChange.emit(value);
  }

  _showInactive: boolean = false;
  @Output() showInactiveChange = new EventEmitter<boolean>();

  addActiveInactiveFilter(filter: DataFilter<T>) {
    if (this._showInactive) {
      delete filter["isActive"];
    } else {
      filter["isActive"] = true;
    }
  }
}
<div *ngIf="isLoading" class="process-spinner">
  <mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>

<div [hidden]="isLoading" class="table-container">
  <table
    mat-table
    [dataSource]="recordsDataSource"
    matSort
    [matSortActive]="_sortBy?.active"
    [matSortDirection]="_sortBy?.direction"
    class="full-width table"
  >
    <ng-container *ngFor="let col of _columns" [matColumnDef]="col.id">
      <th
        mat-header-cell
        mat-sort-header
        *matHeaderCellDef
        [disabled]="col.noSorting"
      >
        <app-entity-field-label
          [field]="col"
          [entityType]="_entityType"
        ></app-entity-field-label>
      </th>

      <td mat-cell *matCellDef="let row">
        <app-entity-field-edit
          *ngIf="row.formGroup?.enabled; else viewField"
          [field]="col"
          [entity]="row.record"
          [form]="row.formGroup"
          [compactMode]="true"
        ></app-entity-field-edit>

        <ng-template #viewField>
          <app-entity-field-view
            [field]="col"
            [entity]="row.record"
          ></app-entity-field-view>
        </ng-template>
      </td>
    </ng-container>

    <!--
      BULK SELECT
    -->
    <ng-container [matColumnDef]="ACTIONCOLUMN_SELECT">
      <th mat-header-cell *matHeaderCellDef style="width: 0">
        <mat-checkbox
          (change)="selectAllRows($event)"
          [checked]="isAllSelected()"
          [indeterminate]="isIndeterminate()"
        ></mat-checkbox>
      </th>

      <td mat-cell *matCellDef="let row">
        <mat-checkbox
          (change)="onRowSelect($event, row)"
          [checked]="selectedRecords?.includes(row.record)"
          (click)="$event.stopPropagation()"
        ></mat-checkbox>
      </td>
    </ng-container>

    <!--
      INLINE EDIT ACTIONS
    -->
    <ng-container [matColumnDef]="ACTIONCOLUMN_EDIT">
      <th mat-header-cell *matHeaderCellDef class="remove-padding-left">
        <app-entity-create-button
          [entityType]="_entityType"
          [newRecordFactory]="newRecordFactory"
          (entityCreate)="showEntity($event); entityClick.emit($event)"
          [iconOnly]="true"
        ></app-entity-create-button>
      </th>

      <td mat-cell *matCellDef="let row">
        <app-entity-inline-edit-actions [row]="row">
        </app-entity-inline-edit-actions>
      </td>
    </ng-container>

    <!-- custom columns via content projection -->
    <ng-content></ng-content>

    <tr mat-header-row *matHeaderRowDef="_columnsToDisplay"></tr>
    <tr
      mat-row
      *matRowDef="let row; columns: _columnsToDisplay"
      [class.inactive-row]="!row.record.isActive"
      [style.background-color]="getBackgroundColor?.(row.record)"
      class="table-row"
      (mousedown)="onRowMouseDown($event, row)"
      style="cursor: pointer"
    ></tr>
  </table>

  <!--
    PAGINATION
  -->
  <app-list-paginator
    class="table-footer"
    [dataSource]="recordsDataSource"
    [idForSavingPagination]="idForSavingPagination"
  ></app-list-paginator>

  <!--
    SHOW ARCHIVED TOGGLE
  -->
  <div class="table-footer filter-inactive-toggle">
    <mat-slide-toggle
      [checked]="_showInactive"
      (change)="showInactive = $event.checked"
      i18n="slider|also show entries that are archived"
    >
      Include archived records
    </mat-slide-toggle>
  </div>
</div>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""