budibase/packages/builder/src/components/common/bindings/BindingPanel.svelte

459 lines
12 KiB
Svelte

<script>
import {
DrawerContent,
ActionButton,
Icon,
Heading,
Body,
Button,
} from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte"
import {
decodeJSBinding,
encodeJSBinding,
processStringSync,
} from "@budibase/string-templates"
import { readableToRuntimeBinding } from "dataBinding"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import {
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 { licensing } from "stores/portal"
const dispatch = createEventDispatcher()
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 Modes = {
Text: "Text",
JavaScript: "JavaScript",
}
const SidePanels = {
Bindings: "FlashOn",
Evaluation: "Play",
Snippets: "Code",
}
let mode
let sidePanel
let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let getCaretPosition
let insertAtPos
let targetMode = null
let expressionResult
let evaluating = false
$: 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
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
$: 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 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 && Object.keys(context).length > 0) {
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 requestEval = (expression, context, snippets) => {
evaluating = true
debouncedEval(expression, context, snippets)
}
const getBindingValue = (binding, context, snippets) => {
const js = `return $("${binding.runtimeBinding}")`
const hbs = encodeJSBinding(js)
const res = processStringSync(hbs, { ...context, snippets })
return JSON.stringify(res, null, 2)
}
const highlightJSON = json => {
return formatHighlight(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
trueColor: "#d19a66",
falseColor: "#d19a66",
nullColor: "#c678dd",
})
}
const enrichBindings = (bindings, context, snippets) => {
return bindings.map(binding => {
if (!context) {
return binding
}
const value = getBindingValue(binding, context, snippets)
return {
...binding,
value,
valueHTML: highlightJSON(value),
}
})
}
const updateValue = val => {
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
dispatch("change", val)
requestEval(runtimeExpression, context, snippets)
}
const onSelectHelper = (helper, js) => {
bindingHelpers.onSelectHelper(js ? jsValue : hbsValue, helper, { js })
}
const onSelectBinding = (binding, { forceJS } = {}) => {
const js = usingJS || forceJS
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
}
const changeMode = newMode => {
if (targetMode || newMode === mode) {
return
}
// 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
}
}
const confirmChangeMode = () => {
jsValue = null
hbsValue = null
updateValue(null)
mode = targetMode
targetMode = null
}
const changeSidePanel = newSidePanel => {
sidePanel = newSidePanel === sidePanel ? null : newSidePanel
}
const onChangeHBSValue = e => {
hbsValue = e.detail
updateValue(hbsValue)
}
const onChangeJSValue = e => {
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue("")
} else {
updateValue(encodeJSBinding(e.detail))
}
}
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">
{#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>
{/if}
<div class="editor">
{#if mode === Modes.Text}
{#key hbsCompletions}
<CodeEditor
value={hbsValue}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
completions={hbsCompletions}
autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"}
jsBindingWrapping={false}
/>
{/key}
{:else if mode === Modes.JavaScript}
{#key jsCompletions}
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
completions={jsCompletions}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping
/>
{/key}
{/if}
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
Switch to {targetMode}?
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep {mode}
</Button>
<Button cta size="S" on:click={confirmChangeMode}>
Yes - discard {mode}
</Button>
</div>
</div>
</div>
{/if}
</div>
</div>
<div class="side" class:visible={!!sidePanel}>
{#if sidePanel === SidePanels.Bindings}
<BindingSidePanel
bindings={enrichedBindings}
{allowHelpers}
{context}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
{:else if sidePanel === SidePanels.Evaluation}
<EvaluationSidePanel
{expressionResult}
{evaluating}
expression={editorValue}
/>
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
/>
{/if}
</div>
</div>
</DrawerContent>
<style>
.binding-panel {
height: 100%;
overflow: hidden;
}
.binding-panel,
.tabs {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
}
.main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.side {
overflow: hidden;
flex: 0 0 360px;
margin-right: -360px;
transition: margin-right 130ms ease-out;
}
.side.visible {
margin-right: 0;
}
/* Tabs */
.tabs {
padding: var(--spacing-m);
border-bottom: var(--border-light);
}
.editor-tabs,
.side-tabs {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
}
.side-tabs :global(.icon) {
width: 16px;
display: flex;
}
/* Editor */
.editor {
flex: 1 1 auto;
height: 0;
position: relative;
}
/* Overlay */
.mode-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border-radius: var(--border-radius-s);
}
.prompt-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-l);
}
.prompt-body .switch-actions {
display: flex;
gap: var(--spacing-l);
}
</style>