commit
34e1b85383
|
@ -6,6 +6,7 @@ packages/server/coverage
|
|||
packages/worker/coverage
|
||||
packages/backend-core/coverage
|
||||
packages/server/client
|
||||
packages/server/coverage
|
||||
packages/builder/.routify
|
||||
packages/sdk/sdk
|
||||
packages/account-portal/packages/server/build
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
StaticDatabases,
|
||||
DEFAULT_TENANT_ID,
|
||||
} from "../constants"
|
||||
import { Database, IdentityContext } from "@budibase/types"
|
||||
import { Database, IdentityContext, Snippet, App } from "@budibase/types"
|
||||
import { ContextMap } from "./types"
|
||||
|
||||
let TEST_APP_ID: string | null = null
|
||||
|
@ -122,10 +122,10 @@ export async function doInAutomationContext<T>(params: {
|
|||
automationId: string
|
||||
task: () => T
|
||||
}): Promise<T> {
|
||||
const tenantId = getTenantIDFromAppID(params.appId)
|
||||
await ensureSnippetContext()
|
||||
return newContext(
|
||||
{
|
||||
tenantId,
|
||||
tenantId: getTenantIDFromAppID(params.appId),
|
||||
appId: params.appId,
|
||||
automationId: params.automationId,
|
||||
},
|
||||
|
@ -281,6 +281,27 @@ export function doInScimContext(task: any) {
|
|||
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() {
|
||||
const context = Context.get()
|
||||
if (!context.environmentVariables) {
|
||||
|
|
|
@ -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
|
||||
export type ContextMap = {
|
||||
|
@ -11,4 +11,5 @@ export type ContextMap = {
|
|||
isMigrating?: boolean
|
||||
vm?: VM
|
||||
cleanup?: (() => void | Promise<void>)[]
|
||||
snippets?: Snippet[]
|
||||
}
|
||||
|
|
|
@ -33,8 +33,8 @@ const handleClick = event => {
|
|||
}
|
||||
|
||||
// Ignore clicks for drawers, unless the handler is registered from a drawer
|
||||
const sourceInDrawer = handler.anchor.closest(".drawer-container") != null
|
||||
const clickInDrawer = event.target.closest(".drawer-container") != null
|
||||
const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
|
||||
const clickInDrawer = event.target.closest(".drawer-wrapper") != null
|
||||
if (clickInDrawer && !sourceInDrawer) {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -57,8 +57,10 @@
|
|||
</script>
|
||||
|
||||
<script>
|
||||
import Portal from "svelte-portal"
|
||||
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 { generate } from "shortid"
|
||||
|
||||
|
@ -170,7 +172,8 @@
|
|||
|
||||
{#if visible}
|
||||
<Portal target=".modal-container">
|
||||
<div class="drawer-container">
|
||||
<!-- This class is unstyled, but needed by click_outside -->
|
||||
<div class="drawer-wrapper">
|
||||
<div
|
||||
class="underlay"
|
||||
class:hidden={!$modal}
|
||||
|
@ -184,10 +187,24 @@
|
|||
{style}
|
||||
>
|
||||
<header>
|
||||
<div class="text">{title || "Bindings"}</div>
|
||||
{#if $$slots.title}
|
||||
<slot name="title" />
|
||||
{:else}
|
||||
<div class="text">{title || "Bindings"}</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<Button secondary quiet on:click={hide}>Cancel</Button>
|
||||
<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>
|
||||
</header>
|
||||
<slot name="body" />
|
||||
|
@ -206,7 +223,7 @@
|
|||
height: 420px;
|
||||
background: var(--background);
|
||||
border: var(--border-light);
|
||||
z-index: 999;
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
@ -233,7 +250,7 @@
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
z-index: 100;
|
||||
display: block;
|
||||
transition: opacity 260ms ease-out;
|
||||
}
|
||||
|
@ -274,4 +291,8 @@
|
|||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.buttons :global(.icon) {
|
||||
width: 16px;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
.drawer-contents {
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
}
|
||||
.container {
|
||||
height: 100%;
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
export let disabled = false
|
||||
export let color
|
||||
export let tooltip
|
||||
export let newStyles = false
|
||||
|
||||
$: rotation = getRotation(direction)
|
||||
|
||||
|
@ -28,6 +29,7 @@
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="icon"
|
||||
class:newStyles
|
||||
on:mouseover={() => (showTooltip = true)}
|
||||
on:focus={() => (showTooltip = true)}
|
||||
on:mouseleave={() => (showTooltip = false)}
|
||||
|
@ -60,6 +62,9 @@
|
|||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.newStyles {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
|
||||
svg.hoverable {
|
||||
pointer-events: all;
|
||||
|
@ -72,7 +77,10 @@
|
|||
svg.hoverable:active {
|
||||
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 {
|
||||
color: var(--spectrum-global-color-gray-500) !important;
|
||||
pointer-events: none !important;
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
<div class="side-bar-controls">
|
||||
<NavHeader
|
||||
title="Automations"
|
||||
placeholder="Search for automation"
|
||||
placeholder="Search for automations"
|
||||
bind:value={searchString}
|
||||
onAdd={() => modal.show()}
|
||||
/>
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
indentMore,
|
||||
indentLess,
|
||||
} from "@codemirror/commands"
|
||||
import { Compartment } from "@codemirror/state"
|
||||
import { Compartment, EditorState } from "@codemirror/state"
|
||||
import { javascript } from "@codemirror/lang-javascript"
|
||||
import { EditorModes } from "./"
|
||||
import { themeStore } from "stores/portal"
|
||||
|
@ -53,6 +53,7 @@
|
|||
export let autocompleteEnabled = true
|
||||
export let autofocus = false
|
||||
export let jsBindingWrapping = true
|
||||
export let readonly = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -82,8 +83,8 @@
|
|||
})
|
||||
}
|
||||
|
||||
// For handlebars only.
|
||||
const bindStyle = new MatchDecorator({
|
||||
// Match decoration for HBS bindings
|
||||
const hbsMatchDeco = new MatchDecorator({
|
||||
regexp: FIND_ANY_HBS_REGEX,
|
||||
decoration: () => {
|
||||
return Decoration.mark({
|
||||
|
@ -94,12 +95,35 @@
|
|||
})
|
||||
},
|
||||
})
|
||||
|
||||
let plugin = ViewPlugin.define(
|
||||
const hbsMatchDecoPlugin = ViewPlugin.define(
|
||||
view => ({
|
||||
decorations: bindStyle.createDeco(view),
|
||||
decorations: hbsMatchDeco.createDeco(view),
|
||||
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 = () => {
|
||||
return [
|
||||
...(mode.name === "handlebars" ? [plugin] : []),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
highlightActiveLine(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
lineNumbers(),
|
||||
foldGutter(),
|
||||
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] : [])]),
|
||||
]
|
||||
}
|
||||
|
||||
// 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 complete = [...base]
|
||||
let complete = [...base]
|
||||
|
||||
if (autocompleteEnabled) {
|
||||
complete.push(
|
||||
|
@ -175,7 +187,10 @@
|
|||
override: [...completions],
|
||||
closeOnBlur: true,
|
||||
icons: false,
|
||||
optionClass: () => "autocomplete-option",
|
||||
optionClass: completion =>
|
||||
completion.simple
|
||||
? "autocomplete-option-simple"
|
||||
: "autocomplete-option",
|
||||
})
|
||||
)
|
||||
complete.push(
|
||||
|
@ -201,20 +216,49 @@
|
|||
view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// JS only plugins
|
||||
if (mode.name === "javascript") {
|
||||
complete.push(snippetMatchDecoPlugin)
|
||||
complete.push(javascript())
|
||||
complete.push(highlightWhitespace())
|
||||
if (!readonly) {
|
||||
complete.push(highlightWhitespace())
|
||||
}
|
||||
}
|
||||
// HBS only plugins
|
||||
else {
|
||||
complete.push(hbsMatchDecoPlugin)
|
||||
}
|
||||
|
||||
if (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
|
||||
}
|
||||
|
||||
|
@ -300,7 +344,6 @@
|
|||
|
||||
/* Active line */
|
||||
.code-editor :global(.cm-line) {
|
||||
height: 16px;
|
||||
padding: 0 var(--spacing-s);
|
||||
color: var(--spectrum-alias-text-color);
|
||||
}
|
||||
|
@ -318,6 +361,9 @@
|
|||
background: var(--spectrum-global-color-gray-100) !important;
|
||||
z-index: -2;
|
||||
}
|
||||
.code-editor :global(.cm-highlightSpace:before) {
|
||||
color: var(--spectrum-global-color-gray-500);
|
||||
}
|
||||
|
||||
/* Code selection */
|
||||
.code-editor :global(.cm-selectionBackground) {
|
||||
|
@ -360,9 +406,12 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Highlight bindings */
|
||||
/* Highlight bindings and snippets */
|
||||
.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 */
|
||||
|
@ -391,7 +440,8 @@
|
|||
}
|
||||
|
||||
/* 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-left: calc(16px + 2 * var(--spacing-m)) !important;
|
||||
display: flex;
|
||||
|
@ -399,9 +449,13 @@
|
|||
align-items: center;
|
||||
color: var(--spectrum-alias-text-color);
|
||||
}
|
||||
.code-editor :global(.autocomplete-option-simple) {
|
||||
padding-left: var(--spacing-s) !important;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
color: white;
|
||||
}
|
||||
|
@ -417,6 +471,9 @@
|
|||
font-family: var(--font-sans);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.code-editor :global(.autocomplete-option-simple .cm-completionLabel) {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Completion item type */
|
||||
.code-editor :global(.autocomplete-option .cm-completionDetail) {
|
||||
|
@ -454,14 +511,14 @@
|
|||
.code-editor :global(.binding__example) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-s);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-height: 480px;
|
||||
}
|
||||
.code-editor :global(.binding__example) {
|
||||
.code-editor :global(.binding__example.helper) {
|
||||
color: var(--spectrum-global-color-blue-700);
|
||||
}
|
||||
.code-editor :global(.binding__example span) {
|
||||
|
|
|
@ -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) => {
|
||||
return options.filter(completion => {
|
||||
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) => {
|
||||
const bindingByCategory = groupBy(bindings, "category")
|
||||
const categoryMeta = bindings?.reduce((acc, ele) => {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
Body,
|
||||
Button,
|
||||
} from "@budibase/bbui"
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
import {
|
||||
decodeJSBinding,
|
||||
encodeJSBinding,
|
||||
|
@ -19,27 +19,33 @@
|
|||
getHelperCompletions,
|
||||
jsAutocomplete,
|
||||
hbAutocomplete,
|
||||
snippetAutoComplete,
|
||||
EditorModes,
|
||||
bindingsToCompletions,
|
||||
} from "../CodeEditor"
|
||||
import BindingSidePanel from "./BindingSidePanel.svelte"
|
||||
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
|
||||
import SnippetSidePanel from "./SnippetSidePanel.svelte"
|
||||
import { BindingHelpers } from "./utils"
|
||||
import formatHighlight from "json-format-highlight"
|
||||
import { capitalise } from "helpers"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
import { get } from "svelte/store"
|
||||
import { licensing } from "stores/portal"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let bindings
|
||||
export let bindings = []
|
||||
export let value = ""
|
||||
export let allowHBS = true
|
||||
export let allowJS = false
|
||||
export let allowHelpers = true
|
||||
export let allowSnippets = true
|
||||
export let context = null
|
||||
export let snippets = null
|
||||
export let autofocusEditor = false
|
||||
export let placeholder = null
|
||||
export let showTabBar = true
|
||||
|
||||
const drawerContext = getContext("drawer")
|
||||
const Modes = {
|
||||
Text: "Text",
|
||||
JavaScript: "JavaScript",
|
||||
|
@ -47,61 +53,110 @@
|
|||
const SidePanels = {
|
||||
Bindings: "FlashOn",
|
||||
Evaluation: "Play",
|
||||
Snippets: "Code",
|
||||
}
|
||||
|
||||
let mode
|
||||
let sidePanel
|
||||
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 hbsValue = initialValueJS ? null : value
|
||||
let getCaretPosition
|
||||
let insertAtPos
|
||||
let targetMode = null
|
||||
let expressionResult
|
||||
let drawerIsModal
|
||||
let evaluating = false
|
||||
|
||||
$: drawerContext?.modal.subscribe(val => (drawerIsModal = val))
|
||||
$: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text]
|
||||
$: sideTabs = context
|
||||
? [SidePanels.Evaluation, SidePanels.Bindings]
|
||||
: [SidePanels.Bindings]
|
||||
$: enrichedBindings = enrichBindings(bindings, context)
|
||||
$: useSnippets = allowSnippets && !$licensing.isFreePlan
|
||||
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
|
||||
$: sidePanelOptions = getSidePanelOptions(
|
||||
bindings,
|
||||
context,
|
||||
allowSnippets,
|
||||
mode
|
||||
)
|
||||
$: enrichedBindings = enrichBindings(bindings, context, snippets)
|
||||
$: usingJS = mode === Modes.JavaScript
|
||||
$: editorMode =
|
||||
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
|
||||
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
|
||||
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
|
||||
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
|
||||
$: requestUpdateEvaluation(runtimeExpression, context)
|
||||
$: requestEval(runtimeExpression, context, snippets)
|
||||
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
|
||||
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
|
||||
$: hbsCompletions = [
|
||||
hbAutocomplete([
|
||||
...bindingCompletions,
|
||||
...getHelperCompletions(EditorModes.Handlebars),
|
||||
]),
|
||||
]
|
||||
$: jsCompletions = [
|
||||
jsAutocomplete([
|
||||
...bindingCompletions,
|
||||
...getHelperCompletions(EditorModes.JS),
|
||||
]),
|
||||
]
|
||||
$: hbsCompletions = getHBSCompletions(bindingCompletions)
|
||||
$: jsCompletions = getJSCompletions(bindingCompletions, snippets, useSnippets)
|
||||
$: {
|
||||
// Ensure a valid side panel option is always selected
|
||||
if (sidePanel && !sidePanelOptions.includes(sidePanel)) {
|
||||
sidePanel = sidePanelOptions[0]
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedUpdateEvaluation = Utils.debounce((expression, context) => {
|
||||
expressionResult = processStringSync(expression || "", context)
|
||||
const getHBSCompletions = bindingCompletions => {
|
||||
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
|
||||
}, 260)
|
||||
|
||||
const requestUpdateEvaluation = (expression, context) => {
|
||||
const requestEval = (expression, context, snippets) => {
|
||||
evaluating = true
|
||||
debouncedUpdateEvaluation(expression, context)
|
||||
debouncedEval(expression, context, snippets)
|
||||
}
|
||||
|
||||
const getBindingValue = (binding, context) => {
|
||||
const getBindingValue = (binding, context, snippets) => {
|
||||
const js = `return $("${binding.runtimeBinding}")`
|
||||
const hbs = encodeJSBinding(js)
|
||||
const res = processStringSync(hbs, context)
|
||||
const res = processStringSync(hbs, { ...context, snippets })
|
||||
return JSON.stringify(res, null, 2)
|
||||
}
|
||||
|
||||
|
@ -116,12 +171,12 @@
|
|||
})
|
||||
}
|
||||
|
||||
const enrichBindings = (bindings, context) => {
|
||||
const enrichBindings = (bindings, context, snippets) => {
|
||||
return bindings.map(binding => {
|
||||
if (!context) {
|
||||
return binding
|
||||
}
|
||||
const value = getBindingValue(binding, context)
|
||||
const value = getBindingValue(binding, context, snippets)
|
||||
return {
|
||||
...binding,
|
||||
value,
|
||||
|
@ -133,7 +188,7 @@
|
|||
const updateValue = val => {
|
||||
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
|
||||
dispatch("change", val)
|
||||
requestUpdateEvaluation(runtimeExpression, context)
|
||||
requestEval(runtimeExpression, context, snippets)
|
||||
}
|
||||
|
||||
const onSelectHelper = (helper, js) => {
|
||||
|
@ -149,7 +204,14 @@
|
|||
if (targetMode || newMode === mode) {
|
||||
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
|
||||
} else {
|
||||
mode = newMode
|
||||
|
@ -177,47 +239,52 @@
|
|||
jsValue = encodeJSBinding(e.detail)
|
||||
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>
|
||||
|
||||
<DrawerContent padding={false}>
|
||||
<div class="binding-panel">
|
||||
<div class="main">
|
||||
<div class="tabs">
|
||||
<div class="editor-tabs">
|
||||
{#each editorTabs as tab}
|
||||
<ActionButton
|
||||
size="M"
|
||||
quiet
|
||||
selected={mode === tab}
|
||||
on:click={() => changeMode(tab)}
|
||||
>
|
||||
{capitalise(tab)}
|
||||
</ActionButton>
|
||||
{/each}
|
||||
{#if showTabBar}
|
||||
<div class="tabs">
|
||||
<div class="editor-tabs">
|
||||
{#each editorModeOptions as editorMode}
|
||||
<ActionButton
|
||||
size="M"
|
||||
quiet
|
||||
selected={mode === editorMode}
|
||||
on:click={() => changeMode(editorMode)}
|
||||
>
|
||||
{capitalise(editorMode)}
|
||||
</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 class="side-tabs">
|
||||
{#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>
|
||||
{/if}
|
||||
<div class="editor">
|
||||
{#if mode === Modes.Text}
|
||||
{#key hbsCompletions}
|
||||
|
@ -228,7 +295,8 @@
|
|||
bind:insertAtPos
|
||||
completions={hbsCompletions}
|
||||
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={false}
|
||||
/>
|
||||
{/key}
|
||||
|
@ -242,7 +310,8 @@
|
|||
bind:getCaretPosition
|
||||
bind:insertAtPos
|
||||
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
|
||||
/>
|
||||
{/key}
|
||||
|
@ -289,6 +358,11 @@
|
|||
{evaluating}
|
||||
expression={editorValue}
|
||||
/>
|
||||
{:else if sidePanel === SidePanels.Snippets}
|
||||
<SnippetSidePanel
|
||||
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
|
||||
{snippets}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
export let context = null
|
||||
|
||||
let search = ""
|
||||
let searching = false
|
||||
let popover
|
||||
let popoverAnchor
|
||||
let hoverTarget
|
||||
|
@ -74,6 +75,13 @@
|
|||
if (!context || !binding.value || binding.value === "") {
|
||||
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()
|
||||
popoverAnchor = target
|
||||
hoverTarget = {
|
||||
|
@ -112,6 +120,17 @@
|
|||
hideTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const startSearching = async () => {
|
||||
searching = true
|
||||
search = ""
|
||||
}
|
||||
|
||||
const stopSearching = e => {
|
||||
e.stopPropagation()
|
||||
searching = false
|
||||
search = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
|
@ -141,7 +160,6 @@
|
|||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="binding-side-panel">
|
||||
<Layout noPadding gap="S">
|
||||
{#if selectedCategory}
|
||||
|
@ -158,25 +176,34 @@
|
|||
|
||||
{#if !selectedCategory}
|
||||
<div class="header">
|
||||
<span class="search-input">
|
||||
<Input
|
||||
placeholder={"Search for bindings"}
|
||||
autocomplete="off"
|
||||
bind:value={search}
|
||||
{#if searching}
|
||||
<div class="search-input">
|
||||
<Input
|
||||
placeholder="Search for bindings"
|
||||
autocomplete="off"
|
||||
bind:value={search}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
size="S"
|
||||
name="Close"
|
||||
hoverable
|
||||
newStyles
|
||||
on:click={stopSearching}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="search-input-icon"
|
||||
on:click={() => {
|
||||
search = null
|
||||
}}
|
||||
class:searching={search}
|
||||
>
|
||||
<Icon size="S" name={search ? "Close" : "Search"} />
|
||||
</span>
|
||||
{:else}
|
||||
<div class="title">Bindings</div>
|
||||
<Icon
|
||||
size="S"
|
||||
name="Search"
|
||||
hoverable
|
||||
newStyles
|
||||
on:click={startSearching}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !selectedCategory && !search}
|
||||
<ul class="category-list">
|
||||
{#each categoryNames as categoryName}
|
||||
|
@ -281,18 +308,15 @@
|
|||
background: var(--background);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.header :global(input) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
.search-input-icon.searching {
|
||||
cursor: pointer;
|
||||
.search-input,
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
ul.category-list {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import BindingPanel from "./BindingPanel.svelte"
|
||||
import { previewStore } from "stores/builder"
|
||||
import { previewStore, snippets } from "stores/builder"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let bindings = []
|
||||
|
@ -28,6 +28,7 @@
|
|||
<BindingPanel
|
||||
bindings={enrichedBindings}
|
||||
context={$previewStore.selectedComponentContext}
|
||||
snippets={$snippets}
|
||||
{value}
|
||||
{allowJS}
|
||||
{allowHelpers}
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
<span />
|
||||
<Icon name="Copy" hoverable on:click={copy} />
|
||||
<Icon name="Copy" size="S" hoverable on:click={copy} />
|
||||
{:else}
|
||||
<div>Preview</div>
|
||||
{#if evaluating}
|
||||
|
@ -65,7 +65,7 @@
|
|||
{/if}
|
||||
<span />
|
||||
{#if !empty}
|
||||
<Icon name="Copy" hoverable on:click={copy} />
|
||||
<Icon name="Copy" newStyles size="S" hoverable on:click={copy} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import BindingPanel from "./BindingPanel.svelte"
|
||||
import { snippets } from "stores/builder"
|
||||
|
||||
export let bindings = []
|
||||
export let value = ""
|
||||
|
@ -20,6 +21,7 @@
|
|||
|
||||
<BindingPanel
|
||||
bindings={enrichedBindings}
|
||||
snippets={$snippets}
|
||||
{value}
|
||||
{allowJS}
|
||||
{context}
|
||||
|
|
|
@ -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) ❴ ... ❵"
|
||||
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>
|
|
@ -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>
|
|
@ -38,4 +38,11 @@ export class BindingHelpers {
|
|||
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}` })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,3 +48,53 @@ export const duplicateName = (name, allNames) => {
|
|||
|
||||
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}`
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { expect, describe, it } from "vitest"
|
||||
import { duplicateName } from "../duplicate"
|
||||
import { duplicateName, getSequentialName } from "../duplicate"
|
||||
|
||||
describe("duplicate", () => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<!-- routify:options index=3 -->
|
||||
<div class="root">
|
||||
<AutomationPanel {modal} {webhookModal} />
|
||||
<div class="content">
|
||||
<div class="content drawer-container">
|
||||
{#if $automationStore.automations?.length}
|
||||
<slot />
|
||||
{:else}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
navigationStore,
|
||||
selectedScreen,
|
||||
hoverStore,
|
||||
snippets,
|
||||
} from "stores/builder"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import {
|
||||
|
@ -68,6 +69,7 @@
|
|||
hostname: window.location.hostname,
|
||||
port: window.location.port,
|
||||
},
|
||||
snippets: $snippets,
|
||||
}
|
||||
|
||||
// Refresh the preview when required
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from "./automations.js"
|
||||
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
|
||||
import { deploymentStore } from "./deployments.js"
|
||||
import { snippets } from "./snippets"
|
||||
|
||||
// Backend
|
||||
import { tables } from "./tables"
|
||||
|
@ -62,6 +63,7 @@ export {
|
|||
queries,
|
||||
flags,
|
||||
hoverStore,
|
||||
snippets,
|
||||
}
|
||||
|
||||
export const reset = () => {
|
||||
|
@ -101,6 +103,7 @@ export const initialise = async pkg => {
|
|||
builderStore.init(application)
|
||||
navigationStore.syncAppNavigation(application?.navigation)
|
||||
themeStore.syncAppTheme(application)
|
||||
snippets.syncMetadata(application)
|
||||
screenStore.syncAppScreens(pkg)
|
||||
layoutStore.syncAppLayouts(pkg)
|
||||
resetBuilderHistory()
|
||||
|
|
|
@ -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()
|
|
@ -6,6 +6,7 @@ import {
|
|||
themeStore,
|
||||
navigationStore,
|
||||
deploymentStore,
|
||||
snippets,
|
||||
datasources,
|
||||
tables,
|
||||
} from "stores/builder"
|
||||
|
@ -64,6 +65,7 @@ export const createBuilderWebsocket = appId => {
|
|||
appStore.syncMetadata(metadata)
|
||||
themeStore.syncMetadata(metadata)
|
||||
navigationStore.syncMetadata(metadata)
|
||||
snippets.syncMetadata(metadata)
|
||||
})
|
||||
socket.onOther(
|
||||
BuilderSocketEvent.AppPublishChange,
|
||||
|
|
|
@ -39,6 +39,7 @@
|
|||
import FreeFooter from "components/FreeFooter.svelte"
|
||||
import MaintenanceScreen from "components/MaintenanceScreen.svelte"
|
||||
import licensing from "../licensing"
|
||||
import SnippetsProvider from "./context/SnippetsProvider.svelte"
|
||||
|
||||
// Provide contexts
|
||||
setContext("sdk", SDK)
|
||||
|
@ -121,114 +122,116 @@
|
|||
<StateBindingsProvider>
|
||||
<RowSelectionProvider>
|
||||
<QueryParamsProvider>
|
||||
<!-- Settings bar can be rendered outside of device preview -->
|
||||
<!-- Key block needs to be outside the if statement or it breaks -->
|
||||
{#key $builderStore.selectedComponentId}
|
||||
{#if $builderStore.inBuilder}
|
||||
<SettingsBar />
|
||||
{/if}
|
||||
{/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 />
|
||||
<SnippetsProvider>
|
||||
<!-- Settings bar can be rendered outside of device preview -->
|
||||
<!-- Key block needs to be outside the if statement or it breaks -->
|
||||
{#key $builderStore.selectedComponentId}
|
||||
{#if $builderStore.inBuilder}
|
||||
<SettingsBar />
|
||||
{/if}
|
||||
{/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}
|
||||
<!-- 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}
|
||||
|
||||
<!--
|
||||
<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.
|
||||
It also needs its own container because otherwise it hijacks
|
||||
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 -->
|
||||
<div class="modal-container" />
|
||||
<!-- Modal container to ensure they sit on top -->
|
||||
<div class="modal-container" />
|
||||
|
||||
<!-- Layers on top of app -->
|
||||
<NotificationDisplay />
|
||||
<ConfirmationDisplay />
|
||||
<PeekScreenDisplay />
|
||||
</CustomThemeWrapper>
|
||||
{/if}
|
||||
<!-- Layers on top of app -->
|
||||
<NotificationDisplay />
|
||||
<ConfirmationDisplay />
|
||||
<PeekScreenDisplay />
|
||||
</CustomThemeWrapper>
|
||||
{/if}
|
||||
|
||||
{#if showDevTools}
|
||||
<DevTools />
|
||||
{#if showDevTools}
|
||||
<DevTools />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
|
||||
<FreeFooter />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
|
||||
<FreeFooter />
|
||||
<!-- Preview and dev tools utilities -->
|
||||
{#if $appStore.isDevApp}
|
||||
<SelectionIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
|
||||
<HoverIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder}
|
||||
<DNDHandler />
|
||||
<GridDNDHandler />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview and dev tools utilities -->
|
||||
{#if $appStore.isDevApp}
|
||||
<SelectionIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
|
||||
<HoverIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder}
|
||||
<DNDHandler />
|
||||
<GridDNDHandler />
|
||||
{/if}
|
||||
</div>
|
||||
</SnippetsProvider>
|
||||
</QueryParamsProvider>
|
||||
</RowSelectionProvider>
|
||||
</StateBindingsProvider>
|
||||
|
|
|
@ -565,7 +565,8 @@
|
|||
|
||||
// If we don't know, check and cache
|
||||
if (used == null) {
|
||||
used = bindingString.indexOf(`[${key}]`) !== -1
|
||||
const searchString = key === "snippets" ? key : `[${key}]`
|
||||
used = bindingString.indexOf(searchString) !== -1
|
||||
knownContextKeyMap[key] = used
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import Provider from "./Provider.svelte"
|
||||
import { snippets } from "stores"
|
||||
</script>
|
||||
|
||||
<Provider key="snippets" data={$snippets}>
|
||||
<slot />
|
||||
</Provider>
|
|
@ -42,6 +42,7 @@ const loadBudibase = async () => {
|
|||
hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"],
|
||||
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
|
||||
location: window["##BUDIBASE_LOCATION##"],
|
||||
snippets: window["##BUDIBASE_SNIPPETS##"],
|
||||
})
|
||||
|
||||
// Set app ID - this window flag is set by both the preview and the real
|
||||
|
|
|
@ -18,6 +18,7 @@ const createBuilderStore = () => {
|
|||
usedPlugins: null,
|
||||
eventResolvers: {},
|
||||
metadata: null,
|
||||
snippets: null,
|
||||
|
||||
// Legacy - allow the builder to specify a layout
|
||||
layout: null,
|
||||
|
|
|
@ -4,3 +4,4 @@
|
|||
export { currentRole } from "./currentRole.js"
|
||||
export { dndComponentPath } from "./dndComponentPath.js"
|
||||
export { devToolsEnabled } from "./devToolsEnabled.js"
|
||||
export { snippets } from "./snippets.js"
|
||||
|
|
|
@ -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 || []
|
||||
}
|
||||
)
|
|
@ -1,23 +1,5 @@
|
|||
import { Helpers } from "@budibase/bbui"
|
||||
import { processString, 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)
|
||||
}
|
||||
import { processObjectSync } from "@budibase/string-templates"
|
||||
|
||||
/**
|
||||
* Recursively enriches all props in a props object and returns the new props.
|
||||
|
|
|
@ -317,7 +317,7 @@
|
|||
align="right"
|
||||
offset={0}
|
||||
popoverTarget={document.getElementById(`grid-${rand}`)}
|
||||
customZindex={100}
|
||||
customZindex={50}
|
||||
>
|
||||
{#if editIsOpen}
|
||||
<div
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
align={$visibleColumns.length ? "right" : "left"}
|
||||
offset={0}
|
||||
popoverTarget={document.getElementById(`add-column-button`)}
|
||||
customZindex={100}
|
||||
customZindex={50}
|
||||
>
|
||||
<div
|
||||
use:clickOutside={() => {
|
||||
|
|
|
@ -13,9 +13,10 @@
|
|||
"build": "node ./scripts/build.js",
|
||||
"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",
|
||||
"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: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",
|
||||
"debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js",
|
||||
"jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest",
|
||||
|
|
|
@ -437,11 +437,11 @@ export class ExternalRequest<T extends Operation> {
|
|||
return { row: newRow, manyRelationships }
|
||||
}
|
||||
|
||||
processRelationshipFields(
|
||||
async processRelationshipFields(
|
||||
table: Table,
|
||||
row: Row,
|
||||
relationships: RelationshipsJson[]
|
||||
): Row {
|
||||
): Promise<Row> {
|
||||
for (let relationship of relationships) {
|
||||
const linkedTable = this.tables[relationship.tableName]
|
||||
if (!linkedTable || !row[relationship.column]) {
|
||||
|
@ -457,7 +457,7 @@ export class ExternalRequest<T extends Operation> {
|
|||
}
|
||||
// process additional types
|
||||
relatedRow = processDates(table, relatedRow)
|
||||
relatedRow = processFormulas(linkedTable, relatedRow)
|
||||
relatedRow = await processFormulas(linkedTable, relatedRow)
|
||||
row[relationship.column][key] = relatedRow
|
||||
}
|
||||
}
|
||||
|
@ -521,7 +521,7 @@ export class ExternalRequest<T extends Operation> {
|
|||
return rows
|
||||
}
|
||||
|
||||
outputProcessing(
|
||||
async outputProcessing(
|
||||
rows: Row[] = [],
|
||||
table: Table,
|
||||
relationships: RelationshipsJson[]
|
||||
|
@ -561,9 +561,12 @@ export class ExternalRequest<T extends Operation> {
|
|||
}
|
||||
|
||||
// make sure all related rows are correct
|
||||
let finalRowArray = Object.values(finalRows).map(row =>
|
||||
this.processRelationshipFields(table, row, relationships)
|
||||
)
|
||||
let finalRowArray = []
|
||||
for (let row of Object.values(finalRows)) {
|
||||
finalRowArray.push(
|
||||
await this.processRelationshipFields(table, row, relationships)
|
||||
)
|
||||
}
|
||||
|
||||
// process some additional types
|
||||
finalRowArray = processDates(table, finalRowArray)
|
||||
|
@ -934,7 +937,11 @@ export class ExternalRequest<T extends Operation> {
|
|||
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 (operation === Operation.READ) {
|
||||
return (
|
||||
|
|
|
@ -110,7 +110,7 @@ export async function updateAllFormulasInTable(table: Table) {
|
|||
(enriched: Row) => enriched._id === row._id
|
||||
)
|
||||
if (enrichedRow) {
|
||||
const processed = processFormulas(table, cloneDeep(row), {
|
||||
const processed = await processFormulas(table, cloneDeep(row), {
|
||||
dynamic: false,
|
||||
contextRows: [enrichedRow],
|
||||
})
|
||||
|
@ -143,7 +143,7 @@ export async function finaliseRow(
|
|||
squash: false,
|
||||
})) as Row
|
||||
// use enriched row to generate formulas for saving, specifically only use as context
|
||||
row = processFormulas(table, row, {
|
||||
row = await processFormulas(table, row, {
|
||||
dynamic: false,
|
||||
contextRows: [enrichedRow],
|
||||
})
|
||||
|
@ -179,7 +179,7 @@ export async function finaliseRow(
|
|||
const response = await db.put(row)
|
||||
// for response, calculate the formulas for the enriched row
|
||||
enrichedRow._rev = response.rev
|
||||
enrichedRow = processFormulas(table, enrichedRow, {
|
||||
enrichedRow = await processFormulas(table, enrichedRow, {
|
||||
dynamic: false,
|
||||
})
|
||||
// this updates the related formulas in other rows based on the relations to this row
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Ctx } from "@budibase/types"
|
||||
import { IsolatedVM } from "../../jsRunner/vm"
|
||||
import { iifeWrapper } from "../../jsRunner/utilities"
|
||||
import { iifeWrapper } from "@budibase/string-templates"
|
||||
|
||||
export async function execute(ctx: Ctx) {
|
||||
const { script, context } = ctx.request.body
|
||||
|
|
|
@ -72,7 +72,8 @@
|
|||
navigation,
|
||||
hiddenComponentIds,
|
||||
usedPlugins,
|
||||
location
|
||||
location,
|
||||
snippets
|
||||
} = parsed
|
||||
|
||||
// Set some flags so the app knows we're in the builder
|
||||
|
@ -89,6 +90,7 @@
|
|||
window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds
|
||||
window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins
|
||||
window["##BUDIBASE_LOCATION##"] = location
|
||||
window["##BUDIBASE_SNIPPETS##"] = snippets
|
||||
|
||||
// Initialise app
|
||||
try {
|
||||
|
|
|
@ -2,6 +2,7 @@ import { auth, permissions } from "@budibase/backend-core"
|
|||
import { DataSourceOperation } from "../../../constants"
|
||||
import { WebhookActionType } from "@budibase/types"
|
||||
import Joi from "joi"
|
||||
import { ValidSnippetNameRegex } from "@budibase/shared-core"
|
||||
|
||||
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
|
||||
const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
|
||||
|
@ -226,6 +227,21 @@ export function applicationValidator(opts = { isCreate: true }) {
|
|||
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(
|
||||
Joi.object({
|
||||
_id: OPTIONAL_STRING,
|
||||
|
@ -235,6 +251,7 @@ export function applicationValidator(opts = { isCreate: true }) {
|
|||
template: Joi.object({
|
||||
templateString: OPTIONAL_STRING,
|
||||
}).unknown(true),
|
||||
snippets: snippetValidator,
|
||||
}).unknown(true)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -202,7 +202,8 @@ export async function attachFullLinkedDocs(
|
|||
table => table._id === linkedTableId
|
||||
)
|
||||
if (linkedTable) {
|
||||
row[link.fieldName].push(processFormulas(linkedTable, linkedRow))
|
||||
const processed = await processFormulas(linkedTable, linkedRow)
|
||||
row[link.fieldName].push(processed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,11 +5,13 @@ import fs from "fs"
|
|||
export const enum BundleType {
|
||||
HELPERS = "helpers",
|
||||
BSON = "bson",
|
||||
SNIPPETS = "snippets",
|
||||
}
|
||||
|
||||
const bundleSourceFile: Record<BundleType, string> = {
|
||||
[BundleType.HELPERS]: "./index-helpers.ivm.bundle.js",
|
||||
[BundleType.BSON]: "./bson.ivm.bundle.js",
|
||||
[BundleType.SNIPPETS]: "./snippets.ivm.bundle.js",
|
||||
}
|
||||
const bundleSourceCode: Partial<Record<BundleType, string>> = {}
|
||||
|
||||
|
|
|
@ -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);})();
|
|
@ -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]
|
||||
},
|
||||
}
|
||||
)
|
|
@ -14,16 +14,19 @@ export function init() {
|
|||
setJSRunner((js: string, ctx: Record<string, any>) => {
|
||||
return tracer.trace("runJS", {}, span => {
|
||||
try {
|
||||
// Reuse an existing isolate from context, or make a new one
|
||||
const bbCtx = context.getCurrentContext()
|
||||
|
||||
const vm =
|
||||
bbCtx?.vm ||
|
||||
new IsolatedVM({
|
||||
memoryLimit: env.JS_RUNNER_MEMORY_LIMIT,
|
||||
invocationTimeout: env.JS_PER_INVOCATION_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) {
|
||||
bbCtx.vm = vm
|
||||
bbCtx.cleanup = bbCtx.cleanup || []
|
||||
|
@ -33,7 +36,7 @@ export function init() {
|
|||
// 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
|
||||
// back in.
|
||||
const { helpers, ...rest } = ctx
|
||||
const { helpers, snippets, ...rest } = ctx
|
||||
return vm.withContext(rest, () => vm.execute(js))
|
||||
} catch (error: any) {
|
||||
if (error.message === "Script execution timed out.") {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { IsolatedVM } from "../vm"
|
||||
import { iifeWrapper } from "../utilities"
|
||||
import { iifeWrapper } from "@budibase/string-templates"
|
||||
|
||||
function runJSWithIsolatedVM(script: string, context: Record<string, any>) {
|
||||
const runner = new IsolatedVM()
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
export function iifeWrapper(script: string) {
|
||||
return `(function(){\n${script}\n})();`
|
||||
}
|
|
@ -6,8 +6,8 @@ import crypto from "crypto"
|
|||
import querystring from "querystring"
|
||||
|
||||
import { BundleType, loadBundle } from "../bundles"
|
||||
import { VM } from "@budibase/types"
|
||||
import { iifeWrapper } from "../utilities"
|
||||
import { Snippet, VM } from "@budibase/types"
|
||||
import { iifeWrapper } from "@budibase/string-templates"
|
||||
import environment from "../../environment"
|
||||
|
||||
class ExecutionTimeoutError extends Error {
|
||||
|
@ -98,6 +98,26 @@ export class IsolatedVM implements VM {
|
|||
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) {
|
||||
this.addToContext(context)
|
||||
|
||||
|
|
|
@ -625,6 +625,7 @@ export async function executeInThread(job: Job<AutomationData>) {
|
|||
})
|
||||
|
||||
return await context.doInAppContext(appId, async () => {
|
||||
await context.ensureSnippetContext()
|
||||
const envVars = await sdkUtils.getEnvironmentVariables()
|
||||
// put into automation thread for whole context
|
||||
return await context.doInEnvironmentContext(envVars, async () => {
|
||||
|
|
|
@ -8,9 +8,8 @@ import {
|
|||
QueryResponse,
|
||||
} from "./definitions"
|
||||
import { IsolatedVM } from "../jsRunner/vm"
|
||||
import { iifeWrapper } from "../jsRunner/utilities"
|
||||
import { iifeWrapper, processStringSync } from "@budibase/string-templates"
|
||||
import { getIntegration } from "../integrations"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
import { context, cache, auth } from "@budibase/backend-core"
|
||||
import { getGlobalIDFromUserMetadataID } from "../db/utils"
|
||||
import sdk from "../sdk"
|
||||
|
|
|
@ -245,7 +245,7 @@ export async function outputProcessing<T extends Row[] | Row>(
|
|||
}
|
||||
|
||||
// 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) {
|
||||
enriched = (await linkRows.squashLinksToPrimaryDisplay(
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
FieldType,
|
||||
} from "@budibase/types"
|
||||
import tracer from "dd-trace"
|
||||
import { context } from "@budibase/backend-core"
|
||||
|
||||
interface FormulaOpts {
|
||||
dynamic?: boolean
|
||||
|
@ -44,16 +45,19 @@ export function fixAutoColumnSubType(
|
|||
/**
|
||||
* 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,
|
||||
inputRows: T,
|
||||
{ dynamic, contextRows }: FormulaOpts = { dynamic: true }
|
||||
): T {
|
||||
return tracer.trace("processFormulas", {}, span => {
|
||||
): Promise<T> {
|
||||
return tracer.trace("processFormulas", {}, async span => {
|
||||
const numRows = Array.isArray(inputRows) ? inputRows.length : 1
|
||||
span?.addTags({ table_id: table._id, dynamic, numRows })
|
||||
const rows = Array.isArray(inputRows) ? inputRows : [inputRows]
|
||||
if (rows) {
|
||||
// Ensure we have snippet context
|
||||
await context.ensureSnippetContext()
|
||||
|
||||
for (let [column, schema] of Object.entries(table.schema)) {
|
||||
if (schema.type !== FieldType.FORMULA) {
|
||||
continue
|
||||
|
|
|
@ -98,6 +98,7 @@ export enum BuilderSocketEvent {
|
|||
export const SocketSessionTTL = 60
|
||||
export const ValidQueryNameRegex = /^[^()]*$/
|
||||
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
|
||||
export const ValidSnippetNameRegex = /^[a-z_][a-z0-9_]*$/i
|
||||
|
||||
export const REBOOT_CRON = "@reboot"
|
||||
|
||||
|
|
|
@ -12,7 +12,8 @@
|
|||
"import": "./dist/bundle.mjs"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./test/utils": "./test/utils.js"
|
||||
"./test/utils": "./test/utils.js",
|
||||
"./iife": "./src/iife.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
|
|
@ -2,6 +2,7 @@ const { atob, isBackendService, isJSAllowed } = require("../utilities")
|
|||
const cloneDeep = require("lodash.clonedeep")
|
||||
const { LITERAL_MARKER } = require("../helpers/constants")
|
||||
const { getJsHelperList } = require("./list")
|
||||
const { iifeWrapper } = require("../iife")
|
||||
|
||||
// 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).
|
||||
|
@ -48,14 +49,36 @@ module.exports.processJS = (handlebars, context) => {
|
|||
try {
|
||||
// Wrap JS in a function and immediately invoke it.
|
||||
// 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.
|
||||
// We clone the context to avoid mutation in the binding affecting real
|
||||
// app context.
|
||||
const clonedContext = cloneDeep({ ...context, snippets: null })
|
||||
const sandboxContext = {
|
||||
$: path => getContextValue(path, cloneDeep(context)),
|
||||
$: path => getContextValue(path, clonedContext),
|
||||
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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
module.exports.iifeWrapper = script => {
|
||||
return `(function(){\n${script}\n})();`
|
||||
}
|
|
@ -3,6 +3,7 @@ const handlebars = require("handlebars")
|
|||
const { registerAll, registerMinimum } = require("./helpers/index")
|
||||
const processors = require("./processors")
|
||||
const { atob, btoa, isBackendService } = require("./utilities")
|
||||
const { iifeWrapper } = require("./iife")
|
||||
const manifest = require("../manifest.json")
|
||||
const {
|
||||
FIND_HBS_REGEX,
|
||||
|
@ -426,3 +427,4 @@ function defaultJSSetup() {
|
|||
defaultJSSetup()
|
||||
|
||||
module.exports.defaultJSSetup = defaultJSSetup
|
||||
module.exports.iifeWrapper = iifeWrapper
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { User, Document, Plugin } from "../"
|
||||
import { User, Document, Plugin, Snippet } from "../"
|
||||
import { SocketSession } from "../../sdk"
|
||||
|
||||
export type AppMetadataErrors = { [key: string]: string[] }
|
||||
|
@ -26,6 +26,7 @@ export interface App extends Document {
|
|||
automations?: AutomationSettings
|
||||
usedPlugins?: Plugin[]
|
||||
upgradableVersion?: string
|
||||
snippets?: Snippet[]
|
||||
}
|
||||
|
||||
export interface AppInstance {
|
||||
|
|
|
@ -14,3 +14,4 @@ export * from "./backup"
|
|||
export * from "./webhook"
|
||||
export * from "./links"
|
||||
export * from "./component"
|
||||
export * from "./snippet"
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export interface Snippet {
|
||||
name: string
|
||||
code: string
|
||||
}
|
Loading…
Reference in New Issue