diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 2cb681670b..01555446d9 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -56,6 +56,8 @@ export default function positionDropdown(element, opts) { styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width } else if (align === "right-outside") { styles.left = anchorBounds.right + offset + } else if (align === "left-outside") { + styles.left = anchorBounds.left - elementBounds.width - offset } else { styles.left = anchorBounds.left } diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 932236bc0c..18ac15dffa 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -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 @@ -25,6 +27,11 @@ 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 +57,20 @@ transition:slide|local style={`width: ${width}; left: ${left};`} > -
-
- {title} - - - -
-
- - -
-
+ {#if !headless} +
+
+ {title} + + + +
+
+ + +
+
+ {/if} diff --git a/packages/bbui/src/Tabs/Tabs.svelte b/packages/bbui/src/Tabs/Tabs.svelte index 7184aedbaf..4cc22b226e 100644 --- a/packages/bbui/src/Tabs/Tabs.svelte +++ b/packages/bbui/src/Tabs/Tabs.svelte @@ -12,6 +12,7 @@ export let emphasized = false export let onTop = false export let size = "M" + export let beforeSwitch = null let thisSelected = undefined @@ -28,9 +29,14 @@ thisSelected = selected dispatch("select", thisSelected) } else if ($tab.title !== thisSelected) { - thisSelected = $tab.title - selected = $tab.title - dispatch("select", thisSelected) + if (typeof beforeSwitch == "function") { + const proceed = beforeSwitch($tab.title) + if (proceed) { + thisSelected = $tab.title + selected = $tab.title + dispatch("select", thisSelected) + } + } } if ($tab.title !== thisSelected) { tab.update(state => { diff --git a/packages/builder/package.json b/packages/builder/package.json index 561dcf6464..73bc003343 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -63,6 +63,13 @@ "@budibase/shared-core": "0.0.0", "@budibase/string-templates": "0.0.0", "@budibase/types": "0.0.0", + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/commands": "^6.2.4", + "@codemirror/lang-javascript": "^6.1.8", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.2.0", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.11.2", "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 0d41931a55..d6bc358d39 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -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 }, []) @@ -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]) } diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 4f2a1bef6f..7f83b2b464 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -71,6 +71,7 @@ const INITIAL_FRONTEND_STATE = { customTheme: {}, previewDevice: "desktop", highlightedSettingKey: null, + propertyFocus: null, builderSidePanel: false, hasLock: true, @@ -1326,6 +1327,12 @@ export const getFrontendStore = () => { highlightedSettingKey: key, })) }, + propertyFocus: key => { + store.update(state => ({ + ...state, + propertyFocus: key, + })) + }, }, dnd: { start: component => { diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 35c9c6ad6d..cd0aa1197f 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -29,7 +29,10 @@ 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" @@ -210,6 +213,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 @@ -218,17 +234,24 @@ ? `steps[${idx - loopBlockCount}].${name}` : `steps.${idx - loopBlockCount}.${name}` const runtime = idx === 0 ? `trigger.${name}` : runtimeName + const categoryName = + idx === 0 + ? "Trigger outputs" + : isLoopBlock + ? "Loop Outputs" + : `Step ${idx - loopBlockCount} outputs` return { - label: runtime, + readableBinding: runtime, + runtimeBinding: runtime, type: value.type, description: value.description, - category: - idx === 0 - ? "Trigger outputs" - : isLoopBlock - ? "Loop Outputs" - : `Step ${idx - loopBlockCount} outputs`, - path: runtime, + icon: bindingIcon, + category: categoryName, + 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, }, } }) diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte new file mode 100644 index 0000000000..b526d5a3b4 --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -0,0 +1,287 @@ + + +{#if label} +
+ +
+{/if} + +
+
+
+ + diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js new file mode 100644 index 0000000000..8815b1aec2 --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -0,0 +1,373 @@ +import { EditorView } from "@codemirror/view" +import { getManifest } from "@budibase/string-templates" +import sanitizeHtml from "sanitize-html" +import { groupBy } from "lodash" + +export const EditorModes = { + JS: { + name: "javascript", + json: false, + match: /\$$/, + }, + Handlebars: { + name: "handlebars", + base: "text/html", + match: /{{[\s]*[\w\s]*/, + }, + Text: { + name: "text/html", + }, +} + +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}` : "", + 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))", + maxHeight: "16em", + }, + "& .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", + fontSize: "10px", + }, + "& .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: "var(--spectrum-global-color-blue-700)", + }, + }, + { dark } + ) +} + +export const buildHelperInfoNode = (completion, helper) => { + const ele = document.createElement("div") + ele.classList.add("info-bubble") + + const exampleNodeHtml = helper.example + ? `
${helper.example}
` + : "" + const descriptionMarkup = sanitizeHtml(helper.description, { + allowedTags: [], + allowedAttributes: {}, + }) + const descriptionNodeHtml = `
${descriptionMarkup}
` + + ele.innerHTML = ` + ${exampleNodeHtml} + ${descriptionNodeHtml} + ` + return ele +} + +const toSpectrumIcon = name => { + return ` + + ` +} + +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)}${sectionName}` + return { + name: sectionName, + header: () => ele, + rank, + } +} + +export const helpersToCompletion = (helpers, mode) => { + 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", + apply: (view, completion, from, to) => { + insertBinding(view, from, to, key, mode) + }, + }) + return acc + }, []) +} + +export const getHelperCompletions = mode => { + const manifest = getManifest() + return Object.keys(manifest).reduce((acc, key) => { + acc = acc || [] + return [...acc, ...helpersToCompletion(manifest[key], mode)] + }, []) +} + +const bindingFilter = (options, query) => { + return options.filter(completion => { + const section_parsed = completion.section.name.toLowerCase() + const label_parsed = completion.label.toLowerCase() + const query_parsed = query.toLowerCase() + + return ( + section_parsed.includes(query_parsed) || + label_parsed.includes(query_parsed) + ) + }) +} + +export const hbAutocomplete = baseCompletions => { + async function coreCompletion(context) { + let bindingStart = context.matchBefore(EditorModes.Handlebars.match) + + let options = baseCompletions || [] + + if (!bindingStart) { + return null + } + // Accommodate spaces + const match = bindingStart.text.match(/{{[\s]*/) + const query = bindingStart.text.replace(match[0], "") + let filtered = bindingFilter(options, query) + + return { + from: bindingStart.from + match[0].length, + filter: false, + options: filtered, + } + } + + return coreCompletion +} + +export const jsAutocomplete = baseCompletions => { + async function coreCompletion(context) { + let jsBinding = context.matchBefore(/\$\("[\s\w]*/) + let options = baseCompletions || [] + + if (jsBinding) { + // Accommodate spaces + const match = jsBinding.text.match(/\$\("[\s]*/) + const query = jsBinding.text.replace(match[0], "") + let filtered = bindingFilter(options, query) + return { + from: jsBinding.from + match[0].length, + filter: false, + options: filtered, + } + } + + return null + } + + return coreCompletion +} + +export const buildBindingInfoNode = (completion, binding) => { + const ele = document.createElement("div") + ele.classList.add("info-bubble") + + const exampleNodeHtml = binding.readableBinding + ? `
{{ ${binding.readableBinding} }}
` + : "" + + const descriptionNodeHtml = binding.description + ? `
${binding.description}
` + : "" + + 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 + } + + let bindingClosePattern = mode.name == "javascript" ? /[\s]*"\)/ : /[\s]*}}/ + let sliced = view.state.doc?.toString().slice(to) + + const rightBrace = sliced.match(bindingClosePattern) + let cursorPos = from + parsedInsert.length + + if (rightBrace) { + cursorPos = from + parsedInsert.length + rightBrace[0].length + } + + view.dispatch({ + changes: { + from, + to, + insert: parsedInsert, + }, + selection: { + anchor: cursorPos, + }, + }) +} + +export const bindingsToCompletions = (bindings, mode) => { + const bindingByCategory = groupBy(bindings, "category") + const categoryMeta = bindings?.reduce((acc, ele) => { + acc[ele.category] = acc[ele.category] || {} + + if (ele.icon) { + acc[ele.category]["icon"] = acc[ele.category]["icon"] || ele.icon + } + if (typeof ele.display?.rank == "number") { + acc[ele.category]["rank"] = acc[ele.category]["rank"] || ele.display.rank + } + return acc + }, {}) + + const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => { + const { icon, rank } = categoryMeta[catKey] || {} + + const bindindSectionHeader = buildSectionHeader( + bindingByCategory.type, + catKey, + icon || "", + typeof rank == "number" ? rank : 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 +} diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 3a1c6c4fee..f74a79c387 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -1,17 +1,13 @@ - - - -
- {#if hoverTarget.title} -
{hoverTarget.title}
- {/if} - {#if hoverTarget.description} -
- {@html hoverTarget.description} -
- {/if} - {#if hoverTarget.example} -
{hoverTarget.example}
- {/if} -
-
-
-
+ + +
+ { + if (selectedMode == mode) { + return true + } - - - - {#if selectedCategory} -
- { - selectedCategory = null - }} - > - Back - -
- {/if} + //Get the current mode value + const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue - {#if !selectedCategory} -
Search
- - {/if} - - {#if !selectedCategory && !search} -
    - {#each categoryNames as categoryName} -
  • { - selectedCategory = categoryName - }} - > - - {categoryName} - -
  • - {/each} -
- {/if} - - {#if selectedCategory || search} - {#each filteredCategories as category} - {#if category.bindings?.length} -
- {category.name} -
-
    - {#each category.bindings as binding} -
  • { - 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)} - > - - {#if binding.display?.name} - {binding.display.name} - {:else if binding.fieldSchema?.name} - {binding.fieldSchema?.name} - {:else} - {binding.readableBinding} - {/if} - - - {#if binding.display?.type || binding.fieldSchema?.type} - - - {binding.display?.type || binding.fieldSchema?.type} - - + if (editorValue) { + targetMode = selectedMode + return false + } + return true + }} + > + +
    +
    +
    + {#if targetMode} +
    +
    + + {`Switch to ${targetMode}?`} + + This will discard anything in your binding +
    + + +
    +
    +
    + {/if} + +
    +
  • - {/each} -
- {/if} - {/each} - - {#if selectedCategory === "Helpers" || search} - {#if filteredHelpers?.length} -
Helpers
-
    - {#each filteredHelpers as helper} -
  • 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={() => {}} - > - {helper.displayText} - - function - -
  • - {/each} -
- {/if} - {/if} - {/if} -
-
-
- - -
-