Merge branch 'master' into sqs-2.0-image

This commit is contained in:
Michael Drury 2024-08-14 13:24:32 +01:00 committed by GitHub
commit 1db269f570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 495 additions and 173 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.30.2", "version": "2.30.3",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -255,4 +255,5 @@ export const flags = new FlagSet({
GOOGLE_SHEETS: Flag.boolean(false), GOOGLE_SHEETS: Flag.boolean(false),
USER_GROUPS: Flag.boolean(false), USER_GROUPS: Flag.boolean(false),
ONBOARDING_TOUR: Flag.boolean(false), ONBOARDING_TOUR: Flag.boolean(false),
DEFAULT_VALUES: Flag.boolean(true),
}) })

View File

@ -70,6 +70,7 @@
// Stop unnecessary rendering // Stop unnecessary rendering
const memoBlock = memo(block) const memoBlock = memo(block)
const memoEnvVariables = memo($environment.variables)
const rowTriggers = [ const rowTriggers = [
TriggerStepID.ROW_UPDATED, TriggerStepID.ROW_UPDATED,
@ -91,11 +92,20 @@
let insertAtPos, getCaretPosition let insertAtPos, getCaretPosition
let stepLayouts = {} let stepLayouts = {}
$: memoEnvVariables.set($environment.variables)
$: memoBlock.set(block) $: memoBlock.set(block)
$: filters = lookForFilters(schemaProperties) || [] $: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters $: tempFilters = filters
$: stepId = block.stepId $: stepId = $memoBlock.stepId
$: bindings = getAvailableBindings(block, $selectedAutomation?.definition)
$: automationBindings = getAvailableBindings(
$memoBlock,
$selectedAutomation?.definition
)
$: environmentBindings = buildEnvironmentBindings($memoEnvVariables)
$: bindings = [...automationBindings, ...environmentBindings]
$: getInputData(testData, $memoBlock.inputs) $: getInputData(testData, $memoBlock.inputs)
$: tableId = inputData ? inputData.tableId : null $: tableId = inputData ? inputData.tableId : null
$: table = tableId $: table = tableId
@ -110,7 +120,7 @@
{ allowLinks: true } { allowLinks: true }
) )
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = block?.type === AutomationStepType.TRIGGER $: isTrigger = $memoBlock?.type === AutomationStepType.TRIGGER
$: codeMode = $: codeMode =
stepId === AutomationActionStepId.EXECUTE_BASH stepId === AutomationActionStepId.EXECUTE_BASH
? EditorModes.Handlebars ? EditorModes.Handlebars
@ -119,13 +129,30 @@
disableWrapping: true, disableWrapping: true,
}) })
$: editingJs = codeMode === EditorModes.JS $: editingJs = codeMode === EditorModes.JS
$: requiredProperties = isTestModal ? [] : block.schema["inputs"].required $: requiredProperties = isTestModal
? []
: $memoBlock.schema["inputs"].required
$: stepCompletions = $: stepCompletions =
codeMode === EditorModes.Handlebars codeMode === EditorModes.Handlebars
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: [] : []
const buildEnvironmentBindings = () => {
if ($licensing.environmentVariablesEnabled) {
return getEnvironmentBindings().map(binding => {
return {
...binding,
display: {
...binding.display,
rank: 98,
},
}
})
}
return []
}
const getInputData = (testData, blockInputs) => { const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity // Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs) let newInputData = testData || cloneDeep(blockInputs)
@ -151,9 +178,9 @@
// Store for any UX related data // Store for any UX related data
const stepStore = writable({}) const stepStore = writable({})
$: currentStep = $stepStore?.[block.id] $: stepState = $stepStore?.[block.id]
$: customStepLayouts($memoBlock, schemaProperties, currentStep) $: customStepLayouts($memoBlock, schemaProperties, stepState)
const customStepLayouts = block => { const customStepLayouts = block => {
if ( if (
@ -185,7 +212,6 @@
onChange: e => { onChange: e => {
onChange({ ["revision"]: e.detail }) onChange({ ["revision"]: e.detail })
}, },
bindings,
updateOnChange: false, updateOnChange: false,
forceModal: true, forceModal: true,
}, },
@ -214,7 +240,6 @@
onChange: e => { onChange: e => {
onChange({ [rowIdentifier]: e.detail }) onChange({ [rowIdentifier]: e.detail })
}, },
bindings,
updateOnChange: false, updateOnChange: false,
forceModal: true, forceModal: true,
}, },
@ -275,7 +300,7 @@
isUpdateRow: block.stepId === ActionStepID.UPDATE_ROW, isUpdateRow: block.stepId === ActionStepID.UPDATE_ROW,
} }
if (isTestModal && currentStep?.rowType === "oldRow") { if (isTestModal && stepState?.rowType === "oldRow") {
return [ return [
{ {
type: RowSelector, type: RowSelector,
@ -722,22 +747,9 @@
) )
} }
// Environment bindings
if ($licensing.environmentVariablesEnabled) {
bindings = bindings.concat(
getEnvironmentBindings().map(binding => {
return {
...binding,
display: {
...binding.display,
rank: 98,
},
}
})
)
}
return bindings return bindings
} }
function lookForFilters(properties) { function lookForFilters(properties) {
if (!properties) { if (!properties) {
return [] return []
@ -770,7 +782,7 @@
drawer.hide() drawer.hide()
} }
function canShowField(key, value) { function canShowField(value) {
const dependsOn = value?.dependsOn const dependsOn = value?.dependsOn
return !dependsOn || !!inputData[dependsOn] return !dependsOn || !!inputData[dependsOn]
} }
@ -829,6 +841,7 @@
<svelte:component <svelte:component
this={config.type} this={config.type}
{...config.props} {...config.props}
{bindings}
on:change={config.props.onChange} on:change={config.props.onChange}
/> />
</PropField> </PropField>
@ -836,6 +849,7 @@
<svelte:component <svelte:component
this={config.type} this={config.type}
{...config.props} {...config.props}
{bindings}
on:change={config.props.onChange} on:change={config.props.onChange}
/> />
{/if} {/if}

View File

@ -13,6 +13,10 @@
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
export let onChange export let onChange
export let field export let field
@ -30,6 +34,8 @@
return clone return clone
}) })
$: readableValue = runtimeToReadableBinding(parsedBindings, fieldData)
let attachmentTypes = [ let attachmentTypes = [
FieldType.ATTACHMENTS, FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE, FieldType.ATTACHMENT_SINGLE,
@ -132,11 +138,11 @@
/> />
{:else if schema.type === "longform"} {:else if schema.type === "longform"}
<TextArea <TextArea
value={fieldData} value={readableValue}
on:change={e => on:change={e =>
onChange({ onChange({
row: { row: {
[field]: e.detail, [field]: readableToRuntimeBinding(parsedBindings, e.detail),
}, },
})} })}
/> />
@ -144,11 +150,11 @@
<span> <span>
<div class="field-wrap json-field"> <div class="field-wrap json-field">
<CodeEditor <CodeEditor
value={fieldData} value={readableValue}
on:change={e => { on:blur={e => {
onChange({ onChange({
row: { row: {
[field]: e.detail, [field]: readableToRuntimeBinding(parsedBindings, e.detail),
}, },
}) })
}} }}

View File

@ -1,6 +1,6 @@
<script> <script>
import { Label } from "@budibase/bbui" import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte" import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates" import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
import { import {
@ -58,6 +58,64 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let textarea
let editor
let mounted = false
let isEditorInitialised = false
let queuedRefresh = false
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
$: {
if (autofocus && isEditorInitialised) {
editor.focus()
}
}
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
initEditor()
}
// Theme change
$: if (mounted && isEditorInitialised && $themeStore?.theme) {
if (currentTheme != $themeStore?.theme) {
currentTheme = $themeStore?.theme
isDark = !currentTheme.includes("light")
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
})
}
}
// Wait to try and gracefully replace
$: refresh(value, isEditorInitialised, mounted)
/**
* Will refresh the editor contents only after
* it has been fully initialised
* @param value {string} the editor value
*/
const refresh = (value, initialised, mounted) => {
if (!initialised || !mounted) {
queuedRefresh = true
return
}
if (editor.state.doc.toString() !== value || queuedRefresh) {
editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: value },
})
queuedRefresh = false
}
}
// Export a function to expose caret position // Export a function to expose caret position
export const getCaretPosition = () => { export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0] const selection_range = editor.state.selection.ranges[0]
@ -132,11 +190,6 @@
} }
) )
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
const indentWithTabCustom = { const indentWithTabCustom = {
key: "Tab", key: "Tab",
run: view => { run: view => {
@ -253,6 +306,11 @@
lineNumbers(), lineNumbers(),
foldGutter(), foldGutter(),
keymap.of(buildKeymap()), keymap.of(buildKeymap()),
EditorView.domEventHandlers({
blur: () => {
dispatch("blur", editor.state.doc.toString())
},
}),
EditorView.updateListener.of(v => { EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString() const docStr = v.state.doc?.toString()
if (docStr === value) { if (docStr === value) {
@ -266,11 +324,6 @@
return complete return complete
} }
let textarea
let editor
let mounted = false
let isEditorInitialised = false
const initEditor = () => { const initEditor = () => {
const baseExtensions = buildBaseExtensions() const baseExtensions = buildBaseExtensions()
@ -281,37 +334,13 @@
}) })
} }
$: {
if (autofocus && isEditorInitialised) {
editor.focus()
}
}
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
initEditor()
}
// Theme change
$: if (mounted && isEditorInitialised && $themeStore?.theme) {
if (currentTheme != $themeStore?.theme) {
currentTheme = $themeStore?.theme
isDark = !currentTheme.includes("light")
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
})
}
}
onMount(async () => { onMount(async () => {
mounted = true mounted = true
return () => { })
if (editor) {
editor.destroy() onDestroy(() => {
} if (editor) {
editor.destroy()
} }
}) })
</script> </script>

View File

@ -20,7 +20,6 @@
export let allowJS = true export let allowJS = true
export let allowHelpers = true export let allowHelpers = true
export let updateOnChange = true export let updateOnChange = true
export let drawerLeft
export let type export let type
export let schema export let schema
@ -170,14 +169,7 @@
<Icon disabled={isJS} size="S" name="Close" /> <Icon disabled={isJS} size="S" name="Close" />
</div> </div>
{:else} {:else}
<slot <slot />
{label}
{disabled}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
{placeholder}
{updateOnChange}
/>
{/if} {/if}
{#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)} {#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
<div <div
@ -195,7 +187,7 @@
on:drawerShow on:drawerShow
bind:this={bindingDrawer} bind:this={bindingDrawer}
title={title ?? placeholder ?? "Bindings"} title={title ?? placeholder ?? "Bindings"}
left={drawerLeft} forceModal={true}
> >
<Button cta slot="buttons" on:click={saveBinding}>Save</Button> <Button cta slot="buttons" on:click={saveBinding}>Save</Button>
<svelte:component <svelte:component

View File

@ -1,4 +1,4 @@
import { getComponentContexts } from "dataBinding" import { getAllComponentContexts } from "dataBinding"
import { capitalise } from "helpers" import { capitalise } from "helpers"
// Generates bindings for all components that provider "datasource like" // Generates bindings for all components that provider "datasource like"
@ -7,7 +7,7 @@ import { capitalise } from "helpers"
// Some examples are saving rows or duplicating rows. // Some examples are saving rows or duplicating rows.
export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => {
// Get all form context providers // Get all form context providers
const formComponentContexts = getComponentContexts( const formComponentContexts = getAllComponentContexts(
asset, asset,
componentId, componentId,
"form", "form",
@ -16,7 +16,7 @@ export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => {
} }
) )
// Get all schema context providers // Get all schema context providers
const schemaComponentContexts = getComponentContexts( const schemaComponentContexts = getAllComponentContexts(
asset, asset,
componentId, componentId,
"schema", "schema",

View File

@ -6,6 +6,7 @@ import {
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
findComponentPath, findComponentPath,
getComponentContexts,
} from "helpers/components" } from "helpers/components"
import { import {
componentStore, componentStore,
@ -213,7 +214,7 @@ export const getComponentBindableProperties = (asset, componentId) => {
* both global and local bindings, taking into account a component's position * both global and local bindings, taking into account a component's position
* in the component tree. * in the component tree.
*/ */
export const getComponentContexts = ( export const getAllComponentContexts = (
asset, asset,
componentId, componentId,
type, type,
@ -229,11 +230,6 @@ export const getComponentContexts = (
// Processes all contexts exposed by a component // Processes all contexts exposed by a component
const processContexts = scope => component => { const processContexts = scope => component => {
const def = componentStore.getDefinition(component._component)
if (!def?.context) {
return
}
// Filter out global contexts not in the same branch. // Filter out global contexts not in the same branch.
// Global contexts are only valid if their branch root is an ancestor of // Global contexts are only valid if their branch root is an ancestor of
// this component. // this component.
@ -242,8 +238,8 @@ export const getComponentContexts = (
return return
} }
// Process all contexts provided by this component const componentType = component._component
const contexts = Array.isArray(def.context) ? def.context : [def.context] const contexts = getComponentContexts(componentType)
contexts.forEach(context => { contexts.forEach(context => {
// Ensure type matches // Ensure type matches
if (type && context.type !== type) { if (type && context.type !== type) {
@ -261,7 +257,7 @@ export const getComponentContexts = (
if (!map[component._id]) { if (!map[component._id]) {
map[component._id] = { map[component._id] = {
component, component,
definition: def, definition: componentStore.getDefinition(componentType),
contexts: [], contexts: [],
} }
} }
@ -286,7 +282,7 @@ export const getComponentContexts = (
} }
/** /**
* Gets all data provider components above a component. * Gets all components available to this component that expose a certain action
*/ */
export const getActionProviders = ( export const getActionProviders = (
asset, asset,
@ -294,36 +290,30 @@ export const getActionProviders = (
actionType, actionType,
options = { includeSelf: false } options = { includeSelf: false }
) => { ) => {
if (!asset) { const contexts = getAllComponentContexts(asset, componentId, "action", {
return [] includeSelf: options?.includeSelf,
}
// Get all components
const components = findAllComponents(asset.props)
// Find matching contexts and generate bindings
let providers = []
components.forEach(component => {
if (!options?.includeSelf && component._id === componentId) {
return
}
const def = componentStore.getDefinition(component._component)
const actions = (def?.actions || []).map(action => {
return typeof action === "string" ? { type: action } : action
})
const action = actions.find(x => x.type === actionType)
if (action) {
let runtimeBinding = component._id
if (action.suffix) {
runtimeBinding += `-${action.suffix}`
}
providers.push({
readableBinding: component._instanceName,
runtimeBinding,
})
}
}) })
return providers return (
contexts
// Find the definition of the action in question, if one is provided
.map(context => ({
...context,
action: context.contexts[0]?.actions?.find(x => x.type === actionType),
}))
// Filter out contexts which don't have this action
.filter(({ action }) => action != null)
// Generate bindings for this component and action
.map(({ component, action }) => {
let runtimeBinding = component._id
if (action.suffix) {
runtimeBinding += `-${action.suffix}`
}
return {
readableBinding: component._instanceName,
runtimeBinding,
}
})
)
} }
/** /**
@ -371,7 +361,7 @@ export const getDatasourceForProvider = (asset, component) => {
*/ */
const getContextBindings = (asset, componentId) => { const getContextBindings = (asset, componentId) => {
// Get all available contexts for this component // Get all available contexts for this component
const componentContexts = getComponentContexts(asset, componentId) const componentContexts = getAllComponentContexts(asset, componentId)
// Generate bindings for each context // Generate bindings for each context
return componentContexts return componentContexts

View File

@ -228,6 +228,25 @@ export const getComponentName = component => {
return componentDefinition.friendlyName || componentDefinition.name || "" return componentDefinition.friendlyName || componentDefinition.name || ""
} }
// Gets all contexts exposed by a certain component type, including actions
export const getComponentContexts = component => {
const def = componentStore.getDefinition(component)
let contexts = []
if (def?.context) {
contexts = Array.isArray(def.context) ? [...def.context] : [def.context]
}
if (def?.actions) {
contexts.push({
type: "action",
scope: ContextScopes.Global,
// Ensure all actions are their verbose object versions
actions: def.actions.map(x => (typeof x === "string" ? { type: x } : x)),
})
}
return contexts
}
/** /**
* Recurses through the component tree and builds a tree of contexts provided * Recurses through the component tree and builds a tree of contexts provided
* by components. * by components.
@ -243,10 +262,9 @@ export const buildContextTree = (
} }
// Process this component's contexts // Process this component's contexts
const def = componentStore.getDefinition(rootComponent._component) const contexts = getComponentContexts(rootComponent._component)
if (def?.context) { if (contexts.length) {
tree[currentBranch].push(rootComponent._id) tree[currentBranch].push(rootComponent._id)
const contexts = Array.isArray(def.context) ? def.context : [def.context]
// If we provide local context, start a new branch for our children // If we provide local context, start a new branch for our children
if (contexts.some(context => context.scope === ContextScopes.Local)) { if (contexts.some(context => context.scope === ContextScopes.Local)) {

View File

@ -147,6 +147,15 @@
onOperatorChange(condition, condition.operator) onOperatorChange(condition, condition.operator)
} }
} }
const onSettingChange = (e, condition) => {
const setting = settings.find(x => x.key === e.detail)
if (setting?.defaultValue != null) {
condition.settingValue = setting.defaultValue
} else {
delete condition.settingValue
}
}
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
@ -189,7 +198,7 @@
<Select <Select
options={settingOptions} options={settingOptions}
bind:value={condition.setting} bind:value={condition.setting}
on:change={() => delete condition.settingValue} on:change={e => onSettingChange(e, condition)}
/> />
<div>TO</div> <div>TO</div>
{#if definition} {#if definition}

View File

@ -16,7 +16,7 @@
export let showOnboardingTypeModal export let showOnboardingTypeModal
const password = Math.random().toString(36).substring(2, 22) const password = generatePassword(12)
let disabled let disabled
let userGroups = [] let userGroups = []
@ -44,7 +44,7 @@
{ {
email: "", email: "",
role: "appUser", role: "appUser",
password: Math.random().toString(36).substring(2, 22), password: generatePassword(12),
forceResetPassword: true, forceResetPassword: true,
error: null, error: null,
}, },
@ -69,6 +69,14 @@
return userData[index].error == null return userData[index].error == null
} }
function generatePassword(length) {
const array = new Uint8Array(length)
window.crypto.getRandomValues(array)
return Array.from(array, byte => byte.toString(36).padStart(2, "0"))
.join("")
.slice(0, length)
}
const onConfirm = () => { const onConfirm = () => {
let valid = true let valid = true
userData.forEach((input, index) => { userData.forEach((input, index) => {

View File

@ -216,7 +216,7 @@
const newUser = { const newUser = {
email: email, email: email,
role: usersRole, role: usersRole,
password: Math.random().toString(36).substring(2, 22), password: generatePassword(12),
forceResetPassword: true, forceResetPassword: true,
} }
@ -288,6 +288,14 @@
} }
} }
const generatePassword = length => {
const array = new Uint8Array(length)
window.crypto.getRandomValues(array)
return Array.from(array, byte => byte.toString(36).padStart(2, "0"))
.join("")
.slice(0, length)
}
onMount(async () => { onMount(async () => {
try { try {
await groups.actions.init() await groups.actions.init()

View File

@ -2,9 +2,16 @@
import { Modal, ModalContent, ProgressBar } from "@budibase/bbui" import { Modal, ModalContent, ProgressBar } from "@budibase/bbui"
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { sleep } from "../../../utils/utils" import { sleep } from "../../../utils/utils"
import { get } from "svelte/store"
const { clipboard, subscribe, copyAllowed, pasteAllowed, selectedCellCount } = const {
getContext("grid") clipboard,
subscribe,
copyAllowed,
pasteAllowed,
selectedCellCount,
focusedCellAPI,
} = getContext("grid")
const duration = 260 const duration = 260
let modal let modal
@ -19,10 +26,15 @@
} }
const handlePasteRequest = async () => { const handlePasteRequest = async () => {
// If a cell is active then let the native paste action take over
if (get(focusedCellAPI)?.isActive()) {
return
}
progressPercentage = 0 progressPercentage = 0
if (!$pasteAllowed) { if (!$pasteAllowed) {
return return
} }
// Prompt if paste will update multiple cells // Prompt if paste will update multiple cells
const multiCellPaste = $selectedCellCount > 1 const multiCellPaste = $selectedCellCount > 1
const prompt = $clipboard.multiCellCopy || multiCellPaste const prompt = $clipboard.multiCellCopy || multiCellPaste

View File

@ -58,7 +58,7 @@
case "c": case "c":
return handle(() => dispatch("copy")) return handle(() => dispatch("copy"))
case "v": case "v":
return handle(() => dispatch("paste")) return dispatch("paste")
case "Enter": case "Enter":
return handle(() => { return handle(() => {
if ($config.canAddRows) { if ($config.canAddRows) {

View File

@ -1,55 +1,36 @@
import { Datasource, Query } from "@budibase/types" import { Datasource, Query } from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
import { import { DatabaseName } from "../../integrations/tests/utils"
DatabaseName,
getDatasource,
knexClient,
} from "../../integrations/tests/utils"
import { Knex } from "knex" import { Knex } from "knex"
import { generator } from "@budibase/backend-core/tests"
describe.each( describe.each([
[ DatabaseName.POSTGRES,
DatabaseName.POSTGRES, DatabaseName.MYSQL,
DatabaseName.MYSQL, DatabaseName.SQL_SERVER,
DatabaseName.SQL_SERVER, DatabaseName.MARIADB,
DatabaseName.MARIADB, DatabaseName.ORACLE,
DatabaseName.ORACLE, ])("execute query action (%s)", name => {
].map(name => [name, getDatasource(name)])
)("execute query action (%s)", (_, dsProvider) => {
let tableName: string let tableName: string
let client: Knex let client: Knex
let datasource: Datasource let datasource: Datasource
let query: Query let query: Query
let config = setup.getConfig() const config = setup.getConfig()
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
const ds = await dsProvider const testSetup = await setup.setupTestDatasource(config, name)
datasource = await config.api.datasource.create(ds) datasource = testSetup.datasource
client = await knexClient(ds) client = testSetup.client
}) })
beforeEach(async () => { beforeEach(async () => {
tableName = generator.guid() tableName = await setup.createTestTable(client, {
await client.schema.createTable(tableName, table => { a: { type: "string" },
table.string("a") b: { type: "number" },
table.integer("b")
})
await client(tableName).insert({ a: "string", b: 1 })
query = await config.api.query.save({
name: "test query",
datasourceId: datasource._id!,
parameters: [],
fields: {
sql: client(tableName).select("*").toSQL().toNative().sql,
},
transformer: "",
schema: {},
readable: true,
queryVerb: "read",
}) })
await setup.insertTestData(client, tableName, [{ a: "string", b: 1 }])
query = await setup.saveTestQuery(config, client, tableName, datasource)
}) })
afterEach(async () => { afterEach(async () => {

View File

@ -1,7 +1,14 @@
import * as automation from "../../index" import * as automation from "../../index"
import * as setup from "../utilities" import * as setup from "../utilities"
import { Table, LoopStepType } from "@budibase/types" import {
Table,
LoopStepType,
CreateRowStepOutputs,
ServerLogStepOutputs,
FieldType,
} from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationBuilder" import { createAutomationBuilder } from "../utilities/AutomationBuilder"
import { DatabaseName } from "../../../integrations/tests/utils"
describe("Automation Scenarios", () => { describe("Automation Scenarios", () => {
let config = setup.getConfig(), let config = setup.getConfig(),
@ -63,6 +70,72 @@ describe("Automation Scenarios", () => {
}) })
}) })
}) })
it("should run an automation where a loop is successfully run twice", async () => {
const builder = createAutomationBuilder({
name: "Test Trigger with Loop and Create Row",
})
const results = await builder
.rowSaved(
{ tableId: table._id! },
{
row: {
name: "Trigger Row",
description: "This row triggers the automation",
},
id: "1234",
revision: "1",
}
)
.loop({
option: LoopStepType.ARRAY,
binding: [1, 2, 3],
})
.createRow({
row: {
name: "Item {{ loop.currentItem }}",
description: "Created from loop",
tableId: table._id,
},
})
.loop({
option: LoopStepType.STRING,
binding: "Message 1,Message 2,Message 3",
})
.serverLog({ text: "{{loop.currentItem}}" })
.run()
expect(results.trigger).toBeDefined()
expect(results.steps).toHaveLength(2)
expect(results.steps[0].outputs.iterations).toBe(3)
expect(results.steps[0].outputs.items).toHaveLength(3)
results.steps[0].outputs.items.forEach(
(output: CreateRowStepOutputs, index: number) => {
expect(output).toMatchObject({
success: true,
row: {
name: `Item ${index + 1}`,
description: "Created from loop",
},
})
}
)
expect(results.steps[1].outputs.iterations).toBe(3)
expect(results.steps[1].outputs.items).toHaveLength(3)
results.steps[1].outputs.items.forEach(
(output: ServerLogStepOutputs, index: number) => {
expect(output).toMatchObject({
success: true,
})
expect(output.message).toContain(`Message ${index + 1}`)
}
)
})
}) })
describe("Row Automations", () => { describe("Row Automations", () => {
@ -157,4 +230,94 @@ describe("Automation Scenarios", () => {
expect(results.steps[1].outputs.success).toBeTruthy() expect(results.steps[1].outputs.success).toBeTruthy()
expect(results.steps[2].outputs.rows).toHaveLength(1) expect(results.steps[2].outputs.rows).toHaveLength(1)
}) })
it("should query an external database for some data then insert than into an internal table", async () => {
const { datasource, client } = await setup.setupTestDatasource(
config,
DatabaseName.MYSQL
)
const newTable = await config.createTable({
name: "table",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
age: {
name: "age",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const tableName = await setup.createTestTable(client, {
name: { type: "string" },
age: { type: "number" },
})
const rows = [
{ name: "Joe", age: 20 },
{ name: "Bob", age: 25 },
{ name: "Paul", age: 30 },
]
await setup.insertTestData(client, tableName, rows)
const query = await setup.saveTestQuery(
config,
client,
tableName,
datasource
)
const builder = createAutomationBuilder({
name: "Test external query and save",
})
const results = await builder
.appAction({
fields: {},
})
.executeQuery({
query: {
queryId: query._id!,
},
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.response }}",
})
.createRow({
row: {
name: "{{ loop.currentItem.name }}",
age: "{{ loop.currentItem.age }}",
tableId: newTable._id!,
},
})
.queryRows({
tableId: newTable._id!,
})
.run()
expect(results.steps).toHaveLength(3)
expect(results.steps[1].outputs.iterations).toBe(3)
expect(results.steps[1].outputs.items).toHaveLength(3)
expect(results.steps[2].outputs.rows).toHaveLength(3)
rows.forEach(expectedRow => {
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining([expect.objectContaining(expectedRow)])
)
})
})
}) })

View File

@ -28,6 +28,7 @@ import {
SmtpEmailStepInputs, SmtpEmailStepInputs,
ExecuteQueryStepInputs, ExecuteQueryStepInputs,
QueryRowsStepInputs, QueryRowsStepInputs,
ServerLogStepInputs,
} from "@budibase/types" } from "@budibase/types"
import {} from "../../steps/loop" import {} from "../../steps/loop"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
@ -119,6 +120,10 @@ class AutomationBuilder {
return this.step(BUILTIN_ACTION_DEFINITIONS.LOOP, inputs) return this.step(BUILTIN_ACTION_DEFINITIONS.LOOP, inputs)
} }
serverLog(input: ServerLogStepInputs): this {
return this.step(BUILTIN_ACTION_DEFINITIONS.SERVER_LOG, input)
}
private trigger<T extends { [key: string]: any }>( private trigger<T extends { [key: string]: any }>(
triggerSchema: AutomationTriggerSchema, triggerSchema: AutomationTriggerSchema,
inputs?: T, inputs?: T,

View File

@ -3,7 +3,14 @@ import { context } from "@budibase/backend-core"
import { BUILTIN_ACTION_DEFINITIONS, getAction } from "../../actions" import { BUILTIN_ACTION_DEFINITIONS, getAction } from "../../actions"
import emitter from "../../../events/index" import emitter from "../../../events/index"
import env from "../../../environment" import env from "../../../environment"
import { AutomationActionStepId } from "@budibase/types" import { AutomationActionStepId, Datasource } from "@budibase/types"
import { Knex } from "knex"
import { generator } from "@budibase/backend-core/tests"
import {
getDatasource,
knexClient,
DatabaseName,
} from "../../../integrations/tests/utils"
let config: TestConfig let config: TestConfig
@ -57,5 +64,58 @@ export async function runStep(stepId: string, inputs: any, stepContext?: any) {
} }
} }
export async function createTestTable(client: Knex, schema: any) {
const tableName = generator.guid()
await client.schema.createTable(tableName, table => {
for (const fieldName in schema) {
const field = schema[fieldName]
if (field.type === "string") {
table.string(fieldName)
} else if (field.type === "number") {
table.integer(fieldName)
}
}
})
return tableName
}
export async function insertTestData(
client: Knex,
tableName: string,
rows: any[]
) {
await client(tableName).insert(rows)
}
export async function saveTestQuery(
config: TestConfig,
client: Knex,
tableName: string,
datasource: Datasource
) {
return await config.api.query.save({
name: "test query",
datasourceId: datasource._id!,
parameters: [],
fields: {
sql: client(tableName).select("*").toSQL().toNative().sql,
},
transformer: "",
schema: {},
readable: true,
queryVerb: "read",
})
}
export async function setupTestDatasource(
config: TestConfig,
dbName: DatabaseName
) {
const db = await getDatasource(dbName)
const datasource = await config.api.datasource.create(db)
const client = await knexClient(db)
return { datasource, client }
}
export const apiKey = "test" export const apiKey = "test"
export const actions = BUILTIN_ACTION_DEFINITIONS export const actions = BUILTIN_ACTION_DEFINITIONS

View File

@ -98,6 +98,24 @@ export type ActionImplementations<T extends Hosting> = {
} }
: {}) : {})
export type AutomationStepOutputs =
| CollectStepOutputs
| CreateRowStepOutputs
| DelayStepOutputs
| DeleteRowStepOutputs
| ExecuteQueryStepOutputs
| ExecuteScriptStepOutputs
| FilterStepOutputs
| QueryRowsStepOutputs
| BaseAutomationOutputs
| BashStepOutputs
| ExternalAppStepOutputs
| OpenAIStepOutputs
| ServerLogStepOutputs
| TriggerAutomationStepOutputs
| UpdateRowStepOutputs
| ZapierStepOutputs
export type BaseAutomationOutputs = { export type BaseAutomationOutputs = {
success?: boolean success?: boolean
response?: { response?: {
@ -199,7 +217,7 @@ export type LoopStepInputs = {
} }
export type LoopStepOutputs = { export type LoopStepOutputs = {
items: string items: AutomationStepOutputs[]
success: boolean success: boolean
iterations: number iterations: number
} }

View File

@ -41,6 +41,14 @@ import { BpmStatusKey, BpmStatusValue } from "@budibase/shared-core"
const MAX_USERS_UPLOAD_LIMIT = 1000 const MAX_USERS_UPLOAD_LIMIT = 1000
const generatePassword = (length: number) => {
const array = new Uint8Array(length)
crypto.getRandomValues(array)
return Array.from(array, byte => byte.toString(36).padStart(2, "0"))
.join("")
.slice(0, length)
}
export const save = async (ctx: UserCtx<User, SaveUserResponse>) => { export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
try { try {
const currentUserId = ctx.user?._id const currentUserId = ctx.user?._id
@ -296,7 +304,7 @@ export const onboardUsers = async (
let createdPasswords: Record<string, string> = {} let createdPasswords: Record<string, string> = {}
const users: User[] = ctx.request.body.map(invite => { const users: User[] = ctx.request.body.map(invite => {
let password = Math.random().toString(36).substring(2, 22) const password = generatePassword(12)
createdPasswords[invite.email] = password createdPasswords[invite.email] = password
return { return {