diff --git a/packages/bbui/src/Form/Core/Switch.svelte b/packages/bbui/src/Form/Core/Switch.svelte
index 667b2ab871..9e45683382 100644
--- a/packages/bbui/src/Form/Core/Switch.svelte
+++ b/packages/bbui/src/Form/Core/Switch.svelte
@@ -25,7 +25,9 @@
class="spectrum-Switch-input"
/>
- {text}
+ {#if text}
+ {text}
+ {/if}
diff --git a/packages/bbui/src/Layout/Layout.svelte b/packages/bbui/src/Layout/Layout.svelte
index af60675582..0dcb1f46ee 100644
--- a/packages/bbui/src/Layout/Layout.svelte
+++ b/packages/bbui/src/Layout/Layout.svelte
@@ -63,6 +63,9 @@
.gap-L {
grid-gap: var(--spectrum-alias-grid-gutter-medium);
}
+ .gap-XL {
+ grid-gap: var(--spectrum-alias-grid-gutter-large);
+ }
.horizontal.gap-S :global(*) + :global(*) {
margin-left: var(--spectrum-alias-grid-gutter-xsmall);
}
diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte
index 09cc4f6c52..c18be1e4e1 100644
--- a/packages/bbui/src/Modal/ModalContent.svelte
+++ b/packages/bbui/src/Modal/ModalContent.svelte
@@ -18,10 +18,23 @@
export let disabled = false
export let showDivider = true
+ export let showSecondaryButton = false
+ export let secondaryButtonText = undefined
+ export let secondaryAction = undefined
+ export let secondaryButtonWarning = false
+
const { hide, cancel } = getContext(Context.Modal)
let loading = false
$: confirmDisabled = disabled || loading
+ async function secondary() {
+ loading = true
+ if (!secondaryAction || (await secondaryAction()) !== false) {
+ hide()
+ }
+ loading = false
+ }
+
async function confirm() {
loading = true
if (!onConfirm || (await onConfirm()) !== false) {
@@ -73,6 +86,18 @@
class="spectrum-ButtonGroup spectrum-Dialog-buttonGroup spectrum-Dialog-buttonGroup--noFooter"
>
+
+ {#if showSecondaryButton && secondaryButtonText && secondaryAction}
+
{/if}
@@ -136,4 +161,8 @@
display: flex;
justify-content: space-between;
}
+
+ .secondary-action {
+ margin-right: auto;
+ }
diff --git a/packages/bbui/src/Table/BoldRenderer.svelte b/packages/bbui/src/Table/BoldRenderer.svelte
new file mode 100644
index 0000000000..ea882d538e
--- /dev/null
+++ b/packages/bbui/src/Table/BoldRenderer.svelte
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/packages/bbui/src/Table/CodeRenderer.svelte b/packages/bbui/src/Table/CodeRenderer.svelte
new file mode 100644
index 0000000000..a75bec663c
--- /dev/null
+++ b/packages/bbui/src/Table/CodeRenderer.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/packages/bbui/src/Table/Table.svelte b/packages/bbui/src/Table/Table.svelte
index bcd84e7112..09ade22627 100644
--- a/packages/bbui/src/Table/Table.svelte
+++ b/packages/bbui/src/Table/Table.svelte
@@ -4,6 +4,7 @@
import CellRenderer from "./CellRenderer.svelte"
import SelectEditRenderer from "./SelectEditRenderer.svelte"
import { cloneDeep } from "lodash"
+ import { deepGet } from "../utils/helpers"
/**
* The expected schema is our normal couch schemas for our tables.
@@ -318,7 +319,7 @@
{customRenderers}
{row}
schema={schema[field]}
- value={row[field]}
+ value={deepGet(row, field)}
on:clickrelationship
>
diff --git a/packages/bbui/src/Tabs/Tab.svelte b/packages/bbui/src/Tabs/Tab.svelte
index 86f2c0ee52..0aa59f7f8a 100644
--- a/packages/bbui/src/Tabs/Tab.svelte
+++ b/packages/bbui/src/Tabs/Tab.svelte
@@ -5,7 +5,7 @@
export let icon = ""
const dispatch = createEventDispatcher()
- const selected = getContext("tab")
+ let selected = getContext("tab")
let tab
let tabInfo
@@ -16,8 +16,8 @@
// We just need to get this off the main thread to fix this, by using
// a 0ms timeout.
setTimeout(() => {
- tabInfo = tab.getBoundingClientRect()
- if ($selected.title === title) {
+ tabInfo = tab?.getBoundingClientRect()
+ if (tabInfo && $selected.title === title) {
$selected.info = tabInfo
}
}, 0)
diff --git a/packages/bbui/src/Tabs/Tabs.svelte b/packages/bbui/src/Tabs/Tabs.svelte
index 2a4017e605..40e02058c1 100644
--- a/packages/bbui/src/Tabs/Tabs.svelte
+++ b/packages/bbui/src/Tabs/Tabs.svelte
@@ -6,8 +6,14 @@
export let selected
export let vertical = false
export let noPadding = false
+ // added as a separate option as noPadding is used for vertical padding
+ export let noHorizPadding = false
export let quiet = false
export let emphasized = false
+ // overlay content from the tab bar onto tabs e.g. for a dropdown
+ export let onTop = false
+
+ let thisSelected = undefined
let _id = id()
const tab = writable({ title: selected, id: _id, emphasized })
@@ -18,9 +24,19 @@
const dispatch = createEventDispatcher()
$: {
- if ($tab.title !== selected) {
+ if (thisSelected !== selected) {
+ thisSelected = selected
+ dispatch("select", thisSelected)
+ } else if ($tab.title !== thisSelected) {
+ thisSelected = $tab.title
selected = $tab.title
- dispatch("select", selected)
+ dispatch("select", thisSelected)
+ }
+ if ($tab.title !== thisSelected) {
+ tab.update(state => {
+ state.title = thisSelected
+ return state
+ })
}
}
@@ -59,10 +75,12 @@
{#if $tab.info}
@@ -83,7 +101,9 @@
.quiet {
border-bottom: none !important;
}
-
+ .onTop {
+ z-index: 20;
+ }
.spectrum-Tabs {
padding-left: var(--spacing-xl);
padding-right: var(--spacing-xl);
@@ -99,6 +119,9 @@
.spectrum-Tabs--horizontal .spectrum-Tabs-selectionIndicator {
bottom: 0 !important;
}
+ .noHorizPadding {
+ padding: 0;
+ }
.noPadding {
margin: 0;
}
diff --git a/packages/bbui/src/Typography/Detail.svelte b/packages/bbui/src/Typography/Detail.svelte
index 7cbafdadef..bb5c78c11e 100644
--- a/packages/bbui/src/Typography/Detail.svelte
+++ b/packages/bbui/src/Typography/Detail.svelte
@@ -13,3 +13,9 @@
>
+
+
diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js
index 377d451604..16f069f4e7 100644
--- a/packages/bbui/src/index.js
+++ b/packages/bbui/src/index.js
@@ -59,6 +59,11 @@ export { default as Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte"
export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte"
+export { default as Banner } from "./Banner/Banner.svelte"
+
+// Renderers
+export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
+export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
// Typography
export { default as Body } from "./Typography/Body.svelte"
@@ -76,3 +81,6 @@ export { default as clickOutside } from "./Actions/click_outside"
// Stores
export { notifications, createNotificationStore } from "./Stores/notifications"
+
+// Utils
+export * from "./utils/helpers"
diff --git a/packages/bbui/src/utils/helpers.js b/packages/bbui/src/utils/helpers.js
index 83d305d573..6cf432f356 100644
--- a/packages/bbui/src/utils/helpers.js
+++ b/packages/bbui/src/utils/helpers.js
@@ -6,3 +6,61 @@ export const generateID = () => {
}
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
+
+/**
+ * Gets a key within an object. The key supports dot syntax for retrieving deep
+ * fields - e.g. "a.b.c".
+ * Exact matches of keys with dots in them take precedence over nested keys of
+ * the same path - e.g. getting "a.b" from { "a.b": "foo", a: { b: "bar" } }
+ * will return "foo" over "bar".
+ * @param obj the object
+ * @param key the key
+ * @return {*|null} the value or null if a value was not found for this key
+ */
+export const deepGet = (obj, key) => {
+ if (!obj || !key) {
+ return null
+ }
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ return obj[key]
+ }
+ const split = key.split(".")
+ for (let i = 0; i < split.length; i++) {
+ obj = obj?.[split[i]]
+ }
+ return obj
+}
+
+/**
+ * Sets a key within an object. The key supports dot syntax for retrieving deep
+ * fields - e.g. "a.b.c".
+ * Exact matches of keys with dots in them take precedence over nested keys of
+ * the same path - e.g. setting "a.b" of { "a.b": "foo", a: { b: "bar" } }
+ * will override the value "foo" rather than "bar".
+ * If a deep path is specified and the parent keys don't exist then these will
+ * be created.
+ * @param obj the object
+ * @param key the key
+ * @param value the value
+ */
+export const deepSet = (obj, key, value) => {
+ if (!obj || !key) {
+ return
+ }
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
+ obj[key] = value
+ return
+ }
+ const split = key.split(".")
+ for (let i = 0; i < split.length - 1; i++) {
+ const nextKey = split[i]
+ if (obj && obj[nextKey] == null) {
+ obj[nextKey] = {}
+ }
+ obj = obj?.[nextKey]
+ }
+ if (!obj) {
+ return
+ }
+ obj[split[split.length - 1]] = value
+}
diff --git a/packages/bbui/yarn.lock b/packages/bbui/yarn.lock
index a102e6c148..42c88af5a4 100644
--- a/packages/bbui/yarn.lock
+++ b/packages/bbui/yarn.lock
@@ -2076,9 +2076,9 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.27:
supports-color "^6.1.0"
postcss@^8.2.9:
- version "8.2.10"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.10.tgz#ca7a042aa8aff494b334d0ff3e9e77079f6f702b"
- integrity sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw==
+ version "8.2.13"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.13.tgz#dbe043e26e3c068e45113b1ed6375d2d37e2129f"
+ integrity sha512-FCE5xLH+hjbzRdpbRb1IMCvPv9yZx2QnDarBEYSN0N0HYk+TcXsEhwdFcFb+SRWOKzKGErhIEbBK2ogyLdTtfQ==
dependencies:
colorette "^1.2.2"
nanoid "^3.1.22"
diff --git a/packages/builder/assets/bb-spaceship.svg b/packages/builder/assets/bb-spaceship.svg
new file mode 100755
index 0000000000..a0bc5a49cd
--- /dev/null
+++ b/packages/builder/assets/bb-spaceship.svg
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/builder/cypress/integration/createAutomation.spec.js b/packages/builder/cypress/integration/createAutomation.spec.js
index 6ff013cd7a..efd3c7d023 100644
--- a/packages/builder/cypress/integration/createAutomation.spec.js
+++ b/packages/builder/cypress/integration/createAutomation.spec.js
@@ -35,19 +35,20 @@ context("Create a automation", () => {
cy.contains("dog").click()
cy.get(".spectrum-Textfield-input")
.first()
- .type("goodboy")
+ .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false })
cy.get(".spectrum-Textfield-input")
.eq(1)
.type("11")
+ cy.contains("Finish and test automation").click()
- cy.contains("Run test").click()
cy.get(".modal-inner-wrapper").within(() => {
cy.wait(1000)
cy.get(".spectrum-Picker-label").click()
cy.contains("dog").click()
+ cy.wait(1000)
cy.get(".spectrum-Textfield-input")
.first()
- .type("goodboy")
+ .type("automationGoodboy")
cy.get(".spectrum-Textfield-input")
.eq(1)
.type("11")
@@ -57,6 +58,9 @@ context("Create a automation", () => {
cy.get(".spectrum-Textfield-input")
.eq(3)
.type("123456")
+ cy.contains("Test").click()
})
+ cy.contains("Data").click()
+ cy.contains("automationGoodboy")
})
})
diff --git a/packages/builder/cypress/integration/renameAnApplication.spec.js b/packages/builder/cypress/integration/renameAnApplication.spec.js
index 95a152c017..a954faee95 100644
--- a/packages/builder/cypress/integration/renameAnApplication.spec.js
+++ b/packages/builder/cypress/integration/renameAnApplication.spec.js
@@ -10,7 +10,7 @@ it("should rename an unpublished application", () => {
cy.get(".home-logo").click()
renameApp(appRename)
cy.searchForApplication(appRename)
- cy.get(".appGrid").find(".wrapper").should("have.length", 1)
+ cy.get(".appTable").find(".title").should("have.length", 1)
cy.deleteApp(appRename)
})
@@ -29,7 +29,7 @@ xit("Should rename a published application", () => {
cy.get(".home-logo").click()
renameApp(appRename, true)
cy.searchForApplication(appRename)
- cy.get(".appGrid").find(".wrapper").should("have.length", 1)
+ cy.get(".appTable").find(".title").should("have.length", 1)
})
it("Should try to rename an application to have no name", () => {
@@ -38,7 +38,7 @@ it("Should try to rename an application to have no name", () => {
// Close modal and confirm name has not been changed
cy.get(".spectrum-Dialog-grid").contains("Cancel").click()
cy.searchForApplication("Cypress Tests")
- cy.get(".appGrid").find(".wrapper").should("have.length", 1)
+ cy.get(".appTable").find(".title").should("have.length", 1)
})
xit("Should create two applications with the same name", () => {
@@ -64,7 +64,7 @@ it("should validate application names", () => {
cy.get(".home-logo").click()
renameApp(numberName)
cy.searchForApplication(numberName)
- cy.get(".appGrid").find(".wrapper").should("have.length", 1)
+ cy.get(".appTable").find(".title").should("have.length", 1)
renameApp(specialCharName)
cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only")
})
@@ -74,14 +74,14 @@ it("should validate application names", () => {
.its("body")
.then(val => {
if (val.length > 0) {
- cy.get(".title > :nth-child(3) > .spectrum-Icon").click()
+ cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
// Check for when an app is published
if (published == true){
// Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
- cy.get(".title > :nth-child(3) > .spectrum-Icon").click()
+ cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
}
cy.contains("Edit").click()
cy.get(".spectrum-Modal")
diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js
index 0870ceac7a..e67057344a 100644
--- a/packages/builder/cypress/support/commands.js
+++ b/packages/builder/cypress/support/commands.js
@@ -50,7 +50,9 @@ Cypress.Commands.add("deleteApp", appName => {
.its("body")
.then(val => {
if (val.length > 0) {
- cy.get(".title > :nth-child(3) > .spectrum-Icon").click()
+ cy.get(
+ ".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon"
+ ).click()
cy.contains("Delete").click()
cy.get(".spectrum-Modal").within(() => {
cy.get("input").type(appName)
diff --git a/packages/builder/package.json b/packages/builder/package.json
index 54d3a22aa9..8f3b64d347 100644
--- a/packages/builder/package.json
+++ b/packages/builder/package.json
@@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
- "version": "1.0.35",
+ "version": "1.0.39",
"license": "GPL-3.0",
"private": true,
"scripts": {
@@ -65,10 +65,10 @@
}
},
"dependencies": {
- "@budibase/bbui": "^1.0.35",
- "@budibase/client": "^1.0.35",
+ "@budibase/bbui": "^1.0.39",
+ "@budibase/client": "^1.0.39",
"@budibase/colorpicker": "1.1.2",
- "@budibase/string-templates": "^1.0.35",
+ "@budibase/string-templates": "^1.0.39",
"@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",
diff --git a/packages/builder/src/analytics/constants.js b/packages/builder/src/analytics/constants.js
index d38b7bba4f..177d5320a5 100644
--- a/packages/builder/src/analytics/constants.js
+++ b/packages/builder/src/analytics/constants.js
@@ -9,6 +9,9 @@ export const Events = {
CREATED: "Datasource Created",
UPDATED: "Datasource Updated",
},
+ QUERIES: {
+ REST: "REST Queries Imported",
+ },
TABLE: {
CREATED: "Table Created",
},
diff --git a/packages/builder/src/builderStore/api.js b/packages/builder/src/builderStore/api.js
index 4bcb9b74c6..62ddfaeaa5 100644
--- a/packages/builder/src/builderStore/api.js
+++ b/packages/builder/src/builderStore/api.js
@@ -1,6 +1,7 @@
import { store } from "./index"
import { get as svelteGet } from "svelte/store"
import { removeCookie, Cookies } from "./cookies"
+import { notifications } from "@budibase/bbui"
const apiCall =
method =>
@@ -13,6 +14,12 @@ const apiCall =
headers,
})
if (resp.status === 403) {
+ if (url.includes("/api/templates")) {
+ notifications.error(
+ "There was a problem loading quick start templates."
+ )
+ return { json: () => [] }
+ }
removeCookie(Cookies.Auth)
// reload after removing cookie, go to login
if (!url.includes("self") && !url.includes("login")) {
diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/componentUtils.js
similarity index 80%
rename from packages/builder/src/builderStore/storeUtils.js
rename to packages/builder/src/builderStore/componentUtils.js
index e25949000f..04a87998fe 100644
--- a/packages/builder/src/builderStore/storeUtils.js
+++ b/packages/builder/src/builderStore/componentUtils.js
@@ -127,18 +127,37 @@ const searchComponentTree = (rootComponent, matchComponent) => {
}
/**
- * Searches a component's definition for a setting matching a certin predicate.
+ * Searches a component's definition for a setting matching a certain predicate.
+ * These settings are cached because they cannot change at run time.
*/
+let componentSettingCache = {}
export const getComponentSettings = componentType => {
- const def = store.actions.components.getDefinition(componentType)
- if (!def) {
+ if (!componentType) {
return []
}
- let settings = def.settings?.filter(setting => !setting.section) ?? []
- def.settings
- ?.filter(setting => setting.section)
- .forEach(section => {
- settings = settings.concat(section.settings || [])
- })
+
+ // Ensure whole component name is used
+ if (!componentType.startsWith("@budibase")) {
+ componentType = `@budibase/standard-components/${componentType}`
+ }
+
+ // Check if we have cached this type already
+ if (componentSettingCache[componentType]) {
+ return componentSettingCache[componentType]
+ }
+
+ // Otherwise get the settings and cache them
+ const def = store.actions.components.getDefinition(componentType)
+ let settings = []
+ if (def) {
+ settings = def.settings?.filter(setting => !setting.section) ?? []
+ def.settings
+ ?.filter(setting => setting.section)
+ .forEach(section => {
+ settings = settings.concat(section.settings || [])
+ })
+ }
+ componentSettingCache[componentType] = settings
+
return settings
}
diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js
index b36613fbc5..0f3cffc4fb 100644
--- a/packages/builder/src/builderStore/dataBinding.js
+++ b/packages/builder/src/builderStore/dataBinding.js
@@ -5,7 +5,7 @@ import {
findComponent,
findComponentPath,
getComponentSettings,
-} from "./storeUtils"
+} from "./componentUtils"
import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
import {
@@ -15,6 +15,11 @@ import {
encodeJSBinding,
} from "@budibase/string-templates"
import { TableNames } from "../constants"
+import {
+ convertJSONSchemaToTableSchema,
+ getJSONArrayDatasourceSchema,
+} from "./jsonUtils"
+import ActionDefinitions from "components/design/PropertiesPanel/PropertyControls/ButtonActionEditor/manifest.json"
// Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@@ -186,6 +191,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
let schema
+ let table
let readablePrefix
let runtimeSuffix = context.suffix
@@ -209,7 +215,16 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
- readablePrefix = info.table?.name
+ table = info.table
+
+ // For JSON arrays, use the array name as the readable prefix.
+ // Otherwise use the table name
+ if (datasource.type === "jsonarray") {
+ const split = datasource.label.split(".")
+ readablePrefix = split[split.length - 1]
+ } else {
+ readablePrefix = info.table?.name
+ }
}
if (!schema) {
return
@@ -229,7 +244,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
const fieldSchema = schema[key]
// Make safe runtime binding
- const runtimeBinding = `${safeComponentId}.${makePropSafe(key)}`
+ const safeKey = key.split(".").map(makePropSafe).join(".")
+ const runtimeBinding = `${safeComponentId}.${safeKey}`
// Optionally use a prefix with readable bindings
let readableBinding = component._instanceName
@@ -247,6 +263,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// datasource options, based on bindable properties
fieldSchema,
providerId,
+ // Table ID is used by JSON fields to know what table the field is in
+ tableId: table?._id,
})
})
})
@@ -339,6 +357,36 @@ const getUrlBindings = asset => {
}))
}
+/**
+ * Gets all bindable properties exposed in a button actions flow up until
+ * the specified action ID.
+ */
+export const getButtonContextBindings = (actions, actionId) => {
+ // Get the steps leading up to this value
+ const index = actions?.findIndex(action => action.id === actionId)
+ if (index == null || index === -1) {
+ return []
+ }
+ const prevActions = actions.slice(0, index)
+
+ // Generate bindings for any steps which provide context
+ let bindings = []
+ prevActions.forEach((action, idx) => {
+ const def = ActionDefinitions.actions.find(
+ x => x.name === action["##eventHandlerType"]
+ )
+ if (def.context) {
+ def.context.forEach(contextValue => {
+ bindings.push({
+ readableBinding: `Action ${idx + 1}.${contextValue.label}`,
+ runtimeBinding: `actions.${idx}.${contextValue.value}`,
+ })
+ })
+ }
+ })
+ return bindings
+}
+
/**
* Gets a schema for a datasource object.
*/
@@ -347,16 +395,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
if (datasource) {
const { type } = datasource
+ const tables = get(tablesStore).list
- // Determine the source table from the datasource type
+ // Determine the entity which backs this datasource.
+ // "provider" datasources are those targeting another data provider
if (type === "provider") {
const component = findComponent(asset.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component)
return getSchemaForDatasource(asset, source, isForm)
- } else if (type === "query") {
+ }
+
+ // "query" datasources are those targeting non-plus datasources or
+ // custom queries
+ else if (type === "query") {
const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource._id)
- } else if (type === "field") {
+ }
+
+ // "field" datasources are array-like fields of rows, such as attachments
+ // or multi-select fields
+ else if (type === "field") {
table = { name: datasource.fieldName }
const { fieldType } = datasource
if (fieldType === "attachment") {
@@ -375,12 +433,22 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
},
}
}
- } else {
- const tables = get(tablesStore).list
+ }
+
+ // "jsonarray" datasources are arrays inside JSON fields
+ else if (type === "jsonarray") {
+ table = tables.find(table => table._id === datasource.tableId)
+ let tableSchema = table?.schema
+ schema = getJSONArrayDatasourceSchema(tableSchema, datasource)
+ }
+
+ // Otherwise we assume we're targeting an internal table or a plus
+ // datasource, and we can treat it as a table with a schema
+ else {
table = tables.find(table => table._id === datasource.tableId)
}
- // Determine the schema from the table if not already determined
+ // Determine the schema from the backing entity if not already determined
if (table && !schema) {
if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema)
@@ -397,6 +465,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
}
}
+ // Check for any JSON fields so we can add any top level properties
+ if (schema) {
+ let jsonAdditions = {}
+ Object.keys(schema).forEach(fieldKey => {
+ const fieldSchema = schema[fieldKey]
+ if (fieldSchema?.type === "json") {
+ const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
+ squashObjects: true,
+ })
+ Object.keys(jsonSchema).forEach(jsonKey => {
+ jsonAdditions[`${fieldKey}.${jsonKey}`] = {
+ type: jsonSchema[jsonKey].type,
+ nestedJSON: true,
+ }
+ })
+ }
+ })
+ schema = { ...schema, ...jsonAdditions }
+ }
+
// Add _id and _rev fields for certain types
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
schema["_id"] = { type: "string" }
@@ -450,15 +538,58 @@ const buildFormSchema = component => {
return schema
}
+/**
+ * Returns an array of the keys of any state variables which are set anywhere
+ * in the app.
+ */
+export const getAllStateVariables = () => {
+ // Get all component containing assets
+ let allAssets = []
+ allAssets = allAssets.concat(get(store).layouts || [])
+ allAssets = allAssets.concat(get(store).screens || [])
+
+ // Find all button action settings in all components
+ let eventSettings = []
+ allAssets.forEach(asset => {
+ findAllMatchingComponents(asset.props, component => {
+ const settings = getComponentSettings(component._component)
+ settings
+ .filter(setting => setting.type === "event")
+ .forEach(setting => {
+ eventSettings.push(component[setting.key])
+ })
+ })
+ })
+
+ // Extract all state keys from any "update state" actions in each setting
+ let bindingSet = new Set()
+ eventSettings.forEach(setting => {
+ if (!Array.isArray(setting)) {
+ return
+ }
+ setting.forEach(action => {
+ if (
+ action["##eventHandlerType"] === "Update State" &&
+ action.parameters?.type === "set" &&
+ action.parameters?.key &&
+ action.parameters?.value
+ ) {
+ bindingSet.add(action.parameters.key)
+ }
+ })
+ })
+ return Array.from(bindingSet)
+}
+
/**
* Recurses the input object to remove any instances of bindings.
*/
-export function removeBindings(obj) {
+export const removeBindings = (obj, replacement = "Invalid binding") => {
for (let [key, value] of Object.entries(obj)) {
if (value && typeof value === "object") {
- obj[key] = removeBindings(value)
+ obj[key] = removeBindings(value, replacement)
} else if (typeof value === "string") {
- obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, "Invalid binding")
+ obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
}
}
return obj
@@ -468,8 +599,8 @@ export function removeBindings(obj) {
* When converting from readable to runtime it can sometimes add too many square brackets,
* this makes sure that doesn't happen.
*/
-function shouldReplaceBinding(currentValue, from, convertTo) {
- if (!currentValue?.includes(from)) {
+const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
+ if (!currentValue?.includes(convertFrom)) {
return false
}
if (convertTo === "readableBinding") {
@@ -478,7 +609,7 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected
const noSpaces = currentValue.replace(/\s+/g, "")
- const fromNoSpaces = from.replace(/\s+/g, "")
+ const fromNoSpaces = convertFrom.replace(/\s+/g, "")
const invalids = [
`[${fromNoSpaces}]`,
`"${fromNoSpaces}"`,
@@ -487,14 +618,21 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
return !invalids.find(invalid => noSpaces?.includes(invalid))
}
-function replaceBetween(string, start, end, replacement) {
+/**
+ * Utility function which replaces a string between given indices.
+ */
+const replaceBetween = (string, start, end, replacement) => {
return string.substring(0, start) + replacement + string.substring(end)
}
/**
- * utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
+ * Utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
*/
-function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
+const bindingReplacement = (
+ bindableProperties,
+ textWithBindings,
+ convertTo
+) => {
// Decide from base64 if using JS
const isJS = isJSBinding(textWithBindings)
if (isJS) {
@@ -559,14 +697,17 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
* Extracts a component ID from a handlebars expression setting of
* {{ literal [componentId] }}
*/
-function extractLiteralHandlebarsID(value) {
+const extractLiteralHandlebarsID = value => {
return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
}
/**
* Converts a readable data binding into a runtime data binding
*/
-export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
+export const readableToRuntimeBinding = (
+ bindableProperties,
+ textWithBindings
+) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
@@ -577,56 +718,13 @@ export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
/**
* Converts a runtime data binding into a readable data binding
*/
-export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
+export const runtimeToReadableBinding = (
+ bindableProperties,
+ textWithBindings
+) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
"readableBinding"
)
}
-
-/**
- * Returns an array of the keys of any state variables which are set anywhere
- * in the app.
- */
-export const getAllStateVariables = () => {
- let allComponents = []
-
- // Find all onClick settings in all layouts
- get(store).layouts.forEach(layout => {
- const components = findAllMatchingComponents(
- layout.props,
- c => c.onClick != null
- )
- allComponents = allComponents.concat(components || [])
- })
-
- // Find all onClick settings in all screens
- get(store).screens.forEach(screen => {
- const components = findAllMatchingComponents(
- screen.props,
- c => c.onClick != null
- )
- allComponents = allComponents.concat(components || [])
- })
-
- // Add state bindings for all state actions
- let bindingSet = new Set()
- allComponents.forEach(component => {
- if (!Array.isArray(component.onClick)) {
- return
- }
- component.onClick.forEach(action => {
- if (
- action["##eventHandlerType"] === "Update State" &&
- action.parameters?.type === "set" &&
- action.parameters?.key &&
- action.parameters?.value
- ) {
- bindingSet.add(action.parameters.key)
- }
- })
- })
-
- return Array.from(bindingSet)
-}
diff --git a/packages/builder/src/builderStore/datasource.js b/packages/builder/src/builderStore/datasource.js
new file mode 100644
index 0000000000..cfdeeac23e
--- /dev/null
+++ b/packages/builder/src/builderStore/datasource.js
@@ -0,0 +1,44 @@
+import { datasources, tables } from "../stores/backend"
+import { IntegrationNames } from "../constants/backend"
+import analytics, { Events } from "../analytics"
+import { get } from "svelte/store"
+import cloneDeep from "lodash/cloneDeepWith"
+
+function prepareData(config) {
+ let datasource = {}
+ let existingTypeCount = get(datasources).list.filter(
+ ds => ds.source === config.type
+ ).length
+
+ let baseName = IntegrationNames[config.type]
+ let name =
+ existingTypeCount === 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
+
+ datasource.type = "datasource"
+ datasource.source = config.type
+ datasource.config = config.config
+ datasource.name = name
+ datasource.plus = config.plus
+
+ return datasource
+}
+
+export async function saveDatasource(config, skipFetch = false) {
+ const datasource = prepareData(config)
+ // Create datasource
+ const resp = await datasources.save(datasource, !skipFetch && datasource.plus)
+
+ // update the tables incase data source plus
+ await tables.fetch()
+ await datasources.select(resp._id)
+ analytics.captureEvent(Events.DATASOURCE.CREATED, {
+ name: resp.name,
+ source: resp.source,
+ })
+ return resp
+}
+
+export async function createRestDatasource(integration) {
+ const config = cloneDeep(integration)
+ return saveDatasource(config)
+}
diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js
index f32dedd47e..23704556ad 100644
--- a/packages/builder/src/builderStore/index.js
+++ b/packages/builder/src/builderStore/index.js
@@ -4,7 +4,7 @@ import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
-import { findComponent } from "./storeUtils"
+import { findComponent } from "./componentUtils"
export const store = getFrontendStore()
export const automationStore = getAutomationStore()
diff --git a/packages/builder/src/builderStore/jsonUtils.js b/packages/builder/src/builderStore/jsonUtils.js
new file mode 100644
index 0000000000..29bf2df34e
--- /dev/null
+++ b/packages/builder/src/builderStore/jsonUtils.js
@@ -0,0 +1,121 @@
+/**
+ * Gets the schema for a datasource which is targeting a JSON array, including
+ * nested JSON arrays. The returned schema is a squashed, table-like schema
+ * which is fully compatible with the rest of the platform.
+ * @param tableSchema the full schema for the table this JSON field is in
+ * @param datasource the datasource configuration
+ */
+export const getJSONArrayDatasourceSchema = (tableSchema, datasource) => {
+ let jsonSchema = tableSchema
+ let keysToSchema = []
+
+ // If we are already deep inside a JSON field then we need to account
+ // for the keys that brought us here, so we can get the schema for the
+ // depth we're actually at
+ if (datasource.prefixKeys) {
+ keysToSchema = datasource.prefixKeys.concat(["schema"])
+ }
+
+ // We parse the label of the datasource to work out where we are inside
+ // the structure. We can use this to know which part of the schema
+ // is available underneath our current position.
+ keysToSchema = keysToSchema.concat(datasource.label.split(".").slice(2))
+
+ // Follow the JSON key path until we reach the schema for the level
+ // we are at
+ for (let i = 0; i < keysToSchema.length; i++) {
+ jsonSchema = jsonSchema?.[keysToSchema[i]]
+ if (jsonSchema?.schema) {
+ jsonSchema = jsonSchema.schema
+ }
+ }
+
+ // We need to convert the JSON schema into a more typical looking table
+ // schema so that it works with the rest of the platform
+ return convertJSONSchemaToTableSchema(jsonSchema, {
+ squashObjects: true,
+ prefixKeys: keysToSchema,
+ })
+}
+
+/**
+ * Converts a JSON field schema (or sub-schema of a nested field) into a schema
+ * that looks like a typical table schema.
+ * @param jsonSchema the JSON field schema or sub-schema
+ * @param options
+ */
+export const convertJSONSchemaToTableSchema = (jsonSchema, options) => {
+ if (!jsonSchema) {
+ return null
+ }
+
+ // Add default options
+ options = { squashObjects: false, prefixKeys: null, ...options }
+
+ // Immediately strip the wrapper schema for objects, or wrap shallow values in
+ // a fake "value" schema
+ if (jsonSchema.schema) {
+ jsonSchema = jsonSchema.schema
+ } else {
+ jsonSchema = {
+ value: jsonSchema,
+ }
+ }
+
+ // Extract all deep keys from the schema
+ const keys = extractJSONSchemaKeys(jsonSchema, options.squashObjects)
+
+ // Form a full schema from all the deep schema keys
+ let schema = {}
+ keys.forEach(({ key, type }) => {
+ schema[key] = { type, name: key, prefixKeys: options.prefixKeys }
+ })
+ return schema
+}
+
+/**
+ * Recursively builds paths to all leaf fields in a JSON field schema structure,
+ * stopping when leaf nodes or arrays are reached.
+ * @param jsonSchema the JSON field schema or sub-schema
+ * @param squashObjects whether to recurse into objects or not
+ */
+const extractJSONSchemaKeys = (jsonSchema, squashObjects = false) => {
+ if (!jsonSchema || !Object.keys(jsonSchema).length) {
+ return []
+ }
+
+ // Iterate through every schema key
+ let keys = []
+ Object.keys(jsonSchema).forEach(key => {
+ const type = jsonSchema[key].type
+
+ // If we encounter an object, then only go deeper if we want to squash
+ // object paths
+ if (type === "json" && squashObjects) {
+ // Find all keys within this objects schema
+ const childKeys = extractJSONSchemaKeys(
+ jsonSchema[key].schema,
+ squashObjects
+ )
+
+ // Append child paths onto the current path to build the full path
+ keys = keys.concat(
+ childKeys.map(childKey => ({
+ key: `${key}.${childKey.key}`,
+ type: childKey.type,
+ }))
+ )
+ }
+
+ // Otherwise add this as a lead node.
+ // We transform array types from "array" into "jsonarray" here to avoid
+ // confusion with the existing "array" type that represents a multi-select.
+ else {
+ keys.push({
+ key,
+ type: type === "array" ? "jsonarray" : type,
+ })
+ }
+ })
+ return keys
+}
diff --git a/packages/builder/src/builderStore/schemaGenerator.js b/packages/builder/src/builderStore/schemaGenerator.js
new file mode 100644
index 0000000000..33115fc997
--- /dev/null
+++ b/packages/builder/src/builderStore/schemaGenerator.js
@@ -0,0 +1,56 @@
+import { FIELDS } from "constants/backend"
+
+function baseConversion(type) {
+ if (type === "string") {
+ return {
+ type: FIELDS.STRING.type,
+ }
+ } else if (type === "boolean") {
+ return {
+ type: FIELDS.BOOLEAN.type,
+ }
+ } else if (type === "number") {
+ return {
+ type: FIELDS.NUMBER.type,
+ }
+ }
+}
+
+function recurse(schemaLevel = {}, objectLevel) {
+ if (!objectLevel) {
+ return null
+ }
+ const baseType = typeof objectLevel
+ if (baseType !== "object") {
+ return baseConversion(baseType)
+ }
+ for (let [key, value] of Object.entries(objectLevel)) {
+ const type = typeof value
+ // check array first, since arrays are objects
+ if (Array.isArray(value)) {
+ const schema = recurse(schemaLevel[key], value[0])
+ if (schema) {
+ schemaLevel[key] = {
+ type: FIELDS.ARRAY.type,
+ schema,
+ }
+ }
+ } else if (type === "object") {
+ const schema = recurse(schemaLevel[key], objectLevel[key])
+ if (schema) {
+ schemaLevel[key] = schema
+ }
+ } else {
+ schemaLevel[key] = baseConversion(type)
+ }
+ }
+ if (!schemaLevel.type) {
+ return { type: FIELDS.JSON.type, schema: schemaLevel }
+ } else {
+ return schemaLevel
+ }
+}
+
+export function generate(object) {
+ return recurse({}, object).schema
+}
diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js
index 9f1a20605f..fdfe450edf 100644
--- a/packages/builder/src/builderStore/store/frontend.js
+++ b/packages/builder/src/builderStore/store/frontend.js
@@ -26,7 +26,7 @@ import {
findAllMatchingComponents,
findComponent,
getComponentSettings,
-} from "../storeUtils"
+} from "../componentUtils"
import { uuid } from "../uuid"
import { removeBindings } from "../dataBinding"
@@ -329,12 +329,12 @@ export const getFrontendStore = () => {
},
components: {
select: component => {
- if (!component) {
+ const asset = get(currentAsset)
+ if (!asset || !component) {
return
}
// If this is the root component, select the asset instead
- const asset = get(currentAsset)
const parent = findComponentParent(asset.props, component._id)
if (parent == null) {
const state = get(store)
@@ -537,7 +537,7 @@ export const getFrontendStore = () => {
// immediately need to remove bindings, currently these aren't valid when pasted
if (!cut && !preserveBindings) {
- state.componentToPaste = removeBindings(state.componentToPaste)
+ state.componentToPaste = removeBindings(state.componentToPaste, "")
}
// Clone the component to paste
diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js
index 5b3bc041ff..ae45b4f25d 100644
--- a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js
+++ b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js
@@ -137,6 +137,7 @@ const fieldTypeToComponentMap = {
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
+ json: "jsonfield",
}
export function makeDatasourceFormComponents(datasource) {
@@ -146,7 +147,7 @@ export function makeDatasourceFormComponents(datasource) {
fields.forEach(field => {
const fieldSchema = schema[field]
// skip autocolumns
- if (fieldSchema.autocolumn) {
+ if (fieldSchema.autocolumn || fieldSchema.nestedJSON) {
return
}
const fieldType =
diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte
index 7f379ba138..fe94b7e63f 100644
--- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte
+++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte
@@ -9,6 +9,7 @@
Modal,
Button,
StatusLight,
+ ActionButton,
} from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
@@ -27,7 +28,7 @@
let blockComplete
$: testResult = $automationStore.selectedAutomation.testResults?.steps.filter(
- step => step.stepId === block.stepId
+ step => (block.id ? step.id === block.id : step.stepId === block.stepId)
)
$: isTrigger = block.type === "TRIGGER"
@@ -119,19 +120,13 @@