1
0
Fork 0
mirror of synced 2024-07-06 15:00:49 +12:00

Merge commit

This commit is contained in:
Dean 2023-05-26 10:11:51 +01:00
parent 659bffa548
commit 56bac67d49
11 changed files with 1287 additions and 353 deletions

View file

@ -3,11 +3,13 @@
import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte"
import { setContext } from "svelte"
export let title
export let fillWidth
export let left = "314px"
export let width = "calc(100% - 626px)"
export let headless = false
let visible = false
@ -19,12 +21,18 @@
}
export function hide() {
console.log("CLOSE")
if (!visible) {
return
}
visible = false
}
setContext("drawer-actions", {
hide,
show,
})
const easeInOutQuad = x => {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2
}
@ -50,18 +58,20 @@
transition:slide|local
style={`width: ${width}; left: ${left};`}
>
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" />
</div>
</header>
{#if !headless}
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" />
</div>
</header>
{/if}
<slot name="body" />
</section>
</Portal>

View file

@ -77,7 +77,7 @@ export const getAuthBindings = () => {
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`,
key: "accessToken",
display: { name: "OAuthToken" },
display: { name: "OAuthToken", type: "text" },
},
]
@ -434,6 +434,9 @@ export const getUserBindings = () => {
providerId: "user",
category: "Current User",
icon: "User",
display: {
name: key,
},
})
return acc
}, [])
@ -516,7 +519,7 @@ export const makeStateBinding = key => {
readableBinding: `State.${key}`,
category: "State",
icon: "AutomatedSegment",
display: { name: key },
display: { name: key }, //no type
}
}
@ -550,7 +553,7 @@ const getUrlBindings = asset => {
readableBinding: `URL.${param}`,
category: "URL",
icon: "RailTop",
display: { type: "string" },
display: { type: "string", name: param },
}))
const queryParamsBinding = {
type: "context",
@ -558,7 +561,7 @@ const getUrlBindings = asset => {
readableBinding: "Query params",
category: "URL",
icon: "RailTop",
display: { type: "object" },
display: { type: "object", name: "Query params" },
}
return urlParamBindings.concat([queryParamsBinding])
}

View file

@ -29,11 +29,15 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core"
import { getSchemaForTable } from "builderStore/dataBinding"
import {
getSchemaForTable,
getEnvironmentBindings,
} from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
export let block
export let testData
@ -210,6 +214,19 @@
}
const outputs = Object.entries(schema)
let bindingIcon = ""
let bindindingRank = 0
if (idx === 0) {
bindingIcon = automation.trigger.icon
} else if (isLoopBlock) {
bindingIcon = "Reuse"
bindindingRank = idx + 1
} else {
bindingIcon = allSteps[idx].icon
bindindingRank = idx - loopBlockCount
}
bindings = bindings.concat(
outputs.map(([name, value]) => {
let runtimeName = isLoopBlock
@ -219,16 +236,22 @@
: `steps.${idx - loopBlockCount}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName
return {
label: runtime,
readableBinding: runtime,
runtimeBinding: runtime,
type: value.type,
description: value.description,
icon: bindingIcon,
category:
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`,
path: runtime,
display: {
type: value.type,
name: name,
rank: bindindingRank,
},
}
})
)
@ -237,15 +260,12 @@
// Environment bindings
if ($licensing.environmentVariablesEnabled) {
bindings = bindings.concat(
$environment.variables.map(variable => {
getEnvironmentBindings().map(binding => {
return {
label: `env.${variable.name}`,
path: `env.${variable.name}`,
icon: "Key",
category: "Environment",
...binding,
display: {
type: "string",
name: variable.name,
...binding.display,
rank: 98,
},
}
})

View file

@ -0,0 +1,289 @@
<script>
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { API } from "api"
import _ from "lodash"
import {
autocompletion,
closeBrackets,
completionKeymap,
closeBracketsKeymap,
} from "@codemirror/autocomplete"
import {
EditorView,
lineNumbers,
keymap,
highlightSpecialChars,
drawSelection,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightWhitespace,
placeholder as placeholderFn,
MatchDecorator,
ViewPlugin,
Decoration,
} from "@codemirror/view"
import {
bracketMatching,
foldKeymap,
foldGutter,
syntaxHighlighting,
} from "@codemirror/language"
import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
import {
defaultKeymap,
historyKeymap,
history,
indentWithTab,
} from "@codemirror/commands"
import { Compartment } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./"
import { themeStore } from "builderStore"
export let label
export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
return {
start: selection_range.from,
end: selection_range.to,
}
}
export const insertAtPos = opts => {
// Updating the value inside.
// Retain focus
editor.dispatch({
changes: {
from: opts.start || editor.state.doc.length,
to: opts.end || editor.state.doc.length,
insert: opts.value,
},
selection: {
anchor: opts.start + opts.value.length,
},
})
}
// For handlebars only. Demo
const bindStyle = new MatchDecorator({
regexp: /{{[.\[\]\"#-\w\s\/]*}}/g,
decoration: match => {
const url = match[0]
return Decoration.mark({
tag: "span",
attributes: {
class: "binding-wrap",
},
})
},
})
let plugin = ViewPlugin.define(
view => ({
decorations: bindStyle.createDeco(view),
update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
const dispatch = createEventDispatcher()
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
const buildKeymap = () => {
const baseMap = [
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
]
return buildKeymap
}
const buildBaseExtensions = () => {
return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(),
autocompletion({
override: [...completions],
closeOnBlur: false,
icons: false,
optionClass: () => "autocomplete-option",
}),
EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
]
}
const buildExtensions = base => {
const complete = [...base]
if (mode.name == "javascript") {
complete.push(javascript())
complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
complete.push(
EditorView.inputHandler.of((view, from, to, insert) => {
console.log({ view, from, to, insert })
if (insert === "$") {
let { text } = view.state.doc.lineAt(from)
const left = from ? text.substring(0, from) : ""
const right = to ? text.substring(to) : ""
const wrap = !left.includes('$("') || !right.includes('")')
const tr = view.state.update(
{
changes: [{ from, insert: wrap ? '$("")' : "$" }],
selection: {
anchor: from + (wrap ? 3 : 1),
},
},
{
scrollIntoView: true,
}
)
view.dispatch(tr)
return true
}
return false
})
)
}
if (placeholder) {
complete.push(placeholderFn(placeholder))
}
return complete
}
let textarea
let editor
let mounted = false
let isEditorInitialised = false
const initEditor = () => {
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value,
extensions: buildExtensions(baseExtensions),
parent: textarea,
})
}
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
initEditor()
}
// Theme change
$: if (mounted && isEditorInitialised && $themeStore?.theme) {
if (currentTheme != $themeStore?.theme) {
currentTheme = $themeStore?.theme
isDark = !currentTheme.includes("light")
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([
getDefaultTheme({
height,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
})
}
}
onMount(async () => {
mounted = true
return () => {
if (editor) {
editor.destroy()
}
}
})
</script>
{#if label}
<div>
<Label small>{label}</Label>
</div>
{/if}
<div class="code-editor">
<div tabindex="-1" bind:this={textarea} />
</div>
<style>
/* Push into theme */
.code-editor :global(.cm-tooltip.cm-completionInfo) {
padding: var(--spacing-m);
}
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
border-radius: var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
}
.code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
/* font-weight: 600; */
}
</style>

View file

@ -0,0 +1,356 @@
import { EditorView } from "@codemirror/view"
// import { insertCompletionText } from "@codemirror/autocomplete"
import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html"
import { groupBy } from "lodash"
// Really just Javascript and Text
export const EditorModes = {
JS: {
name: "javascript",
json: false,
match: /\$$/,
},
JSON: {
name: "javascript",
json: true,
},
XML: {
name: "xml",
},
SQL: {
name: "sql",
},
Handlebars: {
name: "handlebars",
base: "text/html",
match: /{{[\s]*[\w\s]*/,
},
Text: {
name: "text/html",
},
}
// Get a generalised approach to constants in the dataBindings file?
export const SECTIONS = {
HB_HELPER: {
name: "Helper",
type: "helper",
icon: "Code",
},
}
export const getDefaultTheme = opts => {
const { height, resize, dark } = opts
return EditorView.theme(
{
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--spectrum-alias-text-color)",
},
"&": {
height: height ? `${height}px` : "",
lineHeight: "1.3",
border:
"var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)",
borderRadius: "var(--border-radius-s)",
backgroundColor:
"var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )",
resize: resize ? `${resize}` : "",
overflow: "hidden",
color: "var(--spectrum-alias-text-color)",
},
"& .cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily:
"var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))",
},
"& .cm-placeholder": {
color: "var(--spectrum-alias-text-color)",
fontStyle: "italic",
},
"&.cm-focused": {
outline: "none",
borderColor: "var(--spectrum-alias-border-color-mouse-focus)",
},
// AUTO COMPLETE
"& .cm-completionDetail": {
fontStyle: "unset",
textTransform: "uppercase",
},
"& .info-bubble": {
fontSize: "var(--font-size-s)",
display: "grid",
gridGap: "var(--spacing-s)",
gridTemplateColumns: "1fr",
},
"& .cm-tooltip": {
marginLeft: "var(--spacing-s)",
border: "1px solid var(--spectrum-global-color-gray-300)",
borderRadius:
"var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )",
},
// Section header
"& .info-section": {
display: "flex",
padding: "var(--spacing-s)",
gap: "var(--spacing-m)",
borderBottom: "1px solid var(--spectrum-global-color-gray-300)",
},
// Autocomplete Option
"& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "var(--spectrum-alias-font-size-default)",
padding: "var(--spacing-s)",
},
"& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": {
backgroundColor: "var(--spectrum-global-color-gray-200)",
color: "var(--ink)",
},
"& .binding-wrap": {
color: "chartreuse",
},
},
{ dark }
)
}
export const buildHelperInfoNode = (completion, helper, mode) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = helper.example
? `<div class="binding__example">${helper.example}</div>`
: ""
const descriptionMarkup = sanitizeHtml(helper.description, {
allowedTags: [],
allowedAttributes: {},
})
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
const toSpectrumIcon = name => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="false"
aria-label="${name}-section-icon"
>
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
</svg>`
}
export const buildSectionHeader = (type, sectionName, icon, rank) => {
const ele = document.createElement("div")
ele.classList.add("info-section")
ele.classList.add(type)
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
return {
name: sectionName,
header: () => ele,
rank,
}
}
export const helpersToCompletion = helpers => {
const { type, name: sectionName, icon } = SECTIONS.HB_HELPER
const helperSection = buildSectionHeader(type, sectionName, icon, 99)
return Object.keys(helpers).reduce((acc, key) => {
let helper = helpers[key]
acc.push({
label: key,
info: completion => {
return buildHelperInfoNode(completion, helper)
},
type: "helper",
section: helperSection,
detail: "FUNCTION",
})
return acc
}, [])
}
export const getHelperCompletions = () => {
const manifest = getManifest()
return Object.keys(manifest).reduce((acc, key) => {
acc = acc || []
return [...acc, ...helpersToCompletion(manifest[key])]
}, [])
}
export const hbAutocomplete = baseCompletions => {
async function coreCompletion(context) {
let bindingStart = context.matchBefore(EditorModes.Handlebars.match)
let options = baseCompletions || []
if (!bindingStart) {
return null
}
return {
from: bindingStart.from + 2,
filter: true,
options,
}
}
return coreCompletion
}
export const jsAutocomplete = baseCompletions => {
async function coreCompletion(context) {
let jsBinding = context.matchBefore(/\$\(\"[\s\w]*/)
let options = baseCompletions || []
if (!jsBinding) {
console.log("leaving")
return {
from: context.pos,
filter: true,
options,
}
}
return {
from: jsBinding.from + 3,
filter: true,
options,
}
}
return coreCompletion
}
export const buildBindingInfoNode = (completion, binding) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = binding.readableBinding
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
: ""
const descriptionNodeHtml = binding.description
? `<div class="binding__description">${binding.description}</div>`
: ""
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
// Readdress these methods. They shouldn't be used
export const hbInsert = (value, from, to, text) => {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
const right = to ? value.substring(to) : ""
if (!left.includes("{{") || !right.includes("}}")) {
parsedInsert = `{{ ${text} }}`
} else {
parsedInsert = ` ${text} `
}
return parsedInsert
}
export function jsInsert(value, from, to, text, { helper } = {}) {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
const right = to ? value.substring(to) : ""
if (helper) {
parsedInsert = `helpers.${text}()`
} else if (!left.includes('$("') || !right.includes('")')) {
parsedInsert = `$("${text}")`
} else {
parsedInsert = text
}
return parsedInsert
}
// Autocomplete apply behaviour
export const insertBinding = (view, from, to, text, mode) => {
let parsedInsert
if (mode.name == "javascript") {
parsedInsert = jsInsert(view.state.doc?.toString(), from, to, text)
} else if (mode.name == "handlebars") {
parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text)
} else {
console.log("Unsupported")
return
}
view.dispatch({
changes: {
from,
to,
insert: parsedInsert,
},
selection: {
anchor: from + parsedInsert.length,
},
})
}
export const bindingsToCompletions = (bindings, mode) => {
// REFACTOR OUT
const bindingByCategory = groupBy(bindings, "category")
const categoryToIcon = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
// REFACTOR OUT
const categoryToRank = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.display.rank
}
return acc
}, {})
const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => {
// REFACTOR OUT
const bindindSectionHeader = buildSectionHeader(
bindingByCategory.type,
catKey,
categoryToIcon[catKey] || "",
categoryToRank[catKey] || 1
)
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label: binding.display?.name || "NO NAME",
info: completion => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindindSectionHeader,
apply: (view, completion, from, to) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
}, [])
return completions
}

View file

@ -2,7 +2,7 @@
import groupBy from "lodash/fp/groupBy"
import {
Search,
TextArea,
Input,
DrawerContent,
Tabs,
Tab,
@ -24,10 +24,21 @@
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { handlebarsCompletions } from "constants/completions"
import { addHBSBinding, addJSBinding } from "./utils"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { convertToJS } from "@budibase/string-templates"
import { admin } from "stores/portal"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
hbAutocomplete,
EditorModes,
bindingsToCompletions,
hbInsert,
jsInsert,
} from "../CodeEditor"
import { getContext } from "svelte"
import BindingPicker from "./BindingPicker.svelte"
const dispatch = createEventDispatcher()
@ -41,14 +52,18 @@
export let allowJS = false
export let allowHelpers = true
const drawerActions = getContext("drawer-actions")
const bindingDrawerActions = getContext("binding-drawer-actions")
let helpers = handlebarsCompletions()
let getCaretPosition
let insertAtPos
let search = ""
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
let mode = initialValueJS ? "JavaScript" : "Handlebars"
let mode = initialValueJS ? "JavaScript" : "Text"
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let sidebar = true
let selectedCategory = null
let popover
@ -59,6 +74,9 @@
$: searchRgx = new RegExp(search, "ig")
$: categories = Object.entries(groupBy("category", bindings))
$: editorMode = mode == "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
$: bindingCompletions = bindingsToCompletions(bindings, editorMode)
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
@ -88,8 +106,6 @@
$: categoryNames = getCategoryNames(categories)
$: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`)
const updateValue = val => {
valid = isValid(readableToRuntimeBinding(bindings, val))
if (valid) {
@ -106,34 +122,29 @@
}
// Adds a JS/HBS helper to the expression
const addHelper = (helper, js) => {
let tempVal
const onSelectHelper = (helper, js) => {
const pos = getCaretPosition()
const { start, end } = pos
if (js) {
const decoded = decodeJSBinding(jsValue)
tempVal = jsValue = encodeJSBinding(
addJSBinding(decoded, pos, helper.text, { helper: true })
)
let js = decodeJSBinding(jsValue)
const insertVal = jsInsert(js, start, end, helper.text, { helper: true })
insertAtPos({ start, end, value: insertVal })
} else {
tempVal = hbsValue = addHBSBinding(hbsValue, pos, helper.text)
const insertVal = hbInsert(hbsValue, start, end, helper.text)
insertAtPos({ start, end, value: insertVal })
}
updateValue(tempVal)
}
// Adds a data binding to the expression
const addBinding = (binding, { forceJS } = {}) => {
const onSelectBinding = (binding, { forceJS } = {}) => {
const { start, end } = getCaretPosition()
if (usingJS || forceJS) {
let js = decodeJSBinding(jsValue)
js = addJSBinding(js, getCaretPosition(), binding.readableBinding)
jsValue = encodeJSBinding(js)
updateValue(jsValue)
const insertVal = jsInsert(js, start, end, binding.readableBinding)
insertAtPos({ start, end, value: insertVal })
} else {
hbsValue = addHBSBinding(
hbsValue,
getCaretPosition(),
binding.readableBinding
)
updateValue(hbsValue)
const insertVal = hbInsert(hbsValue, start, end, binding.readableBinding)
insertAtPos({ start, end, value: insertVal })
}
}
@ -158,18 +169,7 @@
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
hbsValue = null
mode = "JavaScript"
addBinding("", { forceJS: true })
}
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
onSelectBinding("", { forceJS: true })
}
onMount(() => {
@ -177,242 +177,125 @@
})
</script>
<span class="detailPopover">
<Popover
align="right-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<DrawerContent>
<svelte:fragment slot="sidebar">
<Layout noPadding gap="S">
{#if selectedCategory}
<div>
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="heading">Search</div>
<Search placeholder="Search" bind:value={search} />
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if}
</li>
{/each}
</ul>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, usingJS)}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(helper, usingJS),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</Layout>
</svelte:fragment>
<div class="main">
<Tabs selected={mode} on:select={onChangeMode}>
<Tab title="Handlebars">
<div class="main-content">
<TextArea
bind:getCaretPosition
value={hbsValue}
on:change={onChangeHBSValue}
placeholder="Add text, or click the objects on the left to add them to the textbox."
/>
{#if !valid}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
{#if $admin.isDev && allowJS}
<div class="convert">
<Button secondary on:click={convert}>Convert to JS</Button>
<Tab title="Text">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<CodeEditor
value={hbsValue}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
completions={[
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(),
]),
]}
placeholder="Add text, or click the objects on the left to add them to the textbox."
/>
<div class="binding-footer">
<div class="messaging">
{#if !valid}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
</div>
<div class="actions">
{#if $admin.isDev && allowJS}
<ActionButton secondary>Convert To JS</ActionButton>
{/if}
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content">
<Layout noPadding gap="XS">
<CodeMirrorEditor
bind:getCaretPosition
height={200}
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
hints={codeMirrorHints}
completions={[
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(),
]),
]}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
/>
<Body size="S">
JavaScript expressions are executed as functions, so ensure that
your expression returns a value.
</Body>
</Layout>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
<div class="drawer-actions">
<Button
secondary
quiet
on:click={() => {
console.log(drawerActions)
drawerActions.hide()
}}
>
Cancel
</Button>
<Button
cta
on:click={() => {
bindingDrawerActions.save()
}}
>
Save
</Button>
</div>
</Tabs>
</div>
</DrawerContent>
<style>
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
.main :global(textarea) {
min-height: 202px !important;
}
@ -423,82 +306,20 @@
padding: var(--spacing-s) var(--spacing-xl);
}
.heading,
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
.main :global(.spectrum-Tabs div.drawer-actions) {
display: flex;
gap: var(--spacing-m);
align-items: center;
margin-left: auto;
}
ul {
list-style: none;
padding: 0;
margin: 0;
.main :global(.spectrum-Tabs-content),
.main :global(.spectrum-Tabs-content .main-content) {
margin-top: 0px;
padding: 0px;
}
li {
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-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
.helper {
.main :global(.spectrum-Tabs) {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xs);
}
.helper__name {
font-weight: bold;
}
.helper__description,
.helper__description :global(*) {
color: var(--spectrum-global-color-gray-700);
}
.helper__example {
white-space: normal;
margin: 0.5rem 0 0 0;
font-weight: 700;
}
.helper__description :global(p) {
margin: 0;
}
.syntax-error {
@ -511,7 +332,27 @@
text-decoration: underline;
}
.convert {
padding-top: var(--spacing-m);
.binding-footer {
width: 100%;
display: flex;
justify-content: space-between;
margin-top: var(--spacing-m);
}
.main-content {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 270px;
}
.main-content.binding-panel {
grid-template-columns: 1fr 320px;
}
.binding-picker {
overflow-y: auto;
border-left: 2px solid var(--border-light);
border-left: var(--border-light);
}
.editor {
padding: var(--spacing-xl);
padding-bottom: 0px;
}
</style>

View file

@ -0,0 +1,395 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
import { handlebarsCompletions } from "constants/completions"
export let addHelper
export let addBinding
export let bindings
export let mode
export let allowHelpers
let search = ""
let popover
let popoverAnchor
let hoverTarget
let helpers = handlebarsCompletions()
let selectedCategory
$: searchRgx = new RegExp(search, "ig")
// Icons
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: console.log(filteredCategories)
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
</script>
<span class="detailPopover">
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<Layout noPadding gap="S">
{#if selectedCategory}
<div class="sub-section-back">
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="search">
<span class="search-input">
<Input
placeholder={"Search for bindings"}
autocomplete="off"
bind:value={search}
/>
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="search-input-icon"
on:click={() => {
if (!search) {
return
}
search = null
}}
class:searching={search}
>
<Icon name={search ? "Close" : "Search"} />
</span>
</div>
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="sub-section">
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="sub-section">
<div class="cat-heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, mode.name == "javascript")}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(
helper,
mode.name == "javascript"
),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</Layout>
<style>
.search :global(input) {
border: none;
border-radius: 0px;
background: none;
padding: 0px;
}
.search {
padding: var(--spacing-m) var(--spacing-l);
display: flex;
align-items: center;
border-top: 0px;
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
position: sticky;
top: 0;
background-color: var(--background);
z-index: 2;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
ul.category-list {
padding: 0px var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.sub-section {
padding: var(--spacing-l);
padding-top: 0px;
}
.sub-section-back {
padding: var(--spacing-l);
padding-top: var(--spacing-xl);
padding-bottom: 0px;
}
.cat-heading {
margin-bottom: var(--spacing-l);
}
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
:global(.drawer-actions) {
display: flex;
gap: var(--spacing-m);
}
.heading,
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
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-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
</style>

View file

@ -5,7 +5,7 @@
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
@ -34,6 +34,10 @@
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: handleClose,
})
const onChange = (value, optionPicked) => {
// Add HBS braces if picking binding
if (optionPicked && !options?.includes(value)) {
@ -63,7 +67,6 @@
on:pick={e => onChange(e.detail, true)}
on:blur={() => dispatch("blur")}
{placeholder}
options={allOptions}
{error}
/>
{#if !disabled}
@ -77,6 +80,7 @@
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
Save
</Button>

View file

@ -5,7 +5,7 @@
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
@ -36,6 +36,10 @@
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: saveBinding,
})
const onChange = value => {
currentVal = readableToRuntimeBinding(bindings, value)
dispatch("change", currentVal)
@ -63,7 +67,13 @@
</div>
{/if}
</div>
<Drawer {fillWidth} bind:this={bindingDrawer} {title} left={drawerLeft}>
<Drawer
{fillWidth}
bind:this={bindingDrawer}
{title}
left={drawerLeft}
headless
>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>

View file

@ -17,14 +17,14 @@ import URLSelect from "./controls/URLSelect.svelte"
import OptionsEditor from "./controls/OptionsEditor/OptionsEditor.svelte"
import FormFieldSelect from "./controls/FormFieldSelect.svelte"
import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
const componentMap = {
text: DrawerBindableCombobox,
text: DrawerBindableInput,
select: Select,
radio: RadioGroup,
dataSource: DataSourceSelect,

View file

@ -101,7 +101,13 @@
}
// Ignore events when typing
const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") {
const inCodeEditor =
document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
console.log("KEY PRESS")
return
}
// Key events are always for the selected component