Merge branch 'master' into execute-script-v2

This commit is contained in:
deanhannigan 2025-03-03 15:37:55 +00:00 committed by GitHub
commit abc96b46e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 281 additions and 363 deletions

View File

@ -64,7 +64,7 @@
import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid"
export let title
export let title = ""
export let forceModal = false
const dispatch = createEventDispatcher()

View File

@ -18,7 +18,7 @@
import type { KeyboardEventHandler } from "svelte/elements"
import { PopoverAlignment } from "../constants"
export let anchor: HTMLElement
export let anchor: HTMLElement | undefined
export let align: PopoverAlignment | `${PopoverAlignment}` =
PopoverAlignment.Right
export let portalTarget: string | undefined = undefined

View File

@ -27,12 +27,11 @@
} from "../CodeEditor"
import BindingSidePanel from "./BindingSidePanel.svelte"
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils"
import { capitalise } from "@/helpers"
import { Utils, JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import { BindingMode, SidePanel } from "@budibase/types"
import { BindingMode } from "@budibase/types"
import type {
EnrichedBinding,
Snippet,
@ -44,6 +43,8 @@
import type { Log } from "@budibase/string-templates"
import type { CodeValidator } from "@/types"
type SidePanel = "Bindings" | "Evaluation"
const dispatch = createEventDispatcher()
export let bindings: EnrichedBinding[] = []
@ -55,7 +56,7 @@
export let context = null
export let snippets: Snippet[] | null = null
export let autofocusEditor = false
export let placeholder = null
export let placeholder: string | null = null
export let showTabBar = true
let mode: BindingMode
@ -71,14 +72,13 @@
let expressionError: string | undefined
let evaluating = false
$: useSnippets = allowSnippets && !$licensing.isFreePlan
const SidePanelIcons: Record<SidePanel, string> = {
Bindings: "FlashOn",
Evaluation: "Play",
}
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: sidePanelOptions = getSidePanelOptions(
bindings,
context,
allowSnippets,
mode
)
$: sidePanelOptions = getSidePanelOptions(bindings, context)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === BindingMode.JavaScript
$: editorMode =
@ -93,7 +93,9 @@
$: bindingOptions = bindingsToCompletions(enrichedBindings, editorMode)
$: helperOptions = allowHelpers ? getHelperCompletions(editorMode) : []
$: snippetsOptions =
usingJS && useSnippets && snippets?.length ? snippets : []
usingJS && allowSnippets && !$licensing.isFreePlan && snippets?.length
? snippets
: []
$: completions = !usingJS
? [hbAutocomplete([...bindingOptions, ...helperOptions])]
@ -137,21 +139,13 @@
return options
}
const getSidePanelOptions = (
bindings: EnrichedBinding[],
context: any,
useSnippets: boolean,
mode: BindingMode | null
) => {
let options = []
const getSidePanelOptions = (bindings: EnrichedBinding[], context: any) => {
let options: SidePanel[] = []
if (bindings?.length) {
options.push(SidePanel.Bindings)
options.push("Bindings")
}
if (context && Object.keys(context).length > 0) {
options.push(SidePanel.Evaluation)
}
if (useSnippets && mode === BindingMode.JavaScript) {
options.push(SidePanel.Snippets)
options.push("Evaluation")
}
return options
}
@ -342,14 +336,15 @@
{/each}
</div>
<div class="side-tabs">
{#each sidePanelOptions as panel}
{#each sidePanelOptions as panelOption}
<ActionButton
size="M"
quiet
selected={sidePanel === panel}
on:click={() => changeSidePanel(panel)}
selected={sidePanel === panelOption}
on:click={() => changeSidePanel(panelOption)}
tooltip={panelOption}
>
<Icon name={panel} size="S" />
<Icon name={SidePanelIcons[panelOption]} size="S" />
</ActionButton>
{/each}
</div>
@ -415,16 +410,19 @@
</div>
</div>
<div class="side" class:visible={!!sidePanel}>
{#if sidePanel === SidePanel.Bindings}
{#if sidePanel === "Bindings"}
<BindingSidePanel
bindings={enrichedBindings}
{allowHelpers}
{allowSnippets}
{context}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
{addSnippet}
{mode}
{snippets}
/>
{:else if sidePanel === SidePanel.Evaluation}
{:else if sidePanel === "Evaluation"}
<EvaluationSidePanel
{expressionResult}
{expressionError}
@ -432,8 +430,6 @@
{evaluating}
expression={editorValue ? editorValue : ""}
/>
{:else if sidePanel === SidePanel.Snippets}
<SnippetSidePanel {addSnippet} {snippets} />
{/if}
</div>
</div>

View File

@ -1,31 +1,52 @@
<script lang="ts">
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, Icon, Popover } from "@budibase/bbui"
import { licensing } from "@/stores/portal"
import {
Input,
Layout,
Icon,
Popover,
Tags,
Tag,
Body,
Button,
} from "@budibase/bbui"
import { handlebarsCompletions } from "@/constants/completions"
import type { EnrichedBinding, Helper } from "@budibase/types"
import type { EnrichedBinding, Helper, Snippet } from "@budibase/types"
import { BindingMode } from "@budibase/types"
import { EditorModes } from "../CodeEditor"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import SnippetDrawer from "./SnippetDrawer.svelte"
import UpgradeButton from "@/pages/builder/portal/_components/UpgradeButton.svelte"
export let addHelper: (_helper: Helper, _js?: boolean) => void
export let addBinding: (_binding: EnrichedBinding) => void
export let addSnippet: (_snippet: Snippet) => void
export let bindings: EnrichedBinding[]
export let snippets: Snippet[] | null
export let mode: BindingMode
export let allowHelpers: boolean
export let allowSnippets: boolean
export let context = null
let search = ""
let searching = false
let popover: Popover
let popoverAnchor: HTMLElement | null
let popoverAnchor: HTMLElement | undefined
let hoverTarget: {
helper: boolean
type: "binding" | "helper" | "snippet"
code: string
description?: string
} | null
let helpers = handlebarsCompletions()
let selectedCategory: string | null
let hideTimeout: ReturnType<typeof setTimeout> | null
let snippetDrawer: SnippetDrawer
let editableSnippet: Snippet | null
$: enableSnippets = !$licensing.isFreePlan
$: bindingIcons = bindings?.reduce<Record<string, string>>((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
@ -35,9 +56,14 @@
$: categoryIcons = {
...bindingIcons,
Helpers: "MagicWand",
Snippets: "Code",
} as Record<string, string>
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: categoryNames = getCategoryNames(
categories,
allowSnippets && mode === BindingMode.JavaScript
)
$: searchRgx = new RegExp(search, "ig")
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
@ -61,6 +87,17 @@
)
})
$: filteredSnippets = getFilteredSnippets(
enableSnippets,
snippets || [],
search
)
function onModeChange(_mode: BindingMode) {
selectedCategory = null
}
$: onModeChange(mode)
const getHelperExample = (helper: Helper, js: boolean) => {
let example = helper.example || ""
if (js) {
@ -72,11 +109,17 @@
return example || ""
}
const getCategoryNames = (categories: [string, EnrichedBinding[]][]) => {
const getCategoryNames = (
categories: [string, EnrichedBinding[]][],
showSnippets: boolean
) => {
const names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
if (showSnippets) {
names.push("Snippets")
}
return names
}
@ -90,20 +133,20 @@
stopHidingPopover()
popoverAnchor = target
hoverTarget = {
helper: false,
type: "binding",
code: binding.valueHTML,
}
popover.show()
}
const showHelperPopover = (helper: any, target: HTMLElement) => {
const showHelperPopover = (helper: Helper, target: HTMLElement) => {
stopHidingPopover()
if (!helper.displayText && helper.description) {
return
}
popoverAnchor = target
hoverTarget = {
helper: true,
type: "helper",
description: helper.description,
code: getHelperExample(helper, mode === BindingMode.JavaScript),
}
@ -113,7 +156,7 @@
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
popoverAnchor = undefined
hoverTarget = null
hideTimeout = null
}, 100)
@ -136,9 +179,50 @@
searching = false
search = ""
}
const getFilteredSnippets = (
enableSnippets: boolean,
snippets: Snippet[],
search: string
) => {
if (!enableSnippets || !snippets.length) {
return []
}
if (!search?.length) {
return snippets
}
return snippets.filter(snippet =>
snippet.name.toLowerCase().includes(search.toLowerCase())
)
}
const showSnippet = (snippet: Snippet, target: HTMLElement) => {
stopHidingPopover()
if (!snippet.code) {
return
}
popoverAnchor = target
hoverTarget = {
type: "snippet",
code: snippet.code,
}
popover.show()
}
const createSnippet = () => {
editableSnippet = null
snippetDrawer.show()
}
const editSnippet = (e: Event, snippet: Snippet) => {
e.preventDefault()
e.stopPropagation()
editableSnippet = snippet
snippetDrawer.show()
}
</script>
{#if popoverAnchor && hoverTarget}
<Popover
align="left-outside"
bind:this={popover}
@ -150,7 +234,11 @@
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
<div class="binding-popover" class:helper={hoverTarget.helper}>
{#if hoverTarget}
<div
class="binding-popover"
class:has-code={hoverTarget.type !== "binding"}
>
{#if hoverTarget.description}
<div>
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
@ -158,12 +246,20 @@
</div>
{/if}
{#if hoverTarget.code}
{#if mode === BindingMode.JavaScript}
<CodeEditor
value={hoverTarget.code?.trim()}
mode={EditorModes.JS}
readonly
/>
{:else if mode === BindingMode.Text}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
<pre>{@html hoverTarget.code}</pre>
{/if}
</div>
</Popover>
{/if}
</div>
{/if}
</Popover>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
@ -178,6 +274,25 @@
on:click={() => (selectedCategory = null)}
/>
{selectedCategory}
{#if selectedCategory === "Snippets"}
{#if enableSnippets}
<div class="add-snippet-button">
<Icon
size="S"
name="Add"
hoverable
newStyles
on:click={createSnippet}
/>
</div>
{:else}
<div class="title">
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
</div>
{/if}
{/if}
</div>
{/if}
@ -281,7 +396,6 @@
class="binding"
on:mouseenter={e =>
showHelperPopover(helper, e.currentTarget)}
on:mouseleave={hidePopover}
on:click={() =>
addHelper(helper, mode === BindingMode.JavaScript)}
>
@ -295,10 +409,48 @@
</div>
{/if}
{/if}
{#if selectedCategory === "Snippets" || search}
<div class="snippet-list">
{#if enableSnippets && filteredSnippets.length}
{#each filteredSnippets as snippet}
<li
class="snippet"
on:mouseenter={e => showSnippet(snippet, e.currentTarget)}
on:mouseleave={hidePopover}
on:click={() => addSnippet(snippet)}
>
{snippet.name}
<Icon
name="Edit"
hoverable
newStyles
size="S"
on:click={e => editSnippet(e, snippet)}
/>
</li>
{/each}
{:else if !search}
<div class="upgrade">
<Body size="S">
Snippets let you create reusable JS functions and values that
can all be managed in one place
</Body>
{#if enableSnippets}
<Button cta on:click={createSnippet}>Create snippet</Button>
{:else}
<UpgradeButton />
{/if}
</div>
{/if}
</div>
{/if}
{/if}
</Layout>
</div>
<SnippetDrawer bind:this={snippetDrawer} snippet={editableSnippet} />
<style>
.binding-side-panel {
border-left: var(--border-light);
@ -363,6 +515,7 @@
display: flex;
align-items: center;
gap: var(--spacing-m);
justify-content: space-between;
}
li.binding .binding__typeWrap {
flex: 1;
@ -438,7 +591,7 @@
text-overflow: ellipsis;
overflow: hidden;
}
.binding-popover.helper pre {
.binding-popover.has-code pre {
color: var(--spectrum-global-color-blue-700);
}
.binding-popover pre :global(span) {
@ -450,7 +603,50 @@
padding: 0;
margin: 0;
}
.binding-popover.helper :global(code) {
.binding-popover.has-code :global(code) {
font-size: 12px;
}
.binding-popover.has-code :global(.cm-line),
.binding-popover.has-code :global(.cm-content) {
padding: 0;
}
/* Snippets */
.add-snippet-button {
margin-left: auto;
}
.snippet-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
}
.snippet {
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-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
display: flex;
justify-content: space-between;
}
.snippet:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
/* Upgrade */
.upgrade {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.upgrade :global(p) {
text-align: center;
align-self: center;
}
</style>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import {
Button,
Drawer,
@ -14,19 +14,27 @@
import { getSequentialName } from "@/helpers/duplicate"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
import type { Snippet } from "@budibase/types"
export let snippet
export const show = () => drawer.show()
export const show = () => {
if (!snippet) {
key = Math.random().toString()
// Reset state when creating multiple snippets
code = ""
name = defaultName
}
drawer.show()
}
export const hide = () => drawer.hide()
export let snippet: Snippet | null
const firstCharNumberRegex = /^[0-9].*$/
let drawer
let drawer: Drawer
let name = ""
let code = ""
let loading = false
let deleteConfirmationDialog
let deleteConfirmationDialog: ConfirmDialog
$: defaultName = getSequentialName($snippets, "MySnippet", {
getName: x => x.name,
@ -40,11 +48,11 @@
const saveSnippet = async () => {
loading = true
try {
const newSnippet = { name, code: rawJS }
const newSnippet: Snippet = { name, code: rawJS || "" }
await snippets.saveSnippet(newSnippet)
drawer.hide()
notifications.success(`Snippet ${newSnippet.name} saved`)
} catch (error) {
} catch (error: any) {
notifications.error(error.message || "Error saving snippet")
}
loading = false
@ -53,7 +61,9 @@
const deleteSnippet = async () => {
loading = true
try {
if (snippet) {
await snippets.deleteSnippet(snippet.name)
}
drawer.hide()
} catch (error) {
notifications.error("Error deleting snippet")
@ -61,7 +71,7 @@
loading = false
}
const validateName = (name, snippets) => {
const validateName = (name: string, snippets: Snippet[]) => {
if (!name?.length) {
return "Name is required"
}
@ -108,7 +118,11 @@
Delete
</Button>
{/if}
<Button cta on:click={saveSnippet} disabled={!code || loading || nameError}>
<Button
cta
on:click={saveSnippet}
disabled={!code || loading || !!nameError}
>
Save
</Button>
</svelte:fragment>
@ -124,9 +138,7 @@
value={code}
on:change={e => (code = e.detail)}
>
<div slot="tabs">
<Input placeholder="Name" />
</div>
</BindingPanel>
{/key}
</svelte:fragment>

View File

@ -1,278 +0,0 @@
<script>
import {
Input,
Layout,
Icon,
Popover,
Tags,
Tag,
Body,
Button,
} from "@budibase/bbui"
import CodeEditor from "@/components/common/CodeEditor/CodeEditor.svelte"
import { EditorModes } from "@/components/common/CodeEditor"
import SnippetDrawer from "./SnippetDrawer.svelte"
import { licensing } from "@/stores/portal"
import UpgradeButton from "@/pages/builder/portal/_components/UpgradeButton.svelte"
export let addSnippet
export let snippets
let search = ""
let searching = false
let popover
let popoverAnchor
let hoveredSnippet
let hideTimeout
let snippetDrawer
let editableSnippet
$: enableSnippets = !$licensing.isFreePlan
$: filteredSnippets = getFilteredSnippets(enableSnippets, snippets, search)
const getFilteredSnippets = (enableSnippets, snippets, search) => {
if (!enableSnippets || !snippets?.length) {
return []
}
if (!search?.length) {
return snippets
}
return snippets.filter(snippet =>
snippet.name.toLowerCase().includes(search.toLowerCase())
)
}
const showSnippet = (snippet, target) => {
stopHidingPopover()
popoverAnchor = target
hoveredSnippet = snippet
popover.show()
}
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
hoveredSnippet = null
hideTimeout = null
}, 100)
}
const stopHidingPopover = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const startSearching = () => {
searching = true
search = ""
}
const stopSearching = () => {
searching = false
search = ""
}
const createSnippet = () => {
editableSnippet = null
snippetDrawer.show()
}
const editSnippet = (e, snippet) => {
e.preventDefault()
e.stopPropagation()
editableSnippet = snippet
snippetDrawer.show()
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="snippet-side-panel">
<Layout noPadding gap="S">
<div class="header">
{#if enableSnippets}
{#if searching}
<div class="search-input">
<Input
placeholder="Search for snippets"
autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/>
{:else}
<div class="title">Snippets</div>
<Icon
size="S"
name="Search"
hoverable
newStyles
on:click={startSearching}
/>
<Icon
size="S"
name="Add"
hoverable
newStyles
on:click={createSnippet}
/>
{/if}
{:else}
<div class="title">
Snippets
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
</div>
{/if}
</div>
<div class="snippet-list">
{#if enableSnippets && filteredSnippets?.length}
{#each filteredSnippets as snippet}
<div
class="snippet"
on:mouseenter={e => showSnippet(snippet, e.target)}
on:mouseleave={hidePopover}
on:click={() => addSnippet(snippet)}
>
{snippet.name}
<Icon
name="Edit"
hoverable
newStyles
size="S"
on:click={e => editSnippet(e, snippet)}
/>
</div>
{/each}
{:else}
<div class="upgrade">
<Body size="S">
Snippets let you create reusable JS functions and values that can
all be managed in one place
</Body>
{#if enableSnippets}
<Button cta on:click={createSnippet}>Create snippet</Button>
{:else}
<UpgradeButton />
{/if}
</div>
{/if}
</div>
</Layout>
</div>
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
minWidth={0}
maxWidth={480}
maxHeight={480}
dismissible={false}
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
<div class="snippet-popover">
{#key hoveredSnippet}
<CodeEditor
value={hoveredSnippet.code?.trim()}
mode={EditorModes.JS}
readonly
/>
{/key}
</div>
</Popover>
<SnippetDrawer bind:this={snippetDrawer} snippet={editableSnippet} />
<style>
.snippet-side-panel {
border-left: var(--border-light);
height: 100%;
overflow: auto;
}
/* Header */
.header {
height: 53px;
padding: 0 var(--spacing-l);
display: flex;
align-items: center;
border-bottom: var(--border-light);
position: sticky;
top: 0;
gap: var(--spacing-m);
background: var(--background);
z-index: 1;
}
.header :global(input) {
border: none;
border-radius: 0;
background: none;
padding: 0;
}
.search-input,
.title {
flex: 1 1 auto;
}
.title {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
/* Upgrade */
.upgrade {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.upgrade :global(p) {
text-align: center;
align-self: center;
}
/* List */
.snippet-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.snippet {
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-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
display: flex;
justify-content: space-between;
}
.snippet:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
/* Popover */
.snippet-popover {
width: 400px;
}
</style>

View File

@ -8,5 +8,3 @@ export { default as DrawerBindableSlot } from "./DrawerBindableSlot.svelte"
export { default as EvaluationSidePanel } from "./EvaluationSidePanel.svelte"
export { default as ModalBindableInput } from "./ModalBindableInput.svelte"
export { default as ServerBindingPanel } from "./ServerBindingPanel.svelte"
export { default as SnippetDrawer } from "./SnippetDrawer.svelte"
export { default as SnippetSidePanel } from "./SnippetSidePanel.svelte"

View File

@ -1,4 +1,3 @@
export * from "./sidepanel"
export * from "./codeEditor"
export * from "./errors"

View File

@ -1,5 +0,0 @@
export enum SidePanel {
Bindings = "FlashOn",
Evaluation = "Play",
Snippets = "Code",
}