File

src/lib/data-grid.component.ts

Implements

OnInit OnDestroy AfterContentInit

Metadata

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor(cdr: ChangeDetectorRef, router: Router, formDirective?: FormDirective)
Parameters :
Name Type Optional
cdr ChangeDetectorRef No
router Router No
formDirective FormDirective Yes

Inputs

data
Type : T
dataSource
Type : DataSource<T>
displayProperties
Type : string[] | null
Default value : null
header
Type : boolean
Default value : false
hideEmptyProperties
Type : boolean
Default value : false
mode
Type : DataGridMode | string
viewer
Type : DataSourceViewer
Default value : this

Outputs

editModeChange
Type : EventEmitter

Methods

Public cancel
cancel()
Returns : void
Public disableEditMode
disableEditMode()
Returns : void
Public enableEditMode
enableEditMode(skipPatchValue)
Parameters :
Name Optional Default value
skipPatchValue No false
Returns : void
Public logCurrentFormState
logCurrentFormState()
Returns : void
Public refresh
refresh()
Returns : void
Public reset
reset()
Returns : void
retry
retry()
Returns : void
Public submit
submit()
Returns : void

Properties

Public data$
Type : Observable<T>
Public dataLoading$
Type : Observable<boolean>
Default value : of(false)
Public hasError$
Type : Observable<boolean>
Default value : of(false)
Public isDevMode
Default value : isDevMode()
Public Readonly isEditMode$
Type : Observable<boolean>
Public Readonly isFormMode$
Type : Observable<boolean>
Public Readonly isPlainMode$
Type : Observable<boolean>
Public loading$
Default value : new ToggleSubject()
Public Readonly mode$
Type : Observable<DataGridMode>
Public rows
Type : QueryList<DataGridRowDefDirective<T>>
Decorators :
@ContentChildren(DataGridRowDefDirective)
Public rows$
Type : Observable<QueryList<DataGridRowDefDirective<T>>>
Default value : EMPTY

Accessors

mode
setmode(value: DataGridMode | string)
Parameters :
Name Type Optional
value DataGridMode | string No
Returns : void
isFormModeOrHasAnyEditCells
getisFormModeOrHasAnyEditCells()
isFormMode
getisFormMode()
hasAnyEditCells
gethasAnyEditCells()
isEditMode
getisEditMode()
editMode
seteditMode(value: boolean)
Parameters :
Name Type Optional
value boolean No
Returns : void
loading
getloading()
import {
  AsyncPipe,
  NgClass,
  NgFor,
  NgIf,
  NgTemplateOutlet,
} from '@angular/common';
import {
  AfterContentInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChildren,
  EventEmitter,
  Input,
  isDevMode,
  OnDestroy,
  OnInit,
  Optional,
  Output,
  QueryList,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
  ActivationEnd,
  Router,
} from '@angular/router';
import {
  FormDirective,
  RxapFormsModule,
} from '@rxap/forms';
import {
  DataSource,
  DataSourceViewer,
} from '@rxap/pattern';
import {
  EscapeQuotationMarkPipe,
  GetFromObjectPipe,
  ReplacePipe,
} from '@rxap/pipes';
import { ToggleSubject } from '@rxap/rxjs';
import { clone } from '@rxap/utilities';
import {
  BehaviorSubject,
  combineLatest,
  debounceTime,
  EMPTY,
  merge,
  Observable,
  of,
  Subscription,
} from 'rxjs';
import {
  filter,
  map,
  shareReplay,
  take,
  tap,
} from 'rxjs/operators';
import { DataGridRowDefDirective } from './data-grid-row-def.directive';
import { DataGridValuePipe } from './data-grid-value.pipe';
import { IsEmptyPipe } from './is-empty.pipe';

export enum DataGridMode {
  /**
   * The view cell template is used to display the property value
   */
  PLAIN = 'plain',
  /**
   * The edit cell template is used to display the property value, but the form and all controls are marked as disabled
   */
  FORM  = 'form',
}

function IsDataGridMode(value: string): value is DataGridMode {
  return [ DataGridMode.PLAIN, DataGridMode.FORM ].includes(value as any);
}

