src/app/core/basic-datatypes/discrete/discrete-import-config/discrete-import-dialog.component.ts

Description

UI to configure import value mappings for discrete datatypes like boolean or enum.

Implements

OnInit

Example

Metadata

Relationships

Used by

No results matching.

Depends on

import {
  Component,
  OnInit,
  inject,
  ChangeDetectionStrategy,
} from "@angular/core";
import {
  MAT_DIALOG_DATA,
  MatDialogModule,
  MatDialogRef,
} from "@angular/material/dialog";
import {
  FormBuilder,
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  FormsModule,
} from "@angular/forms";
import { MatFormFieldModule } from "@angular/material/form-field";
import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service";
import { EntitySchemaService } from "../../../entity/schema/entity-schema.service";
import { MappingDialogData } from "app/core/import/import-column-mapping/mapping-dialog-data";
import { splitArrayValue } from "app/core/import/split-array-value";
import { EntitySchemaField } from "../../../entity/schema/entity-schema-field";
import { KeyValuePipe } from "@angular/common";
import { MatButtonModule } from "@angular/material/button";
import { DynamicComponent } from "../../../config/dynamic-components/dynamic-component.decorator";
import { ConfigurableEnumService } from "../../configurable-enum/configurable-enum.service";
import { DynamicEditComponent } from "../../../entity/entity-field-edit/dynamic-edit/dynamic-edit.component";
import { HintBoxComponent } from "#src/app/core/common-components/hint-box/hint-box.component";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { HelpButtonComponent } from "../../../common-components/help-button/help-button.component";
import { DiscreteColumnMappingAdditional } from "../discrete.datatype";

/**
 * UI to configure import value mappings for discrete datatypes like boolean or enum.
 */
@DynamicComponent("DiscreteImportDialog")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-discrete-import-dialog",
  templateUrl: "./discrete-import-dialog.component.html",
  styleUrls: ["./discrete-import-dialog.component.scss"],
  imports: [
    MatDialogModule,
    MatFormFieldModule,
    KeyValuePipe,
    MatButtonModule,
    ReactiveFormsModule,
    DynamicEditComponent,
    HintBoxComponent,
    MatCheckboxModule,
    FormsModule,
    HelpButtonComponent,
  ],
})
export class DiscreteImportDialogComponent implements OnInit {
  data = inject<MappingDialogData>(MAT_DIALOG_DATA);
  private readonly fb = inject(FormBuilder);
  private readonly dialog = inject<MatDialogRef<any>>(MatDialogRef);
  private readonly confirmation = inject(ConfirmationDialogService);
  private readonly schemaService = inject(EntitySchemaService);
  private readonly configurableEnumService = inject(ConfigurableEnumService);

  form: FormGroup;
  component: string;
  schema: EntitySchemaField;
  enableSplitting: boolean;
  separator: string;

  ngOnInit() {
    this.schema = this.data.entityType.schema.get(this.data.col.propertyName);
    this.component = this.schemaService.getComponent(this.schema, "edit");
    this.separator = this.data.additionalSettings?.multiValueSeparator ?? ",";

    const discreteAdditional = this.data.col
      .additional as DiscreteColumnMappingAdditional;

    // For array fields: default to splitting (but can be disabled)
    // For single-select: never split
    if (this.schema?.isArray) {
      this.enableSplitting = discreteAdditional?.enableSplitting ?? true;
    } else {
      this.enableSplitting = false;
    }

    this.buildForm();
  }

  /**
   * Rebuild form when user toggles splitting option.
   * Attempts to preserve existing mappings where possible.
   */
  onSplittingToggle() {
    // Save current mappings before rebuilding
    const currentMappings = this.getValuesInDatabaseFormat(
      this.form.getRawValue(),
    );

    this.buildForm();

    // Try to restore mappings that still match
    const newFormValue = {};
    for (const key in this.form.controls) {
      if (currentMappings[key] !== undefined) {
        newFormValue[key] = this.schemaService.valueToEntityFormat(
          currentMappings[key],
          this.schema,
        );
      }
    }
    if (Object.keys(newFormValue).length > 0) {
      this.form.patchValue(newFormValue);
    }
  }

  /**
   * Build the form with value mappings
   */
  private buildForm() {
    const discreteAdditional = this.data.col
      .additional as DiscreteColumnMappingAdditional;
    const splitValues = this.splitAndFlattenValues(this.data.values);
    this.form = this.fb.group(
      this.getFormValues(discreteAdditional?.values, splitValues),
    );
  }

  /**
   * Split raw values using the configured separator and return unique individual values.
   */
  private splitAndFlattenValues(values: any[]): string[] {
    const uniqueValues = new Set<string>();

    for (const value of values) {
      if (value == null || value === "") {
        continue;
      }

      // Split values only if user enabled splitting for this column
      const parts: string[] = this.enableSplitting
        ? splitArrayValue(value, this.separator)
        : [String(value)];
      parts.forEach((part) => uniqueValues.add(part));
    }

    return [...uniqueValues];
  }

  private getFormValues(additional: any, values: string[]) {
    additional = additional || {};

    let enumOptions = [];
    if (this.schema?.additional) {
      const enumEntity = this.configurableEnumService.getEnum(
        this.schema.additional,
      );
      enumOptions = enumEntity?.values ?? [];
    }

    const formObj = {};
    for (const value of values) {
      let initialValue: string;

      if (value in additional) {
        initialValue = additional[value];
      } else {
        const matchedEnumOption = enumOptions.find(
          (opt) => opt.id === value || opt.label === value,
        );
        initialValue = matchedEnumOption?.id ?? value;
      }
      formObj[value] = new FormControl(
        this.schemaService.valueToEntityFormat(initialValue, this.schema),
      );
    }

    return formObj;
  }

  async save() {
    const rawValues = this.getValuesInDatabaseFormat(this.form.getRawValue());
    const allFilled = Object.values(rawValues).every((val) => val !== null);
    const confirmed =
      allFilled ||
      (await this.confirmation.getConfirmation(
        $localize`Ignore values?`,
        $localize`Some values don't have a mapping and will not be imported. Are you sure you want to keep it like this?`,
      ));
    if (confirmed) {
      // Save value mappings and splitting setting in 'additional'
      const discreteAdditional: DiscreteColumnMappingAdditional = {
        values: rawValues,
      };
      if (this.schema?.isArray) {
        discreteAdditional.enableSplitting = this.enableSplitting;
      }
      this.data.col.additional = discreteAdditional;

      this.dialog.close();
    }
  }

  /**
   * Transform object property values into their database format values to be stored.
   * @private
   */
  private getValuesInDatabaseFormat(rawValues: any) {
    for (const k in rawValues) {
      const value = this.schemaService.valueToDatabaseFormat(
        rawValues[k],
        this.schema,
      );
      // mark a value the user did not assign explicitly as null, because an empty value is
      // dropped whenever the mapping is serialized (e.g. stored with the import history),
      // which loses the information that this value should not be imported at all
      rawValues[k] = this.isAssigned(value) ? value : null;
    }

    return rawValues;
  }

  /**
   * Whether the given value is a target value the user picked,
   * which includes deliberately empty values like `false` for an unchecked checkbox.
   */
  private isAssigned(value: any): boolean {
    if (Array.isArray(value)) {
      return value.some((v) => this.isAssigned(v));
    }
    return value !== undefined && value !== null;
  }
}
<mat-dialog-content>
  <app-hint-box i18n
    >For each unique value from your imported data (left), select the category
    value from the system's data structure into which it will be transformed.
    The values you do not assign here will be ignored (remain empty) during
    import.</app-hint-box
  >

  @if (schema?.isArray) {
    <div style="margin-bottom: 16px">
      <mat-checkbox
        [(ngModel)]="enableSplitting"
        (change)="onSplittingToggle()"
      >
        <span i18n>Split values at separator ({{ separator }})</span>
      </mat-checkbox>
      <app-help-button
        text="Enable this to split values containing the separator into multiple entries. Disable for values that contain the separator as part of the text (e.g., 'media (article, ad, tv)')."
        i18n-text="import discrete config split toggle help"
      >
      </app-help-button>
    </div>
  }

  <table class="mapping-table">
    <tr>
      <th i18n="Import mapping header" class="first-column">Imported values</th>
      <th i18n="Import mapping header">Assigned values</th>
    </tr>
    @for (ctrl of form.controls | keyvalue; track ctrl.key) {
      <tr>
        <td class="first-column">{{ ctrl.key }}:</td>
        <td>
          <mat-form-field>
            <app-dynamic-edit
              [formControl]="$any(ctrl.value)"
              [formFieldConfig]="{
                id: ctrl.key,
                editComponent: component,
                dataType: schema.dataType,
                additional: schema.additional,
                label: ctrl.key,
              }"
            ></app-dynamic-edit>
          </mat-form-field>
        </td>
      </tr>
    }
  </table>
</mat-dialog-content>

<mat-dialog-actions>
  <button mat-raised-button color="accent" (click)="save()" i18n>
    Save & Close
  </button>
  <button mat-stroked-button matDialogClose i18n>Cancel</button>
</mat-dialog-actions>

./discrete-import-dialog.component.scss

.mapping-table {
  width: 100%;
}

.mapping-table th {
  text-align: left;
}

.first-column {
  width: 40%;
  max-width: 400px;
  padding-right: 20px;
}

:host ::ng-deep mat-form-field {
  width: 100%;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""