1
0
Fork 0
mirror of synced 2024-09-28 15:21:28 +12:00

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", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View file

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

View file

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

View file

@ -8,7 +8,7 @@ context("Create a Table", () => {
cy.createTable("dog") cy.createTable("dog")
// Check if Table exists // 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", () => { 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", () => { it("creates a stats calculation view based on age", () => {
cy.contains("Calculate").click() 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") cy.get(".menu-container")
.find("select") .find("select")
.first() .eq(0)
.select("Statistics")
cy.get(".menu-container")
.find("select")
.eq(1)
.select("age") .select("age")
cy.contains("Save").click() cy.contains("Save").click()
cy.get("thead th div").should($headers => { 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.contains("Create New Table").click()
cy.get(".menu-container") cy.get(".menu-container")
.get("input") .get("input")
.first()
.type(tableName) .type(tableName)
cy.contains("Save").click() cy.contains("Save").click()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script> <script>
import { Button, Input, Select } from "@budibase/bbui" import { Button, Input, Select, DatePicker } from "@budibase/bbui"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import analytics from "analytics" import analytics from "analytics"
@ -71,11 +71,38 @@
function isMultipleChoice(field) { function isMultipleChoice(field) {
return ( return (
viewModel.schema[field].constraints && (viewModel.schema[field].constraints &&
viewModel.schema[field].constraints.inclusion && viewModel.schema[field].constraints.inclusion &&
viewModel.schema[field].constraints.inclusion.length 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> </script>
<div class="actions"> <div class="actions">
@ -93,7 +120,11 @@
{/each} {/each}
</Select> </Select>
{/if} {/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> <option value="">Choose an option</option>
{#each fields as field} {#each fields as field}
<option value={field}>{field}</option> <option value={field}>{field}</option>
@ -108,12 +139,25 @@
{#if filter.key && isMultipleChoice(filter.key)} {#if filter.key && isMultipleChoice(filter.key)}
<Select secondary thin bind:value={filter.value}> <Select secondary thin bind:value={filter.value}>
<option value="">Choose an option</option> <option value="">Choose an option</option>
{#each viewModel.schema[filter.key].constraints.inclusion as option} {#each fieldOptions(filter.key) as option}
<option value={option}>{option}</option> <option value={option}>{option.toString()}</option>
{/each} {/each}
</Select> </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} {:else}
<Input thin placeholder="Value" bind:value={filter.value} /> <Input
thin
placeholder={filter.key || fields[0]}
bind:value={filter.value} />
{/if} {/if}
<i class="ri-close-circle-fill" on:click={() => removeFilter(idx)} /> <i class="ri-close-circle-fill" on:click={() => removeFilter(idx)} />
{/each} {/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 { goto } from "@sveltech/routify"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications" 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" import analytics from "analytics"
let anchor let anchor
let dropdown let dropdown
let name let name
let dataImport
let loading
async function saveTable() { async function saveTable() {
loading = true
const model = await backendUiStore.actions.models.save({ const model = await backendUiStore.actions.models.save({
name, name,
schema: {}, schema: dataImport.schema || {},
dataImport,
}) })
notifier.success(`Table ${name} created successfully.`) notifier.success(`Table ${name} created successfully.`)
$goto(`./model/${model._id}`) $goto(`./model/${model._id}`)
analytics.captureEvent("Table Created", { name })
name = "" name = ""
dropdown.hide() dropdown.hide()
analytics.captureEvent("Table Created", { name }) loading = false
} }
const onClosed = () => { const onClosed = () => {
@ -38,9 +45,21 @@
thin thin
label="Table Name" label="Table Name"
bind:value={name} /> bind:value={name} />
<div>
<Label grey extraSmall>Create Table from CSV (Optional)</Label>
<TableDataImport bind:dataImport />
</div>
<footer> <footer>
<Button secondary on:click={onClosed}>Cancel</Button> <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> </footer>
</div> </div>
</Popover> </Popover>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
const fs = require("fs") const fs = require("fs")
const { join } = require("../../utilities/sanitisedPath")
const readline = require("readline") const readline = require("readline")
const { budibaseAppsDir } = require("../../utilities/budibaseDir") const { budibaseAppsDir } = require("../../utilities/budibaseDir")
const ENV_FILE_PATH = "/.env" const ENV_FILE_PATH = "/.env"
@ -7,7 +8,6 @@ exports.fetch = async function(ctx) {
ctx.status = 200 ctx.status = 200
ctx.body = { ctx.body = {
budibase: process.env.BUDIBASE_API_KEY, budibase: process.env.BUDIBASE_API_KEY,
sendgrid: process.env.SENDGRID_API_KEY,
userId: process.env.USERID_API_KEY, userId: process.env.USERID_API_KEY,
} }
} }
@ -29,8 +29,9 @@ exports.update = async function(ctx) {
async function updateValues([key, value]) { async function updateValues([key, value]) {
let newContent = "" let newContent = ""
let keyExists = false let keyExists = false
let envPath = join(budibaseAppsDir(), ENV_FILE_PATH)
const readInterface = readline.createInterface({ const readInterface = readline.createInterface({
input: fs.createReadStream(`${budibaseAppsDir()}/${ENV_FILE_PATH}`), input: fs.createReadStream(envPath),
output: process.stdout, output: process.stdout,
console: false, console: false,
}) })
@ -48,6 +49,6 @@ async function updateValues([key, value]) {
// Add API Key if it doesn't exist in the file at all // Add API Key if it doesn't exist in the file at all
newContent = `${newContent}\n${key}=${value}` 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 { getPackageForBuilder, buildPage } = require("../../utilities/builder")
const env = require("../../environment") const env = require("../../environment")
const instanceController = require("./instance") const instanceController = require("./instance")
const { resolve, join } = require("path")
const { copy, exists, readFile, writeFile } = require("fs-extra") const { copy, exists, readFile, writeFile } = require("fs-extra")
const { budibaseAppsDir } = require("../../utilities/budibaseDir") const { budibaseAppsDir } = require("../../utilities/budibaseDir")
const sqrl = require("squirrelly") const sqrl = require("squirrelly")
const setBuilderToken = require("../../utilities/builder/setBuilderToken") const setBuilderToken = require("../../utilities/builder/setBuilderToken")
const fs = require("fs-extra") const fs = require("fs-extra")
const { join, resolve } = require("../../utilities/sanitisedPath")
const { promisify } = require("util") const { promisify } = require("util")
const chmodr = require("chmodr") const chmodr = require("chmodr")
const { generateAppID, getAppParams } = require("../../db/utils") const { generateAppID, getAppParams } = require("../../db/utils")
@ -116,7 +116,7 @@ exports.delete = async function(ctx) {
const db = new CouchDB(ClientDb.name(getClientId(ctx))) const db = new CouchDB(ClientDb.name(getClientId(ctx)))
const app = await db.get(ctx.params.applicationId) const app = await db.get(ctx.params.applicationId)
const result = await db.remove(app) const result = await db.remove(app)
await fs.rmdir(`${budibaseAppsDir()}/${ctx.params.applicationId}`, { await fs.rmdir(join(budibaseAppsDir(), ctx.params.applicationId), {
recursive: true, recursive: true,
}) })

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const linkRecords = require("../../db/linkedRecords") const linkRecords = require("../../db/linkedRecords")
const csvParser = require("../../utilities/csvParser")
const { const {
getRecordParams, getRecordParams,
getModelParams, getModelParams,
generateModelID, generateModelID,
generateRecordID,
} = require("../../db/utils") } = require("../../db/utils")
exports.fetch = async function(ctx) { exports.fetch = async function(ctx) {
@ -24,11 +26,12 @@ exports.find = async function(ctx) {
exports.save = async function(ctx) { exports.save = async function(ctx) {
const instanceId = ctx.user.instanceId const instanceId = ctx.user.instanceId
const db = new CouchDB(instanceId) const db = new CouchDB(instanceId)
const { dataImport, ...rest } = ctx.request.body
const modelToSave = { const modelToSave = {
type: "model", type: "model",
_id: generateModelID(), _id: generateModelID(),
views: {}, views: {},
...ctx.request.body, ...rest,
} }
// if the model obj had an _id then it will have been retrieved // 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 &&
ctx.eventEmitter.emitModel(`model:save`, instanceId, modelToSave) 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.status = 200
ctx.message = `Model ${ctx.request.body.name} saved successfully.` ctx.message = `Model ${ctx.request.body.name} saved successfully.`
ctx.body = modelToSave ctx.body = modelToSave
@ -116,3 +132,12 @@ exports.destroy = async function(ctx) {
ctx.status = 200 ctx.status = 200
ctx.message = `Model ${ctx.params.modelId} deleted.` ctx.message = `Model ${ctx.params.modelId} deleted.`
} }
exports.validateCSVSchema = async function(ctx) {
const { file, schema = {} } = ctx.request.body
const result = await csvParser.parse(file.path, schema)
ctx.body = {
schema: result,
path: file.path,
}
}

View file

@ -2,6 +2,7 @@ const CouchDB = require("../../db")
const validateJs = require("validate.js") const validateJs = require("validate.js")
const linkRecords = require("../../db/linkedRecords") const linkRecords = require("../../db/linkedRecords")
const { getRecordParams, generateRecordID } = require("../../db/utils") const { getRecordParams, generateRecordID } = require("../../db/utils")
const { cloneDeep } = require("lodash")
const MODEL_VIEW_BEGINS_WITH = "all_model:" const MODEL_VIEW_BEGINS_WITH = "all_model:"
@ -21,6 +22,7 @@ exports.patch = async function(ctx) {
let record = await db.get(ctx.params.id) let record = await db.get(ctx.params.id)
const model = await db.get(record.modelId) const model = await db.get(record.modelId)
const patchfields = ctx.request.body const patchfields = ctx.request.body
record = coerceRecordValues(record, model)
for (let key of Object.keys(patchfields)) { for (let key of Object.keys(patchfields)) {
if (!model.schema[key]) continue if (!model.schema[key]) continue
@ -75,6 +77,8 @@ exports.save = async function(ctx) {
const model = await db.get(record.modelId) const model = await db.get(record.modelId)
record = coerceRecordValues(record, model)
const validateResult = await validate({ const validateResult = await validate({
record, record,
model, model,
@ -285,3 +289,50 @@ exports.fetchEnrichedRecord = async function(ctx) {
ctx.body = record ctx.body = record
ctx.status = 200 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 send = require("koa-send")
const { resolve, join } = require("path") const { resolve, join } = require("../../utilities/sanitisedPath")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const fetch = require("node-fetch") const fetch = require("node-fetch")
const fs = require("fs-extra") const fs = require("fs-extra")

View file

@ -1,7 +1,7 @@
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const viewTemplate = require("./viewBuilder") const viewTemplate = require("./viewBuilder")
const fs = require("fs") const fs = require("fs")
const path = require("path") const { join } = require("../../../utilities/sanitisedPath")
const os = require("os") const os = require("os")
const exporters = require("./exporters") const exporters = require("./exporters")
const { fetchView } = require("../record") const { fetchView } = require("../record")
@ -99,7 +99,8 @@ const controller = {
const exporter = exporters[format] const exporter = exporters[format]
const exportedFile = exporter(headers, ctx.body) const exportedFile = exporter(headers, ctx.body)
const filename = `${view.name}.${format}` const filename = `${view.name}.${format}`
fs.writeFileSync(path.join(os.tmpdir(), filename), exportedFile) fs.writeFileSync(join(os.tmpdir(), filename), exportedFile)
ctx.body = { ctx.body = {
url: `/api/views/export/download/${filename}`, url: `/api/views/export/download/${filename}`,
name: view.name, name: view.name,
@ -109,7 +110,7 @@ const controller = {
const filename = ctx.params.fileName const filename = ctx.params.fileName
ctx.attachment(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}")` `doc["${filter.key}"].${TOKEN_MAP[filter.condition]}("${filter.value}")`
) )
} else { } else {
const value =
typeof filter.value == "string" ? `"${filter.value}"` : filter.value
expression.push( 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 modelController.find
) )
.post("/api/models", authorized(BUILDER), usage, modelController.save) .post("/api/models", authorized(BUILDER), usage, modelController.save)
.post(
"/api/models/csv/validate",
authorized(BUILDER),
modelController.validateCSVSchema
)
.delete( .delete(
"/api/models/:modelId/:revId", "/api/models/:modelId/:revId",
authorized(BUILDER), authorized(BUILDER),

View file

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

View file

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

View file

@ -38,7 +38,7 @@ describe("/records", () => {
const createRecord = async r => const createRecord = async r =>
await request await request
.post(`/api/${model._id}/records`) .post(`/api/${r ? r.modelId : record.modelId}/records`)
.send(r || record) .send(r || record)
.set(defaultHeaders(app._id, instance._id)) .set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -152,6 +152,95 @@ describe("/records", () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(404) .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", () => { describe("patch", () => {

View file

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

View file

@ -6,7 +6,7 @@ const createUser = require("./steps/createUser")
const environment = require("../environment") const environment = require("../environment")
const download = require("download") const download = require("download")
const fetch = require("node-fetch") const fetch = require("node-fetch")
const path = require("path") const { join } = require("../utilities/sanitisedPath")
const os = require("os") const os = require("os")
const fs = require("fs") const fs = require("fs")
const Sentry = require("@sentry/node") const Sentry = require("@sentry/node")
@ -43,7 +43,7 @@ async function downloadPackage(name, version, bundleName) {
`${AUTOMATION_BUCKET}/${name}/${version}/${bundleName}`, `${AUTOMATION_BUCKET}/${name}/${version}/${bundleName}`,
AUTOMATION_DIRECTORY AUTOMATION_DIRECTORY
) )
return require(path.join(AUTOMATION_DIRECTORY, bundleName)) return require(join(AUTOMATION_DIRECTORY, bundleName))
} }
module.exports.getAction = async function(actionName) { module.exports.getAction = async function(actionName) {
@ -57,7 +57,7 @@ module.exports.getAction = async function(actionName) {
const pkg = MANIFEST.packages[actionName] const pkg = MANIFEST.packages[actionName]
const bundleName = buildBundleName(pkg.stepId, pkg.version) const bundleName = buildBundleName(pkg.stepId, pkg.version)
try { try {
return require(path.join(AUTOMATION_DIRECTORY, bundleName)) return require(join(AUTOMATION_DIRECTORY, bundleName))
} catch (err) { } catch (err) {
return downloadPackage(pkg.stepId, pkg.version, bundleName) return downloadPackage(pkg.stepId, pkg.version, bundleName)
} }
@ -66,7 +66,7 @@ module.exports.getAction = async function(actionName) {
module.exports.init = async function() { module.exports.init = async function() {
// set defaults // set defaults
if (!AUTOMATION_DIRECTORY) { if (!AUTOMATION_DIRECTORY) {
AUTOMATION_DIRECTORY = path.join(os.homedir(), DEFAULT_DIRECTORY) AUTOMATION_DIRECTORY = join(os.homedir(), DEFAULT_DIRECTORY)
} }
if (!AUTOMATION_BUCKET) { if (!AUTOMATION_BUCKET) {
AUTOMATION_BUCKET = DEFAULT_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 = { module.exports.definition = {
description: "Send an email", description: "Send an email",
tagline: "Send email to {{inputs.to}}", tagline: "Send email to {{inputs.to}}",
@ -13,6 +9,10 @@ module.exports.definition = {
schema: { schema: {
inputs: { inputs: {
properties: { properties: {
apiKey: {
type: "string",
title: "SendGrid API key",
},
to: { to: {
type: "string", type: "string",
title: "Send To", title: "Send To",
@ -49,6 +49,8 @@ module.exports.definition = {
} }
module.exports.run = async function({ inputs }) { module.exports.run = async function({ inputs }) {
const sgMail = require("@sendgrid/mail")
sgMail.setApiKey(inputs.apiKey)
const msg = { const msg = {
to: inputs.to, to: inputs.to,
from: inputs.from, from: inputs.from,

View file

@ -2,6 +2,7 @@ const PouchDB = require("pouchdb")
const replicationStream = require("pouchdb-replication-stream") const replicationStream = require("pouchdb-replication-stream")
const allDbs = require("pouchdb-all-dbs") const allDbs = require("pouchdb-all-dbs")
const { budibaseAppsDir } = require("../utilities/budibaseDir") const { budibaseAppsDir } = require("../utilities/budibaseDir")
const { sanitise } = require("../utilities/sanitisedPath")
const env = require("../environment") const env = require("../environment")
const COUCH_DB_URL = env.COUCH_DB_URL || `leveldb://${budibaseAppsDir()}/.data/` const COUCH_DB_URL = env.COUCH_DB_URL || `leveldb://${budibaseAppsDir()}/.data/`
@ -26,4 +27,10 @@ const Pouch = PouchDB.defaults(POUCH_DB_DEFAULTS)
allDbs(Pouch) 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 { app, BrowserWindow, shell, dialog } = require("electron")
const { join } = require("path") const { join } = require("./utilities/sanitisedPath")
const isDev = require("electron-is-dev") const isDev = require("electron-is-dev")
const { autoUpdater } = require("electron-updater") const { autoUpdater } = require("electron-updater")
const unhandled = require("electron-unhandled") 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 { homedir } = require("os")
const { app } = require("electron") const { app } = require("electron")
const fixPath = require("fix-path") 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 { homedir, tmpdir } = require("os")
const env = require("../environment") const env = require("../environment")

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
const { appPackageFolder } = require("../createAppPackage") const { appPackageFolder } = require("../createAppPackage")
const { readJSON, readdir, stat } = require("fs-extra") const { readJSON, readdir, stat } = require("fs-extra")
const { join } = require("path") const { join } = require("../sanitisedPath")
const { keyBy } = require("lodash/fp") const { keyBy } = require("lodash/fp")
module.exports = async (config, appname, pagename) => { 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) 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 { cwd } = require("process")
const stream = require("stream") const stream = require("stream")
const fetch = require("node-fetch") 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 { exists, readFile, writeFile, ensureDir } = require("fs-extra")
const { join, resolve } = require("path") const { join, resolve } = require("./sanitisedPath")
const Sqrl = require("squirrelly") const Sqrl = require("squirrelly")
const uuid = require("uuid") 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 fs = require("fs-extra")
const { join } = require("./sanitisedPath")
const os = require("os") const os = require("os")
const fetch = require("node-fetch") const fetch = require("node-fetch")
const stream = require("stream") const stream = require("stream")
@ -27,10 +27,10 @@ exports.downloadTemplate = async function(type, name) {
await streamPipeline( await streamPipeline(
response.body, response.body,
zlib.Unzip(), 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({ exports.exportTemplateFromApp = async function({
@ -39,15 +39,17 @@ exports.exportTemplateFromApp = async function({
instanceId, instanceId,
}) { }) {
// Copy frontend files // Copy frontend files
const appToExport = path.join(os.homedir(), ".budibase", appId, "pages") const appToExport = join(os.homedir(), ".budibase", appId, "pages")
const templatesDir = path.join(os.homedir(), ".budibase", "templates") const templatesDir = join(os.homedir(), ".budibase", "templates")
fs.ensureDirSync(templatesDir) fs.ensureDirSync(templatesDir)
const templateOutputPath = path.join(templatesDir, templateName) const templateOutputPath = join(templatesDir, templateName)
fs.copySync(appToExport, `${templateOutputPath}/pages`) fs.copySync(appToExport, join(templateOutputPath, "pages"))
fs.ensureDirSync(path.join(templateOutputPath, "db")) fs.ensureDirSync(join(templateOutputPath, "db"))
const writeStream = fs.createWriteStream(`${templateOutputPath}/db/dump.txt`) const writeStream = fs.createWriteStream(
join(templateOutputPath, "db", "dump.txt")
)
// perform couch dump // perform couch dump
const instanceDb = new CouchDB(instanceId) 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" lodash "^4.17.13"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@budibase/client@^0.1.23": "@budibase/client@^0.1.25":
version "0.1.23" version "0.1.25"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.23.tgz#d72d2b26ff3a2d99f2b6c1b71020b1136880937d" resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.1.25.tgz#f08c4a614f9018eb0f0faa6d20bb05f7a3215c70"
integrity sha512-pZdwdCq5kKLZfZYxasIHBNnqu3BFFrqJLxXMFs0K9ddCVZ0UNons59nn73nFGbeRgNVdWp6yyW71XyMQr8NOEw== integrity sha512-vZ0cqJwLYcs7MHihFnJO3qOe7qxibnB4Va1+IYNfnPc9kcxy4KvfQxCx/G/DDxP9CXfEvsguy9ymzR3RUAvBHw==
dependencies: dependencies:
deep-equal "^2.0.1" deep-equal "^2.0.1"
mustache "^4.0.1" mustache "^4.0.1"
@ -1079,9 +1079,10 @@ bluebird-lst@^1.0.9:
dependencies: dependencies:
bluebird "^3.5.5" bluebird "^3.5.5"
bluebird@^3.5.5: bluebird@^3.5.1, bluebird@^3.5.5:
version "3.7.2" version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" 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: boolean@^3.0.0, boolean@^3.0.1:
version "3.0.1" version "3.0.1"
@ -1618,6 +1619,15 @@ cssstyle@^1.0.0:
dependencies: dependencies:
cssom "0.3.x" 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: dashdash@^1.12.0:
version "1.14.1" version "1.14.1"
resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" 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" version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" 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: is-weakmap@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2" 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" inline-process-browser "^1.0.0"
unreachable-branch-transform "^0.3.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: lines-and-columns@^1.1.6:
version "1.1.6" version "1.1.6"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" 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" version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" 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: lodash.sortby@^4.7.0:
version "4.7.0" version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" 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" version "4.17.19"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" 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: loose-envify@^1.0.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 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" version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" 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: negotiator@0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" 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" version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" 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: pouchdb-adapter-leveldb-core@7.2.1:
version "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" 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: dependencies:
lie "3.0.4" 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: pouchdb-utils@7.2.1:
version "7.2.1" version "7.2.1"
resolved "https://registry.yarnpkg.com/pouchdb-utils/-/pouchdb-utils-7.2.1.tgz#5dec1c53c8ecba717e5762311e9a1def2d4ebf9c" 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" isarray "0.0.1"
string_decoder "~0.10.x" 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" version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
dependencies: dependencies:
@ -6088,6 +6168,13 @@ split-string@^3.0.1, split-string@^3.0.2:
dependencies: dependencies:
extend-shallow "^3.0.0" 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: split2@^3.1.1:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/split2/-/split2-3.1.1.tgz#c51f18f3e06a8c4469aaab487687d8d956160bb6" resolved "https://registry.yarnpkg.com/split2/-/split2-3.1.1.tgz#c51f18f3e06a8c4469aaab487687d8d956160bb6"
@ -6262,6 +6349,13 @@ strip-ansi@^6.0.0:
dependencies: dependencies:
ansi-regex "^5.0.0" 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: strip-bom@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" 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" readable-stream ">=1.0.33-1 <1.1.0-0"
xtend ">=4.0.0 <4.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: through@^2.3.6, through@^2.3.8, through@~2.3.4:
version "2.3.8" version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
@ -6968,7 +7070,7 @@ xmlbuilder@~9.0.1:
version "9.0.7" version "9.0.7"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" 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" version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"

View file

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

View file

@ -1,9 +1,19 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { fade } from "svelte/transition" 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 Dropzone from "./attachments/Dropzone.svelte"
import LinkedRecordSelector from "./LinkedRecordSelector.svelte"
import debounce from "lodash.debounce" import debounce from "lodash.debounce"
import ErrorsBox from "./ErrorsBox.svelte"
import { capitalise } from "./helpers"
export let _bb export let _bb
export let model export let model
@ -32,16 +42,11 @@
let isNew = true let isNew = true
let errors = {} let errors = {}
$: fields = schema ? Object.keys(schema) : []
$: if (model && model.length !== 0) { $: if (model && model.length !== 0) {
fetchModel() fetchModel()
} }
$: fields = schema ? Object.keys(schema) : []
$: errorMessages = Object.entries(errors).map(
([field, message]) => `${field} ${message}`
)
async function fetchModel() { async function fetchModel() {
const FETCH_MODEL_URL = `/api/models/${model}` const FETCH_MODEL_URL = `/api/models/${model}`
const response = await _bb.api.get(FETCH_MODEL_URL) const response = await _bb.api.get(FETCH_MODEL_URL)
@ -82,11 +87,13 @@
saved = true saved = true
setTimeout(() => { setTimeout(() => {
saved = false saved = false
}, 1000) }, 3000)
} }
if (response.status === 400) { 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 GET_RECORD_URL = `/api/${model}/records/${recordId}`
const response = await _bb.api.get(GET_RECORD_URL) const response = await _bb.api.get(GET_RECORD_URL)
const json = await response.json() record = await response.json()
record = json
}) })
</script> </script>
@ -112,44 +118,52 @@
{#if title} {#if title}
<h1>{title}</h1> <h1>{title}</h1>
{/if} {/if}
{#each errorMessages as error}
<p class="error">{error}</p>
{/each}
<hr />
<div class="form-content"> <div class="form-content">
<ErrorsBox {errors} />
{#each fields as field} {#each fields as field}
<div class="form-item"> {#if schema[field].type === 'options'}
<Label small forAttr={'form-stacked-text'}>{field}</Label> <Select
{#if schema[field].type === 'string' && schema[field].constraints.inclusion} secondary
<select bind:value={record[field]}> label={capitalise(schema[field].name)}
{#each schema[field].constraints.inclusion as opt} bind:value={record[field]}>
<option>{opt}</option> <option value="">Choose an option</option>
{/each} {#each schema[field].constraints.inclusion as opt}
</select> <option>{opt}</option>
{:else if schema[field].type === 'datetime'} {/each}
<DatePicker bind:value={record[field]} /> </Select>
{:else if schema[field].type === 'boolean'} {:else if schema[field].type === 'datetime'}
<input class="input" type="checkbox" bind:checked={record[field]} /> <DatePicker
{:else if schema[field].type === 'number'} label={capitalise(schema[field].name)}
<input class="input" type="number" bind:value={record[field]} /> bind:value={record[field]} />
{:else if schema[field].type === 'string'} {:else if schema[field].type === 'boolean'}
<input class="input" type="text" bind:value={record[field]} /> <Toggle
{:else if schema[field].type === 'attachment'} 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]} /> <Dropzone bind:files={record[field]} />
{/if} </div>
</div> {:else if schema[field].type === 'link'}
<hr /> <LinkedRecordSelector
secondary
bind:linkedRecords={record[field]}
schema={schema[field]} />
{/if}
{/each} {/each}
<div class="button-block"> <div class="buttons">
<button on:click={save} class:saved> <Button primary on:click={save} green={saved}>
{#if saved} {#if saved}Success{:else}{buttonText || 'Submit Form'}{/if}
<div in:fade> </Button>
<span class:saved>Success</span>
</div>
{:else}
<div>{buttonText || 'Submit Form'}</div>
{/if}
</button>
</div> </div>
</div> </div>
</form> </form>
@ -162,104 +176,14 @@
} }
.form-content { .form-content {
margin-bottom: 20px; margin-bottom: var(--spacing-xl);
display: grid;
gap: var(--spacing-xl);
width: 100%; width: 100%;
} }
.input { .buttons {
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 {
display: flex; display: flex;
justify-content: flex-end; 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> </style>

View file

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

View file

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

View file

@ -4,7 +4,28 @@ export default async function fetchData(datasource) {
const { isModel, name } = datasource const { isModel, name } = datasource
if (name) { 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() { 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? # What is Budibase?

View file

@ -913,11 +913,6 @@ argparse@^1.0.7:
dependencies: dependencies:
sprintf-js "~1.0.2" 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: arr-diff@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" 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" version "4.0.6"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" 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: import-fresh@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" 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" once "^1.3.0"
wrappy "1" 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" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 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" version "1.0.2"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" 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: isarray@1.0.0, isarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@ -2805,13 +2790,6 @@ libnpmpublish@^1.1.1:
semver "^5.5.1" semver "^5.5.1"
ssri "^6.0.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: load-json-file@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" 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" version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" 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: lodash.set@^4.3.2:
version "4.3.2" version "4.3.2"
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" 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" version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" 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: neo-async@^2.6.0:
version "2.6.1" version "2.6.1"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" 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" version "0.1.1"
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" 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: prelude-ls@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" 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" string_decoder "^1.1.1"
util-deprecate "^1.0.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: readdir-scoped-modules@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" 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: dependencies:
extend-shallow "^3.0.0" extend-shallow "^3.0.0"
split2@^2.0.0, split2@^2.1.0: split2@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493" resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493"
dependencies: dependencies:
@ -4396,11 +4321,6 @@ string_decoder@^1.1.1:
dependencies: dependencies:
safe-buffer "~5.2.0" 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: string_decoder@~1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" 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" version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" 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" version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
dependencies: dependencies: