Merge remote-tracking branch 'origin/feature/app-list-actions' into feature/app-favourites

This commit is contained in:
Dean 2024-03-15 10:01:38 +00:00
commit b8277fc60f
61 changed files with 1273 additions and 300 deletions

View File

@ -6,6 +6,7 @@ packages/server/coverage
packages/worker/coverage packages/worker/coverage
packages/backend-core/coverage packages/backend-core/coverage
packages/server/client packages/server/client
packages/server/coverage
packages/builder/.routify packages/builder/.routify
packages/sdk/sdk packages/sdk/sdk
packages/account-portal/packages/server/build packages/account-portal/packages/server/build

View File

@ -10,7 +10,7 @@ import {
StaticDatabases, StaticDatabases,
DEFAULT_TENANT_ID, DEFAULT_TENANT_ID,
} from "../constants" } from "../constants"
import { Database, IdentityContext } from "@budibase/types" import { Database, IdentityContext, Snippet, App } from "@budibase/types"
import { ContextMap } from "./types" import { ContextMap } from "./types"
let TEST_APP_ID: string | null = null let TEST_APP_ID: string | null = null
@ -122,10 +122,10 @@ export async function doInAutomationContext<T>(params: {
automationId: string automationId: string
task: () => T task: () => T
}): Promise<T> { }): Promise<T> {
const tenantId = getTenantIDFromAppID(params.appId) await ensureSnippetContext()
return newContext( return newContext(
{ {
tenantId, tenantId: getTenantIDFromAppID(params.appId),
appId: params.appId, appId: params.appId,
automationId: params.automationId, automationId: params.automationId,
}, },
@ -281,6 +281,27 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export async function ensureSnippetContext() {
const ctx = getCurrentContext()
// If we've already added snippets to context, continue
if (!ctx || ctx.snippets) {
return
}
// Otherwise get snippets for this app and update context
let snippets: Snippet[] | undefined
const db = getAppDB()
if (db && !env.isTest()) {
const app = await db.get<App>(DocumentType.APP_METADATA)
snippets = app.snippets
}
// Always set snippets to a non-null value so that we can tell we've attempted
// to load snippets
ctx.snippets = snippets || []
}
export function getEnvironmentVariables() { export function getEnvironmentVariables() {
const context = Context.get() const context = Context.get()
if (!context.environmentVariables) { if (!context.environmentVariables) {

View File

@ -1,4 +1,4 @@
import { IdentityContext, VM } from "@budibase/types" import { IdentityContext, Snippet, VM } from "@budibase/types"
// keep this out of Budibase types, don't want to expose context info // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
@ -11,4 +11,5 @@ export type ContextMap = {
isMigrating?: boolean isMigrating?: boolean
vm?: VM vm?: VM
cleanup?: (() => void | Promise<void>)[] cleanup?: (() => void | Promise<void>)[]
snippets?: Snippet[]
} }

View File

@ -33,8 +33,8 @@ const handleClick = event => {
} }
// Ignore clicks for drawers, unless the handler is registered from a drawer // Ignore clicks for drawers, unless the handler is registered from a drawer
const sourceInDrawer = handler.anchor.closest(".drawer-container") != null const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
const clickInDrawer = event.target.closest(".drawer-container") != null const clickInDrawer = event.target.closest(".drawer-wrapper") != null
if (clickInDrawer && !sourceInDrawer) { if (clickInDrawer && !sourceInDrawer) {
return return
} }

View File

@ -57,8 +57,10 @@
</script> </script>
<script> <script>
import Portal from "svelte-portal"
import Button from "../Button/Button.svelte" import Button from "../Button/Button.svelte"
import Icon from "../Icon/Icon.svelte"
import ActionButton from "../ActionButton/ActionButton.svelte"
import Portal from "svelte-portal"
import { setContext, createEventDispatcher, onDestroy } from "svelte" import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
@ -170,7 +172,8 @@
{#if visible} {#if visible}
<Portal target=".modal-container"> <Portal target=".modal-container">
<div class="drawer-container"> <!-- This class is unstyled, but needed by click_outside -->
<div class="drawer-wrapper">
<div <div
class="underlay" class="underlay"
class:hidden={!$modal} class:hidden={!$modal}
@ -184,10 +187,24 @@
{style} {style}
> >
<header> <header>
<div class="text">{title || "Bindings"}</div> {#if $$slots.title}
<slot name="title" />
{:else}
<div class="text">{title || "Bindings"}</div>
{/if}
<div class="buttons"> <div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button> <Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" /> <slot name="buttons" />
{#if $resizable}
<ActionButton
size="M"
quiet
selected={$modal}
on:click={() => modal.set(!$modal)}
>
<Icon name={$modal ? "Minimize" : "Maximize"} size="S" />
</ActionButton>
{/if}
</div> </div>
</header> </header>
<slot name="body" /> <slot name="body" />
@ -206,7 +223,7 @@
height: 420px; height: 420px;
background: var(--background); background: var(--background);
border: var(--border-light); border: var(--border-light);
z-index: 999; z-index: 100;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
@ -233,7 +250,7 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 999; z-index: 100;
display: block; display: block;
transition: opacity 260ms ease-out; transition: opacity 260ms ease-out;
} }
@ -274,4 +291,8 @@
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.buttons :global(.icon) {
width: 16px;
display: flex;
}
</style> </style>

View File

@ -19,7 +19,6 @@
.drawer-contents { .drawer-contents {
overflow-y: auto; overflow-y: auto;
flex: 1 1 auto; flex: 1 1 auto;
height: 0;
} }
.container { .container {
height: 100%; height: 100%;

View File

@ -17,6 +17,7 @@
export let tooltipType = TooltipType.Default export let tooltipType = TooltipType.Default
export let tooltipColor export let tooltipColor
export let tooltipWrap = true export let tooltipWrap = true
export let newStyles = false
</script> </script>
<AbsTooltip <AbsTooltip
@ -26,7 +27,7 @@
color={tooltipColor} color={tooltipColor}
noWrap={tooltipWrap} noWrap={tooltipWrap}
> >
<div class="icon"> <div class="icon" class:newStyles>
<svg <svg
on:click on:click
class:hoverable class:hoverable
@ -55,6 +56,9 @@
display: grid; display: grid;
place-items: center; place-items: center;
} }
.newStyles {
color: var(--spectrum-global-color-gray-700);
}
svg.hoverable { svg.hoverable {
pointer-events: all; pointer-events: all;
@ -67,7 +71,10 @@
svg.hoverable:active { svg.hoverable:active {
color: var(--spectrum-global-color-blue-400) !important; color: var(--spectrum-global-color-blue-400) !important;
} }
.newStyles svg.hoverable:hover,
.newStyles svg.hoverable:active {
color: var(--spectrum-global-color-gray-900) !important;
}
svg.disabled { svg.disabled {
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;

View File

@ -49,7 +49,7 @@
<div class="side-bar-controls"> <div class="side-bar-controls">
<NavHeader <NavHeader
title="Automations" title="Automations"
placeholder="Search for automation" placeholder="Search for automations"
bind:value={searchString} bind:value={searchString}
onAdd={() => modal.show()} onAdd={() => modal.show()}
/> />

View File

@ -40,7 +40,7 @@
indentMore, indentMore,
indentLess, indentLess,
} from "@codemirror/commands" } from "@codemirror/commands"
import { Compartment } from "@codemirror/state" import { Compartment, EditorState } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./" import { EditorModes } from "./"
import { themeStore } from "stores/portal" import { themeStore } from "stores/portal"
@ -53,6 +53,7 @@
export let autocompleteEnabled = true export let autocompleteEnabled = true
export let autofocus = false export let autofocus = false
export let jsBindingWrapping = true export let jsBindingWrapping = true
export let readonly = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -82,8 +83,8 @@
}) })
} }
// For handlebars only. // Match decoration for HBS bindings
const bindStyle = new MatchDecorator({ const hbsMatchDeco = new MatchDecorator({
regexp: FIND_ANY_HBS_REGEX, regexp: FIND_ANY_HBS_REGEX,
decoration: () => { decoration: () => {
return Decoration.mark({ return Decoration.mark({
@ -94,12 +95,35 @@
}) })
}, },
}) })
const hbsMatchDecoPlugin = ViewPlugin.define(
let plugin = ViewPlugin.define(
view => ({ view => ({
decorations: bindStyle.createDeco(view), decorations: hbsMatchDeco.createDeco(view),
update(u) { update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations) this.decorations = hbsMatchDeco.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
// Match decoration for snippets
const snippetMatchDeco = new MatchDecorator({
regexp: /snippets\.[^\s(]+/g,
decoration: () => {
return Decoration.mark({
tag: "span",
attributes: {
class: "snippet-wrap",
},
})
},
})
const snippetMatchDecoPlugin = ViewPlugin.define(
view => ({
decorations: snippetMatchDeco.createDeco(view),
update(u) {
this.decorations = snippetMatchDeco.updateDeco(u, this.decorations)
}, },
}), }),
{ {
@ -141,33 +165,21 @@
const buildBaseExtensions = () => { const buildBaseExtensions = () => {
return [ return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(), drawSelection(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
closeBrackets(), closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }), syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(), highlightSpecialChars(),
lineNumbers(),
foldGutter(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([...(isDark ? [oneDark] : [])]), themeConfig.of([...(isDark ? [oneDark] : [])]),
] ]
} }
// None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime
const buildExtensions = base => { const buildExtensions = base => {
const complete = [...base] let complete = [...base]
if (autocompleteEnabled) { if (autocompleteEnabled) {
complete.push( complete.push(
@ -175,7 +187,10 @@
override: [...completions], override: [...completions],
closeOnBlur: true, closeOnBlur: true,
icons: false, icons: false,
optionClass: () => "autocomplete-option", optionClass: completion =>
completion.simple
? "autocomplete-option-simple"
: "autocomplete-option",
}) })
) )
complete.push( complete.push(
@ -201,20 +216,49 @@
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
return false return false
}) })
) )
} }
// JS only plugins
if (mode.name === "javascript") { if (mode.name === "javascript") {
complete.push(snippetMatchDecoPlugin)
complete.push(javascript()) complete.push(javascript())
complete.push(highlightWhitespace()) if (!readonly) {
complete.push(highlightWhitespace())
}
}
// HBS only plugins
else {
complete.push(hbsMatchDecoPlugin)
} }
if (placeholder) { if (placeholder) {
complete.push(placeholderFn(placeholder)) complete.push(placeholderFn(placeholder))
} }
if (readonly) {
complete.push(EditorState.readOnly.of(true))
} else {
complete = [
...complete,
history(),
highlightActiveLine(),
highlightActiveLineGutter(),
lineNumbers(),
foldGutter(),
keymap.of(buildKeymap()),
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
]
}
return complete return complete
} }
@ -300,7 +344,6 @@
/* Active line */ /* Active line */
.code-editor :global(.cm-line) { .code-editor :global(.cm-line) {
height: 16px;
padding: 0 var(--spacing-s); padding: 0 var(--spacing-s);
color: var(--spectrum-alias-text-color); color: var(--spectrum-alias-text-color);
} }
@ -318,6 +361,9 @@
background: var(--spectrum-global-color-gray-100) !important; background: var(--spectrum-global-color-gray-100) !important;
z-index: -2; z-index: -2;
} }
.code-editor :global(.cm-highlightSpace:before) {
color: var(--spectrum-global-color-gray-500);
}
/* Code selection */ /* Code selection */
.code-editor :global(.cm-selectionBackground) { .code-editor :global(.cm-selectionBackground) {
@ -360,9 +406,12 @@
font-style: italic; font-style: italic;
} }
/* Highlight bindings */ /* Highlight bindings and snippets */
.code-editor :global(.binding-wrap) { .code-editor :global(.binding-wrap) {
color: var(--spectrum-global-color-blue-700); color: var(--spectrum-global-color-blue-700) !important;
}
.code-editor :global(.snippet-wrap *) {
color: #61afef !important;
} }
/* Completion popover */ /* Completion popover */
@ -391,7 +440,8 @@
} }
/* Completion item container */ /* Completion item container */
.code-editor :global(.autocomplete-option) { .code-editor :global(.autocomplete-option),
.code-editor :global(.autocomplete-option-simple) {
padding: var(--spacing-s) var(--spacing-m) !important; padding: var(--spacing-s) var(--spacing-m) !important;
padding-left: calc(16px + 2 * var(--spacing-m)) !important; padding-left: calc(16px + 2 * var(--spacing-m)) !important;
display: flex; display: flex;
@ -399,9 +449,13 @@
align-items: center; align-items: center;
color: var(--spectrum-alias-text-color); color: var(--spectrum-alias-text-color);
} }
.code-editor :global(.autocomplete-option-simple) {
padding-left: var(--spacing-s) !important;
}
/* Highlighted completion item */ /* Highlighted completion item */
.code-editor :global(.autocomplete-option[aria-selected]) { .code-editor :global(.autocomplete-option[aria-selected]),
.code-editor :global(.autocomplete-option-simple[aria-selected]) {
background: var(--spectrum-global-color-blue-400); background: var(--spectrum-global-color-blue-400);
color: white; color: white;
} }
@ -417,6 +471,9 @@
font-family: var(--font-sans); font-family: var(--font-sans);
text-transform: capitalize; text-transform: capitalize;
} }
.code-editor :global(.autocomplete-option-simple .cm-completionLabel) {
text-transform: none;
}
/* Completion item type */ /* Completion item type */
.code-editor :global(.autocomplete-option .cm-completionDetail) { .code-editor :global(.autocomplete-option .cm-completionDetail) {
@ -454,14 +511,14 @@
.code-editor :global(.binding__example) { .code-editor :global(.binding__example) {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-size: var(--font-size-s); font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
white-space: pre; white-space: pre;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
max-height: 480px; max-height: 480px;
} }
.code-editor :global(.binding__example) { .code-editor :global(.binding__example.helper) {
color: var(--spectrum-global-color-blue-700); color: var(--spectrum-global-color-blue-700);
} }
.code-editor :global(.binding__example span) { .code-editor :global(.binding__example span) {

View File

@ -102,6 +102,29 @@ export const getHelperCompletions = mode => {
}, []) }, [])
} }
export const snippetAutoComplete = snippets => {
return function myCompletions(context) {
if (!snippets?.length) {
return null
}
const word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit) {
return null
}
return {
from: word.from,
options: snippets.map(snippet => ({
label: `snippets.${snippet.name}`,
type: "text",
simple: true,
apply: (view, completion, from, to) => {
insertSnippet(view, from, to, completion.label)
},
})),
}
}
}
const bindingFilter = (options, query) => { const bindingFilter = (options, query) => {
return options.filter(completion => { return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase() const section_parsed = completion.section.name.toLowerCase()
@ -247,6 +270,20 @@ export const insertBinding = (view, from, to, text, mode) => {
}) })
} }
export const insertSnippet = (view, from, to, text) => {
let cursorPos = from + text.length
view.dispatch({
changes: {
from,
to,
insert: text,
},
selection: {
anchor: cursorPos,
},
})
}
export const bindingsToCompletions = (bindings, mode) => { export const bindingsToCompletions = (bindings, mode) => {
const bindingByCategory = groupBy(bindings, "category") const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => { const categoryMeta = bindings?.reduce((acc, ele) => {

View File

@ -7,7 +7,7 @@
Body, Body,
Button, Button,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { import {
decodeJSBinding, decodeJSBinding,
encodeJSBinding, encodeJSBinding,
@ -19,27 +19,33 @@
getHelperCompletions, getHelperCompletions,
jsAutocomplete, jsAutocomplete,
hbAutocomplete, hbAutocomplete,
snippetAutoComplete,
EditorModes, EditorModes,
bindingsToCompletions, bindingsToCompletions,
} from "../CodeEditor" } from "../CodeEditor"
import BindingSidePanel from "./BindingSidePanel.svelte" import BindingSidePanel from "./BindingSidePanel.svelte"
import EvaluationSidePanel from "./EvaluationSidePanel.svelte" import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils" import { BindingHelpers } from "./utils"
import formatHighlight from "json-format-highlight" import formatHighlight from "json-format-highlight"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { get } from "svelte/store" import { licensing } from "stores/portal"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let bindings export let bindings = []
export let value = "" export let value = ""
export let allowHBS = true
export let allowJS = false export let allowJS = false
export let allowHelpers = true export let allowHelpers = true
export let allowSnippets = true
export let context = null export let context = null
export let snippets = null
export let autofocusEditor = false export let autofocusEditor = false
export let placeholder = null
export let showTabBar = true
const drawerContext = getContext("drawer")
const Modes = { const Modes = {
Text: "Text", Text: "Text",
JavaScript: "JavaScript", JavaScript: "JavaScript",
@ -47,61 +53,110 @@
const SidePanels = { const SidePanels = {
Bindings: "FlashOn", Bindings: "FlashOn",
Evaluation: "Play", Evaluation: "Play",
Snippets: "Code",
} }
let mode
let sidePanel
let initialValueJS = value?.startsWith?.("{{ js ") let initialValueJS = value?.startsWith?.("{{ js ")
let mode = initialValueJS ? Modes.JavaScript : Modes.Text
let sidePanel = SidePanels.Bindings
let getCaretPosition
let insertAtPos
let jsValue = initialValueJS ? value : null let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value let hbsValue = initialValueJS ? null : value
let getCaretPosition
let insertAtPos
let targetMode = null let targetMode = null
let expressionResult let expressionResult
let drawerIsModal
let evaluating = false let evaluating = false
$: drawerContext?.modal.subscribe(val => (drawerIsModal = val)) $: useSnippets = allowSnippets && !$licensing.isFreePlan
$: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text] $: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: sideTabs = context $: sidePanelOptions = getSidePanelOptions(
? [SidePanels.Evaluation, SidePanels.Bindings] bindings,
: [SidePanels.Bindings] context,
$: enrichedBindings = enrichBindings(bindings, context) allowSnippets,
mode
)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === Modes.JavaScript $: usingJS = mode === Modes.JavaScript
$: editorMode = $: editorMode =
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue $: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestUpdateEvaluation(runtimeExpression, context) $: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
$: hbsCompletions = [ $: hbsCompletions = getHBSCompletions(bindingCompletions)
hbAutocomplete([ $: jsCompletions = getJSCompletions(bindingCompletions, snippets, useSnippets)
...bindingCompletions, $: {
...getHelperCompletions(EditorModes.Handlebars), // Ensure a valid side panel option is always selected
]), if (sidePanel && !sidePanelOptions.includes(sidePanel)) {
] sidePanel = sidePanelOptions[0]
$: jsCompletions = [ }
jsAutocomplete([ }
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
const debouncedUpdateEvaluation = Utils.debounce((expression, context) => { const getHBSCompletions = bindingCompletions => {
expressionResult = processStringSync(expression || "", context) return [
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.Handlebars),
]),
]
}
const getJSCompletions = (bindingCompletions, snippets, useSnippets) => {
const completions = [
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
if (useSnippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS, allowJS) => {
let options = []
if (allowHBS) {
options.push(Modes.Text)
}
if (allowJS) {
options.push(Modes.JavaScript)
}
return options
}
const getSidePanelOptions = (bindings, context, useSnippets, mode) => {
let options = []
if (bindings?.length) {
options.push(SidePanels.Bindings)
}
if (context) {
options.push(SidePanels.Evaluation)
}
if (useSnippets && mode === Modes.JavaScript) {
options.push(SidePanels.Snippets)
}
return options
}
const debouncedEval = Utils.debounce((expression, context, snippets) => {
expressionResult = processStringSync(expression || "", {
...context,
snippets,
})
evaluating = false evaluating = false
}, 260) }, 260)
const requestUpdateEvaluation = (expression, context) => { const requestEval = (expression, context, snippets) => {
evaluating = true evaluating = true
debouncedUpdateEvaluation(expression, context) debouncedEval(expression, context, snippets)
} }
const getBindingValue = (binding, context) => { const getBindingValue = (binding, context, snippets) => {
const js = `return $("${binding.runtimeBinding}")` const js = `return $("${binding.runtimeBinding}")`
const hbs = encodeJSBinding(js) const hbs = encodeJSBinding(js)
const res = processStringSync(hbs, context) const res = processStringSync(hbs, { ...context, snippets })
return JSON.stringify(res, null, 2) return JSON.stringify(res, null, 2)
} }
@ -116,12 +171,12 @@
}) })
} }
const enrichBindings = (bindings, context) => { const enrichBindings = (bindings, context, snippets) => {
return bindings.map(binding => { return bindings.map(binding => {
if (!context) { if (!context) {
return binding return binding
} }
const value = getBindingValue(binding, context) const value = getBindingValue(binding, context, snippets)
return { return {
...binding, ...binding,
value, value,
@ -133,7 +188,7 @@
const updateValue = val => { const updateValue = val => {
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val) const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
dispatch("change", val) dispatch("change", val)
requestUpdateEvaluation(runtimeExpression, context) requestEval(runtimeExpression, context, snippets)
} }
const onSelectHelper = (helper, js) => { const onSelectHelper = (helper, js) => {
@ -149,7 +204,14 @@
if (targetMode || newMode === mode) { if (targetMode || newMode === mode) {
return return
} }
if (editorValue) {
// Get the raw editor value to see if we are abandoning changes
let rawValue = editorValue
if (mode === Modes.JavaScript) {
rawValue = decodeJSBinding(rawValue)
}
if (rawValue?.length) {
targetMode = newMode targetMode = newMode
} else { } else {
mode = newMode mode = newMode
@ -177,47 +239,52 @@
jsValue = encodeJSBinding(e.detail) jsValue = encodeJSBinding(e.detail)
updateValue(jsValue) updateValue(jsValue)
} }
onMount(() => {
// Set the initial mode appropriately
const initialValueMode = initialValueJS ? Modes.JavaScript : Modes.Text
if (editorModeOptions.includes(initialValueMode)) {
mode = initialValueMode
} else {
mode = editorModeOptions[0]
}
// Set the initial side panel
sidePanel = sidePanelOptions[0]
})
</script> </script>
<DrawerContent padding={false}> <DrawerContent padding={false}>
<div class="binding-panel"> <div class="binding-panel">
<div class="main"> <div class="main">
<div class="tabs"> {#if showTabBar}
<div class="editor-tabs"> <div class="tabs">
{#each editorTabs as tab} <div class="editor-tabs">
<ActionButton {#each editorModeOptions as editorMode}
size="M" <ActionButton
quiet size="M"
selected={mode === tab} quiet
on:click={() => changeMode(tab)} selected={mode === editorMode}
> on:click={() => changeMode(editorMode)}
{capitalise(tab)} >
</ActionButton> {capitalise(editorMode)}
{/each} </ActionButton>
{/each}
</div>
<div class="side-tabs">
{#each sidePanelOptions as panel}
<ActionButton
size="M"
quiet
selected={sidePanel === panel}
on:click={() => changeSidePanel(panel)}
>
<Icon name={panel} size="S" />
</ActionButton>
{/each}
</div>
</div> </div>
<div class="side-tabs"> {/if}
{#each sideTabs as tab}
<ActionButton
size="M"
quiet
selected={sidePanel === tab}
on:click={() => changeSidePanel(tab)}
>
<Icon name={tab} size="S" />
</ActionButton>
{/each}
{#if drawerContext && get(drawerContext.resizable)}
<ActionButton
size="M"
quiet
selected={drawerIsModal}
on:click={() => drawerContext.modal.set(!drawerIsModal)}
>
<Icon name={drawerIsModal ? "Minimize" : "Maximize"} size="S" />
</ActionButton>
{/if}
</div>
</div>
<div class="editor"> <div class="editor">
{#if mode === Modes.Text} {#if mode === Modes.Text}
{#key hbsCompletions} {#key hbsCompletions}
@ -228,7 +295,8 @@
bind:insertAtPos bind:insertAtPos
completions={hbsCompletions} completions={hbsCompletions}
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder="Add bindings by typing &#123;&#123; or use the menu on the right" placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"}
jsBindingWrapping={false} jsBindingWrapping={false}
/> />
{/key} {/key}
@ -242,7 +310,8 @@
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder="Add bindings by typing $ or use the menu on the right" placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping jsBindingWrapping
/> />
{/key} {/key}
@ -289,6 +358,11 @@
{evaluating} {evaluating}
expression={editorValue} expression={editorValue}
/> />
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
/>
{/if} {/if}
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@
export let context = null export let context = null
let search = "" let search = ""
let searching = false
let popover let popover
let popoverAnchor let popoverAnchor
let hoverTarget let hoverTarget
@ -74,6 +75,13 @@
if (!context || !binding.value || binding.value === "") { if (!context || !binding.value || binding.value === "") {
return return
} }
// Roles have always been broken for JS. We need to exclude them from
// showing a popover as it will show "Error while executing JS".
if (binding.category === "Role") {
return
}
stopHidingPopover() stopHidingPopover()
popoverAnchor = target popoverAnchor = target
hoverTarget = { hoverTarget = {
@ -112,6 +120,17 @@
hideTimeout = null hideTimeout = null
} }
} }
const startSearching = async () => {
searching = true
search = ""
}
const stopSearching = e => {
e.stopPropagation()
searching = false
search = ""
}
</script> </script>
<Popover <Popover
@ -141,7 +160,6 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="binding-side-panel"> <div class="binding-side-panel">
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#if selectedCategory} {#if selectedCategory}
@ -158,25 +176,34 @@
{#if !selectedCategory} {#if !selectedCategory}
<div class="header"> <div class="header">
<span class="search-input"> {#if searching}
<Input <div class="search-input">
placeholder={"Search for bindings"} <Input
autocomplete="off" placeholder="Search for bindings"
bind:value={search} autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/> />
</span> {:else}
<span <div class="title">Bindings</div>
class="search-input-icon" <Icon
on:click={() => { size="S"
search = null name="Search"
}} hoverable
class:searching={search} newStyles
> on:click={startSearching}
<Icon size="S" name={search ? "Close" : "Search"} /> />
</span> {/if}
</div> </div>
{/if} {/if}
{#if !selectedCategory && !search} {#if !selectedCategory && !search}
<ul class="category-list"> <ul class="category-list">
{#each categoryNames as categoryName} {#each categoryNames as categoryName}
@ -281,18 +308,15 @@
background: var(--background); background: var(--background);
z-index: 1; z-index: 1;
} }
.header :global(input) { .header :global(input) {
border: none; border: none;
border-radius: 0; border-radius: 0;
background: none; background: none;
padding: 0; padding: 0;
} }
.search-input { .search-input,
flex: 1; .title {
} flex: 1 1 auto;
.search-input-icon.searching {
cursor: pointer;
} }
ul.category-list { ul.category-list {

View File

@ -1,6 +1,6 @@
<script> <script>
import BindingPanel from "./BindingPanel.svelte" import BindingPanel from "./BindingPanel.svelte"
import { previewStore } from "stores/builder" import { previewStore, snippets } from "stores/builder"
import { onMount } from "svelte" import { onMount } from "svelte"
export let bindings = [] export let bindings = []
@ -28,6 +28,7 @@
<BindingPanel <BindingPanel
bindings={enrichedBindings} bindings={enrichedBindings}
context={$previewStore.selectedComponentContext} context={$previewStore.selectedComponentContext}
snippets={$snippets}
{value} {value}
{allowJS} {allowJS}
{allowHelpers} {allowHelpers}

View File

@ -55,7 +55,7 @@
</div> </div>
{/if} {/if}
<span /> <span />
<Icon name="Copy" hoverable on:click={copy} /> <Icon name="Copy" size="S" hoverable on:click={copy} />
{:else} {:else}
<div>Preview</div> <div>Preview</div>
{#if evaluating} {#if evaluating}
@ -65,7 +65,7 @@
{/if} {/if}
<span /> <span />
{#if !empty} {#if !empty}
<Icon name="Copy" hoverable on:click={copy} /> <Icon name="Copy" newStyles size="S" hoverable on:click={copy} />
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@ -1,5 +1,6 @@
<script> <script>
import BindingPanel from "./BindingPanel.svelte" import BindingPanel from "./BindingPanel.svelte"
import { snippets } from "stores/builder"
export let bindings = [] export let bindings = []
export let value = "" export let value = ""
@ -20,6 +21,7 @@
<BindingPanel <BindingPanel
bindings={enrichedBindings} bindings={enrichedBindings}
snippets={$snippets}
{value} {value}
{allowJS} {allowJS}
{context} {context}

View File

@ -0,0 +1,160 @@
<script>
import {
Button,
Drawer,
Input,
Icon,
AbsTooltip,
TooltipType,
notifications,
} from "@budibase/bbui"
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
import { decodeJSBinding, encodeJSBinding } from "@budibase/string-templates"
import { snippets } from "stores/builder"
import { getSequentialName } from "helpers/duplicate"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
export let snippet
export const show = () => drawer.show()
export const hide = () => drawer.hide()
const firstCharNumberRegex = /^[0-9].*$/
let drawer
let name = ""
let code = ""
let loading = false
let deleteConfirmationDialog
$: defaultName = getSequentialName($snippets, "MySnippet", x => x.name)
$: key = snippet?.name
$: name = snippet?.name || defaultName
$: code = snippet?.code ? encodeJSBinding(snippet.code) : ""
$: rawJS = decodeJSBinding(code)
$: nameError = validateName(name, $snippets)
const saveSnippet = async () => {
loading = true
try {
const newSnippet = { name, code: rawJS }
await snippets.saveSnippet(newSnippet)
drawer.hide()
notifications.success(`Snippet ${newSnippet.name} saved`)
} catch (error) {
notifications.error(error.message || "Error saving snippet")
}
loading = false
}
const deleteSnippet = async () => {
loading = true
try {
await snippets.deleteSnippet(snippet.name)
drawer.hide()
} catch (error) {
notifications.error("Error deleting snippet")
}
loading = false
}
const validateName = (name, snippets) => {
if (!name?.length) {
return "Name is required"
}
if (snippets.some(snippet => snippet.name === name)) {
return "That name is already in use"
}
if (firstCharNumberRegex.test(name)) {
return "Can't start with a number"
}
if (!ValidSnippetNameRegex.test(name)) {
return "No special characters or spaces"
}
return null
}
</script>
<Drawer bind:this={drawer}>
<svelte:fragment slot="title">
{#if snippet}
{snippet.name}
{:else}
<div class="name" class:invalid={nameError != null}>
<span>Name</span>
<Input bind:value={name} />
{#if nameError}
<AbsTooltip text={nameError} type={TooltipType.Negative}>
<Icon
name="Help"
size="S"
color="var(--spectrum-global-color-red-400)"
/>
</AbsTooltip>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="buttons">
{#if snippet}
<Button
warning
on:click={deleteConfirmationDialog.show}
disabled={loading}
>
Delete
</Button>
{/if}
<Button
cta
on:click={saveSnippet}
disabled={!snippet && (loading || nameError)}
>
Save
</Button>
</svelte:fragment>
<svelte:fragment slot="body">
{#key key}
<BindingPanel
allowHBS={false}
allowJS
allowSnippets={false}
showTabBar={false}
placeholder="return function(input) &#10100; ... &#10101;"
value={code}
on:change={e => (code = e.detail)}
>
<div slot="tabs">
<Input placeholder="Name" />
</div>
</BindingPanel>
{/key}
</svelte:fragment>
</Drawer>
<ConfirmDialog
bind:this={deleteConfirmationDialog}
title="Delete snippet"
body={`Are you sure you want to delete ${snippet?.name}?`}
onOk={deleteSnippet}
/>
<style>
.name {
display: flex;
gap: var(--spacing-l);
align-items: center;
position: relative;
}
.name :global(input) {
width: 200px;
}
.name.invalid :global(input) {
padding-right: 32px;
}
.name :global(.icon) {
position: absolute;
right: 10px;
}
</style>

View File

@ -0,0 +1,278 @@
<script>
import {
Input,
Layout,
Icon,
Popover,
Tags,
Tag,
Body,
Button,
} from "@budibase/bbui"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import { EditorModes } from "components/common/CodeEditor"
import SnippetDrawer from "./SnippetDrawer.svelte"
import { licensing } from "stores/portal"
import UpgradeButton from "pages/builder/portal/_components/UpgradeButton.svelte"
export let addSnippet
export let snippets
let search = ""
let searching = false
let popover
let popoverAnchor
let hoveredSnippet
let hideTimeout
let snippetDrawer
let editableSnippet
$: enableSnippets = !$licensing.isFreePlan
$: filteredSnippets = getFilteredSnippets(enableSnippets, snippets, search)
const getFilteredSnippets = (enableSnippets, snippets, search) => {
if (!enableSnippets || !snippets?.length) {
return []
}
if (!search?.length) {
return snippets
}
return snippets.filter(snippet =>
snippet.name.toLowerCase().includes(search.toLowerCase())
)
}
const showSnippet = (snippet, target) => {
stopHidingPopover()
popoverAnchor = target
hoveredSnippet = snippet
popover.show()
}
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
hoveredSnippet = null
hideTimeout = null
}, 100)
}
const stopHidingPopover = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const startSearching = () => {
searching = true
search = ""
}
const stopSearching = () => {
searching = false
search = ""
}
const createSnippet = () => {
editableSnippet = null
snippetDrawer.show()
}
const editSnippet = (e, snippet) => {
e.preventDefault()
e.stopPropagation()
editableSnippet = snippet
snippetDrawer.show()
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="snippet-side-panel">
<Layout noPadding gap="S">
<div class="header">
{#if enableSnippets}
{#if searching}
<div class="search-input">
<Input
placeholder="Search for snippets"
autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/>
{:else}
<div class="title">Snippets</div>
<Icon
size="S"
name="Search"
hoverable
newStyles
on:click={startSearching}
/>
<Icon
size="S"
name="Add"
hoverable
newStyles
on:click={createSnippet}
/>
{/if}
{:else}
<div class="title">
Snippets
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
</div>
{/if}
</div>
<div class="snippet-list">
{#if enableSnippets && filteredSnippets?.length}
{#each filteredSnippets as snippet}
<div
class="snippet"
on:mouseenter={e => showSnippet(snippet, e.target)}
on:mouseleave={hidePopover}
on:click={() => addSnippet(snippet)}
>
{snippet.name}
<Icon
name="Edit"
hoverable
newStyles
size="S"
on:click={e => editSnippet(e, snippet)}
/>
</div>
{/each}
{:else}
<div class="upgrade">
<Body size="S">
Snippets let you create reusable JS functions and values that can
all be managed in one place
</Body>
{#if enableSnippets}
<Button cta on:click={createSnippet}>Create snippet</Button>
{:else}
<UpgradeButton />
{/if}
</div>
{/if}
</div>
</Layout>
</div>
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
minWidth={0}
maxWidth={480}
maxHeight={480}
dismissible={false}
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
<div class="snippet-popover">
{#key hoveredSnippet}
<CodeEditor
value={hoveredSnippet.code.trim()}
mode={EditorModes.JS}
readonly
/>
{/key}
</div>
</Popover>
<SnippetDrawer bind:this={snippetDrawer} snippet={editableSnippet} />
<style>
.snippet-side-panel {
border-left: var(--border-light);
height: 100%;
overflow: auto;
}
/* Header */
.header {
height: 53px;
padding: 0 var(--spacing-l);
display: flex;
align-items: center;
border-bottom: var(--border-light);
position: sticky;
top: 0;
gap: var(--spacing-m);
background: var(--background);
z-index: 1;
}
.header :global(input) {
border: none;
border-radius: 0;
background: none;
padding: 0;
}
.search-input,
.title {
flex: 1 1 auto;
}
.title {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
/* Upgrade */
.upgrade {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.upgrade :global(p) {
text-align: center;
align-self: center;
}
/* List */
.snippet-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.snippet {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
display: flex;
justify-content: space-between;
}
.snippet:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
/* Popover */
.snippet-popover {
width: 400px;
}
</style>

View File

@ -38,4 +38,11 @@ export class BindingHelpers {
this.insertAtPos({ start, end, value: insertVal }) this.insertAtPos({ start, end, value: insertVal })
} }
} }
// Adds a snippet to the expression
onSelectSnippet(snippet) {
const pos = this.getCaretPosition()
const { start, end } = pos
this.insertAtPos({ start, end, value: `snippets.${snippet.name}` })
}
} }

View File

@ -48,3 +48,53 @@ export const duplicateName = (name, allNames) => {
return `${baseName} ${number}` return `${baseName} ${number}`
} }
/**
* More flexible alternative to the above function, which handles getting the
* next sequential name from an array of existing items while accounting for
* any type of prefix, and being able to deeply retrieve that name from the
* existing item array.
*
* Examples with a prefix of "foo":
* [] => "foo"
* ["foo"] => "foo2"
* ["foo", "foo6"] => "foo7"
*
* Examples with a prefix of "foo " (space at the end):
* [] => "foo"
* ["foo"] => "foo 2"
* ["foo", "foo 6"] => "foo 7"
*
* @param items the array of existing items
* @param prefix the string prefix of each name, including any spaces desired
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = (items, prefix, getName = x => x) => {
if (!prefix?.length || !getName) {
return null
}
const trimmedPrefix = prefix.trim()
if (!items?.length) {
return trimmedPrefix
}
let max = 0
items.forEach(item => {
const name = getName(item)
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}
const split = name.split(trimmedPrefix)
if (split.length !== 2) {
return
}
if (split[1].trim() === "") {
split[1] = "1"
}
const num = parseInt(split[1])
if (num > max) {
max = num
}
})
return max === 0 ? trimmedPrefix : `${prefix}${max + 1}`
}

View File

@ -1,5 +1,5 @@
import { expect, describe, it } from "vitest" import { expect, describe, it } from "vitest"
import { duplicateName } from "../duplicate" import { duplicateName, getSequentialName } from "../duplicate"
describe("duplicate", () => { describe("duplicate", () => {
describe("duplicates a name ", () => { describe("duplicates a name ", () => {
@ -40,3 +40,64 @@ describe("duplicate", () => {
}) })
}) })
}) })
describe("getSequentialName", () => {
it("handles nullish items", async () => {
const name = getSequentialName(null, "foo", () => {})
expect(name).toBe("foo")
})
it("handles nullish prefix", async () => {
const name = getSequentialName([], null, () => {})
expect(name).toBe(null)
})
it("handles nullish getName function", async () => {
const name = getSequentialName([], "foo", null)
expect(name).toBe(null)
})
it("handles just the prefix", async () => {
const name = getSequentialName(["foo"], "foo", x => x)
expect(name).toBe("foo2")
})
it("handles continuous ranges", async () => {
const name = getSequentialName(["foo", "foo2", "foo3"], "foo", x => x)
expect(name).toBe("foo4")
})
it("handles discontinuous ranges", async () => {
const name = getSequentialName(["foo", "foo3"], "foo", x => x)
expect(name).toBe("foo4")
})
it("handles a space inside the prefix", async () => {
const name = getSequentialName(["foo", "foo 2", "foo 3"], "foo ", x => x)
expect(name).toBe("foo 4")
})
it("handles a space inside the prefix with just the prefix", async () => {
const name = getSequentialName(["foo"], "foo ", x => x)
expect(name).toBe("foo 2")
})
it("handles no matches", async () => {
const name = getSequentialName(["aaa", "bbb"], "foo", x => x)
expect(name).toBe("foo")
})
it("handles similar names", async () => {
const name = getSequentialName(
["fooo1", "2foo", "a3foo4", "5foo5"],
"foo",
x => x
)
expect(name).toBe("foo")
})
it("handles non-string names", async () => {
const name = getSequentialName([null, 4123, [], {}], "foo", x => x)
expect(name).toBe("foo")
})
})

View File

@ -40,7 +40,7 @@
<!-- routify:options index=3 --> <!-- routify:options index=3 -->
<div class="root"> <div class="root">
<AutomationPanel {modal} {webhookModal} /> <AutomationPanel {modal} {webhookModal} />
<div class="content"> <div class="content drawer-container">
{#if $automationStore.automations?.length} {#if $automationStore.automations?.length}
<slot /> <slot />
{:else} {:else}

View File

@ -10,6 +10,7 @@
navigationStore, navigationStore,
selectedScreen, selectedScreen,
hoverStore, hoverStore,
snippets,
} from "stores/builder" } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
@ -68,6 +69,7 @@
hostname: window.location.hostname, hostname: window.location.hostname,
port: window.location.port, port: window.location.port,
}, },
snippets: $snippets,
} }
// Refresh the preview when required // Refresh the preview when required

View File

@ -18,6 +18,7 @@ import {
} from "./automations.js" } from "./automations.js"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js" import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments.js"
import { snippets } from "./snippets"
// Backend // Backend
import { tables } from "./tables" import { tables } from "./tables"
@ -62,6 +63,7 @@ export {
queries, queries,
flags, flags,
hoverStore, hoverStore,
snippets,
} }
export const reset = () => { export const reset = () => {
@ -101,6 +103,7 @@ export const initialise = async pkg => {
builderStore.init(application) builderStore.init(application)
navigationStore.syncAppNavigation(application?.navigation) navigationStore.syncAppNavigation(application?.navigation)
themeStore.syncAppTheme(application) themeStore.syncAppTheme(application)
snippets.syncMetadata(application)
screenStore.syncAppScreens(pkg) screenStore.syncAppScreens(pkg)
layoutStore.syncAppLayouts(pkg) layoutStore.syncAppLayouts(pkg)
resetBuilderHistory() resetBuilderHistory()

View File

@ -0,0 +1,41 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { appStore } from "./app"
const createsnippets = () => {
const store = writable([])
const syncMetadata = metadata => {
store.set(metadata?.snippets || [])
}
const saveSnippet = async updatedSnippet => {
const snippets = [
...get(store).filter(snippet => snippet.name !== updatedSnippet.name),
updatedSnippet,
]
const app = await API.saveAppMetadata({
appId: get(appStore).appId,
metadata: { snippets },
})
syncMetadata(app)
}
const deleteSnippet = async snippetName => {
const snippets = get(store).filter(snippet => snippet.name !== snippetName)
const app = await API.saveAppMetadata({
appId: get(appStore).appId,
metadata: { snippets },
})
syncMetadata(app)
}
return {
...store,
syncMetadata,
saveSnippet,
deleteSnippet,
}
}
export const snippets = createsnippets()

View File

@ -6,6 +6,7 @@ import {
themeStore, themeStore,
navigationStore, navigationStore,
deploymentStore, deploymentStore,
snippets,
datasources, datasources,
tables, tables,
} from "stores/builder" } from "stores/builder"
@ -64,6 +65,7 @@ export const createBuilderWebsocket = appId => {
appStore.syncMetadata(metadata) appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata) themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata) navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
}) })
socket.onOther( socket.onOther(
BuilderSocketEvent.AppPublishChange, BuilderSocketEvent.AppPublishChange,

View File

@ -39,6 +39,7 @@
import FreeFooter from "components/FreeFooter.svelte" import FreeFooter from "components/FreeFooter.svelte"
import MaintenanceScreen from "components/MaintenanceScreen.svelte" import MaintenanceScreen from "components/MaintenanceScreen.svelte"
import licensing from "../licensing" import licensing from "../licensing"
import SnippetsProvider from "./context/SnippetsProvider.svelte"
// Provide contexts // Provide contexts
setContext("sdk", SDK) setContext("sdk", SDK)
@ -121,114 +122,116 @@
<StateBindingsProvider> <StateBindingsProvider>
<RowSelectionProvider> <RowSelectionProvider>
<QueryParamsProvider> <QueryParamsProvider>
<!-- Settings bar can be rendered outside of device preview --> <SnippetsProvider>
<!-- Key block needs to be outside the if statement or it breaks --> <!-- Settings bar can be rendered outside of device preview -->
{#key $builderStore.selectedComponentId} <!-- Key block needs to be outside the if statement or it breaks -->
{#if $builderStore.inBuilder} {#key $builderStore.selectedComponentId}
<SettingsBar /> {#if $builderStore.inBuilder}
{/if} <SettingsBar />
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice ===
"tablet"}
class:mobile-preview={$builderStore.previewDevice ===
"mobile"}
>
<!-- Actual app -->
<div id="app-root">
{#if showDevTools}
<DevToolsHeader />
{/if} {/if}
{/key}
<div id="app-body"> <!-- Clip boundary for selection indicators -->
{#if permissionError} <div
<div class="error"> id="clip-root"
<Layout justifyItems="center" gap="S"> class:preview={$builderStore.inBuilder}
<!-- eslint-disable-next-line svelte/no-at-html-tags --> class:tablet-preview={$builderStore.previewDevice ===
{@html ErrorSVG} "tablet"}
<Heading size="L"> class:mobile-preview={$builderStore.previewDevice ===
You don't have permission to use this app "mobile"}
</Heading> >
<Body size="S"> <!-- Actual app -->
Ask your administrator to grant you access <div id="app-root">
</Body> {#if showDevTools}
</Layout> <DevToolsHeader />
</div> {/if}
{:else if !$screenStore.activeLayout}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
Something went wrong rendering your app
</Heading>
<Body size="S">
Get in touch with support if this issue persists
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!-- <div id="app-body">
{#if permissionError}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
You don't have permission to use this app
</Heading>
<Body size="S">
Ask your administrator to grant you access
</Body>
</Layout>
</div>
{:else if !$screenStore.activeLayout}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
Something went wrong rendering your app
</Heading>
<Body size="S">
Get in touch with support if this issue persists
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!--
Flatpickr needs to be inside the theme wrapper. Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with. key events on the whole page. It is painful to work with.
--> -->
<div id="flatpickr-root" /> <div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top --> <!-- Modal container to ensure they sit on top -->
<div class="modal-container" /> <div class="modal-container" />
<!-- Layers on top of app --> <!-- Layers on top of app -->
<NotificationDisplay /> <NotificationDisplay />
<ConfirmationDisplay /> <ConfirmationDisplay />
<PeekScreenDisplay /> <PeekScreenDisplay />
</CustomThemeWrapper> </CustomThemeWrapper>
{/if} {/if}
{#if showDevTools} {#if showDevTools}
<DevTools /> <DevTools />
{/if}
</div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
<FreeFooter />
{/if} {/if}
</div> </div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()} <!-- Preview and dev tools utilities -->
<FreeFooter /> {#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if} {/if}
</div> </div>
</SnippetsProvider>
<!-- Preview and dev tools utilities -->
{#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if}
</div>
</QueryParamsProvider> </QueryParamsProvider>
</RowSelectionProvider> </RowSelectionProvider>
</StateBindingsProvider> </StateBindingsProvider>

View File

@ -565,7 +565,8 @@
// If we don't know, check and cache // If we don't know, check and cache
if (used == null) { if (used == null) {
used = bindingString.indexOf(`[${key}]`) !== -1 const searchString = key === "snippets" ? key : `[${key}]`
used = bindingString.indexOf(searchString) !== -1
knownContextKeyMap[key] = used knownContextKeyMap[key] = used
} }

View File

@ -0,0 +1,8 @@
<script>
import Provider from "./Provider.svelte"
import { snippets } from "stores"
</script>
<Provider key="snippets" data={$snippets}>
<slot />
</Provider>

View File

@ -42,6 +42,7 @@ const loadBudibase = async () => {
hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"], hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"],
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"], usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
location: window["##BUDIBASE_LOCATION##"], location: window["##BUDIBASE_LOCATION##"],
snippets: window["##BUDIBASE_SNIPPETS##"],
}) })
// Set app ID - this window flag is set by both the preview and the real // Set app ID - this window flag is set by both the preview and the real

View File

@ -18,6 +18,7 @@ const createBuilderStore = () => {
usedPlugins: null, usedPlugins: null,
eventResolvers: {}, eventResolvers: {},
metadata: null, metadata: null,
snippets: null,
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,

View File

@ -4,3 +4,4 @@
export { currentRole } from "./currentRole.js" export { currentRole } from "./currentRole.js"
export { dndComponentPath } from "./dndComponentPath.js" export { dndComponentPath } from "./dndComponentPath.js"
export { devToolsEnabled } from "./devToolsEnabled.js" export { devToolsEnabled } from "./devToolsEnabled.js"
export { snippets } from "./snippets.js"

View File

@ -0,0 +1,10 @@
import { appStore } from "../app.js"
import { builderStore } from "../builder.js"
import { derivedMemo } from "@budibase/frontend-core"
export const snippets = derivedMemo(
[appStore, builderStore],
([$appStore, $builderStore]) => {
return $builderStore?.snippets || $appStore?.application?.snippets || []
}
)

View File

@ -1,23 +1,5 @@
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { processString, processObjectSync } from "@budibase/string-templates" import { processObjectSync } from "@budibase/string-templates"
// Regex to test inputs with to see if they are likely candidates for template strings
const looksLikeTemplate = /{{.*}}/
/**
* Enriches a given input with a row from the database.
*/
export const enrichDataBinding = async (input, context) => {
// Only accept string inputs
if (!input || typeof input !== "string") {
return input
}
// Do a fast regex check if this looks like a template string
if (!looksLikeTemplate.test(input)) {
return input
}
return processString(input, context)
}
/** /**
* Recursively enriches all props in a props object and returns the new props. * Recursively enriches all props in a props object and returns the new props.

View File

@ -317,7 +317,7 @@
align="right" align="right"
offset={0} offset={0}
popoverTarget={document.getElementById(`grid-${rand}`)} popoverTarget={document.getElementById(`grid-${rand}`)}
customZindex={100} customZindex={50}
> >
{#if editIsOpen} {#if editIsOpen}
<div <div

View File

@ -38,7 +38,7 @@
align={$visibleColumns.length ? "right" : "left"} align={$visibleColumns.length ? "right" : "left"}
offset={0} offset={0}
popoverTarget={document.getElementById(`add-column-button`)} popoverTarget={document.getElementById(`add-column-button`)}
customZindex={100} customZindex={50}
> >
<div <div
use:clickOutside={() => { use:clickOutside={() => {

View File

@ -13,9 +13,10 @@
"build": "node ./scripts/build.js", "build": "node ./scripts/build.js",
"postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/", "postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/",
"check:types": "tsc -p tsconfig.json --noEmit --paths null", "check:types": "tsc -p tsconfig.json --noEmit --paths null",
"build:isolated-vm-lib:snippets": "esbuild --minify --bundle src/jsRunner/bundles/snippets.ts --outfile=src/jsRunner/bundles/snippets.ivm.bundle.js --platform=node --format=iife --global-name=snippets",
"build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers", "build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers",
"build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson", "build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson",
"build:isolated-vm-libs": "yarn build:isolated-vm-lib:string-templates && yarn build:isolated-vm-lib:bson", "build:isolated-vm-libs": "yarn build:isolated-vm-lib:string-templates && yarn build:isolated-vm-lib:bson && yarn build:isolated-vm-lib:snippets",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js",
"jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest", "jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest",

View File

@ -437,11 +437,11 @@ export class ExternalRequest<T extends Operation> {
return { row: newRow, manyRelationships } return { row: newRow, manyRelationships }
} }
processRelationshipFields( async processRelationshipFields(
table: Table, table: Table,
row: Row, row: Row,
relationships: RelationshipsJson[] relationships: RelationshipsJson[]
): Row { ): Promise<Row> {
for (let relationship of relationships) { for (let relationship of relationships) {
const linkedTable = this.tables[relationship.tableName] const linkedTable = this.tables[relationship.tableName]
if (!linkedTable || !row[relationship.column]) { if (!linkedTable || !row[relationship.column]) {
@ -457,7 +457,7 @@ export class ExternalRequest<T extends Operation> {
} }
// process additional types // process additional types
relatedRow = processDates(table, relatedRow) relatedRow = processDates(table, relatedRow)
relatedRow = processFormulas(linkedTable, relatedRow) relatedRow = await processFormulas(linkedTable, relatedRow)
row[relationship.column][key] = relatedRow row[relationship.column][key] = relatedRow
} }
} }
@ -521,7 +521,7 @@ export class ExternalRequest<T extends Operation> {
return rows return rows
} }
outputProcessing( async outputProcessing(
rows: Row[] = [], rows: Row[] = [],
table: Table, table: Table,
relationships: RelationshipsJson[] relationships: RelationshipsJson[]
@ -561,9 +561,12 @@ export class ExternalRequest<T extends Operation> {
} }
// make sure all related rows are correct // make sure all related rows are correct
let finalRowArray = Object.values(finalRows).map(row => let finalRowArray = []
this.processRelationshipFields(table, row, relationships) for (let row of Object.values(finalRows)) {
) finalRowArray.push(
await this.processRelationshipFields(table, row, relationships)
)
}
// process some additional types // process some additional types
finalRowArray = processDates(table, finalRowArray) finalRowArray = processDates(table, finalRowArray)
@ -934,7 +937,11 @@ export class ExternalRequest<T extends Operation> {
processed.manyRelationships processed.manyRelationships
) )
} }
const output = this.outputProcessing(responseRows, table, relationships) const output = await this.outputProcessing(
responseRows,
table,
relationships
)
// if reading it'll just be an array of rows, return whole thing // if reading it'll just be an array of rows, return whole thing
if (operation === Operation.READ) { if (operation === Operation.READ) {
return ( return (

View File

@ -110,7 +110,7 @@ export async function updateAllFormulasInTable(table: Table) {
(enriched: Row) => enriched._id === row._id (enriched: Row) => enriched._id === row._id
) )
if (enrichedRow) { if (enrichedRow) {
const processed = processFormulas(table, cloneDeep(row), { const processed = await processFormulas(table, cloneDeep(row), {
dynamic: false, dynamic: false,
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
@ -143,7 +143,7 @@ export async function finaliseRow(
squash: false, squash: false,
})) as Row })) as Row
// use enriched row to generate formulas for saving, specifically only use as context // use enriched row to generate formulas for saving, specifically only use as context
row = processFormulas(table, row, { row = await processFormulas(table, row, {
dynamic: false, dynamic: false,
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
@ -179,7 +179,7 @@ export async function finaliseRow(
const response = await db.put(row) const response = await db.put(row)
// for response, calculate the formulas for the enriched row // for response, calculate the formulas for the enriched row
enrichedRow._rev = response.rev enrichedRow._rev = response.rev
enrichedRow = processFormulas(table, enrichedRow, { enrichedRow = await processFormulas(table, enrichedRow, {
dynamic: false, dynamic: false,
}) })
// this updates the related formulas in other rows based on the relations to this row // this updates the related formulas in other rows based on the relations to this row

View File

@ -1,6 +1,6 @@
import { Ctx } from "@budibase/types" import { Ctx } from "@budibase/types"
import { IsolatedVM } from "../../jsRunner/vm" import { IsolatedVM } from "../../jsRunner/vm"
import { iifeWrapper } from "../../jsRunner/utilities" import { iifeWrapper } from "@budibase/string-templates"
export async function execute(ctx: Ctx) { export async function execute(ctx: Ctx) {
const { script, context } = ctx.request.body const { script, context } = ctx.request.body

View File

@ -72,7 +72,8 @@
navigation, navigation,
hiddenComponentIds, hiddenComponentIds,
usedPlugins, usedPlugins,
location location,
snippets
} = parsed } = parsed
// Set some flags so the app knows we're in the builder // Set some flags so the app knows we're in the builder
@ -89,6 +90,7 @@
window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds
window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins
window["##BUDIBASE_LOCATION##"] = location window["##BUDIBASE_LOCATION##"] = location
window["##BUDIBASE_SNIPPETS##"] = snippets
// Initialise app // Initialise app
try { try {

View File

@ -2,6 +2,7 @@ import { auth, permissions } from "@budibase/backend-core"
import { DataSourceOperation } from "../../../constants" import { DataSourceOperation } from "../../../constants"
import { WebhookActionType } from "@budibase/types" import { WebhookActionType } from "@budibase/types"
import Joi from "joi" import Joi from "joi"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("") const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
const OPTIONAL_NUMBER = Joi.number().optional().allow(null) const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
@ -226,6 +227,21 @@ export function applicationValidator(opts = { isCreate: true }) {
base.name = appNameValidator.optional() base.name = appNameValidator.optional()
} }
const snippetValidator = Joi.array()
.optional()
.items(
Joi.object({
name: Joi.string()
.pattern(new RegExp(ValidSnippetNameRegex))
.error(
new Error(
"Snippet name cannot include spaces or special characters, and cannot start with a number"
)
),
code: OPTIONAL_STRING,
})
)
return auth.joiValidator.body( return auth.joiValidator.body(
Joi.object({ Joi.object({
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
@ -235,6 +251,7 @@ export function applicationValidator(opts = { isCreate: true }) {
template: Joi.object({ template: Joi.object({
templateString: OPTIONAL_STRING, templateString: OPTIONAL_STRING,
}).unknown(true), }).unknown(true),
snippets: snippetValidator,
}).unknown(true) }).unknown(true)
) )
} }

View File

@ -202,7 +202,8 @@ export async function attachFullLinkedDocs(
table => table._id === linkedTableId table => table._id === linkedTableId
) )
if (linkedTable) { if (linkedTable) {
row[link.fieldName].push(processFormulas(linkedTable, linkedRow)) const processed = await processFormulas(linkedTable, linkedRow)
row[link.fieldName].push(processed)
} }
} }
} }

View File

@ -5,11 +5,13 @@ import fs from "fs"
export const enum BundleType { export const enum BundleType {
HELPERS = "helpers", HELPERS = "helpers",
BSON = "bson", BSON = "bson",
SNIPPETS = "snippets",
} }
const bundleSourceFile: Record<BundleType, string> = { const bundleSourceFile: Record<BundleType, string> = {
[BundleType.HELPERS]: "./index-helpers.ivm.bundle.js", [BundleType.HELPERS]: "./index-helpers.ivm.bundle.js",
[BundleType.BSON]: "./bson.ivm.bundle.js", [BundleType.BSON]: "./bson.ivm.bundle.js",
[BundleType.SNIPPETS]: "./snippets.ivm.bundle.js",
} }
const bundleSourceCode: Partial<Record<BundleType, string>> = {} const bundleSourceCode: Partial<Record<BundleType, string>> = {}

View File

@ -0,0 +1,3 @@
"use strict";var snippets=(()=>{var u=Object.create;var n=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var x=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var l=(i,e)=>()=>(e||i((e={exports:{}}).exports,e),e.exports),W=(i,e)=>{for(var p in e)n(i,p,{get:e[p],enumerable:!0})},f=(i,e,p,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of h(e))!C.call(i,t)&&t!==p&&n(i,t,{get:()=>e[t],enumerable:!(r=a(e,t))||r.enumerable});return i};var d=(i,e,p)=>(p=i!=null?u(x(i)):{},f(e||!i||!i.__esModule?n(p,"default",{value:i,enumerable:!0}):p,i)),g=i=>f(n({},"__esModule",{value:!0}),i);var s=l((D,o)=>{o.exports.iifeWrapper=i=>`(function(){
${i}
})();`});var w={};W(w,{default:()=>v});var c=d(s()),v=new Proxy({},{get:function(i,e){return e in snippetCache||(snippetCache[e]=[eval][0]((0,c.iifeWrapper)(snippetDefinitions[e]))),snippetCache[e]}});return g(w);})();

View File

@ -0,0 +1,24 @@
// @ts-ignore
// eslint-disable-next-line local-rules/no-budibase-imports
import { iifeWrapper } from "@budibase/string-templates/iife"
export default new Proxy(
{},
{
get: function (_, name) {
// Both snippetDefinitions and snippetCache are injected to the isolate
// global scope before this bundle is loaded, so we can access it from
// there.
// See https://esbuild.github.io/content-types/#direct-eval for info on
// why eval is being called this way.
// Snippets are cached and reused once they have been evaluated.
// @ts-ignore
if (!(name in snippetCache)) {
// @ts-ignore
snippetCache[name] = [eval][0](iifeWrapper(snippetDefinitions[name]))
}
// @ts-ignore
return snippetCache[name]
},
}
)

View File

@ -14,16 +14,19 @@ export function init() {
setJSRunner((js: string, ctx: Record<string, any>) => { setJSRunner((js: string, ctx: Record<string, any>) => {
return tracer.trace("runJS", {}, span => { return tracer.trace("runJS", {}, span => {
try { try {
// Reuse an existing isolate from context, or make a new one
const bbCtx = context.getCurrentContext() const bbCtx = context.getCurrentContext()
const vm = const vm =
bbCtx?.vm || bbCtx?.vm ||
new IsolatedVM({ new IsolatedVM({
memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, memoryLimit: env.JS_RUNNER_MEMORY_LIMIT,
invocationTimeout: env.JS_PER_INVOCATION_TIMEOUT_MS, invocationTimeout: env.JS_PER_INVOCATION_TIMEOUT_MS,
isolateAccumulatedTimeout: env.JS_PER_REQUEST_TIMEOUT_MS, isolateAccumulatedTimeout: env.JS_PER_REQUEST_TIMEOUT_MS,
}).withHelpers() })
.withHelpers()
.withSnippets(bbCtx?.snippets)
// Persist isolate in context so we can reuse it
if (bbCtx && !bbCtx.vm) { if (bbCtx && !bbCtx.vm) {
bbCtx.vm = vm bbCtx.vm = vm
bbCtx.cleanup = bbCtx.cleanup || [] bbCtx.cleanup = bbCtx.cleanup || []
@ -33,7 +36,7 @@ export function init() {
// Because we can't pass functions into an Isolate, we remove them from // Because we can't pass functions into an Isolate, we remove them from
// the passed context and rely on the withHelpers() method to add them // the passed context and rely on the withHelpers() method to add them
// back in. // back in.
const { helpers, ...rest } = ctx const { helpers, snippets, ...rest } = ctx
return vm.withContext(rest, () => vm.execute(js)) return vm.withContext(rest, () => vm.execute(js))
} catch (error: any) { } catch (error: any) {
if (error.message === "Script execution timed out.") { if (error.message === "Script execution timed out.") {

View File

@ -1,7 +1,7 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { IsolatedVM } from "../vm" import { IsolatedVM } from "../vm"
import { iifeWrapper } from "../utilities" import { iifeWrapper } from "@budibase/string-templates"
function runJSWithIsolatedVM(script: string, context: Record<string, any>) { function runJSWithIsolatedVM(script: string, context: Record<string, any>) {
const runner = new IsolatedVM() const runner = new IsolatedVM()

View File

@ -1,3 +0,0 @@
export function iifeWrapper(script: string) {
return `(function(){\n${script}\n})();`
}

View File

@ -6,8 +6,8 @@ import crypto from "crypto"
import querystring from "querystring" import querystring from "querystring"
import { BundleType, loadBundle } from "../bundles" import { BundleType, loadBundle } from "../bundles"
import { VM } from "@budibase/types" import { Snippet, VM } from "@budibase/types"
import { iifeWrapper } from "../utilities" import { iifeWrapper } from "@budibase/string-templates"
import environment from "../../environment" import environment from "../../environment"
class ExecutionTimeoutError extends Error { class ExecutionTimeoutError extends Error {
@ -98,6 +98,26 @@ export class IsolatedVM implements VM {
return this return this
} }
withSnippets(snippets?: Snippet[]) {
// Transform snippets into a map for faster access
let snippetMap: Record<string, string> = {}
for (let snippet of snippets || []) {
snippetMap[snippet.name] = snippet.code
}
const snippetsSource = loadBundle(BundleType.SNIPPETS)
const script = this.isolate.compileScriptSync(`
const snippetDefinitions = ${JSON.stringify(snippetMap)};
const snippetCache = {};
${snippetsSource};
snippets = snippets.default;
`)
script.runSync(this.vm, { timeout: this.invocationTimeout, release: false })
new Promise(() => {
script.release()
})
return this
}
withContext<T>(context: Record<string, any>, executeWithContext: () => T) { withContext<T>(context: Record<string, any>, executeWithContext: () => T) {
this.addToContext(context) this.addToContext(context)

View File

@ -625,6 +625,7 @@ export async function executeInThread(job: Job<AutomationData>) {
}) })
return await context.doInAppContext(appId, async () => { return await context.doInAppContext(appId, async () => {
await context.ensureSnippetContext()
const envVars = await sdkUtils.getEnvironmentVariables() const envVars = await sdkUtils.getEnvironmentVariables()
// put into automation thread for whole context // put into automation thread for whole context
return await context.doInEnvironmentContext(envVars, async () => { return await context.doInEnvironmentContext(envVars, async () => {

View File

@ -8,9 +8,8 @@ import {
QueryResponse, QueryResponse,
} from "./definitions" } from "./definitions"
import { IsolatedVM } from "../jsRunner/vm" import { IsolatedVM } from "../jsRunner/vm"
import { iifeWrapper } from "../jsRunner/utilities" import { iifeWrapper, processStringSync } from "@budibase/string-templates"
import { getIntegration } from "../integrations" import { getIntegration } from "../integrations"
import { processStringSync } from "@budibase/string-templates"
import { context, cache, auth } from "@budibase/backend-core" import { context, cache, auth } from "@budibase/backend-core"
import { getGlobalIDFromUserMetadataID } from "../db/utils" import { getGlobalIDFromUserMetadataID } from "../db/utils"
import sdk from "../sdk" import sdk from "../sdk"

View File

@ -245,7 +245,7 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
// process formulas after the complex types had been processed // process formulas after the complex types had been processed
enriched = processFormulas(table, enriched, { dynamic: true }) enriched = await processFormulas(table, enriched, { dynamic: true })
if (opts.squash) { if (opts.squash) {
enriched = (await linkRows.squashLinksToPrimaryDisplay( enriched = (await linkRows.squashLinksToPrimaryDisplay(

View File

@ -10,6 +10,7 @@ import {
FieldType, FieldType,
} from "@budibase/types" } from "@budibase/types"
import tracer from "dd-trace" import tracer from "dd-trace"
import { context } from "@budibase/backend-core"
interface FormulaOpts { interface FormulaOpts {
dynamic?: boolean dynamic?: boolean
@ -44,16 +45,19 @@ export function fixAutoColumnSubType(
/** /**
* Looks through the rows provided and finds formulas - which it then processes. * Looks through the rows provided and finds formulas - which it then processes.
*/ */
export function processFormulas<T extends Row | Row[]>( export async function processFormulas<T extends Row | Row[]>(
table: Table, table: Table,
inputRows: T, inputRows: T,
{ dynamic, contextRows }: FormulaOpts = { dynamic: true } { dynamic, contextRows }: FormulaOpts = { dynamic: true }
): T { ): Promise<T> {
return tracer.trace("processFormulas", {}, span => { return tracer.trace("processFormulas", {}, async span => {
const numRows = Array.isArray(inputRows) ? inputRows.length : 1 const numRows = Array.isArray(inputRows) ? inputRows.length : 1
span?.addTags({ table_id: table._id, dynamic, numRows }) span?.addTags({ table_id: table._id, dynamic, numRows })
const rows = Array.isArray(inputRows) ? inputRows : [inputRows] const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
if (rows) { if (rows) {
// Ensure we have snippet context
await context.ensureSnippetContext()
for (let [column, schema] of Object.entries(table.schema)) { for (let [column, schema] of Object.entries(table.schema)) {
if (schema.type !== FieldType.FORMULA) { if (schema.type !== FieldType.FORMULA) {
continue continue

View File

@ -98,6 +98,7 @@ export enum BuilderSocketEvent {
export const SocketSessionTTL = 60 export const SocketSessionTTL = 60
export const ValidQueryNameRegex = /^[^()]*$/ export const ValidQueryNameRegex = /^[^()]*$/
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
export const ValidSnippetNameRegex = /^[a-z_][a-z0-9_]*$/i
export const REBOOT_CRON = "@reboot" export const REBOOT_CRON = "@reboot"

View File

@ -12,7 +12,8 @@
"import": "./dist/bundle.mjs" "import": "./dist/bundle.mjs"
}, },
"./package.json": "./package.json", "./package.json": "./package.json",
"./test/utils": "./test/utils.js" "./test/utils": "./test/utils.js",
"./iife": "./src/iife.js"
}, },
"files": [ "files": [
"dist", "dist",

View File

@ -2,6 +2,7 @@ const { atob, isBackendService, isJSAllowed } = require("../utilities")
const cloneDeep = require("lodash.clonedeep") const cloneDeep = require("lodash.clonedeep")
const { LITERAL_MARKER } = require("../helpers/constants") const { LITERAL_MARKER } = require("../helpers/constants")
const { getJsHelperList } = require("./list") const { getJsHelperList } = require("./list")
const { iifeWrapper } = require("../iife")
// The method of executing JS scripts depends on the bundle being built. // The method of executing JS scripts depends on the bundle being built.
// This setter is used in the entrypoint (either index.js or index.mjs). // This setter is used in the entrypoint (either index.js or index.mjs).
@ -48,14 +49,36 @@ module.exports.processJS = (handlebars, context) => {
try { try {
// Wrap JS in a function and immediately invoke it. // Wrap JS in a function and immediately invoke it.
// This is required to allow the final `return` statement to be valid. // This is required to allow the final `return` statement to be valid.
const js = `(function(){${atob(handlebars)}})();` const js = iifeWrapper(atob(handlebars))
// Transform snippets into an object for faster access, and cache previously
// evaluated snippets
let snippetMap = {}
let snippetCache = {}
for (let snippet of context.snippets || []) {
snippetMap[snippet.name] = snippet.code
}
// Our $ context function gets a value from context. // Our $ context function gets a value from context.
// We clone the context to avoid mutation in the binding affecting real // We clone the context to avoid mutation in the binding affecting real
// app context. // app context.
const clonedContext = cloneDeep({ ...context, snippets: null })
const sandboxContext = { const sandboxContext = {
$: path => getContextValue(path, cloneDeep(context)), $: path => getContextValue(path, clonedContext),
helpers: getJsHelperList(), helpers: getJsHelperList(),
// Proxy to evaluate snippets when running in the browser
snippets: new Proxy(
{},
{
get: function (_, name) {
if (!(name in snippetCache)) {
snippetCache[name] = eval(iifeWrapper(snippetMap[name]))
}
return snippetCache[name]
},
}
),
} }
// Create a sandbox with our context and run the JS // Create a sandbox with our context and run the JS

View File

@ -0,0 +1,3 @@
module.exports.iifeWrapper = script => {
return `(function(){\n${script}\n})();`
}

View File

@ -3,6 +3,7 @@ const handlebars = require("handlebars")
const { registerAll, registerMinimum } = require("./helpers/index") const { registerAll, registerMinimum } = require("./helpers/index")
const processors = require("./processors") const processors = require("./processors")
const { atob, btoa, isBackendService } = require("./utilities") const { atob, btoa, isBackendService } = require("./utilities")
const { iifeWrapper } = require("./iife")
const manifest = require("../manifest.json") const manifest = require("../manifest.json")
const { const {
FIND_HBS_REGEX, FIND_HBS_REGEX,
@ -426,3 +427,4 @@ function defaultJSSetup() {
defaultJSSetup() defaultJSSetup()
module.exports.defaultJSSetup = defaultJSSetup module.exports.defaultJSSetup = defaultJSSetup
module.exports.iifeWrapper = iifeWrapper

View File

@ -1,4 +1,4 @@
import { User, Document, Plugin } from "../" import { User, Document, Plugin, Snippet } from "../"
import { SocketSession } from "../../sdk" import { SocketSession } from "../../sdk"
export type AppMetadataErrors = { [key: string]: string[] } export type AppMetadataErrors = { [key: string]: string[] }
@ -26,6 +26,7 @@ export interface App extends Document {
automations?: AutomationSettings automations?: AutomationSettings
usedPlugins?: Plugin[] usedPlugins?: Plugin[]
upgradableVersion?: string upgradableVersion?: string
snippets?: Snippet[]
} }
export interface AppInstance { export interface AppInstance {

View File

@ -14,3 +14,4 @@ export * from "./backup"
export * from "./webhook" export * from "./webhook"
export * from "./links" export * from "./links"
export * from "./component" export * from "./component"
export * from "./snippet"

View File

@ -0,0 +1,4 @@
export interface Snippet {
name: string
code: string
}