CSV Import end to end

This commit is contained in:
Martin McKeaveney 2020-10-02 16:16:06 +01:00
parent c7a917ce40
commit 5e86076530
9 changed files with 301 additions and 83 deletions

View File

@ -3,6 +3,7 @@
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import { import {
Body,
DropdownMenu, DropdownMenu,
Button, Button,
Heading, Heading,
@ -11,19 +12,20 @@
Select, Select,
Dropzone, Dropzone,
} from "@budibase/bbui" } from "@budibase/bbui"
import TableDataImport from "./TableDataImport.svelte"
import api from "builderStore/api" import api from "builderStore/api"
import analytics from "analytics" import analytics from "analytics"
let anchor let anchor
let dropdown let dropdown
let name let name
let recordImport = {} let dataImport
async function saveTable() { async function saveTable() {
const model = await backendUiStore.actions.models.save({ const model = await backendUiStore.actions.models.save({
name, name,
schema: recordImport.schema || {}, schema: dataImport.schema || {},
recordImport, dataImport,
}) })
notifier.success(`Table ${name} created successfully.`) notifier.success(`Table ${name} created successfully.`)
$goto(`./model/${model._id}`) $goto(`./model/${model._id}`)
@ -36,38 +38,6 @@
name = "" name = ""
dropdown.hide() dropdown.hide()
} }
function handleFileTooLarge(fileSizeLimit) {
notifier.danger(
`Files cannot exceed ${fileSizeLimit /
BYTES_IN_MB}MB. Please try again with smaller files.`
)
}
async function processFiles(fileList) {
const fileArray = Array.from(fileList)
let data = new FormData()
for (var i = 0; i < fileList.length; i++) {
data.append("file", fileList[i])
}
const response = await fetch("/api/csv/validate", {
method: "POST",
body: data,
headers: {
Accept: "application/json",
},
})
recordImport = await response.json()
if (response.status !== 200) {
notifier.danger("CSV Invalid, please try another CSV file")
return []
}
return fileArray.map(file => ({ ...file, extension: "csv" }))
}
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
@ -76,21 +46,27 @@
<DropdownMenu bind:this={dropdown} {anchor} align="left"> <DropdownMenu bind:this={dropdown} {anchor} align="left">
<div class="container"> <div class="container">
<h5>Create Table</h5> <h5>Create Table</h5>
<Body grey small>Table Name</Body>
<Input <Input
data-cy="table-name-input" data-cy="table-name-input"
placeholder="Table Name" placeholder="Table Name"
thin thin
bind:value={name} /> bind:value={name} />
<Heading grey extraSmall>Create Table from CSV (Optional)</Heading> <Body grey small>Create Table from CSV (Optional)</Body>
<Dropzone {handleFileTooLarge} {processFiles} /> <TableDataImport bind:dataImport />
</div> </div>
<footer> <footer>
<div class="button-margin-3"> <div class="button-margin-3">
<Button secondary on:click={onClosed}>Cancel</Button> <Button secondary on:click={onClosed}>Cancel</Button>
</div> </div>
<div class="button-margin-4"> <div class="button-margin-4">
<Button primary on:click={saveTable}>Save</Button> <Button
disabled={!name || !dataImport.valid}
primary
on:click={saveTable}>
Save
</Button>
</div> </div>
</footer> </footer>
</DropdownMenu> </DropdownMenu>

View File

@ -0,0 +1,189 @@
<script>
import { Heading, Body, Button, Select } from "@budibase/bbui"
import { notifier } from "builderStore/store/notifications"
import { FIELDS } from "constants/backend"
const BYTES_IN_KB = 1000
const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 1
export let files = []
export let dataImport = {
valid: true,
schema: {},
}
let parseResult
$: schema = parseResult && parseResult.schema
$: valid =
schema && Object.keys(schema).every(column => schema[column].success)
$: dataImport = {
valid,
schema: buildModelSchema(schema),
path: files.length && files[0].path,
}
function buildModelSchema(schema) {
const modelSchema = {}
for (let key in schema) {
const type = schema[key].type
modelSchema[key] = {
name: key,
type,
constraints: FIELDS[type.toUpperCase()].constraints,
}
}
return modelSchema
}
async function validateCSV() {
const response = await fetch("/api/models/csv/validate", {
method: "POST",
body: JSON.stringify({
file: files[0],
schema: schema || {},
}),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
})
parseResult = await response.json()
if (response.status !== 200) {
notifier.danger("CSV Invalid, please try another CSV file")
return []
}
}
async function handleFile(evt) {
const fileArray = Array.from(evt.target.files)
const filesToProcess = fileArray.map(({ name, path, size }) => ({
name,
path,
size,
}))
if (filesToProcess.some(file => file.size >= FILE_SIZE_LIMIT)) {
notifier.danger(
`Files cannot exceed ${FILE_SIZE_LIMIT /
BYTES_IN_MB}MB. Please try again with smaller files.`
)
return
}
files = filesToProcess
await validateCSV()
}
function omitColumn(columnName) {
parsers[columnName] = PARSERS.omit
}
const handleTypeChange = column => evt => {
schema[column].type = evt.target.value
validateCSV()
}
</script>
<div class="dropzone">
<input id="file-upload" accept=".csv" type="file" on:change={handleFile} />
<label for="file-upload">
{#if files[0]}{files[0].name}{:else}Upload{/if}
</label>
</div>
<div class="schema-fields">
{#if schema}
{#each Object.keys(schema) as columnName}
<div class="field">
<span>{columnName}</span>
<Select
secondary
thin
bind:value={schema[columnName].type}
on:change={handleTypeChange(columnName)}>
<option value={'string'}>Text</option>
<option value={'number'}>Number</option>
<option value={'datetime'}>Date</option>
</Select>
<span
class:success={schema[columnName].success}
class:error={!schema[columnName].success}>
{schema[columnName].success ? 'Success' : 'Failure'}
</span>
<i
class="omit-button ri-close-circle-fill"
on:click={() => omitColumn(columnName)} />
</div>
{/each}
{/if}
</div>
<style>
.dropzone {
text-align: center;
display: flex;
align-items: center;
flex-direction: column;
border-radius: 10px;
transition: all 0.3s;
}
.success {
color: green;
}
.error {
color: red;
}
input[type="file"] {
display: none;
}
label {
font-family: var(--font-sans);
cursor: pointer;
font-weight: 600;
box-sizing: border-box;
overflow: hidden;
border-radius: var(--border-radius-s);
color: var(--white);
padding: var(--spacing-s) var(--spacing-l);
transition: all 0.2s ease 0s;
display: inline-flex;
text-rendering: optimizeLegibility;
min-width: auto;
outline: none;
font-feature-settings: "case" 1, "rlig" 1, "calt" 0;
-webkit-box-align: center;
user-select: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin-top: 10px;
width: 100%;
border: solid 1.5px var(--ink);
background-color: var(--ink);
}
/* .schema-fields {
} */
.omit-button {
font-size: 1.2em;
color: var(--grey-7);
cursor: pointer;
}
.field {
display: grid;
grid-template-columns: repeat(4, 1fr);
margin-top: var(--spacing-m);
align-items: center;
grid-gap: var(--spacing-m);
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -1,5 +1,6 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const newid = require("../../db/newid") const newid = require("../../db/newid")
const csvParser = require("../../utilities/csvParser")
exports.fetch = async function(ctx) { exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)
@ -19,7 +20,7 @@ exports.find = async function(ctx) {
exports.save = async function(ctx) { exports.save = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)
const { recordImport, ...rest } = ctx.request.body const { dataImport, ...rest } = ctx.request.body
const modelToSave = { const modelToSave = {
type: "model", type: "model",
_id: newid(), _id: newid(),
@ -86,15 +87,13 @@ exports.save = async function(ctx) {
} }
await db.put(designDoc) await db.put(designDoc)
if (recordImport && recordImport.path) { if (dataImport && dataImport.path) {
// Populate the table with records imported from CSV in a bulk update // Populate the table with records imported from CSV in a bulk update
const csv = require("csvtojson") const data = await csvParser.transform(dataImport)
const json = await csv().fromFile(recordImport.path)
const records = json.map(record => ({ for (let row of data) row.modelId = modelToSave._id
...record,
modelId: modelToSave._id, await db.bulkDocs(data)
}))
await db.bulkDocs(records)
} }
ctx.status = 200 ctx.status = 200
@ -135,3 +134,12 @@ exports.destroy = async function(ctx) {
ctx.status = 200 ctx.status = 200
ctx.message = `Model ${ctx.params.modelId} deleted.` ctx.message = `Model ${ctx.params.modelId} deleted.`
} }
exports.validateCSVSchema = async function(ctx) {
const { file, schema = {} } = ctx.request.body
const result = await csvParser.parse(file.path, schema)
ctx.body = {
schema: result,
path: file.path,
}
}

View File

@ -5,7 +5,6 @@ const fetch = require("node-fetch")
const fs = require("fs") const fs = require("fs")
const uuid = require("uuid") const uuid = require("uuid")
const AWS = require("aws-sdk") const AWS = require("aws-sdk")
const csv = require("csvtojson")
const { prepareUploadForS3 } = require("./deploy/aws") const { prepareUploadForS3 } = require("./deploy/aws")
const { const {
@ -247,37 +246,3 @@ exports.serveComponentLibrary = async function(ctx) {
await send(ctx, "/index.js", { root: componentLibraryPath }) await send(ctx, "/index.js", { root: componentLibraryPath })
} }
function schemaFromCSV(path) {
const result = csv().fromFile(path)
return new Promise((resolve, reject) => {
result.on("header", headers => {
const schema = {}
for (let header of headers) {
schema[header] = {
type: "string",
constraints: {
type: "string",
length: {},
presence: {
allowEmpty: true,
},
},
name: header,
}
}
resolve(schema)
})
result.on("error", reject)
})
}
exports.validateCSV = async function(ctx) {
const file = ctx.request.files.file
const schema = await schemaFromCSV(file.path)
// if (result.length === 0) ctx.throw(400, "CSV Invalid")
ctx.body = {
schema,
path: file.path,
}
}

View File

@ -13,6 +13,11 @@ router
modelController.find modelController.find
) )
.post("/api/models", authorized(BUILDER), modelController.save) .post("/api/models", authorized(BUILDER), modelController.save)
.post(
"/api/models/csv/validate",
authorized(BUILDER),
modelController.validateCSVSchema
)
.delete( .delete(
"/api/models/:modelId/:revId", "/api/models/:modelId/:revId",
authorized(BUILDER), authorized(BUILDER),

View File

@ -28,7 +28,6 @@ router
authorized(BUILDER), authorized(BUILDER),
controller.performLocalFileProcessing controller.performLocalFileProcessing
) )
.post("/api/csv/validate", authorized(BUILDER), controller.validateCSV)
.post("/api/attachments/upload", controller.uploadFile) .post("/api/attachments/upload", controller.uploadFile)
.get("/componentlibrary", controller.serveComponentLibrary) .get("/componentlibrary", controller.serveComponentLibrary)
.get("/assets/:file*", controller.serveAppAsset) .get("/assets/:file*", controller.serveAppAsset)

View File

@ -0,0 +1,76 @@
const csv = require("csvtojson")
const VALIDATORS = {
string: () => true,
number: attribute => !isNaN(Number(attribute)),
datetime: attribute => !isNaN(new Date(attribute).getTime()),
}
const PARSERS = {
string: attribute => attribute.toString(),
number: attribute => Number(attribute),
datetime: attribute => new Date(attribute).toISOString(),
}
function parse(path, parsers) {
const result = csv().fromFile(path)
const schema = {}
return new Promise((resolve, reject) => {
result.on("header", headers => {
for (let header of headers) {
schema[header] = {
type: parsers[header] ? parsers[header].type : "string",
success: true,
}
}
})
result.fromFile(path).subscribe(row => {
// For each CSV row
// parse all the columns that need parsed
for (let key in parsers) {
// if the schema has already borked for a parser, skip this column
if (!schema[key] || !schema[key].success) continue
// get the validator
const validator = VALIDATORS[parsers[key].type]
try {
schema[key].success = !!validator(row[key])
} catch (err) {
schema[key].success = false
}
}
})
result.on("done", error => {
if (error) {
console.error(error)
reject(error)
}
resolve(schema)
})
})
}
// TODO: significant refactor
async function transform({ schema, path }) {
const colParser = {}
for (let key in schema) {
colParser[key] = PARSERS[schema[key].type]
}
try {
const json = await csv({ colParser }).fromFile(path)
return json
} catch (err) {
console.error(`Error transforming CSV to JSON for data import`, err)
}
}
module.exports = {
parse,
transform,
}