Merge remote-tracking branch 'origin/master' into poc/generate-tables-using-ai

This commit is contained in:
Adria Navarro 2025-04-02 13:36:32 +02:00
commit b2a2af01a9
156 changed files with 6392 additions and 1158 deletions

View File

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

View File

@ -69,7 +69,7 @@
"rotating-file-stream": "3.1.0",
"sanitize-s3-objectkey": "0.0.1",
"semver": "^7.5.4",
"tar-fs": "2.1.1",
"tar-fs": "2.1.2",
"uuid": "^8.3.2"
},
"devDependencies": {

View File

@ -96,6 +96,24 @@ async function get<T extends Document>(db: Database, id: string): Promise<T> {
return cacheItem.doc
}
async function tryGet<T extends Document>(
db: Database,
id: string
): Promise<T | null> {
const cache = await getCache()
const cacheKey = makeCacheKey(db, id)
let cacheItem: CacheItem<T> | null = await cache.get(cacheKey)
if (!cacheItem) {
const doc = await db.tryGet<T>(id)
if (!doc) {
return null
}
cacheItem = makeCacheItem(doc)
await cache.store(cacheKey, cacheItem)
}
return cacheItem.doc
}
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
const cache = await getCache()
if (!docOrId) {
@ -123,10 +141,17 @@ export class Writethrough {
return put(this.db, doc, writeRateMs)
}
/**
* @deprecated use `tryGet` instead
*/
async get<T extends Document>(id: string) {
return get<T>(this.db, id)
}
async tryGet<T extends Document>(id: string) {
return tryGet<T>(this.db, id)
}
async remove(docOrId: any, rev?: any) {
return remove(this.db, docOrId, rev)
}

View File

@ -47,6 +47,9 @@ export async function getConfig<T extends Config>(
export async function save(
config: Config
): Promise<{ id: string; rev: string }> {
if (!config._id) {
config._id = generateConfigID(config.type)
}
const db = context.getGlobalDB()
return db.put(config)
}

View File

@ -12,7 +12,6 @@ describe("configs", () => {
const setDbPlatformUrl = async (dbUrl: string) => {
const settingsConfig = {
_id: configs.generateConfigID(ConfigType.SETTINGS),
type: ConfigType.SETTINGS,
config: {
platformUrl: dbUrl,

View File

@ -60,6 +60,11 @@ export const StaticDatabases = {
SCIM_LOGS: {
name: "scim-logs",
},
// Used by self-host users making use of Budicloud resources. Introduced when
// we started letting self-host users use Budibase AI in the cloud.
SELF_HOST_CLOUD: {
name: "self-host-cloud",
},
}
export const APP_PREFIX = prefixed(DocumentType.APP)

View File

@ -157,6 +157,33 @@ export async function doInTenant<T>(
return newContext(updates, task)
}
// We allow self-host licensed users to make use of some Budicloud services
// (e.g. Budibase AI). When they do this, they use their license key as an API
// key. We use that license key to identify the tenant ID, and we set the
// context to be self-host using cloud. This affects things like where their
// quota documents get stored (because we want to avoid creating a new global
// DB for each self-host tenant).
export async function doInSelfHostTenantUsingCloud<T>(
tenantId: string,
task: () => T
): Promise<T> {
const updates = { tenantId, isSelfHostUsingCloud: true }
return newContext(updates, task)
}
export function isSelfHostUsingCloud() {
const context = Context.get()
return !!context?.isSelfHostUsingCloud
}
export function getSelfHostCloudDB() {
const context = Context.get()
if (!context || !context.isSelfHostUsingCloud) {
throw new Error("Self-host cloud DB not found")
}
return getDB(StaticDatabases.SELF_HOST_CLOUD.name)
}
export async function doInAppContext<T>(
appId: string,
task: () => T
@ -325,6 +352,11 @@ export function getGlobalDB(): Database {
if (!context || (env.MULTI_TENANCY && !context.tenantId)) {
throw new Error("Global DB not found")
}
if (context.isSelfHostUsingCloud) {
throw new Error(
"Global DB not found - self-host users using cloud don't have a global DB"
)
}
return getDB(baseGlobalDBName(context?.tenantId))
}
@ -344,6 +376,11 @@ export function getAppDB(opts?: any): Database {
if (!appId) {
throw new Error("Unable to retrieve app DB - no app ID.")
}
if (isSelfHostUsingCloud()) {
throw new Error(
"App DB not found - self-host users using cloud don't have app DBs"
)
}
return getDB(appId, opts)
}

View File

@ -5,6 +5,7 @@ import { GoogleSpreadsheet } from "google-spreadsheet"
// keep this out of Budibase types, don't want to expose context info
export type ContextMap = {
tenantId?: string
isSelfHostUsingCloud?: boolean
appId?: string
identity?: IdentityContext
environmentVariables?: Record<string, string>

View File

@ -6,6 +6,7 @@ import {
isInvalidISODateString,
isValidFilter,
isValidISODateString,
isValidTime,
sqlLog,
validateManyToMany,
} from "./utils"
@ -417,11 +418,17 @@ class InternalBuilder {
}
if (typeof input === "string" && schema.type === FieldType.DATETIME) {
if (isInvalidISODateString(input)) {
return null
}
if (isValidISODateString(input)) {
return new Date(input.trim())
if (schema.timeOnly) {
if (!isValidTime(input)) {
return null
}
} else {
if (isInvalidISODateString(input)) {
return null
}
if (isValidISODateString(input)) {
return new Date(input.trim())
}
}
}
return input

View File

@ -0,0 +1,35 @@
import { isValidISODateString, isInvalidISODateString } from "../utils"
describe("ISO date string validity checks", () => {
it("accepts a valid ISO date string without a time", () => {
const str = "2013-02-01"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("accepts a valid ISO date string with a time", () => {
const str = "2013-02-01T01:23:45Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("accepts a valid ISO date string with a time and millis", () => {
const str = "2013-02-01T01:23:45.678Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(true)
expect(invalid).toEqual(false)
})
it("rejects an invalid ISO date string", () => {
const str = "2013-523-814T444:22:11Z"
const valid = isValidISODateString(str)
const invalid = isInvalidISODateString(str)
expect(valid).toEqual(false)
expect(invalid).toEqual(true)
})
})

View File

@ -14,7 +14,7 @@ import environment from "../environment"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ")
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}.\d{3}Z)?$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:.\d{3})?Z)?$/
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
export function isExternalTableID(tableId: string) {
@ -142,17 +142,17 @@ export function breakRowIdField(_id: string | { _id: string }): any[] {
}
}
export function isInvalidISODateString(str: string) {
export function isValidISODateString(str: string) {
const trimmedValue = str.trim()
if (!ISO_DATE_REGEX.test(trimmedValue)) {
return false
}
let d = new Date(trimmedValue)
return isNaN(d.getTime())
const d = new Date(trimmedValue)
return !isNaN(d.getTime())
}
export function isValidISODateString(str: string) {
return ISO_DATE_REGEX.test(str.trim())
export function isInvalidISODateString(str: string) {
return !isValidISODateString(str)
}
export function isValidFilter(value: any) {

View File

@ -35,7 +35,7 @@
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 loading: boolean | undefined = false
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
export let customPopoverHeight: string | undefined = undefined

View File

@ -75,6 +75,7 @@
class:is-disabled={disabled}
class:is-focused={isFocused}
>
<!-- We need to ignore prettier here as we want no whitespace -->
<!-- prettier-ignore -->
<textarea
bind:this={textarea}
@ -90,6 +91,7 @@
on:blur
on:keypress
>{value || ""}</textarea>
<slot />
</div>
<style>

View File

@ -114,6 +114,7 @@
inputmode={getInputMode(type)}
autocomplete={autocompleteValue}
/>
<slot />
</div>
<style>

View File

@ -41,5 +41,7 @@
on:blur
on:focus
on:keyup
/>
>
<slot />
</TextField>
</Field>

View File

@ -38,6 +38,7 @@
export let compare: any = undefined
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
export let loading: boolean | undefined = false
const dispatch = createEventDispatcher()
const onChange = (e: CustomEvent<any>) => {
@ -56,6 +57,7 @@
<Field {helpText} {label} {labelPosition} {error} {tooltip}>
<Select
{quiet}
{loading}
{disabled}
{readonly}
{value}

View File

@ -7,11 +7,13 @@
export let label: string | undefined = undefined
export let labelPosition = "above"
export let placeholder: string | undefined = undefined
export let disabled = false
export let readonly: boolean = false
export let disabled: boolean = false
export let error: string | undefined = undefined
export let height: number | undefined = undefined
export let minHeight: number | undefined = undefined
export let helpText: string | undefined = undefined
export let updateOnChange: boolean = false
let textarea: TextArea
export function focus() {
@ -33,11 +35,16 @@
<TextArea
bind:this={textarea}
{disabled}
{readonly}
{value}
{placeholder}
{height}
{minHeight}
{updateOnChange}
on:change={onChange}
on:keypress
/>
on:scrollable
>
<slot />
</TextArea>
</Field>

View File

@ -10,7 +10,7 @@
export let color: string | undefined = undefined
export let hoverColor: string | undefined = undefined
export let tooltip: string | undefined = undefined
export let tooltipPosition = TooltipPosition.Bottom
export let tooltipPosition: TooltipPosition = TooltipPosition.Bottom
export let tooltipType = TooltipType.Default
export let tooltipColor: string | undefined = undefined
export let tooltipWrap: boolean = true

View File

@ -211,9 +211,12 @@ const localeDateFormat = new Intl.DateTimeFormat()
// Formats a dayjs date according to schema flags
export const getDateDisplayValue = (
value: dayjs.Dayjs | null,
value: dayjs.Dayjs | string | null,
{ enableTime = true, timeOnly = false } = {}
): string => {
if (typeof value === "string") {
value = dayjs(value)
}
if (!value?.isValid()) {
return ""
}

9
packages/builder/src/assets.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module "*.png" {
const value: string
export default value
}
declare module "*.svg" {
const value: string
export default value
}

View File

@ -134,9 +134,6 @@
// Size of the view port
let viewDims = {}
// Edge around the draggable content
let contentDragPadding = 200
// Auto scroll
let scrollInterval
@ -234,7 +231,7 @@
: {}),
}))
offsetX = offsetX - xBump
offsetX -= xBump
} else if (e.ctrlKey || e.metaKey) {
// Scale the content on scrolling
let updatedScale
@ -261,7 +258,7 @@
}
: {}),
}))
offsetY = offsetY - yBump
offsetY -= yBump
}
}
@ -335,8 +332,8 @@
scrollX: state.scrollX + xInterval,
scrollY: state.scrollY + yInterval,
}))
offsetX = offsetX + xInterval
offsetY = offsetY + yInterval
offsetX += xInterval
offsetY += yInterval
}
}
@ -488,22 +485,35 @@
const viewToFocusEle = () => {
if ($focusElement) {
const viewWidth = viewDims.width
const viewHeight = viewDims.height
// The amount to shift the content in order to center the trigger on load.
// The content is also padded with `contentDragPadding`
// The sidebar offset factors into the left positioning of the content here.
const targetX =
contentWrap.getBoundingClientRect().x -
$focusElement.x +
(viewWidth / 2 - $focusElement.width / 2)
(viewWidth - $focusElement.width) / 2
const targetY =
contentWrap.getBoundingClientRect().y -
$focusElement.y +
(viewHeight - $focusElement.height) / 2
// Update the content position state
// Shift the content up slightly to accommodate the padding
contentPos.update(state => ({
...state,
x: targetX,
y: -(contentDragPadding / 2),
y: Number.isInteger($focusElement.targetY)
? $focusElement.targetY
: targetY,
}))
// Be sure to set the initial offset correctly
offsetX = targetX
offsetY = Number.isInteger($focusElement.targetY)
? $focusElement.targetY
: targetY
}
}
@ -550,7 +560,6 @@
aria-label="Viewport for building automations"
on:mouseup={onMouseUp}
on:mousemove={Utils.domDebounce(onMouseMove)}
style={`--dragPadding: ${contentDragPadding}px;`}
>
<svg class="draggable-background" style={`--dotSize: ${dotSize};`}>
<!-- Small 2px offset to tuck the points under the viewport on load-->
@ -617,7 +626,6 @@
transform-origin: 50% 50%;
transform: scale(var(--scale));
user-select: none;
padding: var(--dragPadding);
}
.content-wrap {

View File

@ -9,7 +9,7 @@
Tags,
Tag,
} from "@budibase/bbui"
import { AutomationActionStepId } from "@budibase/types"
import { AutomationActionStepId, BlockDefinitionTypes } from "@budibase/types"
import { automationStore, selectedAutomation } from "@/stores/builder"
import { admin, licensing } from "@/stores/portal"
import { externalActions } from "./ExternalActions"
@ -124,15 +124,20 @@
try {
const newBlock = automationStore.actions.constructBlock(
"ACTION",
BlockDefinitionTypes.ACTION,
action.stepId,
action
)
await automationStore.actions.addBlockToAutomation(
newBlock,
blockRef ? blockRef.pathTo : block.pathTo
)
// Determine presence of the block before focusing
const createdBlock = $selectedAutomation.blockRefs[newBlock.id]
const createdBlockLoc = (createdBlock?.pathTo || []).at(-1)
await automationStore.actions.selectNode(createdBlockLoc?.id)
modal.hide()
} catch (error) {
console.error(error)
@ -167,8 +172,8 @@
<img
width={20}
height={20}
src={getExternalAction(action.stepId).icon}
alt={getExternalAction(action.stepId).name}
src={getExternalAction(action.stepId)?.icon}
alt={getExternalAction(action.stepId)?.name}
/>
<span class="icon-spacing">
<Body size="XS">

View File

@ -3,30 +3,28 @@
import {
Drawer,
DrawerContent,
ActionButton,
Icon,
Layout,
Body,
Divider,
TooltipPosition,
TooltipType,
Button,
Modal,
ModalContent,
} from "@budibase/bbui"
import PropField from "@/components/automation/SetupPanel/PropField.svelte"
import AutomationBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import FlowItemActions from "./FlowItemActions.svelte"
import FlowItemStatus from "./FlowItemStatus.svelte"
import {
automationStore,
selectedAutomation,
evaluationContext,
contextMenuStore,
} from "@/stores/builder"
import { QueryUtils, Utils, memo } from "@budibase/frontend-core"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import DragZone from "./DragZone.svelte"
import BlockHeader from "../../SetupPanel/BlockHeader.svelte"
const dispatch = createEventDispatcher()
@ -41,7 +39,6 @@
const memoContext = memo({})
let drawer
let open = true
let confirmDeleteModal
$: memoContext.set($evaluationContext)
@ -61,6 +58,65 @@
branchNode: true,
pathTo: (pathTo || []).concat({ branchIdx, branchStepId: step.id }),
}
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: async () => {
const branchSteps = step.inputs?.children[branch.id]
if (branchSteps.length) {
confirmDeleteModal.show()
} else {
await automationStore.actions.deleteBranch(
branchBlockRef.pathTo,
$selectedAutomation.data
)
}
},
},
{
icon: "ArrowLeft",
name: "Move left",
keyBind: null,
visible: true,
disabled: branchIdx == 0,
callback: async () => {
automationStore.actions.branchLeft(
branchBlockRef.pathTo,
$selectedAutomation.data,
step
)
},
},
{
icon: "ArrowRight",
name: "Move right",
keyBind: null,
visible: true,
disabled: isLast,
callback: async () => {
automationStore.actions.branchRight(
branchBlockRef.pathTo,
$selectedAutomation.data,
step
)
},
},
]
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(branch.id, items, { x: e.clientX, y: e.clientY })
}
</script>
<Modal bind:this={confirmDeleteModal}>
@ -112,7 +168,7 @@
</DrawerContent>
</Drawer>
<div class="flow-item">
<div class="flow-item branch">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={`block branch-node hoverable`}
@ -121,96 +177,64 @@
e.stopPropagation()
}}
>
<FlowItemHeader
{automation}
{open}
itemName={branch.name}
block={step}
deleteStep={async () => {
const branchSteps = step.inputs?.children[branch.id]
if (branchSteps.length) {
confirmDeleteModal.show()
} else {
await automationStore.actions.deleteBranch(
branchBlockRef.pathTo,
$selectedAutomation.data
)
}
}}
on:update={async e => {
let stepUpdate = cloneDeep(step)
let branchUpdate = stepUpdate.inputs?.branches.find(
stepBranch => stepBranch.id == branch.id
)
branchUpdate.name = e.detail
<div class="block-float">
<FlowItemStatus
block={step}
{automation}
{branch}
hideStatus={$view?.dragging}
/>
</div>
<div class="blockSection">
<div class="heading">
<BlockHeader
{automation}
block={step}
itemName={branch.name}
on:update={async e => {
let stepUpdate = cloneDeep(step)
let branchUpdate = stepUpdate.inputs?.branches.find(
stepBranch => stepBranch.id == branch.id
)
branchUpdate.name = e.detail
const updatedAuto = automationStore.actions.updateStep(
pathTo,
$selectedAutomation.data,
stepUpdate
)
await automationStore.actions.save(updatedAuto)
}}
on:toggle={() => (open = !open)}
>
<div slot="custom-actions" class="branch-actions">
<Icon
on:click={() => {
automationStore.actions.branchLeft(
branchBlockRef.pathTo,
const updatedAuto = automationStore.actions.updateStep(
pathTo,
$selectedAutomation.data,
step
stepUpdate
)
await automationStore.actions.save(updatedAuto)
}}
tooltip={"Move left"}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverable
disabled={branchIdx == 0}
name="ArrowLeft"
/>
<Icon
on:click={() => {
automationStore.actions.branchRight(
branchBlockRef.pathTo,
$selectedAutomation.data,
step
)
}}
tooltip={"Move right"}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverable
disabled={isLast}
name="ArrowRight"
/>
<div class="actions">
<Icon
name="Info"
tooltip="Branch sequencing checks each option in order and follows the first one that matches the rules."
/>
<Icon
on:click={e => {
openContextMenu(e)
}}
size="S"
hoverable
name="MoreSmallList"
/>
</div>
</div>
</FlowItemHeader>
{#if open}
<Divider noMargin />
<div class="blockSection">
<!-- Content body for possible slot -->
<Layout noPadding>
<PropField label="Only run when">
<ActionButton fullWidth on:click={drawer.show}>
{editableConditionUI?.groups?.length
? "Update condition"
: "Add condition"}
</ActionButton>
</PropField>
<div class="footer">
<Icon
name="InfoOutline"
size="S"
color="var(--spectrum-global-color-gray-700)"
/>
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
Only the first branch which matches its condition will run
</Body>
</div>
</Layout>
</div>
{/if}
</div>
<Divider noMargin />
<div class="blockSection filter-button">
<PropField label="Only run when:" fullWidth>
<div style="width: 100%">
<Button secondary on:click={drawer.show}>
{editableConditionUI?.groups?.length
? "Update condition"
: "Add condition"}
</Button>
</div>
</PropField>
</div>
</div>
<div class="separator" />
@ -227,6 +251,9 @@
</div>
<style>
.filter-button :global(.spectrum-Button) {
width: 100%;
}
.branch-actions {
display: flex;
gap: var(--spacing-l);
@ -264,7 +291,7 @@
display: inline-block;
}
.block {
width: 480px;
width: 360px;
font-size: 16px;
background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300);
@ -289,4 +316,30 @@
align-items: center;
gap: var(--spacing-s);
}
.branch-node {
position: relative;
}
.block-float {
pointer-events: none;
width: 100%;
position: absolute;
top: -35px;
left: 0px;
}
.blockSection .heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-m);
}
.blockSection .heading .actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-m);
}
</style>

