File

libs/ui/csv-entry/src/lib/csv-entry/csv-entry.component.ts

Description

A component that accepts manual or pasted CSV content and generates a file blob.

Implements

OnInit OnDestroy

Example

<ts-csv-entry
  columnCount="6"
  [columnHeaders]="arrayOfHeaders"
  [columnValidators]="arrayOfValidators"
  [footerDirection]="ltr"
  [fullWidth]="false"
  id="my-id"
  maxRows="1000"
  rowCount="12"
  outputFormat="csv"
  (blobGenerated)="handleTheFileBlob($event)"
></ts-csv-entry>

<example-url>https://getterminus.github.io/ui-demos-release/components/csv-entry</example-url>

Metadata

changeDetection ChangeDetectionStrategy.OnPush
encapsulation ViewEncapsulation.None
exportAs tsCSVEntry
host {
}
selector ts-csv-entry
styleUrls ./csv-entry.component.scss
templateUrl ./csv-entry.component.html

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor(formBuilder: FormBuilder, changeDetectorRef: ChangeDetectorRef, documentService: TsDocumentService)
Parameters :
Name Type Optional
formBuilder FormBuilder No
changeDetectorRef ChangeDetectorRef No
documentService TsDocumentService No

Inputs

columnCount
Type : number

Set the number of columns

columnHeaders

Allow static headers to be set

columnValidators

Define any column validators

footerDirection
Type : "ltr" | "rtl"
Default value : 'ltr'

Define the layout direction for the footer

fullWidth
Default value : false

Allow full-width mode

id
Type : string

Define an ID for the component

maxRows
Type : number

Set the maximum number of allowed rows

outputFormat
Type : "csv" | "tsv"
Default value : 'tsv'

Define output to be CSV rather than TSV

rowCount
Type : number

Define the number of rows

Outputs

blobGenerated
Type : EventEmitter<Blob>

Emit the built file blob

Methods

Public addRows
addRows(rowCount: number, columnCount: number, content?: string[][], index?: number)

Add rows to the form

Parameters :
Name Type Optional Default value Description
rowCount number No 1
  • The number of rows to add
columnCount number No this.columnCount
  • The number of columns each row should have
content string[][] Yes
  • The column content
index number Yes
  • The row index
Returns : void
Public collectErrors
collectErrors()

Collect all errors from the recordsForm and set to allErrors

Returns : [] | null
Public createId
createId(recordIndex: number, cellIndex: number)

Create an ID for a cell. Format: ts-csv-entry-{number}_r_7Xc_2 would be the 2nd cell in the 7th row.

Parameters :
Name Type Optional Description
recordIndex number No
  • The index of the record/row
cellIndex number No
  • The index of the cell within the row
Returns : string

The ID

Public deleteRow
deleteRow(index: number)

Delete a row

Parameters :
Name Type Optional Description
index number No
  • The index of the row to delete
Returns : void
Public getColumns
getColumns(row: FormGroup)

Get the columns of a row

Parameters :
Name Type Optional Description
row FormGroup No
  • The row
Returns : FormArray

The array of columns

Public getHeaderCellName
getHeaderCellName(index: number)

Helper to get the name (content) of a header cell for the title attribute

Parameters :
Name Type Optional Description
index number No
  • The column index
Returns : string

The header cell content

Public onPaste
onPaste(event: ClipboardEvent, hasHeader?: boolean)

Handle paste event for standard content cell

Parameters :
Name Type Optional Description
event ClipboardEvent No
  • The paste event
hasHeader boolean Yes
  • Whether the content has a header row
Returns : void
Public onScroll
onScroll(event: WheelEvent)

Stop accidental page navigation when scrolling to the edges of the CSV form

Parameters :
Name Type Optional Description
event WheelEvent No
  • The scroll wheel event
Returns : void
Public resetTable
resetTable()

Reset the table to it's initial state

Returns : void
Public selectAdjacentCell
selectAdjacentCell(event: KeyboardEvent, currentCellId: string, previous?: boolean)

Select the next cell or previous cell

Parameters :
Name Type Optional Description
event KeyboardEvent No
  • The KeyboardEvent
currentCellId string No
  • The ID of the currently focused cell
previous boolean Yes
  • If the movement is forward or backward
Returns : void
Public selectCellInNextRow
selectCellInNextRow(currentCellId: string, up?: boolean)

Change focus to the cell below the current cell

Parameters :
Name Type Optional Description
currentCellId string No
  • The ID of the currently focused cell
up boolean Yes
  • The direction to move (up vs down)
Returns : void
Public updateAllRowIds
updateAllRowIds()

Update the form control for recordId on each row according to index.

Returns : void
Public updateErrors
updateErrors()

Expose ability to trigger error updates from the DOM

Returns : void

Properties

Protected _id
Type : string
Default value : this.uid
Public allErrors
Type : TsCSVFormError[] | null
Default value : null

Store a reference to all existing errors

Public fakeRowHeight
Type : string
Default value : '35px'

Define the static height needed in the DOM for the external rows

Public iconDelete
Default value : faTrash

Define the delete icon

Public layoutGap
Type : string
Default value : TS_SPACING.small[0]

Expose the flexbox layout gap

Public records
Type : TsCSVEntryRecord[]
Default value : []

Store records (rows)

Public recordsForm
Type : FormGroup
Default value : this.formBuilder.group({ headers: this.formBuilder.array([]), records: this.formBuilder.array([]), })

Initialize the records form with an empty array

Public tooManyRowsMessage
Type : string | null
Default value : null

Expose a validation message if too many rows are added

Protected uid
Default value : `ts-csv-entry-${nextUniqueId++}`

Define the default component ID

Accessors

headerCells
getheaderCells()

Get header cells as a form array

Returns : FormArray
rows
getrows()

Get rows as a form array

Returns : FormArray
columnCount
getcolumnCount()
setcolumnCount(value: number)

Set the number of columns

Parameters :
Name Type Optional
value number No
Returns : void
columnHeaders
getcolumnHeaders()
setcolumnHeaders(value)

Allow static headers to be set

Parameters :
Name Optional
value No
Returns : void
columnValidators
getcolumnValidators()
setcolumnValidators(value)

Define any column validators

Parameters :
Name Optional
value No
Returns : void
id
getid()
setid(value: string)

Define an ID for the component

Parameters :
Name Type Optional
value string No
Returns : void
maxRows
getmaxRows()
setmaxRows(value: number)

Set the maximum number of allowed rows

Parameters :
Name Type Optional
value number No
Returns : void
rowCount
getrowCount()
setrowCount(value: number)

Define the number of rows

Parameters :
Name Type Optional
value number No
Returns : void
validationMessages
getvalidationMessages()

Get all validation messages

NOTE: Currently this only supports a custom error message for URL validation. Other messages can be added when the need arises. FIXME: Find a way to use the existing ValidationMessagesService

Returns : [] | undefined
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  EventEmitter,
  Input,
  OnDestroy,
  OnInit,
  Output,
  ViewEncapsulation,
} from '@angular/core';
import {
  FormArray,
  FormBuilder,
  FormControl,
  FormGroup,
  ValidatorFn,
} from '@angular/forms';
import { faTrash } from '@fortawesome/pro-solid-svg-icons/faTrash';
import { debounceTime } from 'rxjs/operators';

import {
  coerceBooleanProperty,
  coerceNumberProperty,
  TsDocumentService,
  untilComponentDestroyed,
} from '@terminus/fe-utilities';
import { TS_SPACING } from '@terminus/ui-spacing';
import { stripControlCharacters } from '@terminus/ui-utilities';


/**
 * The structure for an individual row
 */
export interface TsCSVEntryRecord {
  recordId: number;
  columns: (string | null)[];
}

/**
 * The structure for the form
 */
export interface TsCSVFormContents {
  headers: string[];
  records: TsCSVEntryRecord[];
}

/**
 * The structure for a required error
 */
export interface TsCSVRequiredError {
  rowId: number;
  valid: boolean;
}

/**
 * The structure for a URL error
 */
export interface TsCSVUrlError {
  actual: string;
  rowId: number;
  valid: boolean;
}

/**
 * The structure for the error set
 */
export interface TsCSVFormError {
  // The control name
  control: string;
  required?: TsCSVRequiredError;
  url?: TsCSVUrlError;
}

/**
 * The structure for a row
 */
export interface TsCSVRow {
  recordId: FormControl;
  columns: FormArray;
}


/**
 * Unique ID for each instance
 */
let nextUniqueId = 0;
const DEFAULT_ROW_COUNT = 4;
const DEFAULT_COLUMN_COUNT = 2;
const DEFAULT_MAX_ROWS = 2000;
const DEFAULT_VALIDATION_MESSAGES_MAX = 6;


/**
 * A component that accepts manual or pasted CSV content and generates a file blob.
 *
 * @example
 * <ts-csv-entry
 *              columnCount="6"
 *              [columnHeaders]="arrayOfHeaders"
 *              [columnValidators]="arrayOfValidators"
 *              [footerDirection]="ltr"
 *              [fullWidth]="false"
 *              id="my-id"
 *              maxRows="1000"
 *              rowCount="12"
 *              outputFormat="csv"
 *              (blobGenerated)="handleTheFileBlob($event)"
 * ></ts-csv-entry>
 *
 * <example-url>https://getterminus.github.io/ui-demos-release/components/csv-entry</example-url>
 */
@Component({
  selector: 'ts-csv-entry',
  templateUrl: './csv-entry.component.html',
  styleUrls: ['./csv-entry.component.scss'],
  host: {
    'class': 'ts-csv-entry',
    '[class.c-csv-entry--full-width]': 'fullWidth',
    '[attr.id]': 'id',
  },
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  exportAs: 'tsCSVEntry',
})
export class TsCSVEntryComponent implements OnInit, OnDestroy {
  private originalColumnCount = DEFAULT_COLUMN_COUNT;
  /**
   * Define the default component ID
   */
  protected uid = `ts-csv-entry-${nextUniqueId++}`;

  /**
   * Define the static height needed in the DOM for the external rows
   */
  public fakeRowHeight = '35px';

  /**
   * Define the delete icon
   */
  public iconDelete = faTrash;

  /**
   * Expose the flexbox layout gap
   */
  public layoutGap: string = TS_SPACING.small[0];

  /**
   * Expose a validation message if too many rows are added
   */
  public tooManyRowsMessage: string | null = null;

  /**
   * Store records (rows)
   */
  public records: TsCSVEntryRecord[] = [];

  /**
   * Initialize the records form with an empty array
   */
  public recordsForm: FormGroup = this.formBuilder.group({
    headers: this.formBuilder.array([]),
    records: this.formBuilder.array([]),
  });

  /**
   * Store a reference to all existing errors
   */
  public allErrors: TsCSVFormError[] | null = null;

  /**
   * Get header cells as a form array
   */
  public get headerCells(): FormArray {
    return this.recordsForm.get('headers') as FormArray;
  }

  /**
   * Get rows as a form array
   */
  public get rows(): FormArray {
    return this.recordsForm.get('records') as FormArray;
  }

  /**
   * Set the number of columns
   *
   * @param value
   */
  @Input()
  public set columnCount(value: number) {
    this._columnCount = coerceNumberProperty(value, DEFAULT_COLUMN_COUNT);
  }
  public get columnCount(): number {
    return this._columnCount;
  }
  private _columnCount: number = DEFAULT_COLUMN_COUNT;

  /**
   * Allow static headers to be set
   *
   * @param value
   */
  @Input()
  public set columnHeaders(value: string[] | undefined) {
    this._columnHeaders = value;
    this.clearHeaderCells();
    this.addHeaders(this.columnCount, this.columnHeaders);
  }
  public get columnHeaders(): string[] | undefined {
    return this._columnHeaders;
  }
  private _columnHeaders: string[] | undefined;

  /**
   * Define any column validators
   *
   * @param value
   */
  @Input()
  public set columnValidators(value: ValidatorFn | null[]) {
    if (!value) {
      return;
    }

    this._columnValidators = value;
  }
  public get columnValidators(): ValidatorFn | null[] {
    return this._columnValidators;
  }
  private _columnValidators: ValidatorFn | null[] = [];

  /**
   * Define the layout direction for the footer
   */
  @Input()
  public footerDirection: 'ltr' | 'rtl' = 'ltr';

  /**
   * Allow full-width mode
   */
  @Input()
  public fullWidth = false;

  /**
   * Define an ID for the component
   *
   * @param value
   */
  @Input()
  public set id(value: string) {
    this._id = value || this.uid;
  }
  public get id(): string {
    return this._id;
  }
  protected _id: string = this.uid;

  /**
   * Set the maximum number of allowed rows
   *
   * @param value
   */
  @Input()
  public set maxRows(value: number) {
    this._maxRows = coerceNumberProperty(value, DEFAULT_MAX_ROWS);
  }
  public get maxRows(): number {
    return this._maxRows;
  }
  private _maxRows: number = DEFAULT_MAX_ROWS;

  /**
   * Define output to be CSV rather than TSV
   */
  @Input()
  public outputFormat: 'csv' | 'tsv' = 'tsv';

  /**
   * Define the number of rows
   *
   * @param value
   */
  @Input()
  public set rowCount(value: number) {
    this._rowCount = coerceNumberProperty(value, DEFAULT_ROW_COUNT);
  }
  public get rowCount(): number {
    return this._rowCount;
  }
  private _rowCount: number = DEFAULT_ROW_COUNT;

  /**
   * Emit the built file blob
   */
  @Output()
  public readonly blobGenerated: EventEmitter<Blob> = new EventEmitter();


  constructor(
    private formBuilder: FormBuilder,
    private changeDetectorRef: ChangeDetectorRef,
    private documentService: TsDocumentService,
  ) {}


  /**
   * Add columns to existing rows + header
   *
   * @param rows - The existing body rows
   * @param headerCells - The array of header cells
   * @param columnsToAdd - The number of columns to add
   */
  private static addColumnsToRows(rows: FormArray, headerCells: FormArray, columnsToAdd: number): void {
    // Add columns to body rows
    for (let i = 0; i < rows.length; i += 1) {
      const row: FormGroup = rows.controls[i] as FormGroup;
      // istanbul ignore else
      if (row) {
        const columns = row.controls.columns as FormArray;

        for (let j = 0; j < columnsToAdd; j += 1) {
          columns.controls.push(new FormControl());
        }
      }
    }

    // Add columns to header
    for (let k = 0; k < columnsToAdd; k += 1) {
      headerCells.controls.push(new FormControl());
    }
  }

  /**
   * Split pasted data into headers, rows, and columns
   *
   * @param content - The event content
   * @param hasHeaders - Whether the content has a header row
   * @returns An object containing all data
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private static splitContent(content: string, hasHeaders: boolean): Record<string, any> {
    const result: {headers: undefined|string[]; rows: undefined|string[]|string[][]} = {
      headers: undefined,
      rows: undefined,
    };
    const rows = content.split('\n');

    if (hasHeaders) {
      result.headers = rows[0].split('\t');
      result.rows = rows.slice(1, rows.length).map(r => r.split('\t'));
    } else {
      result.rows = rows.slice(0, rows.length).map(r => r.split('\t'));
    }

    return result;
  }

  /**
   * Initialize empty rows
   */
  public ngOnInit(): void {
    this.addRows(this.rowCount, this.columnCount);
    this.addHeaders(this.columnCount, this.columnHeaders);
    this.originalColumnCount = this.columnCount;

    this.recordsForm.valueChanges.pipe(
      // Let the form values 'settle' before we emit anything
      debounceTime(1),
      untilComponentDestroyed(this),
    ).subscribe(v => {
      const blob = this.generateBlob(v);
      this.blobGenerated.emit(blob);
    });
  }

  /**
   * Needed for `untilComponentDestroyed`
   */
  public ngOnDestroy(): void {}

  /**
   * Add rows to the form
   *
   * @param rowCount - The number of rows to add
   * @param columnCount - The number of columns each row should have
   * @param content - The column content
   * @param index - The row index
   */
  public addRows(rowCount = 1, columnCount: number = this.columnCount, content?: string[][], index?: number): void {
    if ((this.rows.length + rowCount) > this.maxRows) {
      // eslint-disable-next-line @typescript-eslint/no-magic-numbers
      const rowsThatDontFit = (rowCount === 1 ? 2 : rowCount) - ((this.rows.length + rowCount) - this.maxRows);
      this.tooManyRowsMessage =
        `Adding ${rowsThatDontFit} row${rowsThatDontFit > 1 ? 's' : ''} would exceed the maximum rows allowed (${this.maxRows}).`;
      return;
    }

    if (this.tooManyRowsMessage) {
      this.tooManyRowsMessage = null;
    }

    for (let i = 0; i < rowCount; i += 1) {
      const indexToInjectAt: number = (index === undefined ? this.rowCount : index) + i;
      const c: string[] | null = content ? content[i] : null;
      const createdRow: FormGroup = this.createRow(this.rows.length, c);

      if ((index !== undefined) && (index >= 0)) {
        this.rows.insert(indexToInjectAt, createdRow);
      } else {
        this.rows.push(createdRow);
      }
    }
  }

  /**
   * Get the columns of a row
   *
   * @param row - The row
   * @returns The array of columns
   */
  public getColumns(row: FormGroup): FormArray {
    return row.get('columns') as FormArray;
  }

  /**
   * Update the form control for recordId on each row according to index.
   */
  public updateAllRowIds(): void {
    for (let i = 0; i < this.rows.length; i += 1) {
      const row: FormGroup = this.rows.controls[i] as FormGroup;
      // istanbul ignore else
      if (row) {
        row.controls.recordId.setValue(i);
      }
    }
  }

  /**
   * Handle paste event for standard content cell
   *
   * @param event - The paste event
   * @param hasHeader - Whether the content has a header row
   */
  public onPaste(event: ClipboardEvent, hasHeader?: boolean): void {
    const eventContent = event.clipboardData?.getData('Text');
    if (!eventContent) {
      return;
    }

    // If the user is only pasting the content for a single cell - do nothing
    const isSingleCell = (eventContent.indexOf('\n') < 0) && (eventContent.indexOf('\t') < 0);
    if (isSingleCell) {
      return;
    }

    hasHeader = coerceBooleanProperty(hasHeader);
    const pastedRowId: number = parseInt((event.target as HTMLInputElement).id.split('X')[0].split('_')[2], 10);
    const content = TsCSVEntryComponent.splitContent(eventContent, hasHeader);
    const neededRows: number = content.rows.length;

    // If the paste was into a header cell, verify that header cell content doesn't already exist
    if (hasHeader) {
      this.clearAllRows();
      this.clearHeaderCells();
      this.addHeaders(content.headers.length, this.columnHeaders || content.headers);
      this.columnCount = content.headers.length;
      this.addRows(neededRows, content.headers.length, content.rows);
    } else {
      // Else: the paste happened in a body cell
      const pastedColumnCount = content.rows[0].length;

      // If more columns were pasted than currently exist, increase the column count
      if (pastedColumnCount > this.columnCount) {
        const numberOfMissingColumns = pastedColumnCount - this.columnCount;
        TsCSVEntryComponent.addColumnsToRows(this.rows, this.headerCells, numberOfMissingColumns);
        this.columnCount = pastedColumnCount;
      }
      this.deleteRow(pastedRowId);
      this.addRows(neededRows, content.rows.length, content.rows, pastedRowId);
    }

    this.updateAllRowIds();
    this.updateErrors();
    this.changeDetectorRef.detectChanges();
  }

