Recursos gratis

Componente gratis para importar CSV en Salesforce (LWC + Apex)

Permite que tus usuarios suban un CSV, lo revisen, vean los problemas antes de importar y reciban el resultado fila por fila. Funciona con cualquier objeto.

Gratis y de código abierto (licencia MIT). El .zip es un proyecto de Salesforce DX listo para desplegar.

El Data Import Wizard funciona bien para administradores. Pero cuando un equipo de operaciones carga el mismo tipo de archivo cada semana, quiere un botón en la página que ya usa, una vista previa de lo que va a pasar y una lista clara de lo que falló.

Este componente hace eso. Colócalo en cualquier página Lightning, indícale el objeto y tus usuarios podrán:

  • Elegir un archivo CSV desde su computadora
  • Ver las primeras 50 filas, con los problemas marcados antes de guardar nada
  • Importar las filas válidas y ver cuáles fallaron y por qué

No tiene dependencias: ni static resources ni paquetes administrados. Todo el código es libre para usar, modificar y desplegar en tu org. Los textos de la interfaz están en inglés; tradúcelos o muévelos a Custom Labels si tu equipo trabaja en español.

Cómo funciona

El navegador lee y valida. El CSV se procesa en JavaScript, así que el usuario ve al instante si faltan columnas o valores obligatorios. Solo las filas válidas llegan a Apex.

Apex guarda. Convierte cada valor al tipo de campo correcto, respeta la seguridad a nivel de campo con Security.stripInaccessible y usa Database.insert con allOrNone = false para que una fila con error no detenga a las demás.

Los encabezados del archivo deben ser nombres API de campos, por ejemplo FirstName, LastName, Email o Mi_Campo__c. Las columnas que no coinciden con un campo se ignoran.

El controlador en Apex

CsvImportController.cls
public with sharing class CsvImportController {
    private static final Integer MAX_ROWS = 2000;

    public class RowResult {
        @AuraEnabled public Integer rowNumber;
        @AuraEnabled public Boolean success = false;
        @AuraEnabled public String recordId;
        @AuraEnabled public String message;
    }

    @AuraEnabled
    public static List<RowResult> importRecords(String objectApiName, String rowsJson) {
        Schema.SObjectType objectType = Schema.getGlobalDescribe().get(objectApiName);
        if (objectType == null) {
            throw error('Unknown object: ' + objectApiName);
        }
        Schema.DescribeSObjectResult objectInfo = objectType.getDescribe();
        if (!objectInfo.isCreateable()) {
            throw error('You do not have permission to create ' + objectInfo.getLabelPlural() + '.');
        }
        Map<String, Schema.SObjectField> fields = objectInfo.fields.getMap();

        List<Object> rows = (List<Object>) JSON.deserializeUntyped(rowsJson);
        if (rows.size() > MAX_ROWS) {
            throw error('Import up to ' + MAX_ROWS + ' rows at a time.');
        }

        List<RowResult> results = new List<RowResult>();
        List<SObject> records = new List<SObject>();
        List<Integer> resultIndexes = new List<Integer>();

        for (Integer i = 0; i < rows.size(); i++) {
            Map<String, Object> row = (Map<String, Object>) rows[i];
            RowResult result = new RowResult();
            result.rowNumber = i + 1;
            results.add(result);

            SObject record = objectType.newSObject();
            try {
                for (String header : row.keySet()) {
                    Schema.SObjectField field = fields.get(header.trim());
                    if (field == null) {
                        continue; // not a field on this object
                    }
                    Schema.DescribeFieldResult fieldInfo = field.getDescribe();
                    if (!fieldInfo.isCreateable()) {
                        continue;
                    }
                    Object raw = row.get(header);
                    String value = raw == null ? null : String.valueOf(raw).trim();
                    record.put(field, convert(value, fieldInfo.getType()));
                }
                records.add(record);
                resultIndexes.add(results.size() - 1);
            } catch (Exception e) {
                result.message = 'Could not read a value: ' + e.getMessage();
            }
        }

        // Drop any field the running user cannot create, instead of failing.
        List<SObject> safeRecords = Security.stripInaccessible(AccessType.CREATABLE, records).getRecords();
        List<Database.SaveResult> saves = Database.insert(safeRecords, false);

        for (Integer i = 0; i < saves.size(); i++) {
            RowResult result = results[resultIndexes[i]];
            result.success = saves[i].isSuccess();
            if (result.success) {
                result.recordId = saves[i].getId();
            } else {
                result.message = saves[i].getErrors()[0].getMessage();
            }
        }
        return results;
    }

    private static Object convert(String value, Schema.DisplayType fieldType) {
        if (String.isBlank(value)) {
            return null;
        }
        switch on fieldType {
            when BOOLEAN {
                return value.equalsIgnoreCase('true') || value == '1' || value.equalsIgnoreCase('yes');
            }
            when INTEGER {
                return Integer.valueOf(value);
            }
            when LONG {
                return Long.valueOf(value);
            }
            when DOUBLE, CURRENCY, PERCENT {
                return Decimal.valueOf(value.replace(',', ''));
            }
            when DATE {
                return Date.valueOf(value); // yyyy-mm-dd
            }
            when DATETIME {
                return (Datetime) JSON.deserialize('"' + value + '"', Datetime.class); // ISO 8601
            }
            when else {
                return value;
            }
        }
    }

    private static AuraHandledException error(String message) {
        AuraHandledException e = new AuraHandledException(message);
        e.setMessage(message);
        return e;
    }
}

Algunas decisiones que conviene conocer:

  • with sharing hace que cada usuario solo toque lo que sus reglas de compartición permiten, y stripInaccessible cubre la seguridad a nivel de campo.
  • Los números pierden el separador de miles (1,250.50 se vuelve 1250.50). Si tus usuarios escriben decimales con coma, cambia esa línea.
  • Las fechas deben ir como aaaa-mm-dd y las fechas con hora en ISO 8601. Así no hay que adivinar si 03/04 es marzo o abril.

La clase de prueba

CsvImportControllerTest.cls
@IsTest
private class CsvImportControllerTest {
    @IsTest
    static void importsValidRowsAndReportsFailures() {
        String rowsJson = JSON.serialize(new List<Map<String, Object>>{
            new Map<String, Object>{ 'LastName' => 'Rivera', 'Email' => 'ana.rivera@example.com' },
            new Map<String, Object>{ 'LastName' => '', 'Email' => 'no.name@example.com' }
        });

        Test.startTest();
        List<CsvImportController.RowResult> results = CsvImportController.importRecords('Contact', rowsJson);
        Test.stopTest();

        Assert.areEqual(2, results.size());
        Assert.isTrue(results[0].success, 'First row should be saved');
        Assert.isFalse(results[1].success, 'LastName is required on Contact');
        Assert.isNotNull(results[1].message);
        Assert.areEqual(1, [SELECT COUNT() FROM Contact]);
    }

    @IsTest
    static void rejectsUnknownObject() {
        try {
            CsvImportController.importRecords('Not_A_Real_Object__c', '[]');
            Assert.fail('Expected an error for an unknown object');
        } catch (AuraHandledException e) {
            Assert.isTrue(e.getMessage().contains('Unknown object'));
        }
    }
}

El componente

csvImporter.html
<template>
    <lightning-card title="CSV import" icon-name="doctype:csv">
        <div class="slds-p-horizontal_medium">
            <lightning-input
                type="file"
                label="Choose a CSV file"
                accept=".csv"
                onchange={handleFile}
            ></lightning-input>
            <p class="slds-text-color_weak slds-m-top_x-small">
                Use field API names as column headers. Required columns: {requiredText}
            </p>

            <template lwc:if={fileError}>
                <p class="slds-text-color_error slds-m-top_small">{fileError}</p>
            </template>

            <template lwc:if={hasMissingColumns}>
                <p class="slds-text-color_error slds-m-top_small">
                    This file is missing required columns: {missingColumnsText}
                </p>
            </template>

            <template lwc:if={hasRows}>
                <p class="slds-m-vertical_small">
                    {fileName}: {rowCount} rows, {invalidCount} with problems. Showing up to 50.
                </p>
                <div class="preview">
                    <lightning-datatable
                        key-field="_row"
                        data={previewRows}
                        columns={columns}
                        hide-checkbox-column
                    ></lightning-datatable>
                </div>
                <lightning-button
                    class="slds-m-top_small"
                    variant="brand"
                    label={importLabel}
                    disabled={importDisabled}
                    onclick={handleImport}
                ></lightning-button>
            </template>

            <template lwc:if={isImporting}>
                <lightning-spinner alternative-text="Importing"></lightning-spinner>
            </template>

            <template lwc:if={results}>
                <div class="slds-m-top_medium">
                    <p class="slds-text-heading_small">{successCount} imported, {failureCount} failed</p>
                    <ul class="slds-list_dotted slds-m-top_x-small">
                        <template for:each={failures} for:item="failure">
                            <li key={failure.key}>Row {failure.row}: {failure.message}</li>
                        </template>
                    </ul>
                </div>
            </template>
        </div>
    </lightning-card>
</template>
csvImporter.js
import { LightningElement, api } from 'lwc';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import importRecords from '@salesforce/apex/CsvImportController.importRecords';

const PREVIEW_LIMIT = 50;
const MAX_ROWS = 2000;

export default class CsvImporter extends LightningElement {
    @api targetObject = 'Contact';
    @api requiredFields = 'LastName';

    fileName;
    fileError;
    headers = [];
    rows = [];
    results;
    isImporting = false;

    get required() {
        return (this.requiredFields || '')
            .split(',')
            .map((f) => f.trim())
            .filter(Boolean);
    }
    get requiredText() {
        return this.required.join(', ') || 'none';
    }
    get missingColumns() {
        const present = this.headers.map((h) => h.toLowerCase());
        return this.required.filter((f) => !present.includes(f.toLowerCase()));
    }
    get hasMissingColumns() {
        return this.headers.length > 0 && this.missingColumns.length > 0;
    }
    get missingColumnsText() {
        return this.missingColumns.join(', ');
    }
    get hasRows() {
        return this.rows.length > 0;
    }
    get rowCount() {
        return this.rows.length;
    }
    get validRows() {
        return this.rows.filter((r) => !r._issue);
    }
    get invalidCount() {
        return this.rows.length - this.validRows.length;
    }
    get previewRows() {
        return this.rows.slice(0, PREVIEW_LIMIT);
    }
    get columns() {
        return [
            { label: 'Row', fieldName: '_row', type: 'number', initialWidth: 70 },
            { label: 'Check', fieldName: '_issue', initialWidth: 200 },
            ...this.headers.map((h) => ({ label: h, fieldName: h }))
        ];
    }
    get importDisabled() {
        return this.isImporting || this.hasMissingColumns || this.validRows.length === 0;
    }
    get importLabel() {
        return `Import ${this.validRows.length} rows`;
    }
    get successCount() {
        return this.results ? this.results.filter((r) => r.success).length : 0;
    }
    get failureCount() {
        return this.failures.length;
    }
    get failures() {
        return (this.results || [])
            .filter((r) => !r.success)
            .map((r) => ({ key: `f-${r.csvRow}`, row: r.csvRow, message: r.message }));
    }

    handleFile(event) {
        const file = event.target.files && event.target.files[0];
        this.reset();
        if (!file) {
            return;
        }
        if (!file.name.toLowerCase().endsWith('.csv')) {
            this.fileError = 'Choose a file that ends in .csv.';
            return;
        }
        this.fileName = file.name;
        const reader = new FileReader();
        reader.onload = () => this.loadCsv(reader.result);
        reader.onerror = () => {
            this.fileError = 'The file could not be read. Try saving it again as CSV (UTF-8).';
        };
        reader.readAsText(file);
    }

    loadCsv(text) {
        const table = parseCsv(text.replace(/^/, ''));
        const body = table.slice(1).filter((cells) => cells.some((c) => c.trim() !== ''));
        if (table.length === 0 || body.length === 0) {
            this.fileError = 'The file needs a header row and at least one data row.';
            return;
        }
        if (body.length > MAX_ROWS) {
            this.fileError = `This file has ${body.length} rows. Split it into files of ${MAX_ROWS} rows or fewer.`;
            return;
        }
        this.headers = table[0].map((h) => h.trim());
        this.rows = body.map((cells, index) => {
            const row = { _row: index + 1 };
            this.headers.forEach((h, i) => {
                row[h] = (cells[i] || '').trim();
            });
            const empty = this.required.filter((f) => {
                const header = this.headers.find((h) => h.toLowerCase() === f.toLowerCase());
                return header && !row[header];
            });
            row._issue = empty.length ? `Missing ${empty.join(', ')}` : '';
            return row;
        });
    }

    async handleImport() {
        const valid = this.validRows;
        const payload = valid.map((r) => {
            const record = {};
            this.headers.forEach((h) => {
                record[h] = r[h];
            });
            return record;
        });
        this.isImporting = true;
        try {
            const results = await importRecords({
                objectApiName: this.targetObject,
                rowsJson: JSON.stringify(payload)
            });
            // Map Apex row numbers back to the row numbers in the file.
            this.results = results.map((r) => ({ ...r, csvRow: valid[r.rowNumber - 1]._row }));
            this.dispatchEvent(
                new ShowToastEvent({
                    title: 'Import finished',
                    message: `${this.successCount} imported, ${this.failures.length} failed.`,
                    variant: this.failures.length ? 'warning' : 'success'
                })
            );
        } catch (error) {
            this.fileError = (error && error.body && error.body.message) || 'The import failed.';
        } finally {
            this.isImporting = false;
        }
    }

    reset() {
        this.fileName = undefined;
        this.fileError = undefined;
        this.headers = [];
        this.rows = [];
        this.results = undefined;
    }
}

// Parses CSV text into an array of rows. Handles quoted values,
// commas and line breaks inside quotes, and "" as an escaped quote.
function parseCsv(text) {
    const rows = [];
    let row = [];
    let field = '';
    let inQuotes = false;
    for (let i = 0; i < text.length; i++) {
        const c = text[i];
        if (inQuotes) {
            if (c === '"' && text[i + 1] === '"') {
                field += '"';
                i++;
            } else if (c === '"') {
                inQuotes = false;
            } else {
                field += c;
            }
        } else if (c === '"') {
            inQuotes = true;
        } else if (c === ',') {
            row.push(field);
            field = '';
        } else if (c === '\n' || c === '\r') {
            if (c === '\r' && text[i + 1] === '\n') {
                i++;
            }
            row.push(field);
            rows.push(row);
            row = [];
            field = '';
        } else {
            field += c;
        }
    }
    if (field !== '' || row.length) {
        row.push(field);
        rows.push(row);
    }
    return rows;
}
csvImporter.css
.preview {
    height: 320px;
}
csvImporter.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>62.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>CSV Import</masterLabel>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
        <target>lightning__RecordPage</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightning__AppPage,lightning__HomePage,lightning__RecordPage">
            <property name="targetObject" type="String" label="Object API name" default="Contact"/>
            <property name="requiredFields" type="String" label="Required columns (comma-separated)" default="LastName"/>
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

Cómo agregarlo a una página

  1. Despliega los cuatro archivos del componente y las dos clases de Apex con Salesforce CLI o tu proceso habitual.
  2. Da acceso a CsvImportController a los usuarios que importarán archivos, mediante un permission set.
  3. Abre Lightning App Builder, arrastra CSV Import a una página y configura el objeto y las columnas obligatorias.

La propiedad se llama targetObject y no objectApiName a propósito: en una página de registro, Salesforce llena objectApiName con el objeto de la página, y eso reemplazaría tu configuración.

Ideas para tu versión

  • Relacionar por External ID y usar Database.upsert para actualizar registros existentes en lugar de duplicarlos.
  • Relacionar las filas con el registro actual asignando un lookup a recordId antes de guardar.
  • Aceptar archivos de Excel agregando la librería SheetJS como static resource.

Si quieres ayuda para adaptarlo a tu org, o tienes un proceso de importación que ya rebasó las hojas de cálculo, escríbenos.

¿Necesitas adaptarlo a tu org?

Construimos y mantenemos componentes como este para clientes cada mes.

Contáctanos Más recursos gratis