Merge master.
This commit is contained in:
commit
2a4da79d85
|
@ -52,7 +52,11 @@ class InMemoryQueue implements Partial<Queue> {
|
||||||
_opts?: QueueOptions
|
_opts?: QueueOptions
|
||||||
_messages: JobMessage[]
|
_messages: JobMessage[]
|
||||||
_queuedJobIds: Set<string>
|
_queuedJobIds: Set<string>
|
||||||
_emitter: NodeJS.EventEmitter<{ message: [JobMessage]; completed: [Job] }>
|
_emitter: NodeJS.EventEmitter<{
|
||||||
|
message: [JobMessage]
|
||||||
|
completed: [Job]
|
||||||
|
removed: [JobMessage]
|
||||||
|
}>
|
||||||
_runCount: number
|
_runCount: number
|
||||||
_addCount: number
|
_addCount: number
|
||||||
|
|
||||||
|
@ -83,6 +87,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 message => {
|
||||||
|
// For the purpose of testing, don't trigger cron jobs immediately.
|
||||||
|
// Require the test to trigger them manually with timestamps.
|
||||||
|
if (message.opts?.repeat != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let resp = func(message)
|
let resp = func(message)
|
||||||
|
|
||||||
async function retryFunc(fnc: any) {
|
async function retryFunc(fnc: any) {
|
||||||
|
@ -164,13 +174,14 @@ class InMemoryQueue implements Partial<Queue> {
|
||||||
*/
|
*/
|
||||||
async close() {}
|
async close() {}
|
||||||
|
|
||||||
/**
|
async removeRepeatableByKey(id: string) {
|
||||||
* This removes a cron which has been implemented, this is part of Bull API.
|
for (const [idx, message] of this._messages.entries()) {
|
||||||
* @param cronJobId The cron which is to be removed.
|
if (message.opts?.jobId?.toString() === id) {
|
||||||
*/
|
this._messages.splice(idx, 1)
|
||||||
async removeRepeatableByKey(cronJobId: string) {
|
this._emitter.emit("removed", message)
|
||||||
// TODO: implement for testing
|
return
|
||||||
console.log(cronJobId)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeJobs(_pattern: string) {
|
async removeJobs(_pattern: string) {
|
||||||
|
@ -214,7 +225,9 @@ class InMemoryQueue implements Partial<Queue> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRepeatableJobs() {
|
async getRepeatableJobs() {
|
||||||
return this._messages.map(job => jobToJobInformation(job as Job))
|
return this._messages
|
||||||
|
.filter(job => job.opts?.repeat != null)
|
||||||
|
.map(job => jobToJobInformation(job as Job))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
export let sort = false
|
export let sort = false
|
||||||
export let autoWidth = false
|
export let autoWidth = false
|
||||||
export let searchTerm = null
|
export let searchTerm = null
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight = undefined
|
||||||
export let open = false
|
export let open = false
|
||||||
export let loading
|
export let loading
|
||||||
export let onOptionMouseenter = () => {}
|
export let onOptionMouseenter = () => {}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
const component = getContext("component")
|
const component = getContext("component")
|
||||||
const block = getContext("block")
|
const block = getContext("block")
|
||||||
|
|
||||||
export let text
|
export let text = undefined
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $builderStore.inBuilder}
|
{#if $builderStore.inBuilder}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { getContext, onDestroy } from "svelte"
|
import { getContext, onDestroy } from "svelte"
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { Icon } from "@budibase/bbui"
|
import { Icon } from "@budibase/bbui"
|
||||||
|
@ -6,33 +6,33 @@
|
||||||
import Placeholder from "../Placeholder.svelte"
|
import Placeholder from "../Placeholder.svelte"
|
||||||
import InnerForm from "./InnerForm.svelte"
|
import InnerForm from "./InnerForm.svelte"
|
||||||
|
|
||||||
export let label
|
export let label: string | undefined = undefined
|
||||||
export let field
|
export let field: string | undefined = undefined
|
||||||
export let fieldState
|
export let fieldState: any
|
||||||
export let fieldApi
|
export let fieldApi: any
|
||||||
export let fieldSchema
|
export let fieldSchema: any
|
||||||
export let defaultValue
|
export let defaultValue: string | undefined = undefined
|
||||||
export let type
|
export let type: any
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let readonly = false
|
export let readonly = false
|
||||||
export let validation
|
export let validation: any
|
||||||
export let span = 6
|
export let span = 6
|
||||||
export let helpText = null
|
export let helpText: string | undefined = undefined
|
||||||
|
|
||||||
// Get contexts
|
// Get contexts
|
||||||
const formContext = getContext("form")
|
const formContext: any = getContext("form")
|
||||||
const formStepContext = getContext("form-step")
|
const formStepContext: any = getContext("form-step")
|
||||||
const fieldGroupContext = getContext("field-group")
|
const fieldGroupContext: any = getContext("field-group")
|
||||||
const { styleable, builderStore, Provider } = getContext("sdk")
|
const { styleable, builderStore, Provider } = getContext("sdk")
|
||||||
const component = getContext("component")
|
const component: any = getContext("component")
|
||||||
|
|
||||||
// Register field with form
|
// Register field with form
|
||||||
const formApi = formContext?.formApi
|
const formApi = formContext?.formApi
|
||||||
const labelPos = fieldGroupContext?.labelPosition || "above"
|
const labelPos = fieldGroupContext?.labelPosition || "above"
|
||||||
|
|
||||||
let formField
|
let formField: any
|
||||||
let touched = false
|
let touched = false
|
||||||
let labelNode
|
let labelNode: any
|
||||||
|
|
||||||
// Memoize values required to register the field to avoid loops
|
// Memoize values required to register the field to avoid loops
|
||||||
const formStep = formStepContext || writable(1)
|
const formStep = formStepContext || writable(1)
|
||||||
|
@ -65,7 +65,7 @@
|
||||||
$: $component.editing && labelNode?.focus()
|
$: $component.editing && labelNode?.focus()
|
||||||
|
|
||||||
// Update form properties in parent component on every store change
|
// Update form properties in parent component on every store change
|
||||||
$: unsubscribe = formField?.subscribe(value => {
|
$: unsubscribe = formField?.subscribe((value: any) => {
|
||||||
fieldState = value?.fieldState
|
fieldState = value?.fieldState
|
||||||
fieldApi = value?.fieldApi
|
fieldApi = value?.fieldApi
|
||||||
fieldSchema = value?.fieldSchema
|
fieldSchema = value?.fieldSchema
|
||||||
|
@ -74,7 +74,7 @@
|
||||||
// Determine label class from position
|
// Determine label class from position
|
||||||
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
|
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
|
||||||
|
|
||||||
const registerField = info => {
|
const registerField = (info: any) => {
|
||||||
formField = formApi?.registerField(
|
formField = formApi?.registerField(
|
||||||
info.field,
|
info.field,
|
||||||
info.type,
|
info.type,
|
||||||
|
@ -86,8 +86,9 @@
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLabel = e => {
|
const updateLabel = (e: any) => {
|
||||||
if (touched) {
|
if (touched) {
|
||||||
|
// @ts-expect-error and TODO updateProp isn't recognised - need builder TS conversion
|
||||||
builderStore.actions.updateProp("label", e.target.textContent)
|
builderStore.actions.updateProp("label", e.target.textContent)
|
||||||
}
|
}
|
||||||
touched = false
|
touched = false
|
||||||
|
|
|
@ -4,13 +4,13 @@
|
||||||
import { createValidatorFromConstraints } from "./validation"
|
import { createValidatorFromConstraints } from "./validation"
|
||||||
import { Helpers } from "@budibase/bbui"
|
import { Helpers } from "@budibase/bbui"
|
||||||
|
|
||||||
export let dataSource
|
export let dataSource = undefined
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let readonly = false
|
export let readonly = false
|
||||||
export let initialValues
|
export let initialValues = undefined
|
||||||
export let size
|
export let size = undefined
|
||||||
export let schema
|
export let schema = undefined
|
||||||
export let definition
|
export let definition = undefined
|
||||||
export let disableSchemaValidation = false
|
export let disableSchemaValidation = false
|
||||||
export let editAutoColumns = false
|
export let editAutoColumns = false
|
||||||
|
|
||||||
|
|
|
@ -1,43 +1,61 @@
|
||||||
<script>
|
<script lang="ts">
|
||||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||||
import { FieldType } from "@budibase/types"
|
import { FieldType } from "@budibase/types"
|
||||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import Field from "./Field.svelte"
|
import Field from "./Field.svelte"
|
||||||
|
import type {
|
||||||
|
SearchFilter,
|
||||||
|
RelationshipFieldMetadata,
|
||||||
|
Table,
|
||||||
|
Row,
|
||||||
|
} from "@budibase/types"
|
||||||
|
|
||||||
const { API } = getContext("sdk")
|
const { API } = getContext("sdk")
|
||||||
|
|
||||||
export let field
|
export let field: string | undefined = undefined
|
||||||
export let label
|
export let label: string | undefined = undefined
|
||||||
export let placeholder
|
export let placeholder: any = undefined
|
||||||
export let disabled = false
|
export let disabled: boolean = false
|
||||||
export let readonly = false
|
export let readonly: boolean = false
|
||||||
export let validation
|
export let validation: any
|
||||||
export let autocomplete = true
|
export let autocomplete: boolean = true
|
||||||
export let defaultValue
|
export let defaultValue: string | undefined = undefined
|
||||||
export let onChange
|
export let onChange: any
|
||||||
export let filter
|
export let filter: SearchFilter[]
|
||||||
export let datasourceType = "table"
|
export let datasourceType: "table" | "user" | "groupUser" = "table"
|
||||||
export let primaryDisplay
|
export let primaryDisplay: string | undefined = undefined
|
||||||
export let span
|
export let span: number | undefined = undefined
|
||||||
export let helpText = null
|
export let helpText: string | undefined = undefined
|
||||||
export let type = FieldType.LINK
|
export let type:
|
||||||
|
| FieldType.LINK
|
||||||
|
| FieldType.BB_REFERENCE
|
||||||
|
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
|
||||||
|
|
||||||
let fieldState
|
type RelationshipValue = { _id: string; [key: string]: any }
|
||||||
let fieldApi
|
type OptionObj = Record<string, RelationshipValue>
|
||||||
let fieldSchema
|
type OptionsObjType = Record<string, OptionObj>
|
||||||
let tableDefinition
|
|
||||||
let searchTerm
|
|
||||||
let open
|
|
||||||
|
|
||||||
|
let fieldState: any
|
||||||
|
let fieldApi: any
|
||||||
|
let fieldSchema: RelationshipFieldMetadata | undefined
|
||||||
|
let tableDefinition: Table | null | undefined
|
||||||
|
let searchTerm: any
|
||||||
|
let open: boolean
|
||||||
|
let selectedValue: string[] | string
|
||||||
|
|
||||||
|
// need a cast version of this for reactivity, components below aren't typed
|
||||||
|
$: castSelectedValue = selectedValue as any
|
||||||
$: multiselect =
|
$: multiselect =
|
||||||
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
||||||
fieldSchema?.relationshipType !== "one-to-many"
|
fieldSchema?.relationshipType !== "one-to-many"
|
||||||
$: linkedTableId = fieldSchema?.tableId
|
$: linkedTableId = fieldSchema?.tableId!
|
||||||
$: fetch = fetchData({
|
$: fetch = fetchData({
|
||||||
API,
|
API,
|
||||||
datasource: {
|
datasource: {
|
||||||
type: datasourceType,
|
// typing here doesn't seem correct - we have the correct datasourceType options
|
||||||
|
// but when we configure the fetchData, it seems to think only "table" is valid
|
||||||
|
type: datasourceType as any,
|
||||||
tableId: linkedTableId,
|
tableId: linkedTableId,
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
@ -53,7 +71,8 @@
|
||||||
$: component = multiselect ? CoreMultiselect : CoreSelect
|
$: component = multiselect ? CoreMultiselect : CoreSelect
|
||||||
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
|
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
|
||||||
|
|
||||||
let optionsObj
|
let optionsObj: OptionsObjType = {}
|
||||||
|
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (primaryDisplay && fieldState && !optionsObj) {
|
if (primaryDisplay && fieldState && !optionsObj) {
|
||||||
|
@ -63,27 +82,33 @@
|
||||||
if (!Array.isArray(valueAsSafeArray)) {
|
if (!Array.isArray(valueAsSafeArray)) {
|
||||||
valueAsSafeArray = [fieldState.value]
|
valueAsSafeArray = [fieldState.value]
|
||||||
}
|
}
|
||||||
optionsObj = valueAsSafeArray.reduce((accumulator, value) => {
|
optionsObj = valueAsSafeArray.reduce(
|
||||||
// fieldState has to be an array of strings to be valid for an update
|
(
|
||||||
// therefore we cannot guarantee value will be an object
|
accumulator: OptionObj,
|
||||||
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
|
value: { _id: string; primaryDisplay: any }
|
||||||
if (!value._id) {
|
) => {
|
||||||
|
// fieldState has to be an array of strings to be valid for an update
|
||||||
|
// therefore we cannot guarantee value will be an object
|
||||||
|
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
|
||||||
|
if (!value._id) {
|
||||||
|
return accumulator
|
||||||
|
}
|
||||||
|
accumulator[value._id] = {
|
||||||
|
_id: value._id,
|
||||||
|
[primaryDisplay]: value.primaryDisplay,
|
||||||
|
}
|
||||||
return accumulator
|
return accumulator
|
||||||
}
|
},
|
||||||
accumulator[value._id] = {
|
{}
|
||||||
_id: value._id,
|
)
|
||||||
[primaryDisplay]: value.primaryDisplay,
|
|
||||||
}
|
|
||||||
return accumulator
|
|
||||||
}, {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
|
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
|
||||||
const enrichOptions = (optionsObj, fetchResults) => {
|
const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => {
|
||||||
const result = (fetchResults || [])?.reduce((accumulator, row) => {
|
const result = (fetchResults || [])?.reduce((accumulator, row) => {
|
||||||
if (!accumulator[row._id]) {
|
if (!accumulator[row._id!]) {
|
||||||
accumulator[row._id] = row
|
accumulator[row._id!] = row
|
||||||
}
|
}
|
||||||
return accumulator
|
return accumulator
|
||||||
}, optionsObj || {})
|
}, optionsObj || {})
|
||||||
|
@ -92,24 +117,32 @@
|
||||||
}
|
}
|
||||||
$: {
|
$: {
|
||||||
// We don't want to reorder while the dropdown is open, to avoid UX jumps
|
// We don't want to reorder while the dropdown is open, to avoid UX jumps
|
||||||
if (!open) {
|
if (!open && primaryDisplay) {
|
||||||
enrichedOptions = enrichedOptions.sort((a, b) => {
|
enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => {
|
||||||
const selectedValues = flatten(fieldState?.value) || []
|
const selectedValues = flatten(fieldState?.value) || []
|
||||||
|
|
||||||
const aIsSelected = selectedValues.find(v => v === a._id)
|
const aIsSelected = selectedValues.find(
|
||||||
const bIsSelected = selectedValues.find(v => v === b._id)
|
(v: RelationshipValue) => v === a._id
|
||||||
|
)
|
||||||
|
const bIsSelected = selectedValues.find(
|
||||||
|
(v: RelationshipValue) => v === b._id
|
||||||
|
)
|
||||||
if (aIsSelected && !bIsSelected) {
|
if (aIsSelected && !bIsSelected) {
|
||||||
return -1
|
return -1
|
||||||
} else if (!aIsSelected && bIsSelected) {
|
} else if (!aIsSelected && bIsSelected) {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return a[primaryDisplay] > b[primaryDisplay]
|
return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: forceFetchRows(filter)
|
$: {
|
||||||
|
if (filter || defaultValue) {
|
||||||
|
forceFetchRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||||
|
|
||||||
const forceFetchRows = async () => {
|
const forceFetchRows = async () => {
|
||||||
|
@ -119,7 +152,11 @@
|
||||||
selectedValue = []
|
selectedValue = []
|
||||||
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||||
}
|
}
|
||||||
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
|
async function fetchRows(
|
||||||
|
searchTerm: any,
|
||||||
|
primaryDisplay: string,
|
||||||
|
defaultVal: string | string[]
|
||||||
|
) {
|
||||||
const allRowsFetched =
|
const allRowsFetched =
|
||||||
$fetch.loaded &&
|
$fetch.loaded &&
|
||||||
!Object.keys($fetch.query?.string || {}).length &&
|
!Object.keys($fetch.query?.string || {}).length &&
|
||||||
|
@ -129,17 +166,39 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// must be an array
|
// must be an array
|
||||||
if (defaultVal && !Array.isArray(defaultVal)) {
|
const defaultValArray: string[] = !defaultVal
|
||||||
defaultVal = defaultVal.split(",")
|
? []
|
||||||
}
|
: !Array.isArray(defaultVal)
|
||||||
if (defaultVal && optionsObj && defaultVal.some(val => !optionsObj[val])) {
|
? defaultVal.split(",")
|
||||||
|
: defaultVal
|
||||||
|
|
||||||
|
if (
|
||||||
|
defaultVal &&
|
||||||
|
optionsObj &&
|
||||||
|
defaultValArray.some(val => !optionsObj[val])
|
||||||
|
) {
|
||||||
await fetch.update({
|
await fetch.update({
|
||||||
query: { oneOf: { _id: defaultVal } },
|
query: { oneOf: { _id: defaultValArray } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(Array.isArray(selectedValue) &&
|
||||||
|
selectedValue.some(val => !optionsObj[val])) ||
|
||||||
|
(selectedValue && !optionsObj[selectedValue as string])
|
||||||
|
) {
|
||||||
|
await fetch.update({
|
||||||
|
query: {
|
||||||
|
oneOf: {
|
||||||
|
_id: Array.isArray(selectedValue) ? selectedValue : [selectedValue],
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we match all filters, rather than any
|
// Ensure we match all filters, rather than any
|
||||||
const baseFilter = (filter || []).filter(x => x.operator !== "allOr")
|
// @ts-expect-error this doesn't fit types, but don't want to change it yet
|
||||||
|
const baseFilter: any = (filter || []).filter(x => x.operator !== "allOr")
|
||||||
await fetch.update({
|
await fetch.update({
|
||||||
filter: [
|
filter: [
|
||||||
...baseFilter,
|
...baseFilter,
|
||||||
|
@ -152,9 +211,8 @@
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
|
|
||||||
|
|
||||||
const flatten = values => {
|
const flatten = (values: any | any[]) => {
|
||||||
if (!values) {
|
if (!values) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
@ -162,17 +220,17 @@
|
||||||
if (!Array.isArray(values)) {
|
if (!Array.isArray(values)) {
|
||||||
values = [values]
|
values = [values]
|
||||||
}
|
}
|
||||||
values = values.map(value =>
|
values = values.map((value: any) =>
|
||||||
typeof value === "object" ? value._id : value
|
typeof value === "object" ? value._id : value
|
||||||
)
|
)
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDisplayName = row => {
|
const getDisplayName = (row: Row) => {
|
||||||
return row?.[primaryDisplay] || "-"
|
return row?.[primaryDisplay!] || "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChange = e => {
|
const handleChange = (e: any) => {
|
||||||
let value = e.detail
|
let value = e.detail
|
||||||
if (!multiselect) {
|
if (!multiselect) {
|
||||||
value = value == null ? [] : [value]
|
value = value == null ? [] : [value]
|
||||||
|
@ -220,13 +278,12 @@
|
||||||
this={component}
|
this={component}
|
||||||
options={enrichedOptions}
|
options={enrichedOptions}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
value={selectedValue}
|
value={castSelectedValue}
|
||||||
on:change={handleChange}
|
on:change={handleChange}
|
||||||
on:loadMore={loadMore}
|
on:loadMore={loadMore}
|
||||||
id={fieldState.fieldId}
|
id={fieldState.fieldId}
|
||||||
disabled={fieldState.disabled}
|
disabled={fieldState.disabled}
|
||||||
readonly={fieldState.readonly}
|
readonly={fieldState.readonly}
|
||||||
error={fieldState.error}
|
|
||||||
getOptionLabel={getDisplayName}
|
getOptionLabel={getDisplayName}
|
||||||
getOptionValue={option => option._id}
|
getOptionValue={option => option._id}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
import tk from "timekeeper"
|
|
||||||
import "../../../environment"
|
|
||||||
import * as automations from "../../index"
|
|
||||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
|
||||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
|
||||||
|
|
||||||
const initialTime = Date.now()
|
|
||||||
tk.freeze(initialTime)
|
|
||||||
|
|
||||||
const oneMinuteInMs = 60 * 1000
|
|
||||||
|
|
||||||
describe("cron automations", () => {
|
|
||||||
const config = new TestConfiguration()
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await automations.init()
|
|
||||||
await config.init()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await automations.shutdown()
|
|
||||||
config.end()
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tk.freeze(initialTime)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should initialise the automation timestamp", async () => {
|
|
||||||
await createAutomationBuilder(config).onCron({ cron: "* * * * *" }).save()
|
|
||||||
|
|
||||||
tk.travel(Date.now() + oneMinuteInMs)
|
|
||||||
await config.publish()
|
|
||||||
|
|
||||||
const { data } = await config.getAutomationLogs()
|
|
||||||
expect(data).toHaveLength(1)
|
|
||||||
expect(data).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
trigger: expect.objectContaining({
|
|
||||||
outputs: { timestamp: initialTime + oneMinuteInMs },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,6 +1,11 @@
|
||||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||||
import { captureAutomationResults } from "../utilities"
|
import {
|
||||||
|
captureAutomationQueueMessages,
|
||||||
|
captureAutomationResults,
|
||||||
|
} from "../utilities"
|
||||||
|
import { automations } from "@budibase/pro"
|
||||||
|
import { AutomationStatus } from "@budibase/types"
|
||||||
|
|
||||||
describe("cron trigger", () => {
|
describe("cron trigger", () => {
|
||||||
const config = new TestConfiguration()
|
const config = new TestConfiguration()
|
||||||
|
@ -13,6 +18,13 @@ describe("cron trigger", () => {
|
||||||
config.end()
|
config.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { automations } = await config.api.automation.fetch()
|
||||||
|
for (const automation of automations) {
|
||||||
|
await config.api.automation.delete(automation)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it("should queue a Bull cron job", async () => {
|
it("should queue a Bull cron job", async () => {
|
||||||
const { automation } = await createAutomationBuilder(config)
|
const { automation } = await createAutomationBuilder(config)
|
||||||
.onCron({ cron: "* * * * *" })
|
.onCron({ cron: "* * * * *" })
|
||||||
|
@ -21,12 +33,12 @@ describe("cron trigger", () => {
|
||||||
})
|
})
|
||||||
.save()
|
.save()
|
||||||
|
|
||||||
const jobs = await captureAutomationResults(automation, () =>
|
const messages = await captureAutomationQueueMessages(automation, () =>
|
||||||
config.api.application.publish()
|
config.api.application.publish()
|
||||||
)
|
)
|
||||||
expect(jobs).toHaveLength(1)
|
expect(messages).toHaveLength(1)
|
||||||
|
|
||||||
const repeat = jobs[0].opts?.repeat
|
const repeat = messages[0].opts?.repeat
|
||||||
if (!repeat || !("cron" in repeat)) {
|
if (!repeat || !("cron" in repeat)) {
|
||||||
throw new Error("Expected cron repeat")
|
throw new Error("Expected cron repeat")
|
||||||
}
|
}
|
||||||
|
@ -49,4 +61,82 @@ describe("cron trigger", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.only("should stop if the job fails more than 3 times", async () => {
|
||||||
|
const runner = await createAutomationBuilder(config)
|
||||||
|
.onCron({ cron: "* * * * *" })
|
||||||
|
.queryRows({
|
||||||
|
// @ts-expect-error intentionally sending invalid data
|
||||||
|
tableId: null,
|
||||||
|
})
|
||||||
|
.save()
|
||||||
|
|
||||||
|
await 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: {} })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(results).toHaveLength(5)
|
||||||
|
|
||||||
|
await config.withProdApp(async () => {
|
||||||
|
const logs = await automations.logs.logSearch({
|
||||||
|
automationId: runner.automation._id,
|
||||||
|
status: AutomationStatus.STOPPED_ERROR,
|
||||||
|
})
|
||||||
|
expect(logs.data).toHaveLength(1)
|
||||||
|
expect(logs.data[0].status).toEqual(AutomationStatus.STOPPED_ERROR)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should fill in the timestamp if one is not provided", async () => {
|
||||||
|
const runner = await createAutomationBuilder(config)
|
||||||
|
.onCron({ cron: "* * * * *" })
|
||||||
|
.serverLog({
|
||||||
|
text: "Hello, world!",
|
||||||
|
})
|
||||||
|
.save()
|
||||||
|
|
||||||
|
await config.api.application.publish()
|
||||||
|
|
||||||
|
const results = await captureAutomationResults(
|
||||||
|
runner.automation,
|
||||||
|
async () => {
|
||||||
|
await runner.trigger({ timeout: 1000, fields: {} })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0].data.event.timestamp).toBeWithin(
|
||||||
|
Date.now() - 1000,
|
||||||
|
Date.now() + 1000
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use the given timestamp if one is given", async () => {
|
||||||
|
const timestamp = 1234
|
||||||
|
const runner = await createAutomationBuilder(config)
|
||||||
|
.onCron({ cron: "* * * * *" })
|
||||||
|
.serverLog({
|
||||||
|
text: "Hello, world!",
|
||||||
|
})
|
||||||
|
.save()
|
||||||
|
|
||||||
|
await config.api.application.publish()
|
||||||
|
|
||||||
|
const results = await captureAutomationResults(
|
||||||
|
runner.automation,
|
||||||
|
async () => {
|
||||||
|
await runner.trigger({ timeout: 1000, fields: {}, timestamp })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(results).toHaveLength(1)
|
||||||
|
expect(results[0].data.event.timestamp).toEqual(timestamp)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -221,10 +221,34 @@ class AutomationRunner<TStep extends AutomationTriggerStepId> {
|
||||||
async trigger(
|
async trigger(
|
||||||
request: TriggerAutomationRequest
|
request: TriggerAutomationRequest
|
||||||
): Promise<TriggerAutomationResponse> {
|
): Promise<TriggerAutomationResponse> {
|
||||||
return await this.config.api.automation.trigger(
|
if (!this.config.prodAppId) {
|
||||||
this.automation._id!,
|
throw new Error(
|
||||||
request
|
"Automations can only be triggered in a production app context, call config.api.application.publish()"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// Because you can only trigger automations in a production app context, we
|
||||||
|
// wrap the trigger call to make tests a bit cleaner. If you really want to
|
||||||
|
// test triggering an automation in a dev app context, you can use the
|
||||||
|
// automation API directly.
|
||||||
|
return await this.config.withProdApp(async () => {
|
||||||
|
try {
|
||||||
|
return await this.config.api.automation.trigger(
|
||||||
|
this.automation._id!,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.cause.status === 404) {
|
||||||
|
throw new Error(
|
||||||
|
`Automation with ID ${
|
||||||
|
this.automation._id
|
||||||
|
} not found in app ${this.config.getAppId()}. You may have forgotten to call config.api.application.publish().`,
|
||||||
|
{ cause: e }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,6 +34,42 @@ export async function runInProd(fn: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function captureAllAutomationQueueMessages(
|
||||||
|
f: () => Promise<unknown>
|
||||||
|
) {
|
||||||
|
const messages: Job<AutomationData>[] = []
|
||||||
|
const queue = getQueue()
|
||||||
|
|
||||||
|
const messageListener = async (message: Job<AutomationData>) => {
|
||||||
|
messages.push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.on("message", 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("message", messageListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureAutomationQueueMessages(
|
||||||
|
automation: Automation | string,
|
||||||
|
f: () => Promise<unknown>
|
||||||
|
) {
|
||||||
|
const messages = await captureAllAutomationQueueMessages(f)
|
||||||
|
return messages.filter(
|
||||||
|
m =>
|
||||||
|
m.data.automation._id ===
|
||||||
|
(typeof automation === "string" ? automation : automation._id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture all automation runs that occur during the execution of a function.
|
* Capture all automation runs that occur during the execution of a function.
|
||||||
* This function will wait for all messages to be processed before returning.
|
* This function will wait for all messages to be processed before returning.
|
||||||
|
@ -43,14 +79,18 @@ export async function captureAllAutomationResults(
|
||||||
): Promise<Job<AutomationData>[]> {
|
): Promise<Job<AutomationData>[]> {
|
||||||
const runs: Job<AutomationData>[] = []
|
const runs: Job<AutomationData>[] = []
|
||||||
const queue = getQueue()
|
const queue = getQueue()
|
||||||
let messagesReceived = 0
|
let messagesOutstanding = 0
|
||||||
|
|
||||||
const completedListener = async (job: Job<AutomationData>) => {
|
const completedListener = async (job: Job<AutomationData>) => {
|
||||||
runs.push(job)
|
runs.push(job)
|
||||||
messagesReceived--
|
messagesOutstanding--
|
||||||
}
|
}
|
||||||
const messageListener = async () => {
|
const messageListener = async (message: Job<AutomationData>) => {
|
||||||
messagesReceived++
|
// Don't count cron messages, as they don't get triggered automatically.
|
||||||
|
if (message.opts?.repeat != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messagesOutstanding++
|
||||||
}
|
}
|
||||||
queue.on("message", messageListener)
|
queue.on("message", messageListener)
|
||||||
queue.on("completed", completedListener)
|
queue.on("completed", completedListener)
|
||||||
|
@ -61,9 +101,18 @@ export async function captureAllAutomationResults(
|
||||||
// We wait here to make sure we're queued _after_ any existing async work.
|
// We wait here to make sure we're queued _after_ any existing async work.
|
||||||
await helpers.wait(100)
|
await helpers.wait(100)
|
||||||
} finally {
|
} finally {
|
||||||
|
const waitMax = 10000
|
||||||
|
let waited = 0
|
||||||
// eslint-disable-next-line no-unmodified-loop-condition
|
// eslint-disable-next-line no-unmodified-loop-condition
|
||||||
while (messagesReceived > 0) {
|
while (messagesOutstanding > 0) {
|
||||||
await helpers.wait(50)
|
await helpers.wait(50)
|
||||||
|
waited += 50
|
||||||
|
if (waited > waitMax) {
|
||||||
|
// eslint-disable-next-line no-unsafe-finally
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for automation runs to complete. ${messagesOutstanding} messages waiting for completion.`
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
queue.off("completed", completedListener)
|
queue.off("completed", completedListener)
|
||||||
queue.off("message", messageListener)
|
queue.off("message", messageListener)
|
||||||
|
|
|
@ -73,7 +73,7 @@ export async function processEvent(job: AutomationJob) {
|
||||||
|
|
||||||
const task = async () => {
|
const task = async () => {
|
||||||
try {
|
try {
|
||||||
if (isCronTrigger(job.data.automation)) {
|
if (isCronTrigger(job.data.automation) && !job.data.event.timestamp) {
|
||||||
// Requires the timestamp at run time
|
// Requires the timestamp at run time
|
||||||
job.data.event.timestamp = Date.now()
|
job.data.event.timestamp = Date.now()
|
||||||
}
|
}
|
||||||
|
|
|
@ -261,11 +261,13 @@ export default class TestConfiguration {
|
||||||
async withApp<R>(app: App | string, f: () => Promise<R>) {
|
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 {
|
return await context.doInAppContext(this.appId, async () => {
|
||||||
return await f()
|
try {
|
||||||
} finally {
|
return await f()
|
||||||
this.appId = oldAppId
|
} finally {
|
||||||
}
|
this.appId = oldAppId
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async withProdApp<R>(f: () => Promise<R>) {
|
async withProdApp<R>(f: () => Promise<R>) {
|
||||||
|
|
|
@ -241,9 +241,13 @@ class Orchestrator {
|
||||||
return doc || { _id: id, errorCount: 0 }
|
return doc || { _id: id, errorCount: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isCron(): boolean {
|
||||||
|
return this.automation.definition.trigger.stepId === CRON_STEP_ID
|
||||||
|
}
|
||||||
|
|
||||||
async stopCron(reason: string, opts?: { result: AutomationResults }) {
|
async stopCron(reason: string, opts?: { result: AutomationResults }) {
|
||||||
if (!this.isCron()) {
|
if (!this.isCron()) {
|
||||||
throw new Error("Not a cron automation")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = `CRON disabled reason=${reason} - ${this.appId}/${this.automation._id}`
|
const msg = `CRON disabled reason=${reason} - ${this.appId}/${this.automation._id}`
|
||||||
|
@ -274,18 +278,25 @@ class Orchestrator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async shouldStop(metadata: AutomationMetadata): Promise<boolean> {
|
async incrementErrorCount() {
|
||||||
if (!metadata.errorCount || !this.isCron()) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
return false
|
const metadata = await this.getMetadata()
|
||||||
}
|
metadata.errorCount ||= 0
|
||||||
if (metadata.errorCount >= MAX_AUTOMATION_RECURRING_ERRORS) {
|
metadata.errorCount++
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private isCron(): boolean {
|
const db = context.getAppDB()
|
||||||
return this.automation.definition.trigger.stepId === CRON_STEP_ID
|
try {
|
||||||
|
await db.put(metadata)
|
||||||
|
return metadata.errorCount
|
||||||
|
} catch (err) {
|
||||||
|
logging.logAlertWithInfo(
|
||||||
|
"Failed to update error count in automation metadata",
|
||||||
|
db.name,
|
||||||
|
this.automation._id!,
|
||||||
|
err
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isProdApp(): boolean {
|
private isProdApp(): boolean {
|
||||||
|
@ -325,18 +336,6 @@ class Orchestrator {
|
||||||
}
|
}
|
||||||
const result: AutomationResults = { trigger, steps: [trigger] }
|
const result: AutomationResults = { trigger, steps: [trigger] }
|
||||||
|
|
||||||
let metadata: AutomationMetadata | undefined = undefined
|
|
||||||
|
|
||||||
if (this.isProdApp() && this.isCron()) {
|
|
||||||
span?.addTags({ recurring: true })
|
|
||||||
metadata = await this.getMetadata()
|
|
||||||
if (await this.shouldStop(metadata)) {
|
|
||||||
await this.stopCron("errors")
|
|
||||||
span?.addTags({ shouldStop: true })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx: AutomationContext = {
|
const ctx: AutomationContext = {
|
||||||
trigger: trigger.outputs,
|
trigger: trigger.outputs,
|
||||||
steps: [trigger.outputs],
|
steps: [trigger.outputs],
|
||||||
|
@ -374,29 +373,18 @@ class Orchestrator {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.logResult(result)
|
let errorCount = 0
|
||||||
|
if (isProdAppID(this.appId) && this.isCron() && this.hasErrored(ctx)) {
|
||||||
if (
|
errorCount = (await this.incrementErrorCount()) || 0
|
||||||
this.isProdApp() &&
|
|
||||||
this.isCron() &&
|
|
||||||
metadata &&
|
|
||||||
this.hasErrored(ctx)
|
|
||||||
) {
|
|
||||||
metadata.errorCount ??= 0
|
|
||||||
metadata.errorCount++
|
|
||||||
|
|
||||||
const db = context.getAppDB()
|
|
||||||
try {
|
|
||||||
await db.put(metadata)
|
|
||||||
} catch (err) {
|
|
||||||
logging.logAlertWithInfo(
|
|
||||||
"Failed to write automation metadata",
|
|
||||||
db.name,
|
|
||||||
job.data.automation._id!,
|
|
||||||
err
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (errorCount >= MAX_AUTOMATION_RECURRING_ERRORS) {
|
||||||
|
await this.stopCron("errors", { result })
|
||||||
|
span?.addTags({ shouldStop: true })
|
||||||
|
} else {
|
||||||
|
await this.logResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -65,6 +65,7 @@ export interface ClearAutomationLogResponse {
|
||||||
|
|
||||||
export interface TriggerAutomationRequest {
|
export interface TriggerAutomationRequest {
|
||||||
fields: Record<string, any>
|
fields: Record<string, any>
|
||||||
|
timestamp?: number
|
||||||
// time in seconds
|
// time in seconds
|
||||||
timeout: number
|
timeout: number
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue