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

Description

A reusable table component for displaying, sorting, filtering, and selecting entities.

Implements

AfterContentInit OnInit

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor()

Inputs

clickMode
Type : "popup" | "navigate" | "popup-details" | "none"
Default value : "popup"
columnsToDisplay
Type : string[]
customColumns
Type : ColumnConfig[]
Default value : []
editable
Type : boolean
Default value : true
entityType
Type : EntityConstructor<T>
filter
Type : DataFilter<T>
Default value : {}
getBackgroundColor
Type : (rec: T) => string
newRecordFactory
Type : () => T
recordsDataSource
Type : EntitiesTableDataSource<T>
Required :  true
selectable
Type : boolean
Default value : false
selectedRecords
Type : T[]
Default value : []
showEntityColor
Type : boolean
Default value : false
showInactive
Type : boolean
Default value : false
sortBy
Type : Sort

Outputs

entityClick
Type : T
selectedRecords
Type : T[]
showInactive
Type : boolean

Methods

onRowClick
onRowClick(row: TableRow<T>, event: MouseEvent)
Parameters :
Name Type Optional
row TableRow<T> No
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
showEntity
showEntity(entity: T)
Parameters :
Name Type Optional
entity T No
Returns : void

Properties

Readonly _columns
Type : unknown
Default value : this.sortStore.columns

Columns with sorting rules applied (managed by the sort store).

Readonly _columnsToDisplay
Type : unknown
Default value : computed<string[]>(() => { let colsToDisplay = this.columnsToDisplay(); if (!colsToDisplay || colsToDisplay.length === 0) { colsToDisplay = this._customColumns() .filter((column) => !column.hideFromTable) .map((column) => column.id); } const columns = colsToDisplay.filter((col) => !col.startsWith("__")); if (this.selectable()) { columns.unshift(this.ACTIONCOLUMN_SELECT); } if (this.editable()) { columns.splice(this.selectable() ? 1 : 0, 0, this.ACTIONCOLUMN_EDIT); } return columns; })
Readonly _customColumns
Type : unknown
Default value : computed<FormFieldConfig[]>(() => this.customColumns().map((column) => { const entityType = this.entityType(); return entityType ? this.entityFormService.extendFormFieldConfig(column, entityType) : toFormFieldConfig(column); }), )
Readonly ACTIONCOLUMN_EDIT
Type : string
Default value : "__edit"
Readonly ACTIONCOLUMN_SELECT
Type : string
Default value : "__select"
Readonly effectiveBackgroundColor
Type : unknown
Default value : computed<(rec: T) => string>(() => { const custom = this.getBackgroundColor(); const useEntityColor = this.showEntityColor(); return custom ?? ((rec: T) => (useEntityColor ? rec.getColor() : "")); })
Readonly effectiveFilter
Type : unknown
Default value : computed<DataFilter<T>>(() => { const nextFilter = { ...this.filter() }; if (this.showInactive()) { // the toggle overrides an archived condition that may come from the configured filter delete nextFilter["inactive"]; return nextFilter; } return restrictToNotArchived(nextFilter); })
Readonly idForSavingPagination
Type : unknown
Default value : computed(() => this._customColumns() .map((column) => column.id) .join(""), )
projectedColumns
Type : QueryList<MatColumnDef>
Decorators :
@ContentChildren(MatColumnDef)
Protected Readonly selectionStore
Type : unknown
Default value : inject( EntitiesTableSelectionStore, ) as EntitiesTableSelectionStore<T>
Protected Readonly sortStore
Type : unknown
Default value : inject( EntitiesTableSortStore, ) as EntitiesTableSortStore<T>
table
Type : MatTable<T>
Decorators :
@ViewChild(MatTable, {static: true})

Accessors

sort
setsort(sort: MatSort)
Parameters :
Name Type Optional
sort MatSort No
Returns : void
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  Component,
  computed,
  ContentChildren,
  effect,
  inject,
  input,
  model,
  OnInit,
  output,
  QueryList,
  untracked,
  ViewChild,
} from "@angular/core";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatSlideToggleModule } from "@angular/material/slide-toggle";
import { MatSort, MatSortModule, Sort } from "@angular/material/sort";
import {
  MatColumnDef,
  MatTable,
  MatTableModule,
} from "@angular/material/table";
import { Router } from "@angular/router";
import { EntityFieldEditComponent } from "../../entity/entity-field-edit/entity-field-edit.component";
import { EntityFieldLabelComponent } from "../../entity/entity-field-label/entity-field-label.component";
import { EntityFieldViewComponent } from "../../entity/entity-field-view/entity-field-view.component";
import { getEntityRuntimeRoute } from "../../entity/entity-config.service";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { DataFilter } from "../../filter/filters/filters";
import { restrictToNotArchived } from "../../filter/not-archived-filter";
import { FormDialogService } from "../../form-dialog/form-dialog.service";
import { EntityCreateButtonComponent } from "../entity-create-button/entity-create-button.component";
import {
  ColumnConfig,
  FormFieldConfig,
  toFormFieldConfig,
} from "../entity-form/FormConfig";
import { EntityFormService } from "../entity-form/entity-form.service";
import { EntityInlineEditActionsComponent } from "./entity-inline-edit-actions/entity-inline-edit-actions.component";
import { ListPaginatorComponent } from "./list-paginator/list-paginator.component";
import { TableRow } from "./table-row";
import {
  EntitiesTableSelectionStore,
  shouldSkipRowInteraction,
} from "./entities-table-selection";
import { EntitiesTableSortStore } from "./entities-table-sort.store";
import { EntitiesTableDataSource } from "#src/app/core/common-components/entities-table/data-source/entities-table-data-source";

/**
 * A reusable table component for displaying, sorting, filtering, and selecting entities.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-entities-table",
  providers: [EntitiesTableSortStore, EntitiesTableSelectionStore],
  imports: [
    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, OnInit
{
  private readonly formDialog = inject(FormDialogService);
  private readonly router = inject(Router);
  private readonly entityFormService = inject(EntityFormService);
  protected readonly sortStore = inject(
    EntitiesTableSortStore,
  ) as EntitiesTableSortStore<T>;
  protected readonly selectionStore = inject(
    EntitiesTableSelectionStore,
  ) as EntitiesTableSelectionStore<T>;

  // --- Inputs ---
  recordsDataSource = input.required<EntitiesTableDataSource<T>>();
  customColumns = input<ColumnConfig[], ColumnConfig[] | undefined>([], {
    transform: (value) => value ?? [],
  });
  columnsToDisplay = input<string[]>();
  entityType = input<EntityConstructor<T>>();
  sortBy = input<Sort>();
  filter = input<DataFilter<T>, DataFilter<T> | undefined>(
    {},
    {
      transform: (value) => value ?? {},
    },
  );
  showEntityColor = input<boolean>(false);
  getBackgroundColor = input<(rec: T) => string>();
  clickMode = input<"popup" | "navigate" | "popup-details" | "none">("popup");
  newRecordFactory = input<() => T>();
  editable = input<boolean>(true);
  selectable = input<boolean>(false);

  // --- Outputs & Models ---
  entityClick = output<T>();
  selectedRecords = model<T[]>([]);
  showInactive = model<boolean>(false);

  // --- Internal constants ---
  readonly ACTIONCOLUMN_SELECT = "__select";
  readonly ACTIONCOLUMN_EDIT = "__edit";

  // --- Column state ---
  readonly _customColumns = computed<FormFieldConfig[]>(() =>
    this.customColumns().map((column) => {
      const entityType = this.entityType();
      return entityType
        ? this.entityFormService.extendFormFieldConfig(column, entityType)
        : toFormFieldConfig(column);
    }),
  );
  readonly _columnsToDisplay = computed<string[]>(() => {
    let colsToDisplay = this.columnsToDisplay();
    if (!colsToDisplay || colsToDisplay.length === 0) {
      colsToDisplay = this._customColumns()
        .filter((column) => !column.hideFromTable)
        .map((column) => column.id);
    }
    const columns = colsToDisplay.filter((col) => !col.startsWith("__"));
    if (this.selectable()) {
      columns.unshift(this.ACTIONCOLUMN_SELECT);
    }
    if (this.editable()) {
      columns.splice(this.selectable() ? 1 : 0, 0, this.ACTIONCOLUMN_EDIT);
    }
    return columns;
  });
  /** Columns with sorting rules applied (managed by the sort store). */
  readonly _columns = this.sortStore.columns;
  readonly idForSavingPagination = computed(() =>
    this._customColumns()
      .map((column) => column.id)
      .join(""),
  );

  // --- Filtering (stateless derivation) ---
  readonly effectiveFilter = computed<DataFilter<T>>(() => {
    const nextFilter = { ...this.filter() };
    if (this.showInactive()) {
      // the toggle overrides an archived condition that may come from the configured filter
      delete nextFilter["inactive"];
      return nextFilter;
    }
    return restrictToNotArchived(nextFilter);
  });

  // --- Background color ---
  readonly effectiveBackgroundColor = computed<(rec: T) => string>(() => {
    const custom = this.getBackgroundColor();
    const useEntityColor = this.showEntityColor();
    return custom ?? ((rec: T) => (useEntityColor ? rec.getColor() : ""));
  });

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

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

  constructor() {
    effect(() => {
      this.recordsDataSource().dataFilter.set(this.effectiveFilter());
    });

    effect(() => {
      this.recordsDataSource().sortValueFns.set(this.sortStore.sortValueFns());
    });
    effect(() => {
      this.recordsDataSource().dataFilter();
      this.recordsDataSource().displayedData();
      if (untracked(this.selectedRecords)?.length > 0) {
        // reset selection if filter or input data changes
        this.selectedRecords.set([]);
      }
    });
  }

  ngOnInit() {
    // Connect sort store
    this.sortStore.connect({
      columnsToDisplay: this._columnsToDisplay,
      columns: computed(() => {
        const mappedCustomColumns = this._customColumns();
        const entityType = this.entityType();
        const entityColumns = entityType?.schema
          ? [...entityType.schema.entries()].map(
              ([id, field]) => ({ ...field, id }) as FormFieldConfig,
            )
          : [];
        return [
          ...entityColumns.filter(
            (col) =>
              !mappedCustomColumns.some((custom) => custom.id === col.id),
          ),
          ...mappedCustomColumns,
        ];
      }),
      externalSort: this.sortBy,
    });

    // Connect selection store
    this.selectionStore.connect({
      selectedRecords: this.selectedRecords,
      selectableRows: this.recordsDataSource().displayedData,
      currentPageRows: this.recordsDataSource().renderedRows,
    });
  }

  ngAfterContentInit() {
    this.projectedColumns.forEach((columnDef) =>
      this.table.addColumnDef(columnDef),
    );
  }

  onRowClick(row: TableRow<T>, event: MouseEvent) {
    if (shouldSkipRowInteraction(event.target, row)) {
      return;
    }
    if (this.selectable()) {
      this.selectionStore.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;
    }

    if (event.shiftKey) {
      // stop the browser from also extending its text selection across the rows,
      // which blocks scrolling until the selection is cleared again
      event.preventDefault();
    }

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

  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([
          getEntityRuntimeRoute(entity.getConstructor()),
          entity.isNew ? "new" : entity.getId(true),
        ]);
        break;
    }
  }
}
<div class="table-container">
  @if (recordsDataSource().isLoading()) {
    <!-- shown on top of the (possibly stale) table while data is (re)loaded,
         e.g. after a filter/sort/page change with the server-side data source -->
    <mat-progress-bar
      mode="indeterminate"
      class="loading-bar"
    ></mat-progress-bar>
  }
  <table
    mat-table
    [dataSource]="recordsDataSource()"
    matSort
    [matSortActive]="sortStore.effectiveSort()?.active"
    [matSortDirection]="sortStore.effectiveSort()?.direction"
    class="full-width table"
  >
    @for (col of _columns(); track col.id) {
      <ng-container [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">
          @if (row.formGroup?.enabled) {
            <app-entity-field-edit
              [field]="col"
              [entity]="row.record"
              [form]="row.formGroup"
              [compactMode]="true"
            ></app-entity-field-edit>
          } @else {
            <app-entity-field-view
              [field]="col"
              [entity]="row.record"
            ></app-entity-field-view>
          }
        </td>
      </ng-container>
    }

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

      <td mat-cell *matCellDef="let row">
        <mat-checkbox
          (change)="selectionStore.selectRow(row, $event.checked)"
          [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.inactive"
      [style.background-color]="effectiveBackgroundColor()(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.set($event.checked)"
      i18n="slider|also show entries that are archived"
    >
      Include archived records
    </mat-slide-toggle>
  </div>
</div>

./entities-table.component.scss

@use "variables/sizes";
@use "variables/colors";
@use "@angular/material/core/style/elevation" as mat-elevation;

.table-container {
  position: relative; /* anchor for further absolute positioning of child elements */
}

.column-menu {
  position: absolute;
  top: 0;
  right: 0;
}

.table-row:hover {
  background: #f5f5f5;
}

.loading-bar {
  /* overlay the loading indicator at the top of the table so the current
     content stays visible while new data is being loaded */
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
}

.table {
  @include mat-elevation.elevation(2);
}

.table-footer {
  @include mat-elevation.elevation(1);
  border-top: colors.$grey-darker solid 2px;
}

.filter-inactive-toggle {
  padding: sizes.$regular;
  background-color: white;
  border-top: none;
}

.inactive-row {
  color: colors.$inactive;
}

/* adjust action coloumn width to use least possible width */
.mat-column-__edit {
  flex: 0 0 auto !important;
  width: 78px;
  padding-right: 0;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""