Merge branch 'master' into BUDI-9016/extract-componenterrors-from-client

This commit is contained in:
Adria Navarro 2025-01-30 12:57:02 +01:00 committed by GitHub
commit 8f7e9dc0fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 544 additions and 380 deletions

View File

@ -19,7 +19,7 @@
$: success = !error && !empty $: success = !error && !empty
$: highlightedResult = highlight(expressionResult) $: highlightedResult = highlight(expressionResult)
$: highlightedLogs = expressionLogs.map(l => ({ $: highlightedLogs = expressionLogs.map(l => ({
log: highlight(l.log.join(", ")), log: l.log.map(part => highlight(part)).join(", "),
line: l.line, line: l.line,
type: l.type, type: l.type,
})) }))
@ -95,7 +95,9 @@
{#if empty} {#if empty}
Your expression will be evaluated here Your expression will be evaluated here
{:else if error} {:else if error}
{formatError(expressionError)} <div class="error-msg">
{formatError(expressionError)}
</div>
{:else} {:else}
<div class="output-lines"> <div class="output-lines">
{#each highlightedLogs as logLine} {#each highlightedLogs as logLine}
@ -118,13 +120,17 @@
<span>{@html logLine.log}</span> <span>{@html logLine.log}</span>
</div> </div>
{#if logLine.line} {#if logLine.line}
<span style="color: var(--blue)">:{logLine.line}</span> <span style="color: var(--blue); overflow-wrap: normal;"
>:{logLine.line}</span
>
{/if} {/if}
</div> </div>
{/each} {/each}
<div class="line"> <div class="line">
<!-- eslint-disable-next-line svelte/no-at-html-tags--> <div>
{@html highlightedResult} <!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -169,29 +175,33 @@
.header.error::before { .header.error::before {
background: var(--error-bg); background: var(--error-bg);
} }
.error-msg {
padding-top: var(--spacing-m);
}
.body { .body {
flex: 1 1 auto; flex: 1 1 auto;
padding: var(--spacing-m) var(--spacing-l);
font-family: var(--font-mono); font-family: var(--font-mono);
margin: 0 var(--spacing-m);
font-size: 12px; font-size: 12px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
white-space: pre-line; word-wrap: anywhere;
word-wrap: break-word;
height: 0; height: 0;
} }
.output-lines { .output-lines {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xs);
} }
.line { .line {
border-bottom: var(--border-light);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: end; align-items: end;
padding: var(--spacing-s); padding: var(--spacing-m) 0;
word-wrap: anywhere;
}
.line:not(:first-of-type) {
border-top: var(--border-light);
} }
.icon-log { .icon-log {
display: flex; display: flex;

View File

@ -2,7 +2,7 @@ import { derived, get } from "svelte/store"
import { API } from "@/api" import { API } from "@/api"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { generate } from "shortid" import { generate } from "shortid"
import { createHistoryStore } from "@/stores/builder/history" import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { licensing } from "@/stores/portal" import { licensing } from "@/stores/portal"
import { tables, appStore } from "@/stores/builder" import { tables, appStore } from "@/stores/builder"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -1428,7 +1428,7 @@ const automationActions = (store: AutomationStore) => ({
}) })
class AutomationStore extends BudiStore<AutomationState> { class AutomationStore extends BudiStore<AutomationState> {
history: any history: HistoryStore<Automation>
actions: ReturnType<typeof automationActions> actions: ReturnType<typeof automationActions>
constructor() { constructor() {
@ -1437,8 +1437,6 @@ class AutomationStore extends BudiStore<AutomationState> {
this.history = createHistoryStore({ this.history = createHistoryStore({
getDoc: this.actions.getDefinition.bind(this), getDoc: this.actions.getDefinition.bind(this),
selectDoc: this.actions.select.bind(this), selectDoc: this.actions.select.bind(this),
beforeAction: () => {},
afterAction: () => {},
}) })
// Then wrap save and delete with history // Then wrap save and delete with history

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { selectedScreen as selectedScreenStore } from "./screens" import { selectedScreen as selectedScreenStore } from "./screens"
import { findComponentPath } from "@/helpers/components" import { findComponentPath } from "@/helpers/components"
import { Screen, Component } from "@budibase/types" import { Component, Screen } from "@budibase/types"
import { BudiStore, PersistenceType } from "@/stores/BudiStore" import { BudiStore, PersistenceType } from "@/stores/BudiStore"
interface OpenNodesState { interface OpenNodesState {

View File

@ -30,9 +30,18 @@ import {
} from "@/constants/backend" } from "@/constants/backend"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { Component, FieldType, Screen, Table } from "@budibase/types" import {
Component as ComponentType,
FieldType,
Screen,
Table,
} from "@budibase/types"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
interface Component extends ComponentType {
_id: string
}
export interface ComponentState { export interface ComponentState {
components: Record<string, ComponentDefinition> components: Record<string, ComponentDefinition>
customComponents: string[] customComponents: string[]
@ -254,7 +263,10 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @param {object} opts * @param {object} opts
* @returns * @returns
*/ */
enrichEmptySettings(component: Component, opts: any) { enrichEmptySettings(
component: Component,
opts: { screen?: Screen; parent?: Component; useDefaultValues?: boolean }
) {
if (!component?._component) { if (!component?._component) {
return return
} }
@ -364,7 +376,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
getSchemaForDatasource(screen, dataSource, {}) getSchemaForDatasource(screen, dataSource, {})
// Finds fields by types from the schema of the configured datasource // Finds fields by types from the schema of the configured datasource
const findFieldTypes = (fieldTypes: any) => { const findFieldTypes = (fieldTypes: FieldType | FieldType[]) => {
if (!Array.isArray(fieldTypes)) { if (!Array.isArray(fieldTypes)) {
fieldTypes = [fieldTypes] fieldTypes = [fieldTypes]
} }
@ -439,7 +451,11 @@ export class ComponentStore extends BudiStore<ComponentState> {
* @param {object} parent * @param {object} parent
* @returns * @returns
*/ */
createInstance(componentName: string, presetProps: any, parent: any) { createInstance(
componentName: string,
presetProps: any,
parent: any
): Component | null {
const screen = get(selectedScreen) const screen = get(selectedScreen)
if (!screen) { if (!screen) {
throw "A valid screen must be selected" throw "A valid screen must be selected"
@ -451,7 +467,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
// Generate basic component structure // Generate basic component structure
let instance = { let instance: Component = {
_id: Helpers.uuid(), _id: Helpers.uuid(),
_component: definition.component, _component: definition.component,
_styles: { _styles: {
@ -478,7 +494,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
// Custom post processing for creation only // Custom post processing for creation only
let extras: any = {} let extras: Partial<Component> = {}
if (definition.hasChildren) { if (definition.hasChildren) {
extras._children = [] extras._children = []
} }
@ -487,7 +503,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (componentName.endsWith("/formstep")) { if (componentName.endsWith("/formstep")) {
const parentForm = findClosestMatchingComponent( const parentForm = findClosestMatchingComponent(
screen.props, screen.props,
get(selectedComponent)._id, get(selectedComponent)?._id,
(component: Component) => component._component.endsWith("/form") (component: Component) => component._component.endsWith("/form")
) )
const formSteps = findAllMatchingComponents( const formSteps = findAllMatchingComponents(
@ -515,7 +531,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
async create( async create(
componentName: string, componentName: string,
presetProps: any, presetProps: any,
parent: any, parent: Component,
index: number index: number
) { ) {
const state = get(this.store) const state = get(this.store)
@ -772,7 +788,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!cut) { if (!cut) {
componentToPaste = makeComponentUnique(componentToPaste) componentToPaste = makeComponentUnique(componentToPaste)
} }
newComponentId = componentToPaste._id! newComponentId = componentToPaste._id
// Strip grid position metadata if pasting into a new screen, but keep // Strip grid position metadata if pasting into a new screen, but keep
// alignment metadata // alignment metadata
@ -915,7 +931,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// If we have children, select first child, and the node is not collapsed // If we have children, select first child, and the node is not collapsed
if ( if (
component._children?.length && component?._children?.length &&
(state.selectedComponentId === navComponentId || (state.selectedComponentId === navComponentId ||
componentTreeNodesStore.isNodeExpanded(component._id)) componentTreeNodesStore.isNodeExpanded(component._id))
) { ) {
@ -1339,12 +1355,15 @@ export const componentStore = new ComponentStore()
export const selectedComponent = derived( export const selectedComponent = derived(
[componentStore, selectedScreen], [componentStore, selectedScreen],
([$store, $selectedScreen]) => { ([$store, $selectedScreen]): Component | null => {
if ( if (
$selectedScreen && $selectedScreen &&
$store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`) $store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`)
) { ) {
return $selectedScreen?.props return {
...$selectedScreen.props,
_id: $selectedScreen.props._id!,
}
} }
if (!$selectedScreen || !$store.selectedComponentId) { if (!$selectedScreen || !$store.selectedComponentId) {
return null return null

View File

@ -1,10 +1,25 @@
import * as jsonpatch from "fast-json-patch/index.mjs" import { Document } from "@budibase/types"
import { writable, derived, get } from "svelte/store" import * as jsonpatch from "fast-json-patch"
import { writable, derived, get, Readable } from "svelte/store"
export const Operations = { export const enum Operations {
Add: "Add", Add = "Add",
Delete: "Delete", Delete = "Delete",
Change: "Change", Change = "Change",
}
interface Operator<T extends Document> {
id?: number
type: Operations
doc: T
forwardPatch?: jsonpatch.Operation[]
backwardsPatch?: jsonpatch.Operation[]
}
interface HistoryState<T extends Document> {
history: Operator<T>[]
position: number
loading?: boolean
} }
export const initialState = { export const initialState = {
@ -13,14 +28,38 @@ export const initialState = {
loading: false, loading: false,
} }
export const createHistoryStore = ({ export interface HistoryStore<T extends Document>
extends Readable<
HistoryState<T> & {
canUndo: boolean
canRedo: boolean
}
> {
wrapSaveDoc: (
fn: (doc: T) => Promise<T>
) => (doc: T, operationId?: number) => Promise<T>
wrapDeleteDoc: (
fn: (doc: T) => Promise<void>
) => (doc: T, operationId?: number) => Promise<void>
reset: () => void
undo: () => Promise<void>
redo: () => Promise<void>
}
export const createHistoryStore = <T extends Document>({
getDoc, getDoc,
selectDoc, selectDoc,
beforeAction, beforeAction,
afterAction, afterAction,
}) => { }: {
getDoc: (id: string) => T | undefined
selectDoc: (id: string) => void
beforeAction?: (operation?: Operator<T>) => void
afterAction?: (operation?: Operator<T>) => void
}): HistoryStore<T> => {
// Use a derived store to check if we are able to undo or redo any operations // Use a derived store to check if we are able to undo or redo any operations
const store = writable(initialState) const store = writable<HistoryState<T>>(initialState)
const derivedStore = derived(store, $store => { const derivedStore = derived(store, $store => {
return { return {
...$store, ...$store,
@ -31,8 +70,8 @@ export const createHistoryStore = ({
// Wrapped versions of essential functions which we call ourselves when using // Wrapped versions of essential functions which we call ourselves when using
// undo and redo // undo and redo
let saveFn let saveFn: (doc: T, operationId?: number) => Promise<T>
let deleteFn let deleteFn: (doc: T, operationId?: number) => Promise<void>
/** /**
* Internal util to set the loading flag * Internal util to set the loading flag
@ -66,7 +105,7 @@ export const createHistoryStore = ({
* For internal use only. * For internal use only.
* @param operation the operation to save * @param operation the operation to save
*/ */
const saveOperation = operation => { const saveOperation = (operation: Operator<T>) => {
store.update(state => { store.update(state => {
// Update history // Update history
let history = state.history let history = state.history
@ -93,15 +132,15 @@ export const createHistoryStore = ({
* @param fn the save function * @param fn the save function
* @returns {function} a wrapped version of the save function * @returns {function} a wrapped version of the save function
*/ */
const wrapSaveDoc = fn => { const wrapSaveDoc = (fn: (doc: T) => Promise<T>) => {
saveFn = async (doc, operationId) => { saveFn = async (doc: T, operationId?: number) => {
// Only works on a single doc at a time // Only works on a single doc at a time
if (!doc || Array.isArray(doc)) { if (!doc || Array.isArray(doc)) {
return return
} }
startLoading() startLoading()
try { try {
const oldDoc = getDoc(doc._id) const oldDoc = getDoc(doc._id!)
const newDoc = jsonpatch.deepClone(await fn(doc)) const newDoc = jsonpatch.deepClone(await fn(doc))
// Store the change // Store the change
@ -141,8 +180,8 @@ export const createHistoryStore = ({
* @param fn the delete function * @param fn the delete function
* @returns {function} a wrapped version of the delete function * @returns {function} a wrapped version of the delete function
*/ */
const wrapDeleteDoc = fn => { const wrapDeleteDoc = (fn: (doc: T) => Promise<void>) => {
deleteFn = async (doc, operationId) => { deleteFn = async (doc: T, operationId?: number) => {
// Only works on a single doc at a time // Only works on a single doc at a time
if (!doc || Array.isArray(doc)) { if (!doc || Array.isArray(doc)) {
return return
@ -201,7 +240,7 @@ export const createHistoryStore = ({
// Undo ADD // Undo ADD
if (operation.type === Operations.Add) { if (operation.type === Operations.Add) {
// Try to get the latest doc version to delete // Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id) const latestDoc = getDoc(operation.doc._id!)
const doc = latestDoc || operation.doc const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id) await deleteFn(doc, operation.id)
} }
@ -219,7 +258,7 @@ export const createHistoryStore = ({
// Undo CHANGE // Undo CHANGE
else { else {
// Get the current doc and apply the backwards patch on top of it // Get the current doc and apply the backwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id)) let doc = jsonpatch.deepClone(getDoc(operation.doc._id!))
if (doc) { if (doc) {
jsonpatch.applyPatch( jsonpatch.applyPatch(
doc, doc,
@ -283,7 +322,7 @@ export const createHistoryStore = ({
// Redo DELETE // Redo DELETE
else if (operation.type === Operations.Delete) { else if (operation.type === Operations.Delete) {
// Try to get the latest doc version to delete // Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id) const latestDoc = getDoc(operation.doc._id!)
const doc = latestDoc || operation.doc const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id) await deleteFn(doc, operation.id)
} }
@ -291,7 +330,7 @@ export const createHistoryStore = ({
// Redo CHANGE // Redo CHANGE
else { else {
// Get the current doc and apply the forwards patch on top of it // Get the current doc and apply the forwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id)) let doc = jsonpatch.deepClone(getDoc(operation.doc._id!))
if (doc) { if (doc) {
jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch)) jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch))
await saveFn(doc, operation.id) await saveFn(doc, operation.id)

View File

@ -10,7 +10,7 @@ import {
navigationStore, navigationStore,
selectedComponent, selectedComponent,
} from "@/stores/builder" } from "@/stores/builder"
import { createHistoryStore } from "@/stores/builder/history" import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { API } from "@/api" import { API } from "@/api"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { import {
@ -33,9 +33,9 @@ export const initialScreenState: ScreenState = {
// Review the nulls // Review the nulls
export class ScreenStore extends BudiStore<ScreenState> { export class ScreenStore extends BudiStore<ScreenState> {
history: any history: HistoryStore<Screen>
delete: any delete: (screens: Screen) => Promise<void>
save: any save: (screen: Screen) => Promise<Screen>
constructor() { constructor() {
super(initialScreenState) super(initialScreenState)
@ -58,13 +58,12 @@ export class ScreenStore extends BudiStore<ScreenState> {
getDoc: (id: string) => getDoc: (id: string) =>
get(this.store).screens?.find(screen => screen._id === id), get(this.store).screens?.find(screen => screen._id === id),
selectDoc: this.select, selectDoc: this.select,
beforeAction: () => {},
afterAction: () => { afterAction: () => {
// Ensure a valid component is selected // Ensure a valid component is selected
if (!get(selectedComponent)) { if (!get(selectedComponent)) {
this.update(state => ({ componentStore.update(state => ({
...state, ...state,
selectedComponentId: get(selectedScreen)?.props._id, selectedComponentId: get(selectedScreen)?._id,
})) }))
} }
}, },
@ -281,7 +280,10 @@ export class ScreenStore extends BudiStore<ScreenState> {
* supports deeply mutating the current doc rather than just appending data. * supports deeply mutating the current doc rather than just appending data.
*/ */
sequentialScreenPatch = Utils.sequential( sequentialScreenPatch = Utils.sequential(
async (patchFn: (screen: Screen) => any, screenId: string) => { async (
patchFn: (screen: Screen) => boolean,
screenId: string
): Promise<Screen | void> => {
const state = get(this.store) const state = get(this.store)
const screen = state.screens.find(screen => screen._id === screenId) const screen = state.screens.find(screen => screen._id === screenId)
if (!screen) { if (!screen) {
@ -362,10 +364,10 @@ export class ScreenStore extends BudiStore<ScreenState> {
* Any deleted screens will then have their routes/links purged * Any deleted screens will then have their routes/links purged
* *
* Wrapped by {@link delete} * Wrapped by {@link delete}
* @param {Screen | Screen[]} screens * @param {Screen } screens
*/ */
async deleteScreen(screens: Screen | Screen[]) { async deleteScreen(screen: Screen) {
const screensToDelete = Array.isArray(screens) ? screens : [screens] const screensToDelete = [screen]
// Build array of promises to speed up bulk deletions // Build array of promises to speed up bulk deletions
let promises: Promise<DeleteScreenResponse>[] = [] let promises: Promise<DeleteScreenResponse>[] = []
let deleteUrls: string[] = [] let deleteUrls: string[] = []

View File

@ -1,8 +1,10 @@
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import { SearchFilterGroup, UISearchFilter } from "@budibase/types"
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) export const sleep = (ms: number) =>
new Promise(resolve => setTimeout(resolve, ms))
/** /**
* Utility to wrap an async function and ensure all invocations happen * Utility to wrap an async function and ensure all invocations happen
@ -10,12 +12,18 @@ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
* @param fn the async function to run * @param fn the async function to run
* @return {Function} a sequential version of the function * @return {Function} a sequential version of the function
*/ */
export const sequential = fn => { export const sequential = <
let queue = [] TReturn,
return (...params) => { TFunction extends (...args: any[]) => Promise<TReturn>
return new Promise((resolve, reject) => { >(
fn: TFunction
): TFunction => {
let queue: (() => Promise<void>)[] = []
const result = (...params: Parameters<TFunction>) => {
return new Promise<TReturn>((resolve, reject) => {
queue.push(async () => { queue.push(async () => {
let data, error let data: TReturn | undefined
let error: unknown
try { try {
data = await fn(...params) data = await fn(...params)
} catch (err) { } catch (err) {
@ -28,7 +36,7 @@ export const sequential = fn => {
if (error) { if (error) {
reject(error) reject(error)
} else { } else {
resolve(data) resolve(data!)
} }
}) })
if (queue.length === 1) { if (queue.length === 1) {
@ -36,6 +44,7 @@ export const sequential = fn => {
} }
}) })
} }
return result as TFunction
} }
/** /**
@ -45,9 +54,9 @@ export const sequential = fn => {
* @param minDelay the minimum delay between invocations * @param minDelay the minimum delay between invocations
* @returns a debounced version of the callback * @returns a debounced version of the callback
*/ */
export const debounce = (callback, minDelay = 1000) => { export const debounce = (callback: Function, minDelay = 1000) => {
let timeout let timeout: ReturnType<typeof setTimeout>
return async (...params) => { return async (...params: any[]) => {
return new Promise(resolve => { return new Promise(resolve => {
if (timeout) { if (timeout) {
clearTimeout(timeout) clearTimeout(timeout)
@ -70,11 +79,11 @@ export const debounce = (callback, minDelay = 1000) => {
* @param minDelay * @param minDelay
* @returns {Function} a throttled version function * @returns {Function} a throttled version function
*/ */
export const throttle = (callback, minDelay = 1000) => { export const throttle = (callback: Function, minDelay = 1000) => {
let lastParams let lastParams: any[]
let stalled = false let stalled = false
let pending = false let pending = false
const invoke = (...params) => { const invoke = (...params: any[]) => {
lastParams = params lastParams = params
if (stalled) { if (stalled) {
pending = true pending = true
@ -98,10 +107,10 @@ export const throttle = (callback, minDelay = 1000) => {
* @param callback the function to run * @param callback the function to run
* @returns {Function} * @returns {Function}
*/ */
export const domDebounce = callback => { export const domDebounce = (callback: Function) => {
let active = false let active = false
let lastParams let lastParams: any[]
return (...params) => { return (...params: any[]) => {
lastParams = params lastParams = params
if (!active) { if (!active) {
active = true active = true
@ -119,7 +128,17 @@ export const domDebounce = callback => {
* *
* @param {any} props * @param {any} props
* */ * */
export const buildFormBlockButtonConfig = props => { export const buildFormBlockButtonConfig = (props?: {
_id?: string
actionType?: string
dataSource?: { resourceId: string }
notificationOverride?: boolean
actionUrl?: string
showDeleteButton?: boolean
deleteButtonLabel?: string
showSaveButton?: boolean
saveButtonLabel?: string
}) => {
const { const {
_id, _id,
actionType, actionType,
@ -227,7 +246,11 @@ export const buildFormBlockButtonConfig = props => {
const defaultButtons = [] const defaultButtons = []
if (["Update", "Create"].includes(actionType) && showSaveButton !== false) { if (
actionType &&
["Update", "Create"].includes(actionType) &&
showSaveButton !== false
) {
defaultButtons.push({ defaultButtons.push({
text: saveText || "Save", text: saveText || "Save",
_id: Helpers.uuid(), _id: Helpers.uuid(),
@ -251,7 +274,13 @@ export const buildFormBlockButtonConfig = props => {
return defaultButtons return defaultButtons
} }
export const buildMultiStepFormBlockDefaultProps = props => { export const buildMultiStepFormBlockDefaultProps = (props?: {
_id: string
stepCount: number
currentStep: number
actionType: string
dataSource: { resourceId: string }
}) => {
const { _id, stepCount, currentStep, actionType, dataSource } = props || {} const { _id, stepCount, currentStep, actionType, dataSource } = props || {}
// Sanity check // Sanity check
@ -361,7 +390,7 @@ export const buildMultiStepFormBlockDefaultProps = props => {
* @param {Object} filter UI filter * @param {Object} filter UI filter
* @returns {Object} parsed filter * @returns {Object} parsed filter
*/ */
export function parseFilter(filter) { export function parseFilter(filter: UISearchFilter) {
if (!filter?.groups) { if (!filter?.groups) {
return filter return filter
} }
@ -369,13 +398,13 @@ export function parseFilter(filter) {
const update = cloneDeep(filter) const update = cloneDeep(filter)
update.groups = update.groups update.groups = update.groups
.map(group => { ?.map(group => {
group.filters = group.filters.filter(filter => { group.filters = group.filters?.filter((filter: any) => {
return filter.field && filter.operator return filter.field && filter.operator
}) })
return group.filters.length ? group : null return group.filters?.length ? group : null
}) })
.filter(group => group) .filter((group): group is SearchFilterGroup => !!group)
return update return update
} }

View File

@ -1,7 +1,6 @@
import { import {
checkBuilderEndpoint, checkBuilderEndpoint,
getAllTableRows, getAllTableRows,
clearAllAutomations,
testAutomation, testAutomation,
} from "./utilities/TestFunctions" } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
@ -12,9 +11,9 @@ import {
import { configs, context, events } from "@budibase/backend-core" import { configs, context, events } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { import {
Automation,
ConfigType, ConfigType,
FieldType, FieldType,
isDidNotTriggerResponse,
SettingsConfig, SettingsConfig,
Table, Table,
} from "@budibase/types" } from "@budibase/types"
@ -22,11 +21,13 @@ import { mocks } from "@budibase/backend-core/tests"
import { removeDeprecated } from "../../../automations/utils" import { removeDeprecated } from "../../../automations/utils"
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
import { automations } from "@budibase/shared-core" import { automations } from "@budibase/shared-core"
import { basicTable } from "../../../tests/utilities/structures"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
const FilterConditions = automations.steps.filter.FilterConditions const FilterConditions = automations.steps.filter.FilterConditions
const MAX_RETRIES = 4 const MAX_RETRIES = 4
let { const {
basicAutomation, basicAutomation,
newAutomation, newAutomation,
automationTrigger, automationTrigger,
@ -37,10 +38,11 @@ let {
} = setup.structures } = setup.structures
describe("/automations", () => { describe("/automations", () => {
let request = setup.getRequest() const config = new TestConfiguration()
let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(() => {
config.end()
})
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
@ -52,40 +54,26 @@ describe("/automations", () => {
describe("get definitions", () => { describe("get definitions", () => {
it("returns a list of definitions for actions", async () => { it("returns a list of definitions for actions", async () => {
const res = await request const res = await config.api.automation.getActions()
.get(`/api/automations/action/list`) expect(Object.keys(res).length).not.toEqual(0)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
}) })
it("returns a list of definitions for triggerInfo", async () => { it("returns a list of definitions for triggerInfo", async () => {
const res = await request const res = await config.api.automation.getTriggers()
.get(`/api/automations/trigger/list`) expect(Object.keys(res).length).not.toEqual(0)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(Object.keys(res.body).length).not.toEqual(0)
}) })
it("returns all of the definitions in one", async () => { it("returns all of the definitions in one", async () => {
const res = await request const { action, trigger } = await config.api.automation.getDefinitions()
.get(`/api/automations/definitions/list`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
let definitionsLength = Object.keys( let definitionsLength = Object.keys(
removeDeprecated(BUILTIN_ACTION_DEFINITIONS) removeDeprecated(BUILTIN_ACTION_DEFINITIONS)
).length ).length
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual( expect(Object.keys(action).length).toBeGreaterThanOrEqual(
definitionsLength definitionsLength
) )
expect(Object.keys(res.body.trigger).length).toEqual( expect(Object.keys(trigger).length).toEqual(
Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length
) )
}) })
@ -93,38 +81,27 @@ describe("/automations", () => {
describe("create", () => { describe("create", () => {
it("creates an automation with no steps", async () => { it("creates an automation with no steps", async () => {
const automation = newAutomation() const { message, automation } = await config.api.automation.post(
automation.definition.steps = [] newAutomation({ steps: [] })
)
const res = await request expect(message).toEqual("Automation created successfully")
.post(`/api/automations`) expect(automation.name).toEqual("My Automation")
.set(config.defaultHeaders()) expect(automation._id).not.toEqual(null)
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully")
expect(res.body.automation.name).toEqual("My Automation")
expect(res.body.automation._id).not.toEqual(null)
expect(events.automation.created).toHaveBeenCalledTimes(1) expect(events.automation.created).toHaveBeenCalledTimes(1)
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
}) })
it("creates an automation with steps", async () => { it("creates an automation with steps", async () => {
const automation = newAutomation()
automation.definition.steps.push(automationStep())
jest.clearAllMocks() jest.clearAllMocks()
const res = await request const { message, automation } = await config.api.automation.post(
.post(`/api/automations`) newAutomation({ steps: [automationStep(), automationStep()] })
.set(config.defaultHeaders()) )
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Automation created successfully") expect(message).toEqual("Automation created successfully")
expect(res.body.automation.name).toEqual("My Automation") expect(automation.name).toEqual("My Automation")
expect(res.body.automation._id).not.toEqual(null) expect(automation._id).not.toEqual(null)
expect(events.automation.created).toHaveBeenCalledTimes(1) expect(events.automation.created).toHaveBeenCalledTimes(1)
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2) expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
}) })
@ -241,13 +218,9 @@ describe("/automations", () => {
describe("find", () => { describe("find", () => {
it("should be able to find the automation", async () => { it("should be able to find the automation", async () => {
const automation = await config.createAutomation() const automation = await config.createAutomation()
const res = await request const { _id, _rev } = await config.api.automation.get(automation._id!)
.get(`/api/automations/${automation._id}`) expect(_id).toEqual(automation._id)
.set(config.defaultHeaders()) expect(_rev).toEqual(automation._rev)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toEqual(automation._id)
expect(res.body._rev).toEqual(automation._rev)
}) })
}) })
@ -348,106 +321,104 @@ describe("/automations", () => {
describe("trigger", () => { describe("trigger", () => {
it("does not trigger an automation when not synchronous and in dev", async () => { it("does not trigger an automation when not synchronous and in dev", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation) await config.api.automation.trigger(
const res = await request automation._id!,
.post(`/api/automations/${automation._id}/trigger`) {
.set(config.defaultHeaders()) fields: {},
.expect("Content-Type", /json/) timeout: 1000,
.expect(400) },
{
expect(res.body.message).toEqual( status: 400,
"Only apps in production support this endpoint" body: {
message: "Only apps in production support this endpoint",
},
}
) )
}) })
it("triggers a synchronous automation", async () => { it("triggers a synchronous automation", async () => {
mocks.licenses.useSyncAutomations() mocks.licenses.useSyncAutomations()
let automation = collectAutomation() const { automation } = await config.api.automation.post(
automation = await config.createAutomation(automation) collectAutomation()
const res = await request )
.post(`/api/automations/${automation._id}/trigger`) await config.api.automation.trigger(
.set(config.defaultHeaders()) automation._id!,
.expect("Content-Type", /json/) {
.expect(200) fields: {},
timeout: 1000,
expect(res.body.success).toEqual(true) },
expect(res.body.value).toEqual([1, 2, 3]) {
status: 200,
body: {
success: true,
value: [1, 2, 3],
},
}
)
}) })
it("should throw an error when attempting to trigger a disabled automation", async () => { it("should throw an error when attempting to trigger a disabled automation", async () => {
mocks.licenses.useSyncAutomations() mocks.licenses.useSyncAutomations()
let automation = collectAutomation() const { automation } = await config.api.automation.post(
automation = await config.createAutomation({ collectAutomation({ disabled: true })
...automation, )
disabled: true,
})
const res = await request await config.api.automation.trigger(
.post(`/api/automations/${automation._id}/trigger`) automation._id!,
.set(config.defaultHeaders()) {
.expect("Content-Type", /json/) fields: {},
.expect(400) timeout: 1000,
},
expect(res.body.message).toEqual("Automation is disabled") {
status: 400,
body: {
message: "Automation is disabled",
},
}
)
}) })
it("triggers an asynchronous automation", async () => { it("triggers an asynchronous automation", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
await config.publish() await config.publish()
const res = await request await config.withProdApp(() =>
.post(`/api/automations/${automation._id}/trigger`) config.api.automation.trigger(
.set(config.defaultHeaders({}, true)) automation._id!,
.expect("Content-Type", /json/) {
.expect(200) fields: {},
timeout: 1000,
expect(res.body.message).toEqual( },
`Automation ${automation._id} has been triggered.` {
status: 200,
body: {
message: `Automation ${automation._id} has been triggered.`,
},
}
)
) )
}) })
}) })
describe("update", () => { describe("update", () => {
const update = async (automation: Automation) => {
return request
.put(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
}
const updateWithPost = async (automation: Automation) => {
return request
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(automation)
.expect("Content-Type", /json/)
.expect(200)
}
it("updates a automations name", async () => { it("updates a automations name", async () => {
const automation = await config.createAutomation(newAutomation()) const { automation } = await config.api.automation.post(basicAutomation())
automation.name = "Updated Name" automation.name = "Updated Name"
jest.clearAllMocks() jest.clearAllMocks()
const res = await update(automation) const { automation: updatedAutomation, message } =
await config.api.automation.update(automation)
const automationRes = res.body.automation expect(updatedAutomation._id).toEqual(automation._id)
const message = res.body.message expect(updatedAutomation._rev).toBeDefined()
expect(updatedAutomation._rev).not.toEqual(automation._rev)
// doc attributes expect(updatedAutomation.name).toEqual("Updated Name")
expect(automationRes._id).toEqual(automation._id)
expect(automationRes._rev).toBeDefined()
expect(automationRes._rev).not.toEqual(automation._rev)
// content updates
expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual( expect(message).toEqual(
`Automation ${automation._id} updated successfully.` `Automation ${automation._id} updated successfully.`
) )
// events
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -455,26 +426,23 @@ describe("/automations", () => {
}) })
it("updates a automations name using POST request", async () => { it("updates a automations name using POST request", async () => {
const automation = await config.createAutomation(newAutomation()) const { automation } = await config.api.automation.post(basicAutomation())
automation.name = "Updated Name" automation.name = "Updated Name"
jest.clearAllMocks() jest.clearAllMocks()
// the POST request will defer to the update // the POST request will defer to the update when an id has been supplied.
// when an id has been supplied. const { automation: updatedAutomation, message } =
const res = await updateWithPost(automation) await config.api.automation.post(automation)
const automationRes = res.body.automation expect(updatedAutomation._id).toEqual(automation._id)
const message = res.body.message expect(updatedAutomation._rev).toBeDefined()
// doc attributes expect(updatedAutomation._rev).not.toEqual(automation._rev)
expect(automationRes._id).toEqual(automation._id)
expect(automationRes._rev).toBeDefined() expect(updatedAutomation.name).toEqual("Updated Name")
expect(automationRes._rev).not.toEqual(automation._rev)
// content updates
expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual( expect(message).toEqual(
`Automation ${automation._id} updated successfully.` `Automation ${automation._id} updated successfully.`
) )
// events
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -482,16 +450,14 @@ describe("/automations", () => {
}) })
it("updates an automation trigger", async () => { it("updates an automation trigger", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
automation.definition.trigger = automationTrigger( automation.definition.trigger = automationTrigger(
TRIGGER_DEFINITIONS.WEBHOOK TRIGGER_DEFINITIONS.WEBHOOK
) )
jest.clearAllMocks() jest.clearAllMocks()
await update(automation) await config.api.automation.update(automation)
// events
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -499,16 +465,13 @@ describe("/automations", () => {
}) })
it("adds automation steps", async () => { it("adds automation steps", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
automation.definition.steps.push(automationStep()) automation.definition.steps.push(automationStep())
automation.definition.steps.push(automationStep()) automation.definition.steps.push(automationStep())
jest.clearAllMocks() jest.clearAllMocks()
// check the post request honours updates with same id await config.api.automation.update(automation)
await update(automation)
// events
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2) expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.stepDeleted).not.toHaveBeenCalled() expect(events.automation.stepDeleted).not.toHaveBeenCalled()
@ -516,32 +479,25 @@ describe("/automations", () => {
}) })
it("removes automation steps", async () => { it("removes automation steps", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation.definition.steps.push(automationStep())
automation = await config.createAutomation(automation)
automation.definition.steps = [] automation.definition.steps = []
jest.clearAllMocks() jest.clearAllMocks()
// check the post request honours updates with same id await config.api.automation.update(automation)
await update(automation)
// events expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(2)
expect(events.automation.stepCreated).not.toHaveBeenCalled() expect(events.automation.stepCreated).not.toHaveBeenCalled()
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
expect(events.automation.triggerUpdated).not.toHaveBeenCalled() expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
}) })
it("adds and removes automation steps", async () => { it("adds and removes automation steps", async () => {
let automation = newAutomation() const { automation } = await config.api.automation.post(newAutomation())
automation = await config.createAutomation(automation)
automation.definition.steps = [automationStep(), automationStep()] automation.definition.steps = [automationStep(), automationStep()]
jest.clearAllMocks() jest.clearAllMocks()
// check the post request honours updates with same id await config.api.automation.update(automation)
await update(automation)
// events
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2) expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1) expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
expect(events.automation.created).not.toHaveBeenCalled() expect(events.automation.created).not.toHaveBeenCalled()
@ -551,16 +507,24 @@ describe("/automations", () => {
describe("fetch", () => { describe("fetch", () => {
it("return all the automations for an instance", async () => { it("return all the automations for an instance", async () => {
await clearAllAutomations(config) const fetchResponse = await config.api.automation.fetch()
const autoConfig = await config.createAutomation(basicAutomation()) for (const auto of fetchResponse.automations) {
const res = await request await config.api.automation.delete(auto)
.get(`/api/automations`) }
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.automations[0]).toEqual( const { automation: automation1 } = await config.api.automation.post(
expect.objectContaining(autoConfig) newAutomation()
)
const { automation: automation2 } = await config.api.automation.post(
newAutomation()
)
const { automation: automation3 } = await config.api.automation.post(
newAutomation()
)
const { automations } = await config.api.automation.fetch()
expect(automations).toEqual(
expect.arrayContaining([automation1, automation2, automation3])
) )
}) })
@ -575,29 +539,25 @@ describe("/automations", () => {
describe("destroy", () => { describe("destroy", () => {
it("deletes a automation by its ID", async () => { it("deletes a automation by its ID", async () => {
const automation = await config.createAutomation() const { automation } = await config.api.automation.post(newAutomation())
const res = await request const { id } = await config.api.automation.delete(automation)
.delete(`/api/automations/${automation._id}/${automation._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(automation._id) expect(id).toEqual(automation._id)
expect(events.automation.deleted).toHaveBeenCalledTimes(1) expect(events.automation.deleted).toHaveBeenCalledTimes(1)
}) })
it("cannot delete a row action automation", async () => { it("cannot delete a row action automation", async () => {
const automation = await config.createAutomation( const { automation } = await config.api.automation.post(
setup.structures.rowActionAutomation() setup.structures.rowActionAutomation()
) )
await request
.delete(`/api/automations/${automation._id}/${automation._rev}`) await config.api.automation.delete(automation, {
.set(config.defaultHeaders()) status: 422,
.expect("Content-Type", /json/) body: {
.expect(422, {
message: "Row actions automations cannot be deleted", message: "Row actions automations cannot be deleted",
status: 422, status: 422,
}) },
})
expect(events.automation.deleted).not.toHaveBeenCalled() expect(events.automation.deleted).not.toHaveBeenCalled()
}) })
@ -614,10 +574,19 @@ describe("/automations", () => {
describe("checkForCollectStep", () => { describe("checkForCollectStep", () => {
it("should return true if a collect step exists in an automation", async () => { it("should return true if a collect step exists in an automation", async () => {
let automation = collectAutomation() const { automation } = await config.api.automation.post(
await config.createAutomation(automation) collectAutomation()
let res = await sdk.automations.utils.checkForCollectStep(automation) )
expect(res).toEqual(true) expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
true
)
})
it("should return false if a collect step does not exist in an automation", async () => {
const { automation } = await config.api.automation.post(newAutomation())
expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
false
)
}) })
}) })
@ -628,28 +597,45 @@ describe("/automations", () => {
])( ])(
"triggers an update row automation and compares new to old rows with old city '%s' and new city '%s'", "triggers an update row automation and compares new to old rows with old city '%s' and new city '%s'",
async ({ oldCity, newCity }) => { async ({ oldCity, newCity }) => {
const expectedResult = oldCity === newCity let table = await config.api.table.save(basicTable())
let table = await config.createTable() const { automation } = await config.api.automation.post(
filterAutomation({
definition: {
trigger: {
inputs: {
tableId: table._id,
},
},
steps: [
{
inputs: {
condition: FilterConditions.EQUAL,
field: "{{ trigger.row.City }}",
value: "{{ trigger.oldRow.City }}",
},
},
],
},
})
)
let automation = await filterAutomation(config.getAppId()) const res = await config.api.automation.test(automation._id!, {
automation.definition.trigger.inputs.tableId = table._id fields: {},
automation.definition.steps[0].inputs = {
condition: FilterConditions.EQUAL,
field: "{{ trigger.row.City }}",
value: "{{ trigger.oldRow.City }}",
}
automation = await config.createAutomation(automation)
let triggerInputs = {
oldRow: { oldRow: {
City: oldCity, City: oldCity,
}, },
row: { row: {
City: newCity, City: newCity,
}, },
})
if (isDidNotTriggerResponse(res)) {
throw new Error("Automation did not trigger")
} }
const res = await testAutomation(config, automation, triggerInputs)
expect(res.body.steps[1].outputs.result).toEqual(expectedResult) const expectedResult = oldCity === newCity
expect(res.steps[1].outputs.result).toEqual(expectedResult)
} }
) )
}) })
@ -657,16 +643,18 @@ describe("/automations", () => {
let table: Table let table: Table
beforeAll(async () => { beforeAll(async () => {
table = await config.createTable({ table = await config.api.table.save(
name: "table", basicTable(undefined, {
type: "table", name: "table",
schema: { type: "table",
Approved: { schema: {
name: "Approved", Approved: {
type: FieldType.BOOLEAN, name: "Approved",
type: FieldType.BOOLEAN,
},
}, },
}, })
}) )
}) })
const testCases = [ const testCases = [
@ -712,33 +700,29 @@ describe("/automations", () => {
it.each(testCases)( it.each(testCases)(
"$description", "$description",
async ({ filters, row, oldRow, expectToRun }) => { async ({ filters, row, oldRow, expectToRun }) => {
let automation = await updateRowAutomationWithFilters( let req = updateRowAutomationWithFilters(config.getAppId(), table._id!)
config.getAppId(), req.definition.trigger.inputs = {
table._id!
)
automation.definition.trigger.inputs = {
tableId: table._id, tableId: table._id,
filters, filters,
} }
automation = await config.createAutomation(automation)
const inputs = { const { automation } = await config.api.automation.post(req)
row: { const res = await config.api.automation.test(automation._id!, {
tableId: table._id, fields: {},
...row,
},
oldRow: { oldRow: {
tableId: table._id, tableId: table._id,
...oldRow, ...oldRow,
}, },
} row: {
tableId: table._id,
...row,
},
})
const res = await testAutomation(config, automation, inputs) if (isDidNotTriggerResponse(res)) {
expect(expectToRun).toEqual(false)
if (expectToRun) {
expect(res.body.steps[1].outputs.success).toEqual(true)
} else { } else {
expect(res.body.outputs.success).toEqual(false) expect(res.steps[1].outputs.success).toEqual(expectToRun)
} }
} }
) )

View File

@ -53,15 +53,6 @@ export const clearAllApps = async (
}) })
} }
export const clearAllAutomations = async (config: TestConfiguration) => {
const { automations } = await config.getAllAutomations()
for (let auto of automations) {
await context.doInAppContext(config.getAppId(), async () => {
await config.deleteAutomation(auto)
})
}
}
export const wipeDb = async () => { export const wipeDb = async () => {
const couchInfo = db.getCouchInfo() const couchInfo = db.getCouchInfo()
const nano = Nano({ const nano = Nano({

View File

@ -258,7 +258,7 @@ export default class TestConfiguration {
} }
} }
async withApp(app: App | string, f: () => Promise<void>) { async withApp<R>(app: App | string, f: () => Promise<R>) {
const oldAppId = this.appId const oldAppId = this.appId
this.appId = typeof app === "string" ? app : app.appId this.appId = typeof app === "string" ? app : app.appId
try { try {
@ -268,6 +268,10 @@ export default class TestConfiguration {
} }
} }
async withProdApp<R>(f: () => Promise<R>) {
return await this.withApp(this.getProdAppId(), f)
}
// UTILS // UTILS
_req<Req extends Record<string, any> | void, Res>( _req<Req extends Record<string, any> | void, Res>(

View File

@ -1,8 +1,17 @@
import { import {
Automation, Automation,
CreateAutomationResponse,
DeleteAutomationResponse,
FetchAutomationResponse, FetchAutomationResponse,
GetAutomationActionDefinitionsResponse,
GetAutomationStepDefinitionsResponse,
GetAutomationTriggerDefinitionsResponse,
TestAutomationRequest, TestAutomationRequest,
TestAutomationResponse, TestAutomationResponse,
TriggerAutomationRequest,
TriggerAutomationResponse,
UpdateAutomationRequest,
UpdateAutomationResponse,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -20,6 +29,39 @@ export class AutomationAPI extends TestAPI {
return result return result
} }
getActions = async (
expectations?: Expectations
): Promise<GetAutomationActionDefinitionsResponse> => {
return await this._get<GetAutomationActionDefinitionsResponse>(
`/api/automations/actions/list`,
{
expectations,
}
)
}
getTriggers = async (
expectations?: Expectations
): Promise<GetAutomationTriggerDefinitionsResponse> => {
return await this._get<GetAutomationTriggerDefinitionsResponse>(
`/api/automations/triggers/list`,
{
expectations,
}
)
}
getDefinitions = async (
expectations?: Expectations
): Promise<GetAutomationStepDefinitionsResponse> => {
return await this._get<GetAutomationStepDefinitionsResponse>(
`/api/automations/definitions/list`,
{
expectations,
}
)
}
fetch = async ( fetch = async (
expectations?: Expectations expectations?: Expectations
): Promise<FetchAutomationResponse> => { ): Promise<FetchAutomationResponse> => {
@ -31,11 +73,14 @@ export class AutomationAPI extends TestAPI {
post = async ( post = async (
body: Automation, body: Automation,
expectations?: Expectations expectations?: Expectations
): Promise<Automation> => { ): Promise<CreateAutomationResponse> => {
const result = await this._post<Automation>(`/api/automations`, { const result = await this._post<CreateAutomationResponse>(
body, `/api/automations`,
expectations, {
}) body,
expectations,
}
)
return result return result
} }
@ -52,4 +97,40 @@ export class AutomationAPI extends TestAPI {
} }
) )
} }
trigger = async (
id: string,
body: TriggerAutomationRequest,
expectations?: Expectations
): Promise<TriggerAutomationResponse> => {
return await this._post<TriggerAutomationResponse>(
`/api/automations/${id}/trigger`,
{
expectations,
body,
}
)
}
update = async (
body: UpdateAutomationRequest,
expectations?: Expectations
): Promise<UpdateAutomationResponse> => {
return await this._put<UpdateAutomationResponse>(`/api/automations`, {
body,
expectations,
})
}
delete = async (
automation: Automation,
expectations?: Expectations
): Promise<DeleteAutomationResponse> => {
return await this._delete<DeleteAutomationResponse>(
`/api/automations/${automation._id!}/${automation._rev!}`,
{
expectations,
}
)
}
} }

View File

@ -19,43 +19,43 @@ import { PluginAPI } from "./plugin"
import { WebhookAPI } from "./webhook" import { WebhookAPI } from "./webhook"
export default class API { export default class API {
table: TableAPI
legacyView: LegacyViewAPI
viewV2: ViewV2API
row: RowAPI
permission: PermissionAPI
datasource: DatasourceAPI
screen: ScreenAPI
application: ApplicationAPI application: ApplicationAPI
backup: BackupAPI
attachment: AttachmentAPI attachment: AttachmentAPI
user: UserAPI automation: AutomationAPI
backup: BackupAPI
datasource: DatasourceAPI
legacyView: LegacyViewAPI
permission: PermissionAPI
plugin: PluginAPI
query: QueryAPI query: QueryAPI
roles: RoleAPI roles: RoleAPI
templates: TemplateAPI row: RowAPI
rowAction: RowActionAPI rowAction: RowActionAPI
automation: AutomationAPI screen: ScreenAPI
plugin: PluginAPI table: TableAPI
templates: TemplateAPI
user: UserAPI
viewV2: ViewV2API
webhook: WebhookAPI webhook: WebhookAPI
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
this.legacyView = new LegacyViewAPI(config)
this.viewV2 = new ViewV2API(config)
this.row = new RowAPI(config)
this.permission = new PermissionAPI(config)
this.datasource = new DatasourceAPI(config)
this.screen = new ScreenAPI(config)
this.application = new ApplicationAPI(config) this.application = new ApplicationAPI(config)
this.backup = new BackupAPI(config)
this.attachment = new AttachmentAPI(config) this.attachment = new AttachmentAPI(config)
this.user = new UserAPI(config) this.automation = new AutomationAPI(config)
this.backup = new BackupAPI(config)
this.datasource = new DatasourceAPI(config)
this.legacyView = new LegacyViewAPI(config)
this.permission = new PermissionAPI(config)
this.plugin = new PluginAPI(config)
this.query = new QueryAPI(config) this.query = new QueryAPI(config)
this.roles = new RoleAPI(config) this.roles = new RoleAPI(config)
this.templates = new TemplateAPI(config) this.row = new RowAPI(config)
this.rowAction = new RowActionAPI(config) this.rowAction = new RowActionAPI(config)
this.automation = new AutomationAPI(config) this.screen = new ScreenAPI(config)
this.plugin = new PluginAPI(config) this.table = new TableAPI(config)
this.templates = new TemplateAPI(config)
this.user = new UserAPI(config)
this.viewV2 = new ViewV2API(config)
this.webhook = new WebhookAPI(config) this.webhook = new WebhookAPI(config)
} }
} }

View File

@ -34,6 +34,7 @@ import {
Webhook, Webhook,
WebhookActionType, WebhookActionType,
BuiltinPermissionID, BuiltinPermissionID,
DeepPartial,
} from "@budibase/types" } from "@budibase/types"
import { LoopInput } from "../../definitions/automations" import { LoopInput } from "../../definitions/automations"
import { merge } from "lodash" import { merge } from "lodash"
@ -184,21 +185,12 @@ export function newAutomation({
steps, steps,
trigger, trigger,
}: { steps?: AutomationStep[]; trigger?: AutomationTrigger } = {}) { }: { steps?: AutomationStep[]; trigger?: AutomationTrigger } = {}) {
const automation = basicAutomation() return basicAutomation({
definition: {
if (trigger) { steps: steps || [automationStep()],
automation.definition.trigger = trigger trigger: trigger || automationTrigger(),
} else { },
automation.definition.trigger = automationTrigger() })
}
if (steps) {
automation.definition.steps = steps
} else {
automation.definition.steps = [automationStep()]
}
return automation
} }
export function rowActionAutomation() { export function rowActionAutomation() {
@ -211,8 +203,8 @@ export function rowActionAutomation() {
return automation return automation
} }
export function basicAutomation(appId?: string): Automation { export function basicAutomation(opts?: DeepPartial<Automation>): Automation {
return { const baseAutomation: Automation = {
name: "My Automation", name: "My Automation",
screenId: "kasdkfldsafkl", screenId: "kasdkfldsafkl",
live: true, live: true,
@ -241,8 +233,9 @@ export function basicAutomation(appId?: string): Automation {
steps: [], steps: [],
}, },
type: "automation", type: "automation",
appId: appId!, appId: "appId",
} }
return merge(baseAutomation, opts)
} }
export function basicCronAutomation(appId: string, cron: string): Automation { export function basicCronAutomation(appId: string, cron: string): Automation {
@ -387,16 +380,21 @@ export function loopAutomation(
return automation as Automation return automation as Automation
} }
export function collectAutomation(tableId?: string): Automation { export function collectAutomation(opts?: DeepPartial<Automation>): Automation {
const automation: any = { const baseAutomation: Automation = {
appId: "appId",
name: "looping", name: "looping",
type: "automation", type: "automation",
definition: { definition: {
steps: [ steps: [
{ {
id: "b", id: "b",
type: "ACTION", name: "b",
tagline: "An automation action step",
icon: "Icon",
type: AutomationStepType.ACTION,
internal: true, internal: true,
description: "Execute script",
stepId: AutomationActionStepId.EXECUTE_SCRIPT, stepId: AutomationActionStepId.EXECUTE_SCRIPT,
inputs: { inputs: {
code: "return [1,2,3]", code: "return [1,2,3]",
@ -405,8 +403,12 @@ export function collectAutomation(tableId?: string): Automation {
}, },
{ {
id: "c", id: "c",
type: "ACTION", name: "c",
type: AutomationStepType.ACTION,
tagline: "An automation action step",
icon: "Icon",
internal: true, internal: true,
description: "Collect",
stepId: AutomationActionStepId.COLLECT, stepId: AutomationActionStepId.COLLECT,
inputs: { inputs: {
collection: "{{ literal steps.1.value }}", collection: "{{ literal steps.1.value }}",
@ -416,24 +418,28 @@ export function collectAutomation(tableId?: string): Automation {
], ],
trigger: { trigger: {
id: "a", id: "a",
type: "TRIGGER", type: AutomationStepType.TRIGGER,
event: AutomationEventType.ROW_SAVE, event: AutomationEventType.ROW_SAVE,
stepId: AutomationTriggerStepId.ROW_SAVED, stepId: AutomationTriggerStepId.ROW_SAVED,
name: "trigger Step",
tagline: "An automation trigger",
description: "A trigger",
icon: "Icon",
inputs: { inputs: {
tableId, tableId: "tableId",
}, },
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema, schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
}, },
}, },
} }
return automation return merge(baseAutomation, opts)
} }
export function filterAutomation(appId: string, tableId?: string): Automation { export function filterAutomation(opts?: DeepPartial<Automation>): Automation {
const automation: Automation = { const automation: Automation = {
name: "looping", name: "looping",
type: "automation", type: "automation",
appId, appId: "appId",
definition: { definition: {
steps: [ steps: [
{ {
@ -459,13 +465,13 @@ export function filterAutomation(appId: string, tableId?: string): Automation {
event: AutomationEventType.ROW_SAVE, event: AutomationEventType.ROW_SAVE,
stepId: AutomationTriggerStepId.ROW_SAVED, stepId: AutomationTriggerStepId.ROW_SAVED,
inputs: { inputs: {
tableId: tableId!, tableId: "tableId",
}, },
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema, schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
}, },
}, },
} }
return automation return merge(automation, opts)
} }
export function updateRowAutomationWithFilters( export function updateRowAutomationWithFilters(

View File

@ -75,6 +75,7 @@ export interface TestAutomationRequest {
revision?: string revision?: string
fields: Record<string, any> fields: Record<string, any>
row?: Row row?: Row
oldRow?: Row
} }
export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse