NGO-DB/ndb-core

View on GitHub
src/app/features/matching-entities/matching-entities/matching-entities.component.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  ChangeDetectorRef,
  Component,
  ElementRef,
  Input,
  OnInit,
  ViewChild,
} from "@angular/core";
import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator";
import { Entity, EntityConstructor } from "../../../core/entity/model/entity";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import { EntityRegistry } from "../../../core/entity/database-entity.decorator";
import {
  MatchingEntitiesConfig,
  MatchingSideConfig,
  NewMatchAction,
} from "./matching-entities-config";
import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface";
import { ActivatedRoute } from "@angular/router";
import { FormDialogService } from "../../../core/form-dialog/form-dialog.service";
import { addAlphaToHexColor } from "../../../utils/style-utils";
import { BehaviorSubject } from "rxjs";
import { ConfigService } from "../../../core/config/config.service";
import { MatTableModule } from "@angular/material/table";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatTooltipModule } from "@angular/material/tooltip";
import { NgForOf, NgIf } from "@angular/common";
import { MatButtonModule } from "@angular/material/button";
import { EntityFieldViewComponent } from "../../../core/common-components/entity-field-view/entity-field-view.component";
import { MapComponent } from "../../location/map/map.component";
import { FilterComponent } from "../../../core/filter/filter/filter.component";
import { Coordinates } from "../../location/coordinates";
import { FilterService } from "../../../core/filter/filter.service";
import { LocationProperties } from "../../location/map/map-properties-popup/map-properties-popup.component";
import { getLocationProperties } from "../../location/map-utils";
import { FlattenArrayPipe } from "../../../utils/flatten-array/flatten-array.pipe";
import { isArrayDataType } from "../../../core/basic-datatypes/datatype-utils";
import {
  ColumnConfig,
  FormFieldConfig,
} from "../../../core/common-components/entity-form/FormConfig";
import { RouteTarget } from "../../../route-target";
import { EntitiesTableComponent } from "../../../core/common-components/entities-table/entities-table.component";
import { DataFilter } from "../../../core/filter/filters/filters";

export interface MatchingSide extends MatchingSideConfig {
  /** pass along filters from app-filter to subrecord component */
  filterObj?: DataFilter<Entity>;

  availableEntities?: Entity[];
  selectMatch?: (e) => void;
  entityType: EntityConstructor;

  /** whether this allows to select more than one selected match */
  multiSelect: boolean;

  selected?: Entity[];

  /** item of `selected` that is currently highlighted */
  highlightedSelected: Entity;

  distanceColumn: {
    coordinatesProperties: string[];
    compareCoordinates: BehaviorSubject<Coordinates[]>;
  };
}

@RouteTarget("MatchingEntities")
@DynamicComponent("MatchingEntities")
@Component({
  selector: "app-matching-entities",
  templateUrl: "./matching-entities.component.html",
  styleUrls: ["./matching-entities.component.scss"],
  imports: [
    MatTableModule,
    FontAwesomeModule,
    MatTooltipModule,
    NgIf,
    MatButtonModule,
    NgForOf,
    EntitiesTableComponent,
    EntityFieldViewComponent,
    MapComponent,
    FilterComponent,
    FlattenArrayPipe,
  ],
  standalone: true,
})
export class MatchingEntitiesComponent implements OnInit {
  static DEFAULT_CONFIG_KEY = "appConfig:matching-entities";

  @Input() entity: Entity;
  @Input() leftSide: MatchingSideConfig = {};
  @Input() rightSide: MatchingSideConfig = {};
  /**
   * Column mapping of property pairs of left and right entity that should be compared side by side.
   * @param value
   */
  @Input() columns: [ColumnConfig, ColumnConfig][] = [];
  @Input()
  matchActionLabel: string = $localize`:Matching button label:create matching`;
  @Input() onMatch: NewMatchAction;

  @ViewChild("matchComparison", { static: true })
  matchComparisonElement: ElementRef;

  columnsToDisplay = [];
  lockedMatching = false;
  sideDetails: [MatchingSide, MatchingSide];

  mapVisible = false;
  filteredMapEntities: Entity[] = [];
  displayedProperties: LocationProperties = {};

  constructor(
    private route: ActivatedRoute,
    private formDialog: FormDialogService,
    private entityMapper: EntityMapperService,
    private configService: ConfigService,
    private entityRegistry: EntityRegistry,
    private filterService: FilterService,
    private changeDetector: ChangeDetectorRef,
  ) {
    const config: MatchingEntitiesConfig =
      this.configService.getConfig<MatchingEntitiesConfig>(
        MatchingEntitiesComponent.DEFAULT_CONFIG_KEY,
      ) ?? {};
    Object.assign(this, JSON.parse(JSON.stringify(config)));

    this.route.data.subscribe(
      (data: DynamicComponentConfig<MatchingEntitiesConfig>) => {
        if (
          !data?.config?.leftSide &&
          !data?.config?.rightSide &&
          !data?.config?.columns
        ) {
          return;
        }
        Object.assign(this, JSON.parse(JSON.stringify(data.config)));
      },
    );
  }

  // TODO: fill selection on hover already?

  async ngOnInit() {
    this.sideDetails = [
      await this.initSideDetails(this.leftSide, 0),
      await this.initSideDetails(this.rightSide, 1),
    ];
    this.sideDetails.forEach((side, index) =>
      this.initDistanceColumn(side, index),
    );
    this.filterMapEntities();
    this.columnsToDisplay = ["side-0", "side-1"];
    // needed due to async
    this.changeDetector.detectChanges();
  }

  /**
   * Generate setup for a side of the matching view template based on the component input properties.
   * @param side
   * @param sideIndex
   * @private
   */
  private async initSideDetails(
    side: MatchingSideConfig,
    sideIndex: number,
  ): Promise<MatchingSide> {
    const newSide = Object.assign({}, side) as MatchingSide; // we are transforming it into this type here

    if (!newSide.entityType) {
      newSide.selected = newSide.selected ?? [this.entity];
      newSide.highlightedSelected = newSide.selected[0];
      newSide.entityType = newSide.highlightedSelected?.getConstructor();
    }

    let entityType = newSide.entityType;
    if (typeof entityType === "string") {
      entityType = this.entityRegistry.get(entityType);
    }
    newSide.entityType =
      entityType ?? newSide.highlightedSelected?.getConstructor();

    newSide.columns =
      newSide.columns ??
      this.columns.map((p) => p[sideIndex]).filter((c) => !!c);

    newSide.multiSelect = this.checkIfMultiSelect(
      this.onMatch.newEntityType,
      sideIndex === 0
        ? this.onMatch.newEntityMatchPropertyLeft
        : this.onMatch.newEntityMatchPropertyRight,
    );

    if (newSide.multiSelect) {
      newSide.selectMatch = this.getMultiSelectFunction(newSide);
    } else {
      newSide.selectMatch = this.getSingleSelectFunction(newSide);
    }

    if (!newSide.selected && newSide.entityType) {
      newSide.availableEntities = await this.entityMapper.loadType(
        newSide.entityType,
      );
      newSide.availableFilters = newSide.availableFilters ?? [];
      newSide.filterObj = { ...(side.prefilter ?? {}) };
    }

    this.mapVisible =
      this.mapVisible || getLocationProperties(newSide.entityType).length > 0;

    return newSide;
  }

  private checkIfMultiSelect(
    onMatchEntityType: string,
    onMatchProperty: string,
  ) {
    const schemaField = this.entityRegistry
      .get(onMatchEntityType)
      .schema.get(onMatchProperty);

    return isArrayDataType(schemaField.dataType);
  }

  private getMultiSelectFunction(newSide: MatchingSide) {
    return (e: Entity) => {
      if (!newSide.selected) {
        newSide.selected = [];
      }

      if (newSide.selected.includes(e)) {
        // unselect
        this.highlightSelectedRow(e, true);
        newSide.selected = newSide.selected.filter((s) => s !== e);
        if (newSide.highlightedSelected === e) {
          newSide.highlightedSelected = newSide.selected[0];
        }
      } else {
        this.highlightSelectedRow(e);
        newSide.selected = [...newSide.selected, e];
        newSide.highlightedSelected = e;
      }

      this.matchComparisonElement.nativeElement.scrollIntoView();
      this.updateDistanceColumn(newSide);
    };
  }

  private getSingleSelectFunction(newSide: MatchingSide) {
    return (e: Entity) => {
      this.highlightSelectedRow(e);
      if (newSide.highlightedSelected) {
        this.highlightSelectedRow(newSide.highlightedSelected, true);
      }
      newSide.selected = [e];
      newSide.highlightedSelected = e;
      this.matchComparisonElement.nativeElement.scrollIntoView();
      this.updateDistanceColumn(newSide);
    };
  }

  private highlightSelectedRow(newSelectedEntity: Entity, unHighlight = false) {
    if (unHighlight) {
      newSelectedEntity.getColor =
        newSelectedEntity.getConstructor().prototype.getColor;
    } else {
      newSelectedEntity.getColor = () =>
        addAlphaToHexColor(newSelectedEntity.getConstructor().color, 0.2);
    }
  }