  /**
   * Expose ability to trigger error updates from the DOM
   */
  public updateErrors(): void {
    this.allErrors = this.collectErrors();
  }

  /**
   * Helper to get the name (content) of a header cell for the title attribute
   *
   * @param index - The column index
   * @returns The header cell content
   */
  public getHeaderCellName(index: number): string {
    if (!this.headerCells || !this.headerCells.controls[index]) {
      return '';
    }
    return this.headerCells.controls[index].value;
  }

  /**
   * Stop accidental page navigation when scrolling to the edges of the CSV form
   *
   * @param event - The scroll wheel event
   */
  public onScroll(event: WheelEvent): void {
    if (!event) {
      return;
    }
    const dir: string = (event.deltaX < 0) ? 'right' : 'left';
    // NOTE: TypeScript doesn't believe `form` exists on `EventTarget`
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const targetEl: any = event.target;

    if (!targetEl) {
      return;
    }

    const borderSize = 2;
    const scrollRight: number =
      targetEl.form.scrollWidth - (parseInt(targetEl.form.offsetWidth, 10) + borderSize) - targetEl.form.scrollLeft;
    const scrollLeft = targetEl.form.scrollLeft;
    const stopRightScroll: boolean = (dir === 'right') && (scrollLeft < 1);
    const stopLeftScroll: boolean = (dir === 'left') && (scrollRight < 1);

    // If scrolling horizontally and at either edge, stop the scroll event
    if (event.deltaX !== 0 && (stopRightScroll || stopLeftScroll)) {
      event.preventDefault();
    }
  }

  /**
   * Change focus to the cell below the current cell
   *
   * @param currentCellId - The ID of the currently focused cell
   * @param up - The direction to move (up vs down)
   */
  public selectCellInNextRow(currentCellId: string, up?: boolean): void {
    if (!currentCellId) {
      return;
    }
    const [rowId, columnId]: string[] = currentCellId.split('X');
    const row: string = rowId.split('_')[2];
    const column: string = columnId.split('_')[1];
    const newId = `${this.uid}_r_${parseInt(row, 10) + (up ? -1 : 1)}Xc_${column}`;
    const input: HTMLElement | null = this.documentService.document.querySelector(`#${newId}`);

    if (input) {
      input.focus();
    } else {
      // Else we must be on the last row so we add one more
      this.addRows();
      this.changeDetectorRef.detectChanges();
      this.selectCellInNextRow(currentCellId);
    }
  }

  /**
   * Select the next cell or previous cell
   *
   * @param event - The KeyboardEvent
   * @param currentCellId - The ID of the currently focused cell
   * @param previous - If the movement is forward or backward
   */
  public selectAdjacentCell(event: KeyboardEvent, currentCellId: string, previous?: boolean): void {
    // Prevent native tabindex functionality
    event.preventDefault();
    previous = coerceBooleanProperty(previous);
    const [rowId, columnId]: string[] = currentCellId.split('X');
    const row: number = parseInt(rowId.split('_')[2], 10);
    const column: number = parseInt(columnId.split('_')[1], 10);
    const isFirstColumn: boolean = column === 0;
    const isLastColumn: boolean = column === (this.columnCount - 1);
    let newColumnNumber: number;
    let newRowNumber: number = row;
    // If first column, move to last column of previous row
    if (previous) {
      // Backward
      if (isFirstColumn) {
        newColumnNumber = this.columnCount - 1;
        newRowNumber += -1;
      } else {
        newColumnNumber = column - 1;
      }
    } else if (isLastColumn) {
      // Forward
      newColumnNumber = 0;
      newRowNumber += 1;
    } else {
      newColumnNumber = column + 1;
    }
    const newId = `${this.uid}_r_${newRowNumber}Xc_${newColumnNumber}`;
    const input: HTMLElement | null = this.documentService.document.querySelector(`#${newId}`);

    // istanbul ignore else
    if (input) {
      input.focus();
    }
  }

  /**
   * Create an ID for a cell. Format: `ts-csv-entry-{number}_r_7Xc_2` would be the 2nd cell in the 7th row.
   *
   * @param recordIndex - The index of the record/row
   * @param cellIndex - The index of the cell within the row
   * @returns The ID
   */
  public createId(recordIndex: number, cellIndex: number): string {
    return `${this.uid}_r_${recordIndex}Xc_${cellIndex}`;
  }

  /**
   * Collect all errors from the recordsForm and set to allErrors
   */
  public collectErrors(): TsCSVFormError[] | null {
    const group: FormArray | null = this.recordsForm.get('records') as FormArray;

    // istanbul ignore else
    if (group) {
      const errors = this.getFormErrors(group);

      // istanbul ignore else
      if (errors) {
        return Object.keys(errors).map(key => ({
          control: key,
          // De-duplicate the errors array
          [key]: errors[key].filter((el, i, arr) => arr.indexOf(el) === i),
        }));
      }
      return null;
    }
    return null;
  }

  /**
   * Get all validation messages
   *
   * NOTE: Currently this only supports a custom error message for URL validation. Other messages can be added when the need arises.
   * FIXME: Find a way to use the existing ValidationMessagesService
   *
   * @returns The array of validation messages
   */
  public get validationMessages(): string[] | undefined {
    if (!this.allErrors) {
      return undefined;
    }
    const messages: string[] = [];

    for (const errorObj of this.allErrors) {
      const name: string = errorObj.control;

      for (const error of errorObj[name]) {
        let message = '';
        // The ID is zero-based
        message += `<b>Row ${parseInt(error.rowId, 10) + 1}:</b> `;
        // istanbul ignore else
        if (name === 'url') {
          const maxItemLength = 20;
          const errorItem = (error.actual.length > maxItemLength) ? `${error.actual.slice(0, maxItemLength)  }...` : error.actual;
          message += `"${errorItem}" is not a valid URL.`;
        }
        // istanbul ignore else
        if (name === 'required') {
          message += `Content is required.`;
        }
        messages.push(message);
      }
    }

    // If more messages than allowed exist, truncate the list with a message
    if (messages.length > DEFAULT_VALIDATION_MESSAGES_MAX) {
      const count = messages.length - DEFAULT_VALIDATION_MESSAGES_MAX;
      messages.length = DEFAULT_VALIDATION_MESSAGES_MAX;
      messages.push(`and ${count} more errors...`);
    }

    return messages.length > 0 ? messages : undefined;
  }

  /**
   * Delete a row
   *
   * @param index - The index of the row to delete
   */
  public deleteRow(index: number): void {
    if (index === undefined || index === null || index < 0) {
      return;
    }

    this.rows.removeAt(index);
    this.updateAllRowIds();
    this.updateErrors();
  }

  /**
   * Reset the table to it's initial state
   */
  public resetTable(): void {
    this.clearAllRows();
    this.clearHeaderCells();
    this.columnCount = this.originalColumnCount;
    this.addRows(this.rowCount, this.columnCount);
    this.addHeaders(this.columnCount, this.columnHeaders);
    this.allErrors = null;
  }

  /**
   * Get all form errors from a FormGroup or FormArray
   *
   * NOTE: This external function and `result` object is needed since `getAllErrors` may be recursive
   *
   * @param form - The form
   * @returns An object containing all errors
   */
  private getFormErrors(form: FormGroup | FormArray): {required?: TsCSVRequiredError; url?: TsCSVUrlError} {
    const result: {required?: TsCSVRequiredError; url?: TsCSVUrlError} = {};
    this.getAllErrors(form, result);
    return result;
  }

  /**
   * Get all errors for the form
   *
   * @param form - The primary form group
   * @param result - The collection of errors
   * @param result.required
   * @param result.url
   * @returns An object containing all errors
   */
  private getAllErrors(form: FormGroup | FormArray, result: {required?: TsCSVRequiredError; url?: TsCSVUrlError}): void {
    const keys = Object.keys(form.controls);

    // eslint-disable-next-line @typescript-eslint/prefer-for-of
    for (let i = 0; i < keys.length; i += 1) {
      const ctrl = form.get(keys[i]);

      // istanbul ignore else
      if (ctrl) {
        const errors = (ctrl instanceof FormGroup || ctrl instanceof FormArray) ? this.getAllErrors(ctrl, result) : ctrl.errors;

        // istanbul ignore else
        if (errors) {
          // Get the record ID from the grandparent control
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          const grandparentControls: any = ctrl.parent.parent.controls;
          const rowId: number | undefined = grandparentControls.recordId
            ? grandparentControls.recordId.value /* istanbul ignore next - Unreachable */ : undefined;
          const errorKeys = Object.keys(errors);

          // eslint-disable-next-line @typescript-eslint/prefer-for-of
          for (let j = 0; j < errorKeys.length; j += 1) {
            const errorKey: string = errorKeys[j];
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            let error: Record<string, any> = errors[errorKeys[j]];

            // Angular built in required validator only returns a boolean
            if (typeof error === 'boolean') {
              error = { valid: false };
            }

            // If the rowId exists, add it to the errors object
            // istanbul ignore else
            if (rowId !== undefined) {
              error.rowId = rowId;
            }

            // Add this error to the result object
            if (result[errorKey]) {
              result[errorKey].push(error);
            } else {
              result[errorKey] = [error];
            }
          }
        }
      }
    }
  }

  /**
   * Clear all rows
   */
  private clearAllRows(): void {
    this.recordsForm.setControl('records', this.formBuilder.array([]));
  }

  /**
   * Clear header cells
   */
  private clearHeaderCells(): void {
    this.recordsForm.setControl('headers', this.formBuilder.array([]));
  }

  /**
   * Add header content to the form
   *
   * @param headerCount - The number of header cells
   * @param content - The cell's content
   */
  private addHeaders(headerCount: number, content?: string[]): void {
    for (let i = 0; i < headerCount; i += 1) {
      const value: string | null = (content && content[i]) ? content[i] : null;
      const ctrl = value ? new FormControl(value) : new FormControl();
      this.headerCells.setControl(i, ctrl);
    }
  }

  /**
   * Create a row
   *
   * @param id - The row's ID
   * @param content - The column's content
   * @returns The FormGroup
   */
  private createRow(id: number, content?: string[] | null): FormGroup {
    return this.formBuilder.group({
      recordId: new FormControl(id),
      columns: this.formBuilder.array(this.createColumns(this.columnCount, content)),
    });
  }

  /**
   * Create an array of columns
   *
   * @param count - The number of columns to create
   * @param content - An array of content to seed the columns with
   * @returns The array of form controls
   */
  private createColumns(count: number, content?: string[] | null): FormControl[] {
    const columns: FormControl[] = [];

    for (let i = 0; i < count; i += 1) {
      let value: string | null = (content && content[i]) ? content[i] : null;

      // Strip any control characters
      if (value) {
        value = stripControlCharacters(value);
      }

      const validator: ValidatorFn | null =
        this.columnValidators ? this.columnValidators[i] /* istanbul ignore next - Unreachable */ : null;
      columns.push(new FormControl(value, validator));
    }

    return columns;
  }

  /**
   * Generate a File blob from the form contents
   *
   * @param content - The recordForm content
   * @returns The File blob
   */
  private generateBlob(content: TsCSVFormContents): Blob {
    const prefix = 'data:text/csv;charset=utf-8,';
    const headers = `${content.headers.join('\t')  }\r\n`;
    // Encapsulate content with quotes and escape any existing quotes
    const rows =
      `${content.records.map(v => v.columns.map(column => (column ? `"${column.replace(/"/g, '""')}"` : '')).join('\t'))
        .join('\r\n')  }\r\n`;
    let joined: string = prefix + headers + rows;
    // istanbul ignore else
    if (this.outputFormat === 'csv') {
      joined = JSON.stringify(joined).replace(/\\t/g, ',');
    }

    return new Blob([joined], { type: 'text/csv' });
  }
}

result-matching ""

    No results matching ""