Merge branch 'master' into BUDI-9011

This commit is contained in:
Mike Sealey 2025-02-19 09:28:21 +00:00 committed by GitHub
commit cd967fd086
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 305 additions and 186 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.4.11", "version": "3.4.12",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

View File

@ -3,6 +3,7 @@ import { newid } from "../utils"
import { Queue, QueueOptions, JobOptions } from "./queue" import { Queue, QueueOptions, JobOptions } from "./queue"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { Job, JobId, JobInformation } from "bull" import { Job, JobId, JobInformation } from "bull"
import { cloneDeep } from "lodash"
function jobToJobInformation(job: Job): JobInformation { function jobToJobInformation(job: Job): JobInformation {
let cron = "" let cron = ""
@ -33,12 +34,13 @@ function jobToJobInformation(job: Job): JobInformation {
} }
} }
interface JobMessage<T = any> extends Partial<Job<T>> { export interface TestQueueMessage<T = any> extends Partial<Job<T>> {
id: string id: string
timestamp: number timestamp: number
queue: Queue<T> queue: Queue<T>
data: any data: any
opts?: JobOptions opts?: JobOptions
manualTrigger?: boolean
} }
/** /**
@ -47,15 +49,15 @@ interface JobMessage<T = any> extends Partial<Job<T>> {
* internally to register when messages are available to the consumers - in can * internally to register when messages are available to the consumers - in can
* support many inputs and many consumers. * support many inputs and many consumers.
*/ */
class InMemoryQueue implements Partial<Queue> { export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
_name: string _name: string
_opts?: QueueOptions _opts?: QueueOptions
_messages: JobMessage[] _messages: TestQueueMessage<T>[]
_queuedJobIds: Set<string> _queuedJobIds: Set<string>
_emitter: NodeJS.EventEmitter<{ _emitter: NodeJS.EventEmitter<{
message: [JobMessage] message: [TestQueueMessage<T>]
completed: [Job] completed: [Job<T>]
removed: [JobMessage] removed: [TestQueueMessage<T>]
}> }>
_runCount: number _runCount: number
_addCount: number _addCount: number
@ -86,10 +88,12 @@ class InMemoryQueue implements Partial<Queue> {
*/ */
async process(concurrencyOrFunc: number | any, func?: any) { async process(concurrencyOrFunc: number | any, func?: any) {
func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc
this._emitter.on("message", async message => { this._emitter.on("message", async msg => {
const message = cloneDeep(msg)
// For the purpose of testing, don't trigger cron jobs immediately. // For the purpose of testing, don't trigger cron jobs immediately.
// Require the test to trigger them manually with timestamps. // Require the test to trigger them manually with timestamps.
if (message.opts?.repeat != null) { if (!message.manualTrigger && message.opts?.repeat != null) {
return return
} }
@ -107,7 +111,7 @@ class InMemoryQueue implements Partial<Queue> {
if (resp.then != null) { if (resp.then != null) {
try { try {
await retryFunc(resp) await retryFunc(resp)
this._emitter.emit("completed", message as Job) this._emitter.emit("completed", message as Job<T>)
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
} }
@ -124,7 +128,6 @@ class InMemoryQueue implements Partial<Queue> {
return this as any return this as any
} }
// simply puts a message to the queue and emits to the queue for processing
/** /**
* Simple function to replicate the add message functionality of Bull, putting * Simple function to replicate the add message functionality of Bull, putting
* a new message on the queue. This then emits an event which will be used to * a new message on the queue. This then emits an event which will be used to
@ -133,7 +136,13 @@ class InMemoryQueue implements Partial<Queue> {
* a JSON message as this is required by Bull. * a JSON message as this is required by Bull.
* @param repeat serves no purpose for the import queue. * @param repeat serves no purpose for the import queue.
*/ */
async add(data: any, opts?: JobOptions) { async add(data: T | string, optsOrT?: JobOptions | T) {
if (typeof data === "string") {
throw new Error("doesn't support named jobs")
}
const opts = optsOrT as JobOptions
const jobId = opts?.jobId?.toString() const jobId = opts?.jobId?.toString()
if (jobId && this._queuedJobIds.has(jobId)) { if (jobId && this._queuedJobIds.has(jobId)) {
console.log(`Ignoring already queued job ${jobId}`) console.log(`Ignoring already queued job ${jobId}`)
@ -148,7 +157,7 @@ class InMemoryQueue implements Partial<Queue> {
} }
const pushMessage = () => { const pushMessage = () => {
const message: JobMessage = { const message: TestQueueMessage = {
id: newid(), id: newid(),
timestamp: Date.now(), timestamp: Date.now(),
queue: this as unknown as Queue, queue: this as unknown as Queue,
@ -176,7 +185,7 @@ class InMemoryQueue implements Partial<Queue> {
async removeRepeatableByKey(id: string) { async removeRepeatableByKey(id: string) {
for (const [idx, message] of this._messages.entries()) { for (const [idx, message] of this._messages.entries()) {
if (message.opts?.jobId?.toString() === id) { if (message.id === id) {
this._messages.splice(idx, 1) this._messages.splice(idx, 1)
this._emitter.emit("removed", message) this._emitter.emit("removed", message)
return return
@ -204,6 +213,16 @@ class InMemoryQueue implements Partial<Queue> {
return null return null
} }
manualTrigger(id: JobId) {
for (const message of this._messages) {
if (message.id === id) {
this._emitter.emit("message", { ...message, manualTrigger: true })
return
}
}
throw new Error(`Job with id ${id} not found`)
}
on(event: string, callback: (...args: any[]) => void): Queue { on(event: string, callback: (...args: any[]) => void): Queue {
// @ts-expect-error - this callback can be one of many types // @ts-expect-error - this callback can be one of many types
this._emitter.on(event, callback) this._emitter.on(event, callback)

View File

@ -1,2 +1,3 @@
export * from "./queue" export * from "./queue"
export * from "./constants" export * from "./constants"
export * from "./inMemoryQueue"

View File

@ -2,7 +2,7 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { Input, Label } from "@budibase/bbui" import { Input, Label } from "@budibase/bbui"
import { previewStore, selectedScreen } from "@/stores/builder" import { previewStore, selectedScreen } from "@/stores/builder"
import type { ComponentContext } from "@budibase/types" import type { AppContext } from "@budibase/types"
export let baseRoute = "" export let baseRoute = ""
@ -31,7 +31,7 @@
// This function is needed to repopulate the test value from componentContext // This function is needed to repopulate the test value from componentContext
// when a user navigates to another component and then back again // when a user navigates to another component and then back again
const updateTestValueFromContext = (context: ComponentContext | null) => { const updateTestValueFromContext = (context: AppContext | null) => {
if (context?.url && !testValue) { if (context?.url && !testValue) {
const { wild, ...urlParams } = context.url const { wild, ...urlParams } = context.url
const queryParams = context.query const queryParams = context.query

View File

@ -8,27 +8,32 @@ import {
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { capitalise } from "@/helpers" import { capitalise } from "@/helpers"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { Component, ComponentContext } from "@budibase/types"
const { ContextScopes } = Constants const { ContextScopes } = Constants
/** /**
* Recursively searches for a specific component ID * Recursively searches for a specific component ID
*/ */
export const findComponent = (rootComponent, id) => { export const findComponent = (rootComponent: Component, id: string) => {
return searchComponentTree(rootComponent, comp => comp._id === id) return searchComponentTree(rootComponent, comp => comp._id === id)
} }
/** /**
* Recursively searches for a specific component type * Recursively searches for a specific component type
*/ */
export const findComponentType = (rootComponent, type) => { export const findComponentType = (rootComponent: Component, type: string) => {
return searchComponentTree(rootComponent, comp => comp._component === type) return searchComponentTree(rootComponent, comp => comp._component === type)
} }
/** /**
* Recursively searches for the parent component of a specific component ID * Recursively searches for the parent component of a specific component ID
*/ */
export const findComponentParent = (rootComponent, id, parentComponent) => { export const findComponentParent = (
rootComponent: Component | undefined,
id: string | undefined,
parentComponent: Component | null = null
): Component | null => {
if (!rootComponent || !id) { if (!rootComponent || !id) {
return null return null
} }
@ -51,7 +56,11 @@ export const findComponentParent = (rootComponent, id, parentComponent) => {
* Recursively searches for a specific component ID and records the component * Recursively searches for a specific component ID and records the component
* path to this component * path to this component
*/ */
export const findComponentPath = (rootComponent, id, path = []) => { export const findComponentPath = (
rootComponent: Component,
id: string | undefined,
path: Component[] = []
): Component[] => {
if (!rootComponent || !id) { if (!rootComponent || !id) {
return [] return []
} }
@ -75,11 +84,14 @@ export const findComponentPath = (rootComponent, id, path = []) => {
* Recurses through the component tree and finds all components which match * Recurses through the component tree and finds all components which match
* a certain selector * a certain selector
*/ */
export const findAllMatchingComponents = (rootComponent, selector) => { export const findAllMatchingComponents = (
rootComponent: Component | null,
selector: (component: Component) => boolean
) => {
if (!rootComponent || !selector) { if (!rootComponent || !selector) {
return [] return []
} }
let components = [] let components: Component[] = []
if (rootComponent._children) { if (rootComponent._children) {
rootComponent._children.forEach(child => { rootComponent._children.forEach(child => {
components = [ components = [
@ -97,7 +109,7 @@ export const findAllMatchingComponents = (rootComponent, selector) => {
/** /**
* Recurses through the component tree and finds all components. * Recurses through the component tree and finds all components.
*/ */
export const findAllComponents = rootComponent => { export const findAllComponents = (rootComponent: Component) => {
return findAllMatchingComponents(rootComponent, () => true) return findAllMatchingComponents(rootComponent, () => true)
} }
@ -105,9 +117,9 @@ export const findAllComponents = rootComponent => {
* Finds the closest parent component which matches certain criteria * Finds the closest parent component which matches certain criteria
*/ */
export const findClosestMatchingComponent = ( export const findClosestMatchingComponent = (
rootComponent, rootComponent: Component,
componentId, componentId: string | undefined,
selector selector: (component: Component) => boolean
) => { ) => {
if (!selector) { if (!selector) {
return null return null
@ -125,7 +137,10 @@ export const findClosestMatchingComponent = (
* Recurses through a component tree evaluating a matching function against * Recurses through a component tree evaluating a matching function against
* components until a match is found * components until a match is found
*/ */
const searchComponentTree = (rootComponent, matchComponent) => { const searchComponentTree = (
rootComponent: Component,
matchComponent: (component: Component) => boolean
): Component | null => {
if (!rootComponent || !matchComponent) { if (!rootComponent || !matchComponent) {
return null return null
} }
@ -150,15 +165,18 @@ const searchComponentTree = (rootComponent, matchComponent) => {
* This mutates the object in place. * This mutates the object in place.
* @param component the component to randomise * @param component the component to randomise
*/ */
export const makeComponentUnique = component => { export const makeComponentUnique = (component: Component) => {
if (!component) { if (!component) {
return return
} }
// Generate a full set of component ID replacements in this tree // Generate a full set of component ID replacements in this tree
const idReplacements = [] const idReplacements: [string, string][] = []
const generateIdReplacements = (component, replacements) => { const generateIdReplacements = (
const oldId = component._id component: Component,
replacements: [string, string][]
) => {
const oldId = component._id!
const newId = Helpers.uuid() const newId = Helpers.uuid()
replacements.push([oldId, newId]) replacements.push([oldId, newId])
component._children?.forEach(x => generateIdReplacements(x, replacements)) component._children?.forEach(x => generateIdReplacements(x, replacements))
@ -182,9 +200,9 @@ export const makeComponentUnique = component => {
let js = decodeJSBinding(sanitizedBinding) let js = decodeJSBinding(sanitizedBinding)
if (js != null) { if (js != null) {
// Replace ID inside JS binding // Replace ID inside JS binding
idReplacements.forEach(([oldId, newId]) => { for (const [oldId, newId] of idReplacements) {
js = js.replace(new RegExp(oldId, "g"), newId) js = js.replace(new RegExp(oldId, "g"), newId)
}) }
// Create new valid JS binding // Create new valid JS binding
let newBinding = encodeJSBinding(js) let newBinding = encodeJSBinding(js)
@ -204,7 +222,7 @@ export const makeComponentUnique = component => {
return JSON.parse(definition) return JSON.parse(definition)
} }
export const getComponentText = component => { export const getComponentText = (component: Component) => {
if (component == null) { if (component == null) {
return "" return ""
} }
@ -218,7 +236,7 @@ export const getComponentText = component => {
return capitalise(type) return capitalise(type)
} }
export const getComponentName = component => { export const getComponentName = (component: Component) => {
if (component == null) { if (component == null) {
return "" return ""
} }
@ -229,9 +247,9 @@ export const getComponentName = component => {
} }
// Gets all contexts exposed by a certain component type, including actions // Gets all contexts exposed by a certain component type, including actions
export const getComponentContexts = component => { export const getComponentContexts = (component: string) => {
const def = componentStore.getDefinition(component) const def = componentStore.getDefinition(component)
let contexts = [] let contexts: ComponentContext[] = []
if (def?.context) { if (def?.context) {
contexts = Array.isArray(def.context) ? [...def.context] : [def.context] contexts = Array.isArray(def.context) ? [...def.context] : [def.context]
} }
@ -251,9 +269,9 @@ export const getComponentContexts = component => {
* 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.
*/ */
export const buildContextTree = ( const buildContextTree = (
rootComponent, rootComponent: Component,
tree = { root: [] }, tree: Record<string, string[]> = { root: [] },
currentBranch = "root" currentBranch = "root"
) => { ) => {
// Sanity check // Sanity check
@ -264,12 +282,12 @@ export const buildContextTree = (
// Process this component's contexts // Process this component's contexts
const contexts = getComponentContexts(rootComponent._component) const contexts = getComponentContexts(rootComponent._component)
if (contexts.length) { if (contexts.length) {
tree[currentBranch].push(rootComponent._id) tree[currentBranch].push(rootComponent._id!)
// 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)) {
currentBranch = rootComponent._id currentBranch = rootComponent._id!
tree[rootComponent._id] = [] tree[rootComponent._id!] = []
} }
} }
@ -287,9 +305,9 @@ export const buildContextTree = (
* Generates a lookup map of which context branch all components in a component * Generates a lookup map of which context branch all components in a component
* tree are inside. * tree are inside.
*/ */
export const buildContextTreeLookupMap = rootComponent => { export const buildContextTreeLookupMap = (rootComponent: Component) => {
const tree = buildContextTree(rootComponent) const tree = buildContextTree(rootComponent)
let map = {} const map: Record<string, string> = {}
Object.entries(tree).forEach(([branch, ids]) => { Object.entries(tree).forEach(([branch, ids]) => {
ids.forEach(id => { ids.forEach(id => {
map[id] = branch map[id] = branch
@ -299,9 +317,9 @@ export const buildContextTreeLookupMap = rootComponent => {
} }
// Get a flat list of ids for all descendants of a component // Get a flat list of ids for all descendants of a component
export const getChildIdsForComponent = component => { export const getChildIdsForComponent = (component: Component): string[] => {
return [ return [
component._id, component._id!,
...(component?._children ?? []).map(getChildIdsForComponent).flat(1), ...(component?._children ?? []).map(getChildIdsForComponent).flat(1),
] ]
} }

View File

@ -58,7 +58,7 @@ export class ComponentTreeNodesStore extends BudiStore<OpenNodesState> {
const path = findComponentPath(selectedScreen.props, componentId) const path = findComponentPath(selectedScreen.props, componentId)
const componentIds = path.map((component: Component) => component._id) const componentIds = path.map((component: Component) => component._id!)
this.update((openNodes: OpenNodesState) => { this.update((openNodes: OpenNodesState) => {
const newNodes = Object.fromEntries( const newNodes = Object.fromEntries(

View File

@ -1,3 +1,5 @@
// TODO: analise and fix all the undefined ! and ?
import { get, derived } from "svelte/store" import { get, derived } from "svelte/store"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { API } from "@/api" import { API } from "@/api"
@ -36,7 +38,7 @@ import { Utils } from "@budibase/frontend-core"
import { import {
ComponentDefinition, ComponentDefinition,
ComponentSetting, ComponentSetting,
Component as ComponentType, Component,
ComponentCondition, ComponentCondition,
FieldType, FieldType,
Screen, Screen,
@ -45,10 +47,6 @@ import {
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
import { getSequentialName } from "@/helpers/duplicate" import { getSequentialName } from "@/helpers/duplicate"
interface Component extends ComponentType {
_id: string
}
export interface ComponentState { export interface ComponentState {
components: Record<string, ComponentDefinition> components: Record<string, ComponentDefinition>
customComponents: string[] customComponents: string[]
@ -182,7 +180,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
* Takes an enriched component instance and applies any required migration * Takes an enriched component instance and applies any required migration
* logic * logic
*/ */
migrateSettings(enrichedComponent: Component) { migrateSettings(enrichedComponent: Component | null) {
const componentPrefix = "@budibase/standard-components" const componentPrefix = "@budibase/standard-components"
let migrated = false let migrated = false
@ -232,7 +230,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
enrichEmptySettings( enrichEmptySettings(
component: Component, component: Component,
opts: { screen?: Screen; parent?: Component; useDefaultValues?: boolean } opts: { screen?: Screen; parent?: string; useDefaultValues?: boolean }
) { ) {
if (!component?._component) { if (!component?._component) {
return return
@ -240,7 +238,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
const defaultDS = this.getDefaultDatasource() const defaultDS = this.getDefaultDatasource()
const settings = this.getComponentSettings(component._component) const settings = this.getComponentSettings(component._component)
const { parent, screen, useDefaultValues } = opts || {} const { parent, screen, useDefaultValues } = opts || {}
const treeId = parent?._id || component._id const treeId = parent || component._id
if (!screen) { if (!screen) {
return return
} }
@ -425,7 +423,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
createInstance( createInstance(
componentType: string, componentType: string,
presetProps?: Record<string, any>, presetProps?: Record<string, any>,
parent?: Component parent?: string
): Component | null { ): Component | null {
const screen = get(selectedScreen) const screen = get(selectedScreen)
if (!screen) { if (!screen) {
@ -503,7 +501,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
async create( async create(
componentType: string, componentType: string,
presetProps?: Record<string, any>, presetProps?: Record<string, any>,
parent?: Component, parent?: string,
index?: number index?: number
) { ) {
const state = get(this.store) const state = get(this.store)
@ -519,7 +517,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Insert in position if specified // Insert in position if specified
if (parent && index != null) { if (parent && index != null) {
await screenStore.patch((screen: Screen) => { await screenStore.patch((screen: Screen) => {
let parentComponent = findComponent(screen.props, parent) let parentComponent = findComponent(screen.props, parent)!
if (!parentComponent._children?.length) { if (!parentComponent._children?.length) {
parentComponent._children = [componentInstance] parentComponent._children = [componentInstance]
} else { } else {
@ -538,7 +536,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
const currentComponent = findComponent( const currentComponent = findComponent(
screen.props, screen.props,
selectedComponentId selectedComponentId!
) )
if (!currentComponent) { if (!currentComponent) {
return false return false
@ -581,7 +579,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
return state return state
}) })
componentTreeNodesStore.makeNodeVisible(componentInstance._id) componentTreeNodesStore.makeNodeVisible(componentInstance._id!)
// Log event // Log event
analytics.captureEvent(Events.COMPONENT_CREATED, { analytics.captureEvent(Events.COMPONENT_CREATED, {
@ -633,7 +631,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Determine the next component to select, and select it before deletion // Determine the next component to select, and select it before deletion
// to avoid an intermediate state of no component selection // to avoid an intermediate state of no component selection
const state = get(this.store) const state = get(this.store)
let nextId = "" let nextId: string | null = ""
if (state.selectedComponentId === component._id) { if (state.selectedComponentId === component._id) {
nextId = this.getNext() nextId = this.getNext()
if (!nextId) { if (!nextId) {
@ -646,7 +644,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
nextId = nextId.replace("-navigation", "-screen") nextId = nextId.replace("-navigation", "-screen")
} }
this.update(state => { this.update(state => {
state.selectedComponentId = nextId state.selectedComponentId = nextId ?? undefined
return state return state
}) })
} }
@ -654,18 +652,18 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Patch screen // Patch screen
await screenStore.patch((screen: Screen) => { await screenStore.patch((screen: Screen) => {
// Check component exists // Check component exists
component = findComponent(screen.props, component._id) const updatedComponent = findComponent(screen.props, component._id!)
if (!component) { if (!updatedComponent) {
return false return false
} }
// Check component has a valid parent // Check component has a valid parent
const parent = findComponentParent(screen.props, component._id) const parent = findComponentParent(screen.props, updatedComponent._id)
if (!parent) { if (!parent) {
return false return false
} }
parent._children = parent._children.filter( parent._children = parent._children!.filter(
(child: Component) => child._id !== component._id (child: Component) => child._id !== updatedComponent._id
) )
}, null) }, null)
} }
@ -729,7 +727,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Patch screen // Patch screen
const patch = (screen: Screen) => { const patch = (screen: Screen) => {
// Get up to date ref to target // Get up to date ref to target
targetComponent = findComponent(screen.props, targetComponent._id) targetComponent = findComponent(screen.props, targetComponent!._id!)!
if (!targetComponent) { if (!targetComponent) {
return false return false
} }
@ -743,7 +741,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
@ -820,8 +818,8 @@ export class ComponentStore extends BudiStore<ComponentState> {
if (!screen) { if (!screen) {
throw "A valid screen must be selected" throw "A valid screen must be selected"
} }
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)!
const index = parent?._children.findIndex( const index = parent?._children?.findIndex(
(x: Component) => x._id === componentId (x: Component) => x._id === componentId
) )
@ -839,29 +837,29 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
// If we have siblings above us, choose the sibling or a descendant // If we have siblings above us, choose the sibling or a descendant
if (index > 0) { if (index !== undefined && index > 0) {
// If sibling before us accepts children, and is not collapsed, select a descendant // If sibling before us accepts children, and is not collapsed, select a descendant
const previousSibling = parent._children[index - 1] const previousSibling = parent._children![index - 1]
if ( if (
previousSibling._children?.length && previousSibling._children?.length &&
componentTreeNodesStore.isNodeExpanded(previousSibling._id) componentTreeNodesStore.isNodeExpanded(previousSibling._id!)
) { ) {
let target = previousSibling let target = previousSibling
while ( while (
target._children?.length && target._children?.length &&
componentTreeNodesStore.isNodeExpanded(target._id) componentTreeNodesStore.isNodeExpanded(target._id!)
) { ) {
target = target._children[target._children.length - 1] target = target._children[target._children.length - 1]
} }
return target._id return target._id!
} }
// Otherwise just select sibling // Otherwise just select sibling
return previousSibling._id return previousSibling._id!
} }
// If no siblings above us, select the parent // If no siblings above us, select the parent
return parent._id return parent._id!
} }
getNext() { getNext() {
@ -873,9 +871,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
throw "A valid screen must be selected" throw "A valid screen must be selected"
} }
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex( const index = parent?._children?.findIndex(
(x: Component) => x._id === componentId (x: Component) => x._id === componentId
) )!
// Check for screen and navigation component edge cases // Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen` const screenComponentId = `${screen._id}-screen`
@ -888,37 +886,38 @@ export class ComponentStore extends BudiStore<ComponentState> {
if ( if (
component?._children?.length && component?._children?.length &&
(state.selectedComponentId === navComponentId || (state.selectedComponentId === navComponentId ||
componentTreeNodesStore.isNodeExpanded(component._id)) componentTreeNodesStore.isNodeExpanded(component._id!))
) { ) {
return component._children[0]._id return component._children[0]._id!
} else if (!parent) { } else if (!parent) {
return null return null
} }
// Otherwise select the next sibling if we have one // Otherwise select the next sibling if we have one
if (index < parent._children.length - 1) { if (index < parent._children!.length - 1) {
const nextSibling = parent._children[index + 1] const nextSibling = parent._children![index + 1]
return nextSibling._id return nextSibling._id!
} }
// Last child, select our parents next sibling // Last child, select our parents next sibling
let target = parent let target = parent
let targetParent = findComponentParent(screen.props, target._id) let targetParent = findComponentParent(screen.props, target._id)
let targetIndex = targetParent?._children.findIndex( let targetIndex = targetParent?._children?.findIndex(
(child: Component) => child._id === target._id (child: Component) => child._id === target._id
) )!
while ( while (
targetParent != null && targetParent != null &&
targetIndex === targetParent._children?.length - 1 targetParent._children &&
targetIndex === targetParent._children.length - 1
) { ) {
target = targetParent target = targetParent
targetParent = findComponentParent(screen.props, target._id) targetParent = findComponentParent(screen.props, target._id)
targetIndex = targetParent?._children.findIndex( targetIndex = targetParent?._children!.findIndex(
(child: Component) => child._id === target._id (child: Component) => child._id === target._id
) )!
} }
if (targetParent) { if (targetParent) {
return targetParent._children[targetIndex + 1]._id return targetParent._children![targetIndex + 1]._id!
} else { } else {
return null return null
} }
@ -950,16 +949,16 @@ export class ComponentStore extends BudiStore<ComponentState> {
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
// Check we aren't right at the top of the tree // Check we aren't right at the top of the tree
const index = parent?._children.findIndex( const index = parent?._children?.findIndex(
(x: Component) => x._id === componentId (x: Component) => x._id === componentId
) )!
if (!parent || (index === 0 && parent._id === screen.props._id)) { if (!parent || (index === 0 && parent._id === screen.props._id)) {
return return
} }
// Copy original component and remove it from the parent // Copy original component and remove it from the parent
const originalComponent = cloneDeep(parent._children[index]) const originalComponent = cloneDeep(parent._children![index])
parent._children = parent._children.filter( parent._children = parent._children!.filter(
(component: Component) => component._id !== componentId (component: Component) => component._id !== componentId
) )
@ -971,9 +970,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
const definition = this.getDefinition(previousSibling._component) const definition = this.getDefinition(previousSibling._component)
if ( if (
definition?.hasChildren && definition?.hasChildren &&
componentTreeNodesStore.isNodeExpanded(previousSibling._id) componentTreeNodesStore.isNodeExpanded(previousSibling._id!)
) { ) {
previousSibling._children.push(originalComponent) previousSibling._children!.push(originalComponent)
} }
// Otherwise just move component above sibling // Otherwise just move component above sibling
@ -985,11 +984,11 @@ export class ComponentStore extends BudiStore<ComponentState> {
// If no siblings above us, go above the parent as long as it isn't // If no siblings above us, go above the parent as long as it isn't
// the screen // the screen
else if (parent._id !== screen.props._id) { else if (parent._id !== screen.props._id) {
const grandParent = findComponentParent(screen.props, parent._id) const grandParent = findComponentParent(screen.props, parent._id)!
const parentIndex = grandParent._children.findIndex( const parentIndex = grandParent._children!.findIndex(
(child: Component) => child._id === parent._id (child: Component) => child._id === parent._id
) )
grandParent._children.splice(parentIndex, 0, originalComponent) grandParent._children!.splice(parentIndex, 0, originalComponent)
} }
}, null) }, null)
} }
@ -1028,9 +1027,9 @@ export class ComponentStore extends BudiStore<ComponentState> {
const definition = this.getDefinition(nextSibling._component) const definition = this.getDefinition(nextSibling._component)
if ( if (
definition?.hasChildren && definition?.hasChildren &&
componentTreeNodesStore.isNodeExpanded(nextSibling._id) componentTreeNodesStore.isNodeExpanded(nextSibling._id!)
) { ) {
nextSibling._children.splice(0, 0, originalComponent) nextSibling._children!.splice(0, 0, originalComponent)
} }
// Otherwise move below next sibling // Otherwise move below next sibling
@ -1041,11 +1040,11 @@ export class ComponentStore extends BudiStore<ComponentState> {
// Last child, so move below our parent // Last child, so move below our parent
else { else {
const grandParent = findComponentParent(screen.props, parent._id) const grandParent = findComponentParent(screen.props, parent._id)!
const parentIndex = grandParent._children.findIndex( const parentIndex = grandParent._children!.findIndex(
(child: Component) => child._id === parent._id (child: Component) => child._id === parent._id
) )
grandParent._children.splice(parentIndex + 1, 0, originalComponent) grandParent._children!.splice(parentIndex + 1, 0, originalComponent)
} }
}, null) }, null)
} }
@ -1208,13 +1207,13 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
// Replace component with parent // Replace component with parent
const index = oldParentDefinition._children.findIndex( const index = oldParentDefinition._children!.findIndex(
(component: Component) => component._id === componentId (component: Component) => component._id === componentId
) )
if (index === -1) { if (index === -1) {
return false return false
} }
oldParentDefinition._children[index] = { oldParentDefinition._children![index] = {
...newParentDefinition, ...newParentDefinition,
_children: [definition], _children: [definition],
} }

View File

@ -1,6 +1,6 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { PreviewDevice, ComponentContext } from "@budibase/types" import { PreviewDevice, ComponentContext, AppContext } from "@budibase/types"
type PreviewEventHandler = (name: string, payload?: any) => void type PreviewEventHandler = (name: string, payload?: any) => void
@ -8,7 +8,7 @@ interface PreviewState {
previewDevice: PreviewDevice previewDevice: PreviewDevice
previewEventHandler: PreviewEventHandler | null previewEventHandler: PreviewEventHandler | null
showPreview: boolean showPreview: boolean
selectedComponentContext: ComponentContext | null selectedComponentContext: AppContext | null
} }
const INITIAL_PREVIEW_STATE: PreviewState = { const INITIAL_PREVIEW_STATE: PreviewState = {

View File

@ -490,7 +490,7 @@ export class ScreenStore extends BudiStore<ScreenState> {
// Flatten the recursive component tree // Flatten the recursive component tree
const components = findAllMatchingComponents( const components = findAllMatchingComponents(
screen.props, screen.props,
(x: Component) => x (x: Component) => !!x
) )
// Iterate over all components and run checks // Iterate over all components and run checks

View File

@ -112,10 +112,7 @@ export const EventPublishType = {
ENV_VAR_UPGRADE_PANEL_OPENED: "environment_variable_upgrade_panel_opened", ENV_VAR_UPGRADE_PANEL_OPENED: "environment_variable_upgrade_panel_opened",
} }
export const ContextScopes = { export { ComponentContextScopes as ContextScopes } from "@budibase/types"
Local: "local",
Global: "global",
}
export const TypeIconMap = { export const TypeIconMap = {
[FieldType.STRING]: "Text", [FieldType.STRING]: "Text",

View File

@ -1,7 +1,7 @@
import env from "../../environment" import env from "../../environment"
import { AutomationResults, Automation, App } from "@budibase/types" import { AutomationResults, Automation, App } from "@budibase/types"
import { automations } from "@budibase/pro" import { automations } from "@budibase/pro"
import { db as dbUtils } from "@budibase/backend-core" import { db as dbUtils, logging } from "@budibase/backend-core"
import sizeof from "object-sizeof" import sizeof from "object-sizeof"
const MAX_LOG_SIZE_MB = 5 const MAX_LOG_SIZE_MB = 5
@ -32,7 +32,16 @@ export async function storeLog(
if (bytes / MB_IN_BYTES > MAX_LOG_SIZE_MB) { if (bytes / MB_IN_BYTES > MAX_LOG_SIZE_MB) {
sanitiseResults(results) sanitiseResults(results)
} }
try {
await automations.logs.storeLog(automation, results) await automations.logs.storeLog(automation, results)
} catch (e: any) {
if (e.status === 413 && e.request?.data) {
// if content is too large we shouldn't log it
delete e.request.data
e.request.data = { message: "removed due to large size" }
}
logging.logAlert("Error writing automation log", e)
}
} }
export async function checkAppMetadata(apps: App[]) { export async function checkAppMetadata(apps: App[]) {

View File

@ -21,6 +21,11 @@ describe("Attempt to run a basic loop automation", () => {
}) })
beforeEach(async () => { beforeEach(async () => {
const { automations } = await config.api.automation.fetch()
for (const automation of automations) {
await config.api.automation.delete(automation)
}
table = await config.api.table.save(basicTable()) table = await config.api.table.save(basicTable())
await config.api.row.save(table._id!, {}) await config.api.row.save(table._id!, {})
}) })

View File

@ -1,11 +1,15 @@
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import { import {
captureAutomationQueueMessages, captureAutomationMessages,
captureAutomationRemovals,
captureAutomationResults, captureAutomationResults,
triggerCron,
} from "../utilities" } from "../utilities"
import { automations } from "@budibase/pro" import { automations } from "@budibase/pro"
import { AutomationStatus } from "@budibase/types" import { AutomationData, AutomationStatus } from "@budibase/types"
import { MAX_AUTOMATION_RECURRING_ERRORS } from "../../../constants"
import { queue } from "@budibase/backend-core"
describe("cron trigger", () => { describe("cron trigger", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
@ -33,7 +37,7 @@ describe("cron trigger", () => {
}) })
.save() .save()
const messages = await captureAutomationQueueMessages(automation, () => const messages = await captureAutomationMessages(automation, () =>
config.api.application.publish() config.api.application.publish()
) )
expect(messages).toHaveLength(1) expect(messages).toHaveLength(1)
@ -63,7 +67,7 @@ describe("cron trigger", () => {
}) })
it("should stop if the job fails more than 3 times", async () => { it("should stop if the job fails more than 3 times", async () => {
const runner = await createAutomationBuilder(config) const { automation } = await createAutomationBuilder(config)
.onCron({ cron: "* * * * *" }) .onCron({ cron: "* * * * *" })
.queryRows({ .queryRows({
// @ts-expect-error intentionally sending invalid data // @ts-expect-error intentionally sending invalid data
@ -71,28 +75,31 @@ describe("cron trigger", () => {
}) })
.save() .save()
await config.api.application.publish() const [message] = await captureAutomationMessages(automation, () =>
config.api.application.publish()
const results = await captureAutomationResults(
runner.automation,
async () => {
await runner.trigger({ timeout: 1000, fields: {} })
await runner.trigger({ timeout: 1000, fields: {} })
await runner.trigger({ timeout: 1000, fields: {} })
await runner.trigger({ timeout: 1000, fields: {} })
await runner.trigger({ timeout: 1000, fields: {} })
}
) )
await config.withProdApp(async () => {
let results: queue.TestQueueMessage<AutomationData>[] = []
const removed = await captureAutomationRemovals(automation, async () => {
results = await captureAutomationResults(automation, async () => {
for (let i = 0; i < MAX_AUTOMATION_RECURRING_ERRORS; i++) {
triggerCron(message)
}
})
})
expect(removed).toHaveLength(1)
expect(removed[0].id).toEqual(message.id)
expect(results).toHaveLength(5) expect(results).toHaveLength(5)
await config.withProdApp(async () => { const search = await automations.logs.logSearch({
const { automationId: automation._id,
data: [latest, ..._], status: AutomationStatus.STOPPED_ERROR,
} = await automations.logs.logSearch({
automationId: runner.automation._id,
}) })
expect(latest.status).toEqual(AutomationStatus.STOPPED_ERROR) expect(search.data).toHaveLength(1)
expect(search.data[0].status).toEqual(AutomationStatus.STOPPED_ERROR)
}) })
}) })

View File

@ -6,6 +6,7 @@ import { Knex } from "knex"
import { getQueue } from "../.." import { getQueue } from "../.."
import { Job } from "bull" import { Job } from "bull"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { queue } from "@budibase/backend-core"
let config: TestConfiguration let config: TestConfiguration
@ -20,6 +21,17 @@ export function afterAll() {
config.end() config.end()
} }
export function getTestQueue(): queue.InMemoryQueue<AutomationData> {
return getQueue() as unknown as queue.InMemoryQueue<AutomationData>
}
export function triggerCron(message: Job<AutomationData>) {
if (!message.opts?.repeat || !("cron" in message.opts.repeat)) {
throw new Error("Expected cron message")
}
getTestQueue().manualTrigger(message.id)
}
export async function runInProd(fn: any) { export async function runInProd(fn: any) {
env._set("NODE_ENV", "production") env._set("NODE_ENV", "production")
let error let error
@ -34,9 +46,41 @@ export async function runInProd(fn: any) {
} }
} }
export async function captureAllAutomationQueueMessages( export async function captureAllAutomationRemovals(f: () => Promise<unknown>) {
const messages: Job<AutomationData>[] = []
const queue = getQueue()
const messageListener = async (message: Job<AutomationData>) => {
messages.push(message)
}
queue.on("removed", messageListener)
try {
await f()
// Queue messages tend to be send asynchronously in API handlers, so there's
// no guarantee that awaiting this function will have queued anything yet.
// We wait here to make sure we're queued _after_ any existing async work.
await helpers.wait(100)
} finally {
queue.off("removed", messageListener)
}
return messages
}
export async function captureAutomationRemovals(
automation: Automation | string,
f: () => Promise<unknown> f: () => Promise<unknown>
) { ) {
const messages = await captureAllAutomationRemovals(f)
return messages.filter(
m =>
m.data.automation._id ===
(typeof automation === "string" ? automation : automation._id)
)
}
export async function captureAllAutomationMessages(f: () => Promise<unknown>) {
const messages: Job<AutomationData>[] = [] const messages: Job<AutomationData>[] = []
const queue = getQueue() const queue = getQueue()
@ -58,11 +102,11 @@ export async function captureAllAutomationQueueMessages(
return messages return messages
} }
export async function captureAutomationQueueMessages( export async function captureAutomationMessages(
automation: Automation | string, automation: Automation | string,
f: () => Promise<unknown> f: () => Promise<unknown>
) { ) {
const messages = await captureAllAutomationQueueMessages(f) const messages = await captureAllAutomationMessages(f)
return messages.filter( return messages.filter(
m => m =>
m.data.automation._id === m.data.automation._id ===
@ -76,18 +120,22 @@ export async function captureAutomationQueueMessages(
*/ */
export async function captureAllAutomationResults( export async function captureAllAutomationResults(
f: () => Promise<unknown> f: () => Promise<unknown>
): Promise<Job<AutomationData>[]> { ): Promise<queue.TestQueueMessage<AutomationData>[]> {
const runs: Job<AutomationData>[] = [] const runs: queue.TestQueueMessage<AutomationData>[] = []
const queue = getQueue() const queue = getQueue()
let messagesOutstanding = 0 let messagesOutstanding = 0
const completedListener = async (job: Job<AutomationData>) => { const completedListener = async (
job: queue.TestQueueMessage<AutomationData>
) => {
runs.push(job) runs.push(job)
messagesOutstanding-- messagesOutstanding--
} }
const messageListener = async (message: Job<AutomationData>) => { const messageListener = async (
message: queue.TestQueueMessage<AutomationData>
) => {
// Don't count cron messages, as they don't get triggered automatically. // Don't count cron messages, as they don't get triggered automatically.
if (message.opts?.repeat != null) { if (!message.manualTrigger && message.opts?.repeat != null) {
return return
} }
messagesOutstanding++ messagesOutstanding++

View File

@ -181,17 +181,6 @@ class Orchestrator {
await storeLog(automation, this.executionOutput) await storeLog(automation, this.executionOutput)
} }
async checkIfShouldStop(): Promise<boolean> {
const metadata = await this.getMetadata()
if (!metadata.errorCount || !this.isCron()) {
return false
}
if (metadata.errorCount >= MAX_AUTOMATION_RECURRING_ERRORS) {
return true
}
return false
}
async getMetadata(): Promise<AutomationMetadata> { async getMetadata(): Promise<AutomationMetadata> {
const metadataId = generateAutomationMetadataID(this.automation._id!) const metadataId = generateAutomationMetadataID(this.automation._id!)
const db = context.getAppDB() const db = context.getAppDB()
@ -200,24 +189,29 @@ class Orchestrator {
} }
async incrementErrorCount() { async incrementErrorCount() {
for (let attempt = 0; attempt < 3; attempt++) { const db = context.getAppDB()
let err: Error | undefined = undefined
for (let attempt = 0; attempt < 10; attempt++) {
const metadata = await this.getMetadata() const metadata = await this.getMetadata()
metadata.errorCount ||= 0 metadata.errorCount ||= 0
metadata.errorCount++ metadata.errorCount++
const db = context.getAppDB()
try { try {
await db.put(metadata) await db.put(metadata)
return return metadata.errorCount
} catch (err) { } catch (error: any) {
err = error
await helpers.wait(1000 + Math.random() * 1000)
}
}
logging.logAlertWithInfo( logging.logAlertWithInfo(
"Failed to update error count in automation metadata", "Failed to update error count in automation metadata",
db.name, db.name,
this.automation._id!, this.automation._id!,
err err
) )
} return undefined
}
} }
updateExecutionOutput(id: string, stepId: string, inputs: any, outputs: any) { updateExecutionOutput(id: string, stepId: string, inputs: any, outputs: any) {
@ -295,28 +289,22 @@ class Orchestrator {
} }
) )
try { let errorCount = 0
await storeLog(this.automation, this.executionOutput)
} catch (e: any) {
if (e.status === 413 && e.request?.data) {
// if content is too large we shouldn't log it
delete e.request.data
e.request.data = { message: "removed due to large size" }
}
logging.logAlert("Error writing automation log", e)
}
if ( if (
isProdAppID(this.appId) && isProdAppID(this.appId) &&
this.isCron() && this.isCron() &&
isErrorInOutput(this.executionOutput) isErrorInOutput(this.executionOutput)
) { ) {
await this.incrementErrorCount() errorCount = (await this.incrementErrorCount()) || 0
if (await this.checkIfShouldStop()) { }
if (errorCount >= MAX_AUTOMATION_RECURRING_ERRORS) {
await this.stopCron("errors") await this.stopCron("errors")
span?.addTags({ shouldStop: true }) span?.addTags({ shouldStop: true })
return } else {
} await storeLog(this.automation, this.executionOutput)
} }
return this.executionOutput return this.executionOutput
} }
) )
@ -743,7 +731,7 @@ export async function executeInThread(
})) as AutomationResponse })) as AutomationResponse
} }
export const removeStalled = async (job: Job) => { export const removeStalled = async (job: Job<AutomationData>) => {
const appId = job.data.event.appId const appId = job.data.event.appId
if (!appId) { if (!appId) {
throw new Error("Unable to execute, event doesn't contain app ID.") throw new Error("Unable to execute, event doesn't contain app ID.")

View File

@ -28,6 +28,8 @@ export interface ComponentDefinition {
width: number width: number
height: number height: number
} }
context?: ComponentContext | ComponentContext[]
actions?: (string | any)[]
} }
export type DependsOnComponentSetting = export type DependsOnComponentSetting =
@ -56,3 +58,28 @@ export interface ComponentSetting {
self: boolean self: boolean
} }
} }
interface ComponentAction {
type: string
suffix?: string
}
interface ComponentStaticContextValue {
label: string
key: string
type: string // technically this is a long list of options but there are too many to enumerate
}
export interface ComponentContext {
type: ComponentContextType
scope?: ComponentContextScopes
actions?: ComponentAction[]
suffix?: string
values?: ComponentStaticContextValue[]
}
export type ComponentContextType = "action" | "static" | "schema" | "form"
export const enum ComponentContextScopes {
Local = "local",
Global = "global",
}

View File

@ -1,2 +1,3 @@
export type PreviewDevice = "desktop" | "tablet" | "mobile" export type PreviewDevice = "desktop" | "tablet" | "mobile"
export type ComponentContext = Record<string, any>
export type AppContext = Record<string, any>