View File

@ -1,13 +0,0 @@
import DiscordLogo from "assets/discord.svg"
import ZapierLogo from "assets/zapier.png"
import n8nLogo from "assets/n8n_square.png"
import MakeLogo from "assets/make.svg"
import SlackLogo from "assets/slack.svg"
export const externalActions = {
zapier: { name: "zapier", icon: ZapierLogo },
discord: { name: "discord", icon: DiscordLogo },
slack: { name: "slack", icon: SlackLogo },
integromat: { name: "integromat", icon: MakeLogo },
n8n: { name: "n8n", icon: n8nLogo },
}

View File

@ -0,0 +1,20 @@
import DiscordLogo from "assets/discord.svg"
import ZapierLogo from "assets/zapier.png"
import n8nLogo from "assets/n8n_square.png"
import MakeLogo from "assets/make.svg"
import SlackLogo from "assets/slack.svg"
import { AutomationActionStepId } from "@budibase/types"
export type ExternalAction = {
name: string
icon: string
}
export const externalActions: Partial<
Record<AutomationActionStepId, ExternalAction>
> = {
[AutomationActionStepId.zapier]: { name: "zapier", icon: ZapierLogo },
[AutomationActionStepId.discord]: { name: "discord", icon: DiscordLogo },
[AutomationActionStepId.slack]: { name: "slack", icon: SlackLogo },
[AutomationActionStepId.integromat]: { name: "integromat", icon: MakeLogo },
[AutomationActionStepId.n8n]: { name: "n8n", icon: n8nLogo },
}

View File

@ -3,6 +3,7 @@
automationStore,
automationHistoryStore,
selectedAutomation,
appStore,
} from "@/stores/builder"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import TestDataModal from "./TestDataModal.svelte"
@ -19,6 +20,9 @@
import { memo } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
import DraggableCanvas from "../DraggableCanvas.svelte"
import { onMount } from "svelte"
import { environment } from "@/stores/portal"
import Count from "../../SetupPanel/Count.svelte"
export let automation
@ -31,6 +35,10 @@
let treeEle
let draggable
let prodErrors
$: $automationStore.showTestModal === true && testDataModal.show()
// Memo auto - selectedAutomation
$: memoAutomation.set(automation)
@ -63,53 +71,65 @@
notifications.error("Error deleting automation")
}
}
onMount(async () => {
try {
await automationStore.actions.initAppSelf()
await environment.loadVariables()
const response = await automationStore.actions.getLogs({
automationId: automation._id,
status: "error",
})
prodErrors = response?.data?.length || 0
} catch (error) {
console.error(error)
}
})
</script>
<div class="header" class:scrolling>
<div class="header-left">
<UndoRedoControl store={automationHistoryStore} showButtonGroup />
<div class="zoom">
<div class="group">
<ActionButton icon="Add" quiet on:click={draggable.zoomIn} />
<ActionButton icon="Remove" quiet on:click={draggable.zoomOut} />
</div>
<div class="automation-heading">
<div class="actions-left">
<div class="automation-name">
{automation.name}
</div>
<Button
secondary
on:click={() => {
draggable.zoomToFit()
}}
>
Zoom to fit
</Button>
</div>
<div class="controls">
<Button
icon={"Play"}
cta
<div class="actions-right">
<ActionButton
icon="Play"
quiet
disabled={!automation?.definition?.trigger}
on:click={() => {
testDataModal.show()
automationStore.update(state => ({ ...state, showTestModal: true }))
}}
>
Run test
</Button>
<div class="buttons">
{#if !$automationStore.showTestPanel && $automationStore.testResults}
<Button
secondary
icon={"Multiple"}
disabled={!$automationStore.testResults}
on:click={() => {
$automationStore.showTestPanel = true
}}
>
Test details
</Button>
{/if}
</div>
</ActionButton>
<Count
count={prodErrors}
tooltip={"There are errors in production"}
hoverable={false}
>
<ActionButton
icon="Folder"
quiet
selected={prodErrors}
on:click={() => {
const params = new URLSearchParams({
...(prodErrors ? { open: "error" } : {}),
automationId: automation._id,
})
window.open(
`/builder/app/${
$appStore.appId
}/settings/automations?${params.toString()}`,
"_blank"
)
}}
>
Logs
</ActionButton>
</Count>
{#if !isRowAction}
<div class="toggle-active setting-spacing">
<Toggle
@ -126,33 +146,59 @@
</div>
</div>
<div class="root" bind:this={treeEle}>
<DraggableCanvas
bind:this={draggable}
draggableClasses={[
"main-content",
"content",
"block",
"branched",
"branch",
"flow-item",
"branch-wrap",
]}
>
<span class="main-content" slot="content">
{#if Object.keys(blockRefs).length}
{#each blocks as block, idx (block.id)}
<StepNode
step={blocks[idx]}
stepIdx={idx}
isLast={blocks?.length - 1 === idx}
automation={$memoAutomation}
blocks={blockRefs}
/>
{/each}
{/if}
</span>
</DraggableCanvas>
<div class="main-flow">
<div class="canvas-heading" class:scrolling>
<div class="canvas-controls">
<div class="canvas-heading-left">
<UndoRedoControl store={automationHistoryStore} showButtonGroup />
<div class="zoom">
<div class="group">
<ActionButton icon="Add" quiet on:click={draggable.zoomIn} />
<ActionButton icon="Remove" quiet on:click={draggable.zoomOut} />
</div>
</div>
<Button
secondary
on:click={() => {
draggable.zoomToFit()
}}
>
Zoom to fit
</Button>
</div>
</div>
</div>
<div class="root" bind:this={treeEle}>
<DraggableCanvas
bind:this={draggable}
draggableClasses={[
"main-content",
"content",
"block",
"branched",
"branch",
"flow-item",
"branch-wrap",
]}
>
<span class="main-content" slot="content">
{#if Object.keys(blockRefs).length}
{#each blocks as block, idx (block.id)}
<StepNode
step={blocks[idx]}
stepIdx={idx}
isLast={blocks?.length - 1 === idx}
automation={$memoAutomation}
blocks={blockRefs}
/>
{/each}
{/if}
</span>
</DraggableCanvas>
</div>
</div>
<ConfirmDialog
@ -166,11 +212,50 @@
This action cannot be undone.
</ConfirmDialog>
<Modal bind:this={testDataModal} width="30%" zIndex={5}>
<Modal
bind:this={testDataModal}
zIndex={5}
on:hide={() => {
automationStore.update(state => ({ ...state, showTestModal: false }))
}}
>
<TestDataModal />
</Modal>
<style>
.main-flow {
position: relative;
width: 100%;
height: 100%;
}
.canvas-heading {
position: absolute;
z-index: 1;
width: 100%;
pointer-events: none;
}
.automation-heading {
display: flex;
align-items: center;
width: 100%;
background: var(--background);
padding: var(--spacing-m) var(--spacing-l);
box-sizing: border-box;
justify-content: space-between;
border-bottom: 1px solid var(--spectrum-global-color-gray-200);
}
.automation-heading .actions-right {
display: flex;
gap: var(--spacing-xl);
}
.automation-name :global(.spectrum-Heading) {
font-weight: 600;
}
.toggle-active :global(.spectrum-Switch) {
margin: 0px;
}
@ -181,12 +266,12 @@
align-items: center;
}
.header-left {
.canvas-heading-left {
display: flex;
gap: var(--spacing-l);
}
.header-left :global(div) {
.canvas-heading-left :global(div) {
border-right: none;
}
@ -207,50 +292,31 @@
box-sizing: border-box;
}
.header.scrolling {
.canvas-heading.scrolling {
background: var(--background);
border-bottom: var(--border-light);
z-index: 1;
}
.header {
z-index: 1;
.canvas-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-l);
flex: 0 0 60px;
padding-right: var(--spacing-xl);
position: absolute;
width: 100%;
box-sizing: border-box;
pointer-events: none;
}
.header > * {
.canvas-controls > * {
pointer-events: auto;
}
.controls {
display: flex;
gap: var(--spacing-l);
}
.controls .toggle-active :global(.spectrum-Switch-label) {
.toggle-active :global(.spectrum-Switch-label) {
margin-right: 0px;
}
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-s);
}
.buttons:hover {
cursor: pointer;
}
.group {
border-radius: 4px;
display: flex;
@ -266,17 +332,17 @@
border-bottom-right-radius: 0;
}
.header-left .group :global(.spectrum-Button),
.header-left .group :global(.spectrum-ActionButton),
.header-left .group :global(.spectrum-Icon) {
.canvas-heading-left .group :global(.spectrum-Button),
.canvas-heading-left .group :global(.spectrum-ActionButton),
.canvas-heading-left .group :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-900) !important;
}
.header-left .group :global(.spectrum-Button),
.header-left .group :global(.spectrum-ActionButton) {
.canvas-heading-left .group :global(.spectrum-Button),
.canvas-heading-left .group :global(.spectrum-ActionButton) {
background: var(--spectrum-global-color-gray-200) !important;
}
.header-left .group :global(.spectrum-Button:hover),
.header-left .group :global(.spectrum-ActionButton:hover) {
.canvas-heading-left .group :global(.spectrum-Button:hover),
.canvas-heading-left .group :global(.spectrum-ActionButton:hover) {
background: var(--spectrum-global-color-gray-300) !important;
}
</style>

View File

@ -1,38 +1,21 @@
<script>
import {
automationStore,
permissions,
selectedAutomation,
tables,
} from "@/stores/builder"
import {
Icon,
Divider,
Layout,
Detail,
Modal,
Label,
AbsTooltip,
} from "@budibase/bbui"
import { automationStore, selectedAutomation, tables } from "@/stores/builder"
import { Modal } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "@/components/automation/Shared/CreateWebhookModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "@/components/design/settings/controls/RoleSelect.svelte"
import { ActionStepID, TriggerStepID } from "@/constants/backend/automations"
import { ActionStepID } from "@/constants/backend/automations"
import { AutomationStepType } from "@budibase/types"
import FlowItemActions from "./FlowItemActions.svelte"
import FlowItemStatus from "./FlowItemStatus.svelte"
import DragHandle from "@/components/design/settings/controls/DraggableList/drag-handle.svelte"
import { getContext } from "svelte"
import DragZone from "./DragZone.svelte"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import BlockHeader from "../../SetupPanel/BlockHeader.svelte"
export let block
export let blockRef
export let testDataModal
export let idx
export let automation
export let bindings
export let draggable = true
const view = getContext("draggableView")
@ -40,13 +23,47 @@
const contentPos = getContext("contentPos")
let webhookModal
let open = true
let showLooping = false
let role
let blockEle
let positionStyles
let blockDims
$: pathSteps = loadSteps(blockRef)
$: collectBlockExists = pathSteps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: isTrigger = block.type === AutomationStepType.TRIGGER
$: lastStep = blockRef?.terminating
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation?.data) && {
title: "Automation trigger",
tableName: $tables.list.find(
x =>
x._id === $selectedAutomation.data?.definition?.trigger?.inputs?.tableId
)?.name,
}
$: selectedNodeId = $automationStore.selectedNodeId
$: selected = block.id === selectedNodeId
$: dragging = $view?.moveStep && $view?.moveStep?.id === block.id
$: if (dragging && blockEle) {
updateBlockDims()
}
$: placeholderDims = buildPlaceholderStyles(blockDims)
// Move the selected item
// Listen for scrolling in the content. As its scrolled this will be updated
$: move(
blockEle,
$view?.dragSpot,
dragging,
$contentPos?.scrollX,
$contentPos?.scrollY
)
const updateBlockDims = () => {
if (!blockEle) {
return
@ -66,48 +83,8 @@
: []
}
$: pathSteps = loadSteps(blockRef)
$: collectBlockExists = pathSteps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = automation?._id
$: isTrigger = block.type === AutomationStepType.TRIGGER
$: lastStep = blockRef?.terminating
$: loopBlock = pathSteps.find(x => x.blockToLoop === block.id)
$: isAppAction = block?.stepId === TriggerStepID.APP
$: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId)
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation?.data) && {
title: "Automation trigger",
tableName: $tables.list.find(
x =>
x._id === $selectedAutomation.data?.definition?.trigger?.inputs?.tableId
)?.name,
}
$: selected = $view?.moveStep && $view?.moveStep?.id === block.id
$: if (selected && blockEle) {
updateBlockDims()
}
$: placeholderDims = buildPlaceholderStyles(blockDims)
// Move the selected item
// Listen for scrolling in the content. As its scrolled this will be updated
$: move(
blockEle,
$view?.dragSpot,
selected,
$contentPos?.scrollX,
$contentPos?.scrollY
)
const move = (block, dragPos, selected, scrollX, scrollY) => {
if ((!block && !selected) || !dragPos) {
const move = (block, dragPos, dragging, scrollX, scrollY) => {
if ((!block && !dragging) || !dragPos) {
return
}
positionStyles = `
@ -125,52 +102,6 @@
--psheight: ${Math.round(height)}px;`
}
async function setPermissions(role) {
if (!role || !automationId) {
return
}
await permissions.save({
level: "execute",
role,
resource: automationId,
})
}
async function getPermissions(automationId) {
if (!automationId) {
return
}
const perms = await permissions.forResource(automationId)
if (!perms["execute"]) {
role = "BASIC"
} else {
role = perms["execute"].role
}
}
async function deleteStep() {
await automationStore.actions.deleteAutomationBlock(blockRef.pathTo)
}
async function removeLooping() {
let loopBlockRef = $selectedAutomation.blockRefs[blockRef.looped]
await automationStore.actions.deleteAutomationBlock(loopBlockRef.pathTo)
}
async function addLooping() {
const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP
const loopBlock = automationStore.actions.constructBlock(
"ACTION",
"LOOP",
loopDefinition
)
loopBlock.blockToLoop = block.id
await automationStore.actions.addBlockToAutomation(
loopBlock,
blockRef.pathTo
)
}
const onHandleMouseDown = e => {
if (isTrigger) {
e.preventDefault()
@ -205,143 +136,58 @@
<div
id={`block-${block.id}`}
class={`block ${block.type} hoverable`}
class:selected
class:dragging
class:draggable
class:selected
>
<div class="wrap">
{#if $view.dragging && selected}
{#if $view.dragging && dragging}
<div class="drag-placeholder" style={placeholderDims} />
{/if}
<div
bind:this={blockEle}
class="block-content"
class:dragging={$view.dragging && selected}
class:dragging={$view.dragging && dragging}
style={positionStyles}
on:mousedown={e => {
e.stopPropagation()
}}
>
<div class="block-float">
<FlowItemStatus {block} {automation} hideStatus={$view?.dragging} />
</div>
{#if draggable}
<div
class="handle"
class:grabbing={selected}
class:grabbing={dragging}
on:mousedown={onHandleMouseDown}
>
<DragHandle />
</div>
{/if}
<div class="block-core">
{#if loopBlock}
<div
class="block-core"
on:click={async () => {
await automationStore.actions.selectNode(block.id)
}}
>
<div class="blockSection block-info">
<BlockHeader
disabled
{automation}
{block}
on:update={e =>
automationStore.actions.updateBlockTitle(block, e.detail)}
/>
</div>
{#if isTrigger && triggerInfo}
<div class="blockSection">
<div
on:click={() => {
showLooping = !showLooping
}}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div>
</div>
<div class="blockTitle">
<AbsTooltip type="negative" text="Remove looping">
<Icon
on:click={removeLooping}
hoverable
name="DeleteOutline"
/>
</AbsTooltip>
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon
hoverable
name={showLooping ? "ChevronDown" : "ChevronUp"}
/>
</div>
</div>
</div>
</div>
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
$automationStore.blockDefinitions.ACTION.LOOP.schema
.inputs.properties
)}
{webhookModal}
block={loopBlock}
{automation}
{bindings}
/>
</Layout>
</div>
<Divider noMargin />
{/if}
{/if}
<FlowItemHeader
{automation}
{open}
{block}
{testDataModal}
{idx}
{addLooping}
{deleteStep}
on:toggle={() => (open = !open)}
on:update={async e => {
const newName = e.detail
if (newName.length === 0) {
await automationStore.actions.deleteAutomationName(block.id)
} else {
await automationStore.actions.saveAutomationName(
block.id,
newName
)
}
}}
/>
{#if open}
<Divider noMargin />
<div class="blockSection">
<Layout noPadding gap="S">
{#if isAppAction}
<div>
<Label>Role</Label>
<RoleSelect bind:value={role} />
</div>
{/if}
<AutomationBlockSetup
schemaProperties={Object.entries(
block?.schema?.inputs?.properties || {}
)}
{block}
{webhookModal}
{automation}
{bindings}
/>
{#if isTrigger && triggerInfo}
<InfoDisplay
title={triggerInfo.title}
body="This trigger is tied to your '{triggerInfo.tableName}' table"
icon="InfoOutline"
/>
{/if}
</Layout>
<InfoDisplay
title={triggerInfo.title}
body="This trigger is tied to your '{triggerInfo.tableName}' table"
icon="InfoOutline"
/>
</div>
{/if}
</div>
@ -397,7 +243,7 @@
display: inline-block;
}
.block {
width: 480px;
width: 360px;
font-size: 16px;
border-radius: 4px;
cursor: default;
@ -419,6 +265,8 @@
padding: 6px;
color: var(--grey-6);
cursor: grab;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.block.draggable .wrap .handle.grabbing {
cursor: grabbing;
@ -469,4 +317,22 @@
top: var(--blockPosY);
left: var(--blockPosX);
}
.block-float {
pointer-events: none;
width: 100%;
position: absolute;
top: -35px;
left: 0px;
}
.block-core {
cursor: pointer;
}
.block.selected .block-content {
border-color: var(--spectrum-global-color-blue-700);
transition: border 130ms ease-out;
}
.block-info {
pointer-events: none;
}
</style>

View File

@ -0,0 +1,162 @@
<script lang="ts">
import { automationStore, selectedAutomation } from "@/stores/builder"
import {
type FlowItemStatus,
DataMode,
FilterableRowTriggers,
FlowStatusType,
} from "@/types/automations"
import {
type AutomationStep,
type AutomationStepResult,
type AutomationTrigger,
type AutomationTriggerResult,
type Branch,
type DidNotTriggerResponse,
type TestAutomationResponse,
type AutomationTriggerStepId,
isDidNotTriggerResponse,
isTrigger,
isBranchStep,
isFilterStep,
} from "@budibase/types"
import { ActionButton } from "@budibase/bbui"
export let block: AutomationStep | AutomationTrigger | undefined
export let branch: Branch | undefined
export let hideStatus: boolean | undefined = false
$: blockRef = block?.id ? $selectedAutomation.blockRefs[block?.id] : null
$: isTriggerBlock = block ? isTrigger(block) : false
$: testResults = $automationStore.testResults as TestAutomationResponse
$: blockResult = automationStore.actions.processBlockResults(
testResults,
block
)
$: flowStatus = getFlowStatus(blockResult)
const getFlowStatus = (
result?:
| AutomationTriggerResult
| AutomationStepResult
| DidNotTriggerResponse
): FlowItemStatus | undefined => {
if (!result || !block) {
return
}
const outputs = result?.outputs
const isFilteredRowTrigger =
isTriggerBlock &&
isDidNotTriggerResponse(testResults) &&
FilterableRowTriggers.includes(block.stepId as AutomationTriggerStepId)
if (
isFilteredRowTrigger ||
(isFilterStep(block) && "result" in outputs && outputs.result === false)
) {
return {
message: "Stopped",
icon: "Alert",
type: FlowStatusType.WARN,
}
}
if (branch && isBranchStep(block)) {
// Do not give status markers to branch nodes that were not part of the run.
if ("branchId" in outputs && outputs.branchId !== branch.id) return
// Mark branches as stopped when no branch criteria was met
if (outputs.success == false) {
return {
message: "Stopped",
icon: "Alert",
type: FlowStatusType.WARN,
}
}
}
const success = outputs?.success || isTriggerBlock
return {
message: success ? "Completed" : "Failed",
icon: success ? "CheckmarkCircle" : "AlertCircleFilled",
type:
success || isTriggerBlock
? FlowStatusType.SUCCESS
: FlowStatusType.ERROR,
}
}
</script>
<div class="flow-item-status">
{#if blockRef}
{#if isTriggerBlock}
<span class="block-type">
<ActionButton size="S" active={false} icon="Workflow">
Trigger
</ActionButton>
</span>
{:else if blockRef.looped}
<ActionButton size="S" active={false} icon="Reuse">Looping</ActionButton>
{:else}
<span />
{/if}
{#if blockResult && flowStatus && !hideStatus}
<span class={`flow-${flowStatus.type}`}>
<ActionButton
size="S"
icon={flowStatus.icon}
tooltip={flowStatus?.tooltip}
on:click={async () => {
if (branch || !block) {
return
}
await automationStore.actions.selectNode(
block?.id,
flowStatus.type == FlowStatusType.SUCCESS
? DataMode.OUTPUT
: DataMode.ERRORS
)
}}
>
{flowStatus.message}
</ActionButton>
</span>
{/if}
{/if}
</div>
<style>
.flow-item-status {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-s);
align-items: center;
pointer-events: none;
}
.flow-item-status :global(> *) {
pointer-events: all;
}
.flow-item-status .block-type {
pointer-events: none;
}
.flow-item-status :global(.spectrum-ActionButton),
.flow-item-status :global(.spectrum-ActionButton .spectrum-Icon) {
color: var(--spectrum-alias-text-color-hover);
}
.flow-success :global(.spectrum-ActionButton) {
background-color: var(--spectrum-semantic-positive-color-status);
border-color: var(--spectrum-semantic-positive-color-status);
}
.flow-error :global(.spectrum-ActionButton) {
background-color: var(--spectrum-semantic-negative-color-status);
border-color: var(--spectrum-semantic-negative-color-status);
}
.flow-warn :global(.spectrum-ActionButton) {
background-color: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-300);
}
.flow-warn :global(.spectrum-ActionButton .spectrum-Icon) {
color: var(--spectrum-global-color-yellow-600);
}
</style>

View File

@ -46,16 +46,26 @@
...userBindings,
...settingBindings,
]
onMount(() => {
// Register the trigger as the focus element for the automation
// Onload, the canvas will use the dimensions to center the step
if (stepEle && step.type === "TRIGGER" && !$view.focusEle) {
const { width, height, left, right, top, bottom, x, y } =
stepEle.getBoundingClientRect()
view.update(state => ({
...state,
focusEle: { width, height, left, right, top, bottom, x, y },
focusEle: {
width,
height,
left,
right,
top,
bottom,
x,
y,
...(step.type === "TRIGGER" ? { targetY: 100 } : {}),
},
}))
}
})

View File

@ -122,7 +122,6 @@
await tick()
try {
await automationStore.actions.test($selectedAutomation.data, testData)
$automationStore.showTestPanel = true
} catch (error) {
notifications.error(error)
}

View File

@ -0,0 +1,270 @@
<script lang="ts">
import { ActionButton, Button, Divider, Icon, Modal } from "@budibase/bbui"
import {
type Automation,
type AutomationStep,
type AutomationTrigger,
type BlockRef,
AutomationTriggerStepId,
isBranchStep,
isTrigger,
AutomationFeature,
} from "@budibase/types"
import { memo } from "@budibase/frontend-core"
import {
automationStore,
selectedAutomation,
evaluationContext,
} from "@/stores/builder"
import BlockData from "../SetupPanel/BlockData.svelte"
import BlockProperties from "../SetupPanel/BlockProperties.svelte"
import BlockHeader from "../SetupPanel/BlockHeader.svelte"
import { PropField } from "../SetupPanel"
import RoleSelect from "@/components/common/RoleSelect.svelte"
import { type AutomationContext } from "@/stores/builder/automations"
import CreateWebhookModal from "@/components/automation/Shared/CreateWebhookModal.svelte"
import { getVerticalResizeActions } from "@/components/common/resizable"
const [resizable, resizableHandle] = getVerticalResizeActions()
const memoAutomation = memo<Automation | undefined>($selectedAutomation.data)
const memoBlock = memo<AutomationStep | AutomationTrigger | undefined>()
const memoContext = memo({} as AutomationContext)
let role: string | undefined
let webhookModal: Modal | undefined
$: memoAutomation.set($selectedAutomation.data)
$: memoContext.set($evaluationContext)
$: selectedNodeId = $automationStore.selectedNodeId
$: blockRefs = $selectedAutomation.blockRefs
$: blockRef = selectedNodeId ? blockRefs[selectedNodeId] : undefined
$: block = automationStore.actions.getBlockByRef($memoAutomation, blockRef)
$: memoBlock.set(block)
$: isStep = !!$memoBlock && !isTrigger($memoBlock)
$: pathSteps = loadPathSteps(blockRef, $memoAutomation)
$: loopBlock = pathSteps.find(
x => $memoBlock && x.blockToLoop === $memoBlock.id
)
$: isAppAction = block?.stepId === AutomationTriggerStepId.APP
$: isAppAction && fetchPermissions($memoAutomation?._id)
$: isAppAction &&
automationStore.actions.setPermissions(role, $memoAutomation)
const fetchPermissions = async (automationId?: string) => {
if (!automationId) {
return
}
role = await automationStore.actions.getPermissions(automationId)
}
const loadPathSteps = (
blockRef: BlockRef | undefined,
automation: Automation | undefined
) => {
return blockRef && automation
? automationStore.actions.getPathSteps(blockRef.pathTo, automation)
: []
}
</script>
<Modal bind:this={webhookModal}>
<CreateWebhookModal />
</Modal>
<div class="panel heading">
<div class="details">
<BlockHeader
automation={$memoAutomation}
block={$memoBlock}
on:update={e => {
if ($memoBlock && !isTrigger($memoBlock)) {
automationStore.actions.updateBlockTitle($memoBlock, e.detail)
}
}}
/>
<Icon
name="Close"
hoverable
on:click={() => {
automationStore.actions.selectNode()
}}
/>
</div>
{#if isStep}
<div class="step-actions">
{#if $memoBlock && !isBranchStep($memoBlock) && ($memoBlock.features?.[AutomationFeature.LOOPING] || !$memoBlock.features)}
<ActionButton
quiet
noPadding
icon="RotateCW"
on:click={async () => {
if (loopBlock) {
await automationStore.actions.removeLooping(blockRef)
} else {
await automationStore.actions.addLooping(blockRef)
}
}}
>
{loopBlock ? `Stop Looping` : `Loop`}
</ActionButton>
{/if}
<ActionButton
quiet
noPadding
icon="DeleteOutline"
on:click={async () => {
if (!blockRef) {
return
}
await automationStore.actions.deleteAutomationBlock(blockRef.pathTo)
}}
>
Delete
</ActionButton>
</div>
{/if}
</div>
<Divider noMargin />
<div class="panel config" use:resizable>
<div class="content">
{#if loopBlock}
<span class="loop">
<BlockProperties
block={loopBlock}
context={$memoContext}
automation={$memoAutomation}
/>
</span>
<Divider noMargin />
{:else if isAppAction}
<PropField label="Role" fullWidth>
<RoleSelect bind:value={role} />
</PropField>
{/if}
<span class="props">
<BlockProperties
block={$memoBlock}
context={$memoContext}
automation={$memoAutomation}
/>
</span>
{#if block?.stepId === AutomationTriggerStepId.WEBHOOK}
<Button
secondary
on:click={() => {
if (!webhookModal) return
webhookModal.show()
}}
>
Set Up Webhook
</Button>
{/if}
</div>
<div
role="separator"
class="divider"
class:disabled={false}
use:resizableHandle
/>
</div>
<div class="panel data">
<BlockData
context={$memoContext}
block={$memoBlock}
automation={$memoAutomation}
on:run={() => {
automationStore.update(state => ({
...state,
showTestModal: true,
}))
}}
/>
</div>
<style>
.step-actions {
display: flex;
gap: var(--spacing-l);
align-items: center;
margin-top: var(--spacing-s);
}
.heading.panel {
display: flex;
flex-direction: column;
}
.panel,
.config.panel .content {
padding: var(--spacing-l);
}
.config.panel {
padding: 0px;
position: relative;
display: flex;
flex-direction: column;
min-height: 200px;
max-height: 550px;
transition: height 300ms ease-out, max-height 300ms ease-out;
height: 400px;
box-sizing: border-box;
}
.config.panel .content {
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-l);
}
.config.panel .loop,
.config.panel .content .props {
display: flex;
flex-direction: column;
gap: var(--spacing-l);
}
.data.panel {
padding: 0px;
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
overflow-x: hidden;
}
.divider {
position: absolute;
bottom: 0;
transform: translateY(50%);
height: 16px;
width: 100%;
}
.divider:after {
content: "";
position: absolute;
background: var(--spectrum-global-color-gray-200);
height: 2px;
width: 100%;
top: 50%;
transform: translateY(-50%);
transition: background 130ms ease-out;
}
.divider:hover {
cursor: row-resize;
}
.divider:hover:after {
background: var(--spectrum-global-color-gray-300);
}
.divider.disabled {
cursor: auto;
}
.divider.disabled:after {
background: var(--spectrum-global-color-gray-200);
}
.details {
display: flex;
gap: var(--spacing-l);
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import {
type AutomationStep,
type AutomationTrigger,
type EnrichedBinding,
} from "@budibase/types"
import { PropField } from "."
import { type SchemaConfigProps } from "@/types/automations"
export let block: AutomationStep | AutomationTrigger | undefined = undefined
export let context: {} | undefined
export let bindings: EnrichedBinding[] | undefined = undefined
export let layout: SchemaConfigProps[] | undefined
</script>
{#if layout}
{#each layout as config}
{#if config.wrapped === false}
<svelte:component
this={config.comp}
{...config?.props ? config.props() : {}}
{bindings}
{block}
{context}
on:change={config.onChange}
/>
{:else}
<PropField
label={config.title}
labelTooltip={config.tooltip || ""}
fullWidth
>
<svelte:component
this={config.comp}
{...config?.props ? config.props() : {}}
{bindings}
{block}
{context}
on:change={config.onChange}
/>
</PropField>
{/if}
{/each}
{/if}

View File

@ -0,0 +1,389 @@
<script lang="ts">
import {
type BaseIOStructure,
type AutomationStep,
type AutomationTrigger,
AutomationActionStepId,
AutomationCustomIOType,
AutomationIOType,
AutomationStepType,
} from "@budibase/types"
import {
AutomationSelector,
CronBuilder,
DateSelector,
ExecuteScript,
ExecuteScriptV2,
FieldSelector,
FileSelector,
FilterSelector,
PropField,
QueryParamSelector,
SchemaSetup,
TableSelector,
} from "."
import { getFieldLabel, getInputValue } from "./layouts"
import { automationStore, tables } from "@/stores/builder"
import {
type AutomationSchemaConfig,
type FieldProps,
type FormUpdate,
type FileSelectorMeta,
type StepInputs,
SchemaFieldTypes,
customTypeToSchema,
typeToSchema,
} from "@/types/automations"
import { Select, Checkbox } from "@budibase/bbui"
import {
DrawerBindableInput,
ServerBindingPanel as AutomationBindingPanel,
} from "@/components/common/bindings"
import Editor from "@/components/integration/QueryEditor.svelte"
import WebhookDisplay from "@/components/automation/Shared/WebhookDisplay.svelte"
export let block: AutomationStep | AutomationTrigger | undefined = undefined
export let context: {} | undefined
export let bindings: any[] | undefined = undefined
const SchemaTypes: AutomationSchemaConfig = {
[SchemaFieldTypes.ENUM]: {
comp: Select,
props: (opts: FieldProps = {} as FieldProps) => {
const field = opts.field
return {
placeholder: false,
options: field.enum,
getOptionLabel: (x: string, idx: number) => {
return field.pretty ? field.pretty[idx] : x
},
disabled: field.readonly,
}
},
},
[SchemaFieldTypes.TABLE]: {
comp: TableSelector,
props: (opts: FieldProps = {} as FieldProps) => {
const field = opts.field
return {
isTrigger,
disabled: field.readonly,
}
},
},
[SchemaFieldTypes.FILTER]: {
comp: FilterSelector,
props: (opts: FieldProps = {} as FieldProps) => {
const { key } = opts
return {
key,
}
},
onChange: (e: CustomEvent<FormUpdate>) => {
const update = e.detail
if (block) {
automationStore.actions.requestUpdate(update, block)
}
},
},
[SchemaFieldTypes.COLUMN]: {
comp: Select,
props: (opts: FieldProps = {} as FieldProps) => {
const field = opts.field
return {
options: Object.keys(table?.schema || {}),
disabled: field.readonly,
}
},
},
[SchemaFieldTypes.CODE_V2]: {
comp: ExecuteScriptV2,
fullWidth: true,
},
[SchemaFieldTypes.BOOL]: {
comp: Checkbox,
},
[SchemaFieldTypes.DATE]: {
comp: DateSelector,
props: (opts: FieldProps = {} as FieldProps) => {
const { field, key } = opts
return {
title: field.title ?? getFieldLabel(key, field),
disabled: field.readonly,
}
},
},
[SchemaFieldTypes.TRIGGER_SCHEMA]: {
comp: SchemaSetup,
fullWidth: true,
},
[SchemaFieldTypes.JSON]: {
comp: Editor,
props: (opts: FieldProps = {} as FieldProps) => {
const { field, value: editorBody } = opts
return {
value: editorBody?.value,
editorHeight: "250",
editorWidth: "448",
mode: "json",
readOnly: field.readonly,
}
},
},
[SchemaFieldTypes.FILE]: {
comp: FileSelector,
props: (opts: FieldProps = {} as FieldProps) => {
const { field } = opts
const meta = getInputValue(inputData, "meta") as FileSelectorMeta
return {
buttonText:
field.type === "attachment" ? "Add attachment" : "Add signature",
useAttachmentBinding: meta?.useAttachmentBinding,
}
},
onChange: (e: CustomEvent<FormUpdate>) => {
const update = e.detail
if (block) {
automationStore.actions.requestUpdate(update, block)
}
},
fullWidth: true,
},
[SchemaFieldTypes.CRON]: {
comp: CronBuilder,
props: (opts: FieldProps = {} as FieldProps) => {
const { value } = opts
return {
cronExpression: value,
}
},
},
[SchemaFieldTypes.LOOP_OPTION]: {
comp: Select,
props: () => {
return {
autoWidth: true,
options: ["Array", "String"],
defaultValue: "Array",
}
},
},
[SchemaFieldTypes.AUTOMATION_FIELDS]: {
comp: AutomationSelector,
props: (opts: FieldProps = {} as FieldProps) => {
const { field } = opts
return {
title: field.title,
}
},
fullWidth: true,
wrapped: false,
},
[SchemaFieldTypes.WEBHOOK_URL]: {
comp: WebhookDisplay,
},
[SchemaFieldTypes.QUERY_PARAMS]: {
comp: QueryParamSelector,
},
[SchemaFieldTypes.CODE]: {
comp: ExecuteScript,
fullWidth: true,
},
[SchemaFieldTypes.STRING]: {
comp: DrawerBindableInput,
props: (opts: FieldProps = {} as FieldProps) => {
const { key, field } = opts
return {
title: field.title ?? getFieldLabel(key, field),
panel: AutomationBindingPanel,
type: field.customType,
updateOnChange: false,
}
},
},
[SchemaFieldTypes.QUERY_LIMIT]: {
comp: DrawerBindableInput,
props: (opts: FieldProps = {} as FieldProps) => {
const { key, field } = opts
return {
title: field.title ?? getFieldLabel(key, field),
panel: AutomationBindingPanel,
type: field.customType,
updateOnChange: false,
placeholder: queryLimit,
}
},
},
// Not a core schema type.
[SchemaFieldTypes.FIELDS]: {
comp: FieldSelector,
props: () => {
return {
isTestModal: false,
}
},
fullWidth: true,
},
}
$: isTrigger = block?.type === AutomationStepType.TRIGGER
// The step input properties
$: inputData = automationStore.actions.getInputData(block)
// Automation definition properties
$: schemaProperties = Object.entries(block?.schema?.inputs?.properties || {})
// Used for field labelling
$: requiredProperties = block ? block.schema["inputs"].required : []
$: tableId = inputData && "tableId" in inputData ? inputData.tableId : null
$: table = tableId
? $tables.list.find(table => table._id === tableId)
: { schema: {} }
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
const canShowField = (value: BaseIOStructure, inputData?: StepInputs) => {
if (!inputData) {
console.error("Cannot determine field visibility without field data")
return false
}
const dependsOn = value?.dependsOn
return !dependsOn || !!getInputValue(inputData, dependsOn)
}
const defaultChange = (
update: FormUpdate,
block?: AutomationTrigger | AutomationStep
) => {
if (block) {
automationStore.actions.requestUpdate(update, block)
}
}
/**
*
* Build the core props used to page the Automation Schema field.
*
* @param key
* @param field
* @param block
*/
const schemaToUI = (
key: string,
field: BaseIOStructure,
block?: AutomationStep | AutomationTrigger
) => {
if (!block) {
console.error("Could not process UI elements.")
return {}
}
const type = getFieldType(field, block)
const config = type ? SchemaTypes[type] : null
const title = getFieldLabel(key, field, requiredProperties?.includes(key))
const value = getInputValue(inputData, key)
const meta = getInputValue(inputData, "meta")
return {
meta,
config,
value,
title,
props: config?.props
? config.props({ key, field, block, value, meta, title })
: {},
}
}
/**
* Determine the correct UI element based on the automation
* schema configuration.
*
* @param field
* @param block
*/
const getFieldType = (
field: BaseIOStructure,
block?: AutomationStep | AutomationTrigger
) => {
// Direct customType map
const customType = field.customType && customTypeToSchema[field.customType]
if (customType) {
return customType
}
// Direct type map
const fieldType = field.type && typeToSchema[field.type]
if (fieldType) {
return fieldType
}
// Enum Field
if (field.type === AutomationIOType.STRING && field.enum) {
return SchemaFieldTypes.ENUM
}
// JS V2
if (
field.customType === AutomationCustomIOType.CODE &&
block?.stepId === AutomationActionStepId.EXECUTE_SCRIPT_V2
) {
return SchemaFieldTypes.CODE_V2
}
// Filter step or trigger
if (
field.customType === AutomationCustomIOType.FILTERS ||
field.customType === AutomationCustomIOType.TRIGGER_FILTER
) {
return SchemaFieldTypes.FILTER
}
// Default string/number UX
if (
field.type === AutomationIOType.STRING ||
field.type === AutomationIOType.NUMBER
) {
// "integer" removed as it isnt present in the type
return SchemaFieldTypes.STRING
}
}
</script>
{#each schemaProperties as [key, field]}
{#if canShowField(field, inputData)}
{@const { config, props, value, title } = schemaToUI(key, field, block)}
{#if config}
{#if config.wrapped === false}
<svelte:component
this={config.comp}
{bindings}
{block}
{context}
{key}
{...{ value, ...props }}
on:change={config.onChange ??
(e => {
defaultChange({ [key]: e.detail }, block)
})}
/>
{:else}
<PropField label={title} fullWidth labelTooltip={config.tooltip || ""}>
<svelte:component
this={config.comp}
{bindings}
{block}
{context}
{key}
{...{ value, ...props }}
on:change={config.onChange ??
(e => {
defaultChange({ [key]: e.detail }, block)
})}
/>
</PropField>
{/if}
{/if}
{/if}
{/each}

View File

@ -1,15 +1,18 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { Select } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { automationStore, selectedAutomation } from "@/stores/builder"
import { TriggerStepID } from "@/constants/backend/automations"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import PropField from "./PropField.svelte"
const dispatch = createEventDispatcher()
export let value
export let bindings = []
export let title
const onChangeAutomation = e => {
value.automationId = e.detail
dispatch("change", value)
@ -33,9 +36,8 @@
)
</script>
<div class="schema-field">
<Label>Automation</Label>
<div class="field-width">
<div class="selector">
<PropField label={title} fullWidth>
<Select
on:change={onChangeAutomation}
value={value.automationId}
@ -43,13 +45,12 @@
getOptionValue={automation => automation._id}
getOptionLabel={automation => automation.name}
/>
</div>
</PropField>
</div>
{#if Object.keys(automationFields)}
{#each Object.keys(automationFields) as field}
<div class="schema-field">
<Label>{field}</Label>
<div class="field-width">
<PropField label={field} fullWidth>
<DrawerBindableInput
panel={AutomationBindingPanel}
extraThin
@ -59,27 +60,12 @@
{bindings}
updateOnChange={false}
/>
</div>
</PropField>
</div>
{/each}
{/if}
<style>
.field-width {
width: 320px;
}
.schema-field {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
margin-bottom: 10px;
}
.schema-field :global(label) {
text-transform: capitalize;
}

View File

@ -0,0 +1,385 @@
<script lang="ts">
import {
Divider,
ActionButton,
notifications,
Helpers,
Icon,
Button,
} from "@budibase/bbui"
import JSONViewer, {
type JSONViewerClickEvent,
} from "@/components/common/JSONViewer.svelte"
import {
type AutomationStep,
type AutomationTrigger,
type Automation,
type LoopStepInputs,
type DidNotTriggerResponse,
type TestAutomationResponse,
type BlockRef,
type AutomationIOProps,
type AutomationStepResult,
AutomationStepStatus,
AutomationStatus,
AutomationStepType,
isDidNotTriggerResponse,
isTrigger,
isFilterStep,
isLoopStep,
BlockDefinitionTypes,
AutomationActionStepId,
} from "@budibase/types"
import Count from "./Count.svelte"
import { automationStore, selectedAutomation } from "@/stores/builder"
import {
type BlockStatus,
BlockStatusSource,
BlockStatusType,
DataMode,
RowSteps,
} from "@/types/automations"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash"
import { type AutomationContext } from "@/stores/builder/automations"
export let context: AutomationContext | undefined
export let block: AutomationStep | AutomationTrigger | undefined
export let automation: Automation | undefined
const dispatch = createEventDispatcher()
const DataModeTabs = {
[DataMode.INPUT]: "Data In",
[DataMode.OUTPUT]: "Data Out",
[DataMode.ERRORS]: "Errors",
}
// Add explicit weight to the issue types
const issueOrder = {
[BlockStatusType.ERROR]: 0,
[BlockStatusType.WARN]: 1,
[BlockStatusType.INFO]: 2,
}
let dataMode: DataMode = DataMode.INPUT
let issues: BlockStatus[] = []
$: blockRef = block?.id
? $selectedAutomation?.blockRefs?.[block.id]
: undefined
// The loop block associated with the selected block
$: loopRef = blockRef?.looped
? $selectedAutomation.blockRefs[blockRef.looped]
: undefined
$: loopBlock = automationStore.actions.getBlockByRef(automation, loopRef)
$: if ($automationStore.selectedNodeId) {
dataMode = $automationStore.selectedNodeMode || DataMode.INPUT
}
$: testResults = $automationStore.testResults as TestAutomationResponse
$: blockResults = automationStore.actions.processBlockResults(
testResults,
block
)
$: processTestIssues(testResults, block)
/**
* Take the results of an automation and generate a
* list of graded issues. If the conditions get bigger they
* could be broken out.
*
* @param testResults
* @param block
*/
const processTestIssues = (
testResults: TestAutomationResponse,
block: AutomationStep | AutomationTrigger | undefined
) => {
// Reset issues
issues = []
if (!testResults || !block || !blockResults) {
return
}
// Process loop issues
if (blockRef?.looped && loopBlock && isLoopStep(loopBlock)) {
const inputs = loopBlock.inputs as LoopStepInputs
const loopMax = Number(inputs.iterations)
const loopOutputs = blockResults?.outputs
let loopMessage = "There was an error"
// Not technically failed as it continues to run
if (loopOutputs?.status === AutomationStepStatus.MAX_ITERATIONS) {
loopMessage = `The maximum number of iterations (${loopMax}) has been reached.`
} else if (
loopOutputs?.status === AutomationStepStatus.FAILURE_CONDITION
) {
loopMessage = `The failure condition for the loop was hit: ${inputs.failure}.
The loop was terminated`
} else if (loopOutputs?.status === AutomationStepStatus.INCORRECT_TYPE) {
loopMessage = `An 'Input Type' of '${inputs.option}' was configured which does
not match the value supplied`
}
issues.push({
message: loopMessage,
type: BlockStatusType.ERROR,
source: BlockStatusSource.AUTOMATION_RESULTS,
})
}
// Process filtered row issues
else if (isTrigger(block) && isDidNotTriggerResponse(testResults)) {
issues.push({
message: (blockResults as DidNotTriggerResponse).message,
type: BlockStatusType.WARN,
source: BlockStatusSource.AUTOMATION_RESULTS,
})
}
// Filter step
else if (
isFilterStep(block) &&
blockResults?.outputs.status === AutomationStatus.STOPPED
) {
issues.push({
message: `The conditions were not met and
the automation flow was stopped.`,
type: BlockStatusType.WARN,
source: BlockStatusSource.AUTOMATION_RESULTS,
})
}
// Row step issues
else if (!isTrigger(block) && RowSteps.includes(block.stepId)) {
const outputs = (blockResults as AutomationStepResult)?.outputs
if (outputs.success) return
issues.push({
message: `Could not complete the row request:
${outputs.response?.message || JSON.stringify(outputs.response)}`,
type: BlockStatusType.ERROR,
source: BlockStatusSource.AUTOMATION_RESULTS,
})
}
// Default on error.
else if (!isTrigger(block) && !blockResults.outputs.success) {
issues.push({
message: `There was an issue with the step. See 'Data out' for more information`,
type: BlockStatusType.ERROR,
source: BlockStatusSource.AUTOMATION_RESULTS,
})
}
// Sort the issues
issues.sort((a, b) => issueOrder[a.type] - issueOrder[b.type])
}
const copyContext = (e: JSONViewerClickEvent) => {
Helpers.copyToClipboard(JSON.stringify(e.detail?.value))
notifications.success("Copied to clipboard")
}
/**
* Take the core AutomationContext and humanise it
* Before the test is run, populate the "Data in" values
* with the schema definition keyed by the readable step names.
*/
const parseContext = (context?: AutomationContext, blockRef?: BlockRef) => {
if (!blockRef || !automation) {
return
}
let clonetext = cloneDeep(context)
const pathSteps = automationStore.actions.getPathSteps(
blockRef.pathTo,
automation
)
const defs = $automationStore.blockDefinitions
const stepNames = automation.definition.stepNames
const loopDef =
defs[BlockDefinitionTypes.ACTION]?.[AutomationActionStepId.LOOP]
// Exclude the trigger and loops from steps
const filteredSteps: Record<string, AutomationStep | AutomationIOProps> =
pathSteps
.slice(0, -1)
.filter(
(step): step is AutomationStep =>
step.type === AutomationStepType.ACTION &&
step.stepId !== AutomationActionStepId.LOOP
)
.reduce(
(
acc: Record<string, AutomationStep | AutomationIOProps>,
step: AutomationStep
) => {
// Process the block
const blockRef = $selectedAutomation.blockRefs[step.id]
const blockDefinition =
defs[BlockDefinitionTypes.ACTION]?.[step.stepId]
// Check if the block has a loop
const loopRef = blockRef.looped
? $selectedAutomation.blockRefs[blockRef.looped]
: undefined
const testData = context?.steps?.[step.id]
const sampleDef = loopRef ? loopDef : blockDefinition
// Prioritise the display of testData and fallback to step defs
// This will ensure users have at least an idea of what they are looking at
acc[stepNames?.[step.id] || step.name] =
testData || sampleDef?.schema.outputs.properties || {}
return acc
},
{} as Record<string, AutomationStep | AutomationIOProps>
)
return { ...clonetext, steps: filteredSteps }
}
$: parsedContext = parseContext(context, blockRef)
</script>
<div class="tabs">
{#each Object.values(DataMode) as mode}
<Count count={mode === DataMode.ERRORS ? issues.length : 0}>
<ActionButton
selected={mode === dataMode}
quiet
on:click={() => {
dataMode = mode
}}
>
{DataModeTabs[mode]}
</ActionButton>
</Count>
{/each}
</div>
<Divider noMargin />
<div class="viewer">
{#if dataMode === DataMode.INPUT}
<JSONViewer
value={parsedContext}
showCopyIcon
on:click-copy={copyContext}
/>
{:else if dataMode === DataMode.OUTPUT}
{#if blockResults}
<JSONViewer
value={blockResults.outputs}
showCopyIcon
on:click-copy={copyContext}
/>
{:else if testResults && !blockResults}
<div class="content">
<span class="info">
This step was not executed as part of the test run
</span>
</div>
{:else}
<div class="content">
<span class="info">
Run the automation to show the output of this step
</span>
<Button
size={"S"}
icon={"Play"}
secondary
on:click={() => {
dispatch("run")
}}
>
Run
</Button>
</div>
{/if}
{:else}
<div class="issues" class:empty={!issues.length}>
{#if issues.length === 0}
<span>There are no current issues</span>
{:else}
{#each issues as issue}
<div class={`issue ${issue.type}`}>
<div class="icon"><Icon name="Alert" /></div>
<!-- For custom automations, the error message needs a default -->
<div class="message">
{issue.message || "There was an error"}
</div>
</div>
<Divider noMargin />
{/each}
{/if}
</div>
{/if}
</div>
<style>
.content .info {
text-align: center;
max-width: 70%;
}
.tabs,
.viewer {
padding: var(--spacing-l);
}
.viewer {
overflow-y: scroll;
flex: 1;
padding-right: 0px;
}
.viewer .content {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: var(--spacing-l);
}
.tabs {
display: flex;
gap: var(--spacing-s);
padding: var(--spacing-m) var(--spacing-l);
}
.issue {
display: flex;
gap: var(--spacing-s);
width: 100%;
box-sizing: border-box;
padding-bottom: var(--spacing-l);
}
.issues {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.issues.empty {
align-items: center;
justify-content: center;
}
.issue.error .icon {
color: var(--spectrum-global-color-static-red-600);
}
.issue.warn .icon {
color: var(--spectrum-global-color-static-yellow-600);
}
.issues :global(hr.spectrum-Divider:last-child) {
display: none;
}
.issues .issue:not(:first-child) {
padding-top: var(--spacing-l);
}
.issues {
word-break: break-word;
}
</style>

View File

@ -0,0 +1,236 @@
<script lang="ts">
import { Body, Tags, Tag, Icon, TooltipPosition } from "@budibase/bbui"
import {
type AutomationStep,
type AutomationTrigger,
type Automation,
AutomationStepType,
isBranchStep,
isActionStep,
} from "@budibase/types"
import {
type ExternalAction,
externalActions,
} from "@/components/automation/AutomationBuilder/FlowChart/ExternalActions"
import { automationStore } from "@/stores/builder"
import { createEventDispatcher } from "svelte"
export let block: AutomationStep | AutomationTrigger | undefined = undefined
export let automation: Automation | undefined = undefined
export let itemName: string | undefined = undefined
export let disabled: boolean = false
const dispatch = createEventDispatcher()
let externalAction: ExternalAction | undefined
let editing: boolean
let validRegex: RegExp = /^[A-Za-z0-9_\s]+$/
$: stepNames = automation?.definition.stepNames || {}
$: blockHeading = getHeading(itemName, block) || ""
$: blockNameError = getStepNameError(blockHeading)
$: isBranch = block && isBranchStep(block)
const getHeading = (
itemName?: string,
block?: AutomationStep | AutomationTrigger
) => {
if (itemName) {
return itemName
} else if (block) {
return stepNames?.[block?.id] || block?.name
}
}
$: isTrigger = block?.type === AutomationStepType.TRIGGER
$: allSteps = automation?.definition.steps || []
$: blockDefinition = automationStore.actions.getBlockDefinition(block)
// Put the type in the header if they change the name
// Otherwise the info is obscured
$: blockTitle = isBranch
? "Branch"
: `${
block &&
block?.id in stepNames &&
stepNames[block?.id] !== blockDefinition?.name
? blockDefinition?.name
: "Step"
}`
// Parse external actions
$: if (block && isActionStep(block) && block?.stepId in externalActions) {
externalAction = externalActions[block?.stepId]
} else {
externalAction = undefined
}
const getStepNameError = (name: string) => {
if (!block) {
return
}
const duplicateError =
"This name already exists, please enter a unique name"
if (editing) {
for (const [key, value] of Object.entries(stepNames)) {
if (name !== block.name && name === value && key !== block.id) {
return duplicateError
}
}
for (const step of allSteps) {
if (step.id !== block.id && name === step.name) {
return duplicateError
}
}
}
if (name !== block.name && name?.length > 0) {
let invalidRoleName = !validRegex.test(name)
if (invalidRoleName) {
return "Please enter a name consisting of only alphanumeric symbols and underscores"
}
}
}
const startEditing = () => {
editing = true
}
const stopEditing = () => {
editing = false
if (blockNameError) {
blockHeading =
(block ? stepNames[block?.id] : undefined) || block?.name || ""
} else {
dispatch("update", blockHeading)
}
}
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
blockHeading = target.value.trim()
}
</script>
<div class="block-details">
{#if block}
{#if externalAction}
<img
alt={externalAction.name}
src={externalAction.icon}
width="28px"
height="28px"
/>
{:else}
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{block.icon}" />
</svg>
{/if}
<div class="heading">
{#if isTrigger}
<Body size="XS"><b>Trigger</b></Body>
{:else}
<Body size="XS">
<div class="step">
<b>{blockTitle}</b>
{#if blockDefinition?.deprecated}
<Tags>
<Tag invalid>Deprecated</Tag>
</Tags>
{/if}
</div>
</Body>
{/if}
<input
class="input-text"
placeholder={`Enter step name`}
name="name"
autocomplete="off"
disabled={isTrigger || disabled}
value={blockHeading}
on:input={handleInput}
on:click={e => {
e.stopPropagation()
startEditing()
}}
on:keydown={async e => {
if (e.key === "Enter") {
stopEditing()
}
}}
on:blur={stopEditing}
/>
</div>
{/if}
{#if blockNameError && editing}
<div class="error-container">
<div class="error-icon">
<Icon
size="S"
name="Alert"
tooltip={blockNameError}
tooltipPosition={TooltipPosition.Left}
/>
</div>
</div>
{/if}
</div>
<style>
.step {
display: flex;
gap: 0.5rem;
align-items: center;
}
input {
color: var(--ink);
background-color: transparent;
border: 1px solid transparent;
box-sizing: border-box;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.input-text {
font-size: var(--spectrum-alias-font-size-default);
font-family: var(--font-sans);
text-overflow: ellipsis;
padding-left: 0px;
border: 0px;
}
input:focus {
outline: none;
}
/* Hide arrows for number fields */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.block-details {
display: flex;
align-items: center;
gap: var(--spacing-l);
flex: 1 1 0;
min-width: 0;
}
.error-icon :global(.spectrum-Icon) {
fill: var(--spectrum-global-color-red-600);
}
.heading {
flex: 1;
}
</style>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { automationStore } from "@/stores/builder"
import { memo } from "@budibase/frontend-core"
import { environment } from "@/stores/portal"
import {
type AutomationStep,
type AutomationTrigger,
type Automation,
} from "@budibase/types"
import { type SchemaConfigProps } from "@/types/automations"
import { writable } from "svelte/store"
import { getCustomStepLayout } from "./layouts"
import AutomationSchemaLayout from "./AutomationSchemaLayout.svelte"
import AutomationCustomLayout from "./AutomationCustomLayout.svelte"
export let block: AutomationStep | AutomationTrigger | undefined = undefined
export let automation: Automation | undefined = undefined
export let context: {} | undefined
const memoEnvVariables = memo($environment.variables)
// Databindings requires TypeScript conversion
let environmentBindings: any[]
// All bindings available to this point
$: availableBindings = automationStore.actions.getPathBindings(
block?.id,
automation
)
// Fetch the env bindings
$: if ($memoEnvVariables) {
environmentBindings = automationStore.actions.buildEnvironmentBindings()
}
$: userBindings = automationStore.actions.buildUserBindings()
$: settingBindings = automationStore.actions.buildSettingBindings()
// Combine all bindings for the step
$: bindings = [
...availableBindings,
...environmentBindings,
...userBindings,
...settingBindings,
]
// Store for any UX related data
const stepStore = writable<Record<string, any>>({})
$: if (block?.id) {
stepStore.update(state => ({ ...state, [block.id]: {} }))
}
// Determine if any custom step layouts have been created
let customLayout: SchemaConfigProps[] | undefined
$: if ($stepStore) {
customLayout = getCustomStepLayout(block, stepStore)
}
</script>
{#if customLayout}
<!-- Render custom layout 1 or more components in a custom layout -->
<AutomationCustomLayout {context} {bindings} {block} layout={customLayout} />
{:else}
<!-- Render Automation Step Schema > [string, BaseIOStructure][] -->
<AutomationSchemaLayout {context} {bindings} {block} />
{/if}

View File

@ -0,0 +1,51 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { AbsTooltip } from "@budibase/bbui"
export let count: number = 0
export let tooltip: string | undefined = undefined
export let hoverable: boolean = true
const dispatch = createEventDispatcher()
</script>
<div class="wrapper">
<AbsTooltip text={tooltip}>
{#if count}
<span
class="count"
role="button"
tabindex="-1"
aria-label={`Notifications ${count}`}
class:hoverable
on:mouseenter={() => {
dispatch("hover")
}}
>
{count}
</span>
{/if}
</AbsTooltip>
<slot />
</div>
<style>
.wrapper {
position: relative;
}
.count {
position: absolute;
right: -6px;
top: -6px;
background: var(--spectrum-global-color-static-red-600);
color: white;
border-radius: 8px;
padding: 0 4px;
z-index: 2;
font-size: 0.8em;
cursor: default;
}
.count.hoverable {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import {
DrawerBindableSlot,
ServerBindingPanel as AutomationBindingPanel,
} from "@/components/common/bindings"
import { DatePicker } from "@budibase/bbui"
import { type EnrichedBinding } from "@budibase/types"
import { createEventDispatcher } from "svelte"
export let context: Record<any, any> | undefined = undefined
export let bindings: EnrichedBinding[] | undefined = undefined
export let value: string | undefined = undefined
export let title: string | undefined = undefined
export let disabled: boolean | undefined = false
const dispatch = createEventDispatcher()
</script>
<DrawerBindableSlot
{title}
panel={AutomationBindingPanel}
type={"date"}
{value}
on:change={e => dispatch("change", e.detail)}
{bindings}
allowJS={true}
updateOnChange={false}
{disabled}
{context}
>
<DatePicker
{value}
on:change={e => {
dispatch("change", e.detail)
}}
/>
</DrawerBindableSlot>

View File

@ -0,0 +1,111 @@
<script lang="ts">
import { BindingSidePanel } from "@/components/common/bindings"
import {
BindingType,
BindingHelpers,
} from "@/components/common/bindings/utils"
import CodeEditor from "@/components/common/CodeEditor/CodeEditor.svelte"
import {
bindingsToCompletions,
hbAutocomplete,
EditorModes,
} from "@/components/common/CodeEditor"
import { CodeEditorModal } from "@/components/automation/SetupPanel"
import { createEventDispatcher } from "svelte"
import {
AutomationActionStepId,
type AutomationStep,
type CaretPositionFn,
type InsertAtPositionFn,
type EnrichedBinding,
BindingMode,
} from "@budibase/types"
const dispatch = createEventDispatcher()
export let value: string | undefined
export let isJS: boolean = true
export let block: AutomationStep
export let context
export let bindings
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
$: codeMode =
block.stepId === AutomationActionStepId.EXECUTE_BASH
? EditorModes.Handlebars
: EditorModes.JS
$: bindingsHelpers = new BindingHelpers(getCaretPosition, insertAtPos, {
disableWrapping: true,
})
$: editingJs = codeMode === EditorModes.JS
$: stepCompletions =
codeMode === EditorModes.Handlebars
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: []
const addBinding = (binding: EnrichedBinding) =>
bindingsHelpers.onSelectBinding(value, binding, {
js: true,
dontDecode: true,
type: BindingType.RUNTIME,
})
</script>
<!-- DEPRECATED -->
<CodeEditorModal
on:hide={() => {
// Push any pending changes when the window closes
dispatch("change", value)
}}
>
<div class:js-editor={isJS}>
<div class:js-code={isJS} style="width:100%;height:500px;">
<CodeEditor
{value}
on:change={e => {
// need to pass without the value inside
value = e.detail
}}
completions={stepCompletions}
mode={codeMode}
autocompleteEnabled={codeMode !== EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
placeholder={codeMode === EditorModes.Handlebars
? "Add bindings by typing {{"
: null}
/>
</div>
{#if editingJs}
<div class="js-binding-picker">
<BindingSidePanel
{bindings}
allowHelpers={false}
{addBinding}
mode={BindingMode.JavaScript}
{context}
/>
</div>
{/if}
</div>
</CodeEditorModal>
<style>
.js-editor {
display: flex;
flex-direction: row;
flex-grow: 1;
width: 100%;
}
.js-code {
flex: 7;
}
.js-binding-picker {
flex: 3;
margin-top: calc((var(--spacing-xl) * -1) + 1px);
}
</style>

View File

@ -0,0 +1,67 @@
<script lang="ts">
import {
DrawerBindableSlot,
ServerBindingPanel as AutomationBindingPanel,
} from "@/components/common/bindings"
import { createEventDispatcher } from "svelte"
import { type EnrichedBinding, FieldType } from "@budibase/types"
import CodeEditorField from "@/components/common/bindings/CodeEditorField.svelte"
import { DropdownPosition } from "@/components/common/CodeEditor/CodeEditor.svelte"
export let value: string
export let context: Record<any, any> | undefined = undefined
export let bindings: EnrichedBinding[] | undefined
const dispatch = createEventDispatcher()
</script>
<div class="scriptv2-wrapper">
<DrawerBindableSlot
title={"Edit Code"}
panel={AutomationBindingPanel}
type={FieldType.LONGFORM}
on:change={e => dispatch("change", e.detail)}
{value}
{bindings}
allowJS={true}
allowHBS={false}
updateOnChange={false}
{context}
>
<div class="field-wrap code-editor">
<CodeEditorField
{value}
{bindings}
{context}
placeholder={"Add bindings by typing $"}
on:change={e => dispatch("change", e.detail)}
dropdown={DropdownPosition.Relative}
/>
</div>
</DrawerBindableSlot>
</div>
<style>
.field-wrap :global(.cm-editor),
.field-wrap :global(.cm-scroller) {
border-radius: 4px;
}
.field-wrap {
box-sizing: border-box;
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
}
.field-wrap.code-editor {
height: 180px;
}
.scriptv2-wrapper :global(.icon.slot-icon),
.scriptv2-wrapper :global(.text-area-slot-icon) {
right: 1px;
top: 1px;
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-left-radius: var(--spectrum-alias-border-radius-regular);
border-right: 0px;
border-bottom: 1px solid var(--spectrum-alias-border-color);
}
</style>

View File

@ -0,0 +1,89 @@
<script lang="ts">
import KeyValueBuilder from "@/components/integration/KeyValueBuilder.svelte"
import { Toggle } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import {
DrawerBindableInput,
ServerBindingPanel as AutomationBindingPanel,
} from "@/components/common/bindings"
import { type KeyValuePair } from "@/types/automations"
export let useAttachmentBinding
export let buttonText
export let key
export let context
export let value
export let bindings
const dispatch = createEventDispatcher()
interface Attachment {
filename: string
url: string
}
const handleAttachmentParams = (keyValueObj: Attachment[]) => {
let params: Record<string, string> = {}
if (keyValueObj?.length) {
for (let param of keyValueObj) {
params[param.url] = param.filename
}
}
return params
}
const handleKeyValueChange = (e: CustomEvent<KeyValuePair[]>) => {
const update = {
[key]: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
}
dispatch("change", update)
}
</script>
<div class="attachment-field-wrapper">
<div class="toggle-container">
<Toggle
value={useAttachmentBinding}
text={"Use bindings"}
on:change={e => {
dispatch("change", {
[key]: null,
meta: {
useAttachmentBinding: e.detail,
},
})
}}
/>
</div>
<div class="attachment-field-width">
{#if !useAttachmentBinding}
{#key value}
<KeyValueBuilder
on:change={handleKeyValueChange}
object={handleAttachmentParams(value)}
allowJS
{bindings}
keyBindings
customButtonText={buttonText}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
{context}
/>
{/key}
{:else}
<DrawerBindableInput
panel={AutomationBindingPanel}
{value}
on:change={e => dispatch("change", { [key]: e.detail })}
{bindings}
updateOnChange={false}
{context}
/>
{/if}
</div>
</div>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { getSchemaForDatasourcePlus } from "@/dataBinding"
import { ServerBindingPanel as AutomationBindingPanel } from "@/components/common/bindings"
import FilterBuilder from "@/components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { tables, automationStore } from "@/stores/builder"
import type {
AutomationStep,
AutomationTrigger,
BaseIOStructure,
UISearchFilter,
} from "@budibase/types"
import { AutomationCustomIOType } from "@budibase/types"
import { utils } from "@budibase/shared-core"
import { QueryUtils, Utils, search } from "@budibase/frontend-core"
import { cloneDeep } from "lodash"
import { type DynamicProperties, type StepInputs } from "@/types/automations"
export let block: AutomationStep | AutomationTrigger | undefined
export let context
export let bindings
export let key
// Mix in dynamic filter props
type DynamicInputs = StepInputs & DynamicProperties
const dispatch = createEventDispatcher()
let drawer: Drawer
$: inputData = automationStore.actions.getInputData(block)
$: schemaProperties = Object.entries(block?.schema?.inputs?.properties || {})
$: filters = lookForFilters(schemaProperties)
$: filterCount =
filters?.groups?.reduce((acc: number, group) => {
acc = acc += group?.filters?.length || 0
return acc
}, 0) || 0
$: tempFilters = cloneDeep(filters)
$: tableId = inputData && "tableId" in inputData ? inputData.tableId : null
$: schema = getSchemaForDatasourcePlus(tableId, {
searchableSchema: true,
}).schema
$: schemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{ allowLinks: false }
)
const lookForFilters = (
properties: [string, BaseIOStructure][]
): UISearchFilter | undefined => {
if (!properties || !inputData) {
return
}
let filter: UISearchFilter | undefined
// Does not appear in the test modal. Check testData
const inputs = inputData as DynamicInputs
for (let [key, field] of properties) {
// need to look for the builder definition (keyed separately, see saveFilters)
if (
(field.customType === AutomationCustomIOType.FILTERS ||
field.customType === AutomationCustomIOType.TRIGGER_FILTER) &&
`${key}-def` in inputs
) {
filter = inputs[`${key}-def`]
break
}
}
return Array.isArray(filter) ? utils.processSearchFilters(filter) : filter
}
function saveFilters(key: string, filters?: UISearchFilter) {
const update = filters ? Utils.parseFilter(filters) : filters
const query = QueryUtils.buildQuery(update)
dispatch("change", {
[key]: query,
[`${key}-def`]: update, // need to store the builder definition in the automation
})
drawer.hide()
}
</script>
<ActionButton fullWidth on:click={drawer.show}>
{filterCount > 0 ? "Update Filter" : "No Filter set"}
</ActionButton>
<Drawer
bind:this={drawer}
title="Filtering"
forceModal
on:drawerShow={() => {
tempFilters = filters
}}
>
<Button cta slot="buttons" on:click={() => saveFilters(key, tempFilters)}>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
filters={tempFilters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
on:change={e => (tempFilters = e.detail)}
evaluationContext={context}
/>
</DrawerContent>
</Drawer>

View File

@ -1,10 +1,10 @@
<script>
<script lang="ts">
import { Label } from "@budibase/bbui"
export let label
export let labelTooltip
export let fullWidth = false
export let componentWidth = 320
export let label: string | undefined = ""
export let labelTooltip: string | undefined = ""
export let fullWidth: boolean | undefined = false
export let componentWidth: number | undefined = 320
</script>
<div

View File

@ -0,0 +1,74 @@
<script lang="ts">
import { API } from "@/api"
import { tables } from "@/stores/builder"
import { type TableDatasource, SortOrder } from "@budibase/types"
import { fetchData } from "@budibase/frontend-core"
import type TableFetch from "@budibase/frontend-core/src/fetch/TableFetch"
import { Select } from "@budibase/bbui"
export let tableId: string | undefined
let datasource: TableDatasource
let fetch: TableFetch | undefined
let rowSearchTerm = ""
let selectedRow
$: primaryDisplay = table?.primaryDisplay
$: table = tableId
? $tables.list.find(table => table._id === tableId)
: undefined
$: if (table && tableId) {
datasource = { type: "table", tableId }
fetch = createFetch(datasource)
}
$: if (rowSearchTerm && primaryDisplay) {
fetch?.update({
query: {
fuzzy: {
[primaryDisplay]: rowSearchTerm || "",
},
},
})
}
const createFetch = (datasource: TableDatasource) => {
if (!datasource || !primaryDisplay) {
return
}
return fetchData({
API,
datasource,
options: {
sortColumn: primaryDisplay,
sortOrder: SortOrder.ASCENDING,
query: {
fuzzy: {
[primaryDisplay]: rowSearchTerm || "",
},
},
limit: 20,
},
})
}
$: fetchedRows = fetch ? $fetch?.rows : []
$: fetchLoading = fetch ? $fetch?.loading : false
const compare = (a: any, b: any) => {
primaryDisplay && a?.[primaryDisplay] === b?.[primaryDisplay]
}
</script>
<Select
placeholder={"Select a row"}
options={fetchedRows}
loading={fetchLoading}
value={selectedRow}
autocomplete={true}
getOptionLabel={row => (primaryDisplay ? row?.[primaryDisplay] : "")}
{compare}
/>

View File

@ -11,8 +11,6 @@
import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { FIELDS } from "@/constants/backend"
import { capitalise } from "@/helpers"
import { memo } from "@budibase/frontend-core"
@ -26,6 +24,8 @@
export let bindings
export let isTestModal
export let context = {}
export let componentWidth
export let fullWidth = false
const typeToField = Object.values(FIELDS).reduce((acc, field) => {
acc[field.type] = field
@ -234,66 +234,28 @@
)
dispatch("change", result)
}
/**
* Converts arrays into strings. The CodeEditor expects a string or encoded JS
* @param{object} fieldValue
*/
const drawerValue = fieldValue => {
return Array.isArray(fieldValue) ? fieldValue.join(",") : fieldValue
}
</script>
{#each schemaFields || [] as [field, schema]}
{#if !schema.autocolumn && Object.hasOwn(editableFields, field)}
<PropField label={field} fullWidth={isFullWidth(schema.type)}>
<PropField
label={field}
fullWidth={fullWidth || isFullWidth(schema.type)}
{componentWidth}
>
<div class="prop-control-wrap">
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
value={editableRow}
meta={{
fields: editableFields,
}}
{onChange}
{context}
/>
{:else}
<DrawerBindableSlot
title={$memoStore?.row?.title || field}
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={drawerValue(editableRow[field])}
on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
{context}
>
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
value={editableRow}
meta={{
fields: editableFields,
}}
{context}
onChange={change => onChange(change)}
/>
</DrawerBindableSlot>
{/if}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
value={editableRow}
meta={{
fields: editableFields,
}}
{onChange}
{context}
/>
</div>
</PropField>
{/if}
@ -305,24 +267,30 @@
class:empty={Object.is(editableFields, {})}
bind:this={popoverAnchor}
>
<ActionButton
icon="Add"
on:click={() => {
customPopover.show()
}}
disabled={!schemaFields}
>Add fields
</ActionButton>
<ActionButton
icon="Remove"
on:click={() => {
dispatch("change", {
meta: { fields: {} },
row: {},
})
}}
>Clear
</ActionButton>
<PropField {componentWidth} {fullWidth}>
<div class="prop-control-wrap">
<ActionButton
on:click={() => {
customPopover.show()
}}
disabled={!schemaFields}
>
Edit fields
</ActionButton>
{#if schemaFields.length}
<ActionButton
on:click={() => {
dispatch("change", {
meta: { fields: {} },
row: {},
})
}}
>
Clear
</ActionButton>
{/if}
</div>
</PropField>
</div>
{/if}
@ -388,11 +356,4 @@
.prop-control-wrap :global(.icon.json-slot-icon) {
right: 1px !important;
}
.add-fields-btn {
display: flex;
flex-direction: row;
justify-content: center;
gap: var(--spacing-s);
}
</style>

View File

@ -7,7 +7,7 @@
const dispatch = createEventDispatcher()
export let value
export let isTrigger
export let isTrigger = false
export let disabled = false
$: filteredTables = $tables.list.filter(table => {

View File

@ -0,0 +1,33 @@
// Schema UX Types
import FilterSelector from "./FilterSelector.svelte"
import ExecuteScript from "./ExecuteScript.svelte"
import ExecuteScriptV2 from "./ExecuteScriptV2.svelte"
import DateSelector from "./DateSelector.svelte"
import SchemaSetup from "./SchemaSetup.svelte"
import TableSelector from "./TableSelector.svelte"
import CronBuilder from "./CronBuilder.svelte"
import FieldSelector from "./FieldSelector.svelte"
import FileSelector from "./FileSelector.svelte"
import RowSelector from "./RowSelector.svelte"
import AutomationSelector from "./AutomationSelector.svelte"
import PropField from "./PropField.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte"
import CodeEditorModal from "./CodeEditorModal.svelte"
export {
FilterSelector,
ExecuteScriptV2,
DateSelector,
SchemaSetup,
TableSelector,
CronBuilder,
FieldSelector,
FileSelector,
RowSelector,
AutomationSelector,
PropField,
QueryParamSelector,
CodeEditorModal,
// DEPRECATED
ExecuteScript,
}

View File

@ -0,0 +1,330 @@
import { DrawerBindableInput } from "@/components/common/bindings"
import { automationStore, selectedAutomation } from "@/stores/builder"
import { Divider, Helpers, Select } from "@budibase/bbui"
import {
AutomationEventType,
AutomationStep,
AutomationStepType,
AutomationTrigger,
BaseIOStructure,
isTrigger,
Row,
} from "@budibase/types"
import AutomationBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import { RowSelector, TableSelector } from "."
import { get, Writable } from "svelte/store"
import {
type StepInputs,
type SchemaConfigProps,
type FormUpdate,
} from "@/types/automations"
import RowFetch from "./RowFetch.svelte"
export const getInputValue = (inputData: StepInputs, key: string) => {
const idxInput = inputData as Record<string, unknown>
return idxInput?.[key]
}
// We do not enforce required fields.
export const getFieldLabel = (
key: string,
value: BaseIOStructure,
isRequired = false
) => {
const requiredSuffix = isRequired ? "*" : ""
const label = `${
value.title || (key === "row" ? "Row" : key)
} ${requiredSuffix}`
return Helpers.capitalise(label)
}
/**
* Returns a custom step layout. This generates an array of ui elements
* intended to be rendered as a uniform column
* If you intend to have a completely unique UX,
*
* @param block
* @param stepState
* @param isTest
* @returns
*/
export const getCustomStepLayout = (
block: AutomationStep | AutomationTrigger | undefined,
stepState: Writable<Record<string, any>>,
isTest = false
): SchemaConfigProps[] | undefined => {
if (!block) {
return
}
// Use the test data for test mode otherwise resolve trigger/step inputs by block
const fieldData = isTest
? get(selectedAutomation)?.data?.testData
: automationStore.actions.getInputData(block)
const coreDivider = {
comp: Divider,
props: () => ({
noMargin: true,
}),
wrapped: false,
}
const getIdConfig = (rowIdentifier: string): SchemaConfigProps[] => {
const schema =
block.type === AutomationStepType.TRIGGER
? block.schema.outputs.properties
: block.schema.inputs.properties
const rowIdEntry = schema[rowIdentifier]
if (!rowIdEntry) {
return []
}
const rowIdlabel = getFieldLabel(rowIdentifier, rowIdEntry)
const layout: SchemaConfigProps[] = [
{
comp: DrawerBindableInput,
title: rowIdlabel,
onChange: (e: CustomEvent<FormUpdate>) => {
const update = { [rowIdentifier]: e.detail }
if (block) {
automationStore.actions.requestUpdate(update, block)
}
},
props: () => {
return {
panel: AutomationBindingPanel,
value: getInputValue(fieldData, rowIdentifier),
updateOnChange: false,
}
},
},
]
return layout
}
const getRowSelector = (): SchemaConfigProps[] => {
const row: Row = getInputValue(fieldData, "row") as Row
const meta: Record<string, unknown> = getInputValue(
fieldData,
"meta"
) as Record<string, unknown>
return [
{
comp: RowSelector,
wrapped: false,
props: () => ({
row,
meta: meta || {},
componentWidth: 280,
fullWidth: true,
}),
onChange: (e: CustomEvent<FormUpdate>) => {
if (block) {
const metaUpdate = e.detail?.meta as Record<string, unknown>
automationStore.actions.requestUpdate(
{
row: e.detail.row,
meta: {
fields: metaUpdate?.fields || {},
},
},
block
)
}
},
},
]
}
const getTableSelector = (): SchemaConfigProps[] => {
const row: Row = getInputValue(fieldData, "row") as Row
return [
{
comp: TableSelector,
title: "Table",
onChange: (e: CustomEvent) => {
const rowKey = get(stepState)[block.id]?.rowType || "row"
automationStore.actions.requestUpdate(
{
_tableId: e.detail,
meta: {},
[rowKey]: e.detail
? {
tableId: e.detail,
}
: {},
},
block
)
},
props: () => ({
isTrigger: isTrigger(block),
value: row?.tableId ?? "",
disabled: isTest,
}),
},
]
}
// STEP
if (automationStore.actions.isRowStep(block)) {
const layout: SchemaConfigProps[] = [
...getTableSelector(),
...getIdConfig("rowId"),
coreDivider,
...getRowSelector(),
]
return layout
} else if (automationStore.actions.isRowTrigger(block) && isTest) {
// TRIGGER/TEST - Will be used to replace the test modal
const schema = block.schema.outputs.properties
const getRevConfig = (): SchemaConfigProps[] => {
// part of outputs for row type
const rowRevEntry = schema["revision"]
if (!rowRevEntry) {
return []
}
const rowRevlabel = getFieldLabel("revision", rowRevEntry)
const selected = get(selectedAutomation)
const triggerOutputs = selected.data?.testData
return [
{
comp: DrawerBindableInput,
title: rowRevlabel,
onChange: (e: CustomEvent<FormUpdate>) => {
const update = { ["revision"]: e.detail }
if (block) {
automationStore.actions.requestUpdate(update, block)
}
},
props: () => ({
panel: AutomationBindingPanel,
value: triggerOutputs ? triggerOutputs["revision"] : undefined,
updateOnChange: false,
}),
},
]
}
// A select to switch from `row` to `oldRow`
const getRowTypeConfig = (): SchemaConfigProps[] => {
if (block.event !== AutomationEventType.ROW_UPDATE) {
return []
}
if (!get(stepState)?.[block.id]?.rowType) {
stepState.update(state => ({
...state,
[block.id]: {
rowType: "row",
},
}))
}
type RowType = { name: string; id: string }
return [
{
comp: Select,
tooltip: `You can configure test data for both the updated row and
the old row, if you need it. Just select the one you wish to alter`,
title: "Row data",
props: () => ({
value: get(stepState)?.[block.id].rowType,
placeholder: false,
getOptionLabel: (type: RowType) => type.name,
getOptionValue: (type: RowType) => type.id,
options: [
{
id: "row",
name: "Updated row",
},
{ id: "oldRow", name: "Old row" },
] as RowType[],
}),
onChange: (e: CustomEvent) => {
if (e.detail === get(stepState)?.[block.id].rowType) {
return
}
stepState.update(state => ({
...state,
[block.id]: {
rowType: e.detail,
},
}))
},
},
]
}
const getTriggerRowSelector = (): SchemaConfigProps[] => {
const rowKey = get(stepState)?.[block.id]?.rowType ?? "row"
const rowData: Row = getInputValue(fieldData, rowKey) as Row
const row: Row = getInputValue(fieldData, "row") as Row
const meta: Record<string, unknown> = getInputValue(
fieldData,
"meta"
) as Record<string, unknown>
return [
{
comp: RowSelector,
wrapped: false,
props: () => ({
componentWidth: 280,
row: rowData || {
tableId: row?.tableId,
},
meta: {
fields: meta?.oldFields || {},
},
}),
onChange: (e: CustomEvent<FormUpdate>) => {
const metaUpdate = e.detail?.meta as Record<string, unknown>
const update = {
[rowKey]: e.detail.row,
meta: {
fields: meta?.fields || {},
oldFields:
(metaUpdate?.fields as Record<string, unknown>) || {},
},
}
if (block) {
automationStore.actions.requestUpdate(update, block)
}
},
},
]
}
const layout: SchemaConfigProps[] = [
...getTableSelector(),
{
comp: RowFetch,
title: "Row",
props: () => {
return {
tableId: "ta_bb_employee",
meta: getInputValue(fieldData, "meta"),
}
},
onChange: () => {},
},
...getIdConfig("id"),
...getRevConfig(),
...getRowTypeConfig(),
coreDivider,
...getTriggerRowSelector(),
]
return layout
}
}

View File

@ -6,7 +6,13 @@
Multiselect,
Button,
} from "@budibase/bbui"
import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
import {
CalculationType,
canGroupBy,
FieldType,
isNumeric,
isNumericStaticFormula,
} from "@budibase/types"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte"
import DetailPopover from "@/components/common/DetailPopover.svelte"
@ -94,10 +100,15 @@
if (fieldSchema.calculationType) {
return false
}
// static numeric formulas will work
if (isNumericStaticFormula(fieldSchema)) {
return true
}
// Only allow numeric columns for most calculation types
if (
self.type !== CalculationType.COUNT &&
!isNumeric(fieldSchema.type)
!isNumeric(fieldSchema.type) &&
fieldSchema.responseType !== FieldType.NUMBER
) {
return false
}

View File

@ -45,7 +45,7 @@
},
{
label: "Multi-select",
value: FieldType.ARRAY.type,
value: FieldType.ARRAY,
},
{
label: "Barcode/QR",

View File

@ -7,15 +7,15 @@
import { createEventDispatcher } from "svelte"
import { capitalise } from "@/helpers"
export let value
export let error
export let value = undefined
export let error = undefined
export let placeholder = null
export let autoWidth = false
export let quiet = false
export let allowPublic = true
export let allowRemove = false
export let disabled = false
export let align
export let align = undefined
export let footer = null
export let allowedRoles = null
export let allowCreator = false
@ -135,7 +135,6 @@
{autoWidth}
{quiet}
{disabled}
{align}
{footer}
bind:value
on:change={onChange}

View File

@ -49,11 +49,11 @@
export let bindings: EnrichedBinding[] = []
export let value: string = ""
export let allowHBS = true
export let allowJS = false
export let allowHBS: boolean | undefined = true
export let allowJS: boolean | undefined = false
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let context: Record<any, any> | undefined = undefined
export let snippets: Snippet[] | null = null
export let autofocusEditor = false
export let placeholder: string | null = null
@ -128,7 +128,7 @@
}
}
const getModeOptions = (allowHBS: boolean, allowJS: boolean) => {
const getModeOptions = (allowHBS = true, allowJS = false) => {
let options = []
if (allowHBS) {
options.push(BindingMode.Text)

View File

@ -21,15 +21,15 @@
import SnippetDrawer from "./SnippetDrawer.svelte"
import UpgradeButton from "@/pages/builder/portal/_components/UpgradeButton.svelte"
export let addHelper: (_helper: Helper, _js?: boolean) => void
export let addBinding: (_binding: EnrichedBinding) => void
export let addSnippet: (_snippet: Snippet) => void
export let bindings: EnrichedBinding[]
export let snippets: Snippet[] | null
export let mode: BindingMode
export let allowHelpers: boolean
export let allowSnippets: boolean
export let context = null
export let addHelper: (_helper: Helper, _js?: boolean) => void = () => {}
export let addBinding: (_binding: EnrichedBinding) => void = () => {}
export let addSnippet: (_snippet: Snippet) => void = () => {}
export let bindings: EnrichedBinding[] | undefined
export let snippets: Snippet[] | null = null
export let mode: BindingMode | undefined = BindingMode.Text
export let allowHelpers: boolean = true
export let allowSnippets: boolean = true
export let context: Record<any, any> | undefined = undefined
let search = ""
let searching = false
@ -93,7 +93,7 @@
search
)
function onModeChange(_mode: BindingMode) {
function onModeChange(_mode: BindingMode | undefined) {
selectedCategory = null
}
$: onModeChange(mode)

View File

@ -36,10 +36,10 @@
export let value: string = ""
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let context: Record<any, any> | undefined = undefined
export let autofocusEditor = false
export let placeholder = null
export let height = 180
export let placeholder: string | undefined
export let dropdown = DropdownPosition.Absolute
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
@ -126,26 +126,25 @@
}
const updateValue = (val: any) => {
dispatch("change", readableToRuntimeBinding(bindings, val))
if (!val) {
dispatch("change", null)
}
const update = readableToRuntimeBinding(bindings, encodeJSBinding(val))
dispatch("change", update)
}
const onChangeJSValue = (e: { detail: string }) => {
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(encodeJSBinding(e.detail))
}
const onBlurJSValue = (e: { detail: string }) => {
// Don't bother saving empty values as JS
updateValue(e.detail?.trim())
}
</script>
<div class="code-panel" style="height:{height}px;">
<div class="code-panel">
<div class="editor">
{#key jsCompletions}
<CodeEditor
value={jsValue || ""}
on:change={onChangeJSValue}
on:blur
on:blur={onBlurJSValue}
completions={jsCompletions}
{bindings}
mode={EditorModes.JS}
@ -155,7 +154,7 @@
placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping
dropdown={DropdownPosition.Absolute}
{dropdown}
/>
{/key}
</div>
@ -164,6 +163,7 @@
<style>
.code-panel {
display: flex;
height: 100%;
}
/* Editor */

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { Icon, Input, Drawer, Button, CoreTextArea } from "@budibase/bbui"
import { Icon, Input, Drawer, Button, TextArea } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
@ -67,7 +67,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control" class:multiline class:disabled class:scrollable>
<svelte:component
this={multiline ? CoreTextArea : Input}
this={multiline ? TextArea : Input}
{label}
{disabled}
readonly={isJS}
@ -78,18 +78,19 @@
{placeholder}
{updateOnChange}
{autocomplete}
/>
{#if !disabled && !disableBindings}
<div
class="icon"
on:click={() => {
builderStore.propertyFocus(key)
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
>
{#if !disabled && !disableBindings}
<div
class="icon"
on:click={() => {
builderStore.propertyFocus(key)
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</svelte:component>
</div>
<Drawer
on:drawerHide={onDrawerHide}

View File

@ -14,14 +14,15 @@
export let value = ""
export let bindings = []
export let title = "Bindings"
export let placeholder
export let label
export let placeholder = undefined
export let label = undefined
export let disabled = false
export let allowJS = true
export let allowHelpers = true
export let updateOnChange = true
export let type
export let schema
export let type = undefined
export let schema = undefined
export let allowHBS = true
export let context = {}
@ -230,7 +231,6 @@
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
@ -239,8 +239,6 @@
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);

View File

@ -80,5 +80,6 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
}
</style>

View File

@ -22,6 +22,7 @@ import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelt
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import TopLevelColumnEditor from "./controls/ColumnEditor/TopLevelColumnEditor.svelte"
import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
@ -62,7 +63,10 @@ const componentMap = {
stepConfiguration: FormStepConfiguration,
formStepControls: FormStepControls,
columns: ColumnEditor,
// "Basic" actually includes nested JSON and relationship fields
"columns/basic": BasicColumnEditor,
// "Top level" is only the top level schema fields
"columns/toplevel": TopLevelColumnEditor,
"columns/grid": GridColumnEditor,
tableConditions: TableConditionEditor,
"field/sortable": SortableFieldSelect,

View File

@ -145,7 +145,7 @@
<div class="column">
<div class="wide">
<Body size="S">
By default, all columns will automatically be shown.
The default column configuration will automatically be shown.
<br />
You can manually control which columns are included by adding them
below.

View File

@ -10,10 +10,18 @@
} from "@/dataBinding"
import { selectedScreen, tables } from "@/stores/builder"
export let componentInstance
const getSearchableFields = (schema, tableList) => {
return search.getFields(tableList, Object.values(schema || {}), {
allowLinks: true,
})
}
export let componentInstance = undefined
export let value = []
export let allowCellEditing = true
export let allowReorder = true
export let getSchemaFields = getSearchableFields
export let placeholder = "All columns"
const dispatch = createEventDispatcher()
@ -28,13 +36,7 @@
: enrichedSchemaFields?.map(field => field.name)
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
$: enrichedSchemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{
allowLinks: true,
}
)
$: enrichedSchemaFields = getSchemaFields(schema, $tables.list)
$: {
value = (value || []).filter(
@ -44,7 +46,7 @@
const getText = value => {
if (!value?.length) {
return "All columns"
return placeholder
}
let text = `${value.length} column`
if (value.length !== 1) {

View File

@ -0,0 +1,15 @@
<script lang="ts">
import ColumnEditor from "./ColumnEditor.svelte"
import type { TableSchema } from "@budibase/types"
const getTopLevelSchemaFields = (schema: TableSchema) => {
return Object.values(schema).filter(fieldSchema => !fieldSchema.nestedJSON)
}
</script>
<ColumnEditor
{...$$props}
on:change
allowCellEditing={false}
getSchemaFields={getTopLevelSchemaFields}
/>

View File

@ -7,15 +7,15 @@
readableToRuntimeBinding,
} from "@/dataBinding"
export let schemaFields
export let filters
export let schemaFields = undefined
export let filters = undefined
export let bindings = []
export let panel = ClientBindingPanel
export let allowBindings = true
export let allowOnEmpty
export let datasource
export let builderType
export let docsURL
export let allowOnEmpty = undefined
export let datasource = undefined
export let builderType = undefined
export let docsURL = undefined
export let evaluationContext = {}
</script>

View File

@ -15,24 +15,23 @@
const dispatch = createEventDispatcher()
export let defaults
export let defaults = undefined
export let object = defaults || {}
export let activity = {}
export let readOnly
export let noAddButton
export let name
export let readOnly = false
export let noAddButton = false
export let name = ""
export let headings = false
export let options
export let toggle
export let options = undefined
export let toggle = false
export let keyPlaceholder = "Key"
export let valuePlaceholder = "Value"
export let valueHeading
export let keyHeading
export let tooltip
export let menuItems
export let valueHeading = ""
export let keyHeading = ""
export let tooltip = ""
export let menuItems = []
export let showMenu = false
export let bindings = []
export let bindingDrawerLeft
export let allowHelpers = true
export let customButtonText = null
export let keyBindings = false
@ -140,7 +139,6 @@
value={field.name}
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
{context}
/>
{:else}
@ -167,7 +165,6 @@
value={field.value}
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
{context}
/>
{:else}

View File

@ -21,7 +21,6 @@
keyPlaceholder="Binding name"
valuePlaceholder="Default"
bindings={[...userBindings]}
bindingDrawerLeft="260px"
allowHelpers={false}
on:change
/>

View File

@ -582,7 +582,6 @@
...globalDynamicRequestBindings,
...dataSourceStaticBindings,
]}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Params">
@ -591,7 +590,6 @@
name="param"
headings
bindings={mergedBindings}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Headers">
@ -602,7 +600,6 @@
name="header"
headings
bindings={mergedBindings}
bindingDrawerLeft="260px"
/>
</Tab>
<Tab title="Body">

View File

@ -11,8 +11,7 @@
import { goto } from "@roxi/routify"
import { appStore, oauth2 } from "@/stores/builder"
import DetailPopover from "@/components/common/DetailPopover.svelte"
import { featureFlag } from "@/helpers"
import { FeatureFlag, RestAuthType } from "@budibase/types"
import { RestAuthType } from "@budibase/types"
import { onMount } from "svelte"
type Config = { label: string; value: string }
@ -57,15 +56,13 @@
$: title = !authConfig ? "Authentication" : `Auth: ${authConfig.label}`
$: oauth2Enabled = featureFlag.isEnabled(FeatureFlag.OAUTH2_CONFIG)
onMount(() => {
oauth2.fetch()
})
</script>
<DetailPopover bind:this={popover} {title} align={PopoverAlignment.Right}>
<div slot="anchor" class:display-new={!authConfig && oauth2Enabled}>
<div slot="anchor" class:display-new={!authConfig}>
<ActionButton icon="LockClosed" quiet selected>
{#if !authConfig}
Authentication
@ -96,25 +93,23 @@
>
</div>
{#if oauth2Enabled}
<Divider />
<Divider />
<Body size="S" color="var(--spectrum-global-color-gray-700)">
OAuth 2.0 (Token-Based Authentication)
</Body>
<Body size="S" color="var(--spectrum-global-color-gray-700)">
OAuth 2.0 (Token-Based Authentication)
</Body>
{#if $oauth2.configs.length}
<List>
{#each $oauth2.configs as config}
<ListItem
title={config.name}
on:click={() => selectConfiguration(config._id, RestAuthType.OAUTH2)}
selected={config._id === authConfigId}
/>
{/each}
</List>
{#if $oauth2.configs.length}
<List>
{#each $oauth2.configs as config}
<ListItem
title={config.name}
on:click={() =>
selectConfiguration(config._id, RestAuthType.OAUTH2)}
selected={config._id === authConfigId}
/>
{/each}
</List>
{/if}
<div>
<Button secondary icon="Add" on:click={addOAuth2Configuration}
>Add OAuth2</Button

View File

@ -71,4 +71,5 @@ export const AutoScreenTypes = {
BLANK: "blank",
TABLE: "table",
FORM: "form",
PDF: "pdf",
}

View File

@ -26,7 +26,7 @@ import {
getJsHelperList,
} from "@budibase/string-templates"
import { TableNames } from "./constants"
import { JSONUtils, Constants } from "@budibase/frontend-core"
import { JSONUtils, Constants, SchemaUtils } from "@budibase/frontend-core"
import ActionDefinitions from "@/components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "@/stores/portal"
import { convertOldFieldFormat } from "@/components/design/settings/controls/FieldConfiguration/utils"
@ -1026,25 +1026,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// Check for any JSON fields so we can add any top level properties
if (schema) {
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
const jsonSchema = JSONUtils.convertJSONSchemaToTableSchema(
fieldSchema,
{
squashObjects: true,
}
)
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
})
}
})
schema = { ...schema, ...jsonAdditions }
schema = SchemaUtils.addNestedJSONSchemaFields(schema)
}
// Determine if we should add ID and rev to the schema

View File

@ -3,8 +3,7 @@
import AutomationPanel from "@/components/automation/AutomationPanel/AutomationPanel.svelte"
import CreateAutomationModal from "@/components/automation/AutomationPanel/CreateAutomationModal.svelte"
import CreateWebhookModal from "@/components/automation/Shared/CreateWebhookModal.svelte"
import TestPanel from "@/components/automation/AutomationBuilder/TestPanel.svelte"
import { onDestroy, onMount } from "svelte"
import { onDestroy } from "svelte"
import { syncURLToState } from "@/helpers/urlStateSync"
import * as routify from "@roxi/routify"
import {
@ -12,8 +11,10 @@
automationStore,
selectedAutomation,
} from "@/stores/builder"
import StepPanel from "@/components/automation/AutomationBuilder/StepPanel.svelte"
$: automationId = $selectedAutomation?.data?._id
$: blockRefs = $selectedAutomation.blockRefs
$: builderStore.selectResource(automationId)
const stopSyncing = syncURLToState({
@ -29,15 +30,6 @@
let modal
let webhookModal
onMount(async () => {
await automationStore.actions.initAppSelf()
// Init the binding evaluation context
automationStore.actions.initContext()
$automationStore.showTestPanel = false
})
onDestroy(stopSyncing)
</script>
@ -70,15 +62,16 @@
{/if}
</div>
{#if $automationStore.showTestPanel}
<div class="setup">
<TestPanel automation={$selectedAutomation.data} />
{#if blockRefs[$automationStore.selectedNodeId] && $automationStore.selectedNodeId}
<div class="step-panel">
<StepPanel />
</div>
{/if}
<Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<Modal bind:this={webhookModal}>
<CreateWebhookModal />
</Modal>
</div>
@ -115,16 +108,17 @@
.main {
width: 300px;
}
.setup {
padding-top: 9px;
.step-panel {
border-left: var(--border-light);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-l);
background-color: var(--background);
grid-column: 3;
overflow: auto;
grid-column: 3;
width: 360px;
max-width: 360px;
}
</style>

View File

@ -16,6 +16,7 @@
import { getBindableProperties } from "@/dataBinding"
import BarButtonList from "@/components/design/settings/controls/BarButtonList.svelte"
import URLVariableTestInput from "@/components/design/settings/controls/URLVariableTestInput.svelte"
import { DrawerBindableInput } from "@/components/common/bindings"
$: bindings = getBindableProperties($selectedScreen, null)
$: screenSettings = getScreenSettings($selectedScreen)
@ -23,7 +24,59 @@
let errors = {}
const getScreenSettings = screen => {
let settings = [
// Determine correct screen settings for the top level component
let screenComponentSettings = []
switch ($selectedScreen.props._component) {
case "@budibase/standard-components/pdf":
screenComponentSettings = [
{
key: "props.fileName",
label: "PDF title",
defaultValue: "Report",
control: DrawerBindableInput,
},
{
key: "props.buttonText",
label: "Button text",
defaultValue: "Download PDF",
control: DrawerBindableInput,
},
]
break
default:
screenComponentSettings = [
{
key: "width",
label: "Width",
control: Select,
props: {
options: ["Extra small", "Small", "Medium", "Large", "Max"],
placeholder: "Default",
disabled: !!screen.layoutId,
},
},
{
key: "props.layout",
label: "Layout",
defaultValue: "flex",
control: BarButtonList,
props: {
options: [
{
barIcon: "ModernGridView",
value: "flex",
},
{
barIcon: "ViewGrid",
value: "grid",
},
],
},
},
]
}
return [
{
key: "routing.homeScreen",
control: Checkbox,
@ -66,34 +119,7 @@
label: "On screen load",
control: ButtonActionEditor,
},
{
key: "width",
label: "Width",
control: Select,
props: {
options: ["Extra small", "Small", "Medium", "Large", "Max"],
placeholder: "Default",
disabled: !!screen.layoutId,
},
},
{
key: "props.layout",
label: "Layout",
defaultValue: "flex",
control: BarButtonList,
props: {
options: [
{
barIcon: "ModernGridView",
value: "flex",
},
{
barIcon: "ViewGrid",
value: "grid",
},
],
},
},
...screenComponentSettings,
{
key: "urlTest",
control: URLVariableTestInput,
@ -102,8 +128,6 @@
},
},
]
return settings
}
const routeTaken = url => {

View File

@ -26,7 +26,9 @@
<div class="info">
<Icon name="InfoOutline" size="S" />
<Body size="S">These settings apply to all screens</Body>
<Body size="S">
These settings apply to all screens. PDFs are always light theme.
</Body>
</div>
<Layout noPadding gap="S">
<Layout noPadding gap="XS">

View File

@ -58,7 +58,7 @@
// Get initial set of allowed components
let allowedComponents = []
const definition = componentStore.getDefinition(component?._component)
if (definition.legalDirectChildren?.length) {
if (definition?.legalDirectChildren?.length) {
allowedComponents = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}`
})
@ -67,7 +67,7 @@
}
// Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || []
let illegalChildren = definition?.illegalChildren || []
path.forEach(ancestor => {
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
@ -144,11 +144,6 @@
}
})
// Swap blocks and plugins
let tmp = enrichedStructure[1]
enrichedStructure[1] = enrichedStructure[0]
enrichedStructure[0] = tmp
return enrichedStructure
}

View File

@ -20,9 +20,11 @@
"name": "Data",
"icon": "Data",
"children": [
"singlerowprovider",
"dataprovider",
"repeater",
"gridblock",
"pdftable",
"spreadsheet",
"dynamicfilter",
"daterangepicker"

View File

@ -1,10 +1,13 @@
<script>
import DevicePreviewSelect from "./DevicePreviewSelect.svelte"
import AppPreview from "./AppPreview.svelte"
import { screenStore, appStore } from "@/stores/builder"
import { screenStore, appStore, selectedScreen } from "@/stores/builder"
import UndoRedoControl from "@/components/common/UndoRedoControl.svelte"
import ScreenErrorsButton from "./ScreenErrorsButton.svelte"
import { Divider } from "@budibase/bbui"
import { ScreenVariant } from "@budibase/types"
$: isPDF = $selectedScreen?.variant === ScreenVariant.PDF
</script>
<div class="app-panel">
@ -14,10 +17,12 @@
<UndoRedoControl store={screenStore.history} />
</div>
<div class="header-right">
{#if $appStore.clientFeatures.devicePreview}
<DevicePreviewSelect />
{#if !isPDF}
{#if $appStore.clientFeatures.devicePreview}
<DevicePreviewSelect />
{/if}
<Divider vertical />
{/if}
<Divider vertical />
<ScreenErrorsButton />
</div>
</div>

View File

@ -53,7 +53,7 @@
// Otherwise choose a datasource
datasourceModal.show()
}
} else if (mode === AutoScreenTypes.BLANK) {
} else if (mode === AutoScreenTypes.BLANK || mode === AutoScreenTypes.PDF) {
screenDetailsModal.show()
} else {
throw new Error("Invalid mode provided")
@ -101,8 +101,11 @@
}
}
const createBlankScreen = async ({ route }) => {
const screenTemplates = screenTemplating.blank({ route, screens })
const createBasicScreen = async ({ route }) => {
const screenTemplates =
mode === AutoScreenTypes.BLANK
? screenTemplating.blank({ route, screens })
: screenTemplating.pdf({ route, screens })
const newScreens = await createScreens(screenTemplates)
loadNewScreen(newScreens[0])
}
@ -243,7 +246,7 @@
</Modal>
<Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal onConfirm={createBlankScreen} />
<ScreenDetailsModal onConfirm={createBasicScreen} />
</Modal>
<Modal bind:this={formTypeModal}>

View File

@ -0,0 +1,38 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1099_17726)">
<g clip-path="url(#clip1_1099_17726)" filter="url(#filter0_d_1099_17726)">
<rect width="118" height="65" fill="#D8B500"/>
<mask id="mask0_1099_17726" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="118" height="65">
<rect width="118" height="65" fill="white"/>
</mask>
<g mask="url(#mask0_1099_17726)">
</g>
</g>
<rect x="38.0762" y="22.3271" width="41.6766" height="42.6718" fill="white" fill-opacity="0.5"/>
<rect x="23.1543" y="11.4121" width="71.3021" height="53.5878" fill="url(#paint0_linear_1099_17726)"/>
<rect x="23.6543" y="11.9121" width="70.3021" height="52.5878" stroke="white" stroke-opacity="0.2"/>
<path d="M44.1365 45.7637V34.0637H48.4205C49.2725 34.0637 50.0585 34.1837 50.7785 34.4237C51.4985 34.6637 52.0745 35.0657 52.5065 35.6297C52.9505 36.1937 53.1725 36.9677 53.1725 37.9517C53.1725 38.8997 52.9505 39.6797 52.5065 40.2917C52.0745 40.8917 51.4985 41.3357 50.7785 41.6237C50.0705 41.9117 49.3085 42.0557 48.4925 42.0557H47.2325V45.7637H44.1365ZM47.2325 39.6077H48.3485C48.9605 39.6077 49.4105 39.4637 49.6985 39.1757C49.9985 38.8757 50.1485 38.4677 50.1485 37.9517C50.1485 37.4237 49.9865 37.0517 49.6625 36.8357C49.3385 36.6197 48.8765 36.5117 48.2765 36.5117H47.2325V39.6077ZM55.0877 45.7637V34.0637H58.5437C59.7317 34.0637 60.7757 34.2617 61.6757 34.6577C62.5877 35.0417 63.2957 35.6597 63.7997 36.5117C64.3037 37.3637 64.5557 38.4797 64.5557 39.8597C64.5557 41.2397 64.3037 42.3677 63.7997 43.2437C63.2957 44.1077 62.6057 44.7437 61.7297 45.1517C60.8537 45.5597 59.8517 45.7637 58.7237 45.7637H55.0877ZM58.1837 43.2797H58.3637C58.9277 43.2797 59.4377 43.1837 59.8937 42.9917C60.3497 42.7997 60.7097 42.4577 60.9737 41.9657C61.2497 41.4737 61.3877 40.7717 61.3877 39.8597C61.3877 38.9477 61.2497 38.2577 60.9737 37.7897C60.7097 37.3097 60.3497 36.9857 59.8937 36.8177C59.4377 36.6377 58.9277 36.5477 58.3637 36.5477H58.1837V43.2797ZM66.6365 45.7637V34.0637H74.2685V36.6557H69.7325V38.8877H73.6205V41.4797H69.7325V45.7637H66.6365Z" fill="white"/>
</g>
<defs>
<filter id="filter0_d_1099_17726" x="-10" y="-6" width="138" height="85" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1099_17726"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1099_17726" result="shape"/>
</filter>
<linearGradient id="paint0_linear_1099_17726" x1="23.1543" y1="11.4121" x2="94.0187" y2="65.5726" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_1099_17726">
<rect width="118" height="65" fill="white"/>
</clipPath>
<clipPath id="clip1_1099_17726">
<rect width="118" height="65" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -4,8 +4,10 @@
import blank from "./images/blank.svg"
import table from "./images/tableInline.svg"
import form from "./images/formUpdate.svg"
import pdf from "./images/pdf.svg"
import CreateScreenModal from "./CreateScreenModal.svelte"
import { screenStore } from "@/stores/builder"
import { AutoScreenTypes } from "@/constants"
export let onClose = null
@ -27,32 +29,54 @@
</div>
<div class="cards">
<div class="card" on:click={() => createScreenModal.show("blank")}>
<div
class="card"
on:click={() => createScreenModal.show(AutoScreenTypes.BLANK)}
>
<div class="image">
<img alt="A blank screen" src={blank} />
</div>
<div class="text">
<Body size="S">Blank</Body>
<Body size="M">Blank</Body>
<Body size="XS">Add an empty blank screen</Body>
</div>
</div>
<div class="card" on:click={() => createScreenModal.show("table")}>
<div
class="card"
on:click={() => createScreenModal.show(AutoScreenTypes.TABLE)}
>
<div class="image">
<img alt="A table of data" src={table} />
</div>
<div class="text">
<Body size="S">Table</Body>
<Body size="M">Table</Body>
<Body size="XS">List rows in a table</Body>
</div>
</div>
<div class="card" on:click={() => createScreenModal.show("form")}>
<div
class="card"
on:click={() => createScreenModal.show(AutoScreenTypes.PDF)}
>
<div class="image">
<img alt="A form containing data" src={pdf} width="185" />
</div>
<div class="text">
<Body size="M">PDF</Body>
<Body size="XS">Create, edit and export your PDF</Body>
</div>
</div>
<div
class="card"
on:click={() => createScreenModal.show(AutoScreenTypes.FORM)}
>
<div class="image">
<img alt="A form containing data" src={form} />
</div>
<div class="text">
<Body size="S">Form</Body>
<Body size="M">Form</Body>
<Body size="XS">Capture data from your users</Body>
</div>
</div>
@ -111,14 +135,13 @@
.text {
border: 1px solid var(--grey-4);
border-radius: 0 0 4px 4px;
padding: 8px 16px 13px 16px;
}
.text :global(p:nth-child(1)) {
margin-bottom: 6px;
padding: 12px 16px 12px 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
.text :global(p:nth-child(2)) {
color: var(--grey-6);
color: var(--spectrum-global-color-gray-600);
}
</style>

View File

@ -4,12 +4,8 @@
import { url, isActive } from "@roxi/routify"
import DeleteModal from "@/components/deploy/DeleteModal.svelte"
import { isOnlyUser, appStore } from "@/stores/builder"
import { featureFlag } from "@/helpers"
import { FeatureFlag } from "@budibase/types"
let deleteModal: DeleteModal
$: oauth2Enabled = featureFlag.isEnabled(FeatureFlag.OAUTH2_CONFIG)
</script>
<!-- routify:options index=4 -->
@ -48,13 +44,11 @@
url={$url("./version")}
active={$isActive("./version")}
/>
{#if oauth2Enabled}
<SideNavItem
text="OAuth2"
url={$url("./oauth2")}
active={$isActive("./oauth2")}
/>
{/if}
<SideNavItem
text="OAuth2"
url={$url("./oauth2")}
active={$isActive("./oauth2")}
/>
<SideNavItem
text="App scripts"
url={$url("./scripts")}

View File

@ -147,6 +147,13 @@
await automationStore.actions.fetch()
const params = new URLSearchParams(window.location.search)
const shouldOpen = params.get("open") === ERROR
const defaultAutoId = params.get("automationId")
const defaultAuto = $automationStore.automations.find(
auto => auto._id === defaultAutoId || undefined
)
automationId = defaultAuto?._id || undefined
if (shouldOpen) {
status = ERROR
}

View File

@ -20,6 +20,7 @@
import { API } from "@/api"
import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
import { getFormattedPlanName } from "@/helpers/planTitle"
$: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
@ -260,7 +261,11 @@
<Layout gap="XS" noPadding>
<Heading size="XS">Plan</Heading>
<Layout noPadding gap="S">
<Body size="S">You are currently on the {license.plan.type} plan</Body>
<Body size="S"
>You are currently on the <b
>{getFormattedPlanName(license.plan.type)}</b
></Body
>
<div>
<Body size="S"
>If you purchase or update your plan on the account</Body

File diff suppressed because it is too large Load Diff

View File

@ -4,22 +4,24 @@ import { Helpers } from "@budibase/bbui"
import { RoleUtils, Utils } from "@budibase/frontend-core"
import { findAllMatchingComponents } from "@/helpers/components"
import {
layoutStore,
appStore,
componentStore,
layoutStore,
navigationStore,
previewStore,
selectedComponent,
} from "@/stores/builder"
import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import {
FetchAppPackageResponse,
DeleteScreenResponse,
Screen,
Component,
SaveScreenResponse,
ComponentDefinition,
DeleteScreenResponse,
FetchAppPackageResponse,
SaveScreenResponse,
Screen,
ScreenVariant,
} from "@budibase/types"
interface ScreenState {
@ -115,6 +117,14 @@ export class ScreenStore extends BudiStore<ScreenState> {
state.selectedScreenId = screen._id
return state
})
// If this is a PDF screen, ensure we're on desktop
if (
screen.variant === ScreenVariant.PDF &&
get(previewStore).previewDevice !== "desktop"
) {
previewStore.setDevice("desktop")
}
}
/**

View File

@ -1,5 +1,6 @@
import { BaseStructure } from "../BaseStructure"
import { Helpers } from "@budibase/bbui"
import { ScreenVariant } from "@budibase/types"
export class Screen extends BaseStructure {
constructor() {
@ -81,3 +82,25 @@ export class Screen extends BaseStructure {
return this
}
}
export class PDFScreen extends Screen {
constructor() {
super()
this._json.variant = ScreenVariant.PDF
this._json.width = "Max"
this._json.showNavigation = false
this._json.props = {
_id: Helpers.uuid(),
_component: "@budibase/standard-components/pdf",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_children: [],
_instanceName: "PDF",
title: "PDF",
}
}
}

View File

@ -1,3 +1,4 @@
export { default as blank } from "./blank"
export { default as form } from "./form"
export { default as table } from "./table"
export { default as pdf } from "./pdf"

View File

@ -0,0 +1,20 @@
import { PDFScreen } from "./Screen"
import { capitalise } from "@/helpers"
import getValidRoute from "./getValidRoute"
import { Roles } from "@/constants/backend"
const pdf = ({ route, screens }) => {
const validRoute = getValidRoute(screens, route, Roles.BASIC)
const template = new PDFScreen().role(Roles.BASIC).route(validRoute).json()
return [
{
data: template,
navigationLinkLabel:
validRoute === "/" ? null : capitalise(validRoute.split("/")[1]),
},
]
}
export default pdf

View File

@ -0,0 +1,213 @@
import {
type AutomationStep,
type AutomationStepInputs,
type AutomationTrigger,
type AutomationTriggerInputs,
type BaseIOStructure,
type UISearchFilter,
type TestAutomationResponse,
type AppSelfResponse,
type Automation,
type BlockDefinitions,
type BlockRef,
AutomationActionStepId,
AutomationTriggerStepId,
AutomationCustomIOType,
AutomationIOType,
} from "@budibase/types"
import { SvelteComponent } from "svelte"
export enum DataMode {
INPUT = "data_in",
OUTPUT = "data_out",
ERRORS = "errors",
}
export enum SchemaFieldTypes {
JSON = "json",
ENUM = "enum",
BOOL = "boolean",
DATE = "date",
FILE = "file",
FILTER = "filter",
CRON = "cron",
FIELDS = "fields",
TABLE = "table",
COLUMN = "column",
AUTOMATION_FIELDS = "automation_fields",
WEBHOOK_URL = "webhook_url",
TRIGGER_SCHEMA = "trigger_schema",
LOOP_OPTION = "loop_option",
CODE = "code",
CODE_V2 = "code_v2",
STRING = "string",
QUERY_PARAMS = "query_params",
QUERY_LIMIT = "query_limit",
}
export type KeyValuePair = {
name: string
value: string
}
// Required for filter fields. The have a definition entry
// as well as a configuration
export type DynamicProperties = {
[property: `${string}-def`]: UISearchFilter
}
// Form field update by property key
export type FormUpdate = Record<string, unknown>
export type AutomationSchemaConfig = Record<SchemaFieldTypes, SchemaConfigProps>
export type FieldProps = {
key: string
field: BaseIOStructure
block: AutomationStep | AutomationTrigger
value: any
meta: any
title: string
}
// Unused for the moment
export type InputMeta = {
meta?: AutomationStepInputMeta<AutomationActionStepId>
}
// This is still Attachment Specific and technically reusable.
export type FileSelectorMeta = { useAttachmentBinding: boolean }
export type AutomationStepInputMeta<T extends AutomationActionStepId> =
T extends AutomationActionStepId.SEND_EMAIL_SMTP
? { meta: FileSelectorMeta }
: { meta?: Record<string, unknown> }
export type StepInputs =
| AutomationStepInputs<AutomationActionStepId> //& InputMeta
| AutomationTriggerInputs<AutomationTriggerStepId> //& InputMeta
| undefined
export const RowTriggers = [
AutomationTriggerStepId.ROW_UPDATED,
AutomationTriggerStepId.ROW_SAVED,
AutomationTriggerStepId.ROW_DELETED,
AutomationTriggerStepId.ROW_ACTION,
]
export const RowSteps = [
AutomationActionStepId.CREATE_ROW,
AutomationActionStepId.UPDATE_ROW,
AutomationActionStepId.DELETE_ROW,
]
export const FilterableRowTriggers = [
AutomationTriggerStepId.ROW_UPDATED,
AutomationTriggerStepId.ROW_SAVED,
]
/**
* Used to define how to represent automation setting
* forms
*/
export interface SchemaConfigProps {
comp: typeof SvelteComponent<any>
onChange?: (e: CustomEvent) => void
props?: (opts?: FieldProps) => Record<string, unknown>
fullWidth?: boolean
title?: string
tooltip?: string
wrapped?: boolean
}
export interface AutomationState {
automations: Automation[]
testResults?: TestAutomationResponse
showTestModal: boolean
blockDefinitions: BlockDefinitions
selectedAutomationId: string | null
appSelf?: AppSelfResponse
selectedNodeId?: string
selectedNodeMode?: DataMode
}
export interface DerivedAutomationState extends AutomationState {
data?: Automation
blockRefs: Record<string, BlockRef>
}
/**
* BlockProperties - Direct mapping of customType to SchemaFieldTypes
*/
export const customTypeToSchema: Record<string, SchemaFieldTypes> = {
[AutomationCustomIOType.TRIGGER_SCHEMA]: SchemaFieldTypes.TRIGGER_SCHEMA,
[AutomationCustomIOType.TABLE]: SchemaFieldTypes.TABLE,
[AutomationCustomIOType.COLUMN]: SchemaFieldTypes.COLUMN,
[AutomationCustomIOType.CRON]: SchemaFieldTypes.CRON,
[AutomationCustomIOType.LOOP_OPTION]: SchemaFieldTypes.LOOP_OPTION,
[AutomationCustomIOType.AUTOMATION_FIELDS]:
SchemaFieldTypes.AUTOMATION_FIELDS,
[AutomationCustomIOType.WEBHOOK_URL]: SchemaFieldTypes.WEBHOOK_URL,
[AutomationCustomIOType.QUERY_LIMIT]: SchemaFieldTypes.QUERY_LIMIT,
["fields"]: SchemaFieldTypes.FIELDS,
}
/**
* BlockProperties - Direct mapping of type to SchemaFieldTypes
*/
export const typeToSchema: Partial<Record<AutomationIOType, SchemaFieldTypes>> =
{
[AutomationIOType.BOOLEAN]: SchemaFieldTypes.BOOL,
[AutomationIOType.DATE]: SchemaFieldTypes.DATE,
[AutomationIOType.JSON]: SchemaFieldTypes.JSON,
[AutomationIOType.ATTACHMENT]: SchemaFieldTypes.FILE,
}
/**
* FlowItem - Status type enums for grading issues
*/
export enum FlowStatusType {
INFO = "info",
WARN = "warn",
ERROR = "error",
SUCCESS = "success",
}
/**
* FlowItemStatus - Message structure for building information
* pills that hover above the steps in the automation tree
*/
export type FlowItemStatus = {
message: string
icon: string
type: FlowStatusType
tooltip?: string
}
/**
* BlockData - Status type designation for errors or issues arising from
* test runs or other automation concerns
*/
export enum BlockStatusType {
INFO = "info",
WARN = "warn",
ERROR = "error",
}
/**
* BlockData - Source type used to discern the source of the issues
*/
export enum BlockStatusSource {
AUTOMATION_RESULTS = "results",
VALIDATION = "validation",
}
/**
* BlockData - Message structure for building out issues to be displayed
* in the Step Panel in automations
*/
export type BlockStatus = {
message: string
type: BlockStatusType
source?: BlockStatusSource
}

View File

@ -1,6 +1,7 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"lib": ["ESNext"],
"baseUrl": ".",

View File

@ -8017,6 +8017,32 @@
"key": "text",
"wide": true
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "14px",
"showInBar": true,
"placeholder": "Default",
"options": [
{
"label": "Small",
"value": "12px"
},
{
"label": "Medium",
"value": "14px"
},
{
"label": "Large",
"value": "18px"
},
{
"label": "Extra large",
"value": "24px"
}
]
},
{
"type": "select",
"label": "Alignment",
@ -8058,5 +8084,133 @@
"showInBar": true
}
]
},
"pdf": {
"name": "PDF Generator",
"icon": "Document",
"hasChildren": true,
"showEmptyState": false,
"illegalChildren": ["sidepanel", "modal", "gridblock"],
"grid": {
"hAlign": "center",
"vAlign": "start"
},
"size": {
"width": 800,
"height": 1200
},
"description": "A component to render PDFs from other Budibase components",
"settings": [
{
"type": "text",
"label": "PDF title",
"key": "fileName",
"defaultValue": "Report"
},
{
"type": "text",
"label": "Button text",
"key": "buttonText",
"defaultValue": "Download PDF"
}
]
},
"pdftable": {
"name": "PDF Table",
"icon": "Table",
"styles": ["size"],
"size": {
"width": 600,
"height": 304
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [
{
"type": "dataSource",
"label": "Data",
"key": "datasource",
"required": true
},
{
"type": "filter",
"label": "Filtering",
"key": "filter",
"resetOn": "datasource",
"dependsOn": {
"setting": "datasource.type",
"value": "custom",
"invert": true
}
},
{
"type": "field/sortable",
"label": "Sort column",
"key": "sortColumn",
"placeholder": "Default",
"resetOn": "datasource",
"dependsOn": {
"setting": "datasource.type",
"value": "custom",
"invert": true
}
},
{
"type": "select",
"label": "Sort order",
"key": "sortOrder",
"resetOn": "datasource",
"options": ["Ascending", "Descending"],
"defaultValue": "Ascending",
"dependsOn": "sortColumn"
},
{
"type": "columns/toplevel",
"label": "Columns",
"key": "columns",
"resetOn": "datasource",
"placeholder": "First 3 columns"
},
{
"type": "number",
"label": "Limit",
"key": "limit",
"defaultValue": 10
}
]
},
"singlerowprovider": {
"name": "Single Row Provider",
"icon": "SQLQuery",
"hasChildren": true,
"actions": ["RefreshDatasource"],
"size": {
"width": 500,
"height": 200
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [
{
"type": "table",
"label": "Datasource",
"key": "datasource",
"required": true
},
{
"type": "text",
"label": "Row ID",
"key": "rowId",
"required": true,
"resetOn": "datasource"
}
],
"context": {
"type": "schema"
}
}
}

View File

@ -29,6 +29,7 @@
"apexcharts": "^3.48.0",
"dayjs": "^1.10.8",
"downloadjs": "1.4.7",
"html2pdf.js": "^0.9.3",
"html5-qrcode": "^2.3.8",
"leaflet": "^1.7.1",
"sanitize-html": "^2.13.0",

View File

@ -1,20 +1,20 @@
<script>
import { getContext, onDestroy } from "svelte"
import { generate } from "shortid"
import { builderStore } from "../stores/builder"
import { builderStore } from "@/stores/builder"
import Component from "@/components/Component.svelte"
export let type
export let props
export let styles
export let context
export let name
export let props = undefined
export let styles = undefined
export let context = undefined
export let name = undefined
export let order = 0
export let containsSlot = false
// ID is only exposed as a prop so that it can be bound to from parent
// block components
export let id
export let id = undefined
const component = getContext("component")
const block = getContext("block")

View File

@ -196,8 +196,6 @@
}
// Metadata to pass into grid action to apply CSS
const checkGrid = x =>
x?._component?.endsWith("/container") && x?.layout === "grid"
$: insideGrid = checkGrid(parent)
$: isGrid = checkGrid(instance)
$: gridMetadata = {
@ -601,6 +599,18 @@
}
}
const checkGrid = x => {
// Check for a grid container
if (x?._component?.endsWith("/container") && x?.layout === "grid") {
return true
}
// Check for a PDF (always grid)
if (x?._component?.endsWith("/pdf")) {
return true
}
return false
}
onMount(() => {
// Register this component instance for external access
if ($appStore.isDevApp) {

View File

@ -1,12 +1,18 @@
<script>
import { themeStore } from "@/stores"
import { setContext } from "svelte"
import { Context } from "@budibase/bbui"
import { Context, Helpers } from "@budibase/bbui"
setContext(Context.PopoverRoot, "#theme-root")
export let popoverRoot = true
const id = Helpers.uuid()
if (popoverRoot) {
setContext(Context.PopoverRoot, `#id`)
}
</script>
<div style={$themeStore.customThemeCss} id="theme-root">
<div style={$themeStore.customThemeCss} {id}>
<slot />
</div>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import { getContext } from "svelte"
import type { Row, TableDatasource, ViewDatasource } from "@budibase/types"
export let datasource: TableDatasource | ViewDatasource
export let rowId: string
const component = getContext("component")
const { styleable, API, Provider, ActionTypes } = getContext("sdk")
let row: Row | undefined
$: datasourceId =
datasource.type === "table" ? datasource.tableId : datasource.id
$: fetchRow(datasourceId, rowId)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchRow(datasourceId, rowId),
metadata: { dataSource: datasource },
},
]
const fetchRow = async (datasourceId: string, rowId: string) => {
try {
row = await API.fetchRow(datasourceId, rowId)
} catch (e) {
row = undefined
}
}
</script>
<div use:styleable={$component.styles}>
<Provider {actions} data={row ?? null}>
<slot />
</Provider>
</div>
<style>
div {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -2,23 +2,29 @@
import { getContext } from "svelte"
import { MarkdownViewer } from "@budibase/bbui"
export let text: string = ""
export let text: any = ""
export let color: string | undefined = undefined
export let align: "left" | "center" | "right" | "justify" = "left"
export let size: string | undefined = "14px"
const component = getContext("component")
const { styleable } = getContext("sdk")
// Add in certain settings to styles
$: styles = enrichStyles($component.styles, color, align)
$: styles = enrichStyles($component.styles, color, align, size)
// Ensure we're always passing in a string value to the markdown editor
$: safeText = stringify(text)
const enrichStyles = (
styles: any,
colorStyle: typeof color,
alignStyle: typeof align
alignStyle: typeof align,
size: string | undefined
) => {
let additions: Record<string, string> = {
"text-align": alignStyle,
"font-size": size || "14px",
}
if (colorStyle) {
additions.color = colorStyle
@ -31,10 +37,24 @@
},
}
}
const stringify = (text: any): string => {
if (text == null) {
return ""
}
if (typeof text !== "string") {
try {
return JSON.stringify(text)
} catch (e) {
return ""
}
}
return text
}
</script>
<div use:styleable={styles}>
<MarkdownViewer value={text} />
<MarkdownViewer value={safeText} />
</div>
<style>

View File

@ -135,12 +135,18 @@
use:styleable={$styles}
data-cols={GridColumns}
data-col-size={colSize}
data-required-rows={requiredRows}
on:click={onClick}
>
{#if inBuilder}
<div class="underlay">
{#each { length: GridColumns * rows } as _, idx}
<div class="placeholder" class:first-col={idx % GridColumns === 0} />
<div class="underlay-h">
{#each { length: rows } as _}
<div class="placeholder-h" />
{/each}
</div>
<div class="underlay-v">
{#each { length: GridColumns } as _}
<div class="placeholder-v" />
{/each}
</div>
{/if}
@ -151,7 +157,8 @@
<style>
.grid,
.underlay {
.underlay-h,
.underlay-v {
height: var(--height) !important;
min-height: var(--min-height) !important;
max-height: none !important;
@ -161,37 +168,45 @@
grid-template-columns: repeat(var(--cols), calc(var(--col-size) * 1px));
position: relative;
}
.underlay {
.clickable {
cursor: pointer;
}
/* Underlay grid lines */
.underlay-h,
.underlay-v {
z-index: 0;
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-top: 1px solid var(--spectrum-global-color-gray-900);
opacity: 0.1;
pointer-events: none;
}
.underlay {
z-index: 0;
}
.placeholder {
.placeholder-h {
border-bottom: 1px solid var(--spectrum-global-color-gray-900);
grid-column: 1 / -1;
}
.placeholder-h:first-child {
border-top: 1px solid var(--spectrum-global-color-gray-900);
}
.placeholder-v {
border-right: 1px solid var(--spectrum-global-color-gray-900);
grid-row: 1 / -1;
}
.placeholder.first-col {
.placeholder-v:first-child {
border-left: 1px solid var(--spectrum-global-color-gray-900);
}
.clickable {
cursor: pointer;
}
/* Highlight grid lines when resizing children */
:global(.grid.highlight > .underlay) {
:global(.grid.highlight > .underlay-h),
:global(.grid.highlight > .underlay-v) {
display: grid;
}
/* Highlight sibling borders when resizing childern */
/* Highlight sibling borders when resizing children */
:global(.grid.highlight > .component:not(.dragging)) {
outline: 2px solid var(--spectrum-global-color-static-blue-200);
pointer-events: none !important;

View File

@ -36,10 +36,12 @@ export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte"
export { default as textv2 } from "./Text.svelte"
export { default as singlerowprovider } from "./SingleRowProvider.svelte"
export * from "./charts"
export * from "./forms"
export * from "./blocks"
export * from "./dynamic-filter"
export * from "./pdf"
// Deprecated component left for compatibility in old apps
export * from "./deprecated/table"

View File

@ -0,0 +1,191 @@
<script lang="ts">
import { getContext, onMount, tick } from "svelte"
import { Heading, Button } from "@budibase/bbui"
import { htmlToPdf, pxToPt, A4HeightPx, type PDFOptions } from "./pdf"
import { GridRowHeight } from "@/constants"
import CustomThemeWrapper from "@/components/CustomThemeWrapper.svelte"
const component = getContext("component")
const { styleable, Block, BlockComponent } = getContext("sdk")
export let fileName: string | undefined
export let buttonText: string | undefined
// Derive dimension calculations
const DesiredRows = 40
const innerPageHeightPx = GridRowHeight * DesiredRows
const doubleMarginPx = A4HeightPx - innerPageHeightPx
const marginPt = pxToPt(doubleMarginPx / 2)
let rendering = false
let pageCount = 1
let ref: HTMLElement
let gridRef: HTMLElement
$: safeName = fileName || "Report"
$: safeButtonText = buttonText || "Download PDF"
$: heightPx = pageCount * innerPageHeightPx + doubleMarginPx
$: pageStyle = `--height:${heightPx}px; --margin:${marginPt}pt;`
$: gridMinHeight = pageCount * DesiredRows * GridRowHeight
const generatePDF = async () => {
rendering = true
await tick()
preprocessCSS()
try {
const opts: PDFOptions = {
fileName: safeName,
marginPt,
footer: true,
}
await htmlToPdf(ref, opts)
} catch (error) {
console.error("Error rendering PDF", error)
}
rendering = false
}
const preprocessCSS = () => {
const els = document.getElementsByClassName("grid-child")
for (let el of els) {
if (!(el instanceof HTMLElement)) {
return
}
// Get the computed values and assign them back to the style, simplifying
// the CSS that gets handled by HTML2PDF
const styles = window.getComputedStyle(el)
el.style.setProperty("grid-column-end", styles.gridColumnEnd, "important")
}
}
const getDividerStyle = (idx: number) => {
const top = (idx + 1) * innerPageHeightPx + doubleMarginPx / 2
return `--idx:"${idx + 1}"; --top:${top}px;`
}
const handleGridMutation = () => {
const rows = parseInt(gridRef.dataset.requiredRows || "1")
const nextPageCount = Math.max(1, Math.ceil(rows / DesiredRows))
if (nextPageCount > pageCount || !gridRef.classList.contains("highlight")) {
pageCount = nextPageCount
}
}
onMount(() => {
// Observe required content rows and use this to determine required pages
const gridDOMID = `${$component.id}-grid-dom`
gridRef = document.getElementsByClassName(gridDOMID)[0] as HTMLElement
const mutationObserver = new MutationObserver(handleGridMutation)
mutationObserver.observe(gridRef, {
attributes: true,
attributeFilter: ["data-required-rows", "class"],
})
return () => {
mutationObserver.disconnect()
}
})
</script>
<Block>
<div class="wrapper" style="--margin:{marginPt}pt;">
<div class="container" use:styleable={$component.styles}>
<div class="title">
<Heading size="M">{safeName}</Heading>
<Button disabled={rendering} cta on:click={generatePDF}>
{safeButtonText}
</Button>
</div>
<div class="page" style={pageStyle}>
{#if pageCount > 1}
{#each { length: pageCount } as _, idx}
<div
class="divider"
class:last={idx === pageCount - 1}
style={getDividerStyle(idx)}
/>
{/each}
{/if}
<div
class="spectrum spectrum--medium spectrum--light pageContent"
bind:this={ref}
>
<CustomThemeWrapper popoverRoot={false}>
<BlockComponent
type="container"
props={{ layout: "grid" }}
styles={{
normal: {
height: `${gridMinHeight}px`,
},
}}
context="grid"
>
<slot />
</BlockComponent>
</CustomThemeWrapper>
</div>
</div>
</div>
</div>
</Block>
<style>
.wrapper {
width: 100%;
height: 100%;
padding: 64px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
}
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
width: 595.28pt;
gap: var(--spacing-xl);
align-self: center;
}
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.page {
width: 595.28pt;
min-height: var(--height);
padding: var(--margin);
background-color: white;
flex: 0 0 auto;
display: flex;
justify-content: flex-start;
align-items: stretch;
box-shadow: 2px 2px 10px 0 rgba(0, 0, 0, 0.1);
flex-direction: column;
margin: 0 auto;
position: relative;
}
.pageContent {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
background: white;
}
.divider {
width: 100%;
height: 2px;
background: var(--spectrum-global-color-static-gray-400);
position: absolute;
left: 0;
top: var(--top);
transform: translateY(-50%);
}
.divider.last {
top: calc(var(--top) + var(--margin));
background: transparent;
}
</style>

View File

@ -0,0 +1,143 @@
<script lang="ts">
import type {
DataFetchDatasource,
FieldSchema,
GroupUserDatasource,
SortOrder,
TableSchema,
UISearchFilter,
UserDatasource,
} from "@budibase/types"
import { fetchData, QueryUtils, stringifyRow } from "@budibase/frontend-core"
import { getContext } from "svelte"
type ProviderDatasource = Exclude<
DataFetchDatasource,
UserDatasource | GroupUserDatasource
>
type ChosenColumns = Array<{ name: string; displayName?: string }> | undefined
type Schema = { [key: string]: FieldSchema & { displayName: string } }
export let datasource: ProviderDatasource
export let filter: UISearchFilter | undefined = undefined
export let sortColumn: string | undefined = undefined
export let sortOrder: SortOrder | undefined = undefined
export let columns: ChosenColumns = undefined
export let limit: number = 20
const component = getContext("component")
const { styleable, API } = getContext("sdk")
$: query = QueryUtils.buildQuery(filter)
$: fetch = createFetch(datasource)
$: fetch.update({
query,
sortColumn,
sortOrder,
limit,
})
$: schema = sanitizeSchema($fetch.schema, columns)
$: columnCount = Object.keys(schema).length
$: rowCount = $fetch.rows?.length || 0
$: stringifiedRows = ($fetch?.rows || []).map(row =>
stringifyRow(row, schema)
)
const createFetch = (datasource: ProviderDatasource) => {
return fetchData({
API,
datasource,
options: {
query,
sortColumn,
sortOrder,
limit,
paginate: false,
},
})
}
const sanitizeSchema = (
schema: TableSchema | null,
columns: ChosenColumns
): Schema => {
if (!schema) {
return {}
}
let sanitized: Schema = {}
// Clean out hidden fields and ensure we have
Object.entries(schema).forEach(([field, fieldSchema]) => {
if (fieldSchema.visible !== false) {
sanitized[field] = {
...fieldSchema,
displayName: field,
}
}
})
// Clean out unselected columns.
// Default to first 3 columns if none specified, as we are width contrained.
if (!columns?.length) {
columns = Object.values(sanitized).slice(0, 3)
}
let pruned: Schema = {}
for (let col of columns) {
if (sanitized[col.name]) {
pruned[col.name] = {
...sanitized[col.name],
displayName: col.displayName || sanitized[col.name].displayName,
}
}
}
sanitized = pruned
return sanitized
}
</script>
<div class="vars" style="--cols:{columnCount}; --rows:{rowCount};">
<div class="table" class:valid={!!schema} use:styleable={$component.styles}>
{#if schema}
{#each Object.keys(schema) as col}
<div class="cell header">{schema[col].displayName}</div>
{/each}
{#each stringifiedRows as row}
{#each Object.keys(schema) as col}
<div class="cell">{row[col]}</div>
{/each}
{/each}
{/if}
</div>
</div>
<style>
.vars {
display: contents;
--border-color: var(--spectrum-global-color-gray-300);
}
.table {
display: grid;
grid-template-columns: repeat(var(--cols), minmax(40px, auto));
grid-template-rows: repeat(var(--rows), max-content);
overflow: hidden;
background: var(--spectrum-global-color-gray-50);
}
.table.valid {
border-left: 1px solid var(--border-color);
border-top: 1px solid var(--border-color);
}
.cell {
border-right: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-xs) var(--spacing-s);
overflow: hidden;
word-break: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.cell.header {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,2 @@
export { default as pdf } from "./PDF.svelte"
export { default as pdftable } from "./PDFTable.svelte"

Some files were not shown because too many files have changed in this diff Show More