mirror of https://github.com/Budibase/budibase.git
78 changed files with 1184 additions and 672 deletions
@ -0,0 +1,191 @@ |
|||||
|
<script> |
||||
|
import { Heading, Body, Button, Select } from "@budibase/bbui" |
||||
|
import { notifier } from "builderStore/store/notifications" |
||||
|
import { FIELDS } from "constants/backend" |
||||
|
import api from "builderStore/api" |
||||
|
|
||||
|
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[0] && files[0].path, |
||||
|
} |
||||
|
|
||||
|
function buildModelSchema(schema) { |
||||
|
const modelSchema = {} |
||||
|
for (let key in schema) { |
||||
|
const type = schema[key].type |
||||
|
|
||||
|
if (type === "omit") continue |
||||
|
|
||||
|
modelSchema[key] = { |
||||
|
name: key, |
||||
|
type, |
||||
|
constraints: FIELDS[type.toUpperCase()].constraints, |
||||
|
} |
||||
|
} |
||||
|
return modelSchema |
||||
|
} |
||||
|
|
||||
|
async function validateCSV() { |
||||
|
const response = await api.post("/api/models/csv/validate", { |
||||
|
file: files[0], |
||||
|
schema: schema || {}, |
||||
|
}) |
||||
|
|
||||
|
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() |
||||
|
} |
||||
|
|
||||
|
async function omitColumn(columnName) { |
||||
|
schema[columnName].type = "omit" |
||||
|
await validateCSV() |
||||
|
} |
||||
|
|
||||
|
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" class:uploaded={files[0]}> |
||||
|
{#if files[0]}{files[0].name}{:else}Upload{/if} |
||||
|
</label> |
||||
|
</div> |
||||
|
<div class="schema-fields"> |
||||
|
{#if schema} |
||||
|
{#each Object.keys(schema).filter(key => schema[key].type !== 'omit') 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="field-status" 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; |
||||
|
} |
||||
|
|
||||
|
.field-status { |
||||
|
color: var(--green); |
||||
|
justify-self: center; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.error { |
||||
|
color: var(--red); |
||||
|
} |
||||
|
|
||||
|
.uploaded { |
||||
|
color: var(--blue); |
||||
|
} |
||||
|
|
||||
|
input[type="file"] { |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
label { |
||||
|
font-family: var(--font-sans); |
||||
|
cursor: pointer; |
||||
|
font-weight: 500; |
||||
|
box-sizing: border-box; |
||||
|
overflow: hidden; |
||||
|
border-radius: var(--border-radius-s); |
||||
|
color: var(--ink); |
||||
|
padding: var(--spacing-m) 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; |
||||
|
width: 100%; |
||||
|
background-color: var(--grey-2); |
||||
|
font-size: var(--font-size-xs); |
||||
|
line-height: normal; |
||||
|
border: var(--border-transparent); |
||||
|
} |
||||
|
|
||||
|
.omit-button { |
||||
|
font-size: 1.2em; |
||||
|
color: var(--grey-7); |
||||
|
cursor: pointer; |
||||
|
justify-self: flex-end; |
||||
|
} |
||||
|
|
||||
|
.field { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(4, 1fr); |
||||
|
margin-top: var(--spacing-m); |
||||
|
align-items: center; |
||||
|
grid-gap: var(--spacing-m); |
||||
|
font-size: var(--font-size-xs); |
||||
|
} |
||||
|
</style> |
||||
@ -1,278 +0,0 @@ |
|||||
<script> |
|
||||
import { onMount } from "svelte" |
|
||||
import { fly } from "svelte/transition" |
|
||||
import { Label } from "@budibase/bbui" |
|
||||
const xPath = |
|
||||
"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" |
|
||||
|
|
||||
export let value = [] |
|
||||
export let label |
|
||||
|
|
||||
let placeholder = "Type to search" |
|
||||
let options = [] |
|
||||
let optionsVisible = false |
|
||||
let selected = {} |
|
||||
let first = true |
|
||||
let slot |
|
||||
|
|
||||
onMount(() => { |
|
||||
const domOptions = Array.from(slot.querySelectorAll("option")) |
|
||||
options = domOptions.map(option => ({ |
|
||||
value: option.value, |
|
||||
name: option.textContent, |
|
||||
})) |
|
||||
if (value) { |
|
||||
options.forEach(option => { |
|
||||
if (value.includes(option.value)) { |
|
||||
selected[option.value] = option |
|
||||
} |
|
||||
}) |
|
||||
} |
|
||||
first = false |
|
||||
}) |
|
||||
|
|
||||
// Keep value up to date with selected options |
|
||||
$: { |
|
||||
if (!first) { |
|
||||
value = Object.values(selected).map(option => option.value) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
function add(token) { |
|
||||
selected[token.value] = token |
|
||||
} |
|
||||
|
|
||||
function remove(value) { |
|
||||
const { [value]: val, ...rest } = selected |
|
||||
selected = rest |
|
||||
} |
|
||||
|
|
||||
function removeAll() { |
|
||||
selected = [] |
|
||||
} |
|
||||
|
|
||||
function showOptions(show) { |
|
||||
optionsVisible = show |
|
||||
} |
|
||||
|
|
||||
function handleClick() { |
|
||||
showOptions(!optionsVisible) |
|
||||
} |
|
||||
|
|
||||
function handleOptionMousedown(e) { |
|
||||
const value = e.target.dataset.value |
|
||||
if (value == null) { |
|
||||
return |
|
||||
} |
|
||||
if (selected[value]) { |
|
||||
remove(value) |
|
||||
} else { |
|
||||
add(options.filter(option => option.value === value)[0]) |
|
||||
} |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<div> |
|
||||
{#if label} |
|
||||
<Label extraSmall grey>{label}</Label> |
|
||||
{/if} |
|
||||
<div class="multiselect"> |
|
||||
<div class="tokens-wrapper"> |
|
||||
<div |
|
||||
class="tokens" |
|
||||
class:optionsVisible |
|
||||
on:click|self={handleClick} |
|
||||
class:empty={!value || !value.length}> |
|
||||
{#each Object.values(selected) as option} |
|
||||
<div class="token" data-id={option.value} on:click|self={handleClick}> |
|
||||
<span>{option.name}</span> |
|
||||
<div |
|
||||
class="token-remove" |
|
||||
title="Remove {option.name}" |
|
||||
on:click={() => remove(option.value)}> |
|
||||
<svg |
|
||||
class="icon-clear" |
|
||||
xmlns="http://www.w3.org/2000/svg" |
|
||||
width="18" |
|
||||
height="18" |
|
||||
viewBox="0 0 24 24"> |
|
||||
<path d={xPath} /> |
|
||||
</svg> |
|
||||
</div> |
|
||||
</div> |
|
||||
{/each} |
|
||||
{#if !value || !value.length} {/if} |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<select bind:this={slot} type="multiple" class="hidden"> |
|
||||
<slot /> |
|
||||
</select> |
|
||||
|
|
||||
{#if optionsVisible} |
|
||||
<div class="options-overlay" on:click|self={() => showOptions(false)} /> |
|
||||
<ul |
|
||||
class="options" |
|
||||
transition:fly={{ duration: 200, y: 5 }} |
|
||||
on:mousedown|preventDefault={handleOptionMousedown}> |
|
||||
{#each options as option} |
|
||||
<li class:selected={selected[option.value]} data-value={option.value}> |
|
||||
{option.name} |
|
||||
</li> |
|
||||
{/each} |
|
||||
{#if !options.length} |
|
||||
<li class="no-results">No results</li> |
|
||||
{/if} |
|
||||
</ul> |
|
||||
{/if} |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<style> |
|
||||
.multiselect { |
|
||||
position: relative; |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
justify-content: flex-start; |
|
||||
align-items: stretch; |
|
||||
} |
|
||||
.multiselect:hover { |
|
||||
border-bottom-color: hsl(0, 0%, 50%); |
|
||||
} |
|
||||
|
|
||||
.tokens-wrapper { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-start; |
|
||||
align-items: center; |
|
||||
flex: 0 1 auto; |
|
||||
z-index: 2; |
|
||||
} |
|
||||
|
|
||||
.tokens { |
|
||||
align-items: center; |
|
||||
display: flex; |
|
||||
flex-wrap: wrap; |
|
||||
position: relative; |
|
||||
width: 0; |
|
||||
flex: 1 1 auto; |
|
||||
background-color: var(--grey-2); |
|
||||
border-radius: var(--border-radius-m); |
|
||||
padding: 0 var(--spacing-m) calc(var(--spacing-m) - var(--spacing-xs)) |
|
||||
calc(var(--spacing-m) / 2); |
|
||||
border: var(--border-transparent); |
|
||||
} |
|
||||
.tokens:hover { |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
.tokens::after { |
|
||||
background: none repeat scroll 0 0 transparent; |
|
||||
bottom: -1px; |
|
||||
content: ""; |
|
||||
display: block; |
|
||||
height: 2px; |
|
||||
left: 50%; |
|
||||
position: absolute; |
|
||||
transition: width 0.3s ease 0s, left 0.3s ease 0s; |
|
||||
width: 0; |
|
||||
} |
|
||||
.tokens.optionsVisible { |
|
||||
border: var(--border-blue); |
|
||||
} |
|
||||
.tokens.empty { |
|
||||
padding: var(--spacing-m); |
|
||||
font-size: var(--font-size-xs); |
|
||||
user-select: none; |
|
||||
} |
|
||||
.tokens::after { |
|
||||
width: 100%; |
|
||||
left: 0; |
|
||||
} |
|
||||
.token { |
|
||||
font-size: var(--font-size-xs); |
|
||||
align-items: center; |
|
||||
background-color: white; |
|
||||
border-radius: var(--border-radius-l); |
|
||||
display: flex; |
|
||||
margin: calc(var(--spacing-m) - var(--spacing-xs)) 0 0 |
|
||||
calc(var(--spacing-m) / 2); |
|
||||
max-height: 1.3rem; |
|
||||
padding: var(--spacing-xs) var(--spacing-s); |
|
||||
transition: background-color 0.3s; |
|
||||
white-space: nowrap; |
|
||||
} |
|
||||
.token span { |
|
||||
pointer-events: none; |
|
||||
user-select: none; |
|
||||
} |
|
||||
.token-remove { |
|
||||
align-items: center; |
|
||||
background-color: var(--grey-4); |
|
||||
border-radius: 50%; |
|
||||
color: var(--white); |
|
||||
display: flex; |
|
||||
justify-content: center; |
|
||||
height: 1rem; |
|
||||
width: 1rem; |
|
||||
margin: calc(-1 * var(--spacing-xs)) 0 calc(-1 * var(--spacing-xs)) |
|
||||
var(--spacing-xs); |
|
||||
} |
|
||||
.token-remove:hover { |
|
||||
background-color: var(--grey-5); |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
|
|
||||
.icon-clear path { |
|
||||
fill: white; |
|
||||
} |
|
||||
|
|
||||
.options-overlay { |
|
||||
position: fixed; |
|
||||
top: 0; |
|
||||
left: 0; |
|
||||
width: 100vw; |
|
||||
height: 100vh; |
|
||||
z-index: 1; |
|
||||
} |
|
||||
|
|
||||
.options { |
|
||||
z-index: 2; |
|
||||
left: 0; |
|
||||
list-style: none; |
|
||||
margin-block-end: 0; |
|
||||
margin-block-start: 0; |
|
||||
overflow-y: auto; |
|
||||
padding-inline-start: 0; |
|
||||
position: absolute; |
|
||||
top: calc(100% + 1px); |
|
||||
width: calc(100% - 4px); |
|
||||
border: var(--border-dark); |
|
||||
border-radius: var(--border-radius-m); |
|
||||
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.15); |
|
||||
margin-top: var(--spacing-xs); |
|
||||
padding: var(--spacing-s) 0; |
|
||||
background-color: white; |
|
||||
max-height: 200px; |
|
||||
} |
|
||||
li { |
|
||||
background-color: white; |
|
||||
cursor: pointer; |
|
||||
padding: var(--spacing-s) var(--spacing-m); |
|
||||
font-size: var(--font-size-xs); |
|
||||
} |
|
||||
li.selected { |
|
||||
background-color: var(--blue); |
|
||||
color: white; |
|
||||
} |
|
||||
li:not(.selected):hover { |
|
||||
background-color: var(--grey-1); |
|
||||
} |
|
||||
li.no-results:hover { |
|
||||
background-color: white; |
|
||||
cursor: initial; |
|
||||
} |
|
||||
|
|
||||
.hidden { |
|
||||
display: none; |
|
||||
} |
|
||||
</style> |
|
||||
|
After Width: | Height: | Size: 52 KiB |
@ -1,3 +1,3 @@ |
|||||
const { join } = require("path") |
const { join } = require("../sanitisedPath") |
||||
|
|
||||
module.exports = (appPath, pageName) => join(appPath, "public", pageName) |
module.exports = (appPath, pageName) => join(appPath, "public", pageName) |
||||
|
|||||
@ -0,0 +1,73 @@ |
|||||
|
const csv = require("csvtojson") |
||||
|
|
||||
|
const VALIDATORS = { |
||||
|
string: () => true, |
||||
|
number: attribute => !isNaN(Number(attribute)), |
||||
|
datetime: attribute => !isNaN(new Date(attribute).getTime()), |
||||
|
} |
||||
|
|
||||
|
const PARSERS = { |
||||
|
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 (!schema[key] || schema[key].success) { |
||||
|
// get the validator for the column type
|
||||
|
const validator = VALIDATORS[parsers[key].type] |
||||
|
|
||||
|
try { |
||||
|
// allow null/undefined values
|
||||
|
schema[key].success = !row[key] || validator(row[key]) |
||||
|
} catch (err) { |
||||
|
schema[key].success = false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}) |
||||
|
result.on("done", error => { |
||||
|
if (error) { |
||||
|
console.error(error) |
||||
|
reject(error) |
||||
|
} |
||||
|
|
||||
|
resolve(schema) |
||||
|
}) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
async function transform({ schema, path }) { |
||||
|
const colParser = {} |
||||
|
|
||||
|
for (let key in schema) { |
||||
|
colParser[key] = PARSERS[schema[key].type] || 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) |
||||
|
throw err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
parse, |
||||
|
transform, |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
const path = require("path") |
||||
|
|
||||
|
const regex = new RegExp(/:(?![\\/])/g) |
||||
|
|
||||
|
function sanitiseArgs(args) { |
||||
|
let sanitised = [] |
||||
|
for (let arg of args) { |
||||
|
sanitised.push(arg.replace(regex, "")) |
||||
|
} |
||||
|
return sanitised |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Exactly the same as path.join but creates a sanitised path. |
||||
|
* @param args Any number of string arguments to add to a path |
||||
|
* @returns {string} The final path ready to use |
||||
|
*/ |
||||
|
exports.join = function(...args) { |
||||
|
return path.join(...sanitiseArgs(args)) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Exactly the same as path.resolve but creates a sanitised path. |
||||
|
* @param args Any number of string arguments to add to a path |
||||
|
* @returns {string} The final path ready to use |
||||
|
*/ |
||||
|
exports.resolve = function(...args) { |
||||
|
return path.resolve(...sanitiseArgs(args)) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Sanitise a single string |
||||
|
* @param string input string to sanitise |
||||
|
* @returns {string} the final sanitised string |
||||
|
*/ |
||||
|
exports.sanitise = function(string) { |
||||
|
return sanitiseArgs([string])[0] |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
|
||||
|
exports[`CSV Parser transformation transforms a CSV file into JSON 1`] = ` |
||||
|
Array [ |
||||
|
Object { |
||||
|
"Address": "5 Sesame Street", |
||||
|
"Age": 4324, |
||||
|
"Name": "Bert", |
||||
|
}, |
||||
|
Object { |
||||
|
"Address": "1 World Trade Center", |
||||
|
"Age": 34, |
||||
|
"Name": "Ernie", |
||||
|
}, |
||||
|
Object { |
||||
|
"Address": "44 Second Avenue", |
||||
|
"Age": 23423, |
||||
|
"Name": "Big Bird", |
||||
|
}, |
||||
|
] |
||||
|
`; |
||||
@ -0,0 +1,108 @@ |
|||||
|
const csvParser = require("../csvParser"); |
||||
|
|
||||
|
const CSV_PATH = __dirname + "/test.csv"; |
||||
|
|
||||
|
const SCHEMAS = { |
||||
|
VALID: { |
||||
|
Age: { |
||||
|
type: "number", |
||||
|
}, |
||||
|
}, |
||||
|
INVALID: { |
||||
|
Address: { |
||||
|
type: "number", |
||||
|
}, |
||||
|
Age: { |
||||
|
type: "number", |
||||
|
}, |
||||
|
}, |
||||
|
IGNORE: { |
||||
|
Address: { |
||||
|
type: "omit", |
||||
|
}, |
||||
|
Age: { |
||||
|
type: "omit", |
||||
|
}, |
||||
|
}, |
||||
|
BROKEN: { |
||||
|
Address: { |
||||
|
type: "datetime", |
||||
|
} |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
describe("CSV Parser", () => { |
||||
|
describe("parsing", () => { |
||||
|
it("returns status and types for a valid CSV transformation", async () => { |
||||
|
expect( |
||||
|
await csvParser.parse(CSV_PATH, SCHEMAS.VALID) |
||||
|
).toEqual({ |
||||
|
Address: { |
||||
|
success: true, |
||||
|
type: "string", |
||||
|
}, |
||||
|
Age: { |
||||
|
success: true, |
||||
|
type: "number", |
||||
|
}, |
||||
|
Name: { |
||||
|
success: true, |
||||
|
type: "string", |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it("returns status and types for an invalid CSV transformation", async () => { |
||||
|
expect( |
||||
|
await csvParser.parse(CSV_PATH, SCHEMAS.INVALID) |
||||
|
).toEqual({ |
||||
|
Address: { |
||||
|
success: false, |
||||
|
type: "number", |
||||
|
}, |
||||
|
Age: { |
||||
|
success: true, |
||||
|
type: "number", |
||||
|
}, |
||||
|
Name: { |
||||
|
success: true, |
||||
|
type: "string", |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("transformation", () => { |
||||
|
it("transforms a CSV file into JSON", async () => { |
||||
|
expect( |
||||
|
await csvParser.transform({ |
||||
|
schema: SCHEMAS.VALID, |
||||
|
path: CSV_PATH, |
||||
|
}) |
||||
|
).toMatchSnapshot(); |
||||
|
}); |
||||
|
|
||||
|
it("transforms a CSV file into JSON ignoring certain fields", async () => { |
||||
|
expect( |
||||
|
await csvParser.transform({ |
||||
|
schema: SCHEMAS.IGNORE, |
||||
|
path: CSV_PATH, |
||||
|
}) |
||||
|
).toEqual([ |
||||
|
{ |
||||
|
Name: "Bert" |
||||
|
}, |
||||
|
{ |
||||
|
Name: "Ernie" |
||||
|
}, |
||||
|
{ |
||||
|
Name: "Big Bird" |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it("throws an error on invalid schema", async () => { |
||||
|
await expect(csvParser.transform({ schema: SCHEMAS.BROKEN, path: CSV_PATH })).rejects.toThrow() |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
@ -0,0 +1,31 @@ |
|||||
|
<script> |
||||
|
export let errors = [] |
||||
|
|
||||
|
$: hasErrors = errors.length > 0 |
||||
|
</script> |
||||
|
|
||||
|
{#if hasErrors} |
||||
|
<div class="container"> |
||||
|
{#each errors as error} |
||||
|
<div class="error">{error.dataPath} {error.message}</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
|
||||
|
<style> |
||||
|
.container { |
||||
|
border-radius: var(--border-radius-m); |
||||
|
margin: 0; |
||||
|
padding: var(--spacing-m); |
||||
|
background-color: var(--red-light); |
||||
|
} |
||||
|
|
||||
|
.error { |
||||
|
font-size: var(--font-size-xs); |
||||
|
font-weight: 500; |
||||
|
color: var(--red-dark); |
||||
|
} |
||||
|
.error:first-letter { |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,65 @@ |
|||||
|
<script> |
||||
|
import { onMount } from "svelte" |
||||
|
import { Select, Label, Multiselect } from "@budibase/bbui" |
||||
|
import api from "./api" |
||||
|
import { capitalise } from "./helpers" |
||||
|
|
||||
|
export let schema = {} |
||||
|
export let linkedRecords = [] |
||||
|
export let showLabel = true |
||||
|
export let secondary |
||||
|
|
||||
|
let linkedModel |
||||
|
|
||||
|
$: label = capitalise(schema.name) |
||||
|
$: linkedModelId = schema.modelId |
||||
|
$: recordsPromise = fetchRecords(linkedModelId) |
||||
|
$: fetchModel(linkedModelId) |
||||
|
|
||||
|
async function fetchModel() { |
||||
|
if (linkedModelId == null) { |
||||
|
return |
||||
|
} |
||||
|
const FETCH_MODEL_URL = `/api/models/${linkedModelId}` |
||||
|
const response = await api.get(FETCH_MODEL_URL) |
||||
|
linkedModel = await response.json() |
||||
|
} |
||||
|
|
||||
|
async function fetchRecords(linkedModelId) { |
||||
|
if (linkedModelId == null) { |
||||
|
return |
||||
|
} |
||||
|
const FETCH_RECORDS_URL = `/api/${linkedModelId}/records` |
||||
|
const response = await api.get(FETCH_RECORDS_URL) |
||||
|
return await response.json() |
||||
|
} |
||||
|
|
||||
|
function getPrettyName(record) { |
||||
|
return record[linkedModel?.primaryDisplay || "_id"] |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
{#if linkedModel != null} |
||||
|
{#if linkedModel.primaryDisplay == null} |
||||
|
{#if showLabel} |
||||
|
<Label extraSmall grey>{label}</Label> |
||||
|
{/if} |
||||
|
<Label small black> |
||||
|
Please choose a primary display column for the |
||||
|
<b>{linkedModel.name}</b> |
||||
|
table. |
||||
|
</Label> |
||||
|
{:else} |
||||
|
{#await recordsPromise then records} |
||||
|
<Multiselect |
||||
|
{secondary} |
||||
|
bind:value={linkedRecords} |
||||
|
label={showLabel ? label : null} |
||||
|
placeholder="Choose some options"> |
||||
|
{#each records as record} |
||||
|
<option value={record._id}>{getPrettyName(record)}</option> |
||||
|
{/each} |
||||
|
</Multiselect> |
||||
|
{/await} |
||||
|
{/if} |
||||
|
{/if} |
||||
@ -0,0 +1 @@ |
|||||
|
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1) |
||||
Loading…
Reference in new issue