Parsing a CSV file using a LWC
There are a couple of standard options to upload a CSV file and create records, but sometimes depending on the business requirements you will need to have a custom and intuitive solution to upload a file and run a custom process to create/update records. This post will cover how to upload a CSV, parse the contents of the file and create Account records, displaying some simple feedback for the user at the end. This is a really simple LWC to parse a CSV file and create records and it can be a starting point to a more robust solution.
I’ve seen some posts explaining how to parse CSV files and there were different solutions: parsing the CSV using an Apex method, aura components, LWC, Javascript methods doing some simple parse operations. In our case, we will use an existing library to parse the file and make our lives easier: Papa Parse. This library has good methods and configuration options to parse the CSV file. You can choose what is going to be the delimiter char, whether the file has a header or not, if the columns are quoted and by which char, and so on.
As this library accepts a File object as an input, we will use a lightning-input with the type “file” to get the file from the user. After selecting the file, the Papa parser will parse the file and return a list of objects, each object being a row from the file. To use the library, it’s necessary to upload the .js file as a static resource and import it to the LWC:
import {LightningElement, track} from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';import PARSER from '@salesforce/resourceUrl/PapaParse';export default class ImportAccounts extends LightningElement {
parserInitialized = false;renderedCallback() {
if(!this.parserInitialized){
loadScript(this, PARSER)
.then(() => {
this.parserInitialized = true;
})
.catch(error => console.error(error));
}
}
}
Here is the HTML part of the LWC:
<template>
<lightning-card title="Import Accounts" icon-name="standard:account">
<lightning-spinner if:true={loading}></lightning-spinner>
<div class="slds-p-around_medium">
<lightning-input type="file"
label="CSV file"
multiple="false"
accept=".csv"
onchange={handleInputChange}></lightning-input>
</div>
<template if:true={rows.length}>
<div class="slds-p-around_medium">
<lightning-datatable key-field="key"
hide-checkbox-column
data={rows}
columns={columns}></lightning-datatable>
<div class="slds-p-around_small slds-align_absolute-center">
<lightning-button variant="neutral"
label="Cancel"
title="Cancel" onclick={cancel}
class="slds-m-left_x-small"></lightning-button>
<lightning-button variant="brand"
label="Create"
title="Create" onclick={createAccounts}
class="slds-m-left_x-small"></lightning-button>
</div>
</div>
</template>
</lightning-card>
</template>
When selecting the file, the following function will be executed to parse the CSV file:
handleInputChange(event){
if(event.target.files.length > 0){
const file = event.target.files[0];
this.loading = true;
Papa.parse(file, {
quoteChar: '"',
header: 'true',
complete: (results) => {
this._rows = results.data;
this.loading = false;
},
error: (error) => {
console.error(error);
this.loading = false;
}
})
}
}
The complete attribute is a callback function that receives an array of objects with the rows from the CSV file. With this, we have a getter to display the rows in a lightning-datatable so the user can have a preview of the rows. We are not limiting the number of records displayed, but consider doing this if you are going to deal with a large number of records. The getter:
get rows(){
if(this._rows){
return this._rows.map((row, index) => {
row.key = index;
if(this.results[index]){
row.result = this.results[index].id || this.results[index].error;
}
return row;
})
} return [];
}
Don’t mind the if(this.results[index]) as of now, its explanation comes later. There is a button to create the account records loaded from the CSV and displayed on the datatable. It will call the following function:
createAccounts(){
const accountsToCreate = this.rows.map(row => {
const fields = {};
fields[NAME_FIELD.fieldApiName] = row.AccountName;
fields[DESCRIPTION_FIELD.fieldApiName] = row.Description;
const recordInput = { apiName: ACCOUNT_OBJECT.objectApiName, fields };
return createRecord(recordInput);
}); if(accountsToCreate.length){
this.loading = true;
Promise.allSettled(accountsToCreate)
.then(results => this._results = results)
.catch(error => console.error(error))
.finally(() => this.loading = false);
}
}
Here we are using the createRecord from the lightning/uiRecordApi to create the account records and a Promise.allSettled to have all the records created and get the results to be displayed on the datatable together with the records. You should have noticed that we are using (and defining) only two columns to create the records (Name and Description fields). You can change this part to accept more columns if necessary. I will improve this component in the future to have the columns configurable by the user.
We have defined a getter to transform the results in the way that we want and then use this info together with the rows so the data is displayed to the user. With the successfully created records, we display the record Ids. When there is an error, we display the error message.
get results(){
if(this._results){
return this._results.map(r => {
const result = {};
result.success = r.status === 'fulfilled';
result.id = result.success ? r.value.id : undefined;
result.error = !result.success ? r.reason.body.message : undefined;
return result;
});
} return [];
}
Here is the entire JS controller:
import {LightningElement, track} from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';
import { createRecord } from 'lightning/uiRecordApi';import PARSER from '@salesforce/resourceUrl/PapaParse';import ACCOUNT_OBJECT from '@salesforce/schema/Account';
import NAME_FIELD from '@salesforce/schema/Account.Name';
import DESCRIPTION_FIELD from '@salesforce/schema/Account.Description';export default class ImportAccounts extends LightningElement {
parserInitialized = false;
loading = false;
@track _results;
@track _rows;
get columns(){
const columns = [
{ label: 'Account Name', fieldName: 'AccountName' },
{ label: 'Description', fieldName: 'Description' }
]; if(this.results.length){
columns.push({ label: 'Result',fieldName: 'result' });
} return columns;
} get rows(){
if(this._rows){
return this._rows.map((row, index) => {
row.key = index;
if(this.results[index]){
row.result = this.results[index].id || this.results[index].error;
}
return row;
})
} return [];
} get results(){
if(this._results){
return this._results.map(r => {
const result = {};
result.success = r.status === 'fulfilled';
result.id = result.success ? r.value.id : undefined;
result.error = !result.success ? r.reason.body.message : undefined;
return result;
});
} return [];
} renderedCallback() {
if(!this.parserInitialized){
loadScript(this, PARSER)
.then(() => {
this.parserInitialized = true;
})
.catch(error => console.error(error));
}
} handleInputChange(event){
if(event.target.files.length > 0){
const file = event.target.files[0];
this.loading = true;
Papa.parse(file, {
quoteChar: '"',
header: 'true',
complete: (results) => {
this._rows = results.data;
this.loading = false;
},
error: (error) => {
console.error(error);
this.loading = false;
}
})
}
} createAccounts(){
const accountsToCreate = this.rows.map(row => {
const fields = {};
fields[NAME_FIELD.fieldApiName] = row.AccountName;
fields[DESCRIPTION_FIELD.fieldApiName] = row.Description;
const recordInput = { apiName: ACCOUNT_OBJECT.objectApiName, fields };
return createRecord(recordInput);
}); if(accountsToCreate.length){
this.loading = true;
Promise.allSettled(accountsToCreate)
.then(results => this._results = results)
.catch(error => console.error(error))
.finally(() => this.loading = false);
}
} cancel(){
this._rows = undefined;
this._results = undefined;
}
}
In my next blog post, I will improve this component to dynamically get the fields from the Account object and let the user match the columns from the CSV file with the fields. Stay tuned!