@Component({
    // eslint-disable-next-line @angular-eslint/component-selector
    selector: 'rxap-data-grid',
    templateUrl: './data-grid.component.html',
    styleUrls: ['./data-grid.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush,
    imports: [
        AsyncPipe,
        NgIf,
        NgFor,
        MatButtonModule,
        MatIconModule,
        GetFromObjectPipe,
        ReplacePipe,
        EscapeQuotationMarkPipe,
        RxapFormsModule,
        DataGridValuePipe,
        MatProgressSpinnerModule,
        MatDividerModule,
        NgTemplateOutlet,
        MatFormFieldModule,
        NgClass,
        IsEmptyPipe,
    ]
})
export class DataGridComponent<T extends Record<string, any>> implements OnInit, OnDestroy, AfterContentInit {

  public isDevMode = isDevMode();

  @Input()
  public header = false;

  @Input()
  public dataSource?: DataSource<T>;

  public data$!: Observable<T>;

  @Input()
  public viewer: DataSourceViewer = this;

  @Input()
  public data?: T;

  @Input()
  public displayProperties: string[] | null = null;

  @Input()
  public hideEmptyProperties = false;

  @ContentChildren(DataGridRowDefDirective)
  public rows!: QueryList<DataGridRowDefDirective<T>>;
  @Output()
  public editModeChange                                           = new EventEmitter<{
    mode: boolean,
    data?: T,
    done: () => void
  }>();
  public rows$: Observable<QueryList<DataGridRowDefDirective<T>>> = EMPTY;
  public hasError$: Observable<boolean>                           = of(false);
  public dataLoading$: Observable<boolean>                        = of(false);
  public loading$                                                 = new ToggleSubject();
  public readonly isEditMode$: Observable<boolean>;
  public readonly mode$: Observable<DataGridMode>;
  public readonly isFormMode$: Observable<boolean>;
  public readonly isPlainMode$: Observable<boolean>;
  private _editMode$                                              = new BehaviorSubject<boolean>(false);
  private _mode$                                                  = new BehaviorSubject<DataGridMode>(DataGridMode.PLAIN);
  private _routerEventSubscription: Subscription | null           = null;

  constructor(
    private readonly cdr: ChangeDetectorRef,
    private readonly router: Router,
    @Optional()
    private readonly formDirective?: FormDirective,
  ) {
    this.isEditMode$  = this._editMode$.asObservable();
    this.mode$        = this._mode$.asObservable();
    this.isFormMode$  = this.mode$.pipe(map(mode => mode === DataGridMode.FORM));
    this.isPlainMode$ = this.mode$.pipe(map(mode => mode === DataGridMode.PLAIN));
  }

  @Input()
  public set mode(value: DataGridMode | string) {
    if (IsDataGridMode(value)) {
      this._mode$.next(value);
    } else {
      throw new Error(`The data grid mode only support 'plain' and 'form' - given '${ value }'`);
    }
  }

  public get isFormModeOrHasAnyEditCells() {
    return this._mode$.value === DataGridMode.FORM || this.hasAnyEditCells;
  }

  public get isFormMode() {
    return this._mode$.value === DataGridMode.FORM;
  }

  public get hasAnyEditCells() {
    return this.rows?.some(row => !!row.editCell) ?? false;
  }

  public get isEditMode() {
    return this._editMode$.value;
  }

  public set editMode(value: boolean) {
    this._editMode$.next(value);
  }

  /**
   * @deprecated use the loading$ property instead
   */
  public get loading() {
    return this.loading$.value;
  }

  public ngAfterContentInit() {
    this.rows$ = merge(
      of(this.rows),
      this.rows.changes,
    );
  }

  public logCurrentFormState() {
    console.log(clone(this.formDirective?.form.value));
  }

  public ngOnInit() {
    // resets the edit mode if this component is used in a sibling router path
    // if not reset the edit mode is president after the route changes
    this._routerEventSubscription = this.router.events.pipe(
      filter(event => event instanceof ActivationEnd),
      tap(() => this.disableEditMode()),
    ).subscribe();

    if (this.dataSource && this.data) {
      throw new Error('You can not use both dataSource and data input');
    }

    let data$: Observable<T> = EMPTY;

    if (this.dataSource) {
      data$ = this.dataSource.connect(this.viewer);
    }

    if (this.data) {
      data$ = of(this.data);
    }

    if (data$ === EMPTY && isDevMode()) {
      console.warn('No data source or data input provided');
    }

    this.data$ = data$.pipe(
      debounceTime(100),
      tap(data => this.data = data),
      tap(data => {
        if (this.formDirective && this.isFormMode) {
          this.formDirective.form.patchValue(data, {
            coerce: true,
            strict: true,
          });
          if (this.isFormMode) {
            this.formDirective.form.disable();
          }
        }
      }),
      shareReplay(1),
    );
    if (this.dataSource) {
      this.hasError$    = this.dataSource.hasError$ ?? this.hasError$;
      this.dataLoading$ = this.dataSource.loading$ ?? this.dataLoading$;
    }
    if (this.formDirective && this.isFormMode) {
      this.formDirective.form.disabledWhile(combineLatest([
        this._editMode$,
        this._mode$,
      ]).pipe(
        map(([ editMode, mode ]) => !editMode && mode === 'form'),
      ), {onlySelf: false});
    }
  }

  public ngOnDestroy() {
    this.dataSource?.disconnect(this.viewer);
    this._routerEventSubscription?.unsubscribe();
  }

  public enableEditMode(skipPatchValue = false) {
    if (!this.isFormModeOrHasAnyEditCells) {
      if (isDevMode()) {
        console.warn('Can not enable edit mode if the mode is not form');
      }
      return;
    }
    if (!this.formDirective) {
      if (isDevMode()) {
        console.warn('Can not enable edit mode without a form directive');
      }
      return;
    }
    this.editMode = true;
    if (!skipPatchValue && this.data) {
      this.formDirective.form.patchValue(this.data, {
        coerce: true,
        strict: true,
      });
    }
  }

  public disableEditMode() {
    if (!this.isFormModeOrHasAnyEditCells) {
      if (isDevMode()) {
        console.warn('Can not disable edit mode if the mode is not form');
      }
      return;
    }
    if (!this.formDirective) {
      if (isDevMode()) {
        console.warn('Can not enable edit mode without a form directive');
      }
      return;
    }
    this.editMode = false;
  }

  public submit() {
    if (!this.isFormModeOrHasAnyEditCells) {
      if (isDevMode()) {
        console.warn('Can not submit if the mode is not form');
      }
      return;
    }
    if (!this.formDirective) {
      if (isDevMode()) {
        console.warn('Can not support without a form directive');
      }
      return;
    }
    this.loading$.enable();
    this.formDirective.form.markAllAsDirty();
    this.formDirective.form.markAllAsTouched();
    this.formDirective.cdr.markForCheck();
    this.formDirective.rxapSubmit.pipe(
      take(1),
      tap(() => {
        this.disableEditMode();
      }),
    ).subscribe();
    this.formDirective.invalidSubmit.pipe(
      take(1),
      tap(() => {
        this.loading$.disable();
      }),
    ).subscribe();
    this.formDirective.submitError$.pipe(
      filter(Boolean), // if the error is undefined we do not want to disable the loading
      take(1),
      tap(() => {
        this.loading$.disable();
        this.enableEditMode(true);
      }),
    ).subscribe();
    this.formDirective.submitSuccessful$.pipe(
      take(1),
      tap(() => {
        this.loading$.disable();
        this.refresh();
      }),
    ).subscribe();
    this.formDirective.onSubmit(new Event('submit'));
  }

  public reset() {
    if (this.formDirective && this.data && this.isFormModeOrHasAnyEditCells) {
      this.formDirective.form.patchValue(this.data, {
        coerce: true,
        strict: true,
      });
    }
  }

  public refresh() {
    if (this.dataSource) {
      this.dataSource.refresh();
    } else if (isDevMode()) {
      console.warn('can not refresh the data. data source is not defined');
    }
  }

  public cancel() {
    this.reset();
    this.disableEditMode();
  }

  retry() {
    this.refresh();
  }
}
<div *ngIf="(hasError$ | async)" class="flex flex-col items-center justify-center gap-y-8">
  <span i18n>Something has gone wrong!</span>
  <button (click)="retry()" i18n mat-stroked-button type="button">Retry</button>
</div>
<div *ngIf="(dataLoading$ | async)" class="flex flex-col items-center justify-center gap-y-8">
  <mat-spinner></mat-spinner>
</div>
<!-- if the dataSource input is used. It is possible the the hasError$ or loading$ property is === EMPTY. this would
results in an null as output. If the === false check is used this can result into the behavior that the data grid is
never shown. -->
<!-- eslint-disable-next-line @angular-eslint/template/no-negated-async -->
<table *ngIf="!(hasError$ | async) && !(dataLoading$ | async)" class="w-full table-auto">
  <thead *ngIf="header">
  <tr>
    <th class="py-2" i18n>Label</th>
    <th class="py-2" i18n>Value</th>
  </tr>
  </thead>
  <tbody>
  <ng-template [ngForOf]="rows$ | async" let-first="first" let-row ngFor>
    <ng-template
      [ngIf]="(!displayProperties || row.isSubHeader || (row.name && displayProperties.includes(row.name))) && (!hideEmptyProperties || false === (data$ | isEmpty: row.name | async))">
      <!-- region horizontal view -->
      <ng-template [ngIfElse]="normal" [ngIf]="row.flip">
        <tr>
          <td class="py-2" colspan="2">
            <mat-divider></mat-divider>
          </td>
        </tr>
        <tr [attr.data-name]="row.name + '-header'">
          <ng-container
            *ngTemplateOutlet="row.headerCell?.template ?? defaultHeaderCell; context: { $implicit: row.name }"></ng-container>
        </tr>
        <tr [attr.data-name]="row.name + '-value'">
          <ng-template [ngIfThen]="content" [ngIf]="row.name"></ng-template>
        </tr>
        <tr>
          <td class="py-4" colspan="2">
            <mat-divider></mat-divider>
          </td>
        </tr>
      </ng-template>
      <!-- endregion -->
      <!-- region normal view -->
      <ng-template #normal>
        <tr *ngIf="!first && row.isSubHeader">
          <td class="py-4" colspan="2">
            <mat-divider></mat-divider>
          </td>
        </tr>
        <tr [attr.data-name]="row.name" [ngClass]="{ 'sub-header text-2xl': row.isSubHeader }">
          <ng-container
            *ngTemplateOutlet="row.headerCell?.template ?? defaultHeaderCell; context: { $implicit: row.name }"></ng-container>
          <ng-template [ngIfThen]="content" [ngIf]="row.name"></ng-template>
        </tr>
      </ng-template>
      <!-- endregion -->
    </ng-template>
    <ng-template #content>
      <td [attr.colspan]="row.flip ? 2 : 1" [ngClass]="{
      'h-20': row.editCell?.template && (isEditMode || isFormMode) && false,
      }" class="pl-8 pr-4 w-full py-2">
        <ng-template [ngIfThen]="editMode" [ngIf]="row.editCell?.template && (isEditMode || isFormMode)"></ng-template>
        <ng-template [ngIfThen]="viewMode"
                     [ngIf]="!row.editCell?.template || (!isEditMode && !isFormMode)"></ng-template>
      </td>
      <ng-template #viewMode>
        <ng-container
          *ngTemplateOutlet="row.cell?.template ?? defaultCell;context: { $implicit: data$ | dataGridValue: row.name | async, data: data$ | async }"></ng-container>
      </ng-template>
      <ng-template #editMode>
        <ng-container
          *ngTemplateOutlet="row.editCell?.template ?? row.cell?.template ?? defaultCell;context: { $implicit: data$ | dataGridValue: row.name | async, data: data$ | async }"></ng-container>
      </ng-template>
    </ng-template>
  </ng-template>
  </tbody>
  <tfoot *ngIf="hasAnyEditCells">
  <tr>
    <td class="py-2" colspan="2">
      <ng-template [ngIfElse]="viewModeButton" [ngIf]="isEditMode">
        <div class="pt-8 flex flex-col gap-y-6">
          <div class="flex flex-row gap-x-6 items-center justify-start">
            <button (click)="submit()" [disabled]="loading$ | async" color="primary" mat-raised-button type="button">
              <span class="flex flex-row gap-x-6 items-center justify-center">
                <span i18n>Save</span>
                <mat-spinner *ngIf="loading$ | async" color="accent" diameter="15"></mat-spinner>
              </span>
            </button>
            <button (click)="reset()" [disabled]="loading$ | async" mat-stroked-button type="button">
              <ng-container i18n>Reset</ng-container>
            </button>
            <button (click)="cancel()" [disabled]="loading$ | async" color="warn" mat-stroked-button type="button">
              <ng-container i18n>Cancel</ng-container>
            </button>
            <button (click)="logCurrentFormState()" *ngIf="isDevMode" mat-button type="button">
              <ng-container i18n>Current Form State</ng-container>
            </button>
          </div>

          <ng-template rxapFormSubmitInvalid>
            <mat-error i18n>Ensure all formula fields are valid.</mat-error>
          </ng-template>

          <ng-template let-error rxapFormSubmitFailed>
            <mat-error>{{ error.error?.message ?? error.message }}</mat-error>
          </ng-template>

          <ng-template rxapFormSubmitSuccessful>
            <span i18n>Submit successfully.</span>
          </ng-template>

        </div>
      </ng-template>
      <ng-template #viewModeButton>
        <div class="pt-8">
          <button (click)="enableEditMode()" color="primary" mat-raised-button type="button">
            <span class="flex flex-row gap-x-6 items-center justify-center">
              <span i18n>Edit</span>
              <mat-spinner *ngIf="loading$ | async" color="accent" diameter="15"></mat-spinner>
            </span>
          </button>
        </div>
      </ng-template>
    </td>
  </tr>
  </tfoot>
</table>

<ng-template #defaultCell let-value>
  <span>{{ value }}</span>
</ng-template>
<ng-template #defaultHeaderCell let-name>
  <th class="py-2 whitespace-nowrap">{{ name }}</th>
</ng-template>

./data-grid.component.scss

:host {
  ::ng-deep th {
    text-align: right;
    width: auto;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""