459 lines
12 KiB
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>
|