Merge branch 'master' into readme-stale-link
This commit is contained in:
commit
b2e5eb4a46
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "3.3.6",
|
||||
"version": "3.4.3",
|
||||
"npmClient": "yarn",
|
||||
"concurrency": 20,
|
||||
"command": {
|
||||
|
|
|
@ -1,45 +1,58 @@
|
|||
import events from "events"
|
||||
import { newid } from "../utils"
|
||||
import { Queue, QueueOptions, JobOptions } from "./queue"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
import { Job, JobId, JobInformation } from "bull"
|
||||
|
||||
interface JobMessage {
|
||||
function jobToJobInformation(job: Job): JobInformation {
|
||||
let cron = ""
|
||||
let every = -1
|
||||
let tz: string | undefined = undefined
|
||||
let endDate: number | undefined = undefined
|
||||
|
||||
const repeat = job.opts?.repeat
|
||||
if (repeat) {
|
||||
endDate = repeat.endDate ? new Date(repeat.endDate).getTime() : Date.now()
|
||||
tz = repeat.tz
|
||||
if ("cron" in repeat) {
|
||||
cron = repeat.cron
|
||||
} else {
|
||||
every = repeat.every
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: job.id.toString(),
|
||||
name: "",
|
||||
key: job.id.toString(),
|
||||
tz,
|
||||
endDate,
|
||||
cron,
|
||||
every,
|
||||
next: 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface JobMessage<T = any> extends Partial<Job<T>> {
|
||||
id: string
|
||||
timestamp: number
|
||||
queue: string
|
||||
queue: Queue<T>
|
||||
data: any
|
||||
opts?: JobOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Bull works with a Job wrapper around all messages that contains a lot more information about
|
||||
* the state of the message, this object constructor implements the same schema of Bull jobs
|
||||
* for the sake of maintaining API consistency.
|
||||
* @param queue The name of the queue which the message will be carried on.
|
||||
* @param message The JSON message which will be passed back to the consumer.
|
||||
* @returns A new job which can now be put onto the queue, this is mostly an
|
||||
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
|
||||
*/
|
||||
function newJob(queue: string, message: any, opts?: JobOptions): JobMessage {
|
||||
return {
|
||||
id: newid(),
|
||||
timestamp: Date.now(),
|
||||
queue: queue,
|
||||
data: message,
|
||||
opts,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock.
|
||||
* It is relatively simple, using an event emitter internally to register when messages are available
|
||||
* to the consumers - in can support many inputs and many consumers.
|
||||
* This is designed to replicate Bull (https://github.com/OptimalBits/bull) in
|
||||
* memory as a sort of mock. It is relatively simple, using an event emitter
|
||||
* internally to register when messages are available to the consumers - in can
|
||||
* support many inputs and many consumers.
|
||||
*/
|
||||
class InMemoryQueue implements Partial<Queue> {
|
||||
_name: string
|
||||
_opts?: QueueOptions
|
||||
_messages: JobMessage[]
|
||||
_queuedJobIds: Set<string>
|
||||
_emitter: NodeJS.EventEmitter
|
||||
_emitter: NodeJS.EventEmitter<{ message: [JobMessage]; completed: [Job] }>
|
||||
_runCount: number
|
||||
_addCount: number
|
||||
|
||||
|
@ -69,34 +82,29 @@ class InMemoryQueue implements Partial<Queue> {
|
|||
*/
|
||||
async process(concurrencyOrFunc: number | any, func?: any) {
|
||||
func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc
|
||||
this._emitter.on("message", async () => {
|
||||
if (this._messages.length <= 0) {
|
||||
return
|
||||
}
|
||||
let msg = this._messages.shift()
|
||||
|
||||
let resp = func(msg)
|
||||
this._emitter.on("message", async message => {
|
||||
let resp = func(message)
|
||||
|
||||
async function retryFunc(fnc: any) {
|
||||
try {
|
||||
await fnc
|
||||
} catch (e: any) {
|
||||
await new Promise<void>(r => setTimeout(() => r(), 50))
|
||||
|
||||
await retryFunc(func(msg))
|
||||
await helpers.wait(50)
|
||||
await retryFunc(func(message))
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.then != null) {
|
||||
try {
|
||||
await retryFunc(resp)
|
||||
this._emitter.emit("completed", message as Job)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
this._runCount++
|
||||
const jobId = msg?.opts?.jobId?.toString()
|
||||
if (jobId && msg?.opts?.removeOnComplete) {
|
||||
const jobId = message.opts?.jobId?.toString()
|
||||
if (jobId && message.opts?.removeOnComplete) {
|
||||
this._queuedJobIds.delete(jobId)
|
||||
}
|
||||
})
|
||||
|
@ -130,9 +138,16 @@ class InMemoryQueue implements Partial<Queue> {
|
|||
}
|
||||
|
||||
const pushMessage = () => {
|
||||
this._messages.push(newJob(this._name, data, opts))
|
||||
const message: JobMessage = {
|
||||
id: newid(),
|
||||
timestamp: Date.now(),
|
||||
queue: this as unknown as Queue,
|
||||
data,
|
||||
opts,
|
||||
}
|
||||
this._messages.push(message)
|
||||
this._addCount++
|
||||
this._emitter.emit("message")
|
||||
this._emitter.emit("message", message)
|
||||
}
|
||||
|
||||
const delay = opts?.delay
|
||||
|
@ -158,13 +173,6 @@ class InMemoryQueue implements Partial<Queue> {
|
|||
console.log(cronJobId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemented for tests
|
||||
*/
|
||||
async getRepeatableJobs() {
|
||||
return []
|
||||
}
|
||||
|
||||
async removeJobs(_pattern: string) {
|
||||
// no-op
|
||||
}
|
||||
|
@ -176,13 +184,31 @@ class InMemoryQueue implements Partial<Queue> {
|
|||
return []
|
||||
}
|
||||
|
||||
async getJob() {
|
||||
async getJob(id: JobId) {
|
||||
for (const message of this._messages) {
|
||||
if (message.id === id) {
|
||||
return message as Job
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
on() {
|
||||
// do nothing
|
||||
return this as any
|
||||
on(event: string, callback: (...args: any[]) => void): Queue {
|
||||
// @ts-expect-error - this callback can be one of many types
|
||||
this._emitter.on(event, callback)
|
||||
return this as unknown as Queue
|
||||
}
|
||||
|
||||
async count() {
|
||||
return this._messages.length
|
||||
}
|
||||
|
||||
async getCompletedCount() {
|
||||
return this._runCount
|
||||
}
|
||||
|
||||
async getRepeatableJobs() {
|
||||
return this._messages.map(job => jobToJobInformation(job as Job))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -388,7 +388,7 @@ class InternalBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
if (typeof input === "string") {
|
||||
if (typeof input === "string" && schema.type === FieldType.DATETIME) {
|
||||
if (isInvalidISODateString(input)) {
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -1,5 +1,12 @@
|
|||
import { Feature, License, Quotas } from "@budibase/types"
|
||||
import {
|
||||
Feature,
|
||||
License,
|
||||
MonthlyQuotaName,
|
||||
QuotaType,
|
||||
QuotaUsageType,
|
||||
} from "@budibase/types"
|
||||
import cloneDeep from "lodash/cloneDeep"
|
||||
import merge from "lodash/merge"
|
||||
|
||||
let CLOUD_FREE_LICENSE: License
|
||||
let UNLIMITED_LICENSE: License
|
||||
|
@ -27,18 +34,19 @@ export function initInternal(opts: {
|
|||
|
||||
export interface UseLicenseOpts {
|
||||
features?: Feature[]
|
||||
quotas?: Quotas
|
||||
monthlyQuotas?: [MonthlyQuotaName, number][]
|
||||
}
|
||||
|
||||
// LICENSES
|
||||
|
||||
export const useLicense = (license: License, opts?: UseLicenseOpts) => {
|
||||
if (opts) {
|
||||
if (opts.features) {
|
||||
license.features.push(...opts.features)
|
||||
}
|
||||
if (opts.quotas) {
|
||||
license.quotas = opts.quotas
|
||||
if (opts?.features) {
|
||||
license.features.push(...opts.features)
|
||||
}
|
||||
if (opts?.monthlyQuotas) {
|
||||
for (const [name, value] of opts.monthlyQuotas) {
|
||||
license.quotas[QuotaType.USAGE][QuotaUsageType.MONTHLY][name].value =
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,12 +65,9 @@ export const useCloudFree = () => {
|
|||
|
||||
// FEATURES
|
||||
|
||||
const useFeature = (feature: Feature) => {
|
||||
const useFeature = (feature: Feature, extra?: Partial<UseLicenseOpts>) => {
|
||||
const license = cloneDeep(getCachedLicense() || UNLIMITED_LICENSE)
|
||||
const opts: UseLicenseOpts = {
|
||||
features: [feature],
|
||||
}
|
||||
|
||||
const opts: UseLicenseOpts = merge({ features: [feature] }, extra)
|
||||
return useLicense(license, opts)
|
||||
}
|
||||
|
||||
|
@ -102,8 +107,12 @@ export const useAppBuilders = () => {
|
|||
return useFeature(Feature.APP_BUILDERS)
|
||||
}
|
||||
|
||||
export const useBudibaseAI = () => {
|
||||
return useFeature(Feature.BUDIBASE_AI)
|
||||
export const useBudibaseAI = (opts?: { monthlyQuota?: number }) => {
|
||||
return useFeature(Feature.BUDIBASE_AI, {
|
||||
monthlyQuotas: [
|
||||
[MonthlyQuotaName.BUDIBASE_AI_CREDITS, opts?.monthlyQuota || 1000],
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export const useAICustomConfigs = () => {
|
||||
|
|
|
@ -80,7 +80,7 @@
|
|||
"dayjs": "^1.10.8",
|
||||
"easymde": "^2.16.1",
|
||||
"svelte-dnd-action": "^0.9.8",
|
||||
"svelte-portal": "^1.0.0"
|
||||
"svelte-portal": "^2.2.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"loader-utils": "1.4.1"
|
||||
|
|
|
@ -1,3 +1,17 @@
|
|||
type ClickOutsideCallback = (event: MouseEvent) => void | undefined
|
||||
|
||||
interface ClickOutsideOpts {
|
||||
callback?: ClickOutsideCallback
|
||||
anchor?: HTMLElement
|
||||
}
|
||||
|
||||
interface Handler {
|
||||
id: number
|
||||
element: HTMLElement
|
||||
anchor: HTMLElement
|
||||
callback?: ClickOutsideCallback
|
||||
}
|
||||
|
||||
// These class names will never trigger a callback if clicked, no matter what
|
||||
const ignoredClasses = [
|
||||
".download-js-link",
|
||||
|
@ -14,18 +28,20 @@ const conditionallyIgnoredClasses = [
|
|||
".drawer-wrapper",
|
||||
".spectrum-Popover",
|
||||
]
|
||||
let clickHandlers = []
|
||||
let candidateTarget
|
||||
let clickHandlers: Handler[] = []
|
||||
let candidateTarget: HTMLElement | undefined
|
||||
|
||||
// Processes a "click outside" event and invokes callbacks if our source element
|
||||
// is valid
|
||||
const handleClick = event => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = (e.target || e.relatedTarget) as HTMLElement
|
||||
|
||||
// Ignore click if this is an ignored class
|
||||
if (event.target.closest('[data-ignore-click-outside="true"]')) {
|
||||
if (target.closest('[data-ignore-click-outside="true"]')) {
|
||||
return
|
||||
}
|
||||
for (let className of ignoredClasses) {
|
||||
if (event.target.closest(className)) {
|
||||
if (target.closest(className)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -33,41 +49,41 @@ const handleClick = event => {
|
|||
// Process handlers
|
||||
clickHandlers.forEach(handler => {
|
||||
// Check that the click isn't inside the target
|
||||
if (handler.element.contains(event.target)) {
|
||||
if (handler.element.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore clicks for certain classes unless we're nested inside them
|
||||
for (let className of conditionallyIgnoredClasses) {
|
||||
const sourceInside = handler.anchor.closest(className) != null
|
||||
const clickInside = event.target.closest(className) != null
|
||||
const clickInside = target.closest(className) != null
|
||||
if (clickInside && !sourceInside) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
handler.callback?.(event)
|
||||
handler.callback?.(e)
|
||||
})
|
||||
}
|
||||
|
||||
// On mouse up we only trigger a "click outside" callback if we targetted the
|
||||
// same element that we did on mouse down. This fixes all sorts of issues where
|
||||
// we get annoying callbacks firing when we drag to select text.
|
||||
const handleMouseUp = e => {
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (candidateTarget === e.target) {
|
||||
handleClick(e)
|
||||
}
|
||||
candidateTarget = null
|
||||
candidateTarget = undefined
|
||||
}
|
||||
|
||||
// On mouse down we store which element was targetted for comparison later
|
||||
const handleMouseDown = e => {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
// Only handle the primary mouse button here.
|
||||
// We handle context menu (right click) events in another handler.
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
candidateTarget = e.target
|
||||
candidateTarget = e.target as HTMLElement
|
||||
|
||||
// Clear any previous listeners in case of multiple down events, and register
|
||||
// a single mouse up listener
|
||||
|
@ -75,14 +91,29 @@ const handleMouseDown = e => {
|
|||
document.addEventListener("click", handleMouseUp, true)
|
||||
}
|
||||
|
||||
// Handle iframe clicks by detecting a loss of focus on the main window
|
||||
const handleBlur = () => {
|
||||
if (document.activeElement?.tagName === "IFRAME") {
|
||||
handleClick(
|
||||
new MouseEvent("click", { relatedTarget: document.activeElement })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton listeners for our events
|
||||
document.addEventListener("mousedown", handleMouseDown)
|
||||
document.addEventListener("contextmenu", handleClick)
|
||||
window.addEventListener("blur", handleBlur)
|
||||
|
||||
/**
|
||||
* Adds or updates a click handler
|
||||
*/
|
||||
const updateHandler = (id, element, anchor, callback) => {
|
||||
const updateHandler = (
|
||||
id: number,
|
||||
element: HTMLElement,
|
||||
anchor: HTMLElement,
|
||||
callback: ClickOutsideCallback | undefined
|
||||
) => {
|
||||
let existingHandler = clickHandlers.find(x => x.id === id)
|
||||
if (!existingHandler) {
|
||||
clickHandlers.push({ id, element, anchor, callback })
|
||||
|
@ -94,27 +125,52 @@ const updateHandler = (id, element, anchor, callback) => {
|
|||
/**
|
||||
* Removes a click handler
|
||||
*/
|
||||
const removeHandler = id => {
|
||||
const removeHandler = (id: number) => {
|
||||
clickHandlers = clickHandlers.filter(x => x.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte action to apply a click outside handler for a certain element
|
||||
* Svelte action to apply a click outside handler for a certain element.
|
||||
* opts.anchor is an optional param specifying the real root source of the
|
||||
* component being observed. This is required for things like popovers, where
|
||||
* the element using the clickoutside action is the popover, but the popover is
|
||||
* rendered at the root of the DOM somewhere, whereas the popover anchor is the
|
||||
* element we actually want to consider when determining the source component.
|
||||
*/
|
||||
export default (element, opts) => {
|
||||
export default (
|
||||
element: HTMLElement,
|
||||
opts?: ClickOutsideOpts | ClickOutsideCallback
|
||||
) => {
|
||||
const id = Math.random()
|
||||
const update = newOpts => {
|
||||
const callback =
|
||||
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
|
||||
const anchor = newOpts?.anchor || element
|
||||
|
||||
const isCallback = (
|
||||
opts?: ClickOutsideOpts | ClickOutsideCallback
|
||||
): opts is ClickOutsideCallback => {
|
||||
return typeof opts === "function"
|
||||
}
|
||||
|
||||
const isOpts = (
|
||||
opts?: ClickOutsideOpts | ClickOutsideCallback
|
||||
): opts is ClickOutsideOpts => {
|
||||
return opts != null && typeof opts === "object"
|
||||
}
|
||||
|
||||
const update = (newOpts?: ClickOutsideOpts | ClickOutsideCallback) => {
|
||||
let callback: ClickOutsideCallback | undefined
|
||||
let anchor = element
|
||||
if (isCallback(newOpts)) {
|
||||
callback = newOpts
|
||||
} else if (isOpts(newOpts)) {
|
||||
callback = newOpts.callback
|
||||
if (newOpts.anchor) {
|
||||
anchor = newOpts.anchor
|
||||
}
|
||||
}
|
||||
updateHandler(id, element, anchor, callback)
|
||||
}
|
||||
|
||||
update(opts)
|
||||
|
||||
return {
|
||||
update,
|
||||
destroy: () => removeHandler(id),
|
|
@ -1,13 +1,7 @@
|
|||
/**
|
||||
* Valid alignment options are
|
||||
* - left
|
||||
* - right
|
||||
* - left-outside
|
||||
* - right-outside
|
||||
**/
|
||||
|
||||
// Strategies are defined as [Popover]To[Anchor].
|
||||
// They can apply for both horizontal and vertical alignment.
|
||||
import { PopoverAlignment } from "../constants"
|
||||
|
||||
type Strategy =
|
||||
| "StartToStart"
|
||||
| "EndToEnd"
|
||||
|
@ -33,7 +27,7 @@ export type UpdateHandler = (
|
|||
|
||||
interface Opts {
|
||||
anchor?: HTMLElement
|
||||
align: string
|
||||
align: PopoverAlignment
|
||||
maxHeight?: number
|
||||
maxWidth?: number
|
||||
minWidth?: number
|
||||
|
@ -174,24 +168,33 @@ export default function positionDropdown(element: HTMLElement, opts: Opts) {
|
|||
}
|
||||
|
||||
// Determine X strategy
|
||||
if (align === "right") {
|
||||
if (align === PopoverAlignment.Right) {
|
||||
applyXStrategy("EndToEnd")
|
||||
} else if (align === "right-outside" || align === "right-context-menu") {
|
||||
} else if (
|
||||
align === PopoverAlignment.RightOutside ||
|
||||
align === PopoverAlignment.RightContextMenu
|
||||
) {
|
||||
applyXStrategy("StartToEnd")
|
||||
} else if (align === "left-outside" || align === "left-context-menu") {
|
||||
} else if (
|
||||
align === PopoverAlignment.LeftOutside ||
|
||||
align === PopoverAlignment.LeftContextMenu
|
||||
) {
|
||||
applyXStrategy("EndToStart")
|
||||
} else if (align === "center") {
|
||||
} else if (align === PopoverAlignment.Center) {
|
||||
applyXStrategy("MidPoint")
|
||||
} else {
|
||||
applyXStrategy("StartToStart")
|
||||
}
|
||||
|
||||
// Determine Y strategy
|
||||
if (align === "right-outside" || align === "left-outside") {
|
||||
if (
|
||||
align === PopoverAlignment.RightOutside ||
|
||||
align === PopoverAlignment.LeftOutside
|
||||
) {
|
||||
applyYStrategy("MidPoint")
|
||||
} else if (
|
||||
align === "right-context-menu" ||
|
||||
align === "left-context-menu"
|
||||
align === PopoverAlignment.RightContextMenu ||
|
||||
align === PopoverAlignment.LeftContextMenu
|
||||
) {
|
||||
applyYStrategy("StartToStart")
|
||||
if (styles.top) {
|
||||
|
@ -204,11 +207,11 @@ export default function positionDropdown(element: HTMLElement, opts: Opts) {
|
|||
// Handle screen overflow
|
||||
if (doesXOverflow()) {
|
||||
// Swap left to right
|
||||
if (align === "left") {
|
||||
if (align === PopoverAlignment.Left) {
|
||||
applyXStrategy("EndToEnd")
|
||||
}
|
||||
// Swap right-outside to left-outside
|
||||
else if (align === "right-outside") {
|
||||
else if (align === PopoverAlignment.RightOutside) {
|
||||
applyXStrategy("EndToStart")
|
||||
}
|
||||
}
|
||||
|
@ -225,10 +228,13 @@ export default function positionDropdown(element: HTMLElement, opts: Opts) {
|
|||
applyXStrategy("EndToStart")
|
||||
}
|
||||
}
|
||||
// Othewise invert as normal
|
||||
// Otherwise invert as normal
|
||||
else {
|
||||
// If using an outside strategy then lock to the bottom of the screen
|
||||
if (align === "left-outside" || align === "right-outside") {
|
||||
if (
|
||||
align === PopoverAlignment.LeftOutside ||
|
||||
align === PopoverAlignment.RightOutside
|
||||
) {
|
||||
applyYStrategy("ScreenEdge")
|
||||
}
|
||||
// Otherwise flip above
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
import { createEventDispatcher } from "svelte"
|
||||
import clickOutside from "../../Actions/click_outside"
|
||||
import Popover from "../../Popover/Popover.svelte"
|
||||
import { PopoverAlignment } from "../../constants"
|
||||
|
||||
export let value: string | undefined = undefined
|
||||
export let id: string | undefined = undefined
|
||||
|
@ -97,11 +98,16 @@
|
|||
<Popover
|
||||
{anchor}
|
||||
{open}
|
||||
align="left"
|
||||
align={PopoverAlignment.Left}
|
||||
on:close={() => (open = false)}
|
||||
useAnchorWidth
|
||||
>
|
||||
<div class="popover-content" use:clickOutside={() => (open = false)}>
|
||||
<div
|
||||
class="popover-content"
|
||||
use:clickOutside={() => {
|
||||
open = false
|
||||
}}
|
||||
>
|
||||
<ul class="spectrum-Menu" role="listbox">
|
||||
{#if options && Array.isArray(options)}
|
||||
{#each options as option}
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
<script>
|
||||
<script lang="ts" context="module">
|
||||
type O = any
|
||||
type V = any
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/picker/dist/index-vars.css"
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
import "@spectrum-css/menu/dist/index-vars.css"
|
||||
|
@ -11,43 +16,55 @@
|
|||
import Tags from "../../Tags/Tags.svelte"
|
||||
import Tag from "../../Tags/Tag.svelte"
|
||||
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
|
||||
import { PopoverAlignment } from "../../constants"
|
||||
|
||||
export let id = null
|
||||
export let disabled = false
|
||||
export let fieldText = ""
|
||||
export let fieldIcon = ""
|
||||
export let fieldColour = ""
|
||||
export let isPlaceholder = false
|
||||
export let placeholderOption = null
|
||||
export let options = []
|
||||
export let isOptionSelected = () => false
|
||||
export let isOptionEnabled = () => true
|
||||
export let onSelectOption = () => {}
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let getOptionIcon = () => null
|
||||
export let id: string | undefined = undefined
|
||||
export let disabled: boolean = false
|
||||
export let fieldText: string = ""
|
||||
export let fieldIcon: string = ""
|
||||
export let fieldColour: string = ""
|
||||
export let isPlaceholder: boolean = false
|
||||
export let placeholderOption: string | undefined | boolean = undefined
|
||||
export let options: O[] = []
|
||||
export let isOptionSelected = (option: O) => option as unknown as boolean
|
||||
export let isOptionEnabled = (option: O, _index?: number) =>
|
||||
option as unknown as boolean
|
||||
export let onSelectOption: (_value: V) => void = () => {}
|
||||
export let getOptionLabel = (option: O, _index?: number) => `${option}`
|
||||
export let getOptionValue = (option: O, _index?: number) =>
|
||||
option as unknown as V
|
||||
export let getOptionIcon = (option: O, _index?: number) =>
|
||||
option?.icon ?? undefined
|
||||
export let getOptionColour = (option: O, _index?: number) =>
|
||||
option?.colour ?? undefined
|
||||
export let getOptionSubtitle = (option: O, _index?: number) =>
|
||||
option?.subtitle ?? undefined
|
||||
export let useOptionIconImage = false
|
||||
export let getOptionColour = () => null
|
||||
export let getOptionSubtitle = () => null
|
||||
export let open = false
|
||||
export let readonly = false
|
||||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let sort = false
|
||||
export let searchTerm = null
|
||||
export let customPopoverHeight
|
||||
export let align = "left"
|
||||
export let footer = null
|
||||
export let customAnchor = null
|
||||
export let loading
|
||||
export let onOptionMouseenter = () => {}
|
||||
export let onOptionMouseleave = () => {}
|
||||
export let open: boolean = false
|
||||
export let readonly: boolean = false
|
||||
export let quiet: boolean = false
|
||||
export let autoWidth: boolean | undefined = false
|
||||
export let autocomplete: boolean = false
|
||||
export let sort: boolean = false
|
||||
export let searchTerm: string | null = null
|
||||
export let customPopoverHeight: string | undefined = undefined
|
||||
export let align: PopoverAlignment | undefined = PopoverAlignment.Left
|
||||
export let footer: string | undefined = undefined
|
||||
export let customAnchor: HTMLElement | undefined = undefined
|
||||
export let loading: boolean = false
|
||||
export let onOptionMouseenter: (
|
||||
_e: MouseEvent,
|
||||
_option: any
|
||||
) => void = () => {}
|
||||
export let onOptionMouseleave: (
|
||||
_e: MouseEvent,
|
||||
_option: any
|
||||
) => void = () => {}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let button
|
||||
let component
|
||||
let button: any
|
||||
let component: any
|
||||
|
||||
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
|
||||
$: filteredOptions = getFilteredOptions(
|
||||
|
@ -56,7 +73,7 @@
|
|||
getOptionLabel
|
||||
)
|
||||
|
||||
const onClick = e => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
dispatch("click")
|
||||
|
@ -67,7 +84,11 @@
|
|||
open = !open
|
||||
}
|
||||
|
||||
const getSortedOptions = (options, getLabel, sort) => {
|
||||
const getSortedOptions = (
|
||||
options: any[],
|
||||
getLabel: (_option: any) => string,
|
||||
sort: boolean
|
||||
) => {
|
||||
if (!options?.length || !Array.isArray(options)) {
|
||||
return []
|
||||
}
|
||||
|
@ -81,17 +102,21 @@
|
|||
})
|
||||
}
|
||||
|
||||
const getFilteredOptions = (options, term, getLabel) => {
|
||||
const getFilteredOptions = (
|
||||
options: any[],
|
||||
term: string | null,
|
||||
getLabel: (_option: any) => string
|
||||
) => {
|
||||
if (autocomplete && term) {
|
||||
const lowerCaseTerm = term.toLowerCase()
|
||||
return options.filter(option => {
|
||||
return options.filter((option: any) => {
|
||||
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
const onScroll = e => {
|
||||
const onScroll = (e: any) => {
|
||||
const scrollPxThreshold = 100
|
||||
const scrollPositionFromBottom =
|
||||
e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop
|
||||
|
@ -151,18 +176,20 @@
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<Popover
|
||||
anchor={customAnchor ? customAnchor : button}
|
||||
align={align || "left"}
|
||||
align={align || PopoverAlignment.Left}
|
||||
{open}
|
||||
on:close={() => (open = false)}
|
||||
useAnchorWidth={!autoWidth}
|
||||
maxWidth={autoWidth ? 400 : null}
|
||||
maxWidth={autoWidth ? 400 : undefined}
|
||||
customHeight={customPopoverHeight}
|
||||
maxHeight={360}
|
||||
>
|
||||
<div
|
||||
class="popover-content"
|
||||
class:auto-width={autoWidth}
|
||||
use:clickOutside={() => (open = false)}
|
||||
use:clickOutside={() => {
|
||||
open = false
|
||||
}}
|
||||
>
|
||||
{#if autocomplete}
|
||||
<Search
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/search/dist/index-vars.css"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let placeholder = null
|
||||
export let value: any = null
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let disabled = false
|
||||
export let id = null
|
||||
export let updateOnChange = true
|
||||
export let quiet = false
|
||||
export let inputRef
|
||||
export let inputRef: HTMLElement | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let focus = false
|
||||
|
||||
const updateValue = value => {
|
||||
const updateValue = (value: any) => {
|
||||
dispatch("change", value)
|
||||
}
|
||||
|
||||
|
@ -21,19 +21,19 @@
|
|||
focus = true
|
||||
}
|
||||
|
||||
const onBlur = event => {
|
||||
const onBlur = (event: any) => {
|
||||
focus = false
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const onInput = event => {
|
||||
const onInput = (event: any) => {
|
||||
if (!updateOnChange) {
|
||||
return
|
||||
}
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const updateValueOnEnter = event => {
|
||||
const updateValueOnEnter = (event: any) => {
|
||||
if (event.key === "Enter") {
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
|
|
@ -1,33 +1,44 @@
|
|||
<script>
|
||||
<script lang="ts" context="module">
|
||||
type O = any
|
||||
type V = any
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import Picker from "./Picker.svelte"
|
||||
import { PopoverAlignment } from "../../constants"
|
||||
|
||||
export let value = null
|
||||
export let id = null
|
||||
export let placeholder = "Choose an option"
|
||||
export let disabled = false
|
||||
export let options = []
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let getOptionIcon = () => null
|
||||
export let getOptionColour = () => null
|
||||
export let getOptionSubtitle = () => null
|
||||
export let compare = null
|
||||
export let value: V | null = null
|
||||
export let id: string | undefined = undefined
|
||||
export let placeholder: string | boolean = "Choose an option"
|
||||
export let disabled: boolean = false
|
||||
export let options: O[] = []
|
||||
export let getOptionLabel = (option: O, _index?: number) => `${option}`
|
||||
export let getOptionValue = (option: O, _index?: number) =>
|
||||
option as unknown as V
|
||||
export let getOptionIcon = (option: O, _index?: number) =>
|
||||
option?.icon ?? undefined
|
||||
export let getOptionColour = (option: O, _index?: number) =>
|
||||
option?.colour ?? undefined
|
||||
export let getOptionSubtitle = (option: O, _index?: number) =>
|
||||
option?.subtitle ?? undefined
|
||||
export let compare = (option: O, value: V) => option === value
|
||||
export let useOptionIconImage = false
|
||||
export let isOptionEnabled
|
||||
export let readonly = false
|
||||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
export let autocomplete = false
|
||||
export let sort = false
|
||||
export let align
|
||||
export let footer = null
|
||||
export let open = false
|
||||
export let tag = null
|
||||
export let searchTerm = null
|
||||
export let loading
|
||||
export let isOptionEnabled = (option: O, _index?: number) =>
|
||||
option as unknown as boolean
|
||||
export let readonly: boolean = false
|
||||
export let quiet: boolean = false
|
||||
export let autoWidth: boolean = false
|
||||
export let autocomplete: boolean = false
|
||||
export let sort: boolean = false
|
||||
export let align: PopoverAlignment | undefined = PopoverAlignment.Left
|
||||
export let footer: string | undefined = undefined
|
||||
export let open: boolean = false
|
||||
export let searchTerm: string | undefined = undefined
|
||||
export let loading: boolean | undefined = undefined
|
||||
export let onOptionMouseenter = () => {}
|
||||
export let onOptionMouseleave = () => {}
|
||||
export let customPopoverHeight: string | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -35,24 +46,28 @@
|
|||
$: fieldIcon = getFieldAttribute(getOptionIcon, value, options)
|
||||
$: fieldColour = getFieldAttribute(getOptionColour, value, options)
|
||||
|
||||
function compareOptionAndValue(option, value) {
|
||||
function compareOptionAndValue(option: O, value: V) {
|
||||
return typeof compare === "function"
|
||||
? compare(option, value)
|
||||
: option === value
|
||||
}
|
||||
|
||||
const getFieldAttribute = (getAttribute, value, options) => {
|
||||
const getFieldAttribute = (getAttribute: any, value: V[], options: O[]) => {
|
||||
// Wait for options to load if there is a value but no options
|
||||
if (!options?.length) {
|
||||
return ""
|
||||
}
|
||||
const index = options.findIndex((option, idx) =>
|
||||
const index = options.findIndex((option: any, idx: number) =>
|
||||
compareOptionAndValue(getOptionValue(option, idx), value)
|
||||
)
|
||||
return index !== -1 ? getAttribute(options[index], index) : null
|
||||
}
|
||||
|
||||
const getFieldText = (value, options, placeholder) => {
|
||||
const getFieldText = (
|
||||
value: any,
|
||||
options: any,
|
||||
placeholder: boolean | string
|
||||
) => {
|
||||
if (value == null || value === "") {
|
||||
// Explicit false means use no placeholder and allow an empty fields
|
||||
if (placeholder === false) {
|
||||
|
@ -67,7 +82,7 @@
|
|||
)
|
||||
}
|
||||
|
||||
const selectOption = value => {
|
||||
const selectOption = (value: V) => {
|
||||
dispatch("change", value)
|
||||
open = false
|
||||
}
|
||||
|
@ -98,14 +113,14 @@
|
|||
{isOptionEnabled}
|
||||
{autocomplete}
|
||||
{sort}
|
||||
{tag}
|
||||
{onOptionMouseenter}
|
||||
{onOptionMouseleave}
|
||||
isPlaceholder={value == null || value === ""}
|
||||
placeholderOption={placeholder === false
|
||||
? null
|
||||
? undefined
|
||||
: placeholder || "Choose an option"}
|
||||
isOptionSelected={option => compareOptionAndValue(option, value)}
|
||||
onSelectOption={selectOption}
|
||||
{loading}
|
||||
{customPopoverHeight}
|
||||
/>
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/textfield/dist/index-vars.css"
|
||||
import { createEventDispatcher, onMount, tick } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let placeholder = null
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let type = "text"
|
||||
export let disabled = false
|
||||
export let id = null
|
||||
export let readonly = false
|
||||
export let updateOnChange = true
|
||||
export let quiet = false
|
||||
export let align
|
||||
export let align: "left" | "right" | "center" | undefined = undefined
|
||||
export let autofocus = false
|
||||
export let autocomplete = null
|
||||
export let autocomplete: boolean | undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let field
|
||||
let field: any
|
||||
let focus = false
|
||||
|
||||
const updateValue = newValue => {
|
||||
const updateValue = (newValue: any) => {
|
||||
if (readonly || disabled) {
|
||||
return
|
||||
}
|
||||
|
@ -37,7 +37,7 @@
|
|||
focus = true
|
||||
}
|
||||
|
||||
const onBlur = event => {
|
||||
const onBlur = (event: any) => {
|
||||
if (readonly || disabled) {
|
||||
return
|
||||
}
|
||||
|
@ -45,14 +45,14 @@
|
|||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const onInput = event => {
|
||||
const onInput = (event: any) => {
|
||||
if (readonly || !updateOnChange || disabled) {
|
||||
return
|
||||
}
|
||||
updateValue(event.target.value)
|
||||
}
|
||||
|
||||
const updateValueOnEnter = event => {
|
||||
const updateValueOnEnter = (event: any) => {
|
||||
if (readonly || disabled) {
|
||||
return
|
||||
}
|
||||
|
@ -61,13 +61,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
const getInputMode = type => {
|
||||
const getInputMode = (type: any) => {
|
||||
if (type === "bigint") {
|
||||
return "numeric"
|
||||
}
|
||||
return type === "number" ? "decimal" : "text"
|
||||
}
|
||||
|
||||
$: autocompleteValue =
|
||||
typeof autocomplete === "boolean"
|
||||
? autocomplete
|
||||
? "on"
|
||||
: "off"
|
||||
: undefined
|
||||
|
||||
onMount(async () => {
|
||||
if (disabled) return
|
||||
focus = autofocus
|
||||
|
@ -104,7 +111,7 @@
|
|||
class="spectrum-Textfield-input"
|
||||
style={align ? `text-align: ${align};` : ""}
|
||||
inputmode={getInputMode(type)}
|
||||
{autocomplete}
|
||||
autocomplete={autocompleteValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/fieldlabel/dist/index-vars.css"
|
||||
import FieldLabel from "./FieldLabel.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
|
||||
export let id = null
|
||||
export let label = null
|
||||
export let labelPosition = "above"
|
||||
export let error = null
|
||||
export let helpText = null
|
||||
export let tooltip = ""
|
||||
export let id: string | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let labelPosition: string = "above"
|
||||
export let error: string | undefined = undefined
|
||||
export let helpText: string | undefined = undefined
|
||||
export let tooltip: string | undefined = undefined
|
||||
</script>
|
||||
|
||||
<div class="spectrum-Form-item" class:above={labelPosition === "above"}>
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte"
|
||||
import TextField from "./Core/TextField.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let label = null
|
||||
export let value: any = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let labelPosition = "above"
|
||||
export let placeholder = null
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let type = "text"
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let error = null
|
||||
export let error: string | undefined = undefined
|
||||
export let updateOnChange = true
|
||||
export let quiet = false
|
||||
export let autofocus
|
||||
export let autocomplete
|
||||
export let helpText = null
|
||||
export let autofocus: boolean | undefined = undefined
|
||||
export let autocomplete: boolean | undefined = undefined
|
||||
export let helpText: string | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
const onChange = (e: any) => {
|
||||
value = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
|
@ -27,7 +27,6 @@
|
|||
<Field {helpText} {label} {labelPosition} {error}>
|
||||
<TextField
|
||||
{updateOnChange}
|
||||
{error}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{value}
|
||||
|
|
|
@ -1,44 +1,54 @@
|
|||
<script>
|
||||
<script lang="ts" context="module">
|
||||
type O = any
|
||||
type V = any
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Field from "./Field.svelte"
|
||||
import Select from "./Core/Select.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { PopoverAlignment } from "../constants"
|
||||
|
||||
export let value = null
|
||||
export let label = undefined
|
||||
export let disabled = false
|
||||
export let readonly = false
|
||||
export let labelPosition = "above"
|
||||
export let error = null
|
||||
export let placeholder = "Choose an option"
|
||||
export let options = []
|
||||
export let getOptionLabel = option => extractProperty(option, "label")
|
||||
export let getOptionValue = option => extractProperty(option, "value")
|
||||
export let getOptionSubtitle = option => option?.subtitle
|
||||
export let getOptionIcon = option => option?.icon
|
||||
export let getOptionColour = option => option?.colour
|
||||
export let value: V | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let disabled: boolean = false
|
||||
export let readonly: boolean = false
|
||||
export let labelPosition: string = "above"
|
||||
export let error: string | undefined = undefined
|
||||
export let placeholder: string | boolean = "Choose an option"
|
||||
export let options: O[] = []
|
||||
export let getOptionLabel = (option: O, _index?: number) =>
|
||||
extractProperty(option, "label")
|
||||
export let getOptionValue = (option: O, _index?: number) =>
|
||||
extractProperty(option, "value")
|
||||
export let getOptionSubtitle = (option: O, _index?: number) =>
|
||||
option?.subtitle
|
||||
export let getOptionIcon = (option: O, _index?: number) => option?.icon
|
||||
export let getOptionColour = (option: O, _index?: number) => option?.colour
|
||||
export let useOptionIconImage = false
|
||||
export let isOptionEnabled = undefined
|
||||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
export let sort = false
|
||||
export let tooltip = ""
|
||||
export let autocomplete = false
|
||||
export let customPopoverHeight = undefined
|
||||
export let align = undefined
|
||||
export let footer = null
|
||||
export let tag = null
|
||||
export let helpText = null
|
||||
export let compare = undefined
|
||||
export let isOptionEnabled:
|
||||
| ((_option: O, _index?: number) => boolean)
|
||||
| undefined = undefined
|
||||
export let quiet: boolean = false
|
||||
export let autoWidth: boolean = false
|
||||
export let sort: boolean = false
|
||||
export let tooltip: string | undefined = undefined
|
||||
export let autocomplete: boolean = false
|
||||
export let customPopoverHeight: string | undefined = undefined
|
||||
export let align: PopoverAlignment | undefined = PopoverAlignment.Left
|
||||
export let footer: string | undefined = undefined
|
||||
export let helpText: string | undefined = undefined
|
||||
export let compare: any = undefined
|
||||
export let onOptionMouseenter = () => {}
|
||||
export let onOptionMouseleave = () => {}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onChange = e => {
|
||||
const onChange = (e: CustomEvent<any>) => {
|
||||
value = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
|
||||
const extractProperty = (value, property) => {
|
||||
const extractProperty = (value: any, property: any) => {
|
||||
if (value && typeof value === "object") {
|
||||
return value[property]
|
||||
}
|
||||
|
@ -49,7 +59,6 @@
|
|||
<Field {helpText} {label} {labelPosition} {error} {tooltip}>
|
||||
<Select
|
||||
{quiet}
|
||||
{error}
|
||||
{disabled}
|
||||
{readonly}
|
||||
{value}
|
||||
|
@ -68,7 +77,6 @@
|
|||
{isOptionEnabled}
|
||||
{autocomplete}
|
||||
{customPopoverHeight}
|
||||
{tag}
|
||||
{compare}
|
||||
{onOptionMouseenter}
|
||||
{onOptionMouseleave}
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
default as AbsTooltip,
|
||||
TooltipPosition,
|
||||
TooltipType,
|
||||
} from "../Tooltip/AbsTooltip.svelte"
|
||||
import AbsTooltip from "../Tooltip/AbsTooltip.svelte"
|
||||
import { TooltipPosition, TooltipType } from "../constants"
|
||||
|
||||
export let name: string = "Add"
|
||||
export let size: "XS" | "S" | "M" | "L" | "XL" = "M"
|
||||
export let hidden: boolean = false
|
||||
export let size = "M"
|
||||
export let hoverable: boolean = false
|
||||
export let disabled: boolean = false
|
||||
export let color: string | undefined = undefined
|
||||
|
@ -81,17 +78,6 @@
|
|||
color: var(--spectrum-global-color-gray-500) !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.spectrum-Icon--sizeXS {
|
||||
width: var(--spectrum-global-dimension-size-150);
|
||||
height: var(--spectrum-global-dimension-size-150);
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
export let primary = false
|
||||
export let secondary = false
|
||||
export let overBackground = false
|
||||
export let target
|
||||
export let download
|
||||
export let target = undefined
|
||||
export let download = undefined
|
||||
export let disabled = false
|
||||
export let tooltip = null
|
||||
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
<script context="module" lang="ts">
|
||||
export interface PopoverAPI {
|
||||
show: () => void
|
||||
hide: () => void
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
// @ts-expect-error no types for the version of svelte-portal we're on.
|
||||
import Portal from "svelte-portal"
|
||||
import { createEventDispatcher, getContext, onDestroy } from "svelte"
|
||||
import positionDropdown, {
|
||||
|
@ -10,12 +16,10 @@
|
|||
import { fly } from "svelte/transition"
|
||||
import Context from "../context"
|
||||
import type { KeyboardEventHandler } from "svelte/elements"
|
||||
|
||||
const dispatch = createEventDispatcher<{ open: void; close: void }>()
|
||||
import { PopoverAlignment } from "../constants"
|
||||
|
||||
export let anchor: HTMLElement
|
||||
export let align: "left" | "right" | "left-outside" | "right-outside" =
|
||||
"right"
|
||||
export let align: PopoverAlignment = PopoverAlignment.Right
|
||||
export let portalTarget: string | undefined = undefined
|
||||
export let minWidth: number | undefined = undefined
|
||||
export let maxWidth: number | undefined = undefined
|
||||
|
@ -26,19 +30,24 @@
|
|||
export let offset = 4
|
||||
export let customHeight: string | undefined = undefined
|
||||
export let animate = true
|
||||
export let customZindex: string | undefined = undefined
|
||||
export let handlePostionUpdate: UpdateHandler | undefined = undefined
|
||||
export let customZIndex: number | undefined = undefined
|
||||
export let handlePositionUpdate: UpdateHandler | undefined = undefined
|
||||
export let showPopover = true
|
||||
export let clickOutsideOverride = false
|
||||
export let resizable = true
|
||||
export let wrap = false
|
||||
|
||||
const dispatch = createEventDispatcher<{ open: void; close: void }>()
|
||||
const animationDuration = 260
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
let blockPointerEvents = false
|
||||
|
||||
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
|
||||
// Portal library lacks types, so we have to type this as any even though it's
|
||||
// actually a string
|
||||
$: target = (portalTarget ||
|
||||
getContext(Context.PopoverRoot) ||
|
||||
".spectrum") as any
|
||||
$: {
|
||||
// Disable pointer events for the initial part of the animation, because we
|
||||
// fly from top to bottom and initially can be positioned under the cursor,
|
||||
|
@ -118,7 +127,7 @@
|
|||
minWidth,
|
||||
useAnchorWidth,
|
||||
offset,
|
||||
customUpdate: handlePostionUpdate,
|
||||
customUpdate: handlePositionUpdate,
|
||||
resizable,
|
||||
wrap,
|
||||
}}
|
||||
|
@ -128,11 +137,11 @@
|
|||
}}
|
||||
on:keydown={handleEscape}
|
||||
class="spectrum-Popover is-open"
|
||||
class:customZindex
|
||||
class:customZIndex
|
||||
class:hidden={!showPopover}
|
||||
class:blockPointerEvents
|
||||
role="presentation"
|
||||
style="height: {customHeight}; --customZindex: {customZindex};"
|
||||
style="height: {customHeight}; --customZIndex: {customZIndex};"
|
||||
transition:fly|local={{
|
||||
y: -20,
|
||||
duration: animate ? animationDuration : 0,
|
||||
|
@ -162,7 +171,7 @@
|
|||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.customZindex {
|
||||
z-index: var(--customZindex) !important;
|
||||
.customZIndex {
|
||||
z-index: var(--customZIndex) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,120 +0,0 @@
|
|||
<script>
|
||||
import { View } from "svench";
|
||||
import Popover from "./Popover.svelte";
|
||||
import Button from "../Button/Button.svelte";
|
||||
import TextButton from "../Button/TextButton.svelte";
|
||||
import Icon from "../Icons/Icon.svelte";
|
||||
import Input from "../Form/Input.svelte";
|
||||
import Select from "../Form/Select.svelte";
|
||||
|
||||
let anchorRight;
|
||||
let anchorLeft;
|
||||
let dropdownRight;
|
||||
let dropdownLeft;
|
||||
|
||||
const options = ["Column 1", "Column 2", "Super cool column"];
|
||||
const option1s = ["Is", "Is not", "Contains" , "Does not contain"];
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.button-group {
|
||||
margin-top: var(--spacing-l);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: var(--font-size-m);
|
||||
margin: 0 0 var(--spacing-l) 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-group-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.input-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: [boolean-start] 60px [boolean-end property-start] 120px [property-end opererator-start] 110px [operator-end value-start] auto [value-end menu-start] 32px [menu-end];
|
||||
gap: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
p {
|
||||
margin:0;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
<View name="Simple popover">
|
||||
<div bind:this={anchorLeft}>
|
||||
<Button text on:click={dropdownLeft.show}>
|
||||
<Icon name="view" />
|
||||
Add View
|
||||
</Button>
|
||||
</div>
|
||||
<Popover bind:this={dropdownLeft} anchor={anchorLeft} align="left">
|
||||
<h6>Add New View</h6>
|
||||
<Input thin placeholder="Enter your name" />
|
||||
<div class="button-group">
|
||||
<Button secondary on:click={() => alert('Clicked!')}>Cancel</Button>
|
||||
<Button primary on:click={() => alert('Clicked!')}>Add New View</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</View>
|
||||
|
||||
<View name="Stacked columns">
|
||||
<div bind:this={anchorRight}>
|
||||
<Button text on:click={dropdownRight.show}>
|
||||
<Icon name="addrow" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
<Popover bind:this={dropdownRight} anchor={anchorRight}>
|
||||
<h6>Add New Row</h6>
|
||||
<div class="input-group-column">
|
||||
<Input thin placeholder="Enter your string" />
|
||||
<Input thin placeholder="Enter your string" />
|
||||
<Input thin placeholder="Enter your string" />
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<Button secondary on:click={() => alert('Clicked!')}>Cancel</Button>
|
||||
<Button primary on:click={() => alert('Clicked!')}>Add New Row</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</View>
|
||||
|
||||
<View name="Multiple inputs in a row">
|
||||
<div bind:this={anchorLeft}>
|
||||
<Button text on:click={dropdownLeft.show}>
|
||||
<Icon name="filter" />
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
<Popover bind:this={dropdownLeft} anchor={anchorLeft} align="left">
|
||||
<h6>Add New Filter</h6>
|
||||
<div class="input-group-row">
|
||||
<p>Where</p>
|
||||
<Select secondary thin name="Test">
|
||||
{#each options as option}
|
||||
<option value={option}>{option}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<Select secondary thin name="Test">
|
||||
{#each option1s as option1}
|
||||
<option value={option1}>{option1}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<Input thin placeholder="Enter your name" />
|
||||
<Button text on:click={() => alert('Clicked!')}>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button text on:click={() => alert('Clicked!')}>Add Filter</Button>
|
||||
</Popover>
|
||||
</View>
|
|
@ -1,25 +1,25 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import "@spectrum-css/statuslight"
|
||||
|
||||
export let size = "M"
|
||||
export let celery = false
|
||||
export let yellow = false
|
||||
export let fuchsia = false
|
||||
export let indigo = false
|
||||
export let seafoam = false
|
||||
export let chartreuse = false
|
||||
export let magenta = false
|
||||
export let purple = false
|
||||
export let neutral = false
|
||||
export let info = false
|
||||
export let positive = false
|
||||
export let notice = false
|
||||
export let negative = false
|
||||
export let disabled = false
|
||||
export let active = false
|
||||
export let color = null
|
||||
export let square = false
|
||||
export let hoverable = false
|
||||
export let size: string = "M"
|
||||
export let celery: boolean = false
|
||||
export let yellow: boolean = false
|
||||
export let fuchsia: boolean = false
|
||||
export let indigo: boolean = false
|
||||
export let seafoam: boolean = false
|
||||
export let chartreuse: boolean = false
|
||||
export let magenta: boolean = false
|
||||
export let purple: boolean = false
|
||||
export let neutral: boolean = false
|
||||
export let info: boolean = false
|
||||
export let positive: boolean = false
|
||||
export let notice: boolean = false
|
||||
export let negative: boolean = false
|
||||
export let disabled: boolean = false
|
||||
export let active: boolean = false
|
||||
export let color: string | undefined = undefined
|
||||
export let square: boolean = false
|
||||
export let hoverable: boolean = false
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
|
|
@ -1,38 +1,24 @@
|
|||
<script context="module">
|
||||
export const TooltipPosition = {
|
||||
Top: "top",
|
||||
Right: "right",
|
||||
Bottom: "bottom",
|
||||
Left: "left",
|
||||
}
|
||||
export const TooltipType = {
|
||||
Default: "default",
|
||||
Info: "info",
|
||||
Positive: "positive",
|
||||
Negative: "negative",
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Portal from "svelte-portal"
|
||||
import { fade } from "svelte/transition"
|
||||
import "@spectrum-css/tooltip/dist/index-vars.css"
|
||||
import { onDestroy } from "svelte"
|
||||
import { TooltipPosition, TooltipType } from "../constants"
|
||||
|
||||
export let position = TooltipPosition.Top
|
||||
export let type = TooltipType.Default
|
||||
export let text = ""
|
||||
export let fixed = false
|
||||
export let color = ""
|
||||
export let noWrap = false
|
||||
export let position: TooltipPosition = TooltipPosition.Top
|
||||
export let type: TooltipType = TooltipType.Default
|
||||
export let text: string = ""
|
||||
export let fixed: boolean = false
|
||||
export let color: string | undefined = undefined
|
||||
export let noWrap: boolean = false
|
||||
|
||||
let wrapper
|
||||
let wrapper: HTMLElement | undefined
|
||||
let hovered = false
|
||||
let left
|
||||
let top
|
||||
let left: number | undefined
|
||||
let top: number | undefined
|
||||
let visible = false
|
||||
let timeout
|
||||
let interval
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
$: {
|
||||
if (hovered || fixed) {
|
||||
|
@ -49,8 +35,8 @@
|
|||
const updateTooltipPosition = () => {
|
||||
const node = wrapper?.children?.[0]
|
||||
if (!node) {
|
||||
left = null
|
||||
top = null
|
||||
left = undefined
|
||||
top = undefined
|
||||
return
|
||||
}
|
||||
const bounds = node.getBoundingClientRect()
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
export enum PopoverAlignment {
|
||||
Left = "left",
|
||||
Right = "right",
|
||||
LeftOutside = "left-outside",
|
||||
RightOutside = "right-outside",
|
||||
Center = "center",
|
||||
RightContextMenu = "right-context-menu",
|
||||
LeftContextMenu = "left-context-menu",
|
||||
}
|
||||
|
||||
export enum TooltipPosition {
|
||||
Top = "top",
|
||||
Right = "right",
|
||||
Bottom = "bottom",
|
||||
Left = "left",
|
||||
}
|
||||
|
||||
export enum TooltipType {
|
||||
Default = "default",
|
||||
Info = "info",
|
||||
Positive = "positive",
|
||||
Negative = "negative",
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import "./bbui.css"
|
||||
|
||||
// Spectrum icons
|
||||
import "@spectrum-css/icon/dist/index-vars.css"
|
||||
|
||||
// Constants
|
||||
export * from "./constants"
|
||||
|
||||
// Form components
|
||||
export { default as Input } from "./Form/Input.svelte"
|
||||
export { default as Stepper } from "./Form/Stepper.svelte"
|
||||
|
@ -45,7 +46,7 @@ export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
|
|||
export { default as Icon } from "./Icon/Icon.svelte"
|
||||
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
|
||||
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
|
||||
export { default as Popover } from "./Popover/Popover.svelte"
|
||||
export { default as Popover, type PopoverAPI } from "./Popover/Popover.svelte"
|
||||
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
|
||||
export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte"
|
||||
export { default as Label } from "./Label/Label.svelte"
|
||||
|
@ -92,7 +93,6 @@ export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
|
|||
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
|
||||
export { default as Accordion } from "./Accordion/Accordion.svelte"
|
||||
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
|
||||
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
|
||||
|
||||
// Renderers
|
||||
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
|
||||
|
|
|
@ -16,4 +16,4 @@
|
|||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
"shortid": "2.2.15",
|
||||
"svelte-dnd-action": "^0.9.8",
|
||||
"svelte-loading-spinners": "^0.1.1",
|
||||
"svelte-portal": "1.0.0",
|
||||
"svelte-portal": "^2.2.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
<script>
|
||||
import { Popover, Icon } from "@budibase/bbui"
|
||||
<script lang="ts">
|
||||
import {
|
||||
Popover,
|
||||
Icon,
|
||||
PopoverAlignment,
|
||||
type PopoverAPI,
|
||||
} from "@budibase/bbui"
|
||||
|
||||
export let title
|
||||
export let align = "left"
|
||||
export let showPopover
|
||||
export let width
|
||||
export let title: string = ""
|
||||
export let subtitle: string | undefined = undefined
|
||||
export let align: PopoverAlignment = PopoverAlignment.Left
|
||||
export let showPopover: boolean = true
|
||||
export let width: number | undefined = undefined
|
||||
|
||||
let popover
|
||||
let anchor
|
||||
let open
|
||||
let popover: PopoverAPI | undefined
|
||||
let anchor: HTMLElement | undefined
|
||||
let open: boolean = false
|
||||
|
||||
export const show = () => popover?.show()
|
||||
export const hide = () => popover?.hide()
|
||||
|
@ -30,20 +36,24 @@
|
|||
{showPopover}
|
||||
on:open
|
||||
on:close
|
||||
customZindex={100}
|
||||
customZIndex={100}
|
||||
>
|
||||
<div class="detail-popover">
|
||||
<div class="detail-popover__header">
|
||||
<div class="detail-popover__title">
|
||||
{title}
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
hoverColor="var(--spectrum-global-color-gray-900)"
|
||||
on:click={hide}
|
||||
size="S"
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
hoverColor="var(--spectum-global-color-gray-900)"
|
||||
on:click={hide}
|
||||
/>
|
||||
{#if subtitle}
|
||||
<div class="detail-popover__subtitle">{subtitle}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="detail-popover__body">
|
||||
<slot />
|
||||
|
@ -56,14 +66,18 @@
|
|||
background-color: var(--spectrum-alias-background-color-primary);
|
||||
}
|
||||
.detail-popover__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
|
||||
padding: var(--spacing-l) var(--spacing-xl);
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.detail-popover__title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
|
||||
padding: var(--spacing-l) var(--spacing-xl);
|
||||
}
|
||||
.detail-popover__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,281 @@
|
|||
<script context="module" lang="ts">
|
||||
interface JSONViewerClickContext {
|
||||
label: string | undefined
|
||||
value: any
|
||||
path: (string | number)[]
|
||||
}
|
||||
export interface JSONViewerClickEvent {
|
||||
detail: JSONViewerClickContext
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let label: string | undefined = undefined
|
||||
export let value: any = undefined
|
||||
export let root: boolean = true
|
||||
export let path: (string | number)[] = []
|
||||
export let showCopyIcon: boolean = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const Colors = {
|
||||
Array: "var(--spectrum-global-color-gray-600)",
|
||||
Object: "var(--spectrum-global-color-gray-600)",
|
||||
Other: "var(--spectrum-global-color-blue-700)",
|
||||
Undefined: "var(--spectrum-global-color-gray-600)",
|
||||
Null: "var(--spectrum-global-color-yellow-700)",
|
||||
String: "var(--spectrum-global-color-orange-700)",
|
||||
Number: "var(--spectrum-global-color-purple-700)",
|
||||
True: "var(--spectrum-global-color-celery-700)",
|
||||
False: "var(--spectrum-global-color-red-700)",
|
||||
Date: "var(--spectrum-global-color-green-700)",
|
||||
}
|
||||
|
||||
let expanded = false
|
||||
let valueExpanded = false
|
||||
let clickContext: JSONViewerClickContext
|
||||
|
||||
$: isArray = Array.isArray(value)
|
||||
$: isObject = value?.toString?.() === "[object Object]"
|
||||
$: primitive = !(isArray || isObject)
|
||||
$: keys = getKeys(isArray, isObject, value)
|
||||
$: expandable = keys.length > 0
|
||||
$: displayValue = getDisplayValue(isArray, isObject, keys, value)
|
||||
$: style = getStyle(isArray, isObject, value)
|
||||
$: clickContext = { value, label, path }
|
||||
|
||||
const getKeys = (isArray: boolean, isObject: boolean, value: any) => {
|
||||
if (isArray) {
|
||||
return [...value.keys()]
|
||||
}
|
||||
if (isObject) {
|
||||
return Object.keys(value).sort()
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const pluralise = (text: string, number: number) => {
|
||||
return number === 1 ? text : text + "s"
|
||||
}
|
||||
|
||||
const getDisplayValue = (
|
||||
isArray: boolean,
|
||||
isObject: boolean,
|
||||
keys: any[],
|
||||
value: any
|
||||
) => {
|
||||
if (isArray) {
|
||||
return `[] ${keys.length} ${pluralise("item", keys.length)}`
|
||||
}
|
||||
if (isObject) {
|
||||
return `{} ${keys.length} ${pluralise("key", keys.length)}`
|
||||
}
|
||||
if (typeof value === "object" && typeof value?.toString === "function") {
|
||||
return value.toString()
|
||||
} else {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
const getStyle = (isArray: boolean, isObject: boolean, value: any) => {
|
||||
return `color:${getColor(isArray, isObject, value)};`
|
||||
}
|
||||
|
||||
const getColor = (isArray: boolean, isObject: boolean, value: any) => {
|
||||
if (isArray) {
|
||||
return Colors.Array
|
||||
}
|
||||
if (isObject) {
|
||||
return Colors.Object
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return Colors.Date
|
||||
}
|
||||
switch (value) {
|
||||
case undefined:
|
||||
return Colors.Undefined
|
||||
case null:
|
||||
return Colors.Null
|
||||
case true:
|
||||
return Colors.True
|
||||
case false:
|
||||
return Colors.False
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return Colors.String
|
||||
case "number":
|
||||
return Colors.Number
|
||||
}
|
||||
return Colors.Other
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="binding-node">
|
||||
{#if label != null}
|
||||
<div class="binding-text">
|
||||
<div class="binding-arrow" class:expanded>
|
||||
{#if expandable}
|
||||
<Icon
|
||||
name="Play"
|
||||
hoverable
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
hoverColor="var(--spectrum-global-color-gray-900)"
|
||||
on:click={() => (expanded = !expanded)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="binding-label"
|
||||
class:primitive
|
||||
class:expandable
|
||||
on:click={() => (expanded = !expanded)}
|
||||
on:click={() => dispatch("click-label", clickContext)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
class="binding-value"
|
||||
class:primitive
|
||||
class:expanded={valueExpanded}
|
||||
{style}
|
||||
on:click={() => (valueExpanded = !valueExpanded)}
|
||||
on:click={() => dispatch("click-value", clickContext)}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
{#if showCopyIcon}
|
||||
<div class="copy-value-icon">
|
||||
<Icon
|
||||
name="Copy"
|
||||
size="XS"
|
||||
hoverable
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
hoverColor="var(--spectrum-global-color-gray-900)"
|
||||
on:click={() => dispatch("click-copy", clickContext)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if expandable && (expanded || label == null)}
|
||||
<div class="binding-children" class:root>
|
||||
{#each keys as key}
|
||||
<svelte:self
|
||||
label={key}
|
||||
value={value[key]}
|
||||
root={false}
|
||||
path={[...path, key]}
|
||||
{showCopyIcon}
|
||||
on:click-label
|
||||
on:click-value
|
||||
on:click-copy
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.binding-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Expand arrow */
|
||||
.binding-arrow {
|
||||
margin: -3px 6px -2px 4px;
|
||||
flex: 0 0 9px;
|
||||
transition: transform 130ms ease-out;
|
||||
}
|
||||
.binding-arrow :global(svg) {
|
||||
width: 9px;
|
||||
}
|
||||
.binding-arrow.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Main text wrapper */
|
||||
.binding-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Size label and value according to type */
|
||||
.binding-label {
|
||||
flex: 0 1 auto;
|
||||
margin-right: 8px;
|
||||
transition: color 130ms ease-out;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.binding-label.expandable:hover {
|
||||
cursor: pointer;
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
.binding-value {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
transition: filter 130ms ease-out;
|
||||
}
|
||||
.binding-value.primitive:hover {
|
||||
filter: brightness(1.25);
|
||||
cursor: pointer;
|
||||
}
|
||||
.binding-value.expanded {
|
||||
word-break: break-all;
|
||||
white-space: wrap;
|
||||
}
|
||||
.binding-label.primitive {
|
||||
flex: 0 0 auto;
|
||||
max-width: 75%;
|
||||
}
|
||||
.binding-value.primitive {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* Trim spans in the highlighted HTML */
|
||||
.binding-value :global(span) {
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
/* Copy icon for value */
|
||||
.copy-value-icon {
|
||||
display: none;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.binding-text:hover .copy-value-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Children wrapper */
|
||||
.binding-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-left: 1px solid var(--spectrum-global-color-gray-400);
|
||||
margin-left: 20px;
|
||||
padding-left: 3px;
|
||||
}
|
||||
.binding-children.root {
|
||||
border-left: none;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
</style>
|
|
@ -25,7 +25,7 @@
|
|||
</div>
|
||||
|
||||
<Popover
|
||||
customZindex={998}
|
||||
customZIndex={998}
|
||||
bind:this={formPopover}
|
||||
align="center"
|
||||
anchor={formPopoverAnchor}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import { Icon, Input, Drawer, Button } from "@budibase/bbui"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
|
@ -10,25 +10,25 @@
|
|||
import { builderStore } from "@/stores/builder"
|
||||
|
||||
export let panel = ClientBindingPanel
|
||||
export let value = ""
|
||||
export let bindings = []
|
||||
export let title
|
||||
export let placeholder
|
||||
export let label
|
||||
export let disabled = false
|
||||
export let allowHBS = true
|
||||
export let allowJS = true
|
||||
export let allowHelpers = true
|
||||
export let updateOnChange = true
|
||||
export let key
|
||||
export let disableBindings = false
|
||||
export let forceModal = false
|
||||
export let value: any = ""
|
||||
export let bindings: any[] = []
|
||||
export let title: string | undefined = undefined
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let disabled: boolean = false
|
||||
export let allowHBS: boolean = true
|
||||
export let allowJS: boolean = true
|
||||
export let allowHelpers: boolean = true
|
||||
export let updateOnChange: boolean = true
|
||||
export let key: string | null = null
|
||||
export let disableBindings: boolean = false
|
||||
export let forceModal: boolean = false
|
||||
export let context = null
|
||||
export let autocomplete
|
||||
export let autocomplete: boolean | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let bindingDrawer
|
||||
let bindingDrawer: any
|
||||
let currentVal = value
|
||||
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
|
@ -38,7 +38,7 @@
|
|||
const saveBinding = () => {
|
||||
onChange(tempValue)
|
||||
onBlur()
|
||||
builderStore.propertyFocus()
|
||||
builderStore.propertyFocus(null)
|
||||
bindingDrawer.hide()
|
||||
}
|
||||
|
||||
|
@ -46,7 +46,7 @@
|
|||
save: saveBinding,
|
||||
})
|
||||
|
||||
const onChange = value => {
|
||||
const onChange = (value: any) => {
|
||||
currentVal = readableToRuntimeBinding(bindings, value)
|
||||
dispatch("change", currentVal)
|
||||
}
|
||||
|
@ -55,8 +55,8 @@
|
|||
dispatch("blur", currentVal)
|
||||
}
|
||||
|
||||
const onDrawerHide = e => {
|
||||
builderStore.propertyFocus()
|
||||
const onDrawerHide = (e: any) => {
|
||||
builderStore.propertyFocus(null)
|
||||
dispatch("drawerHide", e.detail)
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -61,6 +61,7 @@
|
|||
anchor={primaryDisplayColumnAnchor}
|
||||
item={columns.primary}
|
||||
on:change={e => columns.update(e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
export let item
|
||||
export let anchor
|
||||
export let bindings
|
||||
|
||||
let draggableStore = writable({
|
||||
selected: null,
|
||||
|
@ -48,6 +49,7 @@
|
|||
componentInstance={item}
|
||||
{parseSettings}
|
||||
on:change
|
||||
{bindings}
|
||||
>
|
||||
<div slot="header" class="type-icon">
|
||||
<Icon name={icon} />
|
||||
|
|
|
@ -69,6 +69,7 @@ const toGridFormat = draggableListColumns => {
|
|||
active: entry.active,
|
||||
width: entry.width,
|
||||
conditions: entry.conditions,
|
||||
format: entry.format,
|
||||
}))
|
||||
}
|
||||
|
||||
|
@ -85,6 +86,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
|
|||
columnType: column.columnType || schema[column.field].type,
|
||||
width: column.width,
|
||||
conditions: column.conditions,
|
||||
format: column.format,
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
runtimeToReadableBinding,
|
||||
} from "@/dataBinding"
|
||||
import { builderStore } from "@/stores/builder"
|
||||
import { onDestroy } from "svelte"
|
||||
|
||||
export let label = ""
|
||||
export let labelHidden = false
|
||||
|
@ -32,7 +31,7 @@
|
|||
$: safeValue = getSafeValue(value, defaultValue, allBindings)
|
||||
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
|
||||
|
||||
$: if (!Array.isArray(value)) {
|
||||
$: if (value) {
|
||||
highlightType =
|
||||
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
|
||||
}
|
||||
|
@ -75,12 +74,6 @@
|
|||
? defaultValue
|
||||
: enriched
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (highlightedProp) {
|
||||
builderStore.highlightSetting(null)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
@ -150,10 +143,10 @@
|
|||
.property-control.highlighted {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
border-color: var(--spectrum-global-color-static-red-600);
|
||||
margin-top: -3.5px;
|
||||
margin-bottom: -3.5px;
|
||||
padding-bottom: 3.5px;
|
||||
padding-top: 3.5px;
|
||||
margin-top: -4px;
|
||||
margin-bottom: -4px;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.property-control.property-focus :global(input) {
|
||||
|
@ -172,7 +165,7 @@
|
|||
}
|
||||
.text {
|
||||
font-size: var(--spectrum-global-dimension-font-size-75);
|
||||
color: var(--grey-6);
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
grid-column: 2 / 2;
|
||||
}
|
||||
|
||||
|
|
|
@ -96,8 +96,8 @@
|
|||
maxWidth={300}
|
||||
dismissible={false}
|
||||
offset={12}
|
||||
handlePostionUpdate={tourStep?.positionHandler}
|
||||
customZindex={3}
|
||||
handlePositionUpdate={tourStep?.positionHandler}
|
||||
customZIndex={3}
|
||||
>
|
||||
<div class="tour-content">
|
||||
<Layout noPadding gap="M">
|
||||
|
|
|
@ -1159,10 +1159,17 @@ export const buildFormSchema = (component, asset) => {
|
|||
* Returns an array of the keys of any state variables which are set anywhere
|
||||
* in the app.
|
||||
*/
|
||||
export const getAllStateVariables = () => {
|
||||
// Find all button action settings in all components
|
||||
export const getAllStateVariables = screen => {
|
||||
let assets = []
|
||||
if (screen) {
|
||||
// only include state variables from a specific screen
|
||||
assets.push(screen)
|
||||
} else {
|
||||
// otherwise include state variables from all screens
|
||||
assets = getAllAssets()
|
||||
}
|
||||
let eventSettings = []
|
||||
getAllAssets().forEach(asset => {
|
||||
assets.forEach(asset => {
|
||||
findAllMatchingComponents(asset.props, component => {
|
||||
const settings = componentStore.getComponentSettings(component._component)
|
||||
const nestedTypes = [
|
||||
|
@ -1214,11 +1221,17 @@ export const getAllStateVariables = () => {
|
|||
})
|
||||
|
||||
// Add on load settings from screens
|
||||
get(screenStore).screens.forEach(screen => {
|
||||
if (screen) {
|
||||
if (screen.onLoad) {
|
||||
eventSettings.push(screen.onLoad)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
get(screenStore).screens.forEach(screen => {
|
||||
if (screen.onLoad) {
|
||||
eventSettings.push(screen.onLoad)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Extract all state keys from any "update state" actions in each setting
|
||||
let bindingSet = new Set()
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
} from "@/dataBinding"
|
||||
import { ActionButton, notifications } from "@budibase/bbui"
|
||||
import { capitalise } from "@/helpers"
|
||||
import { builderStore } from "@/stores/builder"
|
||||
import TourWrap from "@/components/portal/onboarding/TourWrap.svelte"
|
||||
import { TOUR_STEP_KEYS } from "@/components/portal/onboarding/tours.js"
|
||||
|
||||
|
@ -55,6 +56,17 @@
|
|||
$: id = $selectedComponent?._id
|
||||
$: id, (section = tabs[0])
|
||||
$: componentName = getComponentName(componentInstance)
|
||||
|
||||
$: highlightedSetting = $builderStore.highlightedSetting
|
||||
$: if (highlightedSetting) {
|
||||
if (highlightedSetting.key === "_conditions") {
|
||||
section = "conditions"
|
||||
} else if (highlightedSetting.key === "_styles") {
|
||||
section = "styles"
|
||||
} else {
|
||||
section = "settings"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $selectedComponent}
|
||||
|
@ -98,7 +110,7 @@
|
|||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
{#if section == "settings"}
|
||||
{#if section === "settings"}
|
||||
<TourWrap
|
||||
stepKeys={[
|
||||
BUILDER_FORM_CREATE_STEPS,
|
||||
|
@ -115,7 +127,7 @@
|
|||
/>
|
||||
</TourWrap>
|
||||
{/if}
|
||||
{#if section == "styles"}
|
||||
{#if section === "styles"}
|
||||
<DesignSection
|
||||
{componentInstance}
|
||||
{componentBindings}
|
||||
|
@ -130,7 +142,7 @@
|
|||
componentTitle={title}
|
||||
/>
|
||||
{/if}
|
||||
{#if section == "conditions"}
|
||||
{#if section === "conditions"}
|
||||
<ConditionalUISection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
|
|
|
@ -190,7 +190,7 @@
|
|||
<Icon name="DragHandle" size="XL" />
|
||||
</div>
|
||||
<Select
|
||||
placeholder={null}
|
||||
placeholder={false}
|
||||
options={actionOptions}
|
||||
bind:value={condition.action}
|
||||
/>
|
||||
|
@ -227,7 +227,7 @@
|
|||
on:change={e => (condition.newValue = e.detail)}
|
||||
/>
|
||||
<Select
|
||||
placeholder={null}
|
||||
placeholder={false}
|
||||
options={getOperatorOptions(condition)}
|
||||
bind:value={condition.operator}
|
||||
on:change={e => onOperatorChange(condition, e.detail)}
|
||||
|
@ -236,7 +236,7 @@
|
|||
disabled={condition.noValue || condition.operator === "oneOf"}
|
||||
options={valueTypeOptions}
|
||||
bind:value={condition.valueType}
|
||||
placeholder={null}
|
||||
placeholder={false}
|
||||
on:change={e => onValueTypeChange(condition, e.detail)}
|
||||
/>
|
||||
{#if ["string", "number"].includes(condition.valueType)}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { componentStore } from "@/stores/builder"
|
||||
import ConditionalUIDrawer from "./ConditionalUIDrawer.svelte"
|
||||
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
|
||||
import { builderStore } from "@/stores/builder"
|
||||
|
||||
export let componentInstance
|
||||
export let componentDefinition
|
||||
|
@ -18,6 +19,8 @@
|
|||
let tempValue
|
||||
let drawer
|
||||
|
||||
$: highlighted = $builderStore.highlightedSetting?.key === "_conditions"
|
||||
|
||||
const openDrawer = () => {
|
||||
tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? []))
|
||||
drawer.show()
|
||||
|
@ -52,7 +55,9 @@
|
|||
/>
|
||||
|
||||
<DetailSummary name={"Conditions"} collapsible={false}>
|
||||
<ActionButton on:click={openDrawer}>{conditionText}</ActionButton>
|
||||
<div class:highlighted>
|
||||
<ActionButton fullWidth on:click={openDrawer}>{conditionText}</ActionButton>
|
||||
</div>
|
||||
</DetailSummary>
|
||||
<Drawer bind:this={drawer} title="Conditions">
|
||||
<svelte:fragment slot="description">
|
||||
|
@ -61,3 +66,13 @@
|
|||
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
|
||||
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
|
||||
</Drawer>
|
||||
|
||||
<style>
|
||||
.highlighted {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
border-left: 4px solid var(--spectrum-semantic-informative-color-background);
|
||||
transition: background 130ms ease-out, border-color 130ms ease-out;
|
||||
margin: -4px calc(-1 * var(--spacing-xl));
|
||||
padding: 4px var(--spacing-xl) 4px calc(var(--spacing-xl) - 4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "@/dataBinding"
|
||||
import { builderStore } from "@/stores/builder"
|
||||
|
||||
export let componentInstance
|
||||
export let componentDefinition
|
||||
|
@ -32,6 +33,8 @@
|
|||
|
||||
$: icon = componentDefinition?.icon
|
||||
|
||||
$: highlighted = $builderStore.highlightedSetting?.key === "_styles"
|
||||
|
||||
const openDrawer = () => {
|
||||
tempValue = runtimeToReadableBinding(
|
||||
bindings,
|
||||
|
@ -55,7 +58,7 @@
|
|||
name={`Custom CSS${componentInstance?._styles?.custom ? " *" : ""}`}
|
||||
collapsible={false}
|
||||
>
|
||||
<div>
|
||||
<div class:highlighted>
|
||||
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
|
||||
</div>
|
||||
</DetailSummary>
|
||||
|
@ -97,4 +100,12 @@
|
|||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
border-left: 4px solid var(--spectrum-semantic-informative-color-background);
|
||||
transition: background 130ms ease-out, border-color 130ms ease-out;
|
||||
margin: -4px calc(-1 * var(--spacing-xl));
|
||||
padding: 4px var(--spacing-xl) 4px calc(var(--spacing-xl) - 4px);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Layout gap="XS" paddingX="L" paddingY="XL">
|
||||
<Layout gap="XS" paddingX="XL" paddingY="XL">
|
||||
{#if activeTab === "theme"}
|
||||
<ThemePanel />
|
||||
{:else}
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
if (id === `${$screenStore.selectedScreenId}-screen`) return true
|
||||
if (id === `${$screenStore.selectedScreenId}-navigation`) return true
|
||||
|
||||
return !!findComponent($selectedScreen.props, id)
|
||||
return !!findComponent($selectedScreen?.props, id)
|
||||
}
|
||||
|
||||
// Keep URL and state in sync for selected component ID
|
||||
|
|
|
@ -50,6 +50,9 @@
|
|||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
}
|
||||
.header-left :global(div) {
|
||||
border-right: none;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte"
|
||||
import { Helpers, notifications } from "@budibase/bbui"
|
||||
import { processObjectSync } from "@budibase/string-templates"
|
||||
import {
|
||||
previewStore,
|
||||
selectedScreen,
|
||||
componentStore,
|
||||
snippets,
|
||||
} from "@/stores/builder"
|
||||
import { getBindableProperties } from "@/dataBinding"
|
||||
import JSONViewer, {
|
||||
type JSONViewerClickEvent,
|
||||
} from "@/components/common/JSONViewer.svelte"
|
||||
|
||||
// Minimal typing for the real data binding structure, as none exists
|
||||
type DataBinding = {
|
||||
category: string
|
||||
runtimeBinding: string
|
||||
readableBinding: string
|
||||
}
|
||||
|
||||
$: previewContext = $previewStore.selectedComponentContext || {}
|
||||
$: selectedComponentId = $componentStore.selectedComponentId
|
||||
$: context = makeContext(previewContext, bindings)
|
||||
$: bindings = getBindableProperties($selectedScreen, selectedComponentId)
|
||||
|
||||
const makeContext = (
|
||||
previewContext: Record<string, any>,
|
||||
bindings: DataBinding[]
|
||||
) => {
|
||||
// Create a single big array to enrich in one go
|
||||
const bindingStrings = bindings.map(binding => {
|
||||
if (binding.runtimeBinding.startsWith('trim "')) {
|
||||
// Account for nasty hardcoded HBS bindings for roles, for legacy
|
||||
// compatibility
|
||||
return `{{ ${binding.runtimeBinding} }}`
|
||||
} else {
|
||||
return `{{ literal ${binding.runtimeBinding} }}`
|
||||
}
|
||||
})
|
||||
const bindingEvaluations = processObjectSync(bindingStrings, {
|
||||
...previewContext,
|
||||
snippets: $snippets,
|
||||
}) as any[]
|
||||
|
||||
// Deeply set values for all readable bindings
|
||||
const enrichedBindings: any[] = bindings.map((binding, idx) => {
|
||||
return {
|
||||
...binding,
|
||||
value: bindingEvaluations[idx],
|
||||
}
|
||||
})
|
||||
let context = {}
|
||||
for (let binding of enrichedBindings) {
|
||||
Helpers.deepSet(context, binding.readableBinding, binding.value)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const copyBinding = (e: JSONViewerClickEvent) => {
|
||||
const readableBinding = `{{ ${e.detail.path.join(".")} }}`
|
||||
Helpers.copyToClipboard(readableBinding)
|
||||
notifications.success("Binding copied to clipboard")
|
||||
}
|
||||
|
||||
onMount(previewStore.requestComponentContext)
|
||||
</script>
|
||||
|
||||
<div class="bindings-panel">
|
||||
<JSONViewer value={context} showCopyIcon on:click-copy={copyBinding} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bindings-panel {
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l)
|
||||
var(--spacing-l);
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,5 @@
|
|||
<script>
|
||||
import { notifications, Icon, Body } from "@budibase/bbui"
|
||||
import { isActive, goto } from "@roxi/routify"
|
||||
import { notifications, Icon } from "@budibase/bbui"
|
||||
import {
|
||||
selectedScreen,
|
||||
screenStore,
|
||||
|
@ -13,23 +12,12 @@
|
|||
import ComponentTree from "./ComponentTree.svelte"
|
||||
import { dndStore, DropPosition } from "./dndStore.js"
|
||||
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
|
||||
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
|
||||
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
|
||||
import getScreenContextMenuItems from "./getScreenContextMenuItems"
|
||||
|
||||
let scrolling = false
|
||||
|
||||
$: screenComponentId = `${$screenStore.selectedScreenId}-screen`
|
||||
$: navComponentId = `${$screenStore.selectedScreenId}-navigation`
|
||||
|
||||
const toNewComponentRoute = () => {
|
||||
if ($isActive(`./:componentId/new`)) {
|
||||
$goto(`./:componentId`)
|
||||
} else {
|
||||
$goto(`./:componentId/new`)
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = async () => {
|
||||
try {
|
||||
await dndStore.actions.drop()
|
||||
|
@ -39,10 +27,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
const handleScroll = e => {
|
||||
scrolling = e.target.scrollTop !== 0
|
||||
}
|
||||
|
||||
const hover = hoverStore.hover
|
||||
|
||||
// showCopy is used to hide the copy button when the user right-clicks the empty
|
||||
|
@ -72,17 +56,9 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="components">
|
||||
<div class="header" class:scrolling>
|
||||
<Body size="S">Components</Body>
|
||||
<div on:click={toNewComponentRoute} class="addButton">
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-panel">
|
||||
<ComponentScrollWrapper on:scroll={handleScroll}>
|
||||
<ComponentScrollWrapper>
|
||||
<ul
|
||||
class="componentTree"
|
||||
on:contextmenu={e => openScreenContextMenu(e, false)}
|
||||
|
@ -159,7 +135,6 @@
|
|||
</ul>
|
||||
</ComponentScrollWrapper>
|
||||
</div>
|
||||
<ComponentKeyHandler />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -168,35 +143,13 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 50px;
|
||||
box-sizing: border-box;
|
||||
padding: var(--spacing-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-bottom 130ms ease-out;
|
||||
}
|
||||
.header.scrolling {
|
||||
border-bottom: var(--border-light);
|
||||
padding-top: var(--spacing-l);
|
||||
}
|
||||
|
||||
.components :global(.nav-item) {
|
||||
padding-right: 8px !important;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
margin-left: auto;
|
||||
color: var(--grey-7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -1,25 +1,60 @@
|
|||
<script>
|
||||
<script lang="ts">
|
||||
import ScreenList from "./ScreenList/index.svelte"
|
||||
import ComponentList from "./ComponentList/index.svelte"
|
||||
import { getHorizontalResizeActions } from "@/components/common/resizable"
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import StatePanel from "./StatePanel.svelte"
|
||||
import BindingsPanel from "./BindingsPanel.svelte"
|
||||
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
|
||||
|
||||
const [resizable, resizableHandle] = getHorizontalResizeActions()
|
||||
|
||||
const Tabs = {
|
||||
Components: "Components",
|
||||
Bindings: "Bindings",
|
||||
State: "State",
|
||||
}
|
||||
|
||||
let activeTab = Tabs.Components
|
||||
</script>
|
||||
|
||||
<div class="panel" use:resizable>
|
||||
<div class="content">
|
||||
<ScreenList />
|
||||
<ComponentList />
|
||||
<div class="tabs">
|
||||
{#each Object.values(Tabs) as tab}
|
||||
<ActionButton
|
||||
quiet
|
||||
selected={activeTab === tab}
|
||||
on:click={() => (activeTab = tab)}
|
||||
>
|
||||
<div class="tab-label">
|
||||
{tab}
|
||||
{#if tab !== Tabs.Components}
|
||||
<div class="new">NEW</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ActionButton>
|
||||
{/each}
|
||||
</div>
|
||||
{#if activeTab === Tabs.Components}
|
||||
<ComponentList />
|
||||
{:else if activeTab === Tabs.Bindings}
|
||||
<BindingsPanel />
|
||||
{:else if activeTab === Tabs.State}
|
||||
<div class="tab-content"><StatePanel /></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="divider">
|
||||
<div class="dividerClickExtender" role="separator" use:resizableHandle />
|
||||
</div>
|
||||
</div>
|
||||
<ComponentKeyHandler />
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
min-width: 270px;
|
||||
min-width: 310px;
|
||||
width: 310px;
|
||||
height: 100%;
|
||||
}
|
||||
|
@ -34,6 +69,34 @@
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
border-bottom: var(--border-light);
|
||||
}
|
||||
.tab-content {
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--spacing-l);
|
||||
}
|
||||
.tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.new {
|
||||
font-size: 8px;
|
||||
background: var(--bb-indigo);
|
||||
border-radius: 2px;
|
||||
padding: 1px 3px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
@ -45,7 +108,6 @@
|
|||
background: var(--spectrum-global-color-gray-300);
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.dividerClickExtender {
|
||||
position: absolute;
|
||||
cursor: col-resize;
|
||||
|
|
|
@ -0,0 +1,336 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte"
|
||||
import { Select } from "@budibase/bbui"
|
||||
import type {
|
||||
Component,
|
||||
ComponentCondition,
|
||||
ComponentSetting,
|
||||
EventHandler,
|
||||
Screen,
|
||||
} from "@budibase/types"
|
||||
import { getAllStateVariables, getBindableProperties } from "@/dataBinding"
|
||||
import {
|
||||
componentStore,
|
||||
selectedScreen,
|
||||
builderStore,
|
||||
previewStore,
|
||||
} from "@/stores/builder"
|
||||
import {
|
||||
decodeJSBinding,
|
||||
findHBSBlocks,
|
||||
isJSBinding,
|
||||
processStringSync,
|
||||
} from "@budibase/string-templates"
|
||||
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
|
||||
|
||||
interface ComponentUsingState {
|
||||
id: string
|
||||
name: string
|
||||
setting: string
|
||||
}
|
||||
|
||||
let selectedKey: string | undefined = undefined
|
||||
let componentsUsingState: ComponentUsingState[] = []
|
||||
let componentsUpdatingState: ComponentUsingState[] = []
|
||||
let editorValue: string = ""
|
||||
|
||||
$: selectStateKey($selectedScreen, selectedKey)
|
||||
$: keyOptions = getAllStateVariables($selectedScreen)
|
||||
$: bindings = getBindableProperties(
|
||||
$selectedScreen,
|
||||
$componentStore.selectedComponentId
|
||||
)
|
||||
|
||||
// Auto-select first valid state key
|
||||
$: {
|
||||
if (keyOptions.length && !keyOptions.includes(selectedKey)) {
|
||||
selectedKey = keyOptions[0]
|
||||
} else if (!keyOptions.length) {
|
||||
selectedKey = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const selectStateKey = (
|
||||
screen: Screen | undefined,
|
||||
key: string | undefined
|
||||
) => {
|
||||
if (screen && key) {
|
||||
searchComponents(screen, key)
|
||||
editorValue = $previewStore.selectedComponentContext?.state?.[key] ?? ""
|
||||
} else {
|
||||
editorValue = ""
|
||||
componentsUsingState = []
|
||||
componentsUpdatingState = []
|
||||
}
|
||||
}
|
||||
|
||||
const searchComponents = (screen: Screen, stateKey: string) => {
|
||||
const { props, onLoad, _id } = screen
|
||||
componentsUsingState = findComponentsUsingState(props, stateKey)
|
||||
componentsUpdatingState = findComponentsUpdatingState(props, stateKey)
|
||||
|
||||
// Check screen load actions which are outside the component hierarchy
|
||||
if (eventUpdatesState(onLoad, stateKey)) {
|
||||
componentsUpdatingState.push({
|
||||
id: _id!,
|
||||
name: "Screen - On load",
|
||||
setting: "onLoad",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if an event setting updates a certain state key
|
||||
const eventUpdatesState = (
|
||||
handlers: EventHandler[] | undefined,
|
||||
stateKey: string
|
||||
) => {
|
||||
return handlers?.some(handler => {
|
||||
return (
|
||||
handler["##eventHandlerType"] === "Update State" &&
|
||||
handler.parameters?.key === stateKey
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Checks if a setting for the given component updates a certain state key
|
||||
const settingUpdatesState = (
|
||||
component: Record<string, any>,
|
||||
setting: ComponentSetting,
|
||||
stateKey: string
|
||||
) => {
|
||||
if (setting.type === "event") {
|
||||
return eventUpdatesState(component[setting.key], stateKey)
|
||||
} else if (setting.type === "buttonConfiguration") {
|
||||
const buttons = component[setting.key]
|
||||
if (Array.isArray(buttons)) {
|
||||
for (let button of buttons) {
|
||||
if (eventUpdatesState(button.onClick, stateKey)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Checks if a condition updates a certain state key
|
||||
const conditionUpdatesState = (
|
||||
condition: ComponentCondition,
|
||||
settings: ComponentSetting[],
|
||||
stateKey: string
|
||||
) => {
|
||||
const setting = settings.find(s => s.key === condition.setting)
|
||||
if (!setting) {
|
||||
return false
|
||||
}
|
||||
const component = { [setting.key]: condition.settingValue }
|
||||
return settingUpdatesState(component, setting, stateKey)
|
||||
}
|
||||
|
||||
const findComponentsUpdatingState = (
|
||||
component: Component,
|
||||
stateKey: string,
|
||||
foundComponents: ComponentUsingState[] = []
|
||||
): ComponentUsingState[] => {
|
||||
const { _children, _conditions, _component, _instanceName, _id } = component
|
||||
const settings = componentStore
|
||||
.getComponentSettings(_component)
|
||||
.filter(s => s.type === "event" || s.type === "buttonConfiguration")
|
||||
|
||||
// Check all settings of this component
|
||||
settings.forEach(setting => {
|
||||
if (settingUpdatesState(component, setting, stateKey)) {
|
||||
const label = setting.label || setting.key
|
||||
foundComponents.push({
|
||||
id: _id!,
|
||||
name: `${_instanceName} - ${label}`,
|
||||
setting: setting.key,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check if conditions update these settings to update this state key
|
||||
if (_conditions?.some(c => conditionUpdatesState(c, settings, stateKey))) {
|
||||
foundComponents.push({
|
||||
id: _id!,
|
||||
name: `${_instanceName} - Conditions`,
|
||||
setting: "_conditions",
|
||||
})
|
||||
}
|
||||
|
||||
// Check children
|
||||
_children?.forEach(child => {
|
||||
findComponentsUpdatingState(child, stateKey, foundComponents)
|
||||
})
|
||||
return foundComponents
|
||||
}
|
||||
|
||||
const findComponentsUsingState = (
|
||||
component: Component,
|
||||
stateKey: string,
|
||||
componentsUsingState: ComponentUsingState[] = []
|
||||
): ComponentUsingState[] => {
|
||||
const settings = componentStore.getComponentSettings(component._component)
|
||||
|
||||
// Check all settings of this component
|
||||
const settingsWithState = getSettingsUsingState(component, stateKey)
|
||||
settingsWithState.forEach(setting => {
|
||||
// Get readable label for this setting
|
||||
let label = settings.find(s => s.key === setting)?.label || setting
|
||||
if (setting === "_conditions") {
|
||||
label = "Conditions"
|
||||
} else if (setting === "_styles") {
|
||||
label = "Styles"
|
||||
}
|
||||
componentsUsingState.push({
|
||||
id: component._id!,
|
||||
name: `${component._instanceName} - ${label}`,
|
||||
setting,
|
||||
})
|
||||
})
|
||||
|
||||
// Check children
|
||||
component._children?.forEach(child => {
|
||||
findComponentsUsingState(child, stateKey, componentsUsingState)
|
||||
})
|
||||
return componentsUsingState
|
||||
}
|
||||
|
||||
const getSettingsUsingState = (
|
||||
component: Component,
|
||||
stateKey: string
|
||||
): string[] => {
|
||||
return Object.entries(component)
|
||||
.filter(([key]) => key !== "_children")
|
||||
.filter(([_, value]) => hasStateBinding(JSON.stringify(value), stateKey))
|
||||
.map(([key]) => key)
|
||||
}
|
||||
|
||||
const hasStateBinding = (value: string, stateKey: string): boolean => {
|
||||
const bindings = findHBSBlocks(value).map(binding => {
|
||||
const sanitizedBinding = binding.replace(/\\"/g, '"')
|
||||
return isJSBinding(sanitizedBinding)
|
||||
? decodeJSBinding(sanitizedBinding)
|
||||
: sanitizedBinding
|
||||
})
|
||||
return bindings.join(" ").includes(stateKey)
|
||||
}
|
||||
|
||||
const onClickComponentLink = (component: ComponentUsingState) => {
|
||||
componentStore.select(component.id)
|
||||
builderStore.highlightSetting(component.setting)
|
||||
}
|
||||
|
||||
const handleStateInspectorChange = (e: CustomEvent) => {
|
||||
if (!selectedKey || !$previewStore.selectedComponentContext) {
|
||||
return
|
||||
}
|
||||
const stateUpdate = {
|
||||
[selectedKey]: processStringSync(
|
||||
e.detail,
|
||||
$previewStore.selectedComponentContext
|
||||
),
|
||||
}
|
||||
previewStore.updateState(stateUpdate)
|
||||
editorValue = e.detail
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
previewStore.requestComponentContext()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="state-panel">
|
||||
<Select
|
||||
label="State variable"
|
||||
bind:value={selectedKey}
|
||||
placeholder={keyOptions.length > 0 ? false : "No state variables found"}
|
||||
options={keyOptions}
|
||||
/>
|
||||
{#if selectedKey && keyOptions.length > 0}
|
||||
<DrawerBindableInput
|
||||
value={editorValue}
|
||||
title={`Set value for "${selectedKey}"`}
|
||||
placeholder="Enter a value"
|
||||
label="Set temporary value for design preview"
|
||||
on:change={e => handleStateInspectorChange(e)}
|
||||
{bindings}
|
||||
/>
|
||||
{/if}
|
||||
{#if componentsUsingState.length > 0}
|
||||
<div class="section">
|
||||
<span class="text">Updates</span>
|
||||
<div class="updates-section">
|
||||
{#each componentsUsingState as component}
|
||||
<button
|
||||
class="component-link updates-colour"
|
||||
on:click={() => onClickComponentLink(component)}
|
||||
>
|
||||
{component.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if componentsUpdatingState.length > 0}
|
||||
<div class="section">
|
||||
<span class="text">Controlled by</span>
|
||||
<div class="updates-section">
|
||||
{#each componentsUpdatingState as component}
|
||||
<button
|
||||
class="component-link controlled-by-colour"
|
||||
on:click={() => onClickComponentLink(component)}
|
||||
>
|
||||
{component.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.state-panel {
|
||||
background-color: var(--spectrum-alias-background-color-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
.text {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
font-size: 12px;
|
||||
}
|
||||
.updates-colour {
|
||||
color: var(--bb-indigo-light);
|
||||
}
|
||||
.controlled-by-colour {
|
||||
color: var(--spectrum-global-color-orange-700);
|
||||
}
|
||||
.component-link {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
background: none;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
transition: filter 130ms ease-out;
|
||||
}
|
||||
.component-link:hover {
|
||||
text-decoration: underline;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
.updates-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -40,26 +40,33 @@ function setupEnv(hosting, features = {}, flags = {}) {
|
|||
describe("AISettings", () => {
|
||||
let instance = null
|
||||
|
||||
const setupDOM = () => {
|
||||
instance = render(AISettings, {})
|
||||
const modalContainer = document.createElement("div")
|
||||
modalContainer.classList.add("modal-container")
|
||||
instance.baseElement.appendChild(modalContainer)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("that the AISettings is rendered", () => {
|
||||
instance = render(AISettings, {})
|
||||
setupDOM()
|
||||
expect(instance).toBeDefined()
|
||||
})
|
||||
|
||||
describe("Licensing", () => {
|
||||
it("should show the premium label on self host for custom configs", async () => {
|
||||
setupEnv(Hosting.Self)
|
||||
instance = render(AISettings, {})
|
||||
setupDOM()
|
||||
const premiumTag = instance.queryByText("Premium")
|
||||
expect(premiumTag).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should show the enterprise label on cloud for custom configs", async () => {
|
||||
setupEnv(Hosting.Cloud)
|
||||
instance = render(AISettings, {})
|
||||
setupDOM()
|
||||
const enterpriseTag = instance.queryByText("Enterprise")
|
||||
expect(enterpriseTag).toBeInTheDocument()
|
||||
})
|
||||
|
@ -69,7 +76,7 @@ describe("AISettings", () => {
|
|||
let configModal
|
||||
|
||||
setupEnv(Hosting.Cloud)
|
||||
instance = render(AISettings)
|
||||
setupDOM()
|
||||
addConfigurationButton = instance.queryByText("Add configuration")
|
||||
expect(addConfigurationButton).toBeInTheDocument()
|
||||
await fireEvent.click(addConfigurationButton)
|
||||
|
@ -86,7 +93,7 @@ describe("AISettings", () => {
|
|||
{ customAIConfigsEnabled: true },
|
||||
{ AI_CUSTOM_CONFIGS: true }
|
||||
)
|
||||
instance = render(AISettings)
|
||||
setupDOM()
|
||||
addConfigurationButton = instance.queryByText("Add configuration")
|
||||
expect(addConfigurationButton).toBeInTheDocument()
|
||||
await fireEvent.click(addConfigurationButton)
|
||||
|
@ -103,7 +110,7 @@ describe("AISettings", () => {
|
|||
{ customAIConfigsEnabled: true },
|
||||
{ AI_CUSTOM_CONFIGS: true }
|
||||
)
|
||||
instance = render(AISettings)
|
||||
setupDOM()
|
||||
addConfigurationButton = instance.queryByText("Add configuration")
|
||||
expect(addConfigurationButton).toBeInTheDocument()
|
||||
await fireEvent.click(addConfigurationButton)
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
previewStore,
|
||||
tables,
|
||||
componentTreeNodesStore,
|
||||
builderStore,
|
||||
screenComponents,
|
||||
} from "@/stores/builder"
|
||||
import { buildFormSchema, getSchemaForDatasource } from "@/dataBinding"
|
||||
|
@ -32,7 +33,10 @@ import {
|
|||
import { BudiStore } from "../BudiStore"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
import {
|
||||
ComponentDefinition,
|
||||
ComponentSetting,
|
||||
Component as ComponentType,
|
||||
ComponentCondition,
|
||||
FieldType,
|
||||
Screen,
|
||||
Table,
|
||||
|
@ -53,29 +57,6 @@ export interface ComponentState {
|
|||
selectedScreenId?: string | null
|
||||
}
|
||||
|
||||
export interface ComponentDefinition {
|
||||
component: string
|
||||
name: string
|
||||
friendlyName?: string
|
||||
hasChildren?: boolean
|
||||
settings?: ComponentSetting[]
|
||||
features?: Record<string, boolean>
|
||||
typeSupportPresets?: Record<string, any>
|
||||
legalDirectChildren: string[]
|
||||
illegalChildren: string[]
|
||||
}
|
||||
|
||||
export interface ComponentSetting {
|
||||
key: string
|
||||
type: string
|
||||
section?: string
|
||||
name?: string
|
||||
defaultValue?: any
|
||||
selectAllFields?: boolean
|
||||
resetOn?: string | string[]
|
||||
settings?: ComponentSetting[]
|
||||
}
|
||||
|
||||
export const INITIAL_COMPONENTS_STATE: ComponentState = {
|
||||
components: {},
|
||||
customComponents: [],
|
||||
|
@ -743,14 +724,16 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} componentId
|
||||
*/
|
||||
select(componentId: string) {
|
||||
select(id: string) {
|
||||
this.update(state => {
|
||||
state.selectedComponentId = componentId
|
||||
return state
|
||||
// Only clear highlights if selecting a different component
|
||||
if (!id.includes(state.selectedComponentId!)) {
|
||||
builderStore.highlightSetting()
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
selectedComponentId: id,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1132,7 +1115,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
|
|||
})
|
||||
}
|
||||
|
||||
async updateConditions(conditions: Record<string, any>) {
|
||||
async updateConditions(conditions: ComponentCondition[]) {
|
||||
await this.patch((component: Component) => {
|
||||
component._conditions = conditions
|
||||
})
|
||||
|
|
|
@ -16,7 +16,11 @@ import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
|
|||
import { deploymentStore } from "./deployments.js"
|
||||
import { contextMenuStore } from "./contextMenu.js"
|
||||
import { snippets } from "./snippets"
|
||||
import { screenComponents, screenComponentErrors } from "./screenComponent"
|
||||
import {
|
||||
screenComponents,
|
||||
screenComponentErrors,
|
||||
findComponentsBySettingsType,
|
||||
} from "./screenComponent"
|
||||
|
||||
// Backend
|
||||
import { tables } from "./tables"
|
||||
|
@ -70,6 +74,7 @@ export {
|
|||
appPublished,
|
||||
screenComponents,
|
||||
screenComponentErrors,
|
||||
findComponentsBySettingsType,
|
||||
}
|
||||
|
||||
export const reset = () => {
|
||||
|
|
|
@ -82,6 +82,10 @@ export class PreviewStore extends BudiStore<PreviewState> {
|
|||
}))
|
||||
}
|
||||
|
||||
updateState(data: Record<string, any>) {
|
||||
this.sendEvent("builder-state", data)
|
||||
}
|
||||
|
||||
requestComponentContext() {
|
||||
this.sendEvent("request-context")
|
||||
}
|
||||
|
|
|
@ -6,14 +6,17 @@ import {
|
|||
UIDatasourceType,
|
||||
Screen,
|
||||
Component,
|
||||
UIComponentError,
|
||||
ScreenProps,
|
||||
ComponentDefinition,
|
||||
} from "@budibase/types"
|
||||
import { queries } from "./queries"
|
||||
import { views } from "./views"
|
||||
import { bindings, featureFlag } from "@/helpers"
|
||||
import { getBindableProperties } from "@/dataBinding"
|
||||
import { componentStore, ComponentDefinition } from "./components"
|
||||
import { findAllComponents } from "@/helpers/components"
|
||||
import { bindings } from "@/helpers"
|
||||
import { getBindableProperties } from "@/dataBinding"
|
||||
import { componentStore } from "./components"
|
||||
import { getSettingsDefinition } from "@budibase/frontend-core"
|
||||
|
||||
function reduceBy<TItem extends {}, TKey extends keyof TItem>(
|
||||
key: TKey,
|
||||
|
@ -52,61 +55,10 @@ export const screenComponentErrors = derived(
|
|||
$viewsV2,
|
||||
$queries,
|
||||
$componentStore,
|
||||
]): Record<string, string[]> => {
|
||||
if (!featureFlag.isEnabled("CHECK_COMPONENT_SETTINGS_ERRORS")) {
|
||||
]): Record<string, UIComponentError[]> => {
|
||||
if (!$selectedScreen) {
|
||||
return {}
|
||||
}
|
||||
function getInvalidDatasources(
|
||||
screen: Screen,
|
||||
datasources: Record<string, any>
|
||||
) {
|
||||
const result: Record<string, string[]> = {}
|
||||
|
||||
for (const { component, setting } of findComponentsBySettingsType(
|
||||
screen,
|
||||
["table", "dataSource"],
|
||||
$componentStore.components
|
||||
)) {
|
||||
const componentSettings = component[setting.key]
|
||||
if (!componentSettings) {
|
||||
continue
|
||||
}
|
||||
const { label } = componentSettings
|
||||
const type = componentSettings.type as UIDatasourceType
|
||||
|
||||
const validationKey = validationKeyByType[type]
|
||||
if (!validationKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
const componentBindings = getBindableProperties(
|
||||
$selectedScreen,
|
||||
component._id
|
||||
)
|
||||
|
||||
const componentDatasources = {
|
||||
...reduceBy(
|
||||
"rowId",
|
||||
bindings.extractRelationships(componentBindings)
|
||||
),
|
||||
...reduceBy("value", bindings.extractFields(componentBindings)),
|
||||
...reduceBy(
|
||||
"value",
|
||||
bindings.extractJSONArrayFields(componentBindings)
|
||||
),
|
||||
}
|
||||
|
||||
const resourceId = componentSettings[validationKey]
|
||||
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
|
||||
const friendlyTypeName = friendlyNameByType[type] ?? type
|
||||
result[component._id!] = [
|
||||
`The ${friendlyTypeName} named "${label}" could not be found`,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const datasources = {
|
||||
...reduceBy("_id", $tables.list),
|
||||
|
@ -115,16 +67,170 @@ export const screenComponentErrors = derived(
|
|||
...reduceBy("_id", $queries.list),
|
||||
}
|
||||
|
||||
if (!$selectedScreen) {
|
||||
// Skip validation if a screen is not selected.
|
||||
return {}
|
||||
}
|
||||
const { components: definitions } = $componentStore
|
||||
|
||||
return getInvalidDatasources($selectedScreen, datasources)
|
||||
const errors = {
|
||||
...getInvalidDatasources($selectedScreen, datasources, definitions),
|
||||
...getMissingAncestors($selectedScreen, definitions),
|
||||
...getMissingRequiredSettings($selectedScreen, definitions),
|
||||
}
|
||||
return errors
|
||||
}
|
||||
)
|
||||
|
||||
function findComponentsBySettingsType(
|
||||
function getInvalidDatasources(
|
||||
screen: Screen,
|
||||
datasources: Record<string, any>,
|
||||
definitions: Record<string, ComponentDefinition>
|
||||
) {
|
||||
const result: Record<string, UIComponentError[]> = {}
|
||||
for (const { component, setting } of findComponentsBySettingsType(
|
||||
screen,
|
||||
["table", "dataSource"],
|
||||
definitions
|
||||
)) {
|
||||
const componentSettings = component[setting.key]
|
||||
if (!componentSettings) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { label } = componentSettings
|
||||
const type = componentSettings.type as UIDatasourceType
|
||||
|
||||
const validationKey = validationKeyByType[type]
|
||||
if (!validationKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
const componentBindings = getBindableProperties(screen, component._id)
|
||||
|
||||
const componentDatasources = {
|
||||
...reduceBy("rowId", bindings.extractRelationships(componentBindings)),
|
||||
...reduceBy("value", bindings.extractFields(componentBindings)),
|
||||
...reduceBy("value", bindings.extractJSONArrayFields(componentBindings)),
|
||||
}
|
||||
|
||||
const resourceId = componentSettings[validationKey]
|
||||
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
|
||||
const friendlyTypeName = friendlyNameByType[type] ?? type
|
||||
result[component._id!] = [
|
||||
{
|
||||
key: setting.key,
|
||||
message: `The ${friendlyTypeName} named "${label}" could not be found`,
|
||||
errorType: "setting",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getMissingRequiredSettings(
|
||||
screen: Screen,
|
||||
definitions: Record<string, ComponentDefinition>
|
||||
) {
|
||||
const allComponents = findAllComponents(screen.props) as Component[]
|
||||
|
||||
const result: Record<string, UIComponentError[]> = {}
|
||||
for (const component of allComponents) {
|
||||
const definition = definitions[component._component]
|
||||
|
||||
const settings = getSettingsDefinition(definition)
|
||||
|
||||
const missingRequiredSettings = settings.filter((setting: any) => {
|
||||
let empty =
|
||||
component[setting.key] == null || component[setting.key] === ""
|
||||
let missing = setting.required && empty
|
||||
|
||||
// Check if this setting depends on another, as it may not be required
|
||||
if (setting.dependsOn) {
|
||||
const dependsOnKey = setting.dependsOn.setting || setting.dependsOn
|
||||
const dependsOnValue = setting.dependsOn.value
|
||||
const realDependentValue = component[dependsOnKey]
|
||||
|
||||
const sectionDependsOnKey =
|
||||
setting.sectionDependsOn?.setting || setting.sectionDependsOn
|
||||
const sectionDependsOnValue = setting.sectionDependsOn?.value
|
||||
const sectionRealDependentValue = component[sectionDependsOnKey]
|
||||
|
||||
if (dependsOnValue == null && realDependentValue == null) {
|
||||
return false
|
||||
}
|
||||
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
sectionDependsOnValue != null &&
|
||||
sectionDependsOnValue !== sectionRealDependentValue
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
})
|
||||
|
||||
if (missingRequiredSettings?.length) {
|
||||
result[component._id!] = missingRequiredSettings.map((s: any) => ({
|
||||
key: s.key,
|
||||
message: `Add the <mark>${s.label}</mark> setting to start using your component`,
|
||||
errorType: "setting",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const BudibasePrefix = "@budibase/standard-components/"
|
||||
function getMissingAncestors(
|
||||
screen: Screen,
|
||||
definitions: Record<string, ComponentDefinition>
|
||||
) {
|
||||
const result: Record<string, UIComponentError[]> = {}
|
||||
|
||||
function checkMissingAncestors(component: Component, ancestors: string[]) {
|
||||
for (const child of component._children || []) {
|
||||
checkMissingAncestors(child, [...ancestors, component._component])
|
||||
}
|
||||
|
||||
const definition = definitions[component._component]
|
||||
|
||||
if (!definition?.requiredAncestors?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const missingAncestors = definition.requiredAncestors.filter(
|
||||
ancestor => !ancestors.includes(`${BudibasePrefix}${ancestor}`)
|
||||
)
|
||||
|
||||
if (missingAncestors.length) {
|
||||
const pluralise = (name: string) => {
|
||||
return name.endsWith("s") ? `${name}'` : `${name}s`
|
||||
}
|
||||
|
||||
result[component._id!] = missingAncestors.map(ancestor => {
|
||||
const ancestorDefinition = definitions[`${BudibasePrefix}${ancestor}`]
|
||||
return {
|
||||
message: `${pluralise(definition.name)} need to be inside a
|
||||
<mark>${ancestorDefinition.name}</mark>`,
|
||||
errorType: "ancestor-setting",
|
||||
ancestor: {
|
||||
name: ancestorDefinition.name,
|
||||
fullType: `${BudibasePrefix}${ancestor}`,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
checkMissingAncestors(screen.props, [])
|
||||
return result
|
||||
}
|
||||
|
||||
export function findComponentsBySettingsType(
|
||||
screen: Screen,
|
||||
type: string | string[],
|
||||
definitions: Record<string, ComponentDefinition>
|
||||
|
@ -149,10 +255,10 @@ function findComponentsBySettingsType(
|
|||
const setting = definition?.settings?.find((s: any) =>
|
||||
typesArray.includes(s.type)
|
||||
)
|
||||
if (setting && "type" in setting) {
|
||||
if (setting) {
|
||||
result.push({
|
||||
component,
|
||||
setting: { type: setting.type!, key: setting.key! },
|
||||
setting: { type: setting.type, key: setting.key },
|
||||
})
|
||||
}
|
||||
component._children?.forEach(child => {
|
||||
|
|
|
@ -19,8 +19,8 @@ import {
|
|||
Screen,
|
||||
Component,
|
||||
SaveScreenResponse,
|
||||
ComponentDefinition,
|
||||
} from "@budibase/types"
|
||||
import { ComponentDefinition } from "./components"
|
||||
|
||||
interface ScreenState {
|
||||
screens: Screen[]
|
||||
|
|
|
@ -3089,6 +3089,12 @@
|
|||
"type": "tableConditions",
|
||||
"label": "Conditions",
|
||||
"key": "conditions"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"label": "Format",
|
||||
"key": "format",
|
||||
"info": "Changing format will display values as text"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -7685,7 +7691,8 @@
|
|||
{
|
||||
"type": "columns/grid",
|
||||
"key": "columns",
|
||||
"resetOn": "table"
|
||||
"resetOn": "table",
|
||||
"nested": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -11,11 +11,8 @@
|
|||
<script>
|
||||
import { getContext, setContext, onMount } from "svelte"
|
||||
import { writable, get } from "svelte/store"
|
||||
import {
|
||||
enrichProps,
|
||||
propsAreSame,
|
||||
getSettingsDefinition,
|
||||
} from "utils/componentProps"
|
||||
import { enrichProps, propsAreSame } from "utils/componentProps"
|
||||
import { getSettingsDefinition } from "@budibase/frontend-core"
|
||||
import {
|
||||
builderStore,
|
||||
devToolsStore,
|
||||
|
@ -29,7 +26,6 @@
|
|||
import EmptyPlaceholder from "components/app/EmptyPlaceholder.svelte"
|
||||
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
|
||||
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
|
||||
import { BudibasePrefix } from "../stores/components.js"
|
||||
import {
|
||||
decodeJSBinding,
|
||||
findHBSBlocks,
|
||||
|
@ -102,8 +98,6 @@
|
|||
let definition
|
||||
let settingsDefinition
|
||||
let settingsDefinitionMap
|
||||
let missingRequiredSettings = false
|
||||
let componentErrors = false
|
||||
|
||||
// Temporary styles which can be added in the app preview for things like
|
||||
// DND. We clear these whenever a new instance is received.
|
||||
|
@ -141,18 +135,11 @@
|
|||
$: componentErrors = instance?._meta?.errors
|
||||
$: hasChildren = !!definition?.hasChildren
|
||||
$: showEmptyState = definition?.showEmptyState !== false
|
||||
$: hasMissingRequiredSettings = missingRequiredSettings?.length > 0
|
||||
$: hasMissingRequiredSettings = !!componentErrors?.find(
|
||||
e => e.errorType === "setting"
|
||||
)
|
||||
$: editable = !!definition?.editable && !hasMissingRequiredSettings
|
||||
$: hasComponentErrors = componentErrors?.length > 0
|
||||
$: requiredAncestors = definition?.requiredAncestors || []
|
||||
$: missingRequiredAncestors = requiredAncestors.filter(
|
||||
ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`)
|
||||
)
|
||||
$: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0
|
||||
$: errorState =
|
||||
hasMissingRequiredSettings ||
|
||||
hasMissingRequiredAncestors ||
|
||||
hasComponentErrors
|
||||
|
||||
// Interactive components can be selected, dragged and highlighted inside
|
||||
// the builder preview
|
||||
|
@ -218,7 +205,7 @@
|
|||
styles: normalStyles,
|
||||
draggable,
|
||||
definition,
|
||||
errored: errorState,
|
||||
errored: hasComponentErrors,
|
||||
}
|
||||
|
||||
// When dragging and dropping, pad components to allow dropping between
|
||||
|
@ -251,9 +238,8 @@
|
|||
name,
|
||||
editing,
|
||||
type: instance._component,
|
||||
errorState,
|
||||
errorState: hasComponentErrors,
|
||||
parent: id,
|
||||
ancestors: [...($component?.ancestors ?? []), instance._component],
|
||||
path: [...($component?.path ?? []), id],
|
||||
darkMode,
|
||||
})
|
||||
|
@ -310,40 +296,6 @@
|
|||
staticSettings = instanceSettings.staticSettings
|
||||
dynamicSettings = instanceSettings.dynamicSettings
|
||||
|
||||
// Check if we have any missing required settings
|
||||
missingRequiredSettings = settingsDefinition.filter(setting => {
|
||||
let empty = instance[setting.key] == null || instance[setting.key] === ""
|
||||
let missing = setting.required && empty
|
||||
|
||||
// Check if this setting depends on another, as it may not be required
|
||||
if (setting.dependsOn) {
|
||||
const dependsOnKey = setting.dependsOn.setting || setting.dependsOn
|
||||
const dependsOnValue = setting.dependsOn.value
|
||||
const realDependentValue = instance[dependsOnKey]
|
||||
|
||||
const sectionDependsOnKey =
|
||||
setting.sectionDependsOn?.setting || setting.sectionDependsOn
|
||||
const sectionDependsOnValue = setting.sectionDependsOn?.value
|
||||
const sectionRealDependentValue = instance[sectionDependsOnKey]
|
||||
|
||||
if (dependsOnValue == null && realDependentValue == null) {
|
||||
return false
|
||||
}
|
||||
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
sectionDependsOnValue != null &&
|
||||
sectionDependsOnValue !== sectionRealDependentValue
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
})
|
||||
|
||||
// When considering bindings we can ignore children, so we remove that
|
||||
// before storing the reference stringified version
|
||||
const noChildren = JSON.stringify({ ...instance, _children: null })
|
||||
|
@ -686,7 +638,7 @@
|
|||
class:pad
|
||||
class:parent={hasChildren}
|
||||
class:block={isBlock}
|
||||
class:error={errorState}
|
||||
class:error={hasComponentErrors}
|
||||
class:root={isRoot}
|
||||
data-id={id}
|
||||
data-name={name}
|
||||
|
@ -694,12 +646,8 @@
|
|||
data-parent={$component.id}
|
||||
use:gridLayout={gridMetadata}
|
||||
>
|
||||
{#if errorState}
|
||||
<ComponentErrorState
|
||||
{missingRequiredSettings}
|
||||
{missingRequiredAncestors}
|
||||
{componentErrors}
|
||||
/>
|
||||
{#if hasComponentErrors}
|
||||
<ComponentErrorState {componentErrors} />
|
||||
{:else}
|
||||
<svelte:component this={constructor} bind:this={ref} {...initialSettings}>
|
||||
{#if children.length}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import { get, derived, readable } from "svelte/store"
|
||||
import { featuresStore } from "stores"
|
||||
import { Grid } from "@budibase/frontend-core"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
|
||||
// table is actually any datasource, but called table for legacy compatibility
|
||||
export let table
|
||||
|
@ -42,6 +43,7 @@
|
|||
let gridContext
|
||||
let minHeight = 0
|
||||
|
||||
$: id = $component.id
|
||||
$: currentTheme = $context?.device?.theme
|
||||
$: darkMode = !currentTheme?.includes("light")
|
||||
$: parsedColumns = getParsedColumns(columns)
|
||||
|
@ -65,7 +67,6 @@
|
|||
const clean = gridContext?.rows.actions.cleanRow || (x => x)
|
||||
const cleaned = rows.map(clean)
|
||||
const goldenRow = generateGoldenSample(cleaned)
|
||||
const id = get(component).id
|
||||
return {
|
||||
// Not sure what this one is for...
|
||||
[id]: goldenRow,
|
||||
|
@ -104,6 +105,7 @@
|
|||
order: idx,
|
||||
conditions: column.conditions,
|
||||
visible: !!column.active,
|
||||
format: createFormatter(column),
|
||||
}
|
||||
if (column.width) {
|
||||
overrides[column.field].width = column.width
|
||||
|
@ -112,6 +114,13 @@
|
|||
return overrides
|
||||
}
|
||||
|
||||
const createFormatter = column => {
|
||||
if (typeof column.format !== "string" || !column.format.trim().length) {
|
||||
return null
|
||||
}
|
||||
return row => processStringSync(column.format, { [id]: row })
|
||||
}
|
||||
|
||||
const enrichButtons = buttons => {
|
||||
if (!buttons?.length) {
|
||||
return null
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
import { Layout, Toggle } from "@budibase/bbui"
|
||||
import { getSettingsDefinition } from "@budibase/frontend-core"
|
||||
import DevToolsStat from "./DevToolsStat.svelte"
|
||||
import { componentStore } from "stores/index.js"
|
||||
import { getSettingsDefinition } from "utils/componentProps.js"
|
||||
|
||||
let showEnrichedSettings = true
|
||||
|
||||
|
|
|
@ -1,21 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from "svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import MissingRequiredSetting from "./MissingRequiredSetting.svelte"
|
||||
import MissingRequiredAncestor from "./MissingRequiredAncestor.svelte"
|
||||
import { UIComponentError } from "@budibase/types"
|
||||
import ComponentErrorStateCta from "./ComponentErrorStateCTA.svelte"
|
||||
|
||||
export let missingRequiredSettings:
|
||||
| { key: string; label: string }[]
|
||||
| undefined
|
||||
export let missingRequiredAncestors: string[] | undefined
|
||||
export let componentErrors: string[] | undefined
|
||||
export let componentErrors: UIComponentError[] | undefined
|
||||
|
||||
const component = getContext("component")
|
||||
const { styleable, builderStore } = getContext("sdk")
|
||||
|
||||
$: styles = { ...$component.styles, normal: {}, custom: null, empty: true }
|
||||
$: requiredSetting = missingRequiredSettings?.[0]
|
||||
$: requiredAncestor = missingRequiredAncestors?.[0]
|
||||
$: errorMessage = componentErrors?.[0]
|
||||
</script>
|
||||
|
||||
|
@ -23,12 +17,10 @@
|
|||
{#if $component.errorState}
|
||||
<div class="component-placeholder" use:styleable={styles}>
|
||||
<Icon name="Alert" color="var(--spectrum-global-color-static-red-600)" />
|
||||
{#if requiredAncestor}
|
||||
<MissingRequiredAncestor {requiredAncestor} />
|
||||
{:else if errorMessage}
|
||||
{errorMessage}
|
||||
{:else if requiredSetting}
|
||||
<MissingRequiredSetting {requiredSetting} />
|
||||
{#if errorMessage}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
{@html errorMessage.message}
|
||||
<ComponentErrorStateCta error={errorMessage} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from "svelte"
|
||||
import { UIComponentError } from "@budibase/types"
|
||||
|
||||
export let error: UIComponentError | undefined
|
||||
|
||||
const component = getContext("component")
|
||||
const { builderStore } = getContext("sdk")
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
{#if error.errorType === "setting"}
|
||||
<span>-</span>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span
|
||||
class="spectrum-Link"
|
||||
on:click={() => {
|
||||
builderStore.actions.highlightSetting(error.key)
|
||||
}}
|
||||
>
|
||||
Show me
|
||||
</span>
|
||||
{:else if error.errorType === "ancestor-setting"}
|
||||
<span>-</span>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span
|
||||
class="spectrum-Link"
|
||||
on:click={() => {
|
||||
builderStore.actions.addParentComponent(
|
||||
$component.id,
|
||||
error.ancestor.fullType
|
||||
)
|
||||
}}
|
||||
>
|
||||
Add {error.ancestor.name}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
|
@ -1,43 +0,0 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import { BudibasePrefix } from "stores/components"
|
||||
|
||||
export let requiredAncestor
|
||||
|
||||
const component = getContext("component")
|
||||
const { builderStore, componentStore } = getContext("sdk")
|
||||
|
||||
$: definition = componentStore.actions.getComponentDefinition($component.type)
|
||||
$: fullAncestorType = `${BudibasePrefix}${requiredAncestor}`
|
||||
$: ancestorDefinition =
|
||||
componentStore.actions.getComponentDefinition(fullAncestorType)
|
||||
$: pluralName = getPluralName(definition?.name, $component.type)
|
||||
$: ancestorName = getAncestorName(ancestorDefinition?.name, requiredAncestor)
|
||||
|
||||
const getPluralName = (name, type) => {
|
||||
if (!name) {
|
||||
name = type.replace(BudibasePrefix, "")
|
||||
}
|
||||
return name.endsWith("s") ? `${name}'` : `${name}s`
|
||||
}
|
||||
|
||||
const getAncestorName = name => {
|
||||
return name || requiredAncestor
|
||||
}
|
||||
</script>
|
||||
|
||||
<span>
|
||||
{pluralName} need to be inside a
|
||||
<mark>{ancestorName}</mark>
|
||||
</span>
|
||||
<span>-</span>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span
|
||||
class="spectrum-Link"
|
||||
on:click={() => {
|
||||
builderStore.actions.addParentComponent($component.id, fullAncestorType)
|
||||
}}
|
||||
>
|
||||
Add {ancestorName}
|
||||
</span>
|
|
@ -1,22 +0,0 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let requiredSetting
|
||||
|
||||
const { builderStore } = getContext("sdk")
|
||||
</script>
|
||||
|
||||
<span>
|
||||
Add the <mark>{requiredSetting.label}</mark> setting to start using your component
|
||||
</span>
|
||||
<span>-</span>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<span
|
||||
class="spectrum-Link"
|
||||
on:click={() => {
|
||||
builderStore.actions.highlightSetting(requiredSetting.key)
|
||||
}}
|
||||
>
|
||||
Show me
|
||||
</span>
|
|
@ -15,6 +15,7 @@ export const ActionTypes = {
|
|||
|
||||
export const DNDPlaceholderID = "dnd-placeholder"
|
||||
export const ScreenslotType = "screenslot"
|
||||
export const ScreenslotID = "screenslot"
|
||||
export const GridRowHeight = 24
|
||||
export const GridColumns = 12
|
||||
export const GridSpacing = 4
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
dndStore,
|
||||
eventStore,
|
||||
hoverStore,
|
||||
stateStore,
|
||||
} from "./stores"
|
||||
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-vite.js"
|
||||
import { get } from "svelte/store"
|
||||
|
@ -87,8 +88,10 @@ const loadBudibase = async () => {
|
|||
dndStore.actions.reset()
|
||||
}
|
||||
} else if (type === "request-context") {
|
||||
const { selectedComponentInstance } = get(componentStore)
|
||||
const context = selectedComponentInstance?.getDataContext()
|
||||
const { selectedComponentInstance, screenslotInstance } =
|
||||
get(componentStore)
|
||||
const instance = selectedComponentInstance || screenslotInstance
|
||||
const context = instance?.getDataContext()
|
||||
let stringifiedContext = null
|
||||
try {
|
||||
stringifiedContext = JSON.stringify(context)
|
||||
|
@ -102,6 +105,9 @@ const loadBudibase = async () => {
|
|||
hoverStore.actions.hoverComponent(data, false)
|
||||
} else if (type === "builder-meta") {
|
||||
builderStore.actions.setMetadata(data)
|
||||
} else if (type === "builder-state") {
|
||||
const [[key, value]] = Object.entries(data)
|
||||
stateStore.actions.setValue(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,15 @@ export interface SDK {
|
|||
generateGoldenSample: any
|
||||
builderStore: Readable<{
|
||||
inBuilder: boolean
|
||||
}>
|
||||
}> & {
|
||||
actions: {
|
||||
highlightSetting: (key: string) => void
|
||||
addParentComponent: (
|
||||
componentId: string,
|
||||
fullAncestorType: string
|
||||
) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Component = Readable<{
|
||||
|
|
|
@ -6,7 +6,7 @@ import { screenStore } from "./screens"
|
|||
import { builderStore } from "./builder"
|
||||
import Router from "../components/Router.svelte"
|
||||
import * as AppComponents from "../components/app/index.js"
|
||||
import { ScreenslotType } from "../constants"
|
||||
import { ScreenslotID, ScreenslotType } from "../constants"
|
||||
|
||||
export const BudibasePrefix = "@budibase/standard-components/"
|
||||
|
||||
|
@ -43,6 +43,7 @@ const createComponentStore = () => {
|
|||
selectedComponentDefinition: definition,
|
||||
selectedComponentPath: selectedPath?.map(component => component._id),
|
||||
mountedComponentCount: Object.keys($store.mountedComponents).length,
|
||||
screenslotInstance: $store.mountedComponents[ScreenslotID],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -7,7 +7,7 @@ import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js"
|
|||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
import { findComponentById, findComponentParent } from "../utils/components.js"
|
||||
import { Helpers } from "@budibase/bbui"
|
||||
import { DNDPlaceholderID } from "constants"
|
||||
import { DNDPlaceholderID, ScreenslotID, ScreenslotType } from "constants"
|
||||
|
||||
const createScreenStore = () => {
|
||||
const store = derived(
|
||||
|
@ -171,8 +171,8 @@ const createScreenStore = () => {
|
|||
_component: "@budibase/standard-components/layout",
|
||||
_children: [
|
||||
{
|
||||
_component: "screenslot",
|
||||
_id: "screenslot",
|
||||
_component: ScreenslotType,
|
||||
_id: ScreenslotID,
|
||||
_styles: {
|
||||
normal: {
|
||||
flex: "1 1 auto",
|
||||
|
|
|
@ -97,26 +97,3 @@ export const propsUseBinding = (props, bindingKey) => {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the definition of this component's settings from the manifest
|
||||
*/
|
||||
export const getSettingsDefinition = definition => {
|
||||
if (!definition) {
|
||||
return []
|
||||
}
|
||||
let settings = []
|
||||
definition.settings?.forEach(setting => {
|
||||
if (setting.section) {
|
||||
settings = settings.concat(
|
||||
(setting.settings || [])?.map(childSetting => ({
|
||||
...childSetting,
|
||||
sectionDependsOn: setting.dependsOn,
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
settings.push(setting)
|
||||
}
|
||||
})
|
||||
return settings
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import GridCell from "./GridCell.svelte"
|
||||
import { getCellRenderer } from "../lib/renderers"
|
||||
import { derived, writable } from "svelte/store"
|
||||
import TextCell from "./TextCell.svelte"
|
||||
|
||||
const {
|
||||
rows,
|
||||
|
@ -36,11 +37,17 @@
|
|||
|
||||
let api
|
||||
|
||||
// Get the appropriate cell renderer and value
|
||||
$: hasCustomFormat = column.format && !row._isNewRow
|
||||
$: renderer = hasCustomFormat ? TextCell : getCellRenderer(column)
|
||||
$: value = hasCustomFormat ? row.__formatted?.[column.name] : row[column.name]
|
||||
|
||||
// Get the error for this cell if the cell is focused or selected
|
||||
$: error = getErrorStore(rowFocused, cellId)
|
||||
|
||||
// Determine if the cell is editable
|
||||
$: readonly =
|
||||
hasCustomFormat ||
|
||||
columns.actions.isReadonly(column) ||
|
||||
(!$config.canEditRows && !row._isNewRow)
|
||||
|
||||
|
@ -69,7 +76,7 @@
|
|||
onKeyDown: (...params) => api?.onKeyDown?.(...params),
|
||||
isReadonly: () => readonly,
|
||||
getType: () => column.schema.type,
|
||||
getValue: () => row[column.name],
|
||||
getValue: () => value,
|
||||
setValue: (value, options = { apply: true }) => {
|
||||
validation.actions.setError(cellId, null)
|
||||
updateValue({
|
||||
|
@ -136,9 +143,9 @@
|
|||
}}
|
||||
>
|
||||
<svelte:component
|
||||
this={getCellRenderer(column)}
|
||||
this={renderer}
|
||||
bind:api
|
||||
value={row[column.name]}
|
||||
{value}
|
||||
schema={column.schema}
|
||||
onChange={cellAPI.setValue}
|
||||
{focused}
|
||||
|
|
|
@ -53,7 +53,6 @@ export const getCellRenderer = (column: UIColumn) => {
|
|||
if (column.calculationType) {
|
||||
return NumberCell
|
||||
}
|
||||
|
||||
return (
|
||||
getCellRendererByType(column.schema?.cellRenderType) ||
|
||||
getCellRendererByType(column.schema?.type) ||
|
||||
|
|
|
@ -188,6 +188,7 @@ export const initialise = (context: StoreContext) => {
|
|||
conditions: fieldSchema.conditions,
|
||||
related: fieldSchema.related,
|
||||
calculationType: fieldSchema.calculationType,
|
||||
format: fieldSchema.format,
|
||||
__left: undefined as any, // TODO
|
||||
__idx: undefined as any, // TODO
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import { Store as StoreContext } from "."
|
|||
|
||||
interface IndexedUIRow extends UIRow {
|
||||
__idx: number
|
||||
__formatted: Record<string, any>
|
||||
}
|
||||
|
||||
interface RowStore {
|
||||
|
@ -114,26 +115,44 @@ export const createStores = (): RowStore => {
|
|||
export const deriveStores = (context: StoreContext): RowDerivedStore => {
|
||||
const { rows, enrichedSchema } = context
|
||||
|
||||
// Enrich rows with an index property and any pending changes
|
||||
// Enrich rows with an index property and additional values
|
||||
const enrichedRows = derived(
|
||||
[rows, enrichedSchema],
|
||||
([$rows, $enrichedSchema]) => {
|
||||
const customColumns = Object.values($enrichedSchema || {}).filter(
|
||||
f => f.related
|
||||
)
|
||||
return $rows.map<IndexedUIRow>((row, idx) => ({
|
||||
...row,
|
||||
__idx: idx,
|
||||
...customColumns.reduce<Record<string, string>>((map, column) => {
|
||||
const fromField = $enrichedSchema![column.related!.field]
|
||||
map[column.name] = getRelatedTableValues(
|
||||
row,
|
||||
{ ...column, related: column.related! },
|
||||
fromField
|
||||
)
|
||||
return map
|
||||
}, {}),
|
||||
}))
|
||||
// Find columns which require additional processing
|
||||
const cols = Object.values($enrichedSchema || {})
|
||||
const relatedColumns = cols.filter(col => col.related)
|
||||
const formattedColumns = cols.filter(col => col.format)
|
||||
|
||||
return $rows.map<IndexedUIRow>((row, idx) => {
|
||||
// Derive any values that need enriched from related rows
|
||||
const relatedValues = relatedColumns.reduce<Record<string, string>>(
|
||||
(map, column) => {
|
||||
const fromField = $enrichedSchema![column.related!.field]
|
||||
map[column.name] = getRelatedTableValues(
|
||||
row,
|
||||
{ ...column, related: column.related! },
|
||||
fromField
|
||||
)
|
||||
return map
|
||||
},
|
||||
{}
|
||||
)
|
||||
// Derive any display-only formatted values for this row
|
||||
const formattedValues = formattedColumns.reduce<Record<string, any>>(
|
||||
(map, column) => {
|
||||
map[column.name] = column.format!(row)
|
||||
return map
|
||||
},
|
||||
{}
|
||||
)
|
||||
return {
|
||||
...row,
|
||||
...relatedValues,
|
||||
__formatted: formattedValues,
|
||||
__idx: idx,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -791,6 +810,7 @@ export const createActions = (context: StoreContext): RowActionStore => {
|
|||
let clone: Row = { ...row }
|
||||
delete clone.__idx
|
||||
delete clone.__metadata
|
||||
delete clone.__formatted
|
||||
if (!get(hasBudibaseIdentifiers) && isGeneratedRowID(clone._id!)) {
|
||||
delete clone._id
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { ComponentDefinition, ComponentSetting } from "@budibase/types"
|
||||
|
||||
/**
|
||||
* Gets the definition of this component's settings from the manifest
|
||||
*/
|
||||
export const getSettingsDefinition = (
|
||||
definition: ComponentDefinition
|
||||
): ComponentSetting[] => {
|
||||
if (!definition) {
|
||||
return []
|
||||
}
|
||||
let settings: ComponentSetting[] = []
|
||||
definition.settings?.forEach(setting => {
|
||||
if (setting.section) {
|
||||
settings = settings.concat(
|
||||
(setting.settings || [])?.map(childSetting => ({
|
||||
...childSetting,
|
||||
sectionDependsOn: setting.dependsOn,
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
settings.push(setting)
|
||||
}
|
||||
})
|
||||
return settings
|
||||
}
|
|
@ -13,3 +13,4 @@ export * from "./download"
|
|||
export * from "./settings"
|
||||
export * from "./relatedColumns"
|
||||
export * from "./table"
|
||||
export * from "./components"
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 43a5785ccb4f83ce929b29f05ea0a62199fcdf23
|
||||
Subproject commit 8cbaa80a9cc1152c6cd53722e64da7d824da6e16
|
|
@ -107,11 +107,8 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("Should ensure you can't have a branch as not a last step", async () => {
|
||||
const automation = createAutomationBuilder({
|
||||
name: "String Equality Branching",
|
||||
appId: config.getAppId(),
|
||||
})
|
||||
.appAction({ fields: { status: "active" } })
|
||||
const automation = createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({
|
||||
activeBranch: {
|
||||
steps: stepBuilder =>
|
||||
|
@ -134,11 +131,8 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("Should check validation on an automation that has a branch step with no children", async () => {
|
||||
const automation = createAutomationBuilder({
|
||||
name: "String Equality Branching",
|
||||
appId: config.getAppId(),
|
||||
})
|
||||
.appAction({ fields: { status: "active" } })
|
||||
const automation = createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({})
|
||||
.serverLog({ text: "Inactive user" })
|
||||
.build()
|
||||
|
@ -153,11 +147,8 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("Should check validation on a branch step with empty conditions", async () => {
|
||||
const automation = createAutomationBuilder({
|
||||
name: "String Equality Branching",
|
||||
appId: config.getAppId(),
|
||||
})
|
||||
.appAction({ fields: { status: "active" } })
|
||||
const automation = createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({
|
||||
activeBranch: {
|
||||
steps: stepBuilder =>
|
||||
|
@ -177,11 +168,8 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("Should check validation on an branch that has a condition that is not valid", async () => {
|
||||
const automation = createAutomationBuilder({
|
||||
name: "String Equality Branching",
|
||||
appId: config.getAppId(),
|
||||
})
|
||||
.appAction({ fields: { status: "active" } })
|
||||
const automation = createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({
|
||||
activeBranch: {
|
||||
steps: stepBuilder =>
|
||||
|
@ -252,12 +240,8 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("should be able to access platformUrl, logoUrl and company in the automation", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Automation",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({
|
||||
text: "{{ settings.url }}",
|
||||
})
|
||||
|
@ -267,7 +251,7 @@ describe("/automations", () => {
|
|||
.serverLog({
|
||||
text: "{{ settings.company }}",
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(result.steps[0].outputs.message).toEndWith("https://example.com")
|
||||
expect(result.steps[1].outputs.message).toEndWith(
|
||||
|
|
|
@ -169,6 +169,18 @@ if (descriptions.length) {
|
|||
)
|
||||
}
|
||||
|
||||
const resetRowUsage = async () => {
|
||||
await config.doInContext(
|
||||
undefined,
|
||||
async () =>
|
||||
await quotas.setUsage(
|
||||
0,
|
||||
StaticQuotaName.ROWS,
|
||||
QuotaUsageType.STATIC
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const getRowUsage = async () => {
|
||||
const { total } = await config.doInContext(undefined, () =>
|
||||
quotas.getCurrentUsageValues(
|
||||
|
@ -206,6 +218,10 @@ if (descriptions.length) {
|
|||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetRowUsage()
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a new row successfully", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
@ -3317,6 +3333,7 @@ if (descriptions.length) {
|
|||
beforeAll(async () => {
|
||||
mocks.licenses.useBudibaseAI()
|
||||
mocks.licenses.useAICustomConfigs()
|
||||
|
||||
envCleanup = setEnv({
|
||||
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
|
||||
})
|
||||
|
|
|
@ -1040,6 +1040,12 @@ if (descriptions.length) {
|
|||
string: { name: "FO" },
|
||||
}).toContainExactly([{ name: "foo" }])
|
||||
})
|
||||
|
||||
it("should not coerce string to date for string columns", async () => {
|
||||
await expectQuery({
|
||||
string: { name: "2020-01-01" },
|
||||
}).toFindNothing()
|
||||
})
|
||||
})
|
||||
|
||||
describe("range", () => {
|
||||
|
|
|
@ -5,9 +5,9 @@ import * as automation from "../threads/automation"
|
|||
import { backups } from "@budibase/pro"
|
||||
import { getAppMigrationQueue } from "../appMigrations/queue"
|
||||
import { createBullBoard } from "@bull-board/api"
|
||||
import BullQueue from "bull"
|
||||
import { AutomationData } from "@budibase/types"
|
||||
|
||||
export const automationQueue: BullQueue.Queue = queue.createQueue(
|
||||
export const automationQueue = queue.createQueue<AutomationData>(
|
||||
queue.JobQueue.AUTOMATION,
|
||||
{ removeStalledCb: automation.removeStalled }
|
||||
)
|
||||
|
@ -16,24 +16,20 @@ const PATH_PREFIX = "/bulladmin"
|
|||
|
||||
export async function init() {
|
||||
// Set up queues for bull board admin
|
||||
const queues = [new BullAdapter(automationQueue)]
|
||||
|
||||
const backupQueue = backups.getBackupQueue()
|
||||
const appMigrationQueue = getAppMigrationQueue()
|
||||
const queues = [automationQueue]
|
||||
if (backupQueue) {
|
||||
queues.push(backupQueue)
|
||||
queues.push(new BullAdapter(backupQueue))
|
||||
}
|
||||
|
||||
const appMigrationQueue = getAppMigrationQueue()
|
||||
if (appMigrationQueue) {
|
||||
queues.push(appMigrationQueue)
|
||||
queues.push(new BullAdapter(appMigrationQueue))
|
||||
}
|
||||
const adapters = []
|
||||
const serverAdapter: any = new KoaAdapter()
|
||||
for (let queue of queues) {
|
||||
adapters.push(new BullAdapter(queue))
|
||||
}
|
||||
createBullBoard({
|
||||
queues: adapters,
|
||||
serverAdapter,
|
||||
})
|
||||
|
||||
const serverAdapter = new KoaAdapter()
|
||||
createBullBoard({ queues, serverAdapter })
|
||||
serverAdapter.setBasePath(PATH_PREFIX)
|
||||
return serverAdapter.registerPlugin()
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { processEvent } from "./utils"
|
||||
import { automationQueue } from "./bullboard"
|
||||
import { rebootTrigger } from "./triggers"
|
||||
import BullQueue from "bull"
|
||||
import { automationsEnabled } from "../features"
|
||||
|
||||
export { automationQueue } from "./bullboard"
|
||||
|
@ -25,6 +24,6 @@ export async function init() {
|
|||
return promise
|
||||
}
|
||||
|
||||
export function getQueues(): BullQueue.Queue[] {
|
||||
return [automationQueue]
|
||||
export function getQueue() {
|
||||
return automationQueue
|
||||
}
|
||||
|
|
|
@ -17,11 +17,11 @@ import { basicAutomation } from "../../tests/utilities/structures"
|
|||
import { wait } from "../../utilities"
|
||||
import { makePartial } from "../../tests/utilities"
|
||||
import { cleanInputValues } from "../automationUtils"
|
||||
import * as setup from "./utilities"
|
||||
import { Automation } from "@budibase/types"
|
||||
import TestConfiguration from "../../tests/utilities/TestConfiguration"
|
||||
|
||||
describe("Run through some parts of the automations system", () => {
|
||||
let config = setup.getConfig()
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeAll(async () => {
|
||||
await automation.init()
|
||||
|
@ -30,7 +30,7 @@ describe("Run through some parts of the automations system", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await automation.shutdown()
|
||||
setup.afterAll()
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should be able to init in builder", async () => {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import * as automation from "../../index"
|
||||
import * as setup from "../utilities"
|
||||
import * as automation from "../index"
|
||||
import { Table, AutomationStatus } from "@budibase/types"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import TestConfiguration from "../../tests/utilities/TestConfiguration"
|
||||
|
||||
describe("Branching automations", () => {
|
||||
let config = setup.getConfig(),
|
||||
table: Table
|
||||
const config = new TestConfiguration()
|
||||
let table: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
await automation.init()
|
||||
|
@ -14,7 +14,9 @@ describe("Branching automations", () => {
|
|||
await config.createRow()
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should run a multiple nested branching automation", async () => {
|
||||
const firstLogId = "11111111-1111-1111-1111-111111111111"
|
||||
|
@ -22,12 +24,8 @@ describe("Branching automations", () => {
|
|||
const branch2LogId = "33333333-3333-3333-3333-333333333333"
|
||||
const branch2Id = "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Trigger with Loop and Create Row",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog(
|
||||
{ text: "Starting automation" },
|
||||
{ stepName: "FirstLog", stepId: firstLogId }
|
||||
|
@ -78,19 +76,15 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[3].outputs.status).toContain("branch1 branch taken")
|
||||
expect(results.steps[4].outputs.message).toContain("Branch 1.1")
|
||||
})
|
||||
|
||||
it("should execute correct branch based on string equality", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "String Equality Branching",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { status: "active" } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({
|
||||
activeBranch: {
|
||||
steps: stepBuilder => stepBuilder.serverLog({ text: "Active user" }),
|
||||
|
@ -106,7 +100,7 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { status: "active" } })
|
||||
expect(results.steps[0].outputs.status).toContain(
|
||||
"activeBranch branch taken"
|
||||
)
|
||||
|
@ -114,12 +108,8 @@ describe("Branching automations", () => {
|
|||
})
|
||||
|
||||
it("should handle multiple conditions with AND operator", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Multiple AND Conditions Branching",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { status: "active", role: "admin" } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({
|
||||
activeAdminBranch: {
|
||||
steps: stepBuilder =>
|
||||
|
@ -140,18 +130,14 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { status: "active", role: "admin" } })
|
||||
|
||||
expect(results.steps[1].outputs.message).toContain("Active admin user")
|
||||
})
|
||||
|
||||
it("should handle multiple conditions with OR operator", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Multiple OR Conditions Branching",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { status: "test", role: "user" } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.branch({
|
||||
specialBranch: {
|
||||
steps: stepBuilder => stepBuilder.serverLog({ text: "Special user" }),
|
||||
|
@ -176,18 +162,14 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { status: "test", role: "user" } })
|
||||
|
||||
expect(results.steps[1].outputs.message).toContain("Special user")
|
||||
})
|
||||
|
||||
it("should stop the branch automation when no conditions are met", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Multiple OR Conditions Branching",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { status: "test", role: "user" } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.createRow({ row: { name: "Test", tableId: table._id } })
|
||||
.branch({
|
||||
specialBranch: {
|
||||
|
@ -213,8 +195,7 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.serverLog({ text: "Test" })
|
||||
.run()
|
||||
.test({ fields: { status: "test", role: "user" } })
|
||||
|
||||
expect(results.steps[1].outputs.status).toEqual(
|
||||
AutomationStatus.NO_CONDITION_MET
|
||||
|
@ -223,12 +204,8 @@ describe("Branching automations", () => {
|
|||
})
|
||||
|
||||
it("evaluate multiple conditions", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "evaluate multiple conditions",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { test_trigger: true } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({ text: "Starting automation" }, { stepId: "aN6znRYHG" })
|
||||
.branch({
|
||||
specialBranch: {
|
||||
|
@ -262,18 +239,14 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { test_trigger: true } })
|
||||
|
||||
expect(results.steps[2].outputs.message).toContain("Special user")
|
||||
})
|
||||
|
||||
it("evaluate multiple conditions with interpolated text", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "evaluate multiple conditions",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { test_trigger: true } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({ text: "Starting automation" }, { stepId: "aN6znRYHG" })
|
||||
.branch({
|
||||
specialBranch: {
|
||||
|
@ -303,7 +276,7 @@ describe("Branching automations", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { test_trigger: true } })
|
||||
|
||||
expect(results.steps[2].outputs.message).toContain("Special user")
|
||||
})
|
|
@ -1,22 +0,0 @@
|
|||
import { runStep, actions, getConfig } from "./utilities"
|
||||
import { reset } from "timekeeper"
|
||||
|
||||
// need real Date for this test
|
||||
reset()
|
||||
|
||||
describe("test the delay logic", () => {
|
||||
const config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
it("should be able to run the delay", async () => {
|
||||
const time = 100
|
||||
const before = Date.now()
|
||||
await runStep(config, actions.DELAY.stepId, { time: time })
|
||||
const now = Date.now()
|
||||
// divide by two just so that test will always pass as long as there was some sort of delay
|
||||
expect(now - before).toBeGreaterThanOrEqual(time / 2)
|
||||
})
|
||||
})
|
|
@ -1,65 +0,0 @@
|
|||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import * as setup from "./utilities"
|
||||
|
||||
describe("test the delete row action", () => {
|
||||
let table: any,
|
||||
row: any,
|
||||
config = setup.getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
table = await config.createTable()
|
||||
row = await config.createRow()
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
it("should be able to run the delete row action", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Delete Row Automation",
|
||||
})
|
||||
|
||||
await builder
|
||||
.appAction({ fields: {} })
|
||||
.deleteRow({
|
||||
tableId: table._id,
|
||||
id: row._id,
|
||||
revision: row._rev,
|
||||
})
|
||||
.run()
|
||||
|
||||
await config.api.row.get(table._id, row._id, {
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it("should check invalid inputs return an error", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Invalid Inputs Automation",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
.deleteRow({ tableId: "", id: "", revision: "" })
|
||||
.run()
|
||||
|
||||
expect(results.steps[0].outputs.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("should return an error when table doesn't exist", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Nonexistent Table Automation",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
.deleteRow({
|
||||
tableId: "invalid",
|
||||
id: "invalid",
|
||||
revision: "invalid",
|
||||
})
|
||||
.run()
|
||||
|
||||
expect(results.steps[0].outputs.success).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -1,26 +0,0 @@
|
|||
import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities"
|
||||
import nock from "nock"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
let config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll(_afterAll)
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
nock("http://www.example.com/").post("/").reply(200, { foo: "bar" })
|
||||
const res = await runStep(config, actions.discord.stepId, {
|
||||
url: "http://www.example.com",
|
||||
username: "joe_bloggs",
|
||||
})
|
||||
expect(res.response.foo).toEqual("bar")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
})
|
|
@ -1,64 +0,0 @@
|
|||
import * as setup from "./utilities"
|
||||
import { automations } from "@budibase/shared-core"
|
||||
|
||||
const FilterConditions = automations.steps.filter.FilterConditions
|
||||
|
||||
describe("test the filter logic", () => {
|
||||
const config = setup.getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
async function checkFilter(
|
||||
field: any,
|
||||
condition: string,
|
||||
value: any,
|
||||
pass = true
|
||||
) {
|
||||
let res = await setup.runStep(config, setup.actions.FILTER.stepId, {
|
||||
field,
|
||||
condition,
|
||||
value,
|
||||
})
|
||||
expect(res.result).toEqual(pass)
|
||||
expect(res.success).toEqual(true)
|
||||
}
|
||||
|
||||
it("should be able test equality", async () => {
|
||||
await checkFilter("hello", FilterConditions.EQUAL, "hello", true)
|
||||
await checkFilter("hello", FilterConditions.EQUAL, "no", false)
|
||||
})
|
||||
|
||||
it("should be able to test greater than", async () => {
|
||||
await checkFilter(10, FilterConditions.GREATER_THAN, 5, true)
|
||||
await checkFilter(10, FilterConditions.GREATER_THAN, 15, false)
|
||||
})
|
||||
|
||||
it("should be able to test less than", async () => {
|
||||
await checkFilter(5, FilterConditions.LESS_THAN, 10, true)
|
||||
await checkFilter(15, FilterConditions.LESS_THAN, 10, false)
|
||||
})
|
||||
|
||||
it("should be able to in-equality", async () => {
|
||||
await checkFilter("hello", FilterConditions.NOT_EQUAL, "no", true)
|
||||
await checkFilter(10, FilterConditions.NOT_EQUAL, 10, false)
|
||||
})
|
||||
|
||||
it("check number coercion", async () => {
|
||||
await checkFilter("10", FilterConditions.GREATER_THAN, "5", true)
|
||||
})
|
||||
|
||||
it("check date coercion", async () => {
|
||||
await checkFilter(
|
||||
new Date().toISOString(),
|
||||
FilterConditions.GREATER_THAN,
|
||||
new Date(-10000).toISOString(),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it("check objects always false", async () => {
|
||||
await checkFilter({}, FilterConditions.EQUAL, {}, false)
|
||||
})
|
||||
})
|
|
@ -1,153 +0,0 @@
|
|||
import * as automation from "../index"
|
||||
import * as triggers from "../triggers"
|
||||
import { loopAutomation } from "../../tests/utilities/structures"
|
||||
import { context } from "@budibase/backend-core"
|
||||
import * as setup from "./utilities"
|
||||
import { Table, LoopStepType, AutomationResults } from "@budibase/types"
|
||||
import * as loopUtils from "../loopUtils"
|
||||
import { LoopInput } from "../../definitions/automations"
|
||||
|
||||
describe("Attempt to run a basic loop automation", () => {
|
||||
let config = setup.getConfig(),
|
||||
table: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
await automation.init()
|
||||
await config.init()
|
||||
table = await config.createTable()
|
||||
await config.createRow()
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
async function runLoop(loopOpts?: LoopInput): Promise<AutomationResults> {
|
||||
const appId = config.getAppId()
|
||||
return await context.doInAppContext(appId, async () => {
|
||||
const params = { fields: { appId } }
|
||||
const result = await triggers.externalTrigger(
|
||||
loopAutomation(table._id!, loopOpts),
|
||||
params,
|
||||
{ getResponses: true }
|
||||
)
|
||||
if ("outputs" in result && !result.outputs.success) {
|
||||
throw new Error("Unable to proceed - failed to return anything.")
|
||||
}
|
||||
return result as AutomationResults
|
||||
})
|
||||
}
|
||||
|
||||
it("attempt to run a basic loop", async () => {
|
||||
const resp = await runLoop()
|
||||
expect(resp.steps[2].outputs.iterations).toBe(1)
|
||||
})
|
||||
|
||||
it("test a loop with a string", async () => {
|
||||
const resp = await runLoop({
|
||||
option: LoopStepType.STRING,
|
||||
binding: "a,b,c",
|
||||
})
|
||||
expect(resp.steps[2].outputs.iterations).toBe(3)
|
||||
})
|
||||
|
||||
it("test a loop with a binding that returns an integer", async () => {
|
||||
const resp = await runLoop({
|
||||
option: LoopStepType.ARRAY,
|
||||
binding: "{{ 1 }}",
|
||||
})
|
||||
expect(resp.steps[2].outputs.iterations).toBe(1)
|
||||
})
|
||||
|
||||
describe("replaceFakeBindings", () => {
|
||||
it("should replace loop bindings in nested objects", () => {
|
||||
const originalStepInput = {
|
||||
schema: {
|
||||
name: {
|
||||
type: "string",
|
||||
constraints: {
|
||||
type: "string",
|
||||
length: { maximum: null },
|
||||
presence: false,
|
||||
},
|
||||
name: "name",
|
||||
display: { type: "Text" },
|
||||
},
|
||||
},
|
||||
row: {
|
||||
tableId: "ta_aaad4296e9f74b12b1b90ef7a84afcad",
|
||||
name: "{{ loop.currentItem.pokemon }}",
|
||||
},
|
||||
}
|
||||
|
||||
const loopStepNumber = 3
|
||||
|
||||
const result = loopUtils.replaceFakeBindings(
|
||||
originalStepInput,
|
||||
loopStepNumber
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
schema: {
|
||||
name: {
|
||||
type: "string",
|
||||
constraints: {
|
||||
type: "string",
|
||||
length: { maximum: null },
|
||||
presence: false,
|
||||
},
|
||||
name: "name",
|
||||
display: { type: "Text" },
|
||||
},
|
||||
},
|
||||
row: {
|
||||
tableId: "ta_aaad4296e9f74b12b1b90ef7a84afcad",
|
||||
name: "{{ steps.3.currentItem.pokemon }}",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle null values in nested objects", () => {
|
||||
const originalStepInput = {
|
||||
nullValue: null,
|
||||
nestedNull: {
|
||||
someKey: null,
|
||||
},
|
||||
validValue: "{{ loop.someValue }}",
|
||||
}
|
||||
|
||||
const loopStepNumber = 2
|
||||
|
||||
const result = loopUtils.replaceFakeBindings(
|
||||
originalStepInput,
|
||||
loopStepNumber
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
nullValue: null,
|
||||
nestedNull: {
|
||||
someKey: null,
|
||||
},
|
||||
validValue: "{{ steps.2.someValue }}",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty objects and arrays", () => {
|
||||
const originalStepInput = {
|
||||
emptyObject: {},
|
||||
emptyArray: [],
|
||||
nestedEmpty: {
|
||||
emptyObj: {},
|
||||
emptyArr: [],
|
||||
},
|
||||
}
|
||||
|
||||
const loopStepNumber = 1
|
||||
|
||||
const result = loopUtils.replaceFakeBindings(
|
||||
originalStepInput,
|
||||
loopStepNumber
|
||||
)
|
||||
|
||||
expect(result).toEqual(originalStepInput)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,58 +0,0 @@
|
|||
import { getConfig, afterAll, runStep, actions } from "./utilities"
|
||||
import nock from "nock"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
let config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll()
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
nock("http://www.example.com/").post("/").reply(200, { foo: "bar" })
|
||||
const res = await runStep(config, actions.integromat.stepId, {
|
||||
url: "http://www.example.com",
|
||||
})
|
||||
expect(res.response.foo).toEqual("bar")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should add the payload props when a JSON string is provided", async () => {
|
||||
const payload = {
|
||||
value1: 1,
|
||||
value2: 2,
|
||||
value3: 3,
|
||||
value4: 4,
|
||||
value5: 5,
|
||||
name: "Adam",
|
||||
age: 9,
|
||||
}
|
||||
|
||||
nock("http://www.example.com/")
|
||||
.post("/", payload)
|
||||
.reply(200, { foo: "bar" })
|
||||
|
||||
const res = await runStep(config, actions.integromat.stepId, {
|
||||
body: { value: JSON.stringify(payload) },
|
||||
url: "http://www.example.com",
|
||||
})
|
||||
expect(res.response.foo).toEqual("bar")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should return a 400 if the JSON payload string is malformed", async () => {
|
||||
const res = await runStep(config, actions.integromat.stepId, {
|
||||
body: { value: "{ invalid json }" },
|
||||
url: "http://www.example.com",
|
||||
})
|
||||
expect(res.httpStatus).toEqual(400)
|
||||
expect(res.response).toEqual("Invalid payload JSON")
|
||||
expect(res.success).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -1,71 +0,0 @@
|
|||
import { getConfig, afterAll, runStep, actions } from "./utilities"
|
||||
import nock from "nock"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
let config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll()
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
it("should be able to run the action and default to 'get'", async () => {
|
||||
nock("http://www.example.com/").get("/").reply(200, { foo: "bar" })
|
||||
const res = await runStep(config, actions.n8n.stepId, {
|
||||
url: "http://www.example.com",
|
||||
body: {
|
||||
test: "IGNORE_ME",
|
||||
},
|
||||
})
|
||||
expect(res.response.foo).toEqual("bar")
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should add the payload props when a JSON string is provided", async () => {
|
||||
nock("http://www.example.com/")
|
||||
.post("/", { name: "Adam", age: 9 })
|
||||
.reply(200)
|
||||
const res = await runStep(config, actions.n8n.stepId, {
|
||||
body: {
|
||||
value: JSON.stringify({ name: "Adam", age: 9 }),
|
||||
},
|
||||
method: "POST",
|
||||
url: "http://www.example.com",
|
||||
})
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should return a 400 if the JSON payload string is malformed", async () => {
|
||||
const payload = `{ value1 1 }`
|
||||
const res = await runStep(config, actions.n8n.stepId, {
|
||||
value1: "ONE",
|
||||
body: {
|
||||
value: payload,
|
||||
},
|
||||
method: "POST",
|
||||
url: "http://www.example.com",
|
||||
})
|
||||
expect(res.httpStatus).toEqual(400)
|
||||
expect(res.response).toEqual("Invalid payload JSON")
|
||||
expect(res.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("should not append the body if the method is HEAD", async () => {
|
||||
nock("http://www.example.com/")
|
||||
.head("/", body => body === "")
|
||||
.reply(200)
|
||||
const res = await runStep(config, actions.n8n.stepId, {
|
||||
url: "http://www.example.com",
|
||||
method: "HEAD",
|
||||
body: {
|
||||
test: "IGNORE_ME",
|
||||
},
|
||||
})
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
})
|
|
@ -1,165 +0,0 @@
|
|||
import { getConfig, afterAll as _afterAll } from "./utilities"
|
||||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import { OpenAI } from "openai"
|
||||
import { setEnv as setCoreEnv } from "@budibase/backend-core"
|
||||
import * as pro from "@budibase/pro"
|
||||
import { Model } from "@budibase/types"
|
||||
|
||||
jest.mock("openai", () => ({
|
||||
OpenAI: jest.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: jest.fn(() => ({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "This is a test",
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}))
|
||||
jest.mock("@budibase/pro", () => ({
|
||||
...jest.requireActual("@budibase/pro"),
|
||||
ai: {
|
||||
LargeLanguageModel: {
|
||||
forCurrentTenant: jest.fn().mockImplementation(() => ({
|
||||
llm: {},
|
||||
init: jest.fn(),
|
||||
run: jest.fn(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
features: {
|
||||
isAICustomConfigsEnabled: jest.fn(),
|
||||
isBudibaseAIEnabled: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockedPro = jest.mocked(pro)
|
||||
const mockedOpenAI = OpenAI as jest.MockedClass<typeof OpenAI>
|
||||
|
||||
const OPENAI_PROMPT = "What is the meaning of life?"
|
||||
|
||||
describe("test the openai action", () => {
|
||||
let config = getConfig()
|
||||
let resetEnv: () => void | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
setCoreEnv({ SELF_HOSTED: true })
|
||||
await config.init()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetEnv = setCoreEnv({ OPENAI_API_KEY: "abc123" })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetEnv()
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterAll(_afterAll)
|
||||
|
||||
it("should be able to receive a response from ChatGPT given a prompt", async () => {
|
||||
setCoreEnv({ SELF_HOSTED: true })
|
||||
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test OpenAI Response",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
.openai(
|
||||
{ prompt: OPENAI_PROMPT, model: Model.GPT_4O_MINI },
|
||||
{ stepName: "Basic OpenAI Query" }
|
||||
)
|
||||
.run()
|
||||
|
||||
expect(result.steps[0].outputs.response).toEqual("This is a test")
|
||||
expect(result.steps[0].outputs.success).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should present the correct error message when a prompt is not provided", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test OpenAI No Prompt",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
.openai(
|
||||
{ prompt: "", model: Model.GPT_4O_MINI },
|
||||
{ stepName: "Empty Prompt Query" }
|
||||
)
|
||||
.run()
|
||||
|
||||
expect(result.steps[0].outputs.response).toEqual(
|
||||
"Budibase OpenAI Automation Failed: No prompt supplied"
|
||||
)
|
||||
expect(result.steps[0].outputs.success).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => {
|
||||
mockedOpenAI.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
chat: {
|
||||
completions: {
|
||||
create: jest.fn(() => {
|
||||
throw new Error(
|
||||
"An error occurred while calling createChatCompletion"
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
)
|
||||
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test OpenAI Error",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
.openai(
|
||||
{ prompt: OPENAI_PROMPT, model: Model.GPT_4O_MINI },
|
||||
{ stepName: "Error Producing Query" }
|
||||
)
|
||||
.run()
|
||||
|
||||
expect(result.steps[0].outputs.response).toEqual(
|
||||
"Error: An error occurred while calling createChatCompletion"
|
||||
)
|
||||
expect(result.steps[0].outputs.success).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should ensure that the pro AI module is called when the budibase AI features are enabled", async () => {
|
||||
jest.spyOn(pro.features, "isBudibaseAIEnabled").mockResolvedValue(true)
|
||||
jest.spyOn(pro.features, "isAICustomConfigsEnabled").mockResolvedValue(true)
|
||||
|
||||
const prompt = "What is the meaning of life?"
|
||||
await createAutomationBuilder({
|
||||
name: "Test OpenAI Pro Features",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
.openai(
|
||||
{
|
||||
model: Model.GPT_4O_MINI,
|
||||
prompt,
|
||||
},
|
||||
{ stepName: "Pro Features Query" }
|
||||
)
|
||||
.run()
|
||||
|
||||
expect(pro.ai.LargeLanguageModel.forCurrentTenant).toHaveBeenCalledWith(
|
||||
"gpt-4o-mini"
|
||||
)
|
||||
|
||||
const llmInstance =
|
||||
mockedPro.ai.LargeLanguageModel.forCurrentTenant.mock.results[0].value
|
||||
// init does not appear to be called currently
|
||||
// expect(llmInstance.init).toHaveBeenCalled()
|
||||
expect(llmInstance.run).toHaveBeenCalledWith(prompt)
|
||||
})
|
||||
})
|
|
@ -1,37 +0,0 @@
|
|||
import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities"
|
||||
import nock from "nock"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
const config = getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll(_afterAll)
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
nock("http://www.example.com")
|
||||
.post("/", { a: 1 })
|
||||
.reply(200, { foo: "bar" })
|
||||
const res = await runStep(config, actions.OUTGOING_WEBHOOK.stepId, {
|
||||
requestMethod: "POST",
|
||||
url: "www.example.com",
|
||||
requestBody: JSON.stringify({ a: 1 }),
|
||||
})
|
||||
expect(res.success).toEqual(true)
|
||||
expect(res.response.foo).toEqual("bar")
|
||||
})
|
||||
|
||||
it("should return an error if something goes wrong in fetch", async () => {
|
||||
const res = await runStep(config, actions.OUTGOING_WEBHOOK.stepId, {
|
||||
requestMethod: "GET",
|
||||
url: "www.invalid.com",
|
||||
})
|
||||
expect(res.success).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -1,43 +1,36 @@
|
|||
import * as automation from "../../index"
|
||||
import * as setup from "../utilities"
|
||||
import * as automation from "../index"
|
||||
import { LoopStepType, FieldType, Table, Datasource } from "@budibase/types"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import {
|
||||
DatabaseName,
|
||||
datasourceDescribe,
|
||||
} from "../../../integrations/tests/utils"
|
||||
} from "../../integrations/tests/utils"
|
||||
import { Knex } from "knex"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import { automations } from "@budibase/shared-core"
|
||||
import TestConfiguration from "../../tests/utilities/TestConfiguration"
|
||||
import { basicTable } from "../../tests/utilities/structures"
|
||||
|
||||
const FilterConditions = automations.steps.filter.FilterConditions
|
||||
|
||||
describe("Automation Scenarios", () => {
|
||||
let config = setup.getConfig()
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeEach(async () => {
|
||||
await automation.init()
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
describe("Row Automations", () => {
|
||||
it("should trigger an automation which then creates a row", async () => {
|
||||
const table = await config.createTable()
|
||||
const table = await config.api.table.save(basicTable())
|
||||
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Row Save and Create",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.rowUpdated(
|
||||
{ tableId: table._id! },
|
||||
{
|
||||
row: { name: "Test", description: "TEST" },
|
||||
id: "1234",
|
||||
}
|
||||
)
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onRowUpdated({ tableId: table._id! })
|
||||
.createRow({
|
||||
row: {
|
||||
name: "{{trigger.row.name}}",
|
||||
|
@ -45,7 +38,10 @@ describe("Automation Scenarios", () => {
|
|||
tableId: table._id,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({
|
||||
row: { name: "Test", description: "TEST" },
|
||||
id: "1234",
|
||||
})
|
||||
|
||||
expect(results.steps).toHaveLength(1)
|
||||
|
||||
|
@ -58,45 +54,35 @@ describe("Automation Scenarios", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("should trigger an automation which querys the database", async () => {
|
||||
const table = await config.createTable()
|
||||
it("should trigger an automation which queries the database", async () => {
|
||||
const table = await config.api.table.save(basicTable())
|
||||
const row = {
|
||||
name: "Test Row",
|
||||
description: "original description",
|
||||
tableId: table._id,
|
||||
}
|
||||
await config.createRow(row)
|
||||
await config.createRow(row)
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Row Save and Create",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
await config.api.row.save(table._id!, row)
|
||||
await config.api.row.save(table._id!, row)
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.queryRows({
|
||||
tableId: table._id!,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps).toHaveLength(1)
|
||||
expect(results.steps[0].outputs.rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should trigger an automation which querys the database then deletes a row", async () => {
|
||||
const table = await config.createTable()
|
||||
it("should trigger an automation which queries the database then deletes a row", async () => {
|
||||
const table = await config.api.table.save(basicTable())
|
||||
const row = {
|
||||
name: "DFN",
|
||||
description: "original description",
|
||||
tableId: table._id,
|
||||
}
|
||||
await config.createRow(row)
|
||||
await config.createRow(row)
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Row Save and Create",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
await config.api.row.save(table._id!, row)
|
||||
await config.api.row.save(table._id!, row)
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.queryRows({
|
||||
tableId: table._id!,
|
||||
})
|
||||
|
@ -107,7 +93,7 @@ describe("Automation Scenarios", () => {
|
|||
.queryRows({
|
||||
tableId: table._id!,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps).toHaveLength(3)
|
||||
expect(results.steps[1].outputs.success).toBeTruthy()
|
||||
|
@ -115,7 +101,8 @@ describe("Automation Scenarios", () => {
|
|||
})
|
||||
|
||||
it("should trigger an automation which creates and then updates a row", async () => {
|
||||
const table = await config.createTable({
|
||||
const table = await config.api.table.save({
|
||||
...basicTable(),
|
||||
name: "TestTable",
|
||||
type: "table",
|
||||
schema: {
|
||||
|
@ -136,12 +123,8 @@ describe("Automation Scenarios", () => {
|
|||
},
|
||||
})
|
||||
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Create and Update Row",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.createRow(
|
||||
{
|
||||
row: {
|
||||
|
@ -170,7 +153,7 @@ describe("Automation Scenarios", () => {
|
|||
},
|
||||
{ stepName: "QueryRowsStep" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps).toHaveLength(3)
|
||||
|
||||
|
@ -202,20 +185,15 @@ describe("Automation Scenarios", () => {
|
|||
|
||||
describe("Name Based Automations", () => {
|
||||
it("should fetch and delete a rpw using automation naming", async () => {
|
||||
const table = await config.createTable()
|
||||
const table = await config.api.table.save(basicTable())
|
||||
const row = {
|
||||
name: "DFN",
|
||||
description: "original description",
|
||||
tableId: table._id,
|
||||
}
|
||||
await config.createRow(row)
|
||||
await config.createRow(row)
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Query and Delete Row",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
await config.api.row.save(table._id!, row)
|
||||
await config.api.row.save(table._id!, row)
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.queryRows(
|
||||
{
|
||||
tableId: table._id!,
|
||||
|
@ -229,7 +207,7 @@ describe("Automation Scenarios", () => {
|
|||
.queryRows({
|
||||
tableId: table._id!,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps).toHaveLength(3)
|
||||
expect(results.steps[1].outputs.success).toBeTruthy()
|
||||
|
@ -240,7 +218,8 @@ describe("Automation Scenarios", () => {
|
|||
let table: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
table = await config.createTable({
|
||||
table = await config.api.table.save({
|
||||
...basicTable(),
|
||||
name: "TestTable",
|
||||
type: "table",
|
||||
schema: {
|
||||
|
@ -263,12 +242,8 @@ describe("Automation Scenarios", () => {
|
|||
})
|
||||
|
||||
it("should stop an automation if the condition is not met", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Equal",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.createRow({
|
||||
row: {
|
||||
name: "Equal Test",
|
||||
|
@ -285,7 +260,7 @@ describe("Automation Scenarios", () => {
|
|||
value: 20,
|
||||
})
|
||||
.serverLog({ text: "Equal condition met" })
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[2].outputs.success).toBeTrue()
|
||||
expect(results.steps[2].outputs.result).toBeFalse()
|
||||
|
@ -293,12 +268,8 @@ describe("Automation Scenarios", () => {
|
|||
})
|
||||
|
||||
it("should continue the automation if the condition is met", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test Not Equal",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.createRow({
|
||||
row: {
|
||||
name: "Not Equal Test",
|
||||
|
@ -315,7 +286,7 @@ describe("Automation Scenarios", () => {
|
|||
value: 20,
|
||||
})
|
||||
.serverLog({ text: "Not Equal condition met" })
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[2].outputs.success).toBeTrue()
|
||||
expect(results.steps[2].outputs.result).toBeTrue()
|
||||
|
@ -364,12 +335,8 @@ describe("Automation Scenarios", () => {
|
|||
it.each(testCases)(
|
||||
"should pass the filter when condition is $condition",
|
||||
async ({ condition, value, rowValue, expectPass }) => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: `Test ${condition}`,
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.createRow({
|
||||
row: {
|
||||
name: `${condition} Test`,
|
||||
|
@ -388,7 +355,7 @@ describe("Automation Scenarios", () => {
|
|||
.serverLog({
|
||||
text: `${condition} condition ${expectPass ? "passed" : "failed"}`,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[2].outputs.result).toBe(expectPass)
|
||||
if (expectPass) {
|
||||
|
@ -401,35 +368,24 @@ describe("Automation Scenarios", () => {
|
|||
})
|
||||
|
||||
it("Check user is passed through from row trigger", async () => {
|
||||
const table = await config.createTable()
|
||||
const table = await config.api.table.save(basicTable())
|
||||
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test a user is successfully passed from the trigger",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.rowUpdated(
|
||||
{ tableId: table._id! },
|
||||
{
|
||||
row: { name: "Test", description: "TEST" },
|
||||
id: "1234",
|
||||
}
|
||||
)
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onRowUpdated({ tableId: table._id! })
|
||||
.serverLog({ text: "{{ [user].[email] }}" })
|
||||
.run()
|
||||
.test({
|
||||
row: { name: "Test", description: "TEST" },
|
||||
id: "1234",
|
||||
})
|
||||
|
||||
expect(results.steps[0].outputs.message).toContain("example.com")
|
||||
})
|
||||
|
||||
it("Check user is passed through from app trigger", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test a user is successfully passed from the trigger",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({ text: "{{ [user].[email] }}" })
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.message).toContain("example.com")
|
||||
})
|
||||
|
@ -449,7 +405,8 @@ if (descriptions.length) {
|
|||
})
|
||||
|
||||
it("should query an external database for some data then insert than into an internal table", async () => {
|
||||
const newTable = await config.createTable({
|
||||
const newTable = await config.api.table.save({
|
||||
...basicTable(),
|
||||
name: "table",
|
||||
type: "table",
|
||||
schema: {
|
||||
|
@ -484,22 +441,21 @@ if (descriptions.length) {
|
|||
|
||||
await client(tableName).insert(rows)
|
||||
|
||||
const query = await setup.saveTestQuery(
|
||||
config,
|
||||
client,
|
||||
tableName,
|
||||
datasource
|
||||
)
|
||||
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Test external query and save",
|
||||
config,
|
||||
const query = await config.api.query.save({
|
||||
name: "test query",
|
||||
datasourceId: datasource._id!,
|
||||
parameters: [],
|
||||
fields: {
|
||||
sql: client(tableName).select("*").toSQL().toNative().sql,
|
||||
},
|
||||
transformer: "",
|
||||
schema: {},
|
||||
readable: true,
|
||||
queryVerb: "read",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({
|
||||
fields: {},
|
||||
})
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.executeQuery({
|
||||
query: {
|
||||
queryId: query._id!,
|
||||
|
@ -519,7 +475,7 @@ if (descriptions.length) {
|
|||
.queryRows({
|
||||
tableId: newTable._id!,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps).toHaveLength(3)
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities"
|
||||
|
||||
describe("test the server log action", () => {
|
||||
let config = getConfig()
|
||||
let inputs: any
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
inputs = {
|
||||
text: "log message",
|
||||
}
|
||||
})
|
||||
afterAll(_afterAll)
|
||||
|
||||
it("should be able to log the text", async () => {
|
||||
let res = await runStep(config, actions.SERVER_LOG.stepId, inputs)
|
||||
expect(res.message).toEqual(`App ${config.getAppId()} - ${inputs.text}`)
|
||||
expect(res.success).toEqual(true)
|
||||
})
|
||||
})
|
|
@ -1,31 +1,31 @@
|
|||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import * as automation from "../index"
|
||||
import * as setup from "./utilities"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
import * as automation from "../../index"
|
||||
import { Table } from "@budibase/types"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
import { basicTable } from "../../../tests/utilities/structures"
|
||||
|
||||
describe("Execute Bash Automations", () => {
|
||||
let config = setup.getConfig(),
|
||||
table: Table
|
||||
const config = new TestConfiguration()
|
||||
let table: Table
|
||||
|
||||
beforeAll(async () => {
|
||||
await automation.init()
|
||||
await config.init()
|
||||
table = await config.createTable()
|
||||
await config.createRow({
|
||||
table = await config.api.table.save(basicTable())
|
||||
await config.api.row.save(table._id!, {
|
||||
name: "test row",
|
||||
description: "test description",
|
||||
tableId: table._id!,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
automation.shutdown()
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should use trigger data in bash command and pass output to subsequent steps", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Bash with Trigger Data",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { command: "hello world" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.bash(
|
||||
{ code: "echo '{{ trigger.fields.command }}'" },
|
||||
{ stepName: "Echo Command" }
|
||||
|
@ -34,7 +34,7 @@ describe("Execute Bash Automations", () => {
|
|||
{ text: "Bash output was: {{ steps.[Echo Command].stdout }}" },
|
||||
{ stepName: "Log Output" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { command: "hello world" } })
|
||||
|
||||
expect(result.steps[0].outputs.stdout).toEqual("hello world\n")
|
||||
expect(result.steps[1].outputs.message).toContain(
|
||||
|
@ -43,11 +43,8 @@ describe("Execute Bash Automations", () => {
|
|||
})
|
||||
|
||||
it("should chain multiple bash commands using previous outputs", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Chained Bash Commands",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { filename: "testfile.txt" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.bash(
|
||||
{ code: "echo 'initial content' > {{ trigger.fields.filename }}" },
|
||||
{ stepName: "Create File" }
|
||||
|
@ -60,18 +57,15 @@ describe("Execute Bash Automations", () => {
|
|||
{ code: "rm {{ trigger.fields.filename }}" },
|
||||
{ stepName: "Cleanup" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { filename: "testfile.txt" } })
|
||||
|
||||
expect(result.steps[1].outputs.stdout).toEqual("INITIAL CONTENT\n")
|
||||
expect(result.steps[1].outputs.success).toEqual(true)
|
||||
})
|
||||
|
||||
it("should integrate bash output with row operations", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Bash with Row Operations",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.queryRows(
|
||||
{
|
||||
tableId: table._id!,
|
||||
|
@ -89,7 +83,7 @@ describe("Execute Bash Automations", () => {
|
|||
{ text: "{{ steps.[Process Row Data].stdout }}" },
|
||||
{ stepName: "Log Result" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(result.steps[1].outputs.stdout).toContain(
|
||||
"Row data: test row - test description"
|
||||
|
@ -100,11 +94,8 @@ describe("Execute Bash Automations", () => {
|
|||
})
|
||||
|
||||
it("should handle bash output in conditional logic", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Bash with Conditional",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { threshold: "5" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.bash(
|
||||
{ code: "echo $(( {{ trigger.fields.threshold }} + 5 ))" },
|
||||
{ stepName: "Calculate Value" }
|
||||
|
@ -122,7 +113,7 @@ describe("Execute Bash Automations", () => {
|
|||
{ text: "Value was {{ steps.[Check Value].value }}" },
|
||||
{ stepName: "Log Result" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { threshold: "5" } })
|
||||
|
||||
expect(result.steps[0].outputs.stdout).toEqual("10\n")
|
||||
expect(result.steps[1].outputs.value).toEqual("high")
|
||||
|
@ -130,17 +121,14 @@ describe("Execute Bash Automations", () => {
|
|||
})
|
||||
|
||||
it("should handle null values gracefully", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Null Bash Input",
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: {} })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.bash(
|
||||
//@ts-ignore
|
||||
// @ts-expect-error - testing null input
|
||||
{ code: null },
|
||||
{ stepName: "Null Command" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(result.steps[0].outputs.stdout).toBe(
|
||||
"Budibase bash automation failed: Invalid inputs"
|
|
@ -1,7 +1,11 @@
|
|||
import * as setup from "./utilities"
|
||||
import { basicTableWithAttachmentField } from "../../tests/utilities/structures"
|
||||
import {
|
||||
basicTable,
|
||||
basicTableWithAttachmentField,
|
||||
} from "../../../tests/utilities/structures"
|
||||
import { objectStore } from "@budibase/backend-core"
|
||||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
import { Row, Table } from "@budibase/types"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
|
||||
async function uploadTestFile(filename: string) {
|
||||
let bucket = "testbucket"
|
||||
|
@ -10,19 +14,20 @@ async function uploadTestFile(filename: string) {
|
|||
filename,
|
||||
body: Buffer.from("test data"),
|
||||
})
|
||||
let presignedUrl = await objectStore.getPresignedUrl(bucket, filename, 60000)
|
||||
let presignedUrl = objectStore.getPresignedUrl(bucket, filename, 60000)
|
||||
|
||||
return presignedUrl
|
||||
}
|
||||
|
||||
describe("test the create row action", () => {
|
||||
let table: any
|
||||
let row: any
|
||||
let config = setup.getConfig()
|
||||
const config = new TestConfiguration()
|
||||
|
||||
let table: Table
|
||||
let row: Row
|
||||
|
||||
beforeEach(async () => {
|
||||
await config.init()
|
||||
table = await config.createTable()
|
||||
table = await config.api.table.save(basicTable())
|
||||
row = {
|
||||
tableId: table._id,
|
||||
name: "test",
|
||||
|
@ -30,28 +35,27 @@ describe("test the create row action", () => {
|
|||
}
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Create Row Flow",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { status: "new" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({ text: "Starting create row flow" }, { stepName: "StartLog" })
|
||||
.createRow({ row }, { stepName: "CreateRow" })
|
||||
.serverLog(
|
||||
{ text: "Row created with ID: {{ stepsByName.CreateRow.row._id }}" },
|
||||
{ stepName: "CreationLog" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { status: "new" } })
|
||||
|
||||
expect(result.steps[1].outputs.success).toBeDefined()
|
||||
expect(result.steps[1].outputs.id).toBeDefined()
|
||||
expect(result.steps[1].outputs.revision).toBeDefined()
|
||||
|
||||
const gottenRow = await config.api.row.get(
|
||||
table._id,
|
||||
table._id!,
|
||||
result.steps[1].outputs.id
|
||||
)
|
||||
expect(gottenRow.name).toEqual("test")
|
||||
|
@ -62,12 +66,8 @@ describe("test the create row action", () => {
|
|||
})
|
||||
|
||||
it("should return an error (not throw) when bad info provided", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Create Row Error Flow",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { status: "error" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({ text: "Starting error test flow" }, { stepName: "StartLog" })
|
||||
.createRow(
|
||||
{
|
||||
|
@ -78,18 +78,14 @@ describe("test the create row action", () => {
|
|||
},
|
||||
{ stepName: "CreateRow" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { status: "error" } })
|
||||
|
||||
expect(result.steps[1].outputs.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("should check invalid inputs return an error", async () => {
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Create Row Invalid Flow",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { status: "invalid" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog({ text: "Testing invalid input" }, { stepName: "StartLog" })
|
||||
.createRow({ row: {} }, { stepName: "CreateRow" })
|
||||
.filter({
|
||||
|
@ -101,18 +97,18 @@ describe("test the create row action", () => {
|
|||
{ text: "This log should not appear" },
|
||||
{ stepName: "SkippedLog" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { status: "invalid" } })
|
||||
|
||||
expect(result.steps[1].outputs.success).toEqual(false)
|
||||
expect(result.steps.length).toBeLessThan(4)
|
||||
})
|
||||
|
||||
it("should check that an attachment field is sent to storage and parsed", async () => {
|
||||
let attachmentTable = await config.createTable(
|
||||
let attachmentTable = await config.api.table.save(
|
||||
basicTableWithAttachmentField()
|
||||
)
|
||||
|
||||
let attachmentRow: any = {
|
||||
let attachmentRow: Row = {
|
||||
tableId: attachmentTable._id,
|
||||
}
|
||||
|
||||
|
@ -126,12 +122,8 @@ describe("test the create row action", () => {
|
|||
]
|
||||
|
||||
attachmentRow.file_attachment = attachmentObject
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Create Row Attachment Flow",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { type: "attachment" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog(
|
||||
{ text: "Processing attachment upload" },
|
||||
{ stepName: "StartLog" }
|
||||
|
@ -148,7 +140,7 @@ describe("test the create row action", () => {
|
|||
},
|
||||
{ stepName: "UploadLog" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { type: "attachment" } })
|
||||
|
||||
expect(result.steps[1].outputs.success).toEqual(true)
|
||||
expect(result.steps[1].outputs.row.file_attachment[0]).toHaveProperty("key")
|
||||
|
@ -165,11 +157,11 @@ describe("test the create row action", () => {
|
|||
})
|
||||
|
||||
it("should check that an single attachment field is sent to storage and parsed", async () => {
|
||||
let attachmentTable = await config.createTable(
|
||||
let attachmentTable = await config.api.table.save(
|
||||
basicTableWithAttachmentField()
|
||||
)
|
||||
|
||||
let attachmentRow: any = {
|
||||
let attachmentRow: Row = {
|
||||
tableId: attachmentTable._id,
|
||||
}
|
||||
|
||||
|
@ -181,12 +173,8 @@ describe("test the create row action", () => {
|
|||
}
|
||||
|
||||
attachmentRow.single_file_attachment = attachmentObject
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Create Row Single Attachment Flow",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { type: "single-attachment" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog(
|
||||
{ text: "Processing single attachment" },
|
||||
{ stepName: "StartLog" }
|
||||
|
@ -221,7 +209,7 @@ describe("test the create row action", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { type: "single-attachment" } })
|
||||
|
||||
expect(result.steps[1].outputs.success).toEqual(true)
|
||||
expect(result.steps[1].outputs.row.single_file_attachment).toHaveProperty(
|
||||
|
@ -240,11 +228,11 @@ describe("test the create row action", () => {
|
|||
})
|
||||
|
||||
it("should check that attachment without the correct keys throws an error", async () => {
|
||||
let attachmentTable = await config.createTable(
|
||||
let attachmentTable = await config.api.table.save(
|
||||
basicTableWithAttachmentField()
|
||||
)
|
||||
|
||||
let attachmentRow: any = {
|
||||
let attachmentRow: Row = {
|
||||
tableId: attachmentTable._id,
|
||||
}
|
||||
|
||||
|
@ -256,12 +244,8 @@ describe("test the create row action", () => {
|
|||
}
|
||||
|
||||
attachmentRow.single_file_attachment = attachmentObject
|
||||
const result = await createAutomationBuilder({
|
||||
name: "Test Create Row Invalid Attachment Flow",
|
||||
appId: config.getAppId(),
|
||||
config,
|
||||
})
|
||||
.appAction({ fields: { type: "invalid-attachment" } })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog(
|
||||
{ text: "Testing invalid attachment keys" },
|
||||
{ stepName: "StartLog" }
|
||||
|
@ -294,7 +278,7 @@ describe("test the create row action", () => {
|
|||
},
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { type: "invalid-attachment" } })
|
||||
|
||||
expect(result.steps[1].outputs.success).toEqual(false)
|
||||
expect(result.steps[1].outputs.response).toEqual(
|
|
@ -1,8 +1,8 @@
|
|||
import tk from "timekeeper"
|
||||
import "../../environment"
|
||||
import * as automations from "../index"
|
||||
import * as setup from "./utilities"
|
||||
import { basicCronAutomation } from "../../tests/utilities/structures"
|
||||
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)
|
||||
|
@ -10,7 +10,7 @@ tk.freeze(initialTime)
|
|||
const oneMinuteInMs = 60 * 1000
|
||||
|
||||
describe("cron automations", () => {
|
||||
let config = setup.getConfig()
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeAll(async () => {
|
||||
await automations.init()
|
||||
|
@ -19,26 +19,22 @@ describe("cron automations", () => {
|
|||
|
||||
afterAll(async () => {
|
||||
await automations.shutdown()
|
||||
setup.afterAll()
|
||||
config.end()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
tk.freeze(initialTime)
|
||||
})
|
||||
|
||||
async function travel(ms: number) {
|
||||
tk.travel(Date.now() + ms)
|
||||
}
|
||||
|
||||
it("should initialise the automation timestamp", async () => {
|
||||
const automation = basicCronAutomation(config.appId!, "* * * * *")
|
||||
await config.api.automation.post(automation)
|
||||
await travel(oneMinuteInMs)
|
||||
await createAutomationBuilder(config).onCron({ cron: "* * * * *" }).save()
|
||||
|
||||
tk.travel(Date.now() + oneMinuteInMs)
|
||||
await config.publish()
|
||||
|
||||
const automationLogs = await config.getAutomationLogs()
|
||||
expect(automationLogs.data).toHaveLength(1)
|
||||
expect(automationLogs.data).toEqual([
|
||||
const { data } = await config.getAutomationLogs()
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data).toEqual([
|
||||
expect.objectContaining({
|
||||
trigger: expect.objectContaining({
|
||||
outputs: { timestamp: initialTime + oneMinuteInMs },
|
|
@ -0,0 +1,29 @@
|
|||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
|
||||
describe("test the delay logic", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should be able to run the delay", async () => {
|
||||
const time = 100
|
||||
const before = performance.now()
|
||||
|
||||
await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.delay({ time })
|
||||
.test({ fields: {} })
|
||||
|
||||
const now = performance.now()
|
||||
|
||||
// divide by two just so that test will always pass as long as there was some sort of delay
|
||||
expect(now - before).toBeGreaterThanOrEqual(time / 2)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,58 @@
|
|||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
import { Row, Table } from "@budibase/types"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
import { basicTable } from "../../../tests/utilities/structures"
|
||||
|
||||
describe("test the delete row action", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
let table: Table
|
||||
let row: Row
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
table = await config.api.table.save(basicTable())
|
||||
row = await config.api.row.save(table._id!, {})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should be able to run the delete row action", async () => {
|
||||
await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.deleteRow({
|
||||
tableId: table._id!,
|
||||
id: row._id!,
|
||||
revision: row._rev,
|
||||
})
|
||||
.test({ fields: {} })
|
||||
|
||||
await config.api.row.get(table._id!, row._id!, {
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it("should check invalid inputs return an error", async () => {
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.deleteRow({ tableId: "", id: "", revision: "" })
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("should return an error when table doesn't exist", async () => {
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.deleteRow({
|
||||
tableId: "invalid",
|
||||
id: "invalid",
|
||||
revision: "invalid",
|
||||
})
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.success).toEqual(false)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,33 @@
|
|||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
import nock from "nock"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
|
||||
describe("test the outgoing webhook action", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll()
|
||||
})
|
||||
|
||||
it("should be able to run the action", async () => {
|
||||
nock("http://www.example.com/").post("/").reply(200, { foo: "bar" })
|
||||
const result = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.discord({
|
||||
url: "http://www.example.com",
|
||||
username: "joe_bloggs",
|
||||
content: "Hello, world",
|
||||
})
|
||||
.test({ fields: {} })
|
||||
expect(result.steps[0].outputs.response.foo).toEqual("bar")
|
||||
expect(result.steps[0].outputs.success).toEqual(true)
|
||||
})
|
||||
})
|
|
@ -1,9 +1,9 @@
|
|||
import { Datasource, Query } from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
import * as setup from "../utilities"
|
||||
import {
|
||||
DatabaseName,
|
||||
datasourceDescribe,
|
||||
} from "../../integrations/tests/utils"
|
||||
} from "../../../integrations/tests/utils"
|
||||
import { Knex } from "knex"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
|
|
@ -1,61 +1,52 @@
|
|||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import * as automation from "../index"
|
||||
import * as setup from "./utilities"
|
||||
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
|
||||
import * as automation from "../../index"
|
||||
import { Table } from "@budibase/types"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
import { basicTable } from "../../../tests/utilities/structures"
|
||||
|
||||
describe("Execute Script Automations", () => {
|
||||
let config = setup.getConfig(),
|
||||
table: Table
|
||||
const config = new TestConfiguration()
|
||||
let table: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeAll(async () => {
|
||||
await automation.init()
|
||||
await config.init()
|
||||
table = await config.createTable()
|
||||
await config.createRow()
|
||||
table = await config.api.table.save(basicTable())
|
||||
await config.api.row.save(table._id!, {})
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(() => {
|
||||
config.end()
|
||||
})
|
||||
|
||||
it("should execute a basic script and return the result", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Basic Script Execution",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.executeScript({ code: "return 2 + 2" })
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual(4)
|
||||
})
|
||||
|
||||
it("should access bindings from previous steps", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Access Bindings",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { data: [1, 2, 3] } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.executeScript(
|
||||
{
|
||||
code: "return trigger.fields.data.map(x => x * 2)",
|
||||
},
|
||||
{ stepId: "binding-script-step" }
|
||||
)
|
||||
.run()
|
||||
.test({ fields: { data: [1, 2, 3] } })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual([2, 4, 6])
|
||||
})
|
||||
|
||||
it("should handle script execution errors gracefully", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Handle Script Errors",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.executeScript({ code: "return nonexistentVariable.map(x => x)" })
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.response).toContain(
|
||||
"ReferenceError: nonexistentVariable is not defined"
|
||||
|
@ -64,12 +55,8 @@ describe("Execute Script Automations", () => {
|
|||
})
|
||||
|
||||
it("should handle conditional logic in scripts", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Conditional Script Logic",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: { value: 10 } })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.executeScript({
|
||||
code: `
|
||||
if (trigger.fields.value > 5) {
|
||||
|
@ -79,18 +66,14 @@ describe("Execute Script Automations", () => {
|
|||
}
|
||||
`,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: { value: 10 } })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual("Value is greater than 5")
|
||||
})
|
||||
|
||||
it("should use multiple steps and validate script execution", async () => {
|
||||
const builder = createAutomationBuilder({
|
||||
name: "Multi-Step Script Execution",
|
||||
})
|
||||
|
||||
const results = await builder
|
||||
.appAction({ fields: {} })
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.serverLog(
|
||||
{ text: "Starting multi-step automation" },
|
||||
{ stepId: "start-log-step" }
|
||||
|
@ -111,7 +94,7 @@ describe("Execute Script Automations", () => {
|
|||
.serverLog({
|
||||
text: `Final result is {{ steps.ScriptingStep1.value }}`,
|
||||
})
|
||||
.run()
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.message).toContain(
|
||||
"Starting multi-step automation"
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue