src/app/core/common-components/parsed-file-input/parsed-file-input.component.ts

Description

Form field to select and parse a structured data file.

Supports CSV, JSON and XLSX. For multi-sheet XLSX workbooks an inline sheet selector is shown and the chosen sheet's data is re-emitted on change. Attachment uploads use the file datatype components instead.

Example

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs
Outputs

Inputs

fileType
Type : SupportedFileType[]
Default value : ["csv"]

Allowed file types. Accepts a single value (legacy) or an array. String input is normalized to a single-element array.

Outputs

fileLoad
Type : ParsedData<T>

returns parsed data as an object on completing load after user selects a file

Methods

Async loadFile
loadFile($event: Event)
Parameters :
Name Type Optional
$event Event No
Returns : Promise<void>
Async onSheetChange
onSheetChange(value: string | string[])

Re-parse the currently loaded workbook using the chosen sheet and re-emit fileLoad with the new data. No-op for non-xlsx files or when the workbook has been cleared.

Accepts string | string[] to align with the autocomplete's valueChange typing; only single-select is wired, so an array is normalized to its first element.

Parameters :
Name Type Optional
value string | string[] No
Returns : Promise<void>
reparseWithDelimiter
reparseWithDelimiter(delimiter: string)

Re-parse the previously loaded CSV file using the given delimiter and re-emit fileLoad. Useful when PapaParse's auto-detection picked the wrong column separator and the user manually overrides it, without forcing them to re-upload the file.

No-op when no CSV file has been loaded yet (the cache is only populated for CSV files).

Parameters :
Name Type Optional Description
delimiter string No

the column delimiter to parse the cached file content with

Returns : void

Properties

Readonly acceptAttribute
Type : unknown
Default value : computed(() => this.fileType() .map((t) => "." + t) .join(","), )

Comma-separated list of extensions for the file input accept attribute.

Readonly availableSheets
Type : unknown
Default value : signal<SheetInfo[]>([])

Sheets available in the currently loaded xlsx workbook (empty otherwise).

formControl
Type : unknown
Default value : new FormControl()
parsedData
Type : ParsedData<T>
Readonly selectedSheet
Type : unknown
Default value : signal<string | null>(null)
Readonly sheetToLabel
Type : unknown
Default value : () => {...}

Display string for a sheet option in the inline picker.

Readonly sheetToValue
Type : unknown
Default value : () => {...}

Form value for a sheet option in the inline picker.

import {
  Component,
  inject,
  input,
  output,
  signal,
  computed,
  ChangeDetectionStrategy,
} from "@angular/core";
import { readFile, readFileAsArrayBuffer } from "../../../utils/utils";
import { Papa } from "ngx-papaparse";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatButtonModule } from "@angular/material/button";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { Workbook, Worksheet } from "exceljs";
import { BasicAutocompleteComponent } from "../basic-autocomplete/basic-autocomplete.component";

/** File extensions accepted by the parsed-file input component. */
export type SupportedFileType = "csv" | "json" | "xlsx";

/**
 * Lightweight description of a worksheet inside a loaded xlsx workbook,
 * used to populate the inline sheet picker for multi-sheet files.
 */
export interface SheetInfo {
  /** Worksheet name as shown to the user in the picker. */
  name: string;
  /** Data row count, excluding the header row. */
  rowCount: number;
  /** Number of populated columns in the worksheet. */
  columnCount: number;
}

/**
 * Form field to select and parse a structured data file.
 *
 * Supports CSV, JSON and XLSX. For multi-sheet XLSX workbooks an inline
 * sheet selector is shown and the chosen sheet's data is re-emitted on change.
 * Attachment uploads use the file datatype components instead.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-parsed-file-input",
  templateUrl: "./parsed-file-input.component.html",
  styleUrls: ["./parsed-file-input.component.scss"],
  imports: [
    MatFormFieldModule,
    MatInputModule,
    ReactiveFormsModule,
    MatButtonModule,
    FontAwesomeModule,
    BasicAutocompleteComponent,
  ],
})
export class ParsedFileInputComponent<T = any> {
  private papa = inject(Papa);

  /** returns parsed data as an object on completing load after user selects a file */
  fileLoad = output<ParsedData<T>>();

  /**
   * Allowed file types. Accepts a single value (legacy) or an array.
   * String input is normalized to a single-element array.
   */
  fileType = input<
    SupportedFileType[],
    SupportedFileType | SupportedFileType[]
  >(["csv"], {
    transform: (value) => (Array.isArray(value) ? value : [value]),
  });

  /** Sheets available in the currently loaded xlsx workbook (empty otherwise). */
  readonly availableSheets = signal<SheetInfo[]>([]);
  readonly selectedSheet = signal<string | null>(null);

  /** Comma-separated list of extensions for the file input `accept` attribute. */
  readonly acceptAttribute = computed(() =>
    this.fileType()
      .map((t) => "." + t)
      .join(","),
  );

  parsedData: ParsedData<T>;
  formControl = new FormControl();

  private currentWorkbook: Workbook | null = null;

  /** Cached text of the last loaded CSV, kept so the delimiter can be changed without re-upload. */
  private lastCsvContent?: string;
  private lastCsvFilename?: string;

  async loadFile($event: Event): Promise<void> {
    this.formControl.reset();
    this.resetSheetState();

    try {
      const file = this.getFileFromInputEvent($event);
      this.formControl.setValue(file.name);

      this.parsedData = await this.parseFile(file);
      this.fileLoad.emit(this.parsedData);
    } catch (errors) {
      this.formControl.setErrors(errors);
      this.formControl.markAsTouched();
    }
  }

  /** Display string for a sheet option in the inline picker. */
  readonly sheetToLabel = (sheet: SheetInfo) => sheet?.name;

  /** Form value for a sheet option in the inline picker. */
  readonly sheetToValue = (sheet: SheetInfo) => sheet?.name;

  /**
   * Re-parse the currently loaded workbook using the chosen sheet
   * and re-emit `fileLoad` with the new data. No-op for non-xlsx files
   * or when the workbook has been cleared.
   *
   * Accepts `string | string[]` to align with the autocomplete's `valueChange`
   * typing; only single-select is wired, so an array is normalized to its first element.
   */
  async onSheetChange(value: string | string[]): Promise<void> {
    const sheetName = Array.isArray(value) ? value[0] : value;
    if (!sheetName) return;
    if (!this.currentWorkbook) return;
    const sheet = this.currentWorkbook.getWorksheet(sheetName);
    if (!sheet) return;

    try {
      const nextParsedData = this.finalizeParsedData(
        this.worksheetToParsedData(sheet),
        this.parsedData?.filename,
      );
      this.formControl.setErrors(null);
      this.selectedSheet.set(sheetName);
      this.parsedData = nextParsedData;
      this.fileLoad.emit(this.parsedData);
    } catch (errors) {
      this.formControl.setErrors(errors);
      this.formControl.markAsTouched();
    }
  }

  /**
   * Re-parse the previously loaded CSV file using the given delimiter and
   * re-emit `fileLoad`. Useful when PapaParse's auto-detection picked the
   * wrong column separator and the user manually overrides it, without
   * forcing them to re-upload the file.
   *
   * No-op when no CSV file has been loaded yet (the cache is only populated
   * for CSV files).
   *
   * @param delimiter the column delimiter to parse the cached file content with
   */
  reparseWithDelimiter(delimiter: string): void {
    if (this.lastCsvContent === undefined) {
      return;
    }
    try {
      this.parsedData = this.finalizeParsedData(
        this.parseCsv(this.lastCsvContent, delimiter),
        this.lastCsvFilename,
      );
      this.formControl.setErrors(null);
      this.fileLoad.emit(this.parsedData);
    } catch (errors) {
      this.formControl.setErrors(errors);
      this.formControl.markAsTouched();
    }
  }

  /** Clears the picker state, the cached workbook and the cached CSV content. */
  private resetSheetState(): void {
    this.availableSheets.set([]);
    this.selectedSheet.set(null);
    this.currentWorkbook = null;
    this.lastCsvContent = undefined;
    this.lastCsvFilename = undefined;
  }

  private getFileFromInputEvent(inputEvent: Event): File {
    const target = inputEvent.target as HTMLInputElement;
    const file = target.files[0];

    const allowedTypes = this.fileType();
    const matchedType = allowedTypes.find((t) =>
      file.name.toLowerCase().endsWith("." + t),
    );
    if (!matchedType) {
      throw {
        fileInvalid: $localize`:Invalid file type error:Only ${allowedTypes.join(
          ", ",
        )}:fileTypes: files are supported`,
      };
    }
    return file;
  }

  /**
   * Dispatches to the right parser based on the file extension and validates
   * that the result is non-empty before returning it.
   *
   * @throws `{ parsingError: string }` when the file cannot be parsed or has no rows.
   */
  private async parseFile(file: File): Promise<ParsedData<T>> {
    const lowerName = file.name.toLowerCase();

    let result: ParsedData<T>;
    if (lowerName.endsWith(".csv")) {
      const fileContent = await readFile(file);
      this.lastCsvContent = fileContent;
      this.lastCsvFilename = file.name;
      result = this.parseCsv(fileContent);
    } else if (lowerName.endsWith(".json")) {
      result = this.parseJson(await readFile(file));
    } else if (lowerName.endsWith(".xlsx")) {
      result = await this.parseXlsx(await readFileAsArrayBuffer(file));
    }

    return this.finalizeParsedData(result, file.name);
  }

  private finalizeParsedData(
    result: ParsedData<T>,
    filename?: string,
  ): ParsedData<T> {
    if (!result) {
      throw {
        parsingError: $localize`:File parsing error:File could not be parsed`,
      };
    }
    if (!result.data || (result.data as { length?: number }).length === 0) {
      throw {
        parsingError: $localize`:Empty file parsing error:File has no content`,
      };
    }
    result.filename = filename;
    return result;
  }

  /**
   * Parses CSV text via ngx-papaparse using the first row as headers.
   *
   * When `explicitDelimiter` is given it overrides PapaParse's auto-detection;
   * otherwise the auto-detected delimiter is reported back via `detectedDelimiter`.
   */
  private parseCsv(
    fileContent: string,
    explicitDelimiter?: string,
  ): ParsedData<T> {
    const papaConfig: {
      header: boolean;
      dynamicTyping: boolean;
      skipEmptyLines: boolean;
      delimiter?: string;
    } = {
      header: true,
      dynamicTyping: true,
      skipEmptyLines: true,
    };
    if (explicitDelimiter !== undefined) {
      papaConfig.delimiter = explicitDelimiter;
    }
    const papaParsed = this.papa.parse(fileContent, papaConfig);
    if (!papaParsed) return undefined;
    return {
      data: papaParsed.data,
      fields: papaParsed.meta.fields,
      detectedDelimiter: papaParsed.meta.delimiter,
    };
  }

  /** Parses JSON text directly with `JSON.parse`. */
  private parseJson(fileContent: string): ParsedData<T> {
    return { data: JSON.parse(fileContent) };
  }

  /**
   * Loads an xlsx workbook via exceljs, caches it for sheet switching, and
   * returns the first sheet as `ParsedData`. When the workbook has more than
   * one sheet, `availableSheets` is populated so the picker is shown.
   */
  private async parseXlsx(buffer: ArrayBuffer): Promise<ParsedData<T>> {
    const workbook = new Workbook();
    await workbook.xlsx.load(buffer);
    this.currentWorkbook = workbook;

    const sheets = workbook.worksheets;
    if (sheets.length === 0) return undefined;

    if (sheets.length > 1) {
      this.availableSheets.set(
        sheets.map((s) => ({
          name: s.name,
          rowCount: Math.max(0, s.actualRowCount - 1),
          columnCount: s.actualColumnCount,
        })),
      );
    }
    this.selectedSheet.set(sheets[0].name);

    return this.worksheetToParsedData(sheets[0]);
  }

  /**
   * Converts an exceljs `Worksheet` into the same `ParsedData` shape that the
   * CSV path produces: row 1 is treated as the header and subsequent rows
   * become objects keyed by header name.
   *
   * Tracks each header's actual column index so empty header cells don't
   * desynchronize the lookup when reading data rows.
   */
  private worksheetToParsedData(sheet: Worksheet): ParsedData<T> {
    const headerRow = sheet.getRow(1);
    const fields: string[] = [];
    const columnIndices: number[] = [];
    headerRow.eachCell({ includeEmpty: false }, (cell, colNumber) => {
      fields.push(String(cell.value ?? "").trim());
      columnIndices.push(colNumber);
    });

    const data: any[] = [];
    sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
      if (rowNumber === 1) return;
      const record: Record<string, any> = {};
      fields.forEach((field, idx) => {
        const cellValue = row.getCell(columnIndices[idx]).value;
        record[field] = normalizeCellValue(cellValue);
      });
      data.push(record);
    });

    return { data: data as unknown as T, fields };
  }
}

/**
 * Unwraps exceljs's rich cell value shapes (e.g. hyperlinks expose `text`,
 * formulas expose `result`) into a primitive suitable for the import flow.
 */
function normalizeCellValue(value: unknown): unknown {
  if (value == null) return null;
  if (typeof value === "object" && "text" in (value as any)) {
    return (value as { text: string }).text;
  }
  if (typeof value === "object" && "result" in (value as any)) {
    return (value as { result: unknown }).result;
  }
  return value;
}

/**
 * Results and (optional) meta data about data parsed from a file.
 */
export interface ParsedData<T = any[]> {
  /** object or array of objects parsed from a file */
  data: T;

  /** meta information listing the fields contained in data objects */
  fields?: string[];
  filename?: string;

  /** the CSV column delimiter that was used (auto-detected by PapaParse or explicitly chosen) */
  detectedDelimiter?: string;
}
<input
  #fileInput
  type="file"
  style="display: none"
  [attr.accept]="acceptAttribute()"
  (change)="loadFile($event)"
/>
<div class="flex-row flex-wrap align-start gap-regular">
  <mat-form-field>
    <mat-label i18n="Label for file select input">Select File</mat-label>
    <input
      matInput
      readonly
      [formControl]="formControl"
      i18n-placeholder="placeholder for file-input"
      placeholder="No file selected"
      (click)="fileInput.click()"
    />
    <button mat-icon-button matIconSuffix (click)="fileInput.click()">
      <fa-icon icon="upload"></fa-icon>
    </button>
    @if (formControl.invalid) {
      <mat-error>{{
        formControl.errors.fileInvalid || formControl.errors.parsingError
      }}</mat-error>
    }
  </mat-form-field>

  @if (availableSheets().length > 1) {
    <mat-form-field class="sheet-picker">
      <mat-label i18n="Label for xlsx sheet selector">Sheet</mat-label>
      <app-basic-autocomplete
        [value]="selectedSheet()"
        (valueChange)="onSheetChange($event)"
        [options]="availableSheets()"
        [optionToString]="sheetToLabel"
        [valueMapper]="sheetToValue"
      >
        <ng-template let-sheet>
          <span
            class="flex-row align-center justify-space-between gap-regular full-width"
          >
            <span class="truncate-text sheet-option-name">{{
              sheet.name
            }}</span>
            <span class="sheet-option-meta">
              {{ sheet.rowCount }}&nbsp;<ng-container
                i18n="Row count label in xlsx sheet picker"
                >{sheet.rowCount, plural, =1 {row} other {rows}}</ng-container
              >
              &middot; {{ sheet.columnCount }}&nbsp;<ng-container
                i18n="Column count label in xlsx sheet picker"
                >{sheet.columnCount, plural,
                  =1 {col}
                  other {cols}
                }</ng-container
              >
            </span>
          </span>
        </ng-template>
      </app-basic-autocomplete>
    </mat-form-field>
  }
</div>

./parsed-file-input.component.scss

@use "variables/colors";

.sheet-picker {
  min-width: 320px;
}

.sheet-option-name {
  flex: 1 1 auto;
}

.sheet-option-meta {
  color: colors.$muted;
  font-size: 0.85em;
  flex-shrink: 0;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""