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 // Unset table display column
cy.contains("display column").click() cy.contains("display column").click()
cy.contains("Save 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", () => { 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", () => { context("Create a View", () => {
before(() => { before(() => {
cy.visit("localhost:4001/_builder") cy.visit("localhost:4001/_builder")
@ -28,7 +36,7 @@ context("Create a View", () => {
const headers = Array.from($headers).map(header => const headers = Array.from($headers).map(header =>
header.textContent.trim() 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 => const headers = Array.from($headers).map(header =>
header.textContent.trim() 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 => { cy.get(".ag-cell").then($values => {
let values = Array.from($values).map(header => let values = Array.from($values).map(header =>
header.textContent.trim() 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") .find(".ag-cell")
.then($values => { .then($values => {
const values = Array.from($values).map(value => value.textContent) 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": { "dependencies": {
"@budibase/bbui": "^1.58.8", "@budibase/bbui": "^1.58.12",
"@budibase/client": "^0.7.8", "@budibase/client": "^0.7.8",
"@budibase/colorpicker": "1.0.1", "@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.8", "@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 // Replace certain bindings with a new property to help display components
let runtimeBoundKey = key let runtimeBoundKey = key
if (fieldSchema.type === "link") { if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_count` runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") { } else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first` runtimeBoundKey = `${key}_first`
} }
@ -176,7 +176,7 @@ const getUserBindings = () => {
// Replace certain bindings with a new property to help display components // Replace certain bindings with a new property to help display components
let runtimeBoundKey = key let runtimeBoundKey = key
if (fieldSchema.type === "link") { if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_count` runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") { } else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first` runtimeBoundKey = `${key}_first`
} }

View File

@ -198,6 +198,13 @@ export function makeDatasourceFormComponents(datasource) {
if (fieldType === "options") { if (fieldType === "options") {
component.customProps({ placeholder: "Choose an option " }) 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") { if (fieldType === "boolean") {
component.customProps({ text: field, label: "" }) component.customProps({ text: field, label: "" })
} }

View File

@ -301,4 +301,13 @@
padding-top: var(--spacing-xs); padding-top: var(--spacing-xs);
padding-bottom: 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> </style>

View File

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

View File

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

View File

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

View File

@ -1,15 +1,37 @@
<script> <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 export let integration
let unsaved = false
</script> </script>
<form> <form>
{#each Object.keys(integration) as configKey} {#each Object.keys(integration) as configKey}
{#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 <Input
outline
type={integration[configKey].type} type={integration[configKey].type}
label={configKey} on:change
bind:value={integration[configKey]} /> bind:value={integration[configKey]} />
<Spacer large /> </div>
{/if}
{/each} {/each}
</form> </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 { onMount } from "svelte"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import api from "builderStore/api" 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" import ICONS from "../icons"
export let integration = {} export let integration = {}
@ -49,17 +50,6 @@
</div> </div>
{/each} {/each}
</div> </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> </section>
<style> <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 SqlServer from "./SQLServer.svelte"
import MySQL from "./MySQL.svelte" import MySQL from "./MySQL.svelte"
import ArangoDB from "./ArangoDB.svelte" import ArangoDB from "./ArangoDB.svelte"
import Rest from "./Rest.svelte"
export default { export default {
POSTGRES: Postgres, POSTGRES: Postgres,
@ -20,4 +21,5 @@ export default {
AIRTABLE: Airtable, AIRTABLE: Airtable,
MYSQL: MySQL, MYSQL: MySQL,
ARANGODB: ArangoDB, 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 { notifier } from "builderStore/store/notifications"
import { DropdownMenu, Button, Input } from "@budibase/bbui" import { DropdownMenu, Button, Input } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import IntegrationConfigForm from "../TableIntegrationMenu//IntegrationConfigForm.svelte"
import { DropdownContainer, DropdownItem } from "components/common/Dropdowns" import { DropdownContainer, DropdownItem } from "components/common/Dropdowns"
export let datasource 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 { notifier } from "builderStore/store/notifications"
import { DropdownMenu, Button, Input } from "@budibase/bbui" import { DropdownMenu, Button, Input } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import IntegrationConfigForm from "../TableIntegrationMenu//IntegrationConfigForm.svelte"
import { DropdownContainer, DropdownItem } from "components/common/Dropdowns" import { DropdownContainer, DropdownItem } from "components/common/Dropdowns"
export let query export let query

View File

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

View File

@ -41,6 +41,14 @@
table. table.
</Label> </Label>
{:else} {:else}
{#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 <Multiselect
secondary secondary
bind:value={linkedRows} bind:value={linkedRows}
@ -50,4 +58,5 @@
<option value={row._id}>{getPrettyName(row)}</option> <option value={row._id}>{getPrettyName(row)}</option>
{/each} {/each}
</Multiselect> </Multiselect>
{/if}
{/if} {/if}

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<script> <script>
import { Button, Drawer, Spacer } from "@budibase/bbui" import { Button, Drawer, Spacer, Body } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { notifier } from "builderStore/store/notifications" import { notifier } from "builderStore/store/notifications"
import { import {
@ -41,15 +41,15 @@
</heading> </heading>
<div slot="body"> <div slot="body">
<div class="root"> <div class="root">
<Body small grey>
{#if !Object.keys(tempValue || {}).length} {#if !Object.keys(tempValue || {}).length}
<p>Add your first filter column.</p> Add your first filter column.
{:else} {:else}
<p>
Results are filtered to only those which match all of the following Results are filtered to only those which match all of the following
constaints. constaints.
</p>
{/if} {/if}
<Spacer small /> </Body>
<Spacer medium />
<div class="fields"> <div class="fields">
<SaveFields <SaveFields
parameterFields={value} parameterFields={value}
@ -67,11 +67,6 @@
min-height: calc(40vh - 2 * var(--spacing-l)); min-height: calc(40vh - 2 * var(--spacing-l));
} }
p {
margin: 0 0 var(--spacing-s) 0;
font-size: var(--font-size-s);
}
.fields { .fields {
display: grid; display: grid;
column-gap: var(--spacing-l); 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> <script>
import CodeMirror from "./codemirror" import CodeMirror from "./codemirror"
import { Label, Spacer } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte" import { onMount, createEventDispatcher } from "svelte"
import { themeStore } from "builderStore" import { themeStore } from "builderStore"
import { handlebarsCompletions } from "constants/completions" import { handlebarsCompletions } from "constants/completions"
@ -11,6 +12,7 @@
LIGHT: "default", LIGHT: "default",
} }
export let label
export let value = "" export let value = ""
export let readOnly = false export let readOnly = false
export let lineNumbers = true export let lineNumbers = true
@ -170,6 +172,10 @@
} }
</script> </script>
{#if label}
<Label small>{label}</Label>
<Spacer medium />
{/if}
<div style={`--code-mirror-height: ${editorHeight}px`}> <div style={`--code-mirror-height: ${editorHeight}px`}>
<textarea tabindex="0" bind:this={refs.editor} readonly {value} /> <textarea tabindex="0" bind:this={refs.editor} readonly {value} />
</div> </div>

View File

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

View File

@ -1,5 +1,5 @@
<script> <script>
import { Button, Input, Label } from "@budibase/bbui" import { Body, Button, Input, Heading, Spacer } from "@budibase/bbui"
import { import {
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
@ -30,7 +30,22 @@
</script> </script>
<section> <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> <div class="parameters" class:bindable>
{#each parameters as parameter, idx} {#each parameters as parameter, idx}
<Input <Input
@ -58,9 +73,6 @@
{/if} {/if}
{/each} {/each}
</div> </div>
{#if !bindable}
<Button secondary on:click={newQueryParameter}>Add Parameter</Button>
{/if}
</section> </section>
<style> <style>
@ -68,6 +80,13 @@
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
} }
.controls {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
}
.parameters { .parameters {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 5%; grid-template-columns: 1fr 1fr 5%;

View File

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

View File

@ -1,6 +1,7 @@
<script> <script>
import Editor from "./QueryEditor.svelte" import Editor from "./QueryEditor.svelte"
import FieldsBuilder from "./QueryFieldsBuilder.svelte" import FieldsBuilder from "./QueryFieldsBuilder.svelte"
import { Label, Input } from "@budibase/bbui"
const QueryTypes = { const QueryTypes = {
SQL: "sql", SQL: "sql",
@ -9,10 +10,15 @@
} }
export let query export let query
export let datasource
export let schema export let schema
export let editable = true export let editable = true
export let height = 500 export let height = 500
$: urlDisplay =
schema.urlDisplay &&
`${datasource.config.url}${query.fields.path}${query.fields.queryString}`
function updateQuery({ detail }) { function updateQuery({ detail }) {
query.fields[schema.type] = detail.value query.fields[schema.type] = detail.value
} }
@ -40,6 +46,21 @@
parameters={query.parameters} /> parameters={query.parameters} />
{:else if schema.type === QueryTypes.FIELDS} {:else if schema.type === QueryTypes.FIELDS}
<FieldsBuilder bind:fields={query.fields} {schema} {editable} /> <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} {/if}
{/key} {/key}
{/if} {/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> <script>
import { params } from "@sveltech/routify" 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 TableNavigator from "components/backend/TableNavigator/TableNavigator.svelte"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte" import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte" import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
@ -8,11 +8,11 @@
const tabs = [ const tabs = [
{ {
title: "Tables", title: "Internal",
key: "table", key: "table",
}, },
{ {
title: "Data Sources", title: "External",
key: "datasource", key: "datasource",
}, },
] ]
@ -67,6 +67,7 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
gap: var(--spacing-l); gap: var(--spacing-l);
background: var(--background);
} }
.nav { .nav {

View File

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

View File

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

View File

@ -5,6 +5,8 @@ const env = require("../../environment")
const { getAPIKey } = require("../../utilities/usageQuota") const { getAPIKey } = require("../../utilities/usageQuota")
const { generateUserID } = require("../../db/utils") const { generateUserID } = require("../../db/utils")
const { setCookie } = require("../../utilities") const { setCookie } = require("../../utilities")
const { outputProcessing } = require("../../utilities/rowProcessor")
const { ViewNames } = require("../../db/utils")
exports.authenticate = async ctx => { exports.authenticate = async ctx => {
const appId = ctx.appId const appId = ctx.appId
@ -62,12 +64,14 @@ exports.fetchSelf = async ctx => {
const { userId, appId } = ctx.user const { userId, appId } = ctx.user
if (!userId || !appId) { if (!userId || !appId) {
ctx.body = {} ctx.body = {}
} else { return
const database = new CouchDB(appId) }
const user = await database.get(userId) const db = new CouchDB(appId)
const user = await db.get(userId)
const userTable = await db.get(ViewNames.USERS)
if (user) { if (user) {
delete user.password delete user.password
} }
ctx.body = user // 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 // enrich the fields with dynamic parameters
for (let key of Object.keys(fields)) { for (let key of Object.keys(fields)) {
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) enrichedQuery[key] = await processString(fields[key], parameters)
} }
}
if (enrichedQuery.json || enrichedQuery.customData) { if (
enrichedQuery.json ||
enrichedQuery.customData ||
enrichedQuery.requestBody
) {
try { try {
enrichedQuery.json = JSON.parse( enrichedQuery.json = JSON.parse(
enrichedQuery.json || enrichedQuery.customData enrichedQuery.json ||
enrichedQuery.customData ||
enrichedQuery.requestBody
) )
} catch (err) { } catch (err) {
throw { message: `JSON Invalid - error: ${err}` } throw { message: `JSON Invalid - error: ${err}` }

View File

@ -14,6 +14,7 @@ const {
outputProcessing, outputProcessing,
} = require("../../utilities/rowProcessor") } = require("../../utilities/rowProcessor")
const { FieldTypes } = require("../../constants") const { FieldTypes } = require("../../constants")
const { isEqual } = require("lodash")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}` 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 // 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({ const validateResult = await validate({
row, row,
table, table,
@ -101,6 +102,10 @@ exports.patch = async function(ctx) {
} }
const response = await db.put(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 row._rev = response.rev
row.type = "row" row.type = "row"
@ -136,11 +141,8 @@ exports.save = async function(ctx) {
} }
// this returns the table and row incase they have been updated // this returns the table and row incase they have been updated
let { table, row } = await inputProcessing( const dbTable = await db.get(inputs.tableId)
ctx.user, let { table, row } = inputProcessing(ctx.user, dbTable, inputs)
await db.get(inputs.tableId),
inputs
)
const validateResult = await validate({ const validateResult = await validate({
row, row,
table, table,
@ -174,6 +176,10 @@ exports.save = async function(ctx) {
row.type = "row" row.type = "row"
const response = await db.put(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 row._rev = response.rev
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)
ctx.body = row ctx.body = row

View File

@ -9,6 +9,7 @@ const {
} = require("../../db/utils") } = require("../../db/utils")
const { isEqual } = require("lodash/fp") const { isEqual } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../../constants") const { FieldTypes, AutoFieldSubTypes } = require("../../constants")
const { inputProcessing } = require("../../utilities/rowProcessor")
async function checkForColumnUpdates(db, oldTable, updatedTable) { async function checkForColumnUpdates(db, oldTable, updatedTable) {
let updatedRows let updatedRows
@ -61,6 +62,82 @@ function makeSureTableUpToDate(table, tableToSave) {
return 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) { exports.fetch = async function(ctx) {
const db = new CouchDB(ctx.user.appId) const db = new CouchDB(ctx.user.appId)
const body = await db.allDocs( const body = await db.allDocs(
@ -152,61 +229,12 @@ exports.save = async function(ctx) {
const result = await db.post(tableToSave) const result = await db.post(tableToSave)
tableToSave._rev = result.rev tableToSave._rev = result.rev
// create relevant search indexes tableToSave = await handleSearchIndexes(db, tableToSave)
if (tableToSave.indexes && tableToSave.indexes.length > 0) { tableToSave = await handleDataImport(ctx.user, tableToSave, dataImport)
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",
},
})
}
}
ctx.eventEmitter && ctx.eventEmitter &&
ctx.eventEmitter.emitTable(`table:save`, appId, tableToSave) 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.status = 200
ctx.message = `Table ${ctx.request.body.name} saved successfully.` ctx.message = `Table ${ctx.request.body.name} saved successfully.`
ctx.body = tableToSave ctx.body = tableToSave

View File

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

View File

@ -287,7 +287,7 @@ describe("/rows", () => {
})).body })).body
const enriched = await outputProcessing(appId, table, [secondRow]) const enriched = await outputProcessing(appId, table, [secondRow])
expect(enriched[0].link.length).toBe(1) 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, getLinkDocuments,
createLinkView, createLinkView,
getUniqueByProp, getUniqueByProp,
getRelatedTableForField,
getLinkedTableIDs,
getLinkedTable,
} = require("./linkUtils") } = require("./linkUtils")
const { flatten } = require("lodash") 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 * 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.getLinkDocuments = getLinkDocuments
exports.createLinkView = createLinkView 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. * 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 * @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 * @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. * then an array will be output, object input -> object output.
*/ */
exports.attachLinkInfo = async (appId, rows) => { exports.attachLinkIDs = async (appId, rows) => {
// handle a single row as well as multiple const links = await getLinksForRows(appId, rows)
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,
})
)
)
)
// now iterate through the rows and all field information // now iterate through the rows and all field information
for (let row of rows) { for (let row of rows) {
// get all links for row, ignore fieldName for now // find anything that matches the row's ID we are searching for and join it
// have to get unique as the previous table query can links
// 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) .filter(el => el.thisId === row._id)
// create a unique ID which we can use for getting only unique ones .forEach(link => {
.map(el => ({ ...el, unique: el.id + el.fieldName })), if (row[link.fieldName] == null) {
"unique" row[link.fieldName] = []
)
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)
}
} }
row[link.fieldName].push(link.id)
})
} }
// if it was an array when it came in then handle it as an array in response // 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 // 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 CouchDB = require("../index")
const Sentry = require("@sentry/node") const Sentry = require("@sentry/node")
const { ViewNames, getQueryIndex } = require("../utils") const { ViewNames, getQueryIndex } = require("../utils")
const { FieldTypes } = require("../../constants")
/** /**
* Only needed so that boolean parameters are being used for includeDocs * 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 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 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", NUMBER: "number",
PASSWORD: "password", PASSWORD: "password",
LIST: "list", LIST: "list",
OBJECT: "object",
JSON: "json",
} }

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = { const SCHEMA = {
docs: "https://airtable.com/api", 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: { datasource: {
apiKey: { apiKey: {
type: FIELD_TYPES.STRING, type: FIELD_TYPES.STRING,
@ -50,7 +53,7 @@ const SCHEMA = {
}, },
}, },
delete: { delete: {
type: FIELD_TYPES.JSON, type: QUERY_TYPES.JSON,
}, },
}, },
} }

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = { const SCHEMA = {
docs: "https://github.com/arangodb/arangojs", 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: { datasource: {
url: { url: {
type: FIELD_TYPES.STRING, type: FIELD_TYPES.STRING,

View File

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

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = { const SCHEMA = {
docs: "https://github.com/dabit3/dynamodb-documentclient-cheat-sheet", 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: { datasource: {
region: { region: {
type: FIELD_TYPES.STRING, type: FIELD_TYPES.STRING,

View File

@ -4,6 +4,9 @@ const { QUERY_TYPES, FIELD_TYPES } = require("./Integration")
const SCHEMA = { const SCHEMA = {
docs: docs:
"https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html", "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: { datasource: {
url: { url: {
type: "string", type: "string",

View File

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

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES } = require("./Integration")
const SCHEMA = { const SCHEMA = {
docs: "https://github.com/tediousjs/node-mssql", 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: { datasource: {
user: { user: {
type: FIELD_TYPES.STRING, type: FIELD_TYPES.STRING,

View File

@ -3,6 +3,9 @@ const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = { const SCHEMA = {
docs: "https://github.com/mongodb/node-mongodb-native", 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: { datasource: {
connectionString: { connectionString: {
type: FIELD_TYPES.STRING, type: FIELD_TYPES.STRING,

View File

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

View File

@ -2,6 +2,9 @@ const { Client } = require("pg")
const SCHEMA = { const SCHEMA = {
docs: "https://node-postgres.com", 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: { datasource: {
host: { host: {
type: "string", 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 = { const SCHEMA = {
docs: "https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html", 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: { datasource: {
region: { region: {
type: "string", type: "string",

View File

@ -3,7 +3,6 @@ const { OBJ_STORE_DIRECTORY } = require("../constants")
const linkRows = require("../db/linkedRows") const linkRows = require("../db/linkedRows")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { FieldTypes, AutoFieldSubTypes } = require("../constants") const { FieldTypes, AutoFieldSubTypes } = require("../constants")
const CouchDB = require("../db")
const BASE_AUTO_ID = 1 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} 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} 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. * @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. * for automatic ID purposes.
*/ */
async function processAutoColumn(user, table, row) { function processAutoColumn(user, table, row) {
let now = new Date().toISOString() let now = new Date().toISOString()
// if a row doesn't have a revision then it doesn't exist yet // if a row doesn't have a revision then it doesn't exist yet
const creating = !row._rev const creating = !row._rev
let tableUpdated = false
for (let [key, schema] of Object.entries(table.schema)) { for (let [key, schema] of Object.entries(table.schema)) {
if (!schema.autocolumn) { if (!schema.autocolumn) {
continue continue
@ -104,17 +102,10 @@ async function processAutoColumn(user, table, row) {
if (creating) { if (creating) {
schema.lastID = !schema.lastID ? BASE_AUTO_ID : schema.lastID + 1 schema.lastID = !schema.lastID ? BASE_AUTO_ID : schema.lastID + 1
row[key] = schema.lastID row[key] = schema.lastID
tableUpdated = true
} }
break 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 } return { table, row }
} }
@ -143,7 +134,7 @@ exports.coerce = (row, type) => {
* @param {object} table the table which the row is being saved to. * @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. * @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) let clonedRow = cloneDeep(row)
for (let [key, value] of Object.entries(clonedRow)) { for (let [key, value] of Object.entries(clonedRow)) {
const field = table.schema[key] const field = table.schema[key]
@ -166,8 +157,17 @@ exports.inputProcessing = async (user, table, row) => {
* @returns {object[]} the enriched rows will be returned. * @returns {object[]} the enriched rows will be returned.
*/ */
exports.outputProcessing = async (appId, table, rows) => { exports.outputProcessing = async (appId, table, rows) => {
let wasArray = true
if (!(rows instanceof Array)) {
rows = [rows]
wasArray = false
}
// attach any linked row information // 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 // update the attachments URL depending on hosting
if (env.CLOUD && env.SELF_HOSTED) { if (env.CLOUD && env.SELF_HOSTED) {
for (let [property, column] of Object.entries(table.schema)) { 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", "label": "Label",
"key": "label" "key": "label"
}, },
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",

View File

@ -7,6 +7,7 @@
export let field export let field
export let label export let label
export let placeholder
export let disabled = false export let disabled = false
let fieldState let fieldState
@ -16,8 +17,26 @@
// Picker state // Picker state
let options = [] let options = []
let tableDefinition 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) $: valueLookupMap = getValueLookupMap($fieldState?.value)
$: isOptionSelected = option => valueLookupMap[option] === true $: isOptionSelected = option => valueLookupMap[option] === true
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId
@ -55,12 +74,16 @@
} }
const toggleOption = option => { const toggleOption = option => {
if (fieldSchema.type === 'one-to-many') {
fieldApi.setValue([option])
} else {
if ($fieldState.value.includes(option)) { if ($fieldState.value.includes(option)) {
fieldApi.setValue($fieldState.value.filter(x => x !== option)) fieldApi.setValue($fieldState.value.filter(x => x !== option))
} else { } else {
fieldApi.setValue([...$fieldState.value, option]) fieldApi.setValue([...$fieldState.value, option])
} }
} }
}
</script> </script>
<Field <Field

View File

@ -2,13 +2,14 @@
export let columnName export let columnName
export let row export let row
$: count = $: items = row?.[columnName] || []
row && columnName && Array.isArray(row[columnName])
? row[columnName].length
: 0
</script> </script>
<div class="container">{count} related row(s)</div> <div class="container">
{#each items as item}
<div class="item">{item}</div>
{/each}
</div>
<style> <style>
.container { .container {
@ -19,4 +20,13 @@
gap: var(--spacing-xs); gap: var(--spacing-xs);
width: 100%; 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> </style>