src/app/core/import/import/import.component.ts

Description

View providing a full UI workflow to import data from an uploaded file.

Example

Metadata

Relationships

Index

Properties
Methods

Constructor

constructor()

Methods

applyPreviousMapping
applyPreviousMapping(importMetadata: ImportMetadata)
Parameters :
Name Type Optional
importMetadata ImportMetadata No
Returns : void
onColumnMappingUpdate
onColumnMappingUpdate(newColumnMapping: ColumnMapping[])
Parameters :
Name Type Optional
newColumnMapping ColumnMapping[] No
Returns : void
onDataLoaded
onDataLoaded(data: ParsedData)
Parameters :
Name Type Optional
data ParsedData No
Returns : void
onEntityTypeChange
onEntityTypeChange(newType: string)
Parameters :
Name Type Optional
newType string No
Returns : void
onImportCompleted
onImportCompleted()
Returns : unknown
Async reset
reset(skipConfirmation?: boolean)
Parameters :
Name Type Optional
skipConfirmation boolean Yes
Returns : unknown
updateImportSettings
updateImportSettings(patch: Partial<ImportSettings>)
Parameters :
Name Type Optional
patch Partial<ImportSettings> No
Returns : void

Properties

cannotCreateSelectedType
Type : unknown
Default value : computed(() => { const entityType = this.importSettings().entityType; return ( !!entityType && this.ability.initialized && this.ability.cannot("create", entityType) ); })

Whether the selected type cannot be created by the user. The dropdown already hides non-creatable types, but a type can also arrive via the entityType query parameter (e.g. a bookmarked link), so guard the selection here too instead of only failing at the final save.

cannotImport
Type : unknown
Default value : this.ability.initialized && this.ability.cannot("create", ImportMetadata)

Whether the current user lacks permission to create ImportMetadata records. Every import writes an ImportMetadata history entry at the end, so without this permission no import can succeed - block the whole flow up front rather than letting the user prepare an import that would fail.

canUpdateSelectedType
Type : unknown
Default value : computed(() => { const entityType = this.importSettings().entityType; return ( !!entityType && (!this.ability.initialized || this.ability.can("update", entityType)) ); })

Whether the user may update existing records of the selected type. The "check/update existing" option modifies existing records, so it requires update (not just create) permission on the target type.

columnMappingComplete
Type : unknown
Default value : computed( () => this.mappedColumnsCount() > 0 && this.columnsMissingConfig().length === 0, )

whether the user can continue from the column mapping step to the import preview

columnsMissingConfig
Type : unknown
Default value : computed(() => { const entityType = this.importSettings().entityType; const entityCtor = entityType ? this.entities.get(entityType) : undefined; if (!entityCtor) { return []; } return (this.importSettings().columnMapping ?? []).filter((m) => this.configDialogs.isConfigMissing(m, entityCtor), ); })

Columns that are mapped to a field whose values have to be transformed, but whose transformation the user has not configured and confirmed yet.

importFileComponent
Type : ImportFileComponent
Decorators :
@ViewChild(ImportFileComponent)
importSettings
Type : unknown
Default value : signal<Partial<ImportSettings>>({})
mappedColumnsCount
Type : unknown
Default value : computed( () => this.importSettings().columnMapping?.filter((m) => !!m.propertyName) .length ?? 0, )

calculated for validation on columnMapping changes

rawData
Type : any[]
stepper
Type : MatStepper
Decorators :
@ViewChild(MatStepper)
import {
  Component,
  ViewChild,
  inject,
  ChangeDetectionStrategy,
  signal,
  computed,
} from "@angular/core";
import { ParsedData } from "../../common-components/parsed-file-input/parsed-file-input.component";
import { MatStepper, MatStepperModule } from "@angular/material/stepper";
import { ColumnMapping } from "../column-mapping";
import { ImportFileComponent } from "../import-file/import-file.component";
import { ConfirmationDialogService } from "../../common-components/confirmation-dialog/confirmation-dialog.service";
import { ImportMetadata, ImportSettings } from "../import-metadata";
import { AlertService } from "../../alerts/alert.service";
import { ActivatedRoute, Router } from "@angular/router";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatCardModule } from "@angular/material/card";
import { ImportHistoryComponent } from "../import-history/import-history.component";
import { EntityTypeLabelPipe } from "../../common-components/entity-type-label/entity-type-label.pipe";
import { ImportEntityTypeComponent } from "../import-entity-type/import-entity-type.component";
import { MatExpansionModule } from "@angular/material/expansion";
import { ImportAdditionalActionsComponent } from "../additional-actions/import-additional/import-additional-actions.component";
import { MatButtonModule } from "@angular/material/button";
import { ImportColumnMappingComponent } from "../import-column-mapping/import-column-mapping.component";
import { ImportReviewDataComponent } from "../import-review-data/import-review-data.component";
import { LOCATION_TOKEN } from "../../../utils/di-tokens";
import { RouteTarget } from "../../../route-target";
import { ImportMatchExistingComponent } from "../update-existing/import-match-existing/import-match-existing.component";
import { WarningNotOptimizedForSmallScreenComponent } from "#src/app/core/common-components/warning-not-optimized-for-small-screen/warning-not-optimized-for-small-screen.component";
import { EntityAbility } from "../../permissions/ability/entity-ability";
import { HintBoxComponent } from "../../common-components/hint-box/hint-box.component";
import { EntityRegistry } from "../../entity/database-entity.decorator";
import { ImportConfigDialogService } from "../import-column-mapping/import-config-dialog.service";

/**
 * View providing a full UI workflow to import data from an uploaded file.
 */
@RouteTarget("Import")
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-import",
  templateUrl: "./import.component.html",
  styleUrls: ["./import.component.scss"],
  imports: [
    MatStepperModule,
    FontAwesomeModule,
    ImportFileComponent,
    MatCardModule,
    ImportHistoryComponent,
    EntityTypeLabelPipe,
    ImportEntityTypeComponent,
    MatExpansionModule,
    ImportAdditionalActionsComponent,
    ImportMatchExistingComponent,
    MatButtonModule,
    ImportColumnMappingComponent,
    ImportReviewDataComponent,
    WarningNotOptimizedForSmallScreenComponent,
    HintBoxComponent,
  ],
})
export class ImportComponent {
  private confirmationDialog = inject(ConfirmationDialogService);
  private alertService = inject(AlertService);
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private location = inject<Location>(LOCATION_TOKEN);
  private readonly ability = inject(EntityAbility);
  private readonly entities = inject(EntityRegistry);
  private readonly configDialogs = inject(ImportConfigDialogService);

  rawData: any[];

  importSettings = signal<Partial<ImportSettings>>({});

  /**
   * Whether the current user lacks permission to create ImportMetadata records.
   * Every import writes an ImportMetadata history entry at the end, so without this
   * permission no import can succeed - block the whole flow up front rather than
   * letting the user prepare an import that would fail.
   */
  cannotImport =
    this.ability.initialized && this.ability.cannot("create", ImportMetadata);

  /**
   * Whether the user may update existing records of the selected type.
   * The "check/update existing" option modifies existing records, so it requires
   * update (not just create) permission on the target type.
   */
  canUpdateSelectedType = computed(() => {
    const entityType = this.importSettings().entityType;
    return (
      !!entityType &&
      (!this.ability.initialized || this.ability.can("update", entityType))
    );
  });

  /**
   * Whether the selected type cannot be created by the user.
   * The dropdown already hides non-creatable types, but a type can also arrive
   * via the entityType query parameter (e.g. a bookmarked link), so guard the
   * selection here too instead of only failing at the final save.
   */
  cannotCreateSelectedType = computed(() => {
    const entityType = this.importSettings().entityType;
    return (
      !!entityType &&
      this.ability.initialized &&
      this.ability.cannot("create", entityType)
    );
  });

  @ViewChild(MatStepper) stepper: MatStepper;
  @ViewChild(ImportFileComponent) importFileComponent: ImportFileComponent;

  /** calculated for validation on columnMapping changes */
  mappedColumnsCount = computed(
    () =>
      this.importSettings().columnMapping?.filter((m) => !!m.propertyName)
        .length ?? 0,
  );

  /**
   * Columns that are mapped to a field whose values have to be transformed,
   * but whose transformation the user has not configured and confirmed yet.
   */
  columnsMissingConfig = computed(() => {
    const entityType = this.importSettings().entityType;
    const entityCtor = entityType ? this.entities.get(entityType) : undefined;
    if (!entityCtor) {
      return [];
    }
    return (this.importSettings().columnMapping ?? []).filter((m) =>
      this.configDialogs.isConfigMissing(m, entityCtor),
    );
  });

  /** whether the user can continue from the column mapping step to the import preview */
  columnMappingComplete = computed(
    () =>
      this.mappedColumnsCount() > 0 && this.columnsMissingConfig().length === 0,
  );

  constructor() {
    this.route.queryParamMap.subscribe((params) => {
      if (params.has("entityType")) {
        this.updateImportSettings({ entityType: params.get("entityType") });
      }
      if (params.has("additionalAction")) {
        const action = JSON.parse(params.get("additionalAction"));
        this.updateImportSettings({ additionalActions: [action] });
      }
    });
  }

  updateImportSettings(patch: Partial<ImportSettings>) {
    this.importSettings.update((settings) => ({ ...settings, ...patch }));
  }

  async reset(skipConfirmation?: boolean) {
    if (
      !skipConfirmation &&
      !(await this.confirmationDialog.getConfirmation(
        $localize`:Import Reset Confirmation title:Cancel Import?`,
        $localize`:Import Reset Confirmation text:Do you really want to discard the currently prepared import?`,
      ))
    ) {
      return;
    }
    const currentRoute = this.location.pathname;
    return this.router
      .navigate([""], { skipLocationChange: true })
      .then(() =>
        this.router.navigate([currentRoute], { skipLocationChange: true }),
      );
  }

  onDataLoaded(data: ParsedData) {
    this.rawData = data.data;
    this.updateImportSettings({ filename: data.filename });

    if (this.importSettings().columnMapping?.some((m) => m.propertyName)) {
      this.alertService.addInfo(
        $localize`:alert info after file load:Column Mappings have been reset`,
      );
    }
    this.onColumnMappingUpdate(
      data.fields.map((field) => ({ column: field, propertyName: undefined })),
    );
  }

  onEntityTypeChange(newType: string) {
    // reset settings that reference the previous type's fields / link actions
    this.updateImportSettings({
      entityType: newType,
      importExisting: undefined,
      additionalActions: undefined,
    });
    if (this.importSettings().columnMapping?.length) {
      this.onColumnMappingUpdate(
        this.importSettings().columnMapping.map(({ column }) => ({
          column,
          propertyName: undefined,
        })),
      );
    }
  }

  onColumnMappingUpdate(newColumnMapping: ColumnMapping[]) {
    this.updateImportSettings({ columnMapping: newColumnMapping });
  }

  applyPreviousMapping(importMetadata: ImportMetadata) {
    this.updateImportSettings({
      entityType: importMetadata.config.entityType,
      additionalActions: importMetadata.config.additionalActions,
    });

    const currentColumnMapping = this.importSettings().columnMapping;
    if (!currentColumnMapping) {
      return;
    }

    const adjustedMappings = currentColumnMapping.map(
      ({ column }) =>
        importMetadata.config.columnMapping.find(
          (c) => column === c.column,
        ) ?? { column },
    );

    // TODO: load additionalActions also

    this.onColumnMappingUpdate(adjustedMappings);
  }

  onImportCompleted() {
    return this.reset(true);
  }
}
<app-warning-not-optimized-for-small-screen />

@if (cannotImport) {
  <div
    class="flex-column place-center gap-regular text-secondary permission-message"
  >
    <fa-icon icon="triangle-exclamation" size="4x"></fa-icon>
    <h2 i18n>You don't have permission to import</h2>
    <p i18n>
      Importing data requires additional permissions that your account does not
      have. Please contact your system administrator to request access.
    </p>
  </div>
} @else {
  <mat-stepper [linear]="true" #stepper style="height: 100%">
    <ng-template matStepperIcon="edit">
      <fa-icon icon="check" class="stepper-icon"></fa-icon>
    </ng-template>

    <!-- STEP 1: SELECT FILE -->
    <mat-step [completed]="rawData?.length > 0" #step1>
      <ng-template matStepLabel>
        <div i18n="Import Step - upload">Select File</div>

        @if (step1.completed) {
          <div class="step-label-extra" i18n="Import Step - upload - sub-label">
            {{ rawData?.length }} rows to import
          </div>
        }
      </ng-template>

      <ng-template matStepContent>
        <div class="stepper-navigation">
          <button
            mat-raised-button
            [disabled]="!step1.completed"
            i18n="import next step button"
            matStepperNext
          >
            Continue
          </button>
        </div>

        <div class="flex-row gap-large">
          <div class="flex-grow">
            <app-import-file
              [entityType]="importSettings().entityType"
              [additionalSettings]="importSettings().additionalSettings"
              (additionalSettingsChange)="
                updateImportSettings({ additionalSettings: $event })
              "
              (dataLoaded)="onDataLoaded($event)"
            ></app-import-file>
          </div>

          <mat-card class="flex-grow-1-3">
            <mat-card-content>
              <app-import-history
                [data]="rawData"
                (itemSelected)="applyPreviousMapping($event)"
              ></app-import-history>
            </mat-card-content>
          </mat-card>
        </div>
      </ng-template>
    </mat-step>

    <!-- STEP 2: SELECT IMPORT TYPE -->
    <mat-step [completed]="!!importSettings().entityType" #step2>
      <ng-template matStepLabel>
        <div i18n="Import Step - import types">Select Import Type(s)</div>

        @if (step2.completed) {
          <div class="step-label-extra" i18n="Import Step - type - sub-label">
            as {{ importSettings().entityType | entityTypeLabel }}
          </div>
        }
      </ng-template>

      <ng-template matStepContent>
        <div class="stepper-navigation">
          <button
            mat-stroked-button
            i18n="import back button"
            matStepperPrevious
          >
            Back
          </button>
          <button
            mat-raised-button
            [disabled]="!step2.completed || cannotCreateSelectedType()"
            i18n="import next step button"
            matStepperNext
          >
            Continue
          </button>
        </div>

        <app-import-entity-type
          [entityType]="importSettings().entityType"
          (entityTypeChange)="onEntityTypeChange($event)"
        ></app-import-entity-type>

        @if (cannotCreateSelectedType()) {
          <app-hint-box type="warning" i18n>
            You don't have permission to create
            {{ importSettings().entityType | entityTypeLabel }} records, so you
            cannot import them.
          </app-hint-box>
        }

        <app-import-additional-actions
          [entityType]="importSettings().entityType"
          [importActions]="importSettings().additionalActions"
          (importActionsChange)="
            updateImportSettings({ additionalActions: $event })
          "
        ></app-import-additional-actions>

        @if (canUpdateSelectedType()) {
          <app-import-match-existing
            [entityType]="importSettings().entityType"
            [settings]="importSettings().importExisting"
            (settingsChange)="updateImportSettings({ importExisting: $event })"
          ></app-import-match-existing>
        }
      </ng-template>
    </mat-step>

    <!-- STEP 3: MAP COLUMNS -->
    <mat-step [completed]="columnMappingComplete()" #step3>
      <ng-template matStepLabel>
        <div i18n="Import Step - map columns">Map Columns</div>

        @if (step3.completed) {
          <div
            class="step-label-extra"
            i18n="Import Step - map columns - sub-label"
          >
            {{ mappedColumnsCount() }} columns selected
          </div>
        }
      </ng-template>

      <ng-template matStepContent>
        <div class="stepper-navigation">
          <button
            mat-stroked-button
            i18n="import back button"
            matStepperPrevious
          >
            Back
          </button>
          <button
            mat-raised-button
            [disabled]="!step3.completed"
            i18n="import next step button"
            matStepperNext
          >
            Continue
          </button>
        </div>

        <app-import-column-mapping
          [entityType]="importSettings().entityType"
          [columnMapping]="importSettings().columnMapping"
          (columnMappingChange)="onColumnMappingUpdate($event)"
          [rawData]="rawData"
          [additionalSettings]="importSettings().additionalSettings"
        ></app-import-column-mapping>
      </ng-template>
    </mat-step>

    <!-- STEP 4: REVIEW DATA -->
    <mat-step [completed]="false" #step4>
      <ng-template matStepLabel i18n="Import Step - review data">
        Review & Edit Data
      </ng-template>

      <ng-template matStepContent>
        <div class="stepper-navigation">
          <button
            mat-stroked-button
            matStepperPrevious
            i18n="import back button"
          >
            Back
          </button>
          <button
            mat-stroked-button
            color="warn"
            (click)="reset()"
            i18n="import cancel/reset button"
          >
            Cancel
          </button>
        </div>

        <app-import-review-data
          [rawData]="rawData"
          [entityType]="importSettings().entityType"
          [columnMapping]="importSettings().columnMapping"
          [additionalSettings]="importSettings().additionalSettings"
          [additionalActions]="importSettings().additionalActions"
          [importExisting]="importSettings().importExisting"
          [filename]="importSettings().filename"
          [stepIsFocused]="stepper.selectedIndex === 3"
          (importComplete)="onImportCompleted()"
        ></app-import-review-data>
      </ng-template>
    </mat-step>
  </mat-stepper>
}

./import.component.scss

@use "variables/colors";
@use "variables/sizes";

mat-stepper {
  background-color: transparent;
}

.stepper-icon {
  font-size: 0.8em;
}

.step-label-extra {
  font-size: 12px;
  color: colors.$muted;
}

/* this component is designed as a screen filling view with sticky headers */
:host {
  height: 100%;
  padding-bottom: 0; /* overwrite parent assigned (larger) padding to make reasonable use of space with scrolling */
}
:host ::ng-deep .mat-horizontal-stepper-wrapper {
  height: 100%;
}
:host ::ng-deep .mat-horizontal-content-container {
  overflow-y: auto;
  overflow-x: auto;
}

.permission-message {
  max-width: 480px;
  margin: sizes.$large auto 0;
  text-align: center;
}

.stepper-navigation {
  display: flex;
  flex-direction: row;
  justify-content: flex-end;
  gap: sizes.$regular;

  position: sticky;
  top: 0;
  z-index: 100;
  background-color: colors.$background;

  padding-top: sizes.$x-small;
  padding-bottom: sizes.$regular;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""