Skip to content

Instantly share code, notes, and snippets.

@pjlamb12
Created November 26, 2022 23:49
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pjlamb12/6b0382793ee192d2b9007e11eed0a969 to your computer and use it in GitHub Desktop.
Save pjlamb12/6b0382793ee192d2b9007e11eed0a969 to your computer and use it in GitHub Desktop.
Angular Data Table Component
<content-section
[csTitle]="title"
[collapsible]="collapsible"
[state]="state"
[locked]="locked"
[noButtons]="noButtons"
[buttons]="_buttons"
>
<!-- Custom controls -->
<ng-content></ng-content>
<!-- Filter radio -->
<div class="top-bar">
<div *ngIf="filtersExist()">
<mat-radio-group [(ngModel)]="show" (change)="filterRadioChanged($event)">
Show:
<mat-radio-button
*ngFor="let filter of filters"
[value]="filter.value"
(change)="setFilterValue(filter.value)"
>
{{ filter.text }}</mat-radio-button
>
</mat-radio-group>
</div>
<div class="right">
<div *ngIf="showStartDate">
<p class="date-picker-name">{{ startDateName }}</p>
<highlighted-datepicker
[control]="startDateControl"
(dateChange)="changeStartDate($event)"
#startDate
>
</highlighted-datepicker>
</div>
<div *ngIf="showEndDate">
<p class="date-picker-name">{{ endDateName }}</p>
<highlighted-datepicker
[control]="endDateControl"
(dateChange)="changeEndDate($event)"
#endDate
>
</highlighted-datepicker>
</div>
</div>
</div>
<!-- Filter Checkbox -->
<div class="top-bar">
<div *ngIf="checkboxFilterInput">
<mat-checkbox
[checked]="checkboxFilterInput.checked"
(change)="changeCheckboxFilter()"
>
{{ checkboxFilterInput.label }}
</mat-checkbox>
</div>
</div>
<!-- Search controls -->
<ct-search
#search
(searchTermsChange)="applyFilter(); applyTextFilter($event)"
[fieldsToSearch]="searchFields"
[enableDebounce]="true"
(selectTopResult)="runDefaultOnTopResult()"
[searchTerms]="textSearchTerms"
></ct-search>
<!-- Mass actions & column selector -->
<div class="mass-container" #scrollView>
<div class="mass-actions" *ngIf="massActionsExist()">
<ng-container *ngFor="let action of getMassActions()">
<span [matTooltip]="action.tooltip(selection.selected)">
<button
mat-button
class="mass-buttons"
(click)="massChanges(action)"
[@massButtons]="selection.hasValue() ? 'enabled' : 'disabled'"
[disabled]="!action.enabled(selection.selected)"
[color]="action.color"
[class]="action.color ? '' : action.customClass"
>
{{ action.buttonText.toUpperCase() }}
</button>
</span>
</ng-container>
</div>
<span class="fill-flex"></span>
<column-selector
#columnSelector
[persistentStorageName]="storageName"
[dataSource]="dataSource"
[defaultColumnList]="columns"
[defaultPageSize]="pageSize"
></column-selector>
</div>
<responsive-table>
<div class="loading-overlay" *ngIf="isLoading">
<p>Loading...</p>
<mat-spinner mode="indeterminate" diameter="70"></mat-spinner>
</div>
<mat-table
[ngClass]="{ 'no-data': dataSource.filteredData.length === 0 }"
#table
[dataSource]="dataSource"
#sort="matSort"
matSort
>
<!--Bulk selections-->
<ng-container
class="checkBox"
matColumnDef="checkBoxes"
*ngIf="massActionsExist()"
>
<mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
[aria-label]="checkboxLabel()"
>
</mat-checkbox>
</mat-header-cell>
<mat-cell *matCellDef="let row" (click)="$event.stopPropagation()">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
[aria-label]="checkboxLabel(row)"
>
</mat-checkbox>
</mat-cell>
</ng-container>
<!-- Column definitions -->
<ng-container *ngFor="let col of columns" [matColumnDef]="col.column_key">
<mat-header-cell *matHeaderCellDef mat-sort-header
>{{ columnTitle(col) }}</mat-header-cell
>
<ng-container *ngIf="col.routerLink !== null && col.innerHTML === null">
<mat-cell *matCellDef="let row" class="wrap-words">
<a
[routerLink]="col.routerLink(row)"
[innerHtml]="col.contents(row)"
></a>
</mat-cell>
</ng-container>
<ng-container *ngIf="col.component === null && col.innerHTML === null">
<mat-cell *matCellDef="let row" class="wrap-words"
>{{ col.contents(row) }}</mat-cell
>
</ng-container>
<ng-container *ngIf="col.component !== null">
<mat-cell *matCellDef="let row" class="wrap-words">
<custom-cell [component]="col.component" [row]="row"></custom-cell>
</mat-cell>
</ng-container>
<ng-container *ngIf="col.component === null && col.innerHTML !== null">
<mat-cell
*matCellDef="let row"
class="wrap-words"
[innerHtml]="col.innerHTML(row)"
></mat-cell>
</ng-container>
</ng-container>
<!-- Action buttons -->
<ng-container *ngIf="actionsExist()" matColumnDef="actions">
<mat-header-cell *matHeaderCellDef>Actions</mat-header-cell>
<mat-cell *matCellDef="let i = index; let row">
<!-- Buttons outside of the dropdown menu -->
<div class="action-buttons">
<ng-container *ngFor="let button of getButtonActions(row)">
<span [matTooltip]="button.tooltip(row)">
<a
mat-button
appRowButton
*ngIf="button.href(row) !== null"
[href]="button.href(row)"
[color]="row | buttonColor: button"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event.stopPropagation())"
[class]="button.customClass(row)"
[highlight]="button.isDefaultAction && rowIndex === i && !btnHovered"
[disabled]="!button.enabled(row)"
>
{{ button.buttonText }}
</a>
<a
mat-button
appRowButton
*ngIf="button.routerLink(row) !== null"
[routerLink]="button.routerLink(row)"
[queryParams]="button.queryParams(row)"
[color]="row | buttonColor: button"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event.stopPropagation())"
[class]="button.customClass(row)"
[highlight]="button.isDefaultAction && rowIndex === i && !btnHovered"
[disabled]="!button.enabled(row)"
>
{{ button.buttonText }}
</a>
<button
mat-button
appRowButton
*ngIf="button.routerLink(row) === null && button.href(row) === null"
[color]="row | buttonColor: button"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
[class]="button.customClass(row)"
[highlight]="button.isDefaultAction && rowIndex === i && !btnHovered"
[disabled]="!button.enabled(row)"
>
{{ button.buttonText }}
</button>
</span>
</ng-container>
</div>
<!-- Dropdown menu -->
<button
mat-icon-button
*ngIf="menuActionsExist()"
[matMenuTriggerFor]="more"
(click)="$event.stopPropagation()"
appRowButton
(hovered)="btnHovered = $event"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #more="matMenu">
<ng-container *ngFor="let button of getMenuActions()">
<a
mat-menu-item
appRowButton
*ngIf="button.href(row) !== null && button.enabled(row)"
[href]="button.href(row)"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
>
{{ button.buttonText }}
</a>
<a
mat-menu-item
appRowButton
*ngIf="button.routerLink(row) !== null && button.enabled(row)"
[routerLink]="button.routerLink(row)"
[queryParams]="button.queryParams(row)"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
>
{{ button.buttonText }}
</a>
<a
mat-menu-item
appRowButton
*ngIf="button.href(row) === null && button.routerLink(row) === null && button.enabled(row)"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
>
{{ button.buttonText }}
</a>
</ng-container>
<button
mat-menu-item
*ngFor="let button of hasDropDown()"
[matMenuTriggerFor]="dropDowns"
>
{{ button.buttonText }}
</button>
</mat-menu>
<mat-menu #dropDowns="matMenu">
<ng-container *ngFor="let button of dropDownItems()">
<a
mat-menu-item
appRowButton
*ngIf="button.href(row) !== null && button.enabled(row)"
[href]="button.href(row)"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
>
{{ button.buttonText }}
</a>
<a
mat-menu-item
appRowButton
*ngIf="button.routerLink(row) !== null && button.enabled(row)"
[routerLink]="button.routerLink(row)"
[queryParams]="button.queryParams(row)"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
>
{{ button.buttonText }}
</a>
<a
mat-menu-item
appRowButton
*ngIf="button.href(row) === null && button.routerLink(row) === null && button.enabled(row)"
(hovered)="btnHovered = $event"
(click)="button.action(row, $event)"
>
{{ button.buttonText }}
</a>
</ng-container>
</mat-menu>
</mat-cell>
</ng-container>
<!-- Table meta -->
<mat-header-row
[ngStyle]="showHeaderRow()"
*matHeaderRowDef="getColumns()"
></mat-header-row>
<mat-row
[appRowAction]="index"
(hovered)="rowIndex = $event"
*matRowDef="let row; let index = index; columns: getColumns()"
(click)="rowClick(row, $event)"
[ngClass]="resolveRowClass(row)"
></mat-row>
</mat-table>
<!-- Search suggestions -->
<div class="suggested">
<div
class="no-results"
*ngIf="!isLoading && dataSource.filteredData.length === 0"
>
<p>No results found with current filters.</p>
</div>
</div>
</responsive-table>
<!-- Paginator -->
<div class="table-footer">
<button
mat-button
[class.hidden]="!paginator.hasNextPage()"
class="nextPage"
(click)="paginator.nextPage()"
>
<p class="nxText">
{{ numMoreRows }} More...
<span class="nxPage" color="primary">
NEXT PAGE
<mat-icon>chevron_right</mat-icon>
</span>
</p>
</button>
<mat-paginator
#paginator
[length]="dataSource.data.length"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
></mat-paginator>
</div>
</content-section>
import {
animate,
state,
style,
transition,
trigger,
} from "@angular/animations";
import { SelectionModel } from "@angular/cdk/collections";
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from "@angular/core";
import { FormControl } from "@angular/forms";
import { MatDatepickerInputEvent } from "@angular/material/datepicker";
import { MatPaginator, PageEvent } from "@angular/material/paginator";
import { MatRadioChange } from "@angular/material/radio";
import { MatSort } from "@angular/material/sort";
import { MatTableDataSource } from "@angular/material/table";
import { DomSanitizer } from "@angular/platform-browser";
import { Router } from "@angular/router";
import { ColumnSelector } from "app/column-selector/column-selector.component";
import { HighlightedDatepickerComponent } from "app/highlighted-datepicker/highlighted-datepicker.component";
import { NaturalSorter } from "app/shared/natural-sorter.service";
import {
SearchComponent,
SearchField,
SearchTerm,
} from "../../search/search.component";
import { ContentSectionButton } from "../../shared/content-section/content-section-button";
import { UserSession } from "../../shared/user-session.service";
import { ActionConfig } from "../action-config";
import { ColumnConfig } from "../column-config";
import { FilterConfig } from "../filter-config";
import { MassActionConfig } from "../mass-action-config";
export const dataTableAnimations = [
trigger("massButtons", [
state(
"disabled",
style({
transform: "scaleX(0)",
})
),
state(
"enabled",
style({
transform: "scaleX(1)",
})
),
transition("disabled <=> enabled", animate("100ms")),
]),
];
@Component({
selector: "ct-data-table",
templateUrl: "./data-table.component.html",
styleUrls: [
"./data-table.component.scss",
"../../styles/model-list.component.scss",
"../../styles/row-action-list.component.scss",
],
})
export class DataTable implements OnInit, AfterViewInit {
////////////////////////////////////////////////////////////////////
// ContentSection variables
////////////////////////////////////////////////////////////////////
@Input("csTitle") title = "Title";
@Input() collapsible: any = false;
@Input() state: "open" | "closed" = "open";
@Input() locked: false;
@Input() noButtons = false;
public _buttons = new Array<ContentSectionButton>();
@Input()
set buttons(buttons: Array<ContentSectionButton> | ContentSectionButton) {
if (buttons == null) {
return;
}
if (buttons.constructor !== Array) {
buttons = [buttons as ContentSectionButton];
}
this._buttons = buttons as Array<ContentSectionButton>;
}
get buttons() {
return this.noButtons ? [] : this._buttons;
}
////////////////////////////////////////////////////////////////////
// Date range picker variables
////////////////////////////////////////////////////////////////////
@ViewChild("startDate", { static: true })
startDatePicker: HighlightedDatepickerComponent;
@Input() showStartDate = false;
@Input() startDateName = "Start Date";
@Output() startDateChanged = new EventEmitter();
@Input() startDateControl = new FormControl(Date());
@ViewChild("endDate", { static: true })
endDatePicker: HighlightedDatepickerComponent;
@Input() showEndDate = false;
@Input() endDateName = "End Date";
@Output() endDateChanged = new EventEmitter();
@Input() endDateControl = new FormControl(Date());
////////////////////////////////////////////////////////////////////
// Data table variables
////////////////////////////////////////////////////////////////////
@Input() filters: Array<FilterConfig<any>> = [];
@Output() filterChanged = new EventEmitter();
@Output() textFilterChanged: EventEmitter<Array<SearchTerm>> =
new EventEmitter<Array<SearchTerm>>();
@Input() textSearchTerms: SearchTerm[] = [];
@Input() columns: Array<ColumnConfig<any>>;
@Input() actions: Array<ActionConfig<any>> = [];
@Input() massActions: Array<MassActionConfig<any>> = [];
@Input() defaultAction: (row: any) => void = () => {};
@Input() storageName: string;
@Input() resolveRowClass: (row: any) => string = () => null;
@Input() rowIndex: number;
@Input() btnHovered: boolean;
@Input() focusTopResult = false;
@Input() checkboxFilterInput: any;
@Output() checkboxFilterChange = new EventEmitter();
@Input() show = "";
@Input() dataSource: MatTableDataSource<any>;
public selection = new SelectionModel<any>(true, []);
public filterPosition: string;
@Input() isLoading = false;
@Input() pageSize = 50;
@Input() pageSizeOptions = [50, 100];
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@ViewChild("search", { static: true }) searchComponent: SearchComponent;
@ViewChild("columnSelector", { static: true }) columnSelector: ColumnSelector;
@ViewChild("scrollView", { read: ElementRef, static: true })
scrollViewElement: ElementRef;
////////////////////////////////////////////////////////////////////
// Constructor and initialization
////////////////////////////////////////////////////////////////////
constructor(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private router: Router,
private sanitizer: DomSanitizer,
private session: UserSession,
private naturalSorter: NaturalSorter
) {
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
this.startDateControl = new FormControl(startDate);
this.endDateControl = new FormControl(new Date());
}
ngOnInit() {
if (this.filters.length > 0) {
this.show = this.filters.find((f) => f.checked).value;
this.filterPosition = this.show;
}
this.paginator.page.subscribe((event: PageEvent) => {
if (event.previousPageIndex !== event.pageIndex) {
setTimeout(() => {
this.scrollViewElement.nativeElement.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
}
this.clearSelection();
});
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = (row, property) => {
const key =
this.columns.find((c) => c.column_key === property).searchKey(row) ??
"";
return typeof key === "string" ? key?.toLowerCase() : key;
};
this.dataSource.sortData = (data: Array<any>, sort: MatSort) => {
if (!sort.active || sort.direction === "") {
return data;
}
return data.sort((a, b) => {
const first = this.dataSource.sortingDataAccessor(a, sort.active);
const second = this.dataSource.sortingDataAccessor(b, sort.active);
return (
this.naturalSorter.sort(first, second) *
(sort.direction === "asc" ? 1 : -1)
);
});
};
this.dataSource.filterPredicate = (row: any, _filter: string) => {
const terms = this.searchComponent.searchTerms;
const fields = this.searchComponent.fieldsToSearch.map((f) => f.value);
const scope = this.filters.find((f) => f.value === this.show);
if (scope != null && scope.filterFn(row) === false) {
return false;
}
const accumulator = (accumulatingTerms: string, key: string, _e: any) => {
return (
accumulatingTerms +
(this.columns.find((c) => c.column_key === key).searchKey(row) ?? "")
);
};
for (const term of terms) {
if (term.term == null) {
continue;
}
if (term.field !== "<all>") {
if (
![term.field]
.reduce(accumulator, "")
.toLowerCase()
.includes(term.term.toLowerCase())
) {
return false;
}
} else {
const str = fields.reduce(accumulator, "").toLowerCase();
if (!str.includes(term.term.toLowerCase())) {
return false;
}
}
}
return true;
};
this.applyFilter();
}
/**
* Call this method at the end of your Synchronizer.appIsReady callback if any reprocessing is required
* (e.g. for org-configurable accounting codes fields).
*/
public notifyColumnConfigsCalculationFinished() {
this.columnSelector.savedList = this.columnSelector.savedList.filter(
(col) => {
const match = this.columns.find((x) => x.column_key === col.column_key);
return match.isEnabled();
}
);
this.columnSelector.currentList = this.columnSelector.currentList.filter(
(col) => {
const match = this.columns.find((x) => x.column_key === col.column_key);
return match.isEnabled();
}
);
this.columnSelector.currentList.forEach((col) => {
if (col.name === "") {
const match = this.columns.find((x) => x.column_key === col.column_key);
col.name = match.name;
}
});
}
////////////////////////////////////////////////////////////////////
// Methods used in the template
////////////////////////////////////////////////////////////////////
public applyFilter(): void {
this.dataSource.filter = "_";
}
public applyTextFilter(filters: SearchTerm[]) {
this.textFilterChanged.emit(filters);
}
public runDefaultOnTopResult(): void {
if (this.dataSource.filteredData.length !== 0 && this.focusTopResult) {
const organization = this.dataSource.filteredData[0];
this.defaultAction(organization);
}
}
public filterRadioChanged(event: MatRadioChange): void {
this.filterChanged.emit(event.value);
this.applyFilter();
}
get searchFields(): Array<SearchField> {
return this.columns.filter((c) => c.searchField === true && c.isEnabled());
}
public getColumns() {
const Cols = this.columnSelector.currentList
.filter(
(c) =>
c.selected &&
this.columns
.find((col) => col.column_key === c.column_key)
.isEnabled()
)
.map((c) => c.column_key);
if (this.actionsExist()) {
Cols.push("actions");
}
if (this.massActionsExist()) {
Cols.unshift("checkBoxes");
}
return Cols;
}
public showHeaderRow() {
return {
display: `${this.dataSource.filteredData.length !== 0 ? "" : "none"}`,
};
}
public columnTitle(column: ColumnConfig<any>): string {
return column.showTitle ? column.name : "";
}
public filtersExist() {
return this.filters.length > 0;
}
public actionsExist() {
return this.actions.length > 0;
}
public getButtonActions(row: any) {
return this.actions.filter((a) => {
return (
!a.inMenu && a.restrictToUserType <= (this.session.UserType?.id ?? -1)
);
});
}
public menuActionsExist() {
return (
this.actions.filter(
(a) => a.inMenu && a.restrictToUserType <= this.session.UserType.id
).length > 0
);
}
public massActionsExist() {
return (
this.massActions.filter(
(x) => x.restrictToUserType <= this.session.UserType.id
).length > 0
);
}
public getMassActions() {
return this.massActions.filter(
(x) => !x.hidden() && x.restrictToUserType <= this.session.UserType.id
);
}
public getMenuActions() {
return this.actions.filter(
(a) =>
a.inMenu &&
a.subMenuItems.length === 0 &&
a.restrictToUserType <= this.session.UserType.id
);
}
public hasDropDown() {
return this.actions.filter(
(a) =>
a.subMenuItems.length > 0 &&
a.restrictToUserType <= this.session.UserType.id
);
}
public dropDownItems() {
let enabledDropDowns = new Array<ActionConfig<any>>();
this.actions
.filter(
(a) =>
a.subMenuItems.length > 0 &&
a.enabled() &&
a.restrictToUserType <= this.session.UserType.id
)
.map((action) => {
enabledDropDowns = action.subMenuItems.filter(
(a) => a.enabled() && a.restrictToUserType <= this.session.UserType.id
);
});
return enabledDropDowns;
}
public rowClick(row: any, event: MouseEvent) {
const selection = event.view.getSelection();
if (selection.type !== "Range") {
this.defaultAction(row);
}
}
public setFilterValue(f: string) {
this.selection.clear();
this.filterPosition = f;
}
public isAllSelected() {
const numSelected = this.selection.selected.length;
const rowData = this.dataSource.connect().value;
return numSelected === rowData.length;
}
public masterToggle() {
const displayedData = this.dataSource.connect().value;
this.isAllSelected()
? this.selection.clear()
: displayedData.forEach((row) => this.selection.select(row));
}
public checkboxLabel(row?: any): string {
if (!row) {
return `${this.isAllSelected() ? "select" : "deselect"} all`;
}
return `${this.selection.isSelected(row) ? "deselect" : "select"} row ${
row.position + 1
}`;
}
public massChanges(buttonConfig: MassActionConfig<any>) {
buttonConfig.action(this.selection.selected);
}
public clearSelection(): void {
this.selection.clear();
}
public get numMoreRows(): number {
const paginator = this.dataSource.paginator;
if (paginator == null) {
return 0;
}
return Math.max(
paginator.length - paginator.pageSize * (paginator.pageIndex + 1),
0
);
}
public changeStartDate(event: MatDatepickerInputEvent<Date>): void {
this.startDateControl.setValue(event.value);
this.startDateChanged.emit(event.value);
}
public changeEndDate(event: MatDatepickerInputEvent<Date>): void {
this.endDateControl.setValue(event.value);
this.endDateChanged.emit(event.value);
}
public changeCheckboxFilter() {
this.checkboxFilterChange.emit();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment