src/app/core/basic-datatypes/configurable-enum/configure-enum-popup/configure-enum-popup.component.ts

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Constructor

constructor()

Methods

Async createNewOption
createNewOption()
Returns : any
Async delete
delete(value: ConfigurableEnumValue, index: number)
Parameters :
Name Type Optional
value ConfigurableEnumValue No
index number No
Returns : any
drop
drop(event: CdkDragDrop)
Parameters :
Name Type Optional
event CdkDragDrop<string[]> No
Returns : void
hasUnsavedChanges
hasUnsavedChanges()
Returns : boolean
Async onCancel
onCancel()
Returns : any
onPasteNewOption
onPasteNewOption(event: ClipboardEvent)
Parameters :
Name Type Optional
event ClipboardEvent No
Returns : void
Async onSave
onSave()
Returns : any

Properties

enumEntity
Type : unknown
Default value : inject<ConfigurableEnum>(MAT_DIALOG_DATA)
localEnum
Type : ConfigurableEnum
newOptionInput
Type : string
import { Component, inject, ChangeDetectionStrategy } from "@angular/core";
import {
  MAT_DIALOG_DATA,
  MatDialogModule,
  MatDialogRef,
} from "@angular/material/dialog";
import {
  ConfigurableEnum,
  DuplicateEnumOptionException,
  InvalidEnumOptionException,
} from "../configurable-enum";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { FormsModule } from "@angular/forms";
import { DialogCloseComponent } from "../../../common-components/dialog-close/dialog-close.component";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import {
  CdkDrag,
  CdkDragDrop,
  CdkDropList,
  moveItemInArray,
} from "@angular/cdk/drag-drop";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatButtonModule } from "@angular/material/button";
import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service";
import { EntityRegistry } from "../../../entity/database-entity.decorator";
import { Entity } from "../../../entity/model/entity";
import { ConfigurableEnumValue } from "../configurable-enum.types";
import { MatSnackBar } from "@angular/material/snack-bar";
import {
  CustomYesNoButtons,
  OkButton,
  YesNoButtons,
} from "../../../common-components/confirmation-dialog/confirmation-dialog/confirmation-dialog.component";
import { MatTooltipModule } from "@angular/material/tooltip";
import { ColorInputComponent } from "#src/app/core/common-components/color-input/color-input.component";

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-configure-enum-popup",
  templateUrl: "./configure-enum-popup.component.html",
  styleUrls: ["./configure-enum-popup.component.scss"],
  imports: [
    MatDialogModule,
    MatFormFieldModule,
    MatInputModule,
    DialogCloseComponent,
    FormsModule,
    CdkDropList,
    CdkDrag,
    FontAwesomeModule,
    MatButtonModule,
    MatTooltipModule,
    ColorInputComponent,
  ],
})
export class ConfigureEnumPopupComponent {
  enumEntity = inject<ConfigurableEnum>(MAT_DIALOG_DATA);
  private dialog =
    inject<MatDialogRef<ConfigureEnumPopupComponent>>(MatDialogRef);
  private entityMapper = inject(EntityMapperService);
  private confirmationService = inject(ConfirmationDialogService);
  private entities = inject(EntityRegistry);
  private snackBar = inject(MatSnackBar);

  newOptionInput: string;
  localEnum: ConfigurableEnum;

  constructor() {
    // disable closing with backdrop click (so that we can always confirm unsaved changes)
    this.dialog.disableClose = true;

    // Deep copy for editing, using ConfigurableEnum logic
    this.localEnum = new ConfigurableEnum(
      this.enumEntity.getId(),
      this.enumEntity.values.map((v) => ({ ...v })),
    );
  }

  hasUnsavedChanges(): boolean {
    return (
      JSON.stringify(this.localEnum.values) !==
      JSON.stringify(this.enumEntity.values)
    );
  }

  private async confirmDiscardChanges(): Promise<boolean> {
    if (!this.hasUnsavedChanges()) return true;
    const confirmed = await this.confirmationService.getConfirmation(
      $localize`:Discard changes header:Discard Changes?`,
      $localize`You have unsaved changes. Discard them?`,
      CustomYesNoButtons($localize`Discard`, $localize`Continue Editing`),
    );
    return confirmed === true;
  }

  private async confirmAddPendingOption(): Promise<boolean> {
    if (!this.hasValidInput()) return true;
    const confirmed = await this.confirmationService.getConfirmation(
      $localize`Add new option?`,
      $localize`You have a new option that is not added yet, do you want to add it?`,
      YesNoButtons,
    );
    return confirmed === true;
  }

  private async confirmCommaSplit(): Promise<boolean> {
    const confirmed = await this.confirmationService.getConfirmation(
      $localize`Split by commas?`,
      $localize`Do you want to split the text by commas and add multiple options?`,
      YesNoButtons,
    );
    return confirmed === true;
  }

  private splitByLine(input: string): string[] {
    return input
      .split(/\r?\n/)
      .map((line) => line.trim())
      .filter((line) => line);
  }

  private splitByComma(input: string): string[] {
    return input
      .split(",")
      .map((line) => line.trim())
      .filter((line) => line);
  }

  private async parseInputLines(input: string): Promise<string[]> {
    if (input.includes("\n")) {
      return this.splitByLine(input);
    }
    if (input.includes(",")) {
      return (await this.confirmCommaSplit())
        ? this.splitByComma(input)
        : [input];
    }
    return [input];
  }

  private async handlePendingNewOption(): Promise<boolean> {
    const confirmed = await this.confirmAddPendingOption();
    if (confirmed) {
      await this.createNewOption();
      return true;
    } else {
      this.newOptionInput = "";
      return true;
    }
  }

  async onSave() {
    if (!(await this.handlePendingNewOption())) return;
    this.enumEntity.values = this.localEnum.values.map((v) => ({ ...v }));
    await this.saveChanges();
    this.dialog.close(true);
  }

  async onCancel() {
    if (!(await this.handlePendingNewOption())) return;
    if (await this.confirmDiscardChanges()) {
      this.dialog.close(false);
    }
  }

  private async saveChanges() {
    await this.entityMapper.save(this.enumEntity);
  }

  drop(event: CdkDragDrop<string[]>) {
    // TODO: this should probably update the _ordinal to allow sorting by custom order in tables (https://github.com/Aam-Digital/ndb-core/issues/3655)
    moveItemInArray(
      this.localEnum.values,
      event.previousIndex,
      event.currentIndex,
    );
  }

  async delete(value: ConfigurableEnumValue, index: number) {
    const existingUsages = await this.getUsages(value);
    let deletionText = $localize`Are you sure that you want to delete the option "${value.label}"?`;
    if (existingUsages.length > 0) {
      deletionText += $localize` The option is still used in ${existingUsages.join(
        ", ",
      )} records. If deleted, the records will not be lost but specially marked.`;
    }
    const confirmed = await this.confirmationService.getConfirmation(
      $localize`Delete option`,
      deletionText,
      CustomYesNoButtons($localize`Delete`, $localize`Cancel`),
    );
    if (confirmed === true) {
      this.localEnum.values.splice(index, 1);
    }
  }

  private async getUsages(value: ConfigurableEnumValue) {
    const enumMap: { [key in string]: string[] } = {};
    for (const entity of this.entities.values()) {
      const schemaFields = [...entity.schema.entries()]
        .filter(
          ([_, schema]) => schema.additional === this.enumEntity.getId(true),
        )
        .map(([name]) => name);
      if (schemaFields.length > 0) {
        enumMap[entity.ENTITY_TYPE] = schemaFields;
      }
    }
    const entityPromises = Object.entries(enumMap).map(([entityType, props]) =>
      this.entityMapper
        .loadType(entityType)
        .then((entities) => this.getEntitiesWithValue(entities, props, value)),
    );
    const possibleEntities = await Promise.all(entityPromises);
    return possibleEntities
      .filter((entities) => entities.length > 0)
      .map(
        (entities) =>
          `${entities.length} ${entities[0].getConstructor().label}`,
      );
  }

  private getEntitiesWithValue(
    res: Entity[],
    props: string[],
    value: ConfigurableEnumValue,
  ) {
    return res.filter((entity) =>
      props.some(
        (prop) =>
          entity[prop]?.id === value?.id ||
          entity[prop]?.map?.((v) => v.id).includes(value.id),
      ),
    );
  }

  onPasteNewOption(event: ClipboardEvent) {
    const clipboardData = event.clipboardData;
    if (!clipboardData) return;

    const pastedText = clipboardData.getData("text");
    const lines = this.splitByLine(pastedText).filter(
      (l) => !!l && l.trim() !== "",
    );

    event.preventDefault();
    this.newOptionInput = lines.join("\n");
  }

  async createNewOption() {
    if (!this.hasValidInput()) return;

    const input = this.newOptionInput.trim();
    const lines = await this.parseInputLines(input);

    let skipped = 0;
    const invalidEntries: string[] = [];
    for (const line of lines) {
      try {
        this.localEnum.addOption(line);
      } catch (err) {
        if (err instanceof DuplicateEnumOptionException) {
          skipped++;
        } else if (err instanceof InvalidEnumOptionException) {
          invalidEntries.push(line);
        } else {
          console.error("Failed to add option:", line, err);
        }
      }
    }

    this.newOptionInput = "";

    if (skipped > 0) {
      this.showDuplicateSkippedMessage(skipped);
    }
    if (invalidEntries.length > 0) {
      await this.showInvalidSkippedMessage(invalidEntries);
    }
  }

  private hasValidInput(): boolean {
    return !!this.newOptionInput && !!this.newOptionInput.trim();
  }

  private showDuplicateSkippedMessage(skipped: number) {
    this.snackBar.open(
      $localize`:@@duplicateOptionsSkipped:Skipped ${skipped} duplicate entries.`,
      undefined,
      { duration: 3000 },
    );
  }

  private async showInvalidSkippedMessage(invalidEntries: string[]) {
    const count = invalidEntries.length;
    const entriesList = invalidEntries.map((e) => `"${e}"`).join(", ");
    await this.confirmationService.getConfirmation(
      $localize`:@@invalidOptionsSkippedTitle:Some entries skipped`,
      $localize`:@@invalidOptionsSkipped:Skipped ${count} entries without usable letters or numbers for ID: ${entriesList}.`,
      OkButton,
    );
  }
}
<h1 matDialogTitle i18n="title of dropdown options popup dialog">
  Edit dropdown options
</h1>
<app-dialog-close (click)="onCancel()"></app-dialog-close>

<mat-dialog-content
  style="max-width: 400px; padding-top: 5px"
  cdkDropList
  (cdkDropListDropped)="drop($event)"
>
  @for (v of localEnum.values; track v.id; let i = $index) {
    <mat-form-field cdkDrag class="full-width" appearance="fill">
      <fa-icon
        icon="grip-vertical"
        matIconPrefix
        class="grab-icon margin-right-small"
      ></fa-icon>
      <input matInput [(ngModel)]="v.label" />

      <div matSuffix class="flex-row gap-small">
        <app-color-input
          [value]="v.color"
          (valueChange)="v.color = $event"
          [compact]="true"
        ></app-color-input>

        <button mat-icon-button matIconSuffix (click)="delete(v, i)">
          <fa-icon icon="trash"></fa-icon>
        </button>
      </div>
    </mat-form-field>
  }

  <!-- CREATING NEW OPTION -->
  <mat-form-field class="full-width">
    <mat-label i18n>Add new option</mat-label>
    <textarea
      matInput
      [(ngModel)]="newOptionInput"
      (paste)="onPasteNewOption($event)"
      rows="2"
      cdkTextareaAutosize
      #autosize="cdkTextareaAutosize"
    ></textarea>
    <button
      mat-icon-button
      color="accent"
      matIconSuffix
      (click)="createNewOption()"
    >
      <fa-icon icon="square-plus"></fa-icon>
    </button>
    <mat-hint i18n>
      You can paste multiple values at once (one per line)
    </mat-hint>
  </mat-form-field>
</mat-dialog-content>

<mat-dialog-actions>
  <button mat-raised-button color="accent" (click)="onSave()" i18n>Save</button>
  <button mat-raised-button (click)="onCancel()" i18n>Cancel</button>
</mat-dialog-actions>

./configure-enum-popup.component.scss

.grab-icon {
  cursor: grab;
  padding-left: 5px;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""