kalidea/kaligraphi

View on GitHub
projects/kalidea/kaligraphi/src/lib/03-layout/kal-list/kal-list.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ContentChild,
  EventEmitter,
  Input,
  OnChanges,
  OnDestroy,
  OnInit,
  Output,
  QueryList,
  SimpleChanges,
  ViewChildren,
  ViewEncapsulation
} from '@angular/core';
import { CollectionViewer, DataSource, isDataSource, ListRange } from '@angular/cdk/collections';
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs';
import { tap } from 'rxjs/operators';
import isNil from 'lodash-es/isNil';

import { KalListItemDirective } from './kal-list-item.directive';
import { KalListItemSelectionDirective } from './kal-list-item-selection.directive';
import { KalSelectionModel } from '../../utils/classes/kal-selection';
import { Coerce } from '../../utils/decorators/coerce';
import { AutoUnsubscribe } from '../../utils/decorators/auto-unsubscribe';

enum KalListSelectionMode {
  None = 'none',
  Single = 'single',
  Multiple = 'multiple'
}

type KalListDataSource<T> = DataSource<T> & { total?: BehaviorSubject<number> } | Observable<T[]> | T[];

export interface KalVirtualScrollConfig {
  itemSize: number;
  height?: string;
}

@Component({
  selector: 'kal-list',
  exportAs: 'kalList',
  templateUrl: './kal-list.component.html',
  styleUrls: ['./kal-list.sass'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class KalListComponent<T>
  implements CollectionViewer, OnInit, OnChanges, OnDestroy {

  /**
   * @inheritDoc
   */
  viewChange: Observable<ListRange>;

  @Input() highlightedItem: (T & {id: string}) = null;

  /**
   * The icon to display in all templates
   */
  @Input() icon = 'keyboard_arrow_right';

  /**
   * Function that disable rows in template
   */
  @Input() disableRowsFunction: (item: T) => (boolean) = null;

  @Coerce('boolean')
  @Input()
  selectRowOnContentClick = false;

  @Coerce('boolean')
  @Input()
  disabledVirtualScroll = false;

  /**
   * Triggered when selection has changed
   */
  @Output() readonly selectionChange: EventEmitter<KalSelectionModel<T>> = new EventEmitter<KalSelectionModel<T>>();

  /**
   * Triggered when an item has been highlighted
   */
  @Output() readonly highlighted: EventEmitter<T> = new EventEmitter<T>();

  /**
   * Row template
   */
  @ContentChild(KalListItemDirective, {static: true}) row: KalListItemDirective;

  /**
   * The reference to the element thats contains the kal list item directive
   */
  @ViewChildren(KalListItemSelectionDirective) children: QueryList<KalListItemSelectionDirective>;

  /**
   * Results list
   */
  results: T[] | DataSource<T> = [];

  private isInitialized = false;

  /**
   * The subscription
   */
  @AutoUnsubscribe()
  private subscription: Subscription = Subscription.EMPTY;

  @AutoUnsubscribe()
  private selectionSubscription: Subscription = Subscription.EMPTY;

  @AutoUnsubscribe()
  private countSubscription: Subscription = Subscription.EMPTY;

  private groupedByParams: { previous: T, slug: string } = {previous: null, slug: ''};

  constructor(private cdr: ChangeDetectorRef) {
  }

  private _groupByFunction: (item: T) => string;

  /**
   * Function that group items in listing
   */
  @Input()

  get groupByFunction() {
    return this._groupByFunction;
  }

  set groupByFunction(value) {
    this.groupedByParams = {previous: null, slug: ''};
    this._groupByFunction = value;
    this.cdr.markForCheck();
  }

  private _dataSource: KalListDataSource<T> = null;

  /**
   * Datasource to give items list to the component
   */
  @Input()
  get dataSource(): KalListDataSource<T> {
    return this._dataSource;
  }

  set dataSource(dataSource: KalListDataSource<T>) {
    if (dataSource !== this._dataSource) {
      this.destroySubscription();
      this._dataSource = dataSource;

      if (dataSource) {
        this.observeDataSource();
      } else {
        this.results = [];
        this.cdr.markForCheck();
      }
    }
  }

  /**
   * Selectable items (none, single, multiple)
   */
  private _selectionMode: KalListSelectionMode = KalListSelectionMode.Single;

  /**
   * Selectable items (none, single, multiple)
   */
  @Input()
  get selectionMode() {
    return this._selectionMode;
  }

  set selectionMode(value: KalListSelectionMode) {

    switch (value) {
      case KalListSelectionMode.Multiple:
      case KalListSelectionMode.None:
        this._selectionMode = value;
        break;

      default:
        this._selectionMode = KalListSelectionMode.Single;
        break;
    }

    if (this.isInitialized) {
      if (this.selectionMode === KalListSelectionMode.None && !this._selection.isEmpty()) {
        this._selection.clear();
      } else {
        this._selection.multiple = value === KalListSelectionMode.Multiple;
      }
      this.countItems();
    }

    this.cdr.markForCheck();

  }

  private _selection: KalSelectionModel<T> = null;

  @Input()
  get selection(): KalSelectionModel<T> {
    return this._selection;
  }

  set selection(value: KalSelectionModel<T>) {
    if (value && (value instanceof KalSelectionModel)) {
      this._selection = value;

      if (this.isInitialized) {
        this.initSelection();
      }

      this.selectionChanges();
    } else if (this.isInitialized) {
      this._selection.clear();
    }
    this.cdr.markForCheck();
  }

  /**
   * The virtual scroll config
   */
  private _virtualScrollConfig: KalVirtualScrollConfig = {itemSize: 40};

  @Input()
  get virtualScrollConfig(): KalVirtualScrollConfig {
    return this._virtualScrollConfig;
  }

  set virtualScrollConfig(value: KalVirtualScrollConfig) {
    value = value || {itemSize: 40};

    this._virtualScrollConfig = {
      height: value.height || null,
      itemSize: value.itemSize || 40
    };

    this.cdr.markForCheck();
  }

  get hasDataSource(): boolean {
    return isDataSource(this.dataSource);
  }

  initSelection() {
    const isMutliple = this.selectionMode === KalListSelectionMode.Multiple;

    if (this.selectionMode === KalListSelectionMode.None && !this._selection.isEmpty()) {
      this._selection.clear();
    } else if (this._selection.multiple !== isMutliple) {
      this._selection.multiple = isMutliple;
    }

    this.countItems();
  }

  selectAll() {
    if (this._selectionMode === KalListSelectionMode.Multiple) {
      this._selection.selectAll();
      this.cdr.markForCheck();

      this.selectionChange.emit(this._selection);
    } else {
      throw Error('Cannot select multiple rows with single selection mode.');
    }
  }

  /**
   * Select an item in list and emit an event with the selected item value
   */
  selectItem(item) {
    if (!this.selectRowOnContentClick) {

      this.selectCheckbox(item);

    } else {

      this.highlightedItem = item;
      this.highlighted.emit(item);
    }
  }

  selectCheckbox(item, $event?) {
    if ($event) {
      $event.preventDefault();
      $event.stopPropagation();
    }

    if (!this.isRowDisabled(item) && this._selectionMode !== KalListSelectionMode.None) {

      if (this._selectionMode === KalListSelectionMode.Multiple || !this._selection.isSelected(item)) {

        this._selection.toggle(item);

      }

      this.selectionChange.emit(this._selection);
    }

  }

  /**
   * Is the item selected
   */
  isRowSelected(item): boolean {
    return this.selection.isSelected(item);
  }

  /**
   * Is the item disabled
   */
  isRowDisabled(item): boolean {
    return this.disableRowsFunction ? this.disableRowsFunction(item) : false;
  }

  /**
   * Reset the selected item
   */
  clear() {
    this._selection.clear();
    this.cdr.markForCheck();
    this.selectionChange.emit(this._selection);
  }

  /**
   * Check if items need to be grouped
   */
  getSlugName(currentItem): string {
    if (!this.groupByFunction) {
      return null;
    }

    // save current element for next iteration
    const {previous, slug: previousSlug} = this.groupedByParams;
    const currentSlug = this.groupByFunction(currentItem);

    // calcul slug for previous and current element
    this.groupedByParams.previous = currentItem;
    this.groupedByParams.slug = currentSlug;

    // if first element ( no previous ) display slug of current item
    if (!previous || previousSlug !== currentSlug) {
      return currentSlug;
    } else {
      return null;
    }
  }

  /**
   * Is the item highlighted
   */
  isHighlighted(item: (T & {id: string})): boolean {
    if (!this.highlightedItem) {
      return false;
    } else if (!isNil(item.id)) {
      return this.highlightedItem.id === item.id;
    } else {
      return this.highlightedItem === item;
    }
  }

  private countItems() {
    this.countSubscription.unsubscribe();
    let numberOfItems = new BehaviorSubject(0);
    const total = this.dataSource ? (this.dataSource as DataSource<T> & { total?: BehaviorSubject<number> }).total : 0;

    if (this.hasDataSource && total) {
      numberOfItems = total;
    } else if (this.results) {
      numberOfItems.next((this.results as T[]).length);
    }

    if (this._selection) {
      this.countSubscription = numberOfItems.pipe(
        tap(value => {
          this._selection.numberOfItems = Math.max(value, this._selection.numberOfItems);
        })
      ).subscribe();
    }
  }

  private destroySubscription() {
    this.subscription.unsubscribe();
    this.countSubscription.unsubscribe();

    if (this.dataSource && (this.dataSource as DataSource<T>).connect instanceof Function) {
      (this.dataSource as DataSource<T>).disconnect(this);
    }
  }

  private setResults(dataSource$: Observable<T[] | ReadonlyArray<T>>) {
    this.subscription = dataSource$
      .pipe(
        tap((items: T[]) => {
          this.results = items;
          this.countItems();
          this.cdr.markForCheck();
        })
      ).subscribe();
  }

  private observeDataSource() {
    if (this.hasDataSource) {
      this.results = this.dataSource as DataSource<T>;
      this.countItems();
    } else if (this.dataSource instanceof Observable) {
      this.setResults((this.dataSource as Observable<T[]>));
    } else if (Array.isArray(this.dataSource)) {
      this.setResults(of(this.dataSource as T[]));
    }
  }

  private selectionChanges() {
    this.selectionSubscription.unsubscribe();
    this.selectionSubscription = this.selection.changes
      .pipe(
        tap(() => this.cdr.markForCheck())
      ).subscribe();
  }

  ngOnInit(): void {
    if (!this._selection) {
      this._selection = new KalSelectionModel<T>({
        multiple: this.selectionMode === KalListSelectionMode.Multiple
      });
      this.selectionChanges();
    }

    this.initSelection();

    this.isInitialized = true;
  }

  ngOnChanges(changes: SimpleChanges): void {
    this.cdr.markForCheck();
  }

  ngOnDestroy() {
    this.destroySubscription();
  }
}