Merge branch 'js-binding-drawer' of github.com:Budibase/budibase into feature/query-transformers
This commit is contained in:
commit
c01263e8f6
|
@ -8,11 +8,19 @@
|
||||||
const selected = getContext("tab")
|
const selected = getContext("tab")
|
||||||
let tab
|
let tab
|
||||||
let tabInfo
|
let tabInfo
|
||||||
|
|
||||||
const setTabInfo = () => {
|
const setTabInfo = () => {
|
||||||
tabInfo = tab.getBoundingClientRect()
|
// If the tabs are being rendered inside a component which uses
|
||||||
if ($selected.title === title) {
|
// a svelte transition to enter, then this initial getBoundingClientRect
|
||||||
$selected.info = tabInfo
|
// will return an incorrect position.
|
||||||
}
|
// We just need to get this off the main thread to fix this, by using
|
||||||
|
// a 0ms timeout.
|
||||||
|
setTimeout(() => {
|
||||||
|
tabInfo = tab.getBoundingClientRect()
|
||||||
|
if ($selected.title === title) {
|
||||||
|
$selected.info = tabInfo
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
@ -7,11 +7,17 @@ import {
|
||||||
} from "./storeUtils"
|
} from "./storeUtils"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
|
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
|
||||||
import { makePropSafe } from "@budibase/string-templates"
|
import {
|
||||||
|
makePropSafe,
|
||||||
|
isJSBinding,
|
||||||
|
decodeJSBinding,
|
||||||
|
encodeJSBinding,
|
||||||
|
} from "@budibase/string-templates"
|
||||||
import { TableNames } from "../constants"
|
import { TableNames } from "../constants"
|
||||||
|
|
||||||
// Regex to match all instances of template strings
|
// Regex to match all instances of template strings
|
||||||
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
|
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
|
||||||
|
const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g
|
||||||
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g
|
const CAPTURE_HBS_TEMPLATE = /{{[\S\s]*?}}/g
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -430,6 +436,15 @@ function replaceBetween(string, start, end, replacement) {
|
||||||
* utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
|
* utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
|
||||||
*/
|
*/
|
||||||
function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
||||||
|
// Decide from base64 if using JS
|
||||||
|
const isJS = isJSBinding(textWithBindings)
|
||||||
|
if (isJS) {
|
||||||
|
textWithBindings = decodeJSBinding(textWithBindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine correct regex to find bindings to replace
|
||||||
|
const regex = isJS ? CAPTURE_VAR_INSIDE_JS : CAPTURE_VAR_INSIDE_TEMPLATE
|
||||||
|
|
||||||
const convertFrom =
|
const convertFrom =
|
||||||
convertTo === "runtimeBinding" ? "readableBinding" : "runtimeBinding"
|
convertTo === "runtimeBinding" ? "readableBinding" : "runtimeBinding"
|
||||||
if (typeof textWithBindings !== "string") {
|
if (typeof textWithBindings !== "string") {
|
||||||
|
@ -441,7 +456,7 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return b.length - a.length
|
return b.length - a.length
|
||||||
})
|
})
|
||||||
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_TEMPLATE) || []
|
const boundValues = textWithBindings.match(regex) || []
|
||||||
let result = textWithBindings
|
let result = textWithBindings
|
||||||
for (let boundValue of boundValues) {
|
for (let boundValue of boundValues) {
|
||||||
let newBoundValue = boundValue
|
let newBoundValue = boundValue
|
||||||
|
@ -449,7 +464,7 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
||||||
// in the search, working from longest to shortest so always use best match first
|
// in the search, working from longest to shortest so always use best match first
|
||||||
let searchString = newBoundValue
|
let searchString = newBoundValue
|
||||||
for (let from of convertFromProps) {
|
for (let from of convertFromProps) {
|
||||||
if (shouldReplaceBinding(newBoundValue, from, convertTo)) {
|
if (isJS || shouldReplaceBinding(newBoundValue, from, convertTo)) {
|
||||||
const binding = bindableProperties.find(el => el[convertFrom] === from)
|
const binding = bindableProperties.find(el => el[convertFrom] === from)
|
||||||
let idx
|
let idx
|
||||||
do {
|
do {
|
||||||
|
@ -472,6 +487,12 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
|
||||||
}
|
}
|
||||||
result = result.replace(boundValue, newBoundValue)
|
result = result.replace(boundValue, newBoundValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-encode to base64 if using JS
|
||||||
|
if (isJS) {
|
||||||
|
result = encodeJSBinding(result)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -178,6 +178,7 @@
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
on:change={e => onChange(e, key)}
|
on:change={e => onChange(e, key)}
|
||||||
{bindings}
|
{bindings}
|
||||||
|
allowJS={false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if value.customType === "query"}
|
{:else if value.customType === "query"}
|
||||||
|
@ -240,6 +241,7 @@
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
on:change={e => onChange(e, key)}
|
on:change={e => onChange(e, key)}
|
||||||
{bindings}
|
{bindings}
|
||||||
|
allowJS={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -39,6 +39,7 @@
|
||||||
type="string"
|
type="string"
|
||||||
{bindings}
|
{bindings}
|
||||||
fillWidth={true}
|
fillWidth={true}
|
||||||
|
allowJS={false}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -88,6 +88,7 @@
|
||||||
type="string"
|
type="string"
|
||||||
{bindings}
|
{bindings}
|
||||||
fillWidth={true}
|
fillWidth={true}
|
||||||
|
allowJS={false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
<script context="module">
|
||||||
|
export const EditorModes = {
|
||||||
|
JS: {
|
||||||
|
name: "javascript",
|
||||||
|
json: false,
|
||||||
|
},
|
||||||
|
JSON: {
|
||||||
|
name: "javascript",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
SQL: {
|
||||||
|
name: "sql",
|
||||||
|
},
|
||||||
|
Handlebars: {
|
||||||
|
name: "handlebars",
|
||||||
|
base: "text/html",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CodeMirror from "components/integration/codemirror"
|
||||||
|
import { themeStore } from "builderStore"
|
||||||
|
import { createEventDispatcher, onMount } from "svelte"
|
||||||
|
|
||||||
|
export let mode = EditorModes.JS
|
||||||
|
export let value = ""
|
||||||
|
export let height = 300
|
||||||
|
export let readonly = false
|
||||||
|
export let hints = []
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
let textarea
|
||||||
|
let editor
|
||||||
|
|
||||||
|
// Keep editor up to date with value
|
||||||
|
$: editor?.setValue(value || "")
|
||||||
|
|
||||||
|
// Creates an instance of a code mirror editor
|
||||||
|
async function createEditor(mode, value) {
|
||||||
|
if (!CodeMirror || !textarea || editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure CM options
|
||||||
|
const lightTheme = $themeStore.theme.includes("light")
|
||||||
|
const options = {
|
||||||
|
mode,
|
||||||
|
value: value || "",
|
||||||
|
readOnly: readonly,
|
||||||
|
theme: lightTheme ? "default" : "tomorrow-night-eighties",
|
||||||
|
|
||||||
|
// Style
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: true,
|
||||||
|
indentWithTabs: true,
|
||||||
|
indentUnit: 2,
|
||||||
|
tabSize: 2,
|
||||||
|
|
||||||
|
// QOL addons
|
||||||
|
extraKeys: { "Ctrl-Space": "autocomplete" },
|
||||||
|
styleActiveLine: { nonEmpty: true },
|
||||||
|
autoCloseBrackets: true,
|
||||||
|
matchBrackets: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register hints plugin if desired
|
||||||
|
if (hints?.length) {
|
||||||
|
CodeMirror.registerHelper("hint", "dictionaryHint", function (editor) {
|
||||||
|
const cursor = editor.getCursor()
|
||||||
|
return {
|
||||||
|
list: hints,
|
||||||
|
from: CodeMirror.Pos(cursor.line, cursor.ch),
|
||||||
|
to: CodeMirror.Pos(cursor.line, cursor.ch),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
CodeMirror.commands.autocomplete = function (cm) {
|
||||||
|
CodeMirror.showHint(cm, CodeMirror.hint.dictionaryHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct CM instance
|
||||||
|
editor = CodeMirror.fromTextArea(textarea, options)
|
||||||
|
|
||||||
|
// Use a blur handler to update the value
|
||||||
|
editor.on("blur", instance => {
|
||||||
|
dispatch("change", instance.getValue())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a function to expose caret position
|
||||||
|
export const getCaretPosition = () => {
|
||||||
|
const cursor = editor.getCursor()
|
||||||
|
return {
|
||||||
|
start: cursor.ch,
|
||||||
|
end: cursor.ch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Create the editor with initial value
|
||||||
|
createEditor(mode, value)
|
||||||
|
|
||||||
|
// Clean up editor on unmount
|
||||||
|
return () => {
|
||||||
|
if (editor) {
|
||||||
|
editor.toTextArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style={`--code-mirror-height: ${height}px`}>
|
||||||
|
<textarea tabindex="0" bind:this={textarea} readonly {value} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
div :global(.CodeMirror) {
|
||||||
|
height: var(--code-mirror-height);
|
||||||
|
font-family: monospace;
|
||||||
|
line-height: 1.3;
|
||||||
|
border: var(--spectrum-alias-border-size-thin) solid;
|
||||||
|
border-color: var(--spectrum-alias-border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override default active line highlight colour in dark theme */
|
||||||
|
div
|
||||||
|
:global(.CodeMirror-focused.cm-s-tomorrow-night-eighties
|
||||||
|
.CodeMirror-activeline-background) {
|
||||||
|
background: rgba(255, 255, 255, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove active line styling when not focused */
|
||||||
|
div
|
||||||
|
:global(.CodeMirror:not(.CodeMirror-focused)
|
||||||
|
.CodeMirror-activeline-background) {
|
||||||
|
background: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a spectrum themed border when focused */
|
||||||
|
div :global(.CodeMirror-focused) {
|
||||||
|
border-color: var(--spectrum-alias-border-color-mouse-focus);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,32 +1,88 @@
|
||||||
<script>
|
<script>
|
||||||
import groupBy from "lodash/fp/groupBy"
|
import groupBy from "lodash/fp/groupBy"
|
||||||
import { Search, TextArea, DrawerContent } from "@budibase/bbui"
|
import {
|
||||||
|
Search,
|
||||||
|
TextArea,
|
||||||
|
DrawerContent,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Body,
|
||||||
|
Layout,
|
||||||
|
} from "@budibase/bbui"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import { isValid } from "@budibase/string-templates"
|
import {
|
||||||
|
isValid,
|
||||||
|
decodeJSBinding,
|
||||||
|
encodeJSBinding,
|
||||||
|
} from "@budibase/string-templates"
|
||||||
import { readableToRuntimeBinding } from "builderStore/dataBinding"
|
import { readableToRuntimeBinding } from "builderStore/dataBinding"
|
||||||
import { handlebarsCompletions } from "constants/completions"
|
import { handlebarsCompletions } from "constants/completions"
|
||||||
import { addToText } from "./utils"
|
import { addHBSBinding, addJSBinding } from "./utils"
|
||||||
|
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
export let bindableProperties
|
export let bindableProperties
|
||||||
export let value = ""
|
export let value = ""
|
||||||
export let valid
|
export let valid
|
||||||
|
export let allowJS = false
|
||||||
|
|
||||||
let helpers = handlebarsCompletions()
|
let helpers = handlebarsCompletions()
|
||||||
let getCaretPosition
|
let getCaretPosition
|
||||||
let search = ""
|
let search = ""
|
||||||
|
let initialValueJS = value?.startsWith("{{ js ")
|
||||||
|
let mode = initialValueJS ? "JavaScript" : "Handlebars"
|
||||||
|
let jsValue = initialValueJS ? value : null
|
||||||
|
let hbsValue = initialValueJS ? null : value
|
||||||
|
|
||||||
|
$: usingJS = mode === "JavaScript"
|
||||||
$: valid = isValid(readableToRuntimeBinding(bindableProperties, value))
|
$: valid = isValid(readableToRuntimeBinding(bindableProperties, value))
|
||||||
$: dispatch("change", value)
|
|
||||||
$: ({ context } = groupBy("type", bindableProperties))
|
$: ({ context } = groupBy("type", bindableProperties))
|
||||||
$: searchRgx = new RegExp(search, "ig")
|
$: searchRgx = new RegExp(search, "ig")
|
||||||
$: filteredColumns = context?.filter(context => {
|
$: filteredBindings = context?.filter(context => {
|
||||||
return context.readableBinding.match(searchRgx)
|
return context.readableBinding.match(searchRgx)
|
||||||
})
|
})
|
||||||
$: filteredHelpers = helpers?.filter(helper => {
|
$: filteredHelpers = helpers?.filter(helper => {
|
||||||
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
|
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Adds a HBS helper to the expression
|
||||||
|
const addHelper = helper => {
|
||||||
|
hbsValue = addHBSBinding(value, getCaretPosition(), helper.text)
|
||||||
|
dispatch("change", hbsValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a data binding to the expression
|
||||||
|
const addBinding = binding => {
|
||||||
|
if (usingJS) {
|
||||||
|
let js = decodeJSBinding(jsValue)
|
||||||
|
js = addJSBinding(js, getCaretPosition(), binding.readableBinding)
|
||||||
|
jsValue = encodeJSBinding(js)
|
||||||
|
dispatch("change", jsValue)
|
||||||
|
} else {
|
||||||
|
hbsValue = addHBSBinding(
|
||||||
|
hbsValue,
|
||||||
|
getCaretPosition(),
|
||||||
|
binding.readableBinding
|
||||||
|
)
|
||||||
|
dispatch("change", hbsValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeMode = e => {
|
||||||
|
mode = e.detail
|
||||||
|
dispatch("change", mode === "JavaScript" ? jsValue : hbsValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeHBSValue = e => {
|
||||||
|
hbsValue = e.detail
|
||||||
|
dispatch("change", hbsValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChangeJSValue = e => {
|
||||||
|
jsValue = encodeJSBinding(e.detail)
|
||||||
|
dispatch("change", jsValue)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
|
@ -36,32 +92,24 @@
|
||||||
<div class="heading">Search</div>
|
<div class="heading">Search</div>
|
||||||
<Search placeholder="Search" bind:value={search} />
|
<Search placeholder="Search" bind:value={search} />
|
||||||
</section>
|
</section>
|
||||||
{#if filteredColumns?.length}
|
{#if filteredBindings?.length}
|
||||||
<section>
|
<section>
|
||||||
<div class="heading">Bindable Values</div>
|
<div class="heading">Bindable Values</div>
|
||||||
<ul>
|
<ul>
|
||||||
{#each filteredColumns as { readableBinding }}
|
{#each filteredBindings as binding}
|
||||||
<li
|
<li on:click={() => addBinding(binding)}>
|
||||||
on:click={() => {
|
{binding.readableBinding}
|
||||||
value = addToText(value, getCaretPosition(), readableBinding)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{readableBinding}
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
{#if filteredHelpers?.length}
|
{#if filteredHelpers?.length && !usingJS}
|
||||||
<section>
|
<section>
|
||||||
<div class="heading">Helpers</div>
|
<div class="heading">Helpers</div>
|
||||||
<ul>
|
<ul>
|
||||||
{#each filteredHelpers as helper}
|
{#each filteredHelpers as helper}
|
||||||
<li
|
<li on:click={() => addHelper(helper)}>
|
||||||
on:click={() => {
|
|
||||||
value = addToText(value, getCaretPosition(), helper.text)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="helper">
|
<div class="helper">
|
||||||
<div class="helper__name">{helper.displayText}</div>
|
<div class="helper__name">{helper.displayText}</div>
|
||||||
<div class="helper__description">
|
<div class="helper__description">
|
||||||
|
@ -77,24 +125,56 @@
|
||||||
</div>
|
</div>
|
||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<TextArea
|
<Tabs selected={mode} on:select={onChangeMode}>
|
||||||
bind:getCaretPosition
|
<Tab title="Handlebars">
|
||||||
bind:value
|
<div class="main-content">
|
||||||
placeholder="Add text, or click the objects on the left to add them to the textbox."
|
<TextArea
|
||||||
/>
|
bind:getCaretPosition
|
||||||
{#if !valid}
|
value={hbsValue}
|
||||||
<p class="syntax-error">
|
on:change={onChangeHBSValue}
|
||||||
Current Handlebars syntax is invalid, please check the guide
|
placeholder="Add text, or click the objects on the left to add them to the textbox."
|
||||||
<a href="https://handlebarsjs.com/guide/">here</a>
|
/>
|
||||||
for more details.
|
{#if !valid}
|
||||||
</p>
|
<p class="syntax-error">
|
||||||
{/if}
|
Current Handlebars syntax is invalid, please check the guide
|
||||||
|
<a href="https://handlebarsjs.com/guide/">here</a>
|
||||||
|
for more details.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
{#if allowJS}
|
||||||
|
<Tab title="JavaScript">
|
||||||
|
<div class="main-content">
|
||||||
|
<Layout noPadding gap="XS">
|
||||||
|
<CodeMirrorEditor
|
||||||
|
bind:getCaretPosition
|
||||||
|
height={200}
|
||||||
|
value={decodeJSBinding(jsValue)}
|
||||||
|
on:change={onChangeJSValue}
|
||||||
|
hints={context?.map(x => `$("${x.readableBinding}")`)}
|
||||||
|
/>
|
||||||
|
<Body size="S">
|
||||||
|
JavaScript expressions are executed as functions, so ensure that
|
||||||
|
your expression returns a value.
|
||||||
|
</Body>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
{/if}
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.main :global(textarea) {
|
.main :global(textarea) {
|
||||||
min-height: 150px !important;
|
min-height: 202px !important;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
margin: calc(-1 * var(--spacing-xl));
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
padding: var(--spacing-s) var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
} from "builderStore/dataBinding"
|
} from "builderStore/dataBinding"
|
||||||
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
|
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
||||||
export let panel = BindingPanel
|
export let panel = BindingPanel
|
||||||
export let value = ""
|
export let value = ""
|
||||||
|
@ -15,11 +16,14 @@
|
||||||
export let label
|
export let label
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let options
|
export let options
|
||||||
|
export let allowJS = true
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
let bindingDrawer
|
let bindingDrawer
|
||||||
|
|
||||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||||
$: tempValue = readableValue
|
$: tempValue = readableValue
|
||||||
|
$: isJS = isJSBinding(value)
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onChange(tempValue)
|
onChange(tempValue)
|
||||||
|
@ -35,7 +39,7 @@
|
||||||
<Combobox
|
<Combobox
|
||||||
{label}
|
{label}
|
||||||
{disabled}
|
{disabled}
|
||||||
value={readableValue}
|
value={isJS ? "(JavaScript function)" : readableValue}
|
||||||
on:change={event => onChange(event.detail)}
|
on:change={event => onChange(event.detail)}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{options}
|
{options}
|
||||||
|
@ -58,6 +62,7 @@
|
||||||
close={handleClose}
|
close={handleClose}
|
||||||
on:change={event => (tempValue = event.detail)}
|
on:change={event => (tempValue = event.detail)}
|
||||||
bindableProperties={bindings}
|
bindableProperties={bindings}
|
||||||
|
{allowJS}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
} from "builderStore/dataBinding"
|
} from "builderStore/dataBinding"
|
||||||
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
|
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
||||||
export let panel = BindingPanel
|
export let panel = BindingPanel
|
||||||
export let value = ""
|
export let value = ""
|
||||||
|
@ -15,12 +16,15 @@
|
||||||
export let label
|
export let label
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let fillWidth
|
export let fillWidth
|
||||||
|
export let allowJS = true
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
let bindingDrawer
|
let bindingDrawer
|
||||||
let valid = true
|
let valid = true
|
||||||
|
|
||||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||||
$: tempValue = readableValue
|
$: tempValue = readableValue
|
||||||
|
$: isJS = isJSBinding(value)
|
||||||
|
|
||||||
const saveBinding = () => {
|
const saveBinding = () => {
|
||||||
onChange(tempValue)
|
onChange(tempValue)
|
||||||
|
@ -36,7 +40,7 @@
|
||||||
<Input
|
<Input
|
||||||
{label}
|
{label}
|
||||||
{disabled}
|
{disabled}
|
||||||
value={readableValue}
|
value={isJS ? "(JavaScript function)" : readableValue}
|
||||||
on:change={event => onChange(event.detail)}
|
on:change={event => onChange(event.detail)}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
/>
|
/>
|
||||||
|
@ -60,6 +64,7 @@
|
||||||
value={readableValue}
|
value={readableValue}
|
||||||
on:change={event => (tempValue = event.detail)}
|
on:change={event => (tempValue = event.detail)}
|
||||||
bindableProperties={bindings}
|
bindableProperties={bindings}
|
||||||
|
{allowJS}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
import { isValid } from "@budibase/string-templates"
|
import { isValid } from "@budibase/string-templates"
|
||||||
import { handlebarsCompletions } from "constants/completions"
|
import { handlebarsCompletions } from "constants/completions"
|
||||||
import { readableToRuntimeBinding } from "builderStore/dataBinding"
|
import { readableToRuntimeBinding } from "builderStore/dataBinding"
|
||||||
import { addToText } from "./utils"
|
import { addHBSBinding } from "./utils"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@
|
||||||
{#each bindings as binding}
|
{#each bindings as binding}
|
||||||
<li
|
<li
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
value = addToText(value, getCaretPosition(), binding)
|
value = addHBSBinding(value, getCaretPosition(), binding)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="binding__label">{binding.label}</span>
|
<span class="binding__label">{binding.label}</span>
|
||||||
|
@ -71,7 +71,7 @@
|
||||||
{#each filteredHelpers as helper}
|
{#each filteredHelpers as helper}
|
||||||
<li
|
<li
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
value = addToText(value, getCaretPosition(), helper.text)
|
value = addHBSBinding(value, getCaretPosition(), helper.text)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="helper">
|
<div class="helper">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export function addToText(value, caretPos, binding) {
|
export function addHBSBinding(value, caretPos, binding) {
|
||||||
binding = typeof binding === "string" ? binding : binding.path
|
binding = typeof binding === "string" ? binding : binding.path
|
||||||
value = value == null ? "" : value
|
value = value == null ? "" : value
|
||||||
if (!value.includes("{{") && !value.includes("}}")) {
|
if (!value.includes("{{") && !value.includes("}}")) {
|
||||||
|
@ -14,3 +14,18 @@ export function addToText(value, caretPos, binding) {
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addJSBinding(value, caretPos, binding) {
|
||||||
|
binding = typeof binding === "string" ? binding : binding.path
|
||||||
|
value = value == null ? "" : value
|
||||||
|
binding = `$("${binding}")`
|
||||||
|
if (caretPos.start) {
|
||||||
|
value =
|
||||||
|
value.substring(0, caretPos.start) +
|
||||||
|
binding +
|
||||||
|
value.substring(caretPos.end, value.length)
|
||||||
|
} else {
|
||||||
|
value += binding
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script>
|
||||||
|
import { Input } from "@budibase/bbui"
|
||||||
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
||||||
|
export let value
|
||||||
|
|
||||||
|
$: isJS = isJSBinding(value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...$$props}
|
||||||
|
value={isJS ? "(JavaScript function)" : value}
|
||||||
|
readonly={isJS}
|
||||||
|
/>
|
|
@ -105,6 +105,7 @@
|
||||||
value={safeValue}
|
value={safeValue}
|
||||||
on:change={e => (tempValue = e.detail)}
|
on:change={e => (tempValue = e.detail)}
|
||||||
bindableProperties={bindings}
|
bindableProperties={bindings}
|
||||||
|
allowJS
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Checkbox, Input, Select, Stepper } from "@budibase/bbui"
|
import { Checkbox, Select, Stepper } from "@budibase/bbui"
|
||||||
import DataSourceSelect from "./DataSourceSelect.svelte"
|
import DataSourceSelect from "./DataSourceSelect.svelte"
|
||||||
import DataProviderSelect from "./DataProviderSelect.svelte"
|
import DataProviderSelect from "./DataProviderSelect.svelte"
|
||||||
import EventsEditor from "./EventsEditor"
|
import EventsEditor from "./EventsEditor"
|
||||||
|
@ -15,6 +15,7 @@ import URLSelect from "./URLSelect.svelte"
|
||||||
import OptionsEditor from "./OptionsEditor/OptionsEditor.svelte"
|
import OptionsEditor from "./OptionsEditor/OptionsEditor.svelte"
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
import FormFieldSelect from "./FormFieldSelect.svelte"
|
||||||
import ValidationEditor from "./ValidationEditor/ValidationEditor.svelte"
|
import ValidationEditor from "./ValidationEditor/ValidationEditor.svelte"
|
||||||
|
import Input from "./Input.svelte"
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
text: Input,
|
text: Input,
|
||||||
|
|
|
@ -1,12 +1,22 @@
|
||||||
import CodeMirror from "codemirror"
|
import CodeMirror from "codemirror"
|
||||||
import "codemirror/lib/codemirror.css"
|
import "codemirror/lib/codemirror.css"
|
||||||
import "codemirror/theme/tomorrow-night-eighties.css"
|
|
||||||
import "codemirror/addon/hint/show-hint.css"
|
// Modes
|
||||||
import "codemirror/theme/neo.css"
|
import "codemirror/mode/javascript/javascript"
|
||||||
import "codemirror/mode/sql/sql"
|
import "codemirror/mode/sql/sql"
|
||||||
import "codemirror/mode/css/css"
|
import "codemirror/mode/css/css"
|
||||||
import "codemirror/mode/handlebars/handlebars"
|
import "codemirror/mode/handlebars/handlebars"
|
||||||
import "codemirror/mode/javascript/javascript"
|
|
||||||
|
// Hints
|
||||||
import "codemirror/addon/hint/show-hint"
|
import "codemirror/addon/hint/show-hint"
|
||||||
|
import "codemirror/addon/hint/show-hint.css"
|
||||||
|
|
||||||
|
// Theming
|
||||||
|
import "codemirror/theme/tomorrow-night-eighties.css"
|
||||||
|
|
||||||
|
// Functional addons
|
||||||
|
import "codemirror/addon/selection/active-line"
|
||||||
|
import "codemirror/addon/edit/closebrackets"
|
||||||
|
import "codemirror/addon/edit/matchbrackets"
|
||||||
|
|
||||||
export default CodeMirror
|
export default CodeMirror
|
||||||
|
|
|
@ -1,13 +1,20 @@
|
||||||
class Helper {
|
class Helper {
|
||||||
constructor(name, fn) {
|
constructor(name, fn, useValueFallback = true) {
|
||||||
this.name = name
|
this.name = name
|
||||||
this.fn = fn
|
this.fn = fn
|
||||||
|
this.useValueFallback = useValueFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
register(handlebars) {
|
register(handlebars) {
|
||||||
// wrap the function so that no helper can cause handlebars to break
|
// wrap the function so that no helper can cause handlebars to break
|
||||||
handlebars.registerHelper(this.name, value => {
|
handlebars.registerHelper(this.name, (value, info) => {
|
||||||
return this.fn(value) || value
|
const context = info?.data?.root || {}
|
||||||
|
const result = this.fn(value, context)
|
||||||
|
if (result == null) {
|
||||||
|
return this.useValueFallback ? value : null
|
||||||
|
} else {
|
||||||
|
return result
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,7 @@ module.exports.HelperFunctionNames = {
|
||||||
OBJECT: "object",
|
OBJECT: "object",
|
||||||
ALL: "all",
|
ALL: "all",
|
||||||
LITERAL: "literal",
|
LITERAL: "literal",
|
||||||
|
JS: "js",
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.LITERAL_MARKER = "%LITERAL%"
|
module.exports.LITERAL_MARKER = "%LITERAL%"
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const Helper = require("./Helper")
|
const Helper = require("./Helper")
|
||||||
const { SafeString } = require("handlebars")
|
const { SafeString } = require("handlebars")
|
||||||
const externalHandlebars = require("./external")
|
const externalHandlebars = require("./external")
|
||||||
|
const { processJS } = require("./javascript")
|
||||||
const {
|
const {
|
||||||
HelperFunctionNames,
|
HelperFunctionNames,
|
||||||
HelperFunctionBuiltin,
|
HelperFunctionBuiltin,
|
||||||
|
@ -17,6 +18,8 @@ const HELPERS = [
|
||||||
new Helper(HelperFunctionNames.OBJECT, value => {
|
new Helper(HelperFunctionNames.OBJECT, value => {
|
||||||
return new SafeString(JSON.stringify(value))
|
return new SafeString(JSON.stringify(value))
|
||||||
}),
|
}),
|
||||||
|
// javascript helper
|
||||||
|
new Helper(HelperFunctionNames.JS, processJS, false),
|
||||||
// this help is applied to all statements
|
// this help is applied to all statements
|
||||||
new Helper(HelperFunctionNames.ALL, value => {
|
new Helper(HelperFunctionNames.ALL, value => {
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
const CAPTURE_JS = new RegExp(/{{ js "(.*)" }}/)
|
||||||
|
const vm = require("vm")
|
||||||
|
|
||||||
|
// Helper utility to strip square brackets from a value
|
||||||
|
const removeSquareBrackets = value => {
|
||||||
|
if (!value || typeof value !== "string") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const regex = /\[+(.+)]+/
|
||||||
|
const matches = value.match(regex)
|
||||||
|
if (matches && matches[1]) {
|
||||||
|
return matches[1]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our context getter function provided to JS code as $.
|
||||||
|
// Extracts a value from context.
|
||||||
|
const getContextValue = (path, context) => {
|
||||||
|
let data = context
|
||||||
|
path.split(".").forEach(key => {
|
||||||
|
if (data == null || typeof data !== "object") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
data = data[removeSquareBrackets(key)]
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluates JS code against a certain context
|
||||||
|
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 run(){${atob(handlebars)}};run();`
|
||||||
|
|
||||||
|
// Our $ context function gets a value from context
|
||||||
|
const sandboxContext = { $: path => getContextValue(path, context) }
|
||||||
|
|
||||||
|
// Create a sandbox with out context and run the JS
|
||||||
|
vm.createContext(sandboxContext)
|
||||||
|
return vm.runInNewContext(js, sandboxContext)
|
||||||
|
} catch (error) {
|
||||||
|
return "Error while executing JS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if a HBS expression is a valid JS HBS expression
|
||||||
|
module.exports.isJSBinding = handlebars => {
|
||||||
|
return module.exports.decodeJSBinding(handlebars) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encodes a raw JS string as a JS HBS expression
|
||||||
|
module.exports.encodeJSBinding = javascript => {
|
||||||
|
return `{{ js "${btoa(javascript)}" }}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decodes a JS HBS expression to the raw JS string
|
||||||
|
module.exports.decodeJSBinding = handlebars => {
|
||||||
|
if (!handlebars || typeof handlebars !== "string") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS is only valid if it is the only HBS expression
|
||||||
|
if (!handlebars.trim().startsWith("{{ js ")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = handlebars.match(CAPTURE_JS)
|
||||||
|
if (!match || match.length < 2) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return atob(match[1])
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ const { registerAll } = require("./helpers/index")
|
||||||
const processors = require("./processors")
|
const processors = require("./processors")
|
||||||
const { removeHandlebarsStatements } = require("./utilities")
|
const { removeHandlebarsStatements } = require("./utilities")
|
||||||
const manifest = require("../manifest.json")
|
const manifest = require("../manifest.json")
|
||||||
|
const JS = require("./helpers/javascript")
|
||||||
|
|
||||||
const hbsInstance = handlebars.create()
|
const hbsInstance = handlebars.create()
|
||||||
registerAll(hbsInstance)
|
registerAll(hbsInstance)
|
||||||
|
@ -159,3 +160,10 @@ module.exports.isValid = string => {
|
||||||
module.exports.getManifest = () => {
|
module.exports.getManifest = () => {
|
||||||
return manifest
|
return manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export utilities for working with JS bindings
|
||||||
|
*/
|
||||||
|
module.exports.isJSBinding = JS.isJSBinding
|
||||||
|
module.exports.decodeJSBinding = JS.decodeJSBinding
|
||||||
|
module.exports.encodeJSBinding = JS.encodeJSBinding
|
|
@ -6,6 +6,9 @@ import templates from "./index.cjs"
|
||||||
export const isValid = templates.isValid
|
export const isValid = templates.isValid
|
||||||
export const makePropSafe = templates.makePropSafe
|
export const makePropSafe = templates.makePropSafe
|
||||||
export const getManifest = templates.getManifest
|
export const getManifest = templates.getManifest
|
||||||
|
export const isJSBinding = templates.isJSBinding
|
||||||
|
export const encodeJSBinding = templates.encodeJSBinding
|
||||||
|
export const decodeJSBinding = templates.decodeJSBinding
|
||||||
export const processStringSync = templates.processStringSync
|
export const processStringSync = templates.processStringSync
|
||||||
export const processObjectSync = templates.processObjectSync
|
export const processObjectSync = templates.processObjectSync
|
||||||
export const processString = templates.processString
|
export const processString = templates.processString
|
||||||
|
|
Loading…
Reference in New Issue