src/app/features/matching-entities/matching-entities/matching-entities.component.ts

Extends

MatchingSideConfig

Relationships

Used by

No results matching.

Depends on

Index

Properties

Properties

dataSource
dataSource: InMemoryDataSource<Entity>
Type : InMemoryDataSource<Entity>
Optional
distanceColumn
distanceColumn: { coordinatesProperties: string[]; compareCoordinates: BehaviorSubject<Coordinates[]> }
Type : { coordinatesProperties: string[]; compareCoordinates: BehaviorSubject<Coordinates[]> }
Properties :
entityType
entityType: string
Type : string
filterObj
filterObj: WritableSignal<DataFilter<Entity>>
Type : WritableSignal<DataFilter<Entity>>

pass along filters from app-filter to subrecord component

highlightedSelected
highlightedSelected: WritableSignal<Entity | null>
Type : WritableSignal<Entity | null>

item of selected that is currently highlighted

multiSelect
multiSelect: boolean
Type : boolean

whether this allows to select more than one selected match

selected
selected: WritableSignal<Entity[]>
Type : WritableSignal<Entity[]>
selectMatch
selectMatch: function
Type : function
Optional
availableFilters
availableFilters: FilterConfig[]
Type : FilterConfig[]
Optional

UI filter elements displayed for users to filter available entities

columns
columns: ColumnConfig[]
Type : ColumnConfig[]
Optional

columns of the available entities table. Usually inferred from matching columns of the component

prefilter
prefilter: DataFilter<Entity>
Type : DataFilter<Entity>
Optional

fixed pre-filters applied to remove some entities from the list of available entities

import { EntityTypePipe } from "#src/app/core/common-components/entity-type/entity-type.pipe";
import { AsyncPipe } from "@angular/common";
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  ElementRef,
  inject,
  Injector,
  input,
  OnInit,
  runInInjectionContext,
  signal,
  ViewChild,
  WritableSignal,
} from "@angular/core";
import { MatButtonModule, MatIconButton } from "@angular/material/button";
import { MatMenuModule } from "@angular/material/menu";
import { MatTableModule } from "@angular/material/table";
import { MatTooltipModule } from "@angular/material/tooltip";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { AblePurePipe } from "@casl/angular";
import {
  FaIconComponent,
  FontAwesomeModule,
} from "@fortawesome/angular-fontawesome";
import { GeoLocation } from "app/features/location/geo-location";
import { toSignal } from "@angular/core/rxjs-interop";
import { BehaviorSubject, map } from "rxjs";
import { EntitiesTableComponent } from "../../../core/common-components/entities-table/entities-table.component";
import {
  ColumnConfig,
  FormFieldConfig,
} from "../../../core/common-components/entity-form/FormConfig";
import { ConfigService } from "../../../core/config/config.service";
import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface";
import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator";
import { EntityRegistry } from "../../../core/entity/database-entity.decorator";
import { EntityFieldViewComponent } from "../../../core/entity/entity-field-view/entity-field-view.component";
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
import { Entity } from "../../../core/entity/model/entity";
import { FilterService } from "../../../core/filter/filter.service";
import { FilterComponent } from "../../../core/filter/filter/filter.component";
import { DataFilter } from "../../../core/filter/filters/filters";
import { FormDialogService } from "../../../core/form-dialog/form-dialog.service";
import { RouteTarget } from "../../../route-target";
import { FlattenArrayPipe } from "../../../utils/flatten-array/flatten-array.pipe";
import { addAlphaToHexColor } from "../../../utils/style-utils";
import { Coordinates } from "../../location/coordinates";
import {
  getLocationProperties,
  getMinDistanceKm,
} from "../../location/map-utils";
import { LocationProperties } from "../../location/map/map-properties-popup/map-properties-popup.component";
import { MapComponent } from "../../location/map/map.component";
import {
  MatchingEntitiesConfig,
  MatchingSideConfig,
  NewMatchAction,
} from "./matching-entities-config";
import { InMemoryDataSource } from "#src/app/core/common-components/entities-table/in-memory-data-source";
import { Logging } from "#src/app/core/logging/logging.service";

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

  dataSource?: InMemoryDataSource<Entity>;
  selectMatch?: (e) => void;
  entityType: string;

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

  selected: WritableSignal<Entity[]>;

  /** item of `selected` that is currently highlighted */
  highlightedSelected: WritableSignal<Entity | null>;

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

@RouteTarget("MatchingEntities")
@DynamicComponent("MatchingEntities")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-matching-entities",
  templateUrl: "./matching-entities.component.html",
  styleUrls: ["./matching-entities.component.scss"],
  imports: [
    MatTableModule,
    FontAwesomeModule,
    MatTooltipModule,
    MatButtonModule,
    EntitiesTableComponent,
    EntityFieldViewComponent,
    MapComponent,
    FilterComponent,
    FlattenArrayPipe,
    MatMenuModule,
    MatIconButton,
    FaIconComponent,
    RouterLink,
    AblePurePipe,
    AsyncPipe,
    EntityTypePipe,
  ],
})
export class MatchingEntitiesComponent implements OnInit {
  private route = inject(ActivatedRoute);
  private formDialog = inject(FormDialogService);
  private entityMapper = inject(EntityMapperService);
  private configService = inject(ConfigService);
  private entityRegistry = inject(EntityRegistry);
  private filterService = inject(FilterService);
  private injector = inject(Injector);
  static DEFAULT_CONFIG_KEY = "appConfig:matching-entities";

  entity = input<Entity>();
  leftSide = input<MatchingSideConfig>();
  rightSide = input<MatchingSideConfig>();
  /**
   * Column mapping of property pairs of left and right entity that should be compared side by side.
   * @param value
   */
  columns = input<[ColumnConfig, ColumnConfig][]>();
  matchActionLabel = input<string>();
  onMatch = input<NewMatchAction>();

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

  columnsToDisplay = computed(() =>
    this.sideDetails() ? ["side-0", "side-1"] : [],
  );
  lockedMatching = signal(false);
  sideDetails = signal<[MatchingSide, MatchingSide] | undefined>(undefined);

  mapVisible = computed(() =>
    (this.sideDetails() ?? ([] as MatchingSide[])).some(
      (side) =>
        side.entityType &&
        getLocationProperties(this.entityRegistry.get(side.entityType)).length >
          0,
    ),
  );
  filteredMapEntities = computed(() => {
    const entities: Entity[] = [];
    this.sideDetails()?.forEach((side) => {
      const filterObj = side.filterObj();
      if (Object.keys(filterObj).length > 0) {
        const predicate = this.filterService.getFilterPredicate(filterObj);
        entities.push(
          ...(side.dataSource?.allRecords() ?? []).filter(predicate),
        );
      } else {
        entities.push(...(side.dataSource?.allRecords() ?? []));
      }
    });
    return entities;
  });
  displayedLocationProperties = signal<LocationProperties>({});

  private readonly globalConfig: MatchingEntitiesConfig =
    this.configService.getConfig<MatchingEntitiesConfig>(
      MatchingEntitiesComponent.DEFAULT_CONFIG_KEY,
    ) ?? {};

  private readonly routeConfig = toSignal(
    this.route.data.pipe(
      map(
        (data: DynamicComponentConfig<MatchingEntitiesConfig>) =>
          data?.config as MatchingEntitiesConfig | undefined,
      ),
    ),
  );

  private readonly config = computed<MatchingEntitiesConfig>(() => {
    const routeConf = this.routeConfig();
    if (!routeConf?.leftSide && !routeConf?.rightSide && !routeConf?.columns) {
      return this.globalConfig;
    }
    return { ...this.globalConfig, ...structuredClone(routeConf) };
  });

  /**
   * Columns of the summary comparison view, as a working copy of the configured columns
   * so that generated configs (like the calculated "distance" column) can be filled in
   * without changing the given config.
   */
  readonly comparisonColumns = signal<[ColumnConfig, ColumnConfig][]>([]);

  // TODO: fill selection on hover already?

  async ngOnInit() {
    this.comparisonColumns.set(this.cloneColumns(this.resolvedColumns()));
    const sides: [MatchingSide, MatchingSide] = [
      await this.initSideDetails(this.resolvedLeftSide(), 0),
      await this.initSideDetails(this.resolvedRightSide(), 1),
    ];
    this.sideDetails.set(sides);
    sides.forEach((side, index) => this.initDistanceColumn(side, index));
  }

  /**
   * 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 = buildMatchingSideConfig(
      side,
      this.comparisonColumns(),
      sideIndex,
    ) as MatchingSide;

    if (!newSide.entityType) {
      const entity = this.entity();
      const initialSelected = entity ? [entity] : [];
      newSide.selected = signal<Entity[]>(initialSelected);
      newSide.highlightedSelected = signal<Entity | null>(
        initialSelected[0] ?? null,
      );
      newSide.entityType = newSide.highlightedSelected()?.getType();
    }

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

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

    if (!newSide.selected) {
      runInInjectionContext(
        this.injector,
        () => (newSide.dataSource = new InMemoryDataSource()),
      );
      const records = await this.entityMapper
        .loadType(newSide.entityType)
        .catch((err) => {
          Logging.error("Failed to initialize entities for matching side", {
            entityType: newSide.entityType,
            sideIndex,
            error: err,
          });
          return [];
        });
      newSide.dataSource.allRecords.set(records);
      newSide.availableFilters = newSide.availableFilters ?? [];
      newSide.selected = signal<Entity[]>([]);
      newSide.highlightedSelected = signal<Entity | null>(null);
    }

    newSide.filterObj = signal<DataFilter<Entity>>({
      ...(side.prefilter ?? {}),
    });

    return newSide;
  }

  private checkIfMultiSelect(
    onMatchEntityType: string,
    onMatchProperty: string,
  ) {
    if (!onMatchEntityType || !onMatchProperty) {
      return false;
    }

    try {
      const schemaField = this.entityRegistry
        .get(onMatchEntityType)
        .schema.get(onMatchProperty);
      return !!schemaField?.isArray;
    } catch {
      return false;
    }
  }

  private getMultiSelectFunction(newSide: MatchingSide) {
    return (e: Entity) => {
      const currentSelected = newSide.selected();
      if (currentSelected.includes(e)) {
        // unselect
        this.highlightSelectedRow(e, true);
        const newSelected = currentSelected.filter((s) => s !== e);
        newSide.selected.set(newSelected);
        if (newSide.highlightedSelected() === e) {
          newSide.highlightedSelected.set(newSelected[0] ?? null);
        }
      } else {
        this.highlightSelectedRow(e);
        newSide.selected.set([...currentSelected, e]);
        newSide.highlightedSelected.set(e);
      }

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

  private getSingleSelectFunction(newSide: MatchingSide) {
    return (e: Entity) => {
      if (newSide.selected()[0] === e) {
        // Deselect if already selected
        this.highlightSelectedRow(e, true);
        newSide.selected.set([]);
        newSide.highlightedSelected.set(null);
      } else {
        this.highlightSelectedRow(e);
        const prev = newSide.highlightedSelected();
        if (prev) {
          this.highlightSelectedRow(prev, true);
        }
        newSide.selected.set([e]);
        newSide.highlightedSelected.set(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(
          Entity.getColorWithConditions(newSelectedEntity),
          0.2,
        );
    }
  }

  /**
   * Get background color for entity rows in matching tables.
   */
  getEntityBackgroundColor = (entity: Entity): string => {
    const isSelected = this.sideDetails()?.some((side) =>
      side.selected().some((selected) => selected.getId() === entity.getId()),
    );
    if (isSelected) {
      const color = Entity.getColorWithConditions(entity);
      return addAlphaToHexColor(color, 0.2);
    }

    return "";
  };

  async createMatch() {
    const onMatch = this.resolvedOnMatch();
    if (
      !onMatch?.newEntityType ||
      !onMatch.newEntityMatchPropertyLeft ||
      !onMatch.newEntityMatchPropertyRight
    ) {
      return;
    }
    const newMatchEntity = new (this.entityRegistry.get(
      onMatch.newEntityType,
    ))();

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

    newMatchEntity[onMatch.newEntityMatchPropertyLeft] = sides[0].multiSelect
      ? leftMatch.map((e) => e.getId())
      : leftMatch[0].getId();
    newMatchEntity[onMatch.newEntityMatchPropertyRight] = sides[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 (onMatch.columnsToReview) {
      this.formDialog
        .openFormPopup(newMatchEntity, onMatch.columnsToReview)
        .afterClosed()
        .subscribe((result) => {
          if (result instanceof newMatchEntity.getConstructor()) {
            this.resetMatchingSelection();
          }
        });
    } else {
      await this.entityMapper.save(newMatchEntity);
      this.resetMatchingSelection();
    }
  }

  private resetMatchingSelection() {
    this.lockedMatching.set(false);

    for (const side of this.sideDetails()) {
      if (!side.dataSource?.allRecords()) {
        continue;
      }

      side
        .selected()
        .forEach((selectedEntity) =>
          this.highlightSelectedRow(selectedEntity, true),
        );
      side.selected.set([]);
      side.highlightedSelected.set(null);
      this.updateDistanceColumn(side);
    }
  }

  onDisplayedPropertiesChange(value: LocationProperties) {
    this.displayedLocationProperties.set(value);
    this.updateMarkersAndDistances();
  }

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

  entityInMapClicked(entity: Entity) {
    const side = this.sideDetails()?.find(
      (s) => s.entityType === entity.getType(),
    );
    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) =>
      typeof col === "string" ? col === "distance" : col.id === "distance",
    );

    if (sideIndex !== -1) {
      const columnConfig = this.getDistanceColumnConfig(side);
      side.columns[sideIndex] = columnConfig;
      side.distanceColumn = columnConfig.additional;
      this.setDistanceValuesForSide(side);
      const columns = this.comparisonColumns();
      const colIndex = columns.findIndex((row) => {
        const col = row[index];
        return typeof col === "string"
          ? col === "distance"
          : col?.id === "distance";
      });
      if (colIndex !== -1) {
        const updatedColumns = [...columns];
        updatedColumns[colIndex] = [...updatedColumns[colIndex]] as [
          ColumnConfig,
          ColumnConfig,
        ];
        updatedColumns[colIndex][index] = columnConfig;
        this.comparisonColumns.set(updatedColumns);
      }
    }
  }

  /**
   * Build the display-only distance column configuration for matching tables.
   */
  private getDistanceColumnConfig(side: MatchingSide): FormFieldConfig {
    return {
      id: "distance",
      label: $localize`:Matching View column name:Distance`,
      viewComponent: "DisplayDistance",
      dataType: "number",
      additional: {
        coordinatesProperties:
          this.displayedLocationProperties()[side.entityType],
        compareCoordinates: new BehaviorSubject<Coordinates[]>([]),
      },
    };
  }

  /**
   * Pre-calculate and attach a non-enumerable "distance" value on each entity
   * so the table can sort without custom sort functions.
   */
  private setDistanceValuesForSide(side: MatchingSide) {
    if (!side.dataSource?.allRecords()?.length || !side.distanceColumn) {
      return;
    }

    for (const entity of side.dataSource.allRecords()) {
      this.defineDistanceValue(entity, side);
    }
  }

  /**
   * Define a non-enumerable distance value on the entity for table sorting.
   */
  private defineDistanceValue(entity: Entity, side: MatchingSide) {
    const existingDescriptor = Object.getOwnPropertyDescriptor(
      entity,
      "distance",
    );
    if (existingDescriptor && existingDescriptor.configurable === false) {
      return;
    }

    const distanceValue = getMinDistanceKm(
      entity,
      side.distanceColumn?.coordinatesProperties ?? [],
      side.distanceColumn?.compareCoordinates?.value ?? [],
    );

    Object.defineProperty(entity, "distance", {
      configurable: true,
      enumerable: false,
      value: distanceValue,
      writable: true,
    });
  }

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

  private updateDistanceColumn(side: MatchingSide) {
    const highlighted = side.highlightedSelected();
    const locationProperties =
      this.displayedLocationProperties()[highlighted?.getType()];
    const sides = this.sideDetails();
    const otherIndex = sides[0] === side ? 1 : 0;
    const distanceColumn = sides[otherIndex].distanceColumn;
    if (locationProperties && distanceColumn) {
      const coordinates: Coordinates[] = locationProperties.map(
        (prop) => (highlighted[prop] as GeoLocation)?.geoLookup,
      );
      distanceColumn.compareCoordinates.next(coordinates);
    }
    const distanceSide = sides[otherIndex];
    this.setDistanceValuesForSide(distanceSide);
  }

  resolvedMatchActionLabel = computed(
    () =>
      this.matchActionLabel() ??
      this.config().matchActionLabel ??
      $localize`:Matching button label:create matching`,
  );

  private readonly resolvedLeftSide = computed<MatchingSideConfig>(
    () => this.leftSide() ?? this.config().leftSide ?? {},
  );

  private readonly resolvedRightSide = computed<MatchingSideConfig>(
    () => this.rightSide() ?? this.config().rightSide ?? {},
  );

  readonly resolvedColumns = computed<[ColumnConfig, ColumnConfig][]>(
    () => this.columns() ?? this.config().columns ?? [],
  );

  private readonly resolvedOnMatch = computed<NewMatchAction>(
    () =>
      this.onMatch() ??
      this.config().onMatch ?? {
        newEntityType: "",
        newEntityMatchPropertyLeft: "",
        newEntityMatchPropertyRight: "",
      },
  );

  private cloneColumns(
    columns: [ColumnConfig, ColumnConfig][],
  ): [ColumnConfig, ColumnConfig][] {
    return (columns ?? []).map(([left, right]) => [
      this.cloneColumn(left),
      this.cloneColumn(right),
    ]);
  }

  private cloneColumn(column: ColumnConfig): ColumnConfig {
    if (typeof column === "string" || !column) {
      return column;
    }
    return { ...column };
  }
}

/**
 * Create a MatchingSideConfig object
 * and fill it with columns from comparison array if necessary
 * @param side Base side configuration
 * @param columns columns array from the overall matching configuration
 * @param sideIndex index (0 or 1) of the side in the columns array
 */
export function buildMatchingSideConfig(
  side: MatchingSideConfig,
  columns: [ColumnConfig, ColumnConfig][],
  sideIndex: number,
): MatchingSideConfig {
  const newSide = Object.assign({}, side) as MatchingSide; // we are transforming it into this type here
  newSide.columns =
    newSide.columns ??
    (columns ?? []).map((p) => p[sideIndex]).filter((c) => !!c);
  return newSide;
}

results matching ""

    No results matching ""