src/app/core/import/import/import.component.ts
View providing a full UI workflow to import data from an uploaded file.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-import |
| standalone | true |
| imports |
MatStepperModule
FontAwesomeModule
MatCardModule
MatExpansionModule
MatButtonModule
|
| styleUrls | ./import.component.scss |
| templateUrl | ./import.component.html |
No results matching.
MatStepperModule
FontAwesomeModule
ImportFileComponent
MatCardModule
ImportHistoryComponent
EntityTypeLabelPipe
ImportEntityTypeComponent
MatExpansionModule
ImportAdditionalActionsComponent
ImportMatchExistingComponent
MatButtonModule
ImportColumnMappingComponent
ImportReviewDataComponent
WarningNotOptimizedForSmallScreenComponent
HintBoxComponent
Properties |
Methods |
constructor()
|
| applyPreviousMapping | ||||||
applyPreviousMapping(importMetadata: ImportMetadata)
|
||||||
|
Parameters :
Returns :
void
|
| onColumnMappingUpdate | ||||||
onColumnMappingUpdate(newColumnMapping: ColumnMapping[])
|
||||||
|
Parameters :
Returns :
void
|
| onDataLoaded | ||||||
onDataLoaded(data: ParsedData)
|
||||||
|
Parameters :
Returns :
void
|
| onEntityTypeChange | ||||||
onEntityTypeChange(newType: string)
|
||||||
|
Parameters :
Returns :
void
|
| onImportCompleted |
onImportCompleted()
|
|
Returns :
unknown
|
| Async reset | ||||||
reset(skipConfirmation?: boolean)
|
||||||
|
Parameters :
Returns :
unknown
|
| updateImportSettings | ||||||
updateImportSettings(patch: Partial<ImportSettings>)
|
||||||
|
Parameters :
Returns :
void
|
| importFileComponent |
Type : ImportFileComponent
|
Decorators :
@ViewChild(ImportFileComponent)
|
| importSettings |
Type : unknown
|
Default value : signal<Partial<ImportSettings>>({})
|
| 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;
}