  async createMatch() {
    const newMatchEntity = new (this.entityRegistry.get(
      this.onMatch.newEntityType,
    ))();

    const leftMatch = this.sideDetails[0].selected;
    const rightMatch = this.sideDetails[1].selected;

    newMatchEntity[this.onMatch.newEntityMatchPropertyLeft] = this
      .sideDetails[0].multiSelect
      ? leftMatch.map((e) => e.getId())
      : leftMatch[0].getId();
    newMatchEntity[this.onMatch.newEntityMatchPropertyRight] = this
      .sideDetails[1].multiSelect
      ? rightMatch.map((e) => e.getId())
      : rightMatch[0].getId();

    // best guess properties (if they do not exist on the specific entity, the values will be discarded during save
    newMatchEntity["date"] = new Date();
    newMatchEntity["start"] = new Date();
    newMatchEntity["name"] =
      newMatchEntity.getConstructor().label +
      " " +
      leftMatch.map((e) => e.toString()).join(", ") +
      " - " +
      rightMatch.map((e) => e.toString()).join(", ");

    if (this.onMatch.columnsToReview) {
      this.formDialog
        .openFormPopup(newMatchEntity, this.onMatch.columnsToReview)
        .afterClosed()
        .subscribe((result) => {
          if (result instanceof newMatchEntity.getConstructor()) {
            this.lockedMatching = true;
          }
        });
    } else {
      await this.entityMapper.save(newMatchEntity);
      this.lockedMatching = true;
    }
  }

  applySelectedFilters(side: MatchingSide, filter: DataFilter<Entity>) {
    side.filterObj = { ...side.prefilter, ...filter };
    this.filterMapEntities();
  }

  private filterMapEntities() {
    this.filteredMapEntities = [];
    this.sideDetails.forEach((side) => {
      if (side.filterObj) {
        const predicate = this.filterService.getFilterPredicate(side.filterObj);
        const filtered = side.availableEntities.filter(predicate);
        this.filteredMapEntities.push(...filtered);
      } else {
        this.filteredMapEntities.push(...(side.availableEntities ?? []));
      }
    });
  }

  entityInMapClicked(entity: Entity) {
    const side = this.sideDetails.find(
      (s) => s.entityType === entity.getConstructor(),
    );
    if (side) {
      side.selectMatch(entity);
    }
  }

  /**
   * Initialize distance column for columns of side and columns of EntitySubrecord
   * @param side
   * @param index of the side
   * @private
   */
  private initDistanceColumn(side: MatchingSide, index: number) {
    const sideIndex = side.columns.findIndex((col) => col === "distance");
    if (sideIndex !== -1) {
      const columnConfig = this.getDistanceColumnConfig(side);
      side.columns[sideIndex] = columnConfig;
      side.distanceColumn = columnConfig.additional;
      const colIndex = this.columns.findIndex(
        (row) => row[index] === "distance",
      );
      if (colIndex !== -1) {
        this.columns[colIndex][index] = columnConfig;
      }
    }
  }

  private getDistanceColumnConfig(side: MatchingSide): FormFieldConfig {
    return {
      id: "distance",
      label: $localize`:Matching View column name:Distance`,
      viewComponent: "DisplayDistance",
      additional: {
        coordinatesProperties:
          this.displayedProperties[side.entityType.ENTITY_TYPE],
        compareCoordinates: new BehaviorSubject<Coordinates[]>([]),
      },
    };
  }

  updateMarkersAndDistances() {
    this.sideDetails.forEach((side) => {
      const sideProperties =
        this.displayedProperties[side.entityType.ENTITY_TYPE];
      if (side.distanceColumn) {
        side.distanceColumn.coordinatesProperties = sideProperties;
        const lastValue = side.distanceColumn.compareCoordinates.value;
        side.distanceColumn.compareCoordinates.next(lastValue);
      }
      if (side.highlightedSelected) {
        this.updateDistanceColumn(side);
      }
    });
  }

  private updateDistanceColumn(side: MatchingSide) {
    const properties =
      this.displayedProperties[side.highlightedSelected?.getType()];
    const otherIndex = this.sideDetails[0] === side ? 1 : 0;
    const distanceColumn = this.sideDetails[otherIndex].distanceColumn;
    if (properties && distanceColumn) {
      const coordinates = properties.map(
        (prop) => side.highlightedSelected[prop],
      );
      distanceColumn.compareCoordinates.next(coordinates);
    }
  }
}