Merge branch 'linked-records' of github.com:Budibase/budibase into api-usage-tracking

This commit is contained in:
mike12345567 2020-10-07 15:41:39 +01:00
commit 3a6a03403f
78 changed files with 1184 additions and 672 deletions

View File

@ -1,5 +1,5 @@
{
"version": "0.1.23",
"version": "0.1.25",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -30,7 +30,6 @@
"test:e2e:ci": "lerna run cy:ci"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.8",
"pouchdb-replication-stream": "^1.2.9"
"@fortawesome/fontawesome": "^1.1.8"
}
}
}

View File

@ -50,7 +50,6 @@ context("Create a automation", () => {
// Activate Automation
cy.get("[data-cy=activate-automation]").click()
cy.contains("Add Record").should("be.visible")
cy.get(".stop-button.highlighted").should("be.visible")
})

View File

@ -8,7 +8,7 @@ context("Create a Table", () => {
cy.createTable("dog")
// Check if Table exists
cy.get(".title").should("have.text", "dog")
cy.get(".title span").should("have.text", "dog")
})
it("adds a new column to the table", () => {

View File

@ -50,13 +50,11 @@ context("Create a View", () => {
it("creates a stats calculation view based on age", () => {
cy.contains("Calculate").click()
// we may reinstate this - have commented this dropdown for now as there is only one option
//cy.get(".menu-container").find("select").first().select("Statistics")
cy.get(".menu-container")
.find("select")
.first()
.select("Statistics")
cy.get(".menu-container")
.find("select")
.eq(1)
.eq(0)
.select("age")
cy.contains("Save").click()
cy.get("thead th div").should($headers => {

View File

@ -68,6 +68,7 @@ Cypress.Commands.add("createTable", tableName => {
cy.contains("Create New Table").click()
cy.get(".menu-container")
.get("input")
.first()
.type(tableName)
cy.contains("Save").click()

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "0.1.23",
"version": "0.1.25",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@ -63,8 +63,8 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.39.0",
"@budibase/client": "^0.1.23",
"@budibase/bbui": "^1.40.1",
"@budibase/client": "^0.1.25",
"@budibase/colorpicker": "^1.0.1",
"@fortawesome/fontawesome-free": "^5.14.0",
"@sentry/browser": "5.19.1",

View File

@ -37,9 +37,9 @@ export default function({ componentInstanceId, screen, components, models }) {
.filter(isInstanceInSharedContext(walkResult))
.map(componentInstanceToBindable(walkResult)),
...walkResult.target._contexts
...(walkResult.target?._contexts
.map(contextToBindables(models, walkResult))
.flat(),
.flat() ?? []),
]
}
@ -73,6 +73,10 @@ const componentInstanceToBindable = walkResult => i => {
const contextToBindables = (models, walkResult) => context => {
const contextParentPath = getParentPath(walkResult, context)
const isModel = context.model?.isModel || typeof context.model === "string"
const modelId =
typeof context.model === "string" ? context.model : context.model.modelId
const model = models.find(model => model._id === modelId)
const newBindable = key => ({
type: "context",
@ -80,15 +84,12 @@ const contextToBindables = (models, walkResult) => context => {
// how the binding expression persists, and is used in the app at runtime
runtimeBinding: `${contextParentPath}data.${key}`,
// how the binding exressions looks to the user of the builder
readableBinding: `${context.instance._instanceName}.${context.model.label}.${key}`,
readableBinding: `${context.instance._instanceName}.${model.name}.${key}`,
})
// see ModelViewSelect.svelte for the format of context.model
// ... this allows us to bind to Model scheams, or View schemas
const model = models.find(m => m._id === context.model.modelId)
const schema = context.model.isModel
? model.schema
: model.views[context.model.name].schema
// ... this allows us to bind to Model schemas, or View schemas
const schema = isModel ? model.schema : model.views[context.model.name].schema
return (
Object.keys(schema)

View File

@ -8,6 +8,7 @@
import Table from "./Table.svelte"
let data = []
let loading = false
$: title = $backendUiStore.selectedModel.name
$: schema = $backendUiStore.selectedModel.schema
@ -19,14 +20,16 @@
// Fetch records for specified model
$: {
if ($backendUiStore.selectedView?.name?.startsWith("all_")) {
loading = true
api.fetchDataForView($backendUiStore.selectedView).then(records => {
data = records || []
loading = false
})
}
}
</script>
<Table {title} {schema} {data} allowEditing={true}>
<Table {title} {schema} {data} allowEditing={true} {loading}>
<CreateColumnButton />
{#if Object.keys(schema).length > 0}
<CreateRowButton />

View File

@ -2,39 +2,33 @@
import { Input, Select, Label, DatePicker, Toggle } from "@budibase/bbui"
import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "../../../helpers"
import LinkedRecordSelector from "components/common/LinkedRecordSelector.svelte"
export let meta
export let value = meta.type === "boolean" ? false : ""
const type = determineInputType(meta)
const label = capitalise(meta.name)
function determineInputType(meta) {
if (meta.type === "datetime") return "date"
if (meta.type === "number") return "number"
if (meta.type === "boolean") return "checkbox"
if (meta.type === "attachment") return "file"
if (meta.type === "options") return "select"
return "text"
}
$: type = meta.type
$: label = capitalise(meta.name)
</script>
{#if type === 'select'}
{#if type === 'options'}
<Select thin secondary {label} data-cy="{meta.name}-select" bind:value>
<option value="">Choose an option</option>
{#each meta.constraints.inclusion as opt}
<option value={opt}>{opt}</option>
{/each}
</Select>
{:else if type === 'date'}
{:else if type === 'datetime'}
<DatePicker {label} bind:value />
{:else if type === 'file'}
{:else if type === 'attachment'}
<div>
<Label extraSmall grey forAttr={'dropzone-label'}>{label}</Label>
<Dropzone bind:files={value} />
</div>
{:else if type === 'checkbox'}
{:else if type === 'boolean'}
<Toggle text={label} bind:checked={value} data-cy="{meta.name}-input" />
{:else if type === 'link'}
<LinkedRecordSelector bind:linkedRecords={value} schema={meta} />
{:else}
<Input thin {label} data-cy="{meta.name}-input" {type} bind:value />
{/if}

View File

@ -1,6 +1,7 @@
<script>
import { goto, params } from "@sveltech/routify"
import { onMount } from "svelte"
import { fade } from "svelte/transition"
import fsort from "fast-sort"
import getOr from "lodash/fp/getOr"
import { store, backendUiStore } from "builderStore"
@ -16,6 +17,7 @@
import ColumnHeaderPopover from "./popovers/ColumnPopover.svelte"
import EditRowPopover from "./popovers/RowPopover.svelte"
import CalculationPopover from "./buttons/CalculateButton.svelte"
import Spinner from "components/common/Spinner.svelte"
const ITEMS_PER_PAGE = 10
@ -23,6 +25,7 @@
export let data = []
export let title
export let allowEditing = false
export let loading = false
let currentPage = 0
@ -50,7 +53,14 @@
<section>
<div class="table-controls">
<h2 class="title">{title}</h2>
<h2 class="title">
<span>{title}</span>
{#if loading}
<div transition:fade>
<Spinner size="10" />
</div>
{/if}
</h2>
<div class="popovers">
<slot />
</div>
@ -123,6 +133,13 @@
text-rendering: optimizeLegibility;
text-transform: capitalize;
margin-top: 0;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.title > span {
margin-right: var(--spacing-xs);
}
table {

View File

@ -1,7 +1,6 @@
<script>
import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import LinkedRecordSelector from "components/common/LinkedRecordSelector.svelte"
import RecordFieldControl from "../RecordFieldControl.svelte"
import * as api from "../api"
import { Modal } from "components/common/Modal"
@ -44,11 +43,7 @@
<ErrorsBox {errors} />
{#each modelSchema as [key, meta]}
<div>
{#if meta.type === 'link'}
<LinkedRecordSelector bind:linkedRecords={record[key]} schema={meta} />
{:else}
<RecordFieldControl {meta} bind:value={record[key]} />
{/if}
<RecordFieldControl {meta} bind:value={record[key]} />
</div>
{/each}
</Modal>

View File

@ -24,6 +24,7 @@
)
function saveView() {
if (!view.calculation) view.calculation = "stats"
backendUiStore.actions.views.save(view)
notifier.success(`View ${view.name} saved.`)
onClosed()
@ -34,14 +35,15 @@
<div class="actions">
<h5>Calculate</h5>
<div class="input-group-row">
<p>The</p>
<!-- <p>The</p>
<Select secondary thin bind:value={view.calculation}>
<option value="">Choose an option</option>
{#each CALCULATIONS as calculation}
<option value={calculation.key}>{calculation.name}</option>
{/each}
</Select>
<p>of</p>
<p>of</p> -->
<p>The statistics of</p>
<Select secondary thin bind:value={view.field}>
<option value="">Choose an option</option>
{#each fields as field}
@ -74,7 +76,7 @@
.input-group-row {
display: grid;
grid-template-columns: 30px 1fr 20px 1fr;
grid-template-columns: auto 1fr;
gap: var(--spacing-s);
align-items: center;
}

View File

@ -34,6 +34,7 @@
$: modelOptions = $backendUiStore.models.filter(
model => model._id !== $backendUiStore.draftModel._id
)
$: required = !!field?.constraints?.presence
async function saveColumn() {
backendUiStore.update(state => {
@ -53,6 +54,12 @@
field.type = type
field.constraints = constraints
}
function onChangeRequired(e) {
const req = e.target.checked
field.constraints.presence = req ? { allowEmpty: false } : false
required = req
}
</script>
<div class="actions">
@ -71,8 +78,8 @@
{#if field.type !== 'link'}
<Toggle
checked={!field.constraints.presence.allowEmpty}
on:change={e => (field.constraints.presence.allowEmpty = !e.target.checked)}
checked={required}
on:change={onChangeRequired}
thin
text="Required" />
{/if}

View File

@ -1,5 +1,5 @@
<script>
import { Button, Input, Select } from "@budibase/bbui"
import { Button, Input, Select, DatePicker } from "@budibase/bbui"
import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import analytics from "analytics"
@ -71,11 +71,38 @@
function isMultipleChoice(field) {
return (
viewModel.schema[field].constraints &&
viewModel.schema[field].constraints.inclusion &&
viewModel.schema[field].constraints.inclusion.length
(viewModel.schema[field].constraints &&
viewModel.schema[field].constraints.inclusion &&
viewModel.schema[field].constraints.inclusion.length) ||
viewModel.schema[field].type === "boolean"
)
}
function fieldOptions(field) {
return viewModel.schema[field].type === "string"
? viewModel.schema[field].constraints.inclusion
: [true, false]
}
function isDate(field) {
return viewModel.schema[field].type === "datetime"
}
function isNumber(field) {
return viewModel.schema[field].type === "number"
}
const fieldChanged = filter => ev => {
// reset if type changed
if (
filter.key &&
ev.target.value &&
viewModel.schema[filter.key].type !==
viewModel.schema[ev.target.value].type
) {
filter.value = ""
}
}
</script>
<div class="actions">
@ -93,7 +120,11 @@
{/each}
</Select>
{/if}
<Select secondary thin bind:value={filter.key}>
<Select
secondary
thin
bind:value={filter.key}
on:change={fieldChanged(filter)}>
<option value="">Choose an option</option>
{#each fields as field}
<option value={field}>{field}</option>
@ -108,12 +139,25 @@
{#if filter.key && isMultipleChoice(filter.key)}
<Select secondary thin bind:value={filter.value}>
<option value="">Choose an option</option>
{#each viewModel.schema[filter.key].constraints.inclusion as option}
<option value={option}>{option}</option>
{#each fieldOptions(filter.key) as option}
<option value={option}>{option.toString()}</option>
{/each}
</Select>
{:else if filter.key && isDate(filter.key)}
<DatePicker
bind:value={filter.value}
placeholder={filter.key || fields[0]} />
{:else if filter.key && isNumber(filter.key)}
<Input
thin
bind:value={filter.value}
placeholder={filter.key || fields[0]}
type="number" />
{:else}
<Input thin placeholder="Value" bind:value={filter.value} />
<Input
thin
placeholder={filter.key || fields[0]}
bind:value={filter.value} />
{/if}
<i class="ri-close-circle-fill" on:click={() => removeFilter(idx)} />
{/each}

View File

@ -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>

View File

@ -2,23 +2,30 @@
import { goto } from "@sveltech/routify"
import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { Popover, Button, Icon, Input, Select } from "@budibase/bbui"
import { Popover, Button, Icon, Input, Select, Label } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import TableDataImport from "../TableDataImport.svelte"
import analytics from "analytics"
let anchor
let dropdown
let name
let dataImport
let loading
async function saveTable() {
loading = true
const model = await backendUiStore.actions.models.save({
name,
schema: {},
schema: dataImport.schema || {},
dataImport,
})
notifier.success(`Table ${name} created successfully.`)
$goto(`./model/${model._id}`)
analytics.captureEvent("Table Created", { name })
name = ""
dropdown.hide()
analytics.captureEvent("Table Created", { name })
loading = false
}
const onClosed = () => {
@ -38,9 +45,21 @@
thin
label="Table Name"
bind:value={name} />
<div>
<Label grey extraSmall>Create Table from CSV (Optional)</Label>
<TableDataImport bind:dataImport />
</div>
<footer>
<Button secondary on:click={onClosed}>Cancel</Button>
<Button primary on:click={saveTable}>Save</Button>
<Button
disabled={!name || (dataImport && !dataImport.valid)}
primary
on:click={saveTable}>
<span style={`margin-right: ${loading ? '10px' : 0};`}>Save</span>
{#if loading}
<Spinner size="10" />
{/if}
</Button>
</footer>
</div>
</Popover>

View File

@ -2,9 +2,8 @@
import { onMount } from "svelte"
import { backendUiStore } from "builderStore"
import api from "builderStore/api"
import { Select, Label } from "@budibase/bbui"
import { Select, Label, Multiselect } from "@budibase/bbui"
import { capitalise } from "../../helpers"
import MultiSelect from "components/common/MultiSelect.svelte"
export let schema
export let linkedRecords = []
@ -19,8 +18,7 @@
async function fetchRecords(linkedModelId) {
const FETCH_RECORDS_URL = `/api/${linkedModelId}/records`
const response = await api.get(FETCH_RECORDS_URL)
const result = await response.json()
return result
return await response.json()
}
function getPrettyName(record) {
@ -37,15 +35,14 @@
</Label>
{:else}
{#await promise then records}
<MultiSelect
thin
<Multiselect
secondary
bind:value={linkedRecords}
{label}
placeholder="Choose an option">
placeholder="Choose some options">
{#each records as record}
<option value={record._id}>{getPrettyName(record)}</option>
{/each}
</MultiSelect>
</Multiselect>
{/await}
{/if}

View File

@ -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}&nbsp;{/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>

View File

@ -51,6 +51,7 @@
flex-direction: column;
justify-content: flex-start;
align-items: center;
pointer-events: none;
}
.toast {

View File

@ -1,11 +1,9 @@
<script>
import { Input, Button } from "@budibase/bbui"
import { store } from "builderStore"
import { Input } from "@budibase/bbui"
import api from "builderStore/api"
import posthog from "posthog-js"
import analytics from "analytics"
let keys = { budibase: "", sendGrid: "" }
let keys = { budibase: "" }
async function updateKey([key, value]) {
if (key === "budibase") {
@ -40,12 +38,6 @@
edit
value={keys.budibase}
label="Budibase API Key" />
<Input
on:save={e => updateKey(['sendgrid', e.detail])}
thin
edit
value={keys.sendgrid}
label="Sendgrid API Key" />
</div>
<style>

View File

@ -146,7 +146,7 @@
})
const appJson = await appResp.json()
analytics.captureEvent("App Created", {
name,
name: $createAppStore.values.applicationName,
appId: appJson._id,
template,
})

View File

@ -11,7 +11,7 @@
on:input={() => (blurred.api = true)}
label="API Key"
name="apiKey"
placeholder="Enter your API Key"
placeholder="Use command-V to paste your API Key"
type="password"
error={blurred.api && validationErrors.apiKey} />
<a target="_blank" href="https://portal.budi.live/">Get API Key</a>

View File

@ -19,7 +19,7 @@
label="Password"
name="password"
placeholder="Password"
type="pasword"
type="password"
error={blurred.password && validationErrors.password} />
<Select label="Access Level" secondary name="accessLevelId">
<option value="ADMIN">Admin</option>

View File

@ -10,7 +10,7 @@
function handleSelected(selected) {
dispatch("change", selected)
dropdown.hide()
dropdownRight.hide()
}
const models = $backendUiStore.models.map(m => ({

View File

@ -6,7 +6,7 @@ export const FIELDS = {
constraints: {
type: "string",
length: {},
presence: { allowEmpty: true },
presence: false,
},
},
OPTIONS: {
@ -25,7 +25,7 @@ export const FIELDS = {
type: "number",
constraints: {
type: "number",
presence: { allowEmpty: true },
presence: false,
numericality: { greaterThanOrEqualTo: "", lessThanOrEqualTo: "" },
},
},
@ -35,7 +35,7 @@ export const FIELDS = {
type: "boolean",
constraints: {
type: "boolean",
presence: { allowEmpty: true },
presence: false,
},
},
DATETIME: {
@ -45,7 +45,7 @@ export const FIELDS = {
constraints: {
type: "string",
length: {},
presence: { allowEmpty: true },
presence: false,
datetime: {
latest: "",
earliest: "",
@ -58,7 +58,7 @@ export const FIELDS = {
type: "attachment",
constraints: {
type: "array",
presence: { allowEmpty: true },
presence: false,
},
},
LINK: {

View File

@ -709,10 +709,10 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.39.0":
version "1.39.0"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.39.0.tgz#7d3e259a60a0b4602f3d2da3452679d91a591fdd"
integrity sha512-9IL5Lw488sdYCa9mjHHdrap11VqW6wQHNcNTL8fFHaWNzummtlaUlVMScs9cunYgsR/L4NCgH0zSFdP0RnrUqw==
"@budibase/bbui@^1.40.1":
version "1.40.1"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.40.1.tgz#7ebfd52b4da822312d3395447a4f73caa41f8014"
integrity sha512-0t5Makyn5jOURKZIQPvd+8G4m6ps4GyfLUkAz5rKJSnAQSAgLiuZ+RihcEReDEJK8tnfW7h2echJTffJduQRRQ==
dependencies:
sirv-cli "^0.4.6"
svelte-flatpickr "^2.4.0"
@ -5844,10 +5844,6 @@ svelte-portal@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-0.1.0.tgz#cc2821cc84b05ed5814e0218dcdfcbebc53c1742"
svelte-simple-modal@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/svelte-simple-modal/-/svelte-simple-modal-0.4.2.tgz#2cfe26ec8c0760b89813d65dfee836399620d6b2"
svelte@^3.24.1:
version "3.25.1"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.25.1.tgz#218def1243fea5a97af6eb60f5e232315bb57ac4"

View File

@ -1,6 +1,6 @@
{
"name": "budibase",
"version": "0.1.23",
"version": "0.1.25",
"description": "Budibase CLI",
"repository": "https://github.com/Budibase/Budibase",
"homepage": "https://www.budibase.com",
@ -17,7 +17,7 @@
"author": "Budibase",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@budibase/server": "^0.1.23",
"@budibase/server": "^0.1.25",
"@inquirer/password": "^0.0.6-alpha.0",
"chalk": "^2.4.2",
"dotenv": "^8.2.0",

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "0.1.23",
"version": "0.1.25",
"license": "MPL-2.0",
"main": "dist/budibase-client.js",
"module": "dist/budibase-client.esm.mjs",

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/server",
"version": "0.1.23",
"version": "0.1.25",
"description": "Budibase Web Server",
"main": "src/electron.js",
"repository": {
@ -42,13 +42,14 @@
"author": "Michael Shanks",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@budibase/client": "^0.1.23",
"@budibase/client": "^0.1.25",
"@koa/router": "^8.0.0",
"@sendgrid/mail": "^7.1.1",
"@sentry/node": "^5.19.2",
"aws-sdk": "^2.767.0",
"bcryptjs": "^2.4.3",
"chmodr": "^1.2.0",
"csvtojson": "^2.0.10",
"dotenv": "^8.2.0",
"download": "^8.0.0",
"electron-is-dev": "^1.2.0",
@ -71,6 +72,7 @@
"pino-pretty": "^4.0.0",
"pouchdb": "^7.2.1",
"pouchdb-all-dbs": "^1.0.2",
"pouchdb-replication-stream": "^1.2.9",
"sharp": "^0.26.0",
"squirrelly": "^7.5.0",
"tar-fs": "^2.1.0",

View File

@ -1,4 +1,5 @@
const fs = require("fs")
const { join } = require("../../utilities/sanitisedPath")
const readline = require("readline")
const { budibaseAppsDir } = require("../../utilities/budibaseDir")
const ENV_FILE_PATH = "/.env"
@ -7,7 +8,6 @@ exports.fetch = async function(ctx) {
ctx.status = 200
ctx.body = {
budibase: process.env.BUDIBASE_API_KEY,
sendgrid: process.env.SENDGRID_API_KEY,
userId: process.env.USERID_API_KEY,
}
}
@ -29,8 +29,9 @@ exports.update = async function(ctx) {
async function updateValues([key, value]) {
let newContent = ""
let keyExists = false
let envPath = join(budibaseAppsDir(), ENV_FILE_PATH)
const readInterface = readline.createInterface({
input: fs.createReadStream(`${budibaseAppsDir()}/${ENV_FILE_PATH}`),
input: fs.createReadStream(envPath),
output: process.stdout,
console: false,
})
@ -48,6 +49,6 @@ async function updateValues([key, value]) {
// Add API Key if it doesn't exist in the file at all
newContent = `${newContent}\n${key}=${value}`
}
fs.writeFileSync(`${budibaseAppsDir()}/${ENV_FILE_PATH}`, newContent)
fs.writeFileSync(envPath, newContent)
})
}

View File

@ -3,12 +3,12 @@ const ClientDb = require("../../db/clientDb")
const { getPackageForBuilder, buildPage } = require("../../utilities/builder")
const env = require("../../environment")
const instanceController = require("./instance")
const { resolve, join } = require("path")
const { copy, exists, readFile, writeFile } = require("fs-extra")
const { budibaseAppsDir } = require("../../utilities/budibaseDir")
const sqrl = require("squirrelly")
const setBuilderToken = require("../../utilities/builder/setBuilderToken")
const fs = require("fs-extra")
const { join, resolve } = require("../../utilities/sanitisedPath")
const { promisify } = require("util")
const chmodr = require("chmodr")
const { generateAppID, getAppParams } = require("../../db/utils")
@ -116,7 +116,7 @@ exports.delete = async function(ctx) {
const db = new CouchDB(ClientDb.name(getClientId(ctx)))
const app = await db.get(ctx.params.applicationId)
const result = await db.remove(app)
await fs.rmdir(`${budibaseAppsDir()}/${ctx.params.applicationId}`, {
await fs.rmdir(join(budibaseAppsDir(), ctx.params.applicationId), {
recursive: true,
})

View File

@ -1,6 +1,6 @@
const CouchDB = require("../../db")
const ClientDb = require("../../db/clientDb")
const { resolve, join } = require("path")
const { resolve, join } = require("../../utilities/sanitisedPath")
const {
budibaseTempDir,
budibaseAppsDir,

View File

@ -1,4 +1,5 @@
const fs = require("fs")
const { join } = require("../../../utilities/sanitisedPath")
const AWS = require("aws-sdk")
const fetch = require("node-fetch")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
@ -108,7 +109,7 @@ exports.uploadAppAssets = async function({
},
})
const appAssetsPath = `${budibaseAppsDir()}/${appId}/public`
const appAssetsPath = join(budibaseAppsDir(), appId, "public")
const appPages = fs.readdirSync(appAssetsPath)
@ -116,7 +117,7 @@ exports.uploadAppAssets = async function({
for (let page of appPages) {
// Upload HTML, CSS and JS for each page of the web app
walkDir(`${appAssetsPath}/${page}`, function(filePath) {
walkDir(join(appAssetsPath, page), function(filePath) {
const appAssetUpload = prepareUploadForS3({
file: {
path: filePath,

View File

@ -3,6 +3,7 @@ const CouchDB = require("../../db")
const client = require("../../db/clientDb")
const newid = require("../../db/newid")
const { createLinkView } = require("../../db/linkedRecords")
const { join } = require("../../utilities/sanitisedPath")
const { downloadTemplate } = require("../../utilities/templates")
exports.create = async function(ctx) {
@ -39,7 +40,9 @@ exports.create = async function(ctx) {
// replicate the template data to the instance DB
if (template) {
const templatePath = await downloadTemplate(...template.key.split("/"))
const dbDumpReadStream = fs.createReadStream(`${templatePath}/db/dump.txt`)
const dbDumpReadStream = fs.createReadStream(
join(templatePath, "db", "dump.txt")
)
const { ok } = await db.load(dbDumpReadStream)
if (!ok) {
ctx.throw(500, "Error loading database dump from template.")

View File

@ -1,9 +1,11 @@
const CouchDB = require("../../db")
const linkRecords = require("../../db/linkedRecords")
const csvParser = require("../../utilities/csvParser")
const {
getRecordParams,
getModelParams,
generateModelID,
generateRecordID,
} = require("../../db/utils")
exports.fetch = async function(ctx) {
@ -24,11 +26,12 @@ exports.find = async function(ctx) {
exports.save = async function(ctx) {
const instanceId = ctx.user.instanceId
const db = new CouchDB(instanceId)
const { dataImport, ...rest } = ctx.request.body
const modelToSave = {
type: "model",
_id: generateModelID(),
views: {},
...ctx.request.body,
...rest,
}
// if the model obj had an _id then it will have been retrieved
@ -81,6 +84,19 @@ exports.save = async function(ctx) {
ctx.eventEmitter &&
ctx.eventEmitter.emitModel(`model:save`, instanceId, modelToSave)
if (dataImport && dataImport.path) {
// Populate the table with records imported from CSV in a bulk update
const data = await csvParser.transform(dataImport)
for (let row of data) {
row._id = generateRecordID(modelToSave._id)
row.modelId = modelToSave._id
}
await db.bulkDocs(data)
}
ctx.status = 200
ctx.message = `Model ${ctx.request.body.name} saved successfully.`
ctx.body = modelToSave
@ -116,3 +132,12 @@ exports.destroy = async function(ctx) {
ctx.status = 200
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

@ -2,6 +2,7 @@ const CouchDB = require("../../db")
const validateJs = require("validate.js")
const linkRecords = require("../../db/linkedRecords")
const { getRecordParams, generateRecordID } = require("../../db/utils")
const { cloneDeep } = require("lodash")
const MODEL_VIEW_BEGINS_WITH = "all_model:"
@ -21,6 +22,7 @@ exports.patch = async function(ctx) {
let record = await db.get(ctx.params.id)
const model = await db.get(record.modelId)
const patchfields = ctx.request.body
record = coerceRecordValues(record, model)
for (let key of Object.keys(patchfields)) {
if (!model.schema[key]) continue
@ -75,6 +77,8 @@ exports.save = async function(ctx) {
const model = await db.get(record.modelId)
record = coerceRecordValues(record, model)
const validateResult = await validate({
record,
model,
@ -285,3 +289,50 @@ exports.fetchEnrichedRecord = async function(ctx) {
ctx.body = record
ctx.status = 200
}
function coerceRecordValues(rec, model) {
const record = cloneDeep(rec)
for (let [key, value] of Object.entries(record)) {
const field = model.schema[key]
if (!field) continue
// eslint-disable-next-line no-prototype-builtins
if (TYPE_TRANSFORM_MAP[field.type].hasOwnProperty(value)) {
record[key] = TYPE_TRANSFORM_MAP[field.type][value]
} else if (TYPE_TRANSFORM_MAP[field.type].parse) {
record[key] = TYPE_TRANSFORM_MAP[field.type].parse(value)
}
}
return record
}
const TYPE_TRANSFORM_MAP = {
string: {
"": "",
[null]: "",
[undefined]: undefined,
},
number: {
"": null,
[null]: null,
[undefined]: undefined,
parse: n => parseFloat(n),
},
datetime: {
"": null,
[undefined]: undefined,
[null]: null,
},
attachment: {
"": [],
[null]: [],
[undefined]: undefined,
},
boolean: {
"": null,
[null]: null,
[undefined]: undefined,
true: true,
false: false,
},
}

View File

@ -1,5 +1,5 @@
const send = require("koa-send")
const { resolve, join } = require("path")
const { resolve, join } = require("../../utilities/sanitisedPath")
const jwt = require("jsonwebtoken")
const fetch = require("node-fetch")
const fs = require("fs-extra")

View File

@ -1,7 +1,7 @@
const CouchDB = require("../../../db")
const viewTemplate = require("./viewBuilder")
const fs = require("fs")
const path = require("path")
const { join } = require("../../../utilities/sanitisedPath")
const os = require("os")
const exporters = require("./exporters")
const { fetchView } = require("../record")
@ -99,7 +99,8 @@ const controller = {
const exporter = exporters[format]
const exportedFile = exporter(headers, ctx.body)
const filename = `${view.name}.${format}`
fs.writeFileSync(path.join(os.tmpdir(), filename), exportedFile)
fs.writeFileSync(join(os.tmpdir(), filename), exportedFile)
ctx.body = {
url: `/api/views/export/download/${filename}`,
name: view.name,
@ -109,7 +110,7 @@ const controller = {
const filename = ctx.params.fileName
ctx.attachment(filename)
ctx.body = fs.createReadStream(path.join(os.tmpdir(), filename))
ctx.body = fs.createReadStream(join(os.tmpdir(), filename))
},
}

View File

@ -61,8 +61,11 @@ function parseFilterExpression(filters) {
`doc["${filter.key}"].${TOKEN_MAP[filter.condition]}("${filter.value}")`
)
} else {
const value =
typeof filter.value == "string" ? `"${filter.value}"` : filter.value
expression.push(
`doc["${filter.key}"] ${TOKEN_MAP[filter.condition]} "${filter.value}"`
`doc["${filter.key}"] ${TOKEN_MAP[filter.condition]} ${value}`
)
}
}

View File

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

View File

@ -49,13 +49,13 @@ exports.createModel = async (request, appId, instanceId, model) => {
key: "name",
schema: {
name: {
type: "text",
type: "string",
constraints: {
type: "string",
},
},
description: {
type: "text",
type: "string",
constraints: {
type: "string",
},

View File

@ -180,7 +180,7 @@ describe("/models", () => {
key: "name",
schema: {
name: {
type: "text",
type: "string",
constraints: {
type: "string",
},

View File

@ -38,7 +38,7 @@ describe("/records", () => {
const createRecord = async r =>
await request
.post(`/api/${model._id}/records`)
.post(`/api/${r ? r.modelId : record.modelId}/records`)
.send(r || record)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
@ -152,6 +152,95 @@ describe("/records", () => {
.expect('Content-Type', /json/)
.expect(404)
})
it("record values are coerced", async () => {
const str = {type:"string", constraints: { type: "string", presence: false }}
const attachment = {type:"attachment", constraints: { type: "array", presence: false }}
const bool = {type:"boolean", constraints: { type: "boolean", presence: false }}
const number = {type:"number", constraints: { type: "number", presence: false }}
const datetime = {type:"datetime", constraints: { type: "string", presence: false, datetime: {earliest:"", latest: ""} }}
model = await createModel(request, app._id, instance._id, {
name: "TestModel2",
type: "model",
key: "name",
schema: {
name: str,
stringUndefined: str,
stringNull: str,
stringString: str,
numberEmptyString: number,
numberNull: number,
numberUndefined: number,
numberString: number,
datetimeEmptyString: datetime,
datetimeNull: datetime,
datetimeUndefined: datetime,
datetimeString: datetime,
datetimeDate: datetime,
boolNull: bool,
boolEmpty: bool,
boolUndefined: bool,
boolString: bool,
boolBool: bool,
attachmentNull : attachment,
attachmentUndefined : attachment,
attachmentEmpty : attachment,
},
})
record = {
name: "Test Record",
stringUndefined: undefined,
stringNull: null,
stringString: "i am a string",
numberEmptyString: "",
numberNull: null,
numberUndefined: undefined,
numberString: "123",
numberNumber: 123,
datetimeEmptyString: "",
datetimeNull: null,
datetimeUndefined: undefined,
datetimeString: "1984-04-20T00:00:00.000Z",
datetimeDate: new Date("1984-04-20"),
boolNull: null,
boolEmpty: "",
boolUndefined: undefined,
boolString: "true",
boolBool: true,
modelId: model._id,
attachmentNull : null,
attachmentUndefined : undefined,
attachmentEmpty : "",
}
const id = (await createRecord(record)).body._id
const saved = (await loadRecord(id)).body
expect(saved.stringUndefined).toBe(undefined)
expect(saved.stringNull).toBe("")
expect(saved.stringString).toBe("i am a string")
expect(saved.numberEmptyString).toBe(null)
expect(saved.numberNull).toBe(null)
expect(saved.numberUndefined).toBe(undefined)
expect(saved.numberString).toBe(123)
expect(saved.numberNumber).toBe(123)
expect(saved.datetimeEmptyString).toBe(null)
expect(saved.datetimeNull).toBe(null)
expect(saved.datetimeUndefined).toBe(undefined)
expect(saved.datetimeString).toBe(new Date(record.datetimeString).toISOString())
expect(saved.datetimeDate).toBe(record.datetimeDate.toISOString())
expect(saved.boolNull).toBe(null)
expect(saved.boolEmpty).toBe(null)
expect(saved.boolUndefined).toBe(undefined)
expect(saved.boolString).toBe(true)
expect(saved.boolBool).toBe(true)
expect(saved.attachmentNull).toEqual([])
expect(saved.attachmentUndefined).toBe(undefined)
expect(saved.attachmentEmpty).toEqual([])
})
})
describe("patch", () => {

View File

@ -69,13 +69,13 @@ describe("/views", () => {
filters: [],
schema: {
name: {
type: "text",
type: "string",
constraints: {
type: "string"
},
},
description: {
type: "text",
type: "string",
constraints: {
type: "string"
},

View File

@ -6,7 +6,7 @@ const createUser = require("./steps/createUser")
const environment = require("../environment")
const download = require("download")
const fetch = require("node-fetch")
const path = require("path")
const { join } = require("../utilities/sanitisedPath")
const os = require("os")
const fs = require("fs")
const Sentry = require("@sentry/node")
@ -43,7 +43,7 @@ async function downloadPackage(name, version, bundleName) {
`${AUTOMATION_BUCKET}/${name}/${version}/${bundleName}`,
AUTOMATION_DIRECTORY
)
return require(path.join(AUTOMATION_DIRECTORY, bundleName))
return require(join(AUTOMATION_DIRECTORY, bundleName))
}
module.exports.getAction = async function(actionName) {
@ -57,7 +57,7 @@ module.exports.getAction = async function(actionName) {
const pkg = MANIFEST.packages[actionName]
const bundleName = buildBundleName(pkg.stepId, pkg.version)
try {
return require(path.join(AUTOMATION_DIRECTORY, bundleName))
return require(join(AUTOMATION_DIRECTORY, bundleName))
} catch (err) {
return downloadPackage(pkg.stepId, pkg.version, bundleName)
}
@ -66,7 +66,7 @@ module.exports.getAction = async function(actionName) {
module.exports.init = async function() {
// set defaults
if (!AUTOMATION_DIRECTORY) {
AUTOMATION_DIRECTORY = path.join(os.homedir(), DEFAULT_DIRECTORY)
AUTOMATION_DIRECTORY = join(os.homedir(), DEFAULT_DIRECTORY)
}
if (!AUTOMATION_BUCKET) {
AUTOMATION_BUCKET = DEFAULT_BUCKET

View File

@ -1,7 +1,3 @@
const environment = require("../../environment")
const sgMail = require("@sendgrid/mail")
sgMail.setApiKey(environment.SENDGRID_API_KEY)
module.exports.definition = {
description: "Send an email",
tagline: "Send email to {{inputs.to}}",
@ -13,6 +9,10 @@ module.exports.definition = {
schema: {
inputs: {
properties: {
apiKey: {
type: "string",
title: "SendGrid API key",
},
to: {
type: "string",
title: "Send To",
@ -49,6 +49,8 @@ module.exports.definition = {
}
module.exports.run = async function({ inputs }) {
const sgMail = require("@sendgrid/mail")
sgMail.setApiKey(inputs.apiKey)
const msg = {
to: inputs.to,
from: inputs.from,

View File

@ -2,6 +2,7 @@ const PouchDB = require("pouchdb")
const replicationStream = require("pouchdb-replication-stream")
const allDbs = require("pouchdb-all-dbs")
const { budibaseAppsDir } = require("../utilities/budibaseDir")
const { sanitise } = require("../utilities/sanitisedPath")
const env = require("../environment")
const COUCH_DB_URL = env.COUCH_DB_URL || `leveldb://${budibaseAppsDir()}/.data/`
@ -26,4 +27,10 @@ const Pouch = PouchDB.defaults(POUCH_DB_DEFAULTS)
allDbs(Pouch)
module.exports = Pouch
function PouchWrapper(instance) {
Pouch.apply(this, [sanitise(instance)])
}
PouchWrapper.prototype = Object.create(Pouch.prototype)
module.exports = PouchWrapper

View File

@ -1,5 +1,5 @@
const { app, BrowserWindow, shell, dialog } = require("electron")
const { join } = require("path")
const { join } = require("./utilities/sanitisedPath")
const isDev = require("electron-is-dev")
const { autoUpdater } = require("electron-updater")
const unhandled = require("electron-unhandled")

View File

@ -1,4 +1,4 @@
const { resolve, join } = require("path")
const { resolve, join } = require("./utilities/sanitisedPath")
const { homedir } = require("os")
const { app } = require("electron")
const fixPath = require("fix-path")

View File

@ -1,4 +1,4 @@
const { join } = require("path")
const { join } = require("./sanitisedPath")
const { homedir, tmpdir } = require("os")
const env = require("../environment")

View File

@ -6,7 +6,7 @@ const {
readFile,
writeJSON,
} = require("fs-extra")
const { join, resolve } = require("path")
const { join, resolve } = require("../sanitisedPath")
const sqrl = require("squirrelly")
const { convertCssToFiles } = require("./convertCssToFiles")
const publicPath = require("./publicPath")

View File

@ -1,6 +1,6 @@
const crypto = require("crypto")
const { ensureDir, emptyDir, writeFile } = require("fs-extra")
const { join } = require("path")
const { join } = require("../sanitisedPath")
module.exports.convertCssToFiles = async (publicPagePath, pkg) => {
const cssDir = join(publicPagePath, "css")

View File

@ -1,5 +1,5 @@
const { readJSON, readdir } = require("fs-extra")
const { join } = require("path")
const { join } = require("../sanitisedPath")
module.exports = async appPath => {
const pages = {}

View File

@ -8,7 +8,8 @@ const {
unlink,
rmdir,
} = require("fs-extra")
const { join, dirname, resolve } = require("path")
const { join, resolve } = require("../sanitisedPath")
const { dirname } = require("path")
const env = require("../../environment")
const buildPage = require("./buildPage")

View File

@ -1,6 +1,6 @@
const { appPackageFolder } = require("../createAppPackage")
const { readJSON, readdir, stat } = require("fs-extra")
const { join } = require("path")
const { join } = require("../sanitisedPath")
const { keyBy } = require("lodash/fp")
module.exports = async (config, appname, pagename) => {

View File

@ -1,3 +1,3 @@
const { join } = require("path")
const { join } = require("../sanitisedPath")
module.exports = (appPath, pageName) => join(appPath, "public", pageName)

View File

@ -1,4 +1,4 @@
const { resolve } = require("path")
const { resolve } = require("./sanitisedPath")
const { cwd } = require("process")
const stream = require("stream")
const fetch = require("node-fetch")

View File

@ -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,
}

View File

@ -1,5 +1,5 @@
const { exists, readFile, writeFile, ensureDir } = require("fs-extra")
const { join, resolve } = require("path")
const { join, resolve } = require("./sanitisedPath")
const Sqrl = require("squirrelly")
const uuid = require("uuid")

View File

@ -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]
}

View File

@ -1,5 +1,5 @@
const path = require("path")
const fs = require("fs-extra")
const { join } = require("./sanitisedPath")
const os = require("os")
const fetch = require("node-fetch")
const stream = require("stream")
@ -27,10 +27,10 @@ exports.downloadTemplate = async function(type, name) {
await streamPipeline(
response.body,
zlib.Unzip(),
tar.extract(path.join(budibaseAppsDir(), "templates", type))
tar.extract(join(budibaseAppsDir(), "templates", type))
)
return path.join(budibaseAppsDir(), "templates", type, name)
return join(budibaseAppsDir(), "templates", type, name)
}
exports.exportTemplateFromApp = async function({
@ -39,15 +39,17 @@ exports.exportTemplateFromApp = async function({
instanceId,
}) {
// Copy frontend files
const appToExport = path.join(os.homedir(), ".budibase", appId, "pages")
const templatesDir = path.join(os.homedir(), ".budibase", "templates")
const appToExport = join(os.homedir(), ".budibase", appId, "pages")
const templatesDir = join(os.homedir(), ".budibase", "templates")
fs.ensureDirSync(templatesDir)
const templateOutputPath = path.join(templatesDir, templateName)
fs.copySync(appToExport, `${templateOutputPath}/pages`)
const templateOutputPath = join(templatesDir, templateName)
fs.copySync(appToExport, join(templateOutputPath, "pages"))
fs.ensureDirSync(path.join(templateOutputPath, "db"))
const writeStream = fs.createWriteStream(`${templateOutputPath}/db/dump.txt`)
fs.ensureDirSync(join(templateOutputPath, "db"))
const writeStream = fs.createWriteStream(
join(templateOutputPath, "db", "dump.txt")
)
// perform couch dump
const instanceDb = new CouchDB(instanceId)

View File

@ -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",
},
]
`;

View File

@ -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()
});
});
});

View File

@ -0,0 +1,4 @@
"Name","Age","Address"
"Bert","4324","5 Sesame Street"
"Ernie","34","1 World Trade Center"
"Big Bird","23423","44 Second Avenue"
1 Name Age Address
2 Bert 4324 5 Sesame Street
3 Ernie 34 1 World Trade Center
4 Big Bird 23423 44 Second Avenue

View File

@ -172,10 +172,10 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@budibase/client@^0.1.23":
version "0.1.23"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.23.tgz#d72d2b26ff3a2d99f2b6c1b71020b1136880937d"
integrity sha512-pZdwdCq5kKLZfZYxasIHBNnqu3BFFrqJLxXMFs0K9ddCVZ0UNons59nn73nFGbeRgNVdWp6yyW71XyMQr8NOEw==
"@budibase/client@^0.1.25":
version "0.1.25"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.25.tgz#f08c4a614f9018eb0f0faa6d20bb05f7a3215c70"
integrity sha512-vZ0cqJwLYcs7MHihFnJO3qOe7qxibnB4Va1+IYNfnPc9kcxy4KvfQxCx/G/DDxP9CXfEvsguy9ymzR3RUAvBHw==
dependencies:
deep-equal "^2.0.1"
mustache "^4.0.1"
@ -1079,9 +1079,10 @@ bluebird-lst@^1.0.9:
dependencies:
bluebird "^3.5.5"
bluebird@^3.5.5:
bluebird@^3.5.1, bluebird@^3.5.5:
version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
boolean@^3.0.0, boolean@^3.0.1:
version "3.0.1"
@ -1618,6 +1619,15 @@ cssstyle@^1.0.0:
dependencies:
cssom "0.3.x"
csvtojson@^2.0.10:
version "2.0.10"
resolved "https://registry.yarnpkg.com/csvtojson/-/csvtojson-2.0.10.tgz#11e7242cc630da54efce7958a45f443210357574"
integrity sha512-lUWFxGKyhraKCW8Qghz6Z0f2l/PqB1W3AO0HKJzGIQ5JRSlR651ekJDiGJbBT4sRNNv5ddnSGVEnsxP9XRCVpQ==
dependencies:
bluebird "^3.5.1"
lodash "^4.17.3"
strip-bom "^2.0.0"
dashdash@^1.12.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
@ -3455,6 +3465,11 @@ is-typedarray@^1.0.0, is-typedarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
is-utf8@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
is-weakmap@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2"
@ -4347,6 +4362,13 @@ lie@3.0.4:
inline-process-browser "^1.0.0"
unreachable-branch-transform "^0.3.0"
lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=
dependencies:
immediate "~3.0.5"
lines-and-columns@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
@ -4409,6 +4431,11 @@ lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
lodash.pick@^4.0.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@ -4417,6 +4444,11 @@ lodash@^4.17.10, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15:
version "4.17.19"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
lodash@^4.17.3:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
loose-envify@^1.0.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@ -4671,6 +4703,16 @@ natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
ndjson@^1.4.3:
version "1.5.0"
resolved "https://registry.yarnpkg.com/ndjson/-/ndjson-1.5.0.tgz#ae603b36b134bcec347b452422b0bf98d5832ec8"
integrity sha1-rmA7NrE0vOw0e0UkIrC/mNWDLsg=
dependencies:
json-stringify-safe "^5.0.1"
minimist "^1.2.0"
split2 "^2.1.0"
through2 "^2.0.3"
negotiator@0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@ -5164,6 +5206,14 @@ posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
pouch-stream@^0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/pouch-stream/-/pouch-stream-0.4.1.tgz#0c6d8475c9307677627991a2f079b301c3b89bdd"
integrity sha1-DG2EdckwdndieZGi8HmzAcO4m90=
dependencies:
inherits "^2.0.1"
readable-stream "^1.0.27-1"
pouchdb-adapter-leveldb-core@7.2.1:
version "7.2.1"
resolved "https://registry.yarnpkg.com/pouchdb-adapter-leveldb-core/-/pouchdb-adapter-leveldb-core-7.2.1.tgz#71bf2a05755689e2b05e78e796003a18ebf65a69"
@ -5251,6 +5301,26 @@ pouchdb-promise@5.4.3:
dependencies:
lie "3.0.4"
pouchdb-promise@^6.0.4:
version "6.4.3"
resolved "https://registry.yarnpkg.com/pouchdb-promise/-/pouchdb-promise-6.4.3.tgz#74516f4acf74957b54debd0fb2c0e5b5a68ca7b3"
integrity sha512-ruJaSFXwzsxRHQfwNHjQfsj58LBOY1RzGzde4PM5CWINZwFjCQAhZwfMrch2o/0oZT6d+Xtt0HTWhq35p3b0qw==
dependencies:
lie "3.1.1"
pouchdb-replication-stream@^1.2.9:
version "1.2.9"
resolved "https://registry.yarnpkg.com/pouchdb-replication-stream/-/pouchdb-replication-stream-1.2.9.tgz#aa4fa5d8f52df4825392f18e07c7e11acffc650a"
integrity sha1-qk+l2PUt9IJTkvGOB8fhGs/8ZQo=
dependencies:
argsarray "0.0.1"
inherits "^2.0.3"
lodash.pick "^4.0.0"
ndjson "^1.4.3"
pouch-stream "^0.4.0"
pouchdb-promise "^6.0.4"
through2 "^2.0.0"
pouchdb-utils@7.2.1:
version "7.2.1"
resolved "https://registry.yarnpkg.com/pouchdb-utils/-/pouchdb-utils-7.2.1.tgz#5dec1c53c8ecba717e5762311e9a1def2d4ebf9c"
@ -5511,7 +5581,17 @@ readable-stream@1.0.33:
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.5:
readable-stream@^1.0.27-1:
version "1.1.14"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@~2.3.6:
version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
dependencies:
@ -6088,6 +6168,13 @@ split-string@^3.0.1, split-string@^3.0.2:
dependencies:
extend-shallow "^3.0.0"
split2@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493"
integrity sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==
dependencies:
through2 "^2.0.2"
split2@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/split2/-/split2-3.1.1.tgz#c51f18f3e06a8c4469aaab487687d8d956160bb6"
@ -6262,6 +6349,13 @@ strip-ansi@^6.0.0:
dependencies:
ansi-regex "^5.0.0"
strip-bom@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
dependencies:
is-utf8 "^0.2.0"
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
@ -6445,6 +6539,14 @@ through2@^0.6.2, through2@^0.6.5:
readable-stream ">=1.0.33-1 <1.1.0-0"
xtend ">=4.0.0 <4.1.0-0"
through2@^2.0.0, through2@^2.0.2, through2@^2.0.3:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
dependencies:
readable-stream "~2.3.6"
xtend "~4.0.1"
through@^2.3.6, through@^2.3.8, through@~2.3.4:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
@ -6968,7 +7070,7 @@ xmlbuilder@~9.0.1:
version "9.0.7"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.2, xtend@~4.0.0:
"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.2, xtend@~4.0.0, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"

View File

@ -13,7 +13,7 @@
"dev:builder": "rollup -cw"
},
"devDependencies": {
"@budibase/client": "^0.1.23",
"@budibase/client": "^0.1.25",
"@rollup/plugin-commonjs": "^11.1.0",
"lodash": "^4.17.15",
"rollup": "^1.11.0",
@ -31,12 +31,12 @@
"keywords": [
"svelte"
],
"version": "0.1.23",
"version": "0.1.25",
"license": "MIT",
"gitHead": "284cceb9b703c38566c6e6363c022f79a08d5691",
"dependencies": {
"@beyonk/svelte-googlemaps": "^2.2.0",
"@budibase/bbui": "^1.39.0",
"@budibase/bbui": "^1.40.1",
"@fortawesome/fontawesome-free": "^5.14.0",
"britecharts": "^2.16.1",
"d3-selection": "^1.4.2",

View File

@ -1,9 +1,19 @@
<script>
import { onMount } from "svelte"
import { fade } from "svelte/transition"
import { Label, DatePicker } from "@budibase/bbui"
import {
Label,
DatePicker,
Input,
Select,
Button,
Toggle,
} from "@budibase/bbui"
import Dropzone from "./attachments/Dropzone.svelte"
import LinkedRecordSelector from "./LinkedRecordSelector.svelte"
import debounce from "lodash.debounce"
import ErrorsBox from "./ErrorsBox.svelte"
import { capitalise } from "./helpers"
export let _bb
export let model
@ -32,16 +42,11 @@
let isNew = true
let errors = {}
$: fields = schema ? Object.keys(schema) : []
$: if (model && model.length !== 0) {
fetchModel()
}
$: fields = schema ? Object.keys(schema) : []
$: errorMessages = Object.entries(errors).map(
([field, message]) => `${field} ${message}`
)
async function fetchModel() {
const FETCH_MODEL_URL = `/api/models/${model}`
const response = await _bb.api.get(FETCH_MODEL_URL)
@ -82,11 +87,13 @@
saved = true
setTimeout(() => {
saved = false
}, 1000)
}, 3000)
}
if (response.status === 400) {
errors = json.errors
errors = Object.keys(json.errors)
.map(k => ({ dataPath: k, message: json.errors[k] }))
.flat()
}
})
@ -103,8 +110,7 @@
const GET_RECORD_URL = `/api/${model}/records/${recordId}`
const response = await _bb.api.get(GET_RECORD_URL)
const json = await response.json()
record = json
record = await response.json()
})
</script>
@ -112,44 +118,52 @@
{#if title}
<h1>{title}</h1>
{/if}
{#each errorMessages as error}
<p class="error">{error}</p>
{/each}
<hr />
<div class="form-content">
<ErrorsBox {errors} />
{#each fields as field}
<div class="form-item">
<Label small forAttr={'form-stacked-text'}>{field}</Label>
{#if schema[field].type === 'string' && schema[field].constraints.inclusion}
<select bind:value={record[field]}>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={record[field]} />
{:else if schema[field].type === 'boolean'}
<input class="input" type="checkbox" bind:checked={record[field]} />
{:else if schema[field].type === 'number'}
<input class="input" type="number" bind:value={record[field]} />
{:else if schema[field].type === 'string'}
<input class="input" type="text" bind:value={record[field]} />
{:else if schema[field].type === 'attachment'}
{#if schema[field].type === 'options'}
<Select
secondary
label={capitalise(schema[field].name)}
bind:value={record[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker
label={capitalise(schema[field].name)}
bind:value={record[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle
text={capitalise(schema[field].name)}
bind:checked={record[field]} />
{:else if schema[field].type === 'number'}
<Input
label={capitalise(schema[field].name)}
type="number"
bind:value={record[field]} />
{:else if schema[field].type === 'string'}
<Input
label={capitalise(schema[field].name)}
bind:value={record[field]} />
{:else if schema[field].type === 'attachment'}
<div>
<Label extraSmall grey>{schema[field].name}</Label>
<Dropzone bind:files={record[field]} />
{/if}
</div>
<hr />
</div>
{:else if schema[field].type === 'link'}
<LinkedRecordSelector
secondary
bind:linkedRecords={record[field]}
schema={schema[field]} />
{/if}
{/each}
<div class="button-block">
<button on:click={save} class:saved>
{#if saved}
<div in:fade>
<span class:saved>Success</span>
</div>
{:else}
<div>{buttonText || 'Submit Form'}</div>
{/if}
</button>
<div class="buttons">
<Button primary on:click={save} green={saved}>
{#if saved}Success{:else}{buttonText || 'Submit Form'}{/if}
</Button>
</div>
</div>
</form>
@ -162,104 +176,14 @@
}
.form-content {
margin-bottom: 20px;
margin-bottom: var(--spacing-xl);
display: grid;
gap: var(--spacing-xl);
width: 100%;
}
.input {
border-radius: 5px;
border: 1px solid #e6e6e6;
padding: 1rem;
font-size: 16px;
}
.form-item {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
hr {
border: 1px solid var(--grey-1);
margin: 20px 0px;
}
hr:nth-last-child(2) {
border: 1px solid #fff;
margin: 20px 0px;
}
.button-block {
.buttons {
display: flex;
justify-content: flex-end;
}
button {
font-size: 16px;
padding: 0.4em;
box-sizing: border-box;
border-radius: 4px;
color: white;
background-color: #393c44;
outline: none;
width: 300px;
height: 40px;
cursor: pointer;
transition: all 0.2s ease 0s;
overflow: hidden;
outline: none;
user-select: none;
white-space: nowrap;
text-align: center;
}
button.saved {
background-color: #84c991;
border: none;
}
button:hover {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
input[type="checkbox"] {
width: 32px;
height: 32px;
padding: 0;
margin: 0;
vertical-align: bottom;
position: relative;
top: -1px;
*overflow: hidden;
}
select::-ms-expand {
display: none;
}
select {
display: inline-block;
cursor: pointer;
align-items: baseline;
box-sizing: border-box;
padding: 1em 1em;
border: 1px solid #eaeaea;
border-radius: 5px;
font: inherit;
line-height: inherit;
-webkit-appearance: none;
-moz-appearance: none;
-ms-appearance: none;
appearance: none;
background-repeat: no-repeat;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: right 17px top 1.5em, right 10px top 1.5em;
background-size: 7px 7px, 7px 7px;
}
.error {
color: red;
font-weight: 500;
}
</style>

View File

@ -1,6 +1,6 @@
<script>
import { onMount } from "svelte"
import { cssVars, createClasses } from "./cssVars"
import { cssVars } from "./cssVars"
import ArrowUp from "./icons/ArrowUp.svelte"
import ArrowDown from "./icons/ArrowDown.svelte"
import fsort from "fast-sort"
@ -13,6 +13,7 @@
export let stripeColor
export let borderColor
export let datasource = {}
export let _bb
let data = []
let headers = []
@ -29,11 +30,19 @@
$: sorted = sort.direction ? fsort(data)[sort.direction](sort.column) : data
async function fetchModel(modelId) {
const FETCH_MODEL_URL = `/api/models/${modelId}`
const response = await _bb.api.get(FETCH_MODEL_URL)
const model = await response.json()
schema = model.schema
}
onMount(async () => {
if (!isEmpty(datasource)) {
data = await fetchData(datasource)
if (data) {
headers = Object.keys(data[0]).filter(shouldDisplayField)
if (data && data.length) {
await fetchModel(data[0].modelId)
headers = Object.keys(schema).filter(shouldDisplayField)
}
}
})
@ -85,11 +94,15 @@
{#each sorted as row (row._id)}
<tr>
{#each headers as header}
<!-- Rudimentary solution for attachments on array given this entire table will be replaced by AG Grid -->
{#if Array.isArray(row[header])}
<AttachmentList files={row[header]} />
{:else if row[header]}
<td>{row[header]}</td>
{#if schema[header]}
<!-- Rudimentary solution for attachments on array given this entire table will be replaced by AG Grid -->
{#if schema[header].type === 'attachment'}
<AttachmentList files={row[header]} />
{:else if schema[header].type === 'link'}
<td>{row[header] ? row[header].length : 0} related row(s)</td>
{:else if row[header]}
<td>{row[header]}</td>
{/if}
{/if}
{/each}
</tr>

View File

@ -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>

View File

@ -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}

View File

@ -8,6 +8,12 @@
let store = _bb.store
let target
async function fetchModel(id) {
const FETCH_MODEL_URL = `/api/models/${id}`
const response = await _bb.api.get(FETCH_MODEL_URL)
return await response.json()
}
async function fetchFirstRecord() {
const FETCH_RECORDS_URL = `/api/views/all_${model}`
const response = await _bb.api.get(FETCH_RECORDS_URL)
@ -34,6 +40,14 @@
}
if (record) {
// Fetch model schema so we can check for linked records
const model = await fetchModel(record.modelId)
for (let key of Object.keys(model.schema)) {
if (model.schema[key].type === "link") {
record[key] = Array.isArray(record[key]) ? record[key].length : 0
}
}
_bb.attachChildren(target, {
hydrate: false,
context: record,

View File

@ -1,7 +1,7 @@
<script>
import { FILE_TYPES } from "./fileTypes"
export let files
export let files = []
export let height = "70"
export let width = "70"
</script>

View File

@ -4,7 +4,28 @@ export default async function fetchData(datasource) {
const { isModel, name } = datasource
if (name) {
return isModel ? await fetchModelData() : await fetchViewData()
const records = isModel ? await fetchModelData() : await fetchViewData()
// Fetch model schema so we can check for linked records
if (records && records.length) {
const model = await fetchModel(records[0].modelId)
const keys = Object.keys(model.schema)
records.forEach(record => {
for (let key of keys) {
if (model.schema[key].type === "link") {
record[key] = Array.isArray(record[key]) ? record[key].length : 0
}
}
})
}
return records
}
async function fetchModel(id) {
const FETCH_MODEL_URL = `/api/models/${id}`
const response = await api.get(FETCH_MODEL_URL)
return await response.json()
}
async function fetchModelData() {

View File

@ -0,0 +1 @@
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)

View File

@ -1,3 +1,9 @@
# Budibase is in Beta
Budibase is currently beta software. Until our official launch, we cannot ensure backwards compatibility for your budibase applications between versions. Issues may arise when trying to edit apps created with old versions of the budibase builder.
If you are having issues between updates of the builder, please use the guide [here](https://github.com/Budibase/budibase/blob/master/CONTRIBUTING.md#troubleshooting) to clear down your environment.
# What is Budibase?

View File

@ -913,11 +913,6 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
argsarray@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/argsarray/-/argsarray-0.0.1.tgz#6e7207b4ecdb39b0af88303fa5ae22bda8df61cb"
integrity sha1-bnIHtOzbObCviDA/pa4ivajfYcs=
arr-diff@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@ -2336,11 +2331,6 @@ ignore@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
immediate@~3.0.5:
version "3.0.6"
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
import-fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546"
@ -2387,7 +2377,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
@ -2638,11 +2628,6 @@ is-windows@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
isarray@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
isarray@1.0.0, isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@ -2805,13 +2790,6 @@ libnpmpublish@^1.1.1:
semver "^5.5.1"
ssri "^6.0.1"
lie@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=
dependencies:
immediate "~3.0.5"
load-json-file@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@ -2861,11 +2839,6 @@ lodash.ismatch@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37"
lodash.pick@^4.0.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=
lodash.set@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@ -3204,16 +3177,6 @@ natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
ndjson@^1.4.3:
version "1.5.0"
resolved "https://registry.yarnpkg.com/ndjson/-/ndjson-1.5.0.tgz#ae603b36b134bcec347b452422b0bf98d5832ec8"
integrity sha1-rmA7NrE0vOw0e0UkIrC/mNWDLsg=
dependencies:
json-stringify-safe "^5.0.1"
minimist "^1.2.0"
split2 "^2.1.0"
through2 "^2.0.3"
neo-async@^2.6.0:
version "2.6.1"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
@ -3717,34 +3680,6 @@ posix-character-classes@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
pouch-stream@^0.4.0:
version "0.4.1"
resolved "https://registry.yarnpkg.com/pouch-stream/-/pouch-stream-0.4.1.tgz#0c6d8475c9307677627991a2f079b301c3b89bdd"
integrity sha1-DG2EdckwdndieZGi8HmzAcO4m90=
dependencies:
inherits "^2.0.1"
readable-stream "^1.0.27-1"
pouchdb-promise@^6.0.4:
version "6.4.3"
resolved "https://registry.yarnpkg.com/pouchdb-promise/-/pouchdb-promise-6.4.3.tgz#74516f4acf74957b54debd0fb2c0e5b5a68ca7b3"
integrity sha512-ruJaSFXwzsxRHQfwNHjQfsj58LBOY1RzGzde4PM5CWINZwFjCQAhZwfMrch2o/0oZT6d+Xtt0HTWhq35p3b0qw==
dependencies:
lie "3.1.1"
pouchdb-replication-stream@^1.2.9:
version "1.2.9"
resolved "https://registry.yarnpkg.com/pouchdb-replication-stream/-/pouchdb-replication-stream-1.2.9.tgz#aa4fa5d8f52df4825392f18e07c7e11acffc650a"
integrity sha1-qk+l2PUt9IJTkvGOB8fhGs/8ZQo=
dependencies:
argsarray "0.0.1"
inherits "^2.0.3"
lodash.pick "^4.0.0"
ndjson "^1.4.3"
pouch-stream "^0.4.0"
pouchdb-promise "^6.0.4"
through2 "^2.0.0"
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@ -3927,16 +3862,6 @@ read@1, read@~1.0.1:
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readable-stream@^1.0.27-1:
version "1.1.14"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
readdir-scoped-modules@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
@ -4291,7 +4216,7 @@ split-string@^3.0.1, split-string@^3.0.2:
dependencies:
extend-shallow "^3.0.0"
split2@^2.0.0, split2@^2.1.0:
split2@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493"
dependencies:
@ -4396,11 +4321,6 @@ string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@ -4521,7 +4441,7 @@ text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
through2@^2.0.0, through2@^2.0.2, through2@^2.0.3:
through2@^2.0.0, through2@^2.0.2:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
dependencies: