Merge branch 'develop' of github.com:Budibase/budibase into repeater-filtering

This commit is contained in:
Andrew Kingston 2021-02-22 12:23:46 +00:00
commit 8c4cf0bb8d
59 changed files with 1989 additions and 581 deletions

View File

@ -30,7 +30,7 @@ context("Create a Table", () => {
// Unset table display column
cy.contains("display column").click()
cy.contains("Save Column").click()
cy.contains("nameupdated").should("have.text", "nameupdated")
cy.contains("nameupdated ").should("have.text", "nameupdated ")
})
it("edits a row", () => {

View File

@ -1,3 +1,11 @@
function removeSpacing(headers) {
let newHeaders = []
for (let header of headers) {
newHeaders.push(header.replace(/\s\s+/g, " "))
}
return newHeaders
}
context("Create a View", () => {
before(() => {
cy.visit("localhost:4001/_builder")
@ -28,7 +36,7 @@ context("Create a View", () => {
const headers = Array.from($headers).map(header =>
header.textContent.trim()
)
expect(headers).to.deep.eq([ 'rating', 'age', 'group' ])
expect(removeSpacing(headers)).to.deep.eq([ "rating Number", "age Number", "group Text" ])
})
})
@ -60,13 +68,19 @@ context("Create a View", () => {
const headers = Array.from($headers).map(header =>
header.textContent.trim()
)
expect(headers).to.deep.eq([ 'avg', 'sumsqr', 'count', 'max', 'min', 'sum', 'field' ])
expect(removeSpacing(headers)).to.deep.eq([ "avg Number",
"sumsqr Number",
"count Number",
"max Number",
"min Number",
"sum Number",
"field Text" ])
})
cy.get(".ag-cell").then($values => {
let values = Array.from($values).map(header =>
header.textContent.trim()
)
expect(values).to.deep.eq([ '31', '5347', '5', '49', '20', '155', 'age' ])
expect(values).to.deep.eq([ "31", "5347", "5", "49", "20", "155", "age" ])
})
})
@ -85,7 +99,7 @@ context("Create a View", () => {
.find(".ag-cell")
.then($values => {
const values = Array.from($values).map(value => value.textContent)
expect(values).to.deep.eq([ 'Students', '23.333333333333332', '1650', '3', '25', '20', '70' ])
expect(values).to.deep.eq([ "Students", "23.333333333333332", "1650", "3", "25", "20", "70" ])
})
})

View File

@ -63,7 +63,7 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.58.8",
"@budibase/bbui": "^1.58.12",
"@budibase/client": "^0.7.8",
"@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.8",

View File

@ -136,7 +136,7 @@ const getContextBindings = (asset, componentId) => {
// Replace certain bindings with a new property to help display components
let runtimeBoundKey = key
if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_count`
runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first`
}
@ -176,7 +176,7 @@ const getUserBindings = () => {
// Replace certain bindings with a new property to help display components
let runtimeBoundKey = key
if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_count`
runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first`
}

View File

@ -198,6 +198,13 @@ export function makeDatasourceFormComponents(datasource) {
if (fieldType === "options") {
component.customProps({ placeholder: "Choose an option " })
}
if (fieldType === "link") {
let placeholder =
fieldSchema.relationshipType === "one-to-many"
? "Choose an option"
: "Choose some options"
component.customProps({ placeholder })
}
if (fieldType === "boolean") {
component.customProps({ text: field, label: "" })
}

View File

@ -301,4 +301,13 @@
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
:global(.ag-header) {
height: 61px !important;
min-height: 61px !important;
}
:global(.ag-header-row) {
height: 60px !important;
}
</style>

View File

@ -2,6 +2,7 @@
import { onMount, onDestroy } from "svelte"
import { Modal, ModalContent } from "@budibase/bbui"
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
import { FIELDS } from "constants/backend"
const SORT_ICON_MAP = {
asc: "ri-arrow-down-fill",
@ -51,6 +52,8 @@
column.removeEventListener("sortChanged", setSort)
column.removeEventListener("filterActiveChanged", setFilterActive)
})
$: type = FIELDS[field?.type?.toUpperCase()]?.name
</script>
<header
@ -58,12 +61,17 @@
data-cy="table-header"
on:mouseover={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}>
<div>
<div class="col-icon">
{#if field.autocolumn}<i class="auto ri-magic-fill" />{/if}
<span class="column-header-name">{displayName}</span>
<div class="column-header">
<div class="column-header-text">
<div class="column-header-name">
{displayName}
{#if field.autocolumn}<i class="auto ri-magic-fill" />{/if}
</div>
{#if type}
<div class="column-header-type">{type}</div>
{/if}
</div>
<i class={`${SORT_ICON_MAP[sortDirection]} sort-icon icon`} />
<i class={`${SORT_ICON_MAP[sortDirection]} icon`} />
</div>
<Modal bind:this={modal}>
<ModalContent
@ -106,6 +114,23 @@
opacity: 1;
}
.column-header {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
}
.column-header-text {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xs);
}
.column-header-name {
white-space: normal !important;
text-overflow: ellipsis;
@ -115,9 +140,9 @@
overflow: hidden;
}
.sort-icon {
position: relative;
top: 2px;
.column-header-type {
font-size: var(--font-size-xs);
color: var(--grey-6);
}
.icon {
@ -125,16 +150,13 @@
font-size: var(--font-size-m);
font-weight: 500;
}
.col-icon {
display: flex;
}
.auto {
font-size: var(--font-size-xs);
font-size: 9px;
transition: none;
margin-right: 6px;
margin-top: 2px;
position: relative;
margin-left: 2px;
top: -3px;
color: var(--grey-6);
}
.icon:hover {

View File

@ -3,24 +3,43 @@
export let row
export let selectRelationship
$: count =
row && columnName && Array.isArray(row[columnName])
? row[columnName].length
: 0
$: items = row?.[columnName] || []
</script>
<div class:link={count} on:click={() => selectRelationship(row, columnName)}>
{count}
related row(s)
<div
class="container"
class:link={!!items.length}
on:click={() => selectRelationship(row, columnName)}>
{#each items as item}
<div class="item">{item}</div>
{/each}
</div>
<style>
.link {
text-decoration: underline;
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
}
.link:hover {
color: var(--grey-6);
cursor: pointer;
}
.link:hover .item {
color: var(--ink);
border-color: var(--ink);
}
.item {
font-size: var(--font-size-xs);
padding: var(--spacing-xs) var(--spacing-s);
border: 1px solid var(--grey-5);
color: var(--grey-7);
line-height: normal;
border-radius: 4px;
text-decoration: none;
}
</style>

View File

@ -6,6 +6,8 @@
TextButton,
Select,
Toggle,
Radio,
} from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
import { backendUiStore } from "builderStore"
@ -18,6 +20,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
const AUTO_COL = "auto"
const LINK_TYPE = FIELDS.LINK.type
let fieldDefinitions = cloneDeep(FIELDS)
export let onClosed
@ -33,6 +36,15 @@
let primaryDisplay =
$backendUiStore.selectedTable.primaryDisplay == null ||
$backendUiStore.selectedTable.primaryDisplay === field.name
let relationshipTypes = [
{text: 'Many to many (N:N)', value: 'many-to-many',},
{text: 'One to many (1:N)', value: 'one-to-many',}
]
let types = ['Many to many (N:N)', 'One to many (1:N)']
let selectedRelationshipType = relationshipTypes.find(type => type.value === field.relationshipType)?.text || 'Many to many (N:N)'
let indexes = [...($backendUiStore.selectedTable.indexes || [])]
let confirmDeleteDialog
let deletion
@ -44,17 +56,23 @@
$: uneditable =
$backendUiStore.selectedTable?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(field.name)
$: invalid = field.type === FIELDS.LINK.type && !field.tableId
// used to select what different options can be displayed for column type
$: canBeSearched =
field.type !== "link" &&
field.type !== LINK_TYPE &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY
$: canBeDisplay = field.type !== "link" && field.type !== AUTO_COL
$: canBeDisplay = field.type !== LINK_TYPE && field.type !== AUTO_COL
$: canBeRequired =
field.type !== "link" && !uneditable && field.type !== AUTO_COL
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_COL
async function saveColumn() {
// Set relationship type if it's
if (field.type === 'link') {
field.relationshipType = relationshipTypes.find(type => type.text === selectedRelationshipType).value
}
if (field.type === AUTO_COL) {
field = buildAutoColumn(
$backendUiStore.draftTable.name,
@ -84,13 +102,17 @@
}
}
function handleFieldConstraints(event) {
function handleTypeChange(event) {
const definition = fieldDefinitions[event.target.value.toUpperCase()]
if (!definition) {
return
}
field.type = definition.type
field.constraints = definition.constraints
// remove any extra fields that may not be related to this type
delete field.autocolumn
delete field.subtype
delete field.tableId
}
function onChangeRequired(e) {
@ -138,7 +160,7 @@
secondary
thin
label="Type"
on:change={handleFieldConstraints}
on:change={handleTypeChange}
bind:value={field.type}>
{#each Object.values(fieldDefinitions) as field}
<option value={field.type}>{field.name}</option>
@ -206,6 +228,16 @@
label="Max Value"
bind:value={field.constraints.numericality.lessThanOrEqualTo} />
{:else if field.type === 'link'}
<div>
<Label grey extraSmall>Select relationship type</Label>
<div class="radio-buttons">
{#each types as type}
<Radio disabled={originalName} name="Relationship type" value={type} bind:group={selectedRelationshipType}>
<label for={type}>{type}</label>
</Radio>
{/each}
</div>
</div>
<Select label="Table" thin secondary bind:value={field.tableId}>
<option value="">Choose an option</option>
{#each tableOptions as table}
@ -229,7 +261,9 @@
<TextButton text on:click={confirmDelete}>Delete Column</TextButton>
{/if}
<Button secondary on:click={onClosed}>Cancel</Button>
<Button primary on:click={saveColumn}>Save Column</Button>
<Button primary on:click={saveColumn} bind:disabled={invalid}>
Save Column
</Button>
</footer>
</div>
<ConfirmDialog
@ -241,6 +275,15 @@
title="Confirm Deletion" />
<style>
label {
display: grid;
place-items: center;
}
.radio-buttons {
display: flex;
gap: var(--spacing-m);
font-size: var(--font-size-xs)
}
.actions {
display: grid;
grid-gap: var(--spacing-xl);

View File

@ -1,15 +1,37 @@
<script>
import { Input, TextArea, Spacer } from "@budibase/bbui"
import { Label, Input, TextArea, Spacer } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let integration
let unsaved = false
</script>
<form>
{#each Object.keys(integration) as configKey}
<Input
type={integration[configKey].type}
label={configKey}
bind:value={integration[configKey]} />
<Spacer large />
{#if typeof integration[configKey] === 'object'}
<Label small>{configKey}</Label>
<Spacer small />
<KeyValueBuilder bind:object={integration[configKey]} on:change />
{:else}
<div class="form-row">
<Label small>{configKey}</Label>
<Input
outline
type={integration[configKey].type}
on:change
bind:value={integration[configKey]} />
</div>
{/if}
{/each}
</form>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
margin-bottom: var(--spacing-m);
}
</style>

View File

@ -2,7 +2,8 @@
import { onMount } from "svelte"
import { backendUiStore } from "builderStore"
import api from "builderStore/api"
import { Input, TextArea, Spacer } from "@budibase/bbui"
import { Input, Label, TextArea, Spacer } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import ICONS from "../icons"
export let integration = {}
@ -49,17 +50,6 @@
</div>
{/each}
</div>
{#if schema}
{#each Object.keys(schema) as configKey}
<Input
thin
type={schema[configKey].type}
label={configKey}
bind:value={integration[configKey]} />
<Spacer medium />
{/each}
{/if}
</section>
<style>

View File

@ -0,0 +1,36 @@
<script>
export let width = "100"
export let height = "100"
</script>
<svg
{width}
{height}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M103.125 20.625H68.25L60.375
36.375H16.5V99.375H111V20.625H103.125ZM103.125 36.375H72.375L76.5
28.5H103.125V36.375Z"
fill="#FFBA58" />
<path
d="M75 46.875V52.5H60C58.0127 52.5059 56.1085 53.298 54.7033 54.7033C53.298
56.1085 52.5059 58.0127 52.5 60V75H46.875C44.3886 75 42.004 75.9877 40.2459
77.7459C38.4877 79.504 37.5 81.8886 37.5 84.375C37.5 86.8614 38.4877 89.246
40.2459 91.0041C42.004 92.7623 44.3886 93.75 46.875
93.75H52.5V108.75C52.5059 110.737 53.298 112.642 54.7033 114.047C56.1085
115.452 58.0127 116.244 60 116.25H74.25V110.625C74.25 107.94 75.3167 105.364
77.2155 103.466C79.1143 101.567 81.6897 100.5 84.375 100.5C87.0603 100.5
89.6357 101.567 91.5345 103.466C93.4333 105.364 94.5 107.94 94.5
110.625V116.25H108.75C110.737 116.244 112.642 115.452 114.047
114.047C115.452 112.642 116.244 110.737 116.25 108.75V94.5H110.625C107.94
94.5 105.364 93.4333 103.466 91.5345C101.567 89.6357 100.5 87.0603 100.5
84.375C100.5 81.6897 101.567 79.1143 103.466 77.2155C105.364 75.3167 107.94
74.25 110.625 74.25H116.25V60C116.244 58.0127 115.452 56.1085 114.047
54.7033C112.642 53.298 110.737 52.5059 108.75 52.5H93.75V46.875C93.75
44.3886 92.7623 42.004 91.0041 40.2459C89.246 38.4877 86.8614 37.5 84.375
37.5C81.8886 37.5 79.504 38.4877 77.7459 40.2459C75.9877 42.004 75 44.3886
75 46.875Z"
fill="#E76A00" />
</svg>

View File

@ -8,6 +8,7 @@ import Airtable from "./Airtable.svelte"
import SqlServer from "./SQLServer.svelte"
import MySQL from "./MySQL.svelte"
import ArangoDB from "./ArangoDB.svelte"
import Rest from "./Rest.svelte"
export default {
POSTGRES: Postgres,
@ -20,4 +21,5 @@ export default {
AIRTABLE: Airtable,
MYSQL: MySQL,
ARANGODB: ArangoDB,
REST: Rest,
}

View File

@ -1,61 +0,0 @@
<script>
import { goto, params } from "@sveltech/routify"
import { backendUiStore, store } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { Input, Label, ModalContent, Button, Spacer } from "@budibase/bbui"
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte"
import analytics from "analytics"
let modal
let error = ""
let name
let source
let integration
let datasource
function checkValid(evt) {
const datasourceName = evt.target.value
if (
$backendUiStore.datasources?.some(
datasource => datasource.name === datasourceName
)
) {
error = `Datasource with name ${tableName} already exists. Please choose another name.`
return
}
error = ""
}
async function saveDatasource() {
const { type, ...config } = integration
// Create datasource
await backendUiStore.actions.datasources.save({
name,
source: type,
config,
})
notifier.success(`Datasource ${name} created successfully.`)
analytics.captureEvent("Datasource Created", { name })
// Navigate to new datasource
$goto(`./datasource/${datasource._id}`)
}
</script>
<ModalContent
title="Create Datasource"
confirmText="Create"
onConfirm={saveDatasource}
disabled={error || !name}>
<Input
data-cy="datasource-name-input"
thin
label="Datasource Name"
on:input={checkValid}
bind:value={name}
{error} />
<Label grey extraSmall>Create Integrated Table from External Source</Label>
<TableIntegrationMenu bind:integration />
</ModalContent>

View File

@ -3,7 +3,6 @@
import { notifier } from "builderStore/store/notifications"
import { DropdownMenu, Button, Input } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import IntegrationConfigForm from "../TableIntegrationMenu//IntegrationConfigForm.svelte"
import { DropdownContainer, DropdownItem } from "components/common/Dropdowns"
export let datasource

View File

@ -1,39 +0,0 @@
<script>
import { backendUiStore, store, allScreens } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { DropdownMenu, Button, Input, TextButton, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import IntegrationConfigForm from "../TableIntegrationMenu//IntegrationConfigForm.svelte"
import { DropdownContainer, DropdownItem } from "components/common/Dropdowns"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
export let bindable
export let parameters
let anchor
let dropdown
let confirmDeleteDialog
function hideEditor() {
dropdown?.hide()
}
</script>
<div on:click|stopPropagation bind:this={anchor}>
<TextButton text on:click={dropdown.show} active={false}>
<Icon name="add" />
Add Parameters
</TextButton>
<DropdownMenu align="right" {anchor} bind:this={dropdown}>
<div class="wrapper">
<ParameterBuilder bind:parameters {bindable} />
</div>
</DropdownMenu>
</div>
<style>
.wrapper {
padding: var(--spacing-xl);
min-width: 600px;
}
</style>

View File

@ -3,7 +3,6 @@
import { notifier } from "builderStore/store/notifications"
import { DropdownMenu, Button, Input } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import IntegrationConfigForm from "../TableIntegrationMenu//IntegrationConfigForm.svelte"
import { DropdownContainer, DropdownItem } from "components/common/Dropdowns"
export let query

View File

@ -1,8 +1,8 @@
<script>
export let icon
export let title
export let subtitle
export let disabled
export let subtitle = undefined
export let disabled = false
</script>
<div class="dropdown-item" class:disabled on:click {...$$restProps}>

View File

@ -41,13 +41,22 @@
table.
</Label>
{:else}
<Multiselect
secondary
bind:value={linkedRows}
{label}
placeholder="Choose some options">
{#each rows as row}
<option value={row._id}>{getPrettyName(row)}</option>
{/each}
</Multiselect>
{#if schema.relationshipType === 'one-to-many'}
<Select thin secondary on:change={e => linkedRows = [e.target.value]} name={label} {label}>
<option value="">Choose an option</option>
{#each rows as row}
<option selected={row._id === linkedRows[0]} value={row._id}>{getPrettyName(row)}</option>
{/each}
</Select>
{:else}
<Multiselect
secondary
bind:value={linkedRows}
{label}
placeholder="Choose some options">
{#each rows as row}
<option value={row._id}>{getPrettyName(row)}</option>
{/each}
</Multiselect>
{/if}
{/if}

View File

@ -24,7 +24,7 @@
timeOnly: {
hour: "numeric",
minute: "numeric",
hour12: true,
hourCycle: "h12",
},
}
const POLL_INTERVAL = 5000

View File

@ -13,7 +13,6 @@
import { notifier } from "builderStore/store/notifications"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import { getSchemaForDatasource } from "builderStore/dataBinding"
const dispatch = createEventDispatcher()
let anchorRight, dropdownRight
@ -96,7 +95,7 @@
</div>
{#if value?.type === 'query'}
<i class="ri-settings-5-line" on:click={drawer.show} />
<Drawer title={'Query'} bind:this={drawer}>
<Drawer title={'Query Parameters'} bind:this={drawer}>
<div slot="buttons">
<Button
blue
@ -116,10 +115,12 @@
parameters={queries.find(query => query._id === value._id).parameters}
bindings={queryBindableProperties} />
{/if}
<!-- <Spacer large />-->
<IntegrationQueryEditor
height={200}
query={value}
schema={fetchQueryDefinition(value)}
datasource={$backendUiStore.datasources.find(ds => ds._id === value.datasourceId)}
editable={false} />
<Spacer large />
</div>

View File

@ -1,5 +1,5 @@
<script>
import { Button, Drawer, Spacer } from "@budibase/bbui"
import { Button, Drawer, Spacer, Body } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { notifier } from "builderStore/store/notifications"
import {
@ -41,15 +41,15 @@
</heading>
<div slot="body">
<div class="root">
{#if !Object.keys(tempValue || {}).length}
<p>Add your first filter column.</p>
{:else}
<p>
<Body small grey>
{#if !Object.keys(tempValue || {}).length}
Add your first filter column.
{:else}
Results are filtered to only those which match all of the following
constaints.
</p>
{/if}
<Spacer small />
{/if}
</Body>
<Spacer medium />
<div class="fields">
<SaveFields
parameterFields={value}
@ -67,11 +67,6 @@
min-height: calc(40vh - 2 * var(--spacing-l));
}
p {
margin: 0 0 var(--spacing-s) 0;
font-size: var(--font-size-s);
}
.fields {
display: grid;
column-gap: var(--spacing-l);

View File

@ -0,0 +1,50 @@
<script>
import { Button, Input } from "@budibase/bbui"
export let object = {}
export let readOnly
let fields = Object.entries(object).map(([name, value]) => ({ name, value }))
$: object = fields.reduce(
(acc, next) => ({ ...acc, [next.name]: next.value }),
{}
)
function addEntry() {
fields = [...fields, {}]
}
function deleteEntry(idx) {
fields.splice(idx, 1)
fields = fields
}
</script>
<!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. -->
<div class="container" class:readOnly>
{#each fields as field, idx}
<Input placeholder="Key" thin outline bind:value={field.name} />
<Input placeholder="Value" thin outline bind:value={field.value} />
{#if !readOnly}
<i class="ri-close-circle-fill" on:click={() => deleteEntry(idx)} />
{/if}
{/each}
</div>
{#if !readOnly}
<Button secondary thin outline on:click={addEntry}>Add</Button>
{/if}
<style>
.container {
display: grid;
grid-template-columns: 1fr 1fr 20px;
grid-gap: var(--spacing-m);
align-items: center;
margin-bottom: var(--spacing-m);
}
.ri-close-circle-fill {
cursor: pointer;
}
</style>

View File

@ -1,5 +1,6 @@
<script>
import CodeMirror from "./codemirror"
import { Label, Spacer } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { themeStore } from "builderStore"
import { handlebarsCompletions } from "constants/completions"
@ -11,6 +12,7 @@
LIGHT: "default",
}
export let label
export let value = ""
export let readOnly = false
export let lineNumbers = true
@ -170,6 +172,10 @@
}
</script>
{#if label}
<Label small>{label}</Label>
<Spacer medium />
{/if}
<div style={`--code-mirror-height: ${editorHeight}px`}>
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
</div>

View File

@ -1,6 +1,7 @@
<script>
import { Input } from "@budibase/bbui"
import { Label, Spacer, Input } from "@budibase/bbui"
import Editor from "./QueryEditor.svelte"
import KeyValueBuilder from "./KeyValueBuilder.svelte"
export let fields = {}
export let schema
@ -19,13 +20,33 @@
<form on:submit|preventDefault>
<div class="field">
{#each schemaKeys as field}
<Input
placeholder="Enter {field} name"
outline
disabled={!editable}
type={schema.fields[field]?.type}
required={schema.fields[field]?.required}
bind:value={fields[field]} />
{#if schema.fields[field]?.type === 'object'}
<div>
<Label small>{field}</Label>
<Spacer small />
<KeyValueBuilder readOnly={!editable} bind:object={fields[field]} />
</div>
{:else if schema.fields[field]?.type === 'json'}
<div>
<Label extraSmall grey>{field}</Label>
<Editor
mode="json"
on:change={({ detail }) => (fields[field] = detail.value)}
readOnly={!editable}
value={fields[field]} />
</div>
{:else}
<div class="horizontal">
<Label small>{field}</Label>
<Input
placeholder="Enter {field}"
outline
disabled={!editable}
type={schema.fields[field]?.type}
required={schema.fields[field]?.required}
bind:value={fields[field]} />
</div>
{/if}
{/each}
</div>
</form>
@ -42,8 +63,15 @@
.field {
margin-bottom: var(--spacing-m);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr;
grid-gap: var(--spacing-m);
align-items: center;
}
.horizontal {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { Button, Input, Label } from "@budibase/bbui"
import { Body, Button, Input, Heading, Spacer } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
@ -30,7 +30,22 @@
</script>
<section>
<Label small>Parameters</Label>
<div class="controls">
<Heading small lh>Parameters</Heading>
{#if !bindable}
<Button secondary on:click={newQueryParameter}>Add Param</Button>
{/if}
</div>
<Body small grey>
{#if !bindable}
Parameters come in two parts: the parameter name, and a default/fallback
value.
{:else}
Enter a value for each parameter. The default values will be used for any
values left blank.
{/if}
</Body>
<Spacer large />
<div class="parameters" class:bindable>
{#each parameters as parameter, idx}
<Input
@ -58,9 +73,6 @@
{/if}
{/each}
</div>
{#if !bindable}
<Button secondary on:click={newQueryParameter}>Add Parameter</Button>
{/if}
</section>
<style>
@ -68,6 +80,13 @@
grid-template-columns: 1fr 1fr 1fr;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
}
.parameters {
display: grid;
grid-template-columns: 1fr 1fr 5%;

View File

@ -4,6 +4,7 @@
import {
Select,
Button,
Body,
Label,
Input,
TextArea,
@ -15,7 +16,7 @@
import api from "builderStore/api"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import EditQueryParamsPopover from "components/backend/DatasourceNavigator/popovers/EditQueryParamsPopover.svelte"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
import { backendUiStore } from "builderStore"
const PREVIEW_HEADINGS = [
@ -59,10 +60,10 @@
$: datasourceType = datasource?.source
$: config = $backendUiStore.integrations[datasourceType]?.query
$: docsLink = $backendUiStore.integrations[datasourceType]?.docs
$: integrationInfo = $backendUiStore.integrations[datasourceType]
$: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = config && query.queryVerb
$: shouldShowQueryConfig = queryConfig && query.queryVerb
function newField() {
fields = [...fields, {}]
@ -129,58 +130,88 @@
}
</script>
<header>
<div class="input">
<div class="label">Enter query name:</div>
<Input outline border bind:value={query.name} />
<section class="config">
<Heading medium lh>Query {integrationInfo?.friendlyName}</Heading>
<hr />
<Heading small lh>Config</Heading>
<Body small grey>Provide a name for your query and select its function.</Body>
<Spacer medium />
<div class="config-field">
<Label small>Query Name</Label>
<Input thin outline bind:value={query.name} />
</div>
{#if config}
<div class="props">
<div class="query-type">
Query type:
<span class="query-type-span">{config[query.queryVerb].type}</span>
</div>
<div class="select">
<Select primary thin bind:value={query.queryVerb}>
{#each Object.keys(config) as queryVerb}
<option value={queryVerb}>{queryVerb}</option>
{/each}
</Select>
</div>
<Spacer medium />
{#if queryConfig}
<div class="config-field">
<Label small>Function</Label>
<Select primary outline thin bind:value={query.queryVerb}>
{#each Object.keys(queryConfig) as queryVerb}
<option value={queryVerb}>
{queryConfig[queryVerb]?.displayName || queryVerb}
</option>
{/each}
</Select>
</div>
<EditQueryParamsPopover
bind:parameters={query.parameters}
bindable={false} />
<hr />
<ParameterBuilder bind:parameters={query.parameters} bindable={false} />
<hr />
{/if}
</header>
<Spacer extraLarge />
</section>
{#if shouldShowQueryConfig}
<section>
<div class="config">
<Heading small lh>Fields</Heading>
<Body small grey>Fill in the fields specific to this query.</Body>
<Spacer medium />
<IntegrationQueryEditor
{datasource}
{query}
schema={config[query.queryVerb]}
schema={queryConfig[query.queryVerb]}
bind:parameters />
<Spacer extraLarge />
<Spacer large />
<hr />
<div class="viewer-controls">
<Button
blue
disabled={data.length === 0 || !query.name}
on:click={saveQuery}>
Save Query
</Button>
<Button primary on:click={previewQuery}>Run Query</Button>
<Heading small lh>Query Results</Heading>
<div class="button-container">
<Button
secondary
thin
disabled={data.length === 0 || !query.name}
on:click={saveQuery}>
Save Query
</Button>
<Spacer medium />
<Button thin primary on:click={previewQuery}>Run Query</Button>
</div>
</div>
<Body small grey>
Below, you can preview the results from your query and change the
schema.
</Body>
<Spacer large />
<section class="viewer">
{#if data}
<Switcher headings={PREVIEW_HEADINGS} bind:value={tab}>
{#if tab === 'JSON'}
<pre class="preview">{JSON.stringify(data[0], undefined, 2)}</pre>
<pre class="preview">
{#if !data[0]}
Please run your query to fetch some data.
{:else}
{JSON.stringify(data[0], undefined, 2)}
{/if}
</pre>
{:else if tab === 'PREVIEW'}
<ExternalDataSourceTable {query} {data} />
{:else if tab === 'SCHEMA'}
@ -215,33 +246,26 @@
{/if}
<style>
.input {
width: 500px;
display: flex;
.config-field {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.select {
width: 200px;
margin-right: 40px;
}
.props {
display: flex;
flex-direction: row;
margin-left: auto;
align-items: center;
gap: var(--layout-l);
}
.field {
display: grid;
grid-template-columns: 1fr 1fr 50px;
grid-template-columns: 1fr 1fr 5%;
gap: var(--spacing-l);
}
a {
font-size: var(--font-size-s);
.button-container {
display: flex;
}
hr {
margin-top: var(--layout-m);
margin-bottom: var(--layout-m);
}
.config {
@ -253,16 +277,6 @@
cursor: pointer;
}
.query-type {
font-family: var(--font-sans);
color: var(--grey-8);
font-size: var(--font-size-s);
}
.query-type-span {
text-transform: uppercase;
}
.preview {
width: 800px;
height: 100%;
@ -271,31 +285,12 @@
white-space: pre-wrap;
}
header {
display: flex;
align-items: center;
}
.viewer-controls {
display: flex;
flex-direction: row;
margin-left: auto;
direction: rtl;
z-index: 5;
justify-content: space-between;
gap: var(--spacing-m);
min-width: 150px;
}
.viewer {
margin-top: -28px;
z-index: -2;
}
.label {
font-family: var(--font-sans);
color: var(--grey-8);
font-size: var(--font-size-s);
margin-right: 8px;
font-weight: 600;
align-items: center;
}
</style>

View File

@ -1,6 +1,7 @@
<script>
import Editor from "./QueryEditor.svelte"
import FieldsBuilder from "./QueryFieldsBuilder.svelte"
import { Label, Input } from "@budibase/bbui"
const QueryTypes = {
SQL: "sql",
@ -9,10 +10,15 @@
}
export let query
export let datasource
export let schema
export let editable = true
export let height = 500
$: urlDisplay =
schema.urlDisplay &&
`${datasource.config.url}${query.fields.path}${query.fields.queryString}`
function updateQuery({ detail }) {
query.fields[schema.type] = detail.value
}
@ -40,6 +46,21 @@
parameters={query.parameters} />
{:else if schema.type === QueryTypes.FIELDS}
<FieldsBuilder bind:fields={query.fields} {schema} {editable} />
{#if schema.urlDisplay}
<div class="url-row">
<Label small>URL</Label>
<Input thin outline disabled value={urlDisplay} />
</div>
{/if}
{/if}
{/key}
{/if}
<style>
.url-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -1,6 +1,6 @@
<script>
import { params } from "@sveltech/routify"
import { Switcher, Modal } from "@budibase/bbui"
import { Button, Switcher, Modal } from "@budibase/bbui"
import TableNavigator from "components/backend/TableNavigator/TableNavigator.svelte"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
@ -8,11 +8,11 @@
const tabs = [
{
title: "Tables",
title: "Internal",
key: "table",
},
{
title: "Data Sources",
title: "External",
key: "datasource",
},
]
@ -67,6 +67,7 @@
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-l);
background: var(--background);
}
.nav {

View File

@ -36,6 +36,8 @@
<style>
section {
overflow: scroll;
width: 800px;
margin: 0 auto;
}
::-webkit-scrollbar {
width: 0px;

View File

@ -1,18 +1,23 @@
<script>
import { goto } from "@sveltech/routify"
import { Button, Spacer, Icon } from "@budibase/bbui"
import { goto, beforeUrlChange } from "@sveltech/routify"
import { Button, Heading, Body, Spacer, Icon } from "@budibase/bbui"
import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons"
let unsaved = false
$: datasource = $backendUiStore.datasources.find(
ds => ds._id === $backendUiStore.selectedDatasourceId
)
$: integration = datasource && $backendUiStore.integrations[datasource.source]
async function saveDatasource() {
// Create datasource
await backendUiStore.actions.datasources.save(datasource)
notifier.success(`Datasource ${name} saved successfully.`)
unsaved = false
}
function onClickQuery(query) {
@ -22,28 +27,60 @@
backendUiStore.actions.queries.select(query)
$goto(`../${query._id}`)
}
function setUnsaved() {
unsaved = true
}
$beforeUrlChange((event, store) => {
if (unsaved) {
notifier.danger(
"Unsaved changes. Please save your datasource configuration before leaving."
)
return false
}
return true
})
</script>
{#if datasource}
<section>
<Spacer medium />
<header>
<div class="datasource-icon">
<svelte:component
this={ICONS[datasource.source]}
height="30"
width="30" />
</div>
<h3 class="section-title">{datasource.name}</h3>
</header>
<Spacer extraLarge />
<Body small grey lh>{integration.description}</Body>
<hr />
<div class="container">
<div class="config-header">
<h5>Configuration</h5>
<Heading small>Configuration</Heading>
<Button secondary on:click={saveDatasource}>Save</Button>
</div>
<Body small grey>
Connect your database to Budibase using the config below.
</Body>
<Spacer medium />
<IntegrationConfigForm integration={datasource.config} />
</div>
<Spacer extraLarge />
<div class="container">
<IntegrationConfigForm
integration={datasource.config}
on:change={setUnsaved} />
<Spacer medium />
<hr />
<div class="query-header">
<h5>Queries</h5>
<Button blue on:click={() => $goto('../new')}>Create Query</Button>
<Heading small>Queries</Heading>
<Button secondary on:click={() => $goto('../new')}>Add Query</Button>
</div>
<Spacer extraLarge />
<div class="query-list">
@ -54,7 +91,6 @@
<p></p>
</div>
{/each}
<Spacer medium />
</div>
</div>
</section>
@ -64,13 +100,20 @@
h3 {
margin: 0;
}
section {
margin: 0 auto;
width: 800px;
}
hr {
margin-bottom: var(--layout-m);
}
header {
margin: 0 0 var(--spacing-xs) 0;
display: flex;
gap: var(--spacing-m);
}
.section-title {
@ -85,13 +128,12 @@
.container {
border-radius: var(--border-radius-m);
background: var(--background);
padding: var(--layout-s);
margin: 0 auto;
}
h5 {
margin: 0 !important;
font-size: var(--font-size-l);
}
.query-header {
@ -115,7 +157,8 @@
display: grid;
grid-template-columns: 2fr 0.75fr 20px;
align-items: center;
padding: var(--spacing-m) var(--layout-xs);
padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
gap: var(--layout-xs);
transition: 200ms background ease;
}

File diff suppressed because it is too large Load Diff

View File

@ -119,8 +119,8 @@ export const enrichRows = async (rows, tableId) => {
for (let key of keys) {
const type = schema[key].type
if (type === "link") {
// Enrich row with the count of any relationship fields
row[`${key}_count`] = Array.isArray(row[key]) ? row[key].length : 0
// Enrich row a string join of relationship fields
row[`${key}_text`] = row[key]?.join(", ") || ""
} else if (type === "attachment") {
// Enrich row with the first image URL for any attachment fields
let url = null

View File

@ -5,6 +5,8 @@ const env = require("../../environment")
const { getAPIKey } = require("../../utilities/usageQuota")
const { generateUserID } = require("../../db/utils")
const { setCookie } = require("../../utilities")
const { outputProcessing } = require("../../utilities/rowProcessor")
const { ViewNames } = require("../../db/utils")
exports.authenticate = async ctx => {
const appId = ctx.appId
@ -62,12 +64,14 @@ exports.fetchSelf = async ctx => {
const { userId, appId } = ctx.user
if (!userId || !appId) {
ctx.body = {}
} else {
const database = new CouchDB(appId)
const user = await database.get(userId)
if (user) {
delete user.password
}
ctx.body = user
return
}
const db = new CouchDB(appId)
const user = await db.get(userId)
const userTable = await db.get(ViewNames.USERS)
if (user) {
delete user.password
}
// specifically needs to make sure is enriched
ctx.body = await outputProcessing(appId, userTable, user)
}

View File

@ -58,13 +58,25 @@ async function enrichQueryFields(fields, parameters) {
// enrich the fields with dynamic parameters
for (let key of Object.keys(fields)) {
enrichedQuery[key] = await processString(fields[key], parameters)
if (typeof fields[key] === "object") {
// enrich nested fields object
enrichedQuery[key] = await enrichQueryFields(fields[key], parameters)
} else {
// enrich string value as normal
enrichedQuery[key] = await processString(fields[key], parameters)
}
}
if (enrichedQuery.json || enrichedQuery.customData) {
if (
enrichedQuery.json ||
enrichedQuery.customData ||
enrichedQuery.requestBody
) {
try {
enrichedQuery.json = JSON.parse(
enrichedQuery.json || enrichedQuery.customData
enrichedQuery.json ||
enrichedQuery.customData ||
enrichedQuery.requestBody
)
} catch (err) {
throw { message: `JSON Invalid - error: ${err}` }

View File

@ -14,6 +14,7 @@ const {
outputProcessing,
} = require("../../utilities/rowProcessor")
const { FieldTypes } = require("../../constants")
const { isEqual } = require("lodash")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}`
@ -68,7 +69,7 @@ exports.patch = async function(ctx) {
}
// this returns the table and row incase they have been updated
let { table, row } = await inputProcessing(ctx.user, dbTable, dbRow)
let { table, row } = inputProcessing(ctx.user, dbTable, dbRow)
const validateResult = await validate({
row,
table,
@ -101,6 +102,10 @@ exports.patch = async function(ctx) {
}
const response = await db.put(row)
// don't worry about rev, tables handle rev/lastID updates
if (!isEqual(dbTable, table)) {
await db.put(table)
}
row._rev = response.rev
row.type = "row"
@ -136,11 +141,8 @@ exports.save = async function(ctx) {
}
// this returns the table and row incase they have been updated
let { table, row } = await inputProcessing(
ctx.user,
await db.get(inputs.tableId),
inputs
)
const dbTable = await db.get(inputs.tableId)
let { table, row } = inputProcessing(ctx.user, dbTable, inputs)
const validateResult = await validate({
row,
table,
@ -174,6 +176,10 @@ exports.save = async function(ctx) {
row.type = "row"
const response = await db.put(row)
// don't worry about rev, tables handle rev/lastID updates
if (!isEqual(dbTable, table)) {
await db.put(table)
}
row._rev = response.rev
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)
ctx.body = row

View File

@ -9,6 +9,7 @@ const {
} = require("../../db/utils")
const { isEqual } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../../constants")
const { inputProcessing } = require("../../utilities/rowProcessor")
async function checkForColumnUpdates(db, oldTable, updatedTable) {
let updatedRows
@ -61,6 +62,82 @@ function makeSureTableUpToDate(table, tableToSave) {
return tableToSave
}
async function handleDataImport(user, table, dataImport) {
const db = new CouchDB(user.appId)
if (dataImport && dataImport.csvString) {
// Populate the table with rows imported from CSV in a bulk update
const data = await csvParser.transform(dataImport)
for (let i = 0; i < data.length; i++) {
let row = data[i]
row._id = generateRowID(table._id)
row.tableId = table._id
const processed = inputProcessing(user, table, row)
row = processed.row
// these auto-fields will never actually link anywhere (always builder)
for (let [fieldName, schema] of Object.entries(table.schema)) {
if (
schema.autocolumn &&
(schema.subtype === AutoFieldSubTypes.CREATED_BY ||
schema.subtype === AutoFieldSubTypes.UPDATED_BY)
) {
delete row[fieldName]
}
}
table = processed.table
data[i] = row
}
await db.bulkDocs(data)
let response = await db.put(table)
table._rev = response._rev
}
return table
}
async function handleSearchIndexes(db, table) {
// create relevant search indexes
if (table.indexes && table.indexes.length > 0) {
const currentIndexes = await db.getIndexes()
const indexName = `search:${table._id}`
const existingIndex = currentIndexes.indexes.find(
existing => existing.name === indexName
)
if (existingIndex) {
const currentFields = existingIndex.def.fields.map(
field => Object.keys(field)[0]
)
// if index fields have changed, delete the original index
if (!isEqual(currentFields, table.indexes)) {
await db.deleteIndex(existingIndex)
// create/recreate the index with fields
await db.createIndex({
index: {
fields: table.indexes,
name: indexName,
ddoc: "search_ddoc",
type: "json",
},
})
}
} else {
// create/recreate the index with fields
await db.createIndex({
index: {
fields: table.indexes,
name: indexName,
ddoc: "search_ddoc",
type: "json",
},
})
}
}
return table
}
exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.appId)
const body = await db.allDocs(
@ -152,61 +229,12 @@ exports.save = async function(ctx) {
const result = await db.post(tableToSave)
tableToSave._rev = result.rev
// create relevant search indexes
if (tableToSave.indexes && tableToSave.indexes.length > 0) {
const currentIndexes = await db.getIndexes()
const indexName = `search:${result.id}`
const existingIndex = currentIndexes.indexes.find(
existing => existing.name === indexName
)
if (existingIndex) {
const currentFields = existingIndex.def.fields.map(
field => Object.keys(field)[0]
)
// if index fields have changed, delete the original index
if (!isEqual(currentFields, tableToSave.indexes)) {
await db.deleteIndex(existingIndex)
// create/recreate the index with fields
await db.createIndex({
index: {
fields: tableToSave.indexes,
name: indexName,
ddoc: "search_ddoc",
type: "json",
},
})
}
} else {
// create/recreate the index with fields
await db.createIndex({
index: {
fields: tableToSave.indexes,
name: indexName,
ddoc: "search_ddoc",
type: "json",
},
})
}
}
tableToSave = await handleSearchIndexes(db, tableToSave)
tableToSave = await handleDataImport(ctx.user, tableToSave, dataImport)
ctx.eventEmitter &&
ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave)
if (dataImport && dataImport.csvString) {
// Populate the table with rows imported from CSV in a bulk update
const data = await csvParser.transform(dataImport)
for (let row of data) {
row._id = generateRowID(tableToSave._id)
row.tableId = tableToSave._id
}
await db.bulkDocs(data)
}
ctx.status = 200
ctx.message = `Table ${ctx.request.body.name} saved successfully.`
ctx.body = tableToSave

View File

@ -137,6 +137,7 @@ exports.addPermission = async (
exports.createLinkedTable = async (request, appId) => {
// get the ID to link to
const table = await exports.createTable(request, appId)
table.primaryDisplay = "name"
table.schema.link = {
type: "link",
fieldName: "link",

View File

@ -287,7 +287,7 @@ describe("/rows", () => {
})).body
const enriched = await outputProcessing(appId, table, [secondRow])
expect(enriched[0].link.length).toBe(1)
expect(enriched[0].link[0]).toBe(firstRow._id)
expect(enriched[0].link[0]).toBe("Test Contact")
})
})

View File

@ -4,8 +4,13 @@ const {
getLinkDocuments,
createLinkView,
getUniqueByProp,
getRelatedTableForField,
getLinkedTableIDs,
getLinkedTable,
} = require("./linkUtils")
const { flatten } = require("lodash")
const CouchDB = require("../../db")
const { getMultiIDParams } = require("../../db/utils")
/**
* This functionality makes sure that when rows with links are created, updated or deleted they are processed
@ -27,6 +32,30 @@ exports.IncludeDocs = IncludeDocs
exports.getLinkDocuments = getLinkDocuments
exports.createLinkView = createLinkView
async function getLinksForRows(appId, rows) {
const tableIds = [...new Set(rows.map(el => el.tableId))]
// start by getting all the link values for performance reasons
const responses = flatten(
await Promise.all(
tableIds.map(tableId =>
getLinkDocuments({
appId,
tableId: tableId,
includeDocs: IncludeDocs.EXCLUDE,
})
)
)
)
// have to get unique as the previous table query can
// return duplicates, could be querying for both tables in a relation
return getUniqueByProp(
responses
// create a unique ID which we can use for getting only unique ones
.map(el => ({ ...el, unique: el.id + el.fieldName })),
"unique"
)
}
/**
* Update link documents for a row or table - this is to be called by the API controller when a change is occurring.
* @param {string} eventType states what type of change which is occurring, means this can be expanded upon in the
@ -92,49 +121,66 @@ exports.updateLinks = async function({
* @returns {Promise<object>} The updated row (this may be the same if no links were found). If an array was input
* then an array will be output, object input -> object output.
*/
exports.attachLinkInfo = async (appId, rows) => {
// handle a single row as well as multiple
let wasArray = true
if (!(rows instanceof Array)) {
rows = [rows]
wasArray = false
}
let tableIds = [...new Set(rows.map(el => el.tableId))]
// start by getting all the link values for performance reasons
let responses = flatten(
await Promise.all(
tableIds.map(tableId =>
getLinkDocuments({
appId,
tableId: tableId,
includeDocs: IncludeDocs.EXCLUDE,
})
)
)
)
exports.attachLinkIDs = async (appId, rows) => {
const links = await getLinksForRows(appId, rows)
// now iterate through the rows and all field information
for (let row of rows) {
// get all links for row, ignore fieldName for now
// have to get unique as the previous table query can
// return duplicates, could be querying for both tables in a relation
const linkVals = getUniqueByProp(
responses
// find anything that matches the row's ID we are searching for
.filter(el => el.thisId === row._id)
// create a unique ID which we can use for getting only unique ones
.map(el => ({ ...el, unique: el.id + el.fieldName })),
"unique"
)
for (let linkVal of linkVals) {
// work out which link pertains to this row
if (!(row[linkVal.fieldName] instanceof Array)) {
row[linkVal.fieldName] = [linkVal.id]
} else {
row[linkVal.fieldName].push(linkVal.id)
}
}
// find anything that matches the row's ID we are searching for and join it
links
.filter(el => el.thisId === row._id)
.forEach(link => {
if (row[link.fieldName] == null) {
row[link.fieldName] = []
}
row[link.fieldName].push(link.id)
})
}
// if it was an array when it came in then handle it as an array in response
// otherwise return the first element as there was only one input
return wasArray ? rows : rows[0]
return rows
}
/**
* Given information about the table we can extract the display name from the linked rows, this
* is what we do for showing the display name of each linked row when in a table format.
* @param {string} appId The app in which the tables/rows/links exist.
* @param {object} table The table from which the rows originated.
* @param {array<object>} rows The rows which are to be enriched with the linked display names/IDs.
* @returns {Promise<Array>} The enriched rows after having display names/IDs attached to the linked fields.
*/
exports.attachLinkedPrimaryDisplay = async (appId, table, rows) => {
const linkedTableIds = getLinkedTableIDs(table)
if (linkedTableIds.length === 0) {
return rows
}
const db = new CouchDB(appId)
const links = (await getLinksForRows(appId, rows)).filter(link =>
rows.some(row => row._id === link.thisId)
)
const linkedRowIds = links.map(link => link.id)
const linked = (await db.allDocs(getMultiIDParams(linkedRowIds))).rows.map(
row => row.doc
)
// will populate this as we find them
const linkedTables = []
for (let row of rows) {
for (let link of links.filter(link => link.thisId === row._id)) {
if (row[link.fieldName] == null) {
row[link.fieldName] = []
}
const linkedRow = linked.find(row => row._id === link.id)
const linkedTableId =
linkedRow.tableId || getRelatedTableForField(table, link.fieldName)
const linkedTable = await getLinkedTable(db, linkedTableId, linkedTables)
if (!linkedRow || !linkedTable) {
continue
}
// need to handle an edge case where relationship just wasn't found
const value = linkedRow[linkedTable.primaryDisplay] || linkedRow._id
if (value) {
row[link.fieldName].push(value)
}
}
}
return rows
}

View File

@ -1,6 +1,7 @@
const CouchDB = require("../index")
const Sentry = require("@sentry/node")
const { ViewNames, getQueryIndex } = require("../utils")
const { FieldTypes } = require("../../constants")
/**
* Only needed so that boolean parameters are being used for includeDocs
@ -120,3 +121,35 @@ exports.getUniqueByProp = (array, prop) => {
return arr.map(mapObj => mapObj[prop]).indexOf(obj[prop]) === pos
})
}
exports.getLinkedTableIDs = table => {
return Object.values(table.schema)
.filter(column => column.type === FieldTypes.LINK)
.map(column => column.tableId)
}
exports.getLinkedTable = async (db, id, tables) => {
let linkedTable = tables.find(table => table._id === id)
if (linkedTable) {
return linkedTable
}
linkedTable = await db.get(id)
if (linkedTable) {
tables.push(linkedTable)
}
return linkedTable
}
exports.getRelatedTableForField = (table, fieldName) => {
// look to see if its on the table, straight in the schema
const field = table.schema[fieldName]
if (field != null) {
return field.tableId
}
for (let column of Object.values(table.schema)) {
if (column.type === FieldTypes.LINK && column.fieldName === fieldName) {
return column.tableId
}
}
return null
}

View File

@ -277,3 +277,13 @@ exports.getQueryParams = (datasourceId = null, otherProps = {}) => {
otherProps
)
}
/**
* This can be used with the db.allDocs to get a list of IDs
*/
exports.getMultiIDParams = ids => {
return {
keys: ids,
include_docs: true,
}
}

View File

@ -9,4 +9,6 @@ exports.FIELD_TYPES = {
NUMBER: "number",
PASSWORD: "password",
LIST: "list",
OBJECT: "object",
JSON: "json",
}

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://airtable.com/api",
description:
"Airtable is a spreadsheet-database hybrid, with the features of a database but applied to a spreadsheet.",
friendlyName: "Airtable",
datasource: {
apiKey: {
type: FIELD_TYPES.STRING,
@ -50,7 +53,7 @@ const SCHEMA = {
},
},
delete: {
type: FIELD_TYPES.JSON,
type: QUERY_TYPES.JSON,
},
},
}

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/arangodb/arangojs",
friendlyName: "ArangoDB",
description:
"ArangoDB is a scalable open-source multi-model database natively supporting graph, document and search. All supported data models & access patterns can be combined in queries allowing for maximal flexibility. ",
datasource: {
url: {
type: FIELD_TYPES.STRING,

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://docs.couchdb.org/en/stable/",
friendlyName: "CouchDB",
description:
"Apache CouchDB is an open-source document-oriented NoSQL database, implemented in Erlang.",
datasource: {
url: {
type: FIELD_TYPES.STRING,

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/dabit3/dynamodb-documentclient-cheat-sheet",
description:
"Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale.",
friendlyName: "DynamoDB",
datasource: {
region: {
type: FIELD_TYPES.STRING,

View File

@ -4,6 +4,9 @@ const { QUERY_TYPES, FIELD_TYPES } = require("./Integration")
const SCHEMA = {
docs:
"https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html",
description:
"Elasticsearch is a search engine based on the Lucene library. It provides a distributed, multitenant-capable full-text search engine with an HTTP web interface and schema-free JSON documents.",
friendlyName: "ElasticSearch",
datasource: {
url: {
type: "string",

View File

@ -8,6 +8,7 @@ const s3 = require("./s3")
const airtable = require("./airtable")
const mysql = require("./mysql")
const arangodb = require("./arangodb")
const rest = require("./rest")
const DEFINITIONS = {
POSTGRES: postgres.schema,
@ -20,6 +21,7 @@ const DEFINITIONS = {
AIRTABLE: airtable.schema,
MYSQL: mysql.schema,
ARANGODB: arangodb.schema,
REST: rest.schema,
}
const INTEGRATIONS = {
@ -33,6 +35,7 @@ const INTEGRATIONS = {
AIRTABLE: airtable.integration,
MYSQL: mysql.integration,
ARANGODB: arangodb.integration,
REST: rest.integration,
}
module.exports = {

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/tediousjs/node-mssql",
description:
"Microsoft SQL Server is a relational database management system developed by Microsoft. ",
friendlyName: "MS SQL Server",
datasource: {
user: {
type: FIELD_TYPES.STRING,

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/mongodb/node-mongodb-native",
friendlyName: "MongoDB",
description:
"MongoDB is a general purpose, document-based, distributed database built for modern application developers and for the cloud era.",
datasource: {
connectionString: {
type: FIELD_TYPES.STRING,

View File

@ -1,8 +1,11 @@
const mysql = require("mysql")
const { FIELD_TYPES } = require("./Integration")
const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/mysqljs/mysql",
friendlyName: "MySQL",
description:
"MySQL Database Service is a fully managed database service to deploy cloud-native applications. ",
datasource: {
host: {
type: FIELD_TYPES.STRING,
@ -31,16 +34,16 @@ const SCHEMA = {
},
query: {
create: {
type: "sql",
type: QUERY_TYPES.SQL,
},
read: {
type: "sql",
type: QUERY_TYPES.SQL,
},
update: {
type: "sql",
type: QUERY_TYPES.SQL,
},
delete: {
type: "sql",
type: QUERY_TYPES.SQL,
},
},
}

View File

@ -2,6 +2,9 @@ const { Client } = require("pg")
const SCHEMA = {
docs: "https://node-postgres.com",
friendlyName: "PostgreSQL",
description:
"PostgreSQL, also known as Postgres, is a free and open-source relational database management system emphasizing extensibility and SQL compliance.",
datasource: {
host: {
type: "string",

View File

@ -0,0 +1,175 @@
const fetch = require("node-fetch")
const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/node-fetch/node-fetch",
description:
"Representational state transfer (REST) is a de-facto standard for a software architecture for interactive applications that typically use multiple Web services. ",
friendlyName: "REST API",
datasource: {
url: {
type: FIELD_TYPES.STRING,
default: "localhost",
required: true,
},
defaultHeaders: {
type: FIELD_TYPES.OBJECT,
required: false,
default: {},
},
},
query: {
create: {
displayName: "POST",
type: QUERY_TYPES.FIELDS,
urlDisplay: true,
fields: {
path: {
type: FIELD_TYPES.STRING,
},
queryString: {
type: FIELD_TYPES.STRING,
},
headers: {
type: FIELD_TYPES.OBJECT,
},
requestBody: {
type: FIELD_TYPES.JSON,
},
},
},
read: {
displayName: "GET",
type: QUERY_TYPES.FIELDS,
urlDisplay: true,
fields: {
path: {
type: FIELD_TYPES.STRING,
},
queryString: {
type: FIELD_TYPES.STRING,
},
headers: {
type: FIELD_TYPES.OBJECT,
},
},
},
update: {
displayName: "PUT",
type: QUERY_TYPES.FIELDS,
urlDisplay: true,
fields: {
path: {
type: FIELD_TYPES.STRING,
},
queryString: {
type: FIELD_TYPES.STRING,
},
headers: {
type: FIELD_TYPES.OBJECT,
},
requestBody: {
type: FIELD_TYPES.JSON,
},
},
},
delete: {
displayName: "DELETE",
type: QUERY_TYPES.FIELDS,
urlDisplay: true,
fields: {
path: {
type: FIELD_TYPES.STRING,
},
queryString: {
type: FIELD_TYPES.STRING,
},
headers: {
type: FIELD_TYPES.OBJECT,
},
requestBody: {
type: FIELD_TYPES.JSON,
},
},
},
},
}
class RestIntegration {
constructor(config) {
this.config = config
}
async parseResponse(response) {
switch (this.headers.Accept) {
case "application/json":
return await response.json()
case "text/html":
return await response.text()
default:
return await response.json()
}
}
async create({ path, queryString, headers = {}, json }) {
this.headers = {
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.config.url + path + queryString, {
method: "POST",
headers: this.headers,
body: JSON.stringify(json),
})
return await this.parseResponse(response)
}
async read({ path, queryString, headers = {} }) {
this.headers = {
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.config.url + path + queryString, {
headers: this.headers,
})
return await this.parseResponse(response)
}
async update({ path, queryString, headers = {}, json }) {
this.headers = {
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.config.url + path + queryString, {
method: "POST",
headers: this.headers,
body: JSON.stringify(json),
})
return await this.parseResponse(response)
}
async delete({ path, queryString, headers = {} }) {
this.headers = {
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.config.url + path + queryString, {
method: "DELETE",
headers: this.headers,
})
return await this.parseResponse(response)
}
}
module.exports = {
schema: SCHEMA,
integration: RestIntegration,
}

View File

@ -2,6 +2,9 @@ const AWS = require("aws-sdk")
const SCHEMA = {
docs: "https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html",
description:
"Amazon Simple Storage Service (Amazon S3) is an object storage service that offers industry-leading scalability, data availability, security, and performance.",
friendlyName: "Amazon S3",
datasource: {
region: {
type: "string",

View File

@ -3,7 +3,6 @@ const { OBJ_STORE_DIRECTORY } = require("../constants")
const linkRows = require("../db/linkedRows")
const { cloneDeep } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../constants")
const CouchDB = require("../db")
const BASE_AUTO_ID = 1
@ -71,14 +70,13 @@ const TYPE_TRANSFORM_MAP = {
* @param {Object} user The user to be used for an appId as well as the createdBy and createdAt fields.
* @param {Object} table The table which is to be used for the schema, as well as handling auto IDs incrementing.
* @param {Object} row The row which is to be updated with information for the auto columns.
* @returns {Promise<{row: Object, table: Object}>} The updated row and table, the table may need to be updated
* @returns {{row: Object, table: Object}} The updated row and table, the table may need to be updated
* for automatic ID purposes.
*/
async function processAutoColumn(user, table, row) {
function processAutoColumn(user, table, row) {
let now = new Date().toISOString()
// if a row doesn't have a revision then it doesn't exist yet
const creating = !row._rev
let tableUpdated = false
for (let [key, schema] of Object.entries(table.schema)) {
if (!schema.autocolumn) {
continue
@ -104,17 +102,10 @@ async function processAutoColumn(user, table, row) {
if (creating) {
schema.lastID = !schema.lastID ? BASE_AUTO_ID : schema.lastID + 1
row[key] = schema.lastID
tableUpdated = true
}
break
}
}
if (tableUpdated) {
const db = new CouchDB(user.appId)
const response = await db.put(table)
// update the revision
table._rev = response._rev
}
return { table, row }
}
@ -143,7 +134,7 @@ exports.coerce = (row, type) => {
* @param {object} table the table which the row is being saved to.
* @returns {object} the row which has been prepared to be written to the DB.
*/
exports.inputProcessing = async (user, table, row) => {
exports.inputProcessing = (user, table, row) => {
let clonedRow = cloneDeep(row)
for (let [key, value] of Object.entries(clonedRow)) {
const field = table.schema[key]
@ -166,8 +157,17 @@ exports.inputProcessing = async (user, table, row) => {
* @returns {object[]} the enriched rows will be returned.
*/
exports.outputProcessing = async (appId, table, rows) => {
let wasArray = true
if (!(rows instanceof Array)) {
rows = [rows]
wasArray = false
}
// attach any linked row information
const outputRows = await linkRows.attachLinkInfo(appId, rows)
const outputRows = await linkRows.attachLinkedPrimaryDisplay(
appId,
table,
rows
)
// update the attachments URL depending on hosting
if (env.CLOUD && env.SELF_HOSTED) {
for (let [property, column] of Object.entries(table.schema)) {
@ -184,5 +184,5 @@ exports.outputProcessing = async (appId, table, rows) => {
}
}
}
return outputRows
return wasArray ? outputRows : outputRows[0]
}

View File

@ -1391,6 +1391,11 @@
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "boolean",
"label": "Disabled",

View File

@ -7,6 +7,7 @@
export let field
export let label
export let placeholder
export let disabled = false
let fieldState
@ -16,8 +17,26 @@
// Picker state
let options = []
let tableDefinition
let fieldText = ""
$: fieldText = `${$fieldState?.value?.length ?? 0} selected rows`
const setFieldText = (value) => {
if (fieldSchema?.relationshipType === 'one-to-many') {
if (value?.length && options?.length) {
const row = options.find(row => row._id === value[0])
return row.name
} else {
return placeholder || 'Choose an option'
}
} else {
if (value?.length) {
return `${value?.length ?? 0} selected rows`
} else {
return placeholder || 'Choose some options'
}
}
}
$: options, fieldText = setFieldText($fieldState?.value)
$: valueLookupMap = getValueLookupMap($fieldState?.value)
$: isOptionSelected = option => valueLookupMap[option] === true
$: linkedTableId = fieldSchema?.tableId
@ -55,11 +74,15 @@
}
const toggleOption = option => {
if ($fieldState.value.includes(option)) {
if (fieldSchema.type === 'one-to-many') {
fieldApi.setValue([option])
} else {
if ($fieldState.value.includes(option)) {
fieldApi.setValue($fieldState.value.filter(x => x !== option))
} else {
fieldApi.setValue([...$fieldState.value, option])
}
}
}
</script>

View File

@ -2,13 +2,14 @@
export let columnName
export let row
$: count =
row && columnName && Array.isArray(row[columnName])
? row[columnName].length
: 0
$: items = row?.[columnName] || []
</script>
<div class="container">{count} related row(s)</div>
<div class="container">
{#each items as item}
<div class="item">{item}</div>
{/each}
</div>
<style>
.container {
@ -19,4 +20,13 @@
gap: var(--spacing-xs);
width: 100%;
}
.item {
font-size: var(--font-size-xs);
padding: var(--spacing-xs) var(--spacing-s);
border: 1px solid var(--grey-5);
color: var(--grey-7);
line-height: normal;
border-radius: 4px;
}
</style>