Merge branch 'master' into BUDI-8084/single-attachment-column-setting

This commit is contained in:
Adria Navarro 2024-03-15 12:53:11 +01:00 committed by GitHub
commit bb4b24219b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
126 changed files with 3446 additions and 1656 deletions

View File

@ -6,6 +6,7 @@ packages/server/coverage
packages/worker/coverage packages/worker/coverage
packages/backend-core/coverage packages/backend-core/coverage
packages/server/client packages/server/client
packages/server/coverage
packages/builder/.routify packages/builder/.routify
packages/sdk/sdk packages/sdk/sdk
packages/account-portal/packages/server/build packages/account-portal/packages/server/build

View File

@ -10,7 +10,7 @@ import {
StaticDatabases, StaticDatabases,
DEFAULT_TENANT_ID, DEFAULT_TENANT_ID,
} from "../constants" } from "../constants"
import { Database, IdentityContext } from "@budibase/types" import { Database, IdentityContext, Snippet, App } from "@budibase/types"
import { ContextMap } from "./types" import { ContextMap } from "./types"
let TEST_APP_ID: string | null = null let TEST_APP_ID: string | null = null
@ -122,10 +122,10 @@ export async function doInAutomationContext<T>(params: {
automationId: string automationId: string
task: () => T task: () => T
}): Promise<T> { }): Promise<T> {
const tenantId = getTenantIDFromAppID(params.appId) await ensureSnippetContext()
return newContext( return newContext(
{ {
tenantId, tenantId: getTenantIDFromAppID(params.appId),
appId: params.appId, appId: params.appId,
automationId: params.automationId, automationId: params.automationId,
}, },
@ -281,6 +281,27 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export async function ensureSnippetContext() {
const ctx = getCurrentContext()
// If we've already added snippets to context, continue
if (!ctx || ctx.snippets) {
return
}
// Otherwise get snippets for this app and update context
let snippets: Snippet[] | undefined
const db = getAppDB()
if (db && !env.isTest()) {
const app = await db.get<App>(DocumentType.APP_METADATA)
snippets = app.snippets
}
// Always set snippets to a non-null value so that we can tell we've attempted
// to load snippets
ctx.snippets = snippets || []
}
export function getEnvironmentVariables() { export function getEnvironmentVariables() {
const context = Context.get() const context = Context.get()
if (!context.environmentVariables) { if (!context.environmentVariables) {

View File

@ -1,4 +1,4 @@
import { IdentityContext, VM } from "@budibase/types" import { IdentityContext, Snippet, VM } from "@budibase/types"
// keep this out of Budibase types, don't want to expose context info // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
@ -11,4 +11,5 @@ export type ContextMap = {
isMigrating?: boolean isMigrating?: boolean
vm?: VM vm?: VM
cleanup?: (() => void | Promise<void>)[] cleanup?: (() => void | Promise<void>)[]
snippets?: Snippet[]
} }

View File

@ -13,6 +13,7 @@ import {
AppVersionRevertedEvent, AppVersionRevertedEvent,
AppRevertedEvent, AppRevertedEvent,
AppExportedEvent, AppExportedEvent,
AppDuplicatedEvent,
} from "@budibase/types" } from "@budibase/types"
const created = async (app: App, timestamp?: string | number) => { const created = async (app: App, timestamp?: string | number) => {
@ -77,6 +78,17 @@ async function fileImported(app: App) {
await publishEvent(Event.APP_FILE_IMPORTED, properties) await publishEvent(Event.APP_FILE_IMPORTED, properties)
} }
async function duplicated(app: App, duplicateAppId: string) {
const properties: AppDuplicatedEvent = {
duplicateAppId,
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_DUPLICATED, properties)
}
async function templateImported(app: App, templateKey: string) { async function templateImported(app: App, templateKey: string) {
const properties: AppTemplateImportedEvent = { const properties: AppTemplateImportedEvent = {
appId: app.appId, appId: app.appId,
@ -147,6 +159,7 @@ export default {
published, published,
unpublished, unpublished,
fileImported, fileImported,
duplicated,
templateImported, templateImported,
versionUpdated, versionUpdated,
versionReverted, versionReverted,

View File

@ -15,6 +15,7 @@ beforeAll(async () => {
jest.spyOn(events.app, "created") jest.spyOn(events.app, "created")
jest.spyOn(events.app, "updated") jest.spyOn(events.app, "updated")
jest.spyOn(events.app, "duplicated")
jest.spyOn(events.app, "deleted") jest.spyOn(events.app, "deleted")
jest.spyOn(events.app, "published") jest.spyOn(events.app, "published")
jest.spyOn(events.app, "unpublished") jest.spyOn(events.app, "unpublished")

View File

@ -38,7 +38,7 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}> <Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

View File

@ -32,6 +32,13 @@ const handleClick = event => {
return return
} }
// Ignore clicks for drawers, unless the handler is registered from a drawer
const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
const clickInDrawer = event.target.closest(".drawer-wrapper") != null
if (clickInDrawer && !sourceInDrawer) {
return
}
handler.callback?.(event) handler.callback?.(event)
}) })
} }

View File

@ -15,6 +15,7 @@ export default function positionDropdown(element, opts) {
align, align,
maxHeight, maxHeight,
maxWidth, maxWidth,
minWidth,
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
@ -28,7 +29,7 @@ export default function positionDropdown(element, opts) {
const elementBounds = element.getBoundingClientRect() const elementBounds = element.getBoundingClientRect()
let styles = { let styles = {
maxHeight: null, maxHeight: null,
minWidth: null, minWidth,
maxWidth, maxWidth,
left: null, left: null,
top: null, top: null,
@ -41,8 +42,13 @@ export default function positionDropdown(element, opts) {
}) })
} else { } else {
// Determine vertical styles // Determine vertical styles
if (align === "right-outside") { if (align === "right-outside" || align === "left-outside") {
styles.top = anchorBounds.top styles.top =
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2
styles.maxHeight = maxHeight
if (styles.top + elementBounds.height > window.innerHeight) {
styles.top = window.innerHeight - elementBounds.height
}
} else if ( } else if (
window.innerHeight - anchorBounds.bottom < window.innerHeight - anchorBounds.bottom <
(maxHeight || 100) (maxHeight || 100)

View File

@ -1,28 +1,111 @@
<script context="module">
import { writable, get } from "svelte/store"
// Observe this class name if possible in order to know how to size the
// drawer. If this doesn't exist we'll use a fixed size.
const drawerContainer = "drawer-container"
// Context level stores to keep drawers in sync
const openDrawers = writable([])
const modal = writable(false)
const resizable = writable(true)
const drawerLeft = writable(null)
const drawerWidth = writable(null)
// Resize observer to keep track of size changes
let observer
// Starts observing the target node to watching to size changes.
// Invoked when the first drawer of a chain is rendered.
const observe = () => {
const target = document.getElementsByClassName(drawerContainer)[0]
if (observer || !target) {
return
}
observer = new ResizeObserver(entries => {
if (!entries?.[0]) {
return
}
const bounds = entries[0].target.getBoundingClientRect()
drawerLeft.set(bounds.left)
drawerWidth.set(bounds.width)
})
observer.observe(target)
// Manually measure once to ensure that we have dimensions for the initial
// paint
const bounds = target.getBoundingClientRect()
drawerLeft.set(bounds.left)
drawerWidth.set(bounds.width)
}
// Stops observing the target node.
// Invoked when the last drawer of a chain is removed.
const unobserve = () => {
if (get(openDrawers).length) {
return
}
observer?.disconnect()
// Reset state
observer = null
modal.set(false)
resizable.set(true)
drawerLeft.set(null)
drawerWidth.set(null)
}
</script>
<script> <script>
import Portal from "svelte-portal"
import Button from "../Button/Button.svelte" import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte" import Icon from "../Icon/Icon.svelte"
import Heading from "../Typography/Heading.svelte" import ActionButton from "../ActionButton/ActionButton.svelte"
import { setContext, createEventDispatcher } from "svelte" import Portal from "svelte-portal"
import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
export let title export let title
export let fillWidth export let forceModal = false
export let left = "314px"
export let width = "calc(100% - 626px)"
export let headless = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const spacing = 11
let visible = false let visible = false
let drawerId = generate() let drawerId = generate()
$: depth = $openDrawers.length - $openDrawers.indexOf(drawerId) - 1
$: style = getStyle(depth, $drawerLeft, $drawerWidth, $modal)
const getStyle = (depth, left, width, modal) => {
let style = `
--scale-factor: ${getScaleFactor(depth)};
--spacing: ${spacing}px;
`
// Most modal styles are handled by class names
if (modal || left == null || width == null) {
return style
}
// Drawers observing another dom node need custom position styles
return `
${style}
left: ${left + spacing}px;
width: ${width - 2 * spacing}px;
`
}
export function show() { export function show() {
if (visible) { if (visible) {
return return
} }
if (forceModal) {
modal.set(true)
resizable.set(false)
}
observe()
visible = true visible = true
dispatch("drawerShow", drawerId) dispatch("drawerShow", drawerId)
openDrawers.update(state => [...state, drawerId])
} }
export function hide() { export function hide() {
@ -31,12 +114,15 @@
} }
visible = false visible = false
dispatch("drawerHide", drawerId) dispatch("drawerHide", drawerId)
openDrawers.update(state => state.filter(id => id !== drawerId))
unobserve()
} }
setContext("drawer-actions", { setContext("drawer", {
hide, hide,
show, show,
headless, modal,
resizable,
}) })
const easeInOutQuad = x => { const easeInOutQuad = x => {
@ -45,66 +131,142 @@
// Use a custom svelte transition here because the built-in slide // Use a custom svelte transition here because the built-in slide
// transition has a horrible overshoot // transition has a horrible overshoot
const slide = () => { const drawerSlide = () => {
return { return {
duration: 360, duration: 260,
css: t => { css: t => {
const translation = 100 - Math.round(easeInOutQuad(t) * 100) const f = easeInOutQuad(t)
return `transform: translateY(${translation}%);` const yOffset = (1 - f) * 200
return `
transform: translateY(calc(${yOffset}px - 800px * (1 - var(--scale-factor))));
opacity: ${f};
`
}, },
} }
} }
// Custom fade transition because the default svelte one doesn't work any more
// with svelte 4
const drawerFade = () => {
return {
duration: 260,
css: t => {
return `opacity: ${easeInOutQuad(t)};`
},
}
}
const getScaleFactor = depth => {
// Quadratic function approaching a limit of 1 as depth tends to infinity
const lim = 1 - 1 / (depth * depth + 1)
// Scale drawers between 1 and 0.9 as depth approaches infinity
return 1 - lim * 0.1
}
onDestroy(() => {
if (visible) {
hide()
}
})
</script> </script>
{#if visible} {#if visible}
<Portal> <Portal target=".modal-container">
<section <!-- This class is unstyled, but needed by click_outside -->
class:fillWidth <div class="drawer-wrapper">
<div
class="underlay"
class:hidden={!$modal}
transition:drawerFade|local
/>
<div
class="drawer" class="drawer"
class:headless class:stacked={depth > 0}
transition:slide|local class:modal={$modal}
style={`width: ${width}; left: ${left};`} transition:drawerSlide|local
{style}
> >
{#if !headless}
<header> <header>
<div class="text"> {#if $$slots.title}
<Heading size="XS">{title}</Heading> <slot name="title" />
<Body size="S"> {:else}
<slot name="description" /> <div class="text">{title || "Bindings"}</div>
</Body> {/if}
</div>
<div class="buttons"> <div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button> <Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" /> <slot name="buttons" />
{#if $resizable}
<ActionButton
size="M"
quiet
selected={$modal}
on:click={() => modal.set(!$modal)}
>
<Icon name={$modal ? "Minimize" : "Maximize"} size="S" />
</ActionButton>
{/if}
</div> </div>
</header> </header>
{/if}
<slot name="body" /> <slot name="body" />
</section> <div class="overlay" class:hidden={$modal || depth === 0} />
</div>
</div>
</Portal> </Portal>
{/if} {/if}
<style> <style>
.drawer.headless :global(.drawer-contents) {
height: calc(40vh + 75px);
}
.buttons {
display: flex;
gap: var(--spacing-m);
}
.drawer { .drawer {
position: absolute; position: absolute;
bottom: 0; left: 25vw;
width: 50vw;
bottom: var(--spacing);
height: 420px;
background: var(--background); background: var(--background);
border-top: var(--border-light); border: var(--border-light);
z-index: 3; z-index: 100;
border-radius: 8px;
overflow: hidden;
box-sizing: border-box;
transition: transform 260ms ease-out, bottom 260ms ease-out,
left 260ms ease-out, width 260ms ease-out, height 260ms ease-out;
display: flex;
flex-direction: column;
align-items: stretch;
}
.drawer.modal {
left: 15vw;
width: 70vw;
bottom: 15vh;
height: 70vh;
}
.drawer.stacked {
transform: translateY(calc(-1 * 1024px * (1 - var(--scale-factor))))
scale(var(--scale-factor));
} }
.fillWidth { .overlay,
left: 260px !important; .underlay {
width: calc(100% - 260px) !important; top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
display: block;
transition: opacity 260ms ease-out;
}
.overlay {
position: absolute;
background: var(--background);
opacity: 0.5;
}
.underlay {
position: fixed;
background: rgba(0, 0, 0, 0.5);
}
.underlay.hidden,
.overlay.hidden {
opacity: 0 !important;
pointer-events: none;
} }
header { header {
@ -112,10 +274,9 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: var(--border-light); border-bottom: var(--border-light);
padding: var(--spacing-l) var(--spacing-xl); padding: var(--spacing-m) var(--spacing-xl);
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.text { .text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -123,7 +284,6 @@
align-items: flex-start; align-items: flex-start;
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -131,4 +291,8 @@
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.buttons :global(.icon) {
width: 16px;
display: flex;
}
</style> </style>

View File

@ -1,4 +1,8 @@
<div class="drawer-contents"> <script>
export let padding = true
</script>
<div class="drawer-contents" class:padding>
<div class:no-sidebar={!$$slots.sidebar} class="container"> <div class:no-sidebar={!$$slots.sidebar} class="container">
{#if $$slots.sidebar} {#if $$slots.sidebar}
<div class="sidebar"> <div class="sidebar">
@ -13,8 +17,8 @@
<style> <style>
.drawer-contents { .drawer-contents {
height: 40vh;
overflow-y: auto; overflow-y: auto;
flex: 1 1 auto;
} }
.container { .container {
height: 100%; height: 100%;
@ -27,14 +31,22 @@
.sidebar { .sidebar {
border-right: var(--border-light); border-right: var(--border-light);
overflow: auto; overflow: auto;
padding: var(--spacing-xl);
scrollbar-width: none; scrollbar-width: none;
} }
.padding .sidebar {
padding: var(--spacing-xl);
}
.sidebar::-webkit-scrollbar { .sidebar::-webkit-scrollbar {
display: none; display: none;
} }
.main { .main {
height: 100%;
overflow: auto;
overflow-x: hidden;
}
.padding .main {
padding: var(--spacing-xl); padding: var(--spacing-xl);
height: calc(100% - var(--spacing-xl) * 2);
} }
.main :global(textarea) { .main :global(textarea) {
min-height: 200px; min-height: 200px;

View File

@ -14,6 +14,7 @@
export let disabled = false export let disabled = false
export let color export let color
export let tooltip export let tooltip
export let newStyles = false
$: rotation = getRotation(direction) $: rotation = getRotation(direction)
@ -28,6 +29,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="icon" class="icon"
class:newStyles
on:mouseover={() => (showTooltip = true)} on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)} on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)} on:mouseleave={() => (showTooltip = false)}
@ -60,6 +62,9 @@
display: grid; display: grid;
place-items: center; place-items: center;
} }
.newStyles {
color: var(--spectrum-global-color-gray-700);
}
svg.hoverable { svg.hoverable {
pointer-events: all; pointer-events: all;
@ -72,7 +77,10 @@
svg.hoverable:active { svg.hoverable:active {
color: var(--spectrum-global-color-blue-400) !important; color: var(--spectrum-global-color-blue-400) !important;
} }
.newStyles svg.hoverable:hover,
.newStyles svg.hoverable:active {
color: var(--spectrum-global-color-gray-900) !important;
}
svg.disabled { svg.disabled {
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;

View File

@ -10,6 +10,7 @@
export let inline = false export let inline = false
export let disableCancel = false export let disableCancel = false
export let autoFocus = true export let autoFocus = true
export let zIndex = 999
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let visible = fixed || inline let visible = fixed || inline
@ -101,7 +102,11 @@
<Portal target=".modal-container"> <Portal target=".modal-container">
{#if visible} {#if visible}
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="spectrum-Underlay is-open" on:mousedown|self={cancel}> <div
class="spectrum-Underlay is-open"
on:mousedown|self={cancel}
style="z-index:{zIndex || 999}"
>
<div <div
class="background" class="background"
in:fade={{ duration: 200 }} in:fade={{ duration: 200 }}
@ -132,7 +137,6 @@
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 999;
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
background: transparent; background: transparent;

View File

@ -12,6 +12,7 @@
export let anchor export let anchor
export let align = "right" export let align = "right"
export let portalTarget export let portalTarget
export let minWidth
export let maxWidth export let maxWidth
export let maxHeight export let maxHeight
export let open = false export let open = false
@ -21,7 +22,6 @@
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex export let customZindex
export let handlePostionUpdate export let handlePostionUpdate
export let showPopover = true export let showPopover = true
export let clickOutsideOverride = false export let clickOutsideOverride = false
@ -87,6 +87,7 @@
align, align,
maxHeight, maxHeight,
maxWidth, maxWidth,
minWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
customUpdate: handlePostionUpdate, customUpdate: handlePostionUpdate,
@ -102,6 +103,8 @@
role="presentation" role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};" style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }} transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
on:mouseenter
on:mouseleave
> >
<slot /> <slot />
</div> </div>

View File

@ -66,10 +66,11 @@
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",
"@zerodevx/svelte-json-view": "^1.0.7", "@zerodevx/svelte-json-view": "^1.0.7",
"codemirror": "^5.59.0", "codemirror": "^5.65.16",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"json-format-highlight": "^1.0.4",
"lodash": "4.17.21", "lodash": "4.17.21",
"posthog-js": "^1.36.0", "posthog-js": "^1.36.0",
"remixicon": "2.5.0", "remixicon": "2.5.0",

View File

@ -49,7 +49,7 @@
<div class="side-bar-controls"> <div class="side-bar-controls">
<NavHeader <NavHeader
title="Automations" title="Automations"
placeholder="Search for automation" placeholder="Search for automations"
bind:value={searchString} bind:value={searchString}
onAdd={() => modal.show()} onAdd={() => modal.show()}
/> />

View File

@ -12,7 +12,6 @@
Drawer, Drawer,
Modal, Modal,
notifications, notifications,
Icon,
Checkbox, Checkbox,
DatePicker, DatePicker,
} from "@budibase/bbui" } from "@budibase/bbui"
@ -31,7 +30,7 @@
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import BindingPicker from "components/common/bindings/BindingPicker.svelte" import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte"
import { BindingHelpers } from "components/common/bindings/utils" import { BindingHelpers } from "components/common/bindings/utils"
import { import {
bindingsToCompletions, bindingsToCompletions,
@ -52,11 +51,12 @@
export let testData export let testData
export let schemaProperties export let schemaProperties
export let isTestModal = false export let isTestModal = false
let webhookModal let webhookModal
let drawer let drawer
let fillWidth = true
let inputData let inputData
let insertAtPos, getCaretPosition let insertAtPos, getCaretPosition
$: filters = lookForFilters(schemaProperties) || [] $: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters $: tempFilters = filters
$: stepId = block.stepId $: stepId = block.stepId
@ -80,7 +80,6 @@
}) })
$: editingJs = codeMode === EditorModes.JS $: editingJs = codeMode === EditorModes.JS
$: requiredProperties = block.schema.inputs.required || [] $: requiredProperties = block.schema.inputs.required || []
$: stepCompletions = $: stepCompletions =
codeMode === EditorModes.Handlebars codeMode === EditorModes.Handlebars
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
@ -377,12 +376,13 @@
<div class="fields"> <div class="fields">
{#each schemaProperties as [key, value]} {#each schemaProperties as [key, value]}
{#if canShowField(key, value)} {#if canShowField(key, value)}
{@const label = getFieldLabel(key, value)}
<div class:block-field={shouldRenderField(value)}> <div class:block-field={shouldRenderField(value)}>
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)} {#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
<Label <Label
tooltip={value.title === "Binding / Value" tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string" ? "If using the String input type, please use a comma or newline separated string"
: null}>{getFieldLabel(key, value)}</Label : null}>{label}</Label
> >
{/if} {/if}
<div class:field-width={shouldRenderField(value)}> <div class:field-width={shouldRenderField(value)}>
@ -415,8 +415,7 @@
</div> </div>
{:else if value.type === "date"} {:else if value.type === "date"}
<DrawerBindableSlot <DrawerBindableSlot
fillWidth title={value.title ?? label}
title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={"date"} type={"date"}
value={inputData[key]} value={inputData[key]}
@ -439,7 +438,7 @@
/> />
{:else if value.customType === "filters"} {:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton> <ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering"> <Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}> <Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save Save
</Button> </Button>
@ -450,7 +449,6 @@
{schemaFields} {schemaFields}
datasource={{ type: "table", tableId }} datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)} on:change={e => (tempFilters = e.detail)}
/> />
</Drawer> </Drawer>
@ -463,19 +461,17 @@
{:else if value.customType === "email"} {:else if value.customType === "email"}
{#if isTestModal} {#if isTestModal}
<ModalBindableInput <ModalBindableInput
title={value.title} title={value.title ?? label}
value={inputData[key]} value={inputData[key]}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type="email" type="email"
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
fillWidth
updateOnChange={false} updateOnChange={false}
/> />
{:else} {:else}
<DrawerBindableInput <DrawerBindableInput
fillWidth title={value.title ?? label}
title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type="email" type="email"
value={inputData[key]} value={inputData[key]}
@ -550,7 +546,7 @@
{:else if value.customType === "code"} {:else if value.customType === "code"}
<CodeEditorModal> <CodeEditorModal>
<div class:js-editor={editingJs}> <div class:js-editor={editingJs}>
<div class:js-code={editingJs} style="width: 100%"> <div class:js-code={editingJs} style="width:100%;height:500px;">
<CodeEditor <CodeEditor
value={inputData[key]} value={inputData[key]}
on:change={e => { on:change={e => {
@ -563,24 +559,14 @@
autocompleteEnabled={codeMode !== EditorModes.JS} autocompleteEnabled={codeMode !== EditorModes.JS}
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
height={500} placeholder={codeMode === EditorModes.Handlebars
? "Add bindings by typing {{"
: null}
/> />
<div class="messaging">
{#if codeMode === EditorModes.Handlebars}
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing <strong>
&#125;&#125;
</strong>
</div>
</div>
{/if}
</div>
</div> </div>
{#if editingJs} {#if editingJs}
<div class="js-binding-picker"> <div class="js-binding-picker">
<BindingPicker <BindingSidePanel
{bindings} {bindings}
allowHelpers={false} allowHelpers={false}
addBinding={binding => addBinding={binding =>
@ -609,7 +595,7 @@
{:else if value.type === "string" || value.type === "number" || value.type === "integer"} {:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal} {#if isTestModal}
<ModalBindableInput <ModalBindableInput
title={value.title} title={value.title || label}
value={inputData[key]} value={inputData[key]}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
@ -620,8 +606,7 @@
{:else} {:else}
<div class="test"> <div class="test">
<DrawerBindableInput <DrawerBindableInput
fillWidth={true} title={value.title ?? label}
title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
value={inputData[key]} value={inputData[key]}
@ -654,11 +639,6 @@
width: 320px; width: 320px;
} }
.messaging {
display: flex;
align-items: center;
margin-top: var(--spacing-xl);
}
.fields { .fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -670,7 +650,6 @@
.block-field { .block-field {
display: flex; /* Use Flexbox */ display: flex; /* Use Flexbox */
justify-content: space-between; justify-content: space-between;
align-items: center;
flex-direction: row; /* Arrange label and field side by side */ flex-direction: row; /* Arrange label and field side by side */
align-items: center; /* Align vertically in the center */ align-items: center; /* Align vertically in the center */
gap: 10px; /* Add some space between label and field */ gap: 10px; /* Add some space between label and field */

View File

@ -57,7 +57,6 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
type="string" type="string"
{bindings} {bindings}
fillWidth={true}
updateOnChange={false} updateOnChange={false}
/> />
</div> </div>

View File

@ -52,7 +52,6 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
type="string" type="string"
{bindings} {bindings}
fillWidth={true}
updateOnChange={false} updateOnChange={false}
/> />
</div> </div>

View File

@ -129,8 +129,7 @@
/> />
{:else} {:else}
<DrawerBindableSlot <DrawerBindableSlot
fillWidth title={value.title || field}
title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={schema.type} type={schema.type}
{schema} {schema}

View File

@ -85,8 +85,8 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
type="string" type="string"
bindings={parsedBindings} bindings={parsedBindings}
fillWidth={true}
allowJS={true} allowJS={true}
updateOnChange={false} updateOnChange={false}
title={schema.name}
/> />
{/if} {/if}

View File

@ -36,6 +36,8 @@
import { ValidColumnNameRegex } from "@budibase/shared-core" import { ValidColumnNameRegex } from "@budibase/shared-core"
import { FieldType, FieldSubtype, SourceName } from "@budibase/types" import { FieldType, FieldSubtype, SourceName } from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
const AUTO_TYPE = FIELDS.AUTO.type const AUTO_TYPE = FIELDS.AUTO.type
const FORMULA_TYPE = FIELDS.FORMULA.type const FORMULA_TYPE = FIELDS.FORMULA.type
@ -49,43 +51,21 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { dispatch: gridDispatch } = getContext("grid") const { dispatch: gridDispatch, rows } = getContext("grid")
export let field export let field
let mounted = false let mounted = false
const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id
(acc, field) => ({
...acc,
[makeFieldId(field.type, field.subtype)]: field,
}),
{}
)
function makeFieldId(type, subtype, autocolumn) {
// don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase()
} else {
return `${type}${subtype || ""}`.toUpperCase()
}
}
let originalName let originalName
let linkEditDisabled let linkEditDisabled
let primaryDisplay let primaryDisplay
let indexes = [...($tables.selected.indexes || [])] let indexes = [...($tables.selected.indexes || [])]
let isCreating = undefined let isCreating = undefined
let relationshipPart1 = PrettyRelationshipDefinitions.Many let relationshipPart1 = PrettyRelationshipDefinitions.Many
let relationshipPart2 = PrettyRelationshipDefinitions.One let relationshipPart2 = PrettyRelationshipDefinitions.One
let relationshipTableIdPrimary = null let relationshipTableIdPrimary = null
let relationshipTableIdSecondary = null let relationshipTableIdSecondary = null
let table = $tables.selected let table = $tables.selected
let confirmDeleteDialog let confirmDeleteDialog
let savingColumn let savingColumn
let deleteColName let deleteColName
@ -99,11 +79,6 @@
} }
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
let relationshipMap = { let relationshipMap = {
[RelationshipType.ONE_TO_MANY]: { [RelationshipType.ONE_TO_MANY]: {
part1: PrettyRelationshipDefinitions.MANY, part1: PrettyRelationshipDefinitions.MANY,
@ -118,7 +93,12 @@
part2: PrettyRelationshipDefinitions.MANY, part2: PrettyRelationshipDefinitions.MANY,
}, },
} }
let autoColumnInfo = getAutoColumnInformation()
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
$: { $: {
// this parses any changes the user has made when creating a new internal relationship // this parses any changes the user has made when creating a new internal relationship
// into what we expect the schema to look like // into what we expect the schema to look like
@ -148,6 +128,74 @@
editableColumn.tableId = relationshipTableIdSecondary editableColumn.tableId = relationshipTableIdSecondary
} }
} }
$: initialiseField(field, savingColumn)
$: checkConstraints(editableColumn)
$: required = !!editableColumn?.constraints?.presence || primaryDisplay
$: uneditable =
$tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
$: invalid =
!editableColumn?.name ||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0
$: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
)
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
if (!tableAutoColumnsTypes.includes(key)) {
acc[key] = autoColumnInfo[key]
}
return acc
}, {})
$: availableAutoColumnKeys = availableAutoColumns
? Object.keys(availableAutoColumns)
: []
$: autoColumnOptions = editableColumn.autocolumn
? autoColumnInfo
: availableAutoColumns
// used to select what different options can be displayed for column type
$: canBeDisplay =
editableColumn?.type !== LINK_TYPE &&
editableColumn?.type !== AUTO_TYPE &&
editableColumn?.type !== JSON_TYPE &&
!editableColumn.autocolumn
$: canBeRequired =
editableColumn?.type !== LINK_TYPE &&
!uneditable &&
editableColumn?.type !== AUTO_TYPE &&
!editableColumn.autocolumn
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
// in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter(
opt =>
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
)
$: typeEnabled =
!originalName ||
(originalName &&
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
!editableColumn?.autocolumn)
const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id
(acc, field) => ({
...acc,
[makeFieldId(field.type, field.subtype)]: field,
}),
{}
)
function makeFieldId(type, subtype, autocolumn) {
// don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase()
} else {
return `${type}${subtype || ""}`.toUpperCase()
}
}
const initialiseField = (field, savingColumn) => { const initialiseField = (field, savingColumn) => {
isCreating = !field isCreating = !field
if (field && !savingColumn) { if (field && !savingColumn) {
@ -187,22 +235,6 @@
} }
} }
$: initialiseField(field, savingColumn)
$: checkConstraints(editableColumn)
$: required = !!editableColumn?.constraints?.presence || primaryDisplay
$: uneditable =
$tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
$: invalid =
!editableColumn?.name ||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0
$: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
)
const getTableAutoColumnTypes = table => { const getTableAutoColumnTypes = table => {
return Object.keys(table?.schema).reduce((acc, key) => { return Object.keys(table?.schema).reduce((acc, key) => {
let fieldSchema = table?.schema[key] let fieldSchema = table?.schema[key]
@ -213,47 +245,6 @@
}, []) }, [])
} }
let autoColumnInfo = getAutoColumnInformation()
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
if (!tableAutoColumnsTypes.includes(key)) {
acc[key] = autoColumnInfo[key]
}
return acc
}, {})
$: availableAutoColumnKeys = availableAutoColumns
? Object.keys(availableAutoColumns)
: []
$: autoColumnOptions = editableColumn.autocolumn
? autoColumnInfo
: availableAutoColumns
// used to select what different options can be displayed for column type
$: canBeDisplay =
editableColumn?.type !== LINK_TYPE &&
editableColumn?.type !== AUTO_TYPE &&
editableColumn?.type !== JSON_TYPE &&
!editableColumn.autocolumn
$: canBeRequired =
editableColumn?.type !== LINK_TYPE &&
!uneditable &&
editableColumn?.type !== AUTO_TYPE &&
!editableColumn.autocolumn
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
// in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter(
opt =>
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
)
$: typeEnabled =
!originalName ||
(originalName &&
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
!editableColumn?.autocolumn)
async function saveColumn() { async function saveColumn() {
savingColumn = true savingColumn = true
if (errors?.length) { if (errors?.length) {
@ -679,6 +670,7 @@
</div> </div>
<div class="input-length"> <div class="input-length">
<ModalBindableInput <ModalBindableInput
panel={ServerBindingPanel}
title="Formula" title="Formula"
value={editableColumn.formula} value={editableColumn.formula}
on:change={e => { on:change={e => {
@ -689,6 +681,7 @@
}} }}
bindings={getBindings({ table })} bindings={getBindings({ table })}
allowJS allowJS
context={rowGoldenSample}
/> />
</div> </div>
</div> </div>

View File

@ -40,21 +40,22 @@
indentMore, indentMore,
indentLess, indentLess,
} from "@codemirror/commands" } from "@codemirror/commands"
import { Compartment } from "@codemirror/state" import { Compartment, EditorState } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./" import { EditorModes } from "./"
import { themeStore } from "stores/portal" import { themeStore } from "stores/portal"
export let label export let label
export let completions = [] export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars export let mode = EditorModes.Handlebars
export let value = "" export let value = ""
export let placeholder = null export let placeholder = null
export let autocompleteEnabled = true export let autocompleteEnabled = true
export let autofocus = false export let autofocus = false
export let jsBindingWrapping = true export let jsBindingWrapping = true
export let readonly = false
const dispatch = createEventDispatcher()
// Export a function to expose caret position // Export a function to expose caret position
export const getCaretPosition = () => { export const getCaretPosition = () => {
@ -82,8 +83,8 @@
}) })
} }
// For handlebars only. // Match decoration for HBS bindings
const bindStyle = new MatchDecorator({ const hbsMatchDeco = new MatchDecorator({
regexp: FIND_ANY_HBS_REGEX, regexp: FIND_ANY_HBS_REGEX,
decoration: () => { decoration: () => {
return Decoration.mark({ return Decoration.mark({
@ -94,12 +95,11 @@
}) })
}, },
}) })
const hbsMatchDecoPlugin = ViewPlugin.define(
let plugin = ViewPlugin.define(
view => ({ view => ({
decorations: bindStyle.createDeco(view), decorations: hbsMatchDeco.createDeco(view),
update(u) { update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations) this.decorations = hbsMatchDeco.updateDeco(u, this.decorations)
}, },
}), }),
{ {
@ -107,7 +107,29 @@
} }
) )
const dispatch = createEventDispatcher() // Match decoration for snippets
const snippetMatchDeco = new MatchDecorator({
regexp: /snippets\.[^\s(]+/g,
decoration: () => {
return Decoration.mark({
tag: "span",
attributes: {
class: "snippet-wrap",
},
})
},
})
const snippetMatchDecoPlugin = ViewPlugin.define(
view => ({
decorations: snippetMatchDeco.createDeco(view),
update(u) {
this.decorations = snippetMatchDeco.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
// Theming! // Theming!
let currentTheme = $themeStore?.theme let currentTheme = $themeStore?.theme
@ -117,7 +139,7 @@
const indentWithTabCustom = { const indentWithTabCustom = {
key: "Tab", key: "Tab",
run: view => { run: view => {
if (completionStatus(view.state) == "active") { if (completionStatus(view.state) === "active") {
acceptCompletion(view) acceptCompletion(view)
return true return true
} }
@ -131,7 +153,7 @@
} }
const buildKeymap = () => { const buildKeymap = () => {
const baseMap = [ return [
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
@ -139,43 +161,25 @@
...completionKeymap, ...completionKeymap,
indentWithTabCustom, indentWithTabCustom,
] ]
return baseMap
} }
const buildBaseExtensions = () => { const buildBaseExtensions = () => {
return [ return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(), drawSelection(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
closeBrackets(), closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }), syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(), highlightSpecialChars(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of(v => { themeConfig.of([...(isDark ? [oneDark] : [])]),
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
] ]
} }
// None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime
const buildExtensions = base => { const buildExtensions = base => {
const complete = [...base] let complete = [...base]
if (autocompleteEnabled) { if (autocompleteEnabled) {
complete.push( complete.push(
@ -183,7 +187,10 @@
override: [...completions], override: [...completions],
closeOnBlur: true, closeOnBlur: true,
icons: false, icons: false,
optionClass: () => "autocomplete-option", optionClass: completion =>
completion.simple
? "autocomplete-option-simple"
: "autocomplete-option",
}) })
) )
complete.push( complete.push(
@ -209,22 +216,49 @@
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
return false return false
}) })
) )
} }
if (mode.name == "javascript") { // JS only plugins
if (mode.name === "javascript") {
complete.push(snippetMatchDecoPlugin)
complete.push(javascript()) complete.push(javascript())
if (!readonly) {
complete.push(highlightWhitespace()) complete.push(highlightWhitespace())
complete.push(lineNumbers()) }
complete.push(foldGutter()) }
// HBS only plugins
else {
complete.push(hbsMatchDecoPlugin)
} }
if (placeholder) { if (placeholder) {
complete.push(placeholderFn(placeholder)) complete.push(placeholderFn(placeholder))
} }
if (readonly) {
complete.push(EditorState.readOnly.of(true))
} else {
complete = [
...complete,
history(),
highlightActiveLine(),
highlightActiveLineGutter(),
lineNumbers(),
foldGutter(),
keymap.of(buildKeymap()),
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
]
}
return complete return complete
} }
@ -249,8 +283,6 @@
} }
} }
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready // Init when all elements are ready
$: if (mounted && !isEditorInitialised) { $: if (mounted && !isEditorInitialised) {
isEditorInitialised = true isEditorInitialised = true
@ -265,14 +297,7 @@
// Issue theme compartment update // Issue theme compartment update
editor.dispatch({ editor.dispatch({
effects: themeConfig.reconfigure([ effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
}) })
} }
} }
@ -298,27 +323,207 @@
</div> </div>
<style> <style>
.code-editor.handlebars :global(.cm-content) { /* Editor */
font-family: var(--font-sans); .code-editor {
font-size: 12px;
height: 100%;
} }
.code-editor :global(.cm-tooltip.cm-completionInfo) { .code-editor :global(.cm-editor) {
padding: var(--spacing-m); height: 100%;
background: var(--spectrum-global-color-gray-50) !important;
outline: none;
border: none;
border-radius: 0;
} }
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) { .code-editor :global(.cm-content) {
border-radius: var( padding: 10px 0;
--spectrum-popover-border-radius, }
var(--spectrum-alias-border-radius-regular) .code-editor > div {
), height: 100%;
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
} }
/* Active line */
.code-editor :global(.cm-line) {
padding: 0 var(--spacing-s);
color: var(--spectrum-alias-text-color);
}
.code-editor :global(.cm-activeLine) {
position: relative;
background: transparent;
}
.code-editor :global(.cm-activeLine::before) {
content: "";
position: absolute;
left: 0;
top: 1px;
height: calc(100% - 2px);
width: 100%;
background: var(--spectrum-global-color-gray-100) !important;
z-index: -2;
}
.code-editor :global(.cm-highlightSpace:before) {
color: var(--spectrum-global-color-gray-500);
}
/* Code selection */
.code-editor :global(.cm-selectionBackground) {
background-color: var(--spectrum-global-color-blue-400) !important;
opacity: 0.4;
}
/* Gutters */
.code-editor :global(.cm-gutterElement) {
margin-bottom: 0;
}
.code-editor :global(.cm-gutters) {
background-color: var(--spectrum-global-color-gray-75) !important;
color: var(--spectrum-global-color-gray-500);
}
.code-editor :global(.cm-activeLineGutter::before) {
content: "";
position: absolute;
left: 0;
top: 1px;
height: calc(100% - 2px);
width: 100%;
background: var(--spectrum-global-color-gray-200) !important;
z-index: -2;
}
.code-editor :global(.cm-activeLineGutter) {
color: var(--spectrum-global-color-gray-700);
background: transparent;
position: relative;
}
/* Cursor color */
.code-editor :global(.cm-focused .cm-cursor) {
border-left-color: var(--spectrum-alias-text-color);
}
/* Placeholder */
.code-editor :global(.cm-placeholder) {
color: var(--spectrum-global-color-gray-700);
font-style: italic;
}
/* Highlight bindings and snippets */
.code-editor :global(.binding-wrap) {
color: var(--spectrum-global-color-blue-700) !important;
}
.code-editor :global(.snippet-wrap *) {
color: #61afef !important;
}
/* Completion popover */
.code-editor :global(.cm-tooltip-autocomplete) {
background: var(--spectrum-global-color-gray-75);
border-radius: 4px;
border: 1px solid var(--spectrum-global-color-gray-200);
}
.code-editor :global(.cm-tooltip-autocomplete > ul) {
max-height: 20em;
}
/* Completion section header*/
.code-editor :global(.info-section) {
display: flex;
align-items: center;
padding: var(--spacing-m);
font-family: var(--font-sans);
font-size: var(--font-size-s);
gap: var(--spacing-m);
color: var(--spectrum-alias-text-color);
font-weight: 600;
}
.code-editor :global(.info-section:not(:first-of-type)) {
border-top: 1px solid var(--spectrum-global-color-gray-200);
}
/* Completion item container */
.code-editor :global(.autocomplete-option),
.code-editor :global(.autocomplete-option-simple) {
padding: var(--spacing-s) var(--spacing-m) !important;
padding-left: calc(16px + 2 * var(--spacing-m)) !important;
display: flex;
gap: var(--spacing-m);
align-items: center;
color: var(--spectrum-alias-text-color);
}
.code-editor :global(.autocomplete-option-simple) {
padding-left: var(--spacing-s) !important;
}
/* Highlighted completion item */
.code-editor :global(.autocomplete-option[aria-selected]),
.code-editor :global(.autocomplete-option-simple[aria-selected]) {
background: var(--spectrum-global-color-blue-400);
color: white;
}
.code-editor
:global(.autocomplete-option[aria-selected] .cm-completionDetail) {
color: white;
}
/* Completion item label */
.code-editor :global(.cm-completionLabel) {
flex: 1 1 auto;
font-size: var(--font-size-s);
font-family: var(--font-sans);
text-transform: capitalize;
}
.code-editor :global(.autocomplete-option-simple .cm-completionLabel) {
text-transform: none;
}
/* Completion item type */
.code-editor :global(.autocomplete-option .cm-completionDetail) { .code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200); font-family: var(--font-mono);
color: var(--spectrum-global-color-gray-700);
font-style: normal;
text-transform: capitalize;
font-size: 10px;
}
/* Live binding value / helper container */
.code-editor :global(.cm-completionInfo) {
margin-left: var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
padding: 4px 6px; background-color: var(--spectrum-global-color-gray-50);
padding: var(--spacing-m);
margin-top: -2px;
}
/* Wrapper around helpers */
.code-editor :global(.info-bubble) {
font-size: var(--font-size-s);
display: flex;
flex-direction: column;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-800);
}
/* Live binding value / helper value */
.code-editor :global(.binding__description) {
color: var(--spectrum-alias-text-color);
font-size: var(--font-size-m);
}
.code-editor :global(.binding__example) {
padding: 0;
margin: 0;
font-size: 12px;
font-family: var(--font-mono);
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
max-height: 480px;
}
.code-editor :global(.binding__example.helper) {
color: var(--spectrum-global-color-blue-700);
}
.code-editor :global(.binding__example span) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
} }
</style> </style>

View File

@ -1,4 +1,3 @@
import { EditorView } from "@codemirror/view"
import { getManifest } from "@budibase/string-templates" import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html" import sanitizeHtml from "sanitize-html"
import { groupBy } from "lodash" import { groupBy } from "lodash"
@ -27,123 +26,33 @@ export const SECTIONS = {
}, },
} }
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",
backgroundColor: "var(--spectrum-global-color-gray-100)",
color: "var(--spectrum-global-color-gray-600)",
},
"& .cm-completionLabel": {
marginLeft:
"calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))",
},
"& .info-bubble": {
fontSize: "var(--font-size-s)",
display: "grid",
gridGap: "var(--spacing-s)",
gridTemplateColumns: "1fr",
color: "var(--spectrum-global-color-gray-800)",
},
"& .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) )",
backgroundColor: "var(--spectrum-global-color-gray-50)",
},
// Section header
"& .info-section": {
display: "flex",
padding: "var(--spacing-s)",
gap: "var(--spacing-m)",
borderBottom: "1px solid var(--spectrum-global-color-gray-200)",
color: "var(--spectrum-global-color-gray-800)",
fontWeight: "bold",
},
"& .info-section .spectrum-Icon": {
color: "var(--spectrum-global-color-gray-600)",
},
// 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)",
color: "var(--spectrum-global-color-gray-800)",
},
"& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": {
backgroundColor: "var(--spectrum-global-color-gray-200)",
},
"& .binding-wrap": {
color: "var(--spectrum-global-color-blue-700)",
fontFamily: "monospace",
},
},
{ dark }
)
}
export const buildHelperInfoNode = (completion, helper) => { export const buildHelperInfoNode = (completion, helper) => {
const ele = document.createElement("div") const ele = document.createElement("div")
ele.classList.add("info-bubble") ele.classList.add("info-bubble")
const exampleNodeHtml = helper.example const exampleNodeHtml = helper.example
? `<div class="binding__example">${helper.example}</div>` ? `<div class="binding__example helper">${helper.example}</div>`
: "" : ""
const descriptionMarkup = sanitizeHtml(helper.description, { const descriptionMarkup = sanitizeHtml(helper.description, {
allowedTags: [], allowedTags: [],
allowedAttributes: {}, allowedAttributes: {},
}) })
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>` const descriptionNodeHtml = `<div class="binding__description helper">${descriptionMarkup}</div>`
ele.innerHTML = ` ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml} ${descriptionNodeHtml}
${exampleNodeHtml}
` `
return ele return ele
} }
const toSpectrumIcon = name => { const toSpectrumIcon = name => {
return `<svg return `<svg
class="spectrum-Icon spectrum-Icon--sizeM" class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false" focusable="false"
aria-hidden="false" aria-hidden="false"
aria-label="${name}-section-icon" aria-label="${name}-section-icon"
style="color:var(--spectrum-global-color-gray-700)"
> >
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" /> <use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
</svg>` </svg>`
@ -152,7 +61,9 @@ const toSpectrumIcon = name => {
export const buildSectionHeader = (type, sectionName, icon, rank) => { export const buildSectionHeader = (type, sectionName, icon, rank) => {
const ele = document.createElement("div") const ele = document.createElement("div")
ele.classList.add("info-section") ele.classList.add("info-section")
if (type) {
ele.classList.add(type) ele.classList.add(type)
}
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>` ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
return { return {
name: sectionName, name: sectionName,
@ -174,7 +85,7 @@ export const helpersToCompletion = (helpers, mode) => {
}, },
type: "helper", type: "helper",
section: helperSection, section: helperSection,
detail: "FUNCTION", detail: "Function",
apply: (view, completion, from, to) => { apply: (view, completion, from, to) => {
insertBinding(view, from, to, key, mode) insertBinding(view, from, to, key, mode)
}, },
@ -191,6 +102,29 @@ export const getHelperCompletions = mode => {
}, []) }, [])
} }
export const snippetAutoComplete = snippets => {
return function myCompletions(context) {
if (!snippets?.length) {
return null
}
const word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit) {
return null
}
return {
from: word.from,
options: snippets.map(snippet => ({
label: `snippets.${snippet.name}`,
type: "text",
simple: true,
apply: (view, completion, from, to) => {
insertSnippet(view, from, to, completion.label)
},
})),
}
}
}
const bindingFilter = (options, query) => { const bindingFilter = (options, query) => {
return options.filter(completion => { return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase() const section_parsed = completion.section.name.toLowerCase()
@ -252,21 +186,12 @@ export const jsAutocomplete = baseCompletions => {
} }
export const buildBindingInfoNode = (completion, binding) => { export const buildBindingInfoNode = (completion, binding) => {
if (!binding.valueHTML || binding.value == null) {
return null
}
const ele = document.createElement("div") const ele = document.createElement("div")
ele.classList.add("info-bubble") ele.classList.add("info-bubble")
ele.innerHTML = `<div class="binding__example">${binding.valueHTML}</div>`
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 return ele
} }
@ -345,6 +270,20 @@ export const insertBinding = (view, from, to, text, mode) => {
}) })
} }
export const insertSnippet = (view, from, to, text) => {
let cursorPos = from + text.length
view.dispatch({
changes: {
from,
to,
insert: text,
},
selection: {
anchor: cursorPos,
},
})
}
export const bindingsToCompletions = (bindings, mode) => { export const bindingsToCompletions = (bindings, mode) => {
const bindingByCategory = groupBy(bindings, "category") const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => { const categoryMeta = bindings?.reduce((acc, ele) => {

View File

@ -71,6 +71,7 @@
class:scrollable class:scrollable
class:highlighted class:highlighted
class:selectedBy class:selectedBy
class:actionsOpen={highlighted && withActions}
on:dragend on:dragend
on:dragstart on:dragstart
on:dragover on:dragover
@ -168,8 +169,9 @@
--avatars-background: var(--spectrum-global-color-gray-300); --avatars-background: var(--spectrum-global-color-gray-300);
} }
.nav-item:hover .actions, .nav-item:hover .actions,
.hovering .actions { .hovering .actions,
visibility: visible; .nav-item.withActions.actionsOpen .actions {
opacity: 1;
} }
.nav-item-content { .nav-item-content {
flex: 1 1 auto; flex: 1 1 auto;
@ -272,7 +274,6 @@
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
visibility: hidden;
order: 3; order: 3;
opacity: 0; opacity: 0;
width: 20px; width: 20px;

View File

@ -1,74 +1,194 @@
<script> <script>
import { import {
DrawerContent, DrawerContent,
Tabs, ActionButton,
Tab, Icon,
Heading,
Body, Body,
Button, Button,
ActionButton,
Heading,
Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, onMount, getContext } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { import {
isValid,
decodeJSBinding, decodeJSBinding,
encodeJSBinding, encodeJSBinding,
convertToJS, processStringSync,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { import { readableToRuntimeBinding } from "dataBinding"
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import { admin } from "stores/portal"
import CodeEditor from "../CodeEditor/CodeEditor.svelte" import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import { import {
getHelperCompletions, getHelperCompletions,
jsAutocomplete, jsAutocomplete,
hbAutocomplete, hbAutocomplete,
snippetAutoComplete,
EditorModes, EditorModes,
bindingsToCompletions, bindingsToCompletions,
} from "../CodeEditor" } from "../CodeEditor"
import BindingPicker from "./BindingPicker.svelte" import BindingSidePanel from "./BindingSidePanel.svelte"
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils" 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() const dispatch = createEventDispatcher()
export let bindings export let bindings = []
// jsValue/hbsValue are the state of the value that is being built
// within this binding panel - the value should not be updated until
// the binding panel is saved. This is the default value of the
// expression when the binding panel is opened, but shouldn't be updated.
export let value = "" export let value = ""
export let valid export let allowHBS = true
export let allowJS = false export let allowJS = false
export let allowHelpers = true export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let snippets = null
export let autofocusEditor = false export let autofocusEditor = false
export let placeholder = null
export let showTabBar = true
const drawerActions = getContext("drawer-actions") const Modes = {
const bindingDrawerActions = getContext("binding-drawer-actions") Text: "Text",
JavaScript: "JavaScript",
}
const SidePanels = {
Bindings: "FlashOn",
Evaluation: "Play",
Snippets: "Code",
}
let getCaretPosition let mode
let insertAtPos let sidePanel
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ") let initialValueJS = value?.startsWith?.("{{ js ")
let mode = initialValueJS ? "JavaScript" : "Text"
let jsValue = initialValueJS ? value : null let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value let hbsValue = initialValueJS ? null : value
let sidebar = true let getCaretPosition
let insertAtPos
let targetMode = null let targetMode = null
let expressionResult
let evaluating = false
$: usingJS = mode === "JavaScript" $: useSnippets = allowSnippets && !$licensing.isFreePlan
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: sidePanelOptions = getSidePanelOptions(
bindings,
context,
allowSnippets,
mode
)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === Modes.JavaScript
$: editorMode = $: editorMode =
mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: bindingCompletions = bindingsToCompletions(bindings, editorMode) $: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) $: 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) {
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 updateValue = val => {
valid = isValid(readableToRuntimeBinding(bindings, val)) const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
if (valid) {
dispatch("change", val) dispatch("change", val)
} requestEval(runtimeExpression, context, snippets)
} }
const onSelectHelper = (helper, js) => { const onSelectHelper = (helper, js) => {
@ -80,9 +200,34 @@
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js }) bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
} }
const onChangeMode = e => { const changeMode = newMode => {
mode = e.detail if (targetMode || newMode === mode) {
updateValue(mode === "JavaScript" ? jsValue : hbsValue) 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 => { const onChangeHBSValue = e => {
@ -95,164 +240,87 @@
updateValue(jsValue) updateValue(jsValue)
} }
const switchMode = () => {
if (targetMode == "Text") {
jsValue = null
updateValue(jsValue)
} else {
hbsValue = null
updateValue(hbsValue)
}
mode = targetMode + ""
targetMode = null
}
const convert = () => {
const runtime = readableToRuntimeBinding(bindings, hbsValue)
const runtimeJs = encodeJSBinding(convertToJS(runtime))
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
hbsValue = null
mode = "JavaScript"
onSelectBinding("", { forceJS: true })
}
onMount(() => { onMount(() => {
valid = isValid(readableToRuntimeBinding(bindings, value)) // 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> </script>
<span class="binding-drawer"> <DrawerContent padding={false}>
<DrawerContent> <div class="binding-panel">
<div class="main"> <div class="main">
<Tabs {#if showTabBar}
selected={mode} <div class="tabs">
on:select={onChangeMode} <div class="editor-tabs">
beforeSwitch={selectedMode => { {#each editorModeOptions as editorMode}
if (selectedMode == mode) { <ActionButton
return true size="M"
} quiet
selected={mode === editorMode}
//Get the current mode value on:click={() => changeMode(editorMode)}
const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
if (editorValue) {
targetMode = selectedMode
return false
}
return true
}}
> >
<Tab title="Text"> {capitalise(editorMode)}
<div class="main-content" class:binding-panel={sidebar}> </ActionButton>
<div class="editor"> {/each}
<div class="overlay-wrap">
{#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 text
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard text
</Button>
</div> </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>
</div> </div>
{/if} {/if}
<div class="editor">
{#if mode === Modes.Text}
{#key hbsCompletions}
<CodeEditor <CodeEditor
value={hbsValue} value={hbsValue}
on:change={onChangeHBSValue} on:change={onChangeHBSValue}
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
completions={[ completions={hbsCompletions}
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
placeholder=""
height="100%"
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"}
jsBindingWrapping={false}
/> />
</div> {/key}
<div class="binding-footer"> {:else if mode === Modes.JavaScript}
<div class="messaging"> {#key jsCompletions}
{#if !valid} <CodeEditor
<div class="syntax-error"> value={decodeJSBinding(jsValue)}
Current Handlebars syntax is invalid, please check the on:change={onChangeJSValue}
guide completions={jsCompletions}
<a href="https://handlebarsjs.com/guide/" target="_blank" mode={EditorModes.JS}
>here</a bind:getCaretPosition
> bind:insertAtPos
for more details. autofocus={autofocusEditor}
</div> placeholder={placeholder ||
{:else} "Add bindings by typing $ or use the menu on the right"}
<Icon name="FlashOn" /> jsBindingWrapping
<div class="messaging-wrap">
<div>
Add available bindings by typing &#123;&#123; or use the
menu on the right
</div>
</div>
{/if}
</div>
<div class="actions">
{#if $admin.isDev && allowJS}
<ActionButton
secondary
on:click={() => {
convert()
targetMode = null
}}
>
Convert To JS
</ActionButton>
{/if}
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/> />
</div> {/key}
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if} {/if}
</div>
</Tab>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<div class="overlay-wrap">
{#if targetMode} {#if targetMode}
<div class="mode-overlay"> <div class="mode-overlay">
<div class="prompt-body"> <div class="prompt-body">
<Heading size="S"> <Heading size="S">
{`Switch to ${targetMode}?`} Switch to {targetMode}?
</Heading> </Heading>
<Body>This will discard anything in your binding</Body> <Body>This will discard anything in your binding</Body>
<div class="switch-actions"> <div class="switch-actions">
@ -263,206 +331,97 @@
targetMode = null targetMode = null
}} }}
> >
No - keep javascript No - keep {mode}
</Button> </Button>
<Button cta size="S" on:click={switchMode}> <Button cta size="S" on:click={confirmChangeMode}>
Yes - discard javascript Yes - discard {mode}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
completions={[
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
height="100%"
autofocus={autofocusEditor}
/>
</div>
<div class="binding-footer">
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing $ or use the menu on
the right
</div> </div>
</div> </div>
</div> <div class="side" class:visible={!!sidePanel}>
<div class="actions"> {#if sidePanel === SidePanels.Bindings}
<ActionButton <BindingSidePanel
secondary bindings={enrichedBindings}
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers} {allowHelpers}
{context}
addHelper={onSelectHelper} addHelper={onSelectHelper}
addBinding={onSelectBinding} addBinding={onSelectBinding}
mode={editorMode} mode={editorMode}
/> />
</div> {:else if sidePanel === SidePanels.Evaluation}
<EvaluationSidePanel
{expressionResult}
{evaluating}
expression={editorValue}
/>
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
/>
{/if} {/if}
</div> </div>
</Tab>
{/if}
<div class="drawer-actions">
{#if typeof drawerActions?.hide === "function" && drawerActions?.headless}
<Button
secondary
quiet
on:click={() => {
drawerActions.hide()
}}
>
Cancel
</Button>
{/if}
{#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
<Button
cta
disabled={!valid}
on:click={() => {
bindingDrawerActions.save()
}}
>
Save
</Button>
{/if}
</div> </div>
</Tabs> </DrawerContent>
</div>
</DrawerContent>
</span>
<style> <style>
.binding-drawer :global(.container > .main) { .binding-panel {
overflow: hidden;
height: 100%;
padding: 0px;
}
.binding-drawer :global(.container > .main > .main) {
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.binding-drawer :global(.spectrum-Tabs-content) {
flex: 1;
overflow: hidden;
}
.binding-drawer :global(.spectrum-Tabs-content > div),
.binding-drawer :global(.spectrum-Tabs-content > div > div),
.binding-drawer :global(.spectrum-Tabs-content .main-content) {
height: 100%; height: 100%;
} }
.binding-panel,
.binding-drawer .main-content { .tabs {
grid-template-rows: unset;
}
.messaging {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
flex: 1;
}
.messaging-wrap {
overflow: hidden;
}
.messaging-wrap > div {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.main :global(textarea) {
min-height: 202px !important;
}
.main-content {
padding: var(--spacing-s) var(--spacing-xl);
}
.main :global(.spectrum-Tabs div.drawer-actions) {
display: flex;
gap: var(--spacing-m);
margin-left: auto;
}
.main :global(.spectrum-Tabs-content),
.main :global(.spectrum-Tabs-content .main-content) {
margin-top: 0px;
padding: 0px;
}
.main :global(.spectrum-Tabs) {
display: flex;
}
.syntax-error {
color: var(--red);
font-size: 12px;
}
.syntax-error a {
color: var(--red);
text-decoration: underline;
}
.binding-footer {
width: 100%;
display: flex; display: flex;
flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: stretch;
} }
.main-content { .main {
display: grid; flex: 1 1 auto;
grid-template-columns: 1fr;
grid-template-rows: 380px;
}
.main-content.binding-panel {
grid-template-columns: 1fr 320px;
}
.binding-picker {
border-left: 2px solid var(--border-light);
border-left: var(--border-light);
overflow: scroll;
height: 100%;
}
.editor {
padding: var(--spacing-xl);
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-xl); justify-content: flex-start;
overflow: hidden; align-items: stretch;
} }
.overlay-wrap { .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; position: relative;
flex: 1;
overflow: hidden;
} }
/* Overlay */
.mode-overlay { .mode-overlay {
position: absolute; position: absolute;
top: 0; top: 0;
@ -471,6 +430,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: var( background-color: var(
@ -490,9 +450,4 @@
display: flex; display: flex;
gap: var(--spacing-l); gap: var(--spacing-l);
} }
.binding-drawer :global(.code-editor),
.binding-drawer :global(.code-editor > div) {
height: 100%;
}
</style> </style>

View File

@ -1,399 +0,0 @@
<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
export let noPaddingTop = false
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 !search || binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return (
(!search ||
helper.label.match(searchRgx) ||
helper.description.match(searchRgx)) &&
(mode.name !== "javascript" || helper.allowsJs)
)
})
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}
maxHeight={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">
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
<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>
<span
class="search-input-icon"
on:click={() => {
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}
<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);
}
.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

@ -0,0 +1,446 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, 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
export let context = null
let search = ""
let searching = false
let popover
let popoverAnchor
let hoverTarget
let helpers = handlebarsCompletions()
let selectedCategory
let hideTimeout
$: 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)
$: searchRgx = new RegExp(search, "ig")
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return !search || binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return (
(!search ||
helper.label.match(searchRgx) ||
helper.description.match(searchRgx)) &&
(mode.name !== "javascript" || helper.allowsJs)
)
})
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
}
const showBindingPopover = (binding, target) => {
if (!context || !binding.value || binding.value === "") {
return
}
// Roles have always been broken for JS. We need to exclude them from
// showing a popover as it will show "Error while executing JS".
if (binding.category === "Role") {
return
}
stopHidingPopover()
popoverAnchor = target
hoverTarget = {
helper: false,
code: binding.valueHTML,
}
popover.show()
}
const showHelperPopover = (helper, target) => {
stopHidingPopover()
if (!helper.displayText && helper.description) {
return
}
popoverAnchor = target
hoverTarget = {
helper: true,
description: helper.description,
code: getHelperExample(helper, mode.name === "javascript"),
}
popover.show()
}
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
hideTimeout = null
}, 100)
}
const stopHidingPopover = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const startSearching = async () => {
searching = true
search = ""
}
const stopSearching = e => {
e.stopPropagation()
searching = false
search = ""
}
</script>
<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="binding-popover" class:helper={hoverTarget.helper}>
{#if hoverTarget.description}
<div>
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.code}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
<pre>{@html hoverTarget.code}</pre>
{/if}
</div>
</Popover>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="binding-side-panel">
<Layout noPadding gap="S">
{#if selectedCategory}
<div class="header">
<Icon
name="BackAndroid"
hoverable
size="S"
on:click={() => (selectedCategory = null)}
/>
{selectedCategory}
</div>
{/if}
{#if !selectedCategory}
<div class="header">
{#if searching}
<div class="search-input">
<Input
placeholder="Search for bindings"
autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/>
{:else}
<div class="title">Bindings</div>
<Icon
size="S"
name="Search"
hoverable
newStyles
on:click={startSearching}
/>
{/if}
</div>
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon
size="S"
color="var(--spectrum-global-color-gray-700)"
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">
{#if filteredCategories.length > 1}
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
{/if}
<ul>
{#each category.bindings as binding}
<li
class="binding"
on:mouseenter={e => showBindingPopover(binding, e.target)}
on:mouseleave={hidePopover}
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">
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:mouseenter={e => showHelperPopover(helper, e.target)}
on:mouseleave={hidePopover}
on:click={() => addHelper(helper, mode.name === "javascript")}
>
<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>
</div>
<style>
.binding-side-panel {
border-left: var(--border-light);
height: 100%;
overflow: auto;
}
.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;
}
ul.category-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.sub-section {
padding: var(--spacing-l);
padding-top: 0;
}
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list :global(.spectrum-Icon) {
margin: -4px 0;
}
ul.category-list .category-name {
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;
gap: var(--spacing-m);
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
:global(.drawer-actions) {
display: flex;
gap: var(--spacing-m);
}
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
color: var(--spectrum-global-color-gray-700);
margin-bottom: var(--spacing-s);
}
.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-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
font-size: 10px;
color: var(--spectrum-global-color-gray-700);
}
.binding-popover {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
padding: var(--spacing-m);
}
.binding-popover pre {
padding: 0;
margin: 0;
font-size: 12px;
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
}
.binding-popover.helper pre {
color: var(--spectrum-global-color-blue-700);
}
.binding-popover pre :global(span) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
.binding-popover :global(p) {
padding: 0;
margin: 0;
}
.binding-popover.helper :global(code) {
font-size: 12px;
}
</style>

View File

@ -1,8 +1,9 @@
<script> <script>
import BindingPanel from "./BindingPanel.svelte" import BindingPanel from "./BindingPanel.svelte"
import { previewStore, snippets } from "stores/builder"
import { onMount } from "svelte"
export let bindings = [] export let bindings = []
export let valid
export let value = "" export let value = ""
export let allowJS = false export let allowJS = false
export let allowHelpers = true export let allowHelpers = true
@ -20,11 +21,14 @@
type: null, type: null,
})) }))
} }
onMount(previewStore.requestComponentContext)
</script> </script>
<BindingPanel <BindingPanel
bind:valid
bindings={enrichedBindings} bindings={enrichedBindings}
context={$previewStore.selectedComponentContext}
snippets={$snippets}
{value} {value}
{allowJS} {allowJS}
{allowHelpers} {allowHelpers}

View File

@ -22,7 +22,6 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
let valid = true
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue $: tempValue = readableValue
@ -79,20 +78,13 @@
{/if} {/if}
</div> </div>
<Drawer bind:this={bindingDrawer} {title} headless> <Drawer bind:this={bindingDrawer} title={title ?? placeholder ?? "Bindings"}>
<svelte:fragment slot="description"> <Button cta slot="buttons" on:click={handleClose}>Save</Button>
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
Save
</Button>
<svelte:component <svelte:component
this={panel} this={panel}
slot="body" slot="body"
value={readableValue} value={readableValue}
close={handleClose} close={handleClose}
bind:valid
on:change={event => (tempValue = event.detail)} on:change={event => (tempValue = event.detail)}
{bindings} {bindings}
{allowJS} {allowJS}

View File

@ -13,21 +13,21 @@
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let value = "" export let value = ""
export let bindings = [] export let bindings = []
export let title = "Bindings" export let title
export let placeholder export let placeholder
export let label export let label
export let disabled = false export let disabled = false
export let fillWidth
export let allowJS = true export let allowJS = true
export let allowHelpers = true export let allowHelpers = true
export let updateOnChange = true export let updateOnChange = true
export let drawerLeft
export let key export let key
export let disableBindings = false export let disableBindings = false
export let forceModal = false
export let context = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
let valid = true
let currentVal = value let currentVal = value
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
@ -88,27 +88,20 @@
<Drawer <Drawer
on:drawerHide={onDrawerHide} on:drawerHide={onDrawerHide}
on:drawerShow on:drawerShow
{fillWidth}
bind:this={bindingDrawer} bind:this={bindingDrawer}
{title} title={title ?? placeholder ?? "Bindings"}
left={drawerLeft} {forceModal}
headless
> >
<svelte:fragment slot="description"> <Button cta slot="buttons" on:click={saveBinding}>Save</Button>
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
Save
</Button>
<svelte:component <svelte:component
this={panel} this={panel}
slot="body" slot="body"
bind:valid
value={readableValue} value={readableValue}
on:change={event => (tempValue = event.detail)} on:change={event => (tempValue = event.detail)}
{bindings} {bindings}
{allowJS} {allowJS}
{allowHelpers} {allowHelpers}
{context}
/> />
</Drawer> </Drawer>

View File

@ -16,7 +16,6 @@
export let placeholder export let placeholder
export let label export let label
export let disabled = false export let disabled = false
export let fillWidth
export let allowJS = true export let allowJS = true
export let allowHelpers = true export let allowHelpers = true
export let updateOnChange = true export let updateOnChange = true
@ -26,7 +25,6 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
let valid = true
let currentVal = value let currentVal = value
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
@ -173,22 +171,14 @@
<Drawer <Drawer
on:drawerHide on:drawerHide
on:drawerShow on:drawerShow
{fillWidth}
bind:this={bindingDrawer} bind:this={bindingDrawer}
{title} title={title ?? placeholder ?? "Bindings"}
left={drawerLeft} left={drawerLeft}
headless
> >
<svelte:fragment slot="description"> <Button cta slot="buttons" on:click={saveBinding}>Save</Button>
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
Save
</Button>
<svelte:component <svelte:component
this={panel} this={panel}
slot="body" slot="body"
bind:valid
value={readableValue} value={readableValue}
on:change={event => (tempValue = event.detail)} on:change={event => (tempValue = event.detail)}
{bindings} {bindings}

View File

@ -0,0 +1,133 @@
<script>
import formatHighlight from "json-format-highlight"
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
import { copyToClipboard } from "@budibase/bbui/helpers"
import { fade } from "svelte/transition"
export let expressionResult
export let evaluating = false
export let expression = null
$: error = expressionResult === "Error while executing JS"
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
const highlight = json => {
if (json == null) {
return ""
}
// Attempt to parse and then stringify, in case this is valid JSON
try {
json = JSON.stringify(JSON.parse(json), null, 2)
} catch (err) {
// Ignore
}
return formatHighlight(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
trueColor: "#d19a66",
falseColor: "#d19a66",
nullColor: "#c678dd",
})
}
const copy = () => {
let clipboardVal = expressionResult
if (typeof clipboardVal === "object") {
clipboardVal = JSON.stringify(clipboardVal, null, 2)
}
copyToClipboard(clipboardVal)
notifications.success("Value copied to clipboard")
}
</script>
<div class="evaluation-side-panel">
<div class="header" class:success class:error>
<div class="header-content">
{#if error}
<Icon name="Alert" color="var(--spectrum-global-color-red-600)" />
<div>Error</div>
{#if evaluating}
<div transition:fade|local={{ duration: 130 }}>
<ProgressCircle size="S" />
</div>
{/if}
<span />
<Icon name="Copy" size="S" hoverable on:click={copy} />
{:else}
<div>Preview</div>
{#if evaluating}
<div transition:fade|local={{ duration: 130 }}>
<ProgressCircle size="S" />
</div>
{/if}
<span />
{#if !empty}
<Icon name="Copy" newStyles size="S" hoverable on:click={copy} />
{/if}
{/if}
</div>
</div>
<div class="body">
{#if empty}
Your expression will be evaluated here
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
{/if}
</div>
</div>
<style>
.evaluation-side-panel {
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
border-left: var(--border-light);
}
.header {
padding: var(--spacing-m) var(--spacing-l);
flex: 0 0 auto;
position: relative;
border-bottom: var(--border-light);
}
.header-content {
height: var(--spectrum-alias-item-height-m);
display: flex;
align-items: center;
z-index: 2;
position: relative;
gap: var(--spacing-m);
}
.header-content span {
flex: 1 1 auto;
}
.header.error::before {
content: "";
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
position: absolute;
opacity: 10%;
}
.header.error::before {
background: var(--spectrum-global-color-red-400);
}
.body {
flex: 1 1 auto;
padding: var(--spacing-m) var(--spacing-l);
font-family: var(--font-mono);
font-size: 12px;
overflow-y: scroll;
overflow-x: hidden;
white-space: pre-wrap;
word-wrap: break-word;
height: 0;
}
</style>

View File

@ -1,115 +1,12 @@
<script> <script>
import { Icon, Input, Modal, Body, ModalContent } from "@budibase/bbui" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ServerBindingPanel
export let value = ""
export let bindings = []
export let title = "Bindings"
export let placeholder
export let label
export let allowJS = false
export let updateOnChange = true
const dispatch = createEventDispatcher()
let bindingModal
let valid = true
$: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
$: isJS = isJSBinding(value)
const saveBinding = () => {
onChange(tempValue)
bindingModal.hide()
}
const onChange = input => {
dispatch("change", readableToRuntimeBinding(bindings, input))
}
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <DrawerBindableInput
<!-- svelte-ignore a11y-no-static-element-interactions --> {...$$props}
<div class="control"> forceModal
<Input on:change
{label} on:blur
readonly={isJS} on:drawerHide
value={isJS ? "(JavaScript function)" : readableValue} on:drawerShow
on:change={event => onChange(event.detail)} />
{placeholder}
{updateOnChange}
/>
<div class="icon" on:click={bindingModal.show}>
<Icon size="S" name="FlashOn" />
</div>
</div>
<Modal bind:this={bindingModal}>
<ModalContent {title} onConfirm={saveBinding} disabled={!valid} size="XL">
<Body extraSmall grey>
Add the objects on the left to enrich your text.
</Body>
<div class="panel-wrapper">
<svelte:component
this={panel}
serverSide
value={readableValue}
bind:valid
on:change={e => (tempValue = e.detail)}
{bindings}
{allowJS}
/>
</div>
</ModalContent>
</Modal>
<style>
.control {
flex: 1;
position: relative;
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.panel-wrapper {
border: var(--border-light);
border-radius: 4px;
}
.control :global(.spectrum-Textfield-input) {
padding-right: 40px;
}
</style>

View File

@ -1,10 +1,11 @@
<script> <script>
import BindingPanel from "./BindingPanel.svelte" import BindingPanel from "./BindingPanel.svelte"
import { snippets } from "stores/builder"
export let bindings = [] export let bindings = []
export let valid
export let value = "" export let value = ""
export let allowJS = false export let allowJS = false
export let context = null
$: enrichedBindings = enrichBindings(bindings) $: enrichedBindings = enrichBindings(bindings)
@ -19,9 +20,10 @@
</script> </script>
<BindingPanel <BindingPanel
bind:valid
bindings={enrichedBindings} bindings={enrichedBindings}
snippets={$snippets}
{value} {value}
{allowJS} {allowJS}
{context}
on:change on:change
/> />

View File

@ -0,0 +1,160 @@
<script>
import {
Button,
Drawer,
Input,
Icon,
AbsTooltip,
TooltipType,
notifications,
} from "@budibase/bbui"
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
import { decodeJSBinding, encodeJSBinding } from "@budibase/string-templates"
import { snippets } from "stores/builder"
import { getSequentialName } from "helpers/duplicate"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
export let snippet
export const show = () => drawer.show()
export const hide = () => drawer.hide()
const firstCharNumberRegex = /^[0-9].*$/
let drawer
let name = ""
let code = ""
let loading = false
let deleteConfirmationDialog
$: defaultName = getSequentialName($snippets, "MySnippet", x => x.name)
$: key = snippet?.name
$: name = snippet?.name || defaultName
$: code = snippet?.code ? encodeJSBinding(snippet.code) : ""
$: rawJS = decodeJSBinding(code)
$: nameError = validateName(name, $snippets)
const saveSnippet = async () => {
loading = true
try {
const newSnippet = { name, code: rawJS }
await snippets.saveSnippet(newSnippet)
drawer.hide()
notifications.success(`Snippet ${newSnippet.name} saved`)
} catch (error) {
notifications.error(error.message || "Error saving snippet")
}
loading = false
}
const deleteSnippet = async () => {
loading = true
try {
await snippets.deleteSnippet(snippet.name)
drawer.hide()
} catch (error) {
notifications.error("Error deleting snippet")
}
loading = false
}
const validateName = (name, snippets) => {
if (!name?.length) {
return "Name is required"
}
if (snippets.some(snippet => snippet.name === name)) {
return "That name is already in use"
}
if (firstCharNumberRegex.test(name)) {
return "Can't start with a number"
}
if (!ValidSnippetNameRegex.test(name)) {
return "No special characters or spaces"
}
return null
}
</script>
<Drawer bind:this={drawer}>
<svelte:fragment slot="title">
{#if snippet}
{snippet.name}
{:else}
<div class="name" class:invalid={nameError != null}>
<span>Name</span>
<Input bind:value={name} />
{#if nameError}
<AbsTooltip text={nameError} type={TooltipType.Negative}>
<Icon
name="Help"
size="S"
color="var(--spectrum-global-color-red-400)"
/>
</AbsTooltip>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="buttons">
{#if snippet}
<Button
warning
on:click={deleteConfirmationDialog.show}
disabled={loading}
>
Delete
</Button>
{/if}
<Button
cta
on:click={saveSnippet}
disabled={!snippet && (loading || nameError)}
>
Save
</Button>
</svelte:fragment>
<svelte:fragment slot="body">
{#key key}
<BindingPanel
allowHBS={false}
allowJS
allowSnippets={false}
showTabBar={false}
placeholder="return function(input) &#10100; ... &#10101;"
value={code}
on:change={e => (code = e.detail)}
>
<div slot="tabs">
<Input placeholder="Name" />
</div>
</BindingPanel>
{/key}
</svelte:fragment>
</Drawer>
<ConfirmDialog
bind:this={deleteConfirmationDialog}
title="Delete snippet"
body={`Are you sure you want to delete ${snippet?.name}?`}
onOk={deleteSnippet}
/>
<style>
.name {
display: flex;
gap: var(--spacing-l);
align-items: center;
position: relative;
}
.name :global(input) {
width: 200px;
}
.name.invalid :global(input) {
padding-right: 32px;
}
.name :global(.icon) {
position: absolute;
right: 10px;
}
</style>

View File

@ -0,0 +1,278 @@
<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

@ -38,4 +38,11 @@ export class BindingHelpers {
this.insertAtPos({ start, end, value: insertVal }) this.insertAtPos({ start, end, value: insertVal })
} }
} }
// Adds a snippet to the expression
onSelectSnippet(snippet) {
const pos = this.getCaretPosition()
const { start, end } = pos
this.insertAtPos({ start, end, value: `snippets.${snippet.name}` })
}
} }

View File

@ -3,9 +3,16 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { appStore } from "stores/builder"
import { API } from "api" import { API } from "api"
export let appId
export let appName
export let onDeleteSuccess = () => {
$goto("/builder")
}
let deleting = false
export const show = () => { export const show = () => {
deletionModal.show() deletionModal.show()
} }
@ -17,32 +24,52 @@
let deletionModal let deletionModal
let deletionConfirmationAppName let deletionConfirmationAppName
const copyName = () => {
deletionConfirmationAppName = appName
}
const deleteApp = async () => { const deleteApp = async () => {
if (!appId) {
console.error("No app id provided")
return
}
deleting = true
try { try {
await API.deleteApp($appStore.appId) await API.deleteApp(appId)
apps.load() apps.load()
notifications.success("App deleted successfully") notifications.success("App deleted successfully")
$goto("/builder") onDeleteSuccess()
} catch (err) { } catch (err) {
notifications.error("Error deleting app") notifications.error("Error deleting app")
deleting = false
} }
} }
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<ConfirmDialog <ConfirmDialog
bind:this={deletionModal} bind:this={deletionModal}
title="Delete app" title="Delete app"
okText="Delete" okText="Delete"
onOk={deleteApp} onOk={deleteApp}
onCancel={() => (deletionConfirmationAppName = null)} onCancel={() => (deletionConfirmationAppName = null)}
disabled={deletionConfirmationAppName !== $appStore.name} disabled={deletionConfirmationAppName !== appName || deleting}
> >
Are you sure you want to delete <b>{$appStore.name}</b>? Are you sure you want to delete
<span class="app-name" role="button" tabindex={-1} on:click={copyName}>
{appName}
</span>?
<br /> <br />
Please enter the app name below to confirm. Please enter the app name below to confirm.
<br /><br /> <br /><br />
<Input <Input bind:value={deletionConfirmationAppName} placeholder={appName} />
bind:value={deletionConfirmationAppName}
placeholder={$appStore.name}
/>
</ConfirmDialog> </ConfirmDialog>
<style>
.app-name {
cursor: pointer;
font-weight: bold;
display: inline-block;
}
</style>

View File

@ -12,6 +12,7 @@
export let bindings export let bindings
export let nested export let nested
export let componentInstance export let componentInstance
export let title = "Actions"
let drawer let drawer
let tmpValue let tmpValue
@ -37,7 +38,7 @@
<ActionButton on:click={openDrawer}>{actionText}</ActionButton> <ActionButton on:click={openDrawer}>{actionText}</ActionButton>
</div> </div>
<Drawer bind:this={drawer} title={"Actions"} on:drawerHide on:drawerShow> <Drawer bind:this={drawer} {title} on:drawerHide on:drawerShow>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Define what actions to run. Define what actions to run.
</svelte:fragment> </svelte:fragment>

View File

@ -31,7 +31,7 @@
<Label small>Row IDs</Label> <Label small>Row IDs</Label>
<DrawerBindableInput <DrawerBindableInput
{bindings} {bindings}
title="Rows to delete" title="Row IDs to delete"
value={parameters.rowId} value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)} on:change={value => (parameters.rowId = value.detail)}
/> />

View File

@ -29,7 +29,7 @@
<Label small>Row ID</Label> <Label small>Row ID</Label>
<DrawerBindableInput <DrawerBindableInput
{bindings} {bindings}
title="Row ID to Fetch" title="Row ID"
value={parameters.rowId} value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)} on:change={value => (parameters.rowId = value.detail)}
/> />

View File

@ -62,7 +62,7 @@
{/if} {/if}
<Label small>{valueLabel}</Label> <Label small>{valueLabel}</Label>
<DrawerBindableInput <DrawerBindableInput
title={`Value for "${field[0]}"`} title={field[0]}
value={field[1]} value={field[1]}
{bindings} {bindings}
on:change={event => updateFieldValue(idx, event.detail)} on:change={event => updateFieldValue(idx, event.detail)}

View File

@ -40,6 +40,7 @@
<Select bind:value={parameters.type} options={types} placeholder={null} /> <Select bind:value={parameters.type} options={types} placeholder={null} />
<Label>Message</Label> <Label>Message</Label>
<DrawerBindableInput <DrawerBindableInput
title="Message"
{bindings} {bindings}
value={parameters.message} value={parameters.message}
on:change={e => (parameters.message = e.detail)} on:change={e => (parameters.message = e.detail)}

View File

@ -72,6 +72,7 @@
{#if parameters.type === "set"} {#if parameters.type === "set"}
<Label small>Value</Label> <Label small>Value</Label>
<DrawerBindableInput <DrawerBindableInput
title="Field value"
{bindings} {bindings}
value={parameters.value} value={parameters.value}
on:change={e => (parameters.value = e.detail)} on:change={e => (parameters.value = e.detail)}

View File

@ -38,6 +38,7 @@
{#if parameters.type === "set"} {#if parameters.type === "set"}
<Label small>Value</Label> <Label small>Value</Label>
<DrawerBindableInput <DrawerBindableInput
title="State value"
{bindings} {bindings}
value={parameters.value} value={parameters.value}
on:change={e => (parameters.value = e.detail)} on:change={e => (parameters.value = e.detail)}

View File

@ -14,6 +14,7 @@
export let key export let key
export let nested export let nested
export let max export let max
export let context
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -28,6 +28,7 @@
placeholder="Default" placeholder="Default"
/> />
<DrawerBindableInput <DrawerBindableInput
title="Value"
label="Value" label="Value"
value={column.template} value={column.template}
on:change={e => (column.template = e.detail)} on:change={e => (column.template = e.detail)}

View File

@ -25,7 +25,7 @@
</script> </script>
<Icon name="Settings" hoverable size="S" on:click={open} /> <Icon name="Settings" hoverable size="S" on:click={open} />
<Drawer bind:this={drawer} title="Table Columns"> <Drawer bind:this={drawer} title={column.name}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
"{column.name}" column settings "{column.name}" column settings
</svelte:fragment> </svelte:fragment>

View File

@ -48,7 +48,6 @@
let drawer let drawer
let tmpQueryParams let tmpQueryParams
let tmpCustomData let tmpCustomData
let customDataValid = true
let modal let modal
$: text = value?.label ?? "Choose an option" $: text = value?.label ?? "Choose an option"
@ -267,14 +266,11 @@
<Drawer title="Custom data" bind:this={drawer}> <Drawer title="Custom data" bind:this={drawer}>
<div slot="buttons" style="display:contents"> <div slot="buttons" style="display:contents">
<Button primary on:click={promptForCSV}>Load CSV</Button> <Button primary on:click={promptForCSV}>Load CSV</Button>
<Button cta on:click={saveCustomData} disabled={!customDataValid}> <Button cta on:click={saveCustomData}>Save</Button>
Save
</Button>
</div> </div>
<div slot="description">Provide a JSON array to use as data</div> <div slot="description">Provide a JSON array to use as data</div>
<ClientBindingPanel <ClientBindingPanel
slot="body" slot="body"
bind:valid={customDataValid}
value={tmpCustomData} value={tmpCustomData}
on:change={event => (tmpCustomData = event.detail)} on:change={event => (tmpCustomData = event.detail)}
{bindings} {bindings}

View File

@ -26,7 +26,6 @@
export let bindings = [] export let bindings = []
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let allowBindings = true export let allowBindings = true
export let fillWidth = false
export let datasource export let datasource
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -260,13 +259,12 @@
{#if filter.field && filter.valueType === "Binding"} {#if filter.field && filter.valueType === "Binding"}
<DrawerBindableInput <DrawerBindableInput
disabled={filter.noValue} disabled={filter.noValue}
title={`Value for "${filter.field}"`} title={filter.field}
value={filter.value} value={filter.value}
placeholder="Value" placeholder="Value"
{panel} {panel}
{bindings} {bindings}
on:change={event => (filter.value = event.detail)} on:change={event => (filter.value = event.detail)}
{fillWidth}
/> />
{:else if ["string", "longform", "number", "bigint", "formula"].includes(filter.type)} {:else if ["string", "longform", "number", "bigint", "formula"].includes(filter.type)}
<Input disabled={filter.noValue} bind:value={filter.value} /> <Input disabled={filter.noValue} bind:value={filter.value} />

View File

@ -105,6 +105,7 @@
onChange={handleChange} onChange={handleChange}
bindings={allBindings} bindings={allBindings}
name={key} name={key}
title={label}
{nested} {nested}
{key} {key}
{type} {type}

View File

@ -143,7 +143,6 @@
value={field.value} value={field.value}
allowJS={false} allowJS={false}
{allowHelpers} {allowHelpers}
fillWidth={true}
drawerLeft={bindingDrawerLeft} drawerLeft={bindingDrawerLeft}
/> />
{:else} {:else}

View File

@ -31,17 +31,11 @@
: null} : null}
> >
<Body> <Body>
You are currently on our <span class="free-plan">Free plan</span>. Upgrade You have exceeded the app limit for your current plan. Upgrade to get
to our Pro plan to get unlimited apps and additional features. unlimited apps and additional features!
</Body> </Body>
{#if !$auth.user.accountPortalAccess} {#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body> <Body>Please contact the account holder to upgrade.</Body>
{/if} {/if}
</ModalContent> </ModalContent>
</Modal> </Modal>
<style>
.free-plan {
font-weight: 600;
}
</style>

View File

@ -5,6 +5,7 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import AppRowContext from "./AppRowContext.svelte"
export let app export let app
export let lockedAction export let lockedAction
@ -76,12 +77,10 @@
{#if isBuilder} {#if isBuilder}
<div class="app-row-actions"> <div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToOverview}> <Button size="S" secondary on:click={lockedAction || goToBuilder}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
Edit Edit
</Button> </Button>
<AppRowContext {app} />
</div> </div>
{:else if app.deployed} {:else if app.deployed}
<!-- this can happen if an app builder has app user access to an app --> <!-- this can happen if an app builder has app user access to an app -->

View File

@ -0,0 +1,88 @@
<script>
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import DeleteModal from "components/deploy/DeleteModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import ExportAppModal from "./ExportAppModal.svelte"
import DuplicateAppModal from "./DuplicateAppModal.svelte"
import { licensing } from "stores/portal"
export let app
export let align = "right"
let deleteModal
let exportModal
let duplicateModal
let exportPublishedVersion = false
let appLimitModal
</script>
<DeleteModal
bind:this={deleteModal}
appId={app.devId}
appName={app.name}
onDeleteSuccess={async () => {
await licensing.init()
}}
/>
<AppLimitModal bind:this={appLimitModal} />
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={duplicateModal} padding={false}>
<DuplicateAppModal
appId={app.devId}
appName={app.name}
onDuplicateSuccess={async () => {
await licensing.init()
}}
/>
</Modal>
<ActionMenu {align} on:open on:close>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
<MenuItem
icon="Copy"
on:click={() => {
if ($licensing?.usageMetrics?.apps < 100) {
duplicateModal.show()
} else {
appLimitModal.show()
}
}}
>
Duplicate
</MenuItem>
<MenuItem
icon="Export"
on:click={() => {
exportPublishedVersion = false
exportModal.show()
}}
>
Export latest edited app
</MenuItem>
{#if app.deployed}
<MenuItem
icon="Export"
on:click={() => {
exportPublishedVersion = true
exportModal.show()
}}
>
Export latest published app
</MenuItem>
{/if}
<MenuItem
icon="Delete"
on:click={() => {
deleteModal.show()
}}
>
Delete
</MenuItem>
</ActionMenu>

View File

@ -0,0 +1,158 @@
<script>
import {
ModalContent,
Input,
notifications,
Layout,
keepOpen,
} from "@budibase/bbui"
import { createValidationStore } from "helpers/validation/yup"
import { writable, get } from "svelte/store"
import * as appValidation from "helpers/validation/yup/app"
import { apps } from "stores/portal"
import { onMount } from "svelte"
import { API } from "api"
export let appId
export let appName
export let onDuplicateSuccess = () => {}
const validation = createValidationStore()
const values = writable({ name: appName + " copy", url: null })
const appPrefix = "/app"
let defaultAppName = appName + " copy"
let duplicating = false
$: {
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
const resolveAppName = name => {
return name ? name.trim() : null
}
const resolveAppUrl = name => {
let parsedName
const resolvedName = resolveAppName(name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(appName)
tidyUrl(resolvedUrl)
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const duplicateApp = async () => {
duplicating = true
let data = new FormData()
data.append("name", $values.name.trim())
if ($values.url) {
data.append("url", $values.url.trim())
}
try {
await API.duplicateApp(data, appId)
apps.load()
onDuplicateSuccess()
notifications.success("App duplicated successfully")
} catch (err) {
notifications.error("Error duplicating app")
duplicating = false
}
}
const setupValidation = async () => {
const applications = get(apps)
appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications })
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
$: appUrl = `${window.location.origin}${
$values.url
? `${appPrefix}${$values.url}`
: `${appPrefix}${resolveAppUrl($values.name)}`
}`
onMount(async () => {
nameToUrl($values.name)
await setupValidation()
})
</script>
<ModalContent
title={"Duplicate App"}
onConfirm={async () => {
validation.check({
...$values,
})
if ($validation.valid) {
await duplicateApp()
} else {
return keepOpen
}
}}
>
<Layout gap="S" noPadding>
<Input
autofocus={true}
bind:value={$values.name}
disabled={duplicating}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
placeholder={defaultAppName}
/>
<span>
<Input
bind:value={$values.url}
disabled={duplicating}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.url
? $values.url
: `/${resolveAppUrl($values.name)}`}
/>
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if}
</span>
</Layout>
</ModalContent>
<style>
.app-server {
color: var(--spectrum-global-color-gray-600);
margin-top: 10px;
width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -121,6 +121,7 @@
<Input <Input
type="password" type="password"
label="Password" label="Password"
autocomplete="new-password"
placeholder="Type here..." placeholder="Type here..."
bind:value={password} bind:value={password}
error={$validation.errors.password} error={$validation.errors.password}

View File

@ -48,3 +48,53 @@ export const duplicateName = (name, allNames) => {
return `${baseName} ${number}` return `${baseName} ${number}`
} }
/**
* More flexible alternative to the above function, which handles getting the
* next sequential name from an array of existing items while accounting for
* any type of prefix, and being able to deeply retrieve that name from the
* existing item array.
*
* Examples with a prefix of "foo":
* [] => "foo"
* ["foo"] => "foo2"
* ["foo", "foo6"] => "foo7"
*
* Examples with a prefix of "foo " (space at the end):
* [] => "foo"
* ["foo"] => "foo 2"
* ["foo", "foo 6"] => "foo 7"
*
* @param items the array of existing items
* @param prefix the string prefix of each name, including any spaces desired
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = (items, prefix, getName = x => x) => {
if (!prefix?.length || !getName) {
return null
}
const trimmedPrefix = prefix.trim()
if (!items?.length) {
return trimmedPrefix
}
let max = 0
items.forEach(item => {
const name = getName(item)
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}
const split = name.split(trimmedPrefix)
if (split.length !== 2) {
return
}
if (split[1].trim() === "") {
split[1] = "1"
}
const num = parseInt(split[1])
if (num > max) {
max = num
}
})
return max === 0 ? trimmedPrefix : `${prefix}${max + 1}`
}

View File

@ -1,5 +1,5 @@
import { expect, describe, it } from "vitest" import { expect, describe, it } from "vitest"
import { duplicateName } from "../duplicate" import { duplicateName, getSequentialName } from "../duplicate"
describe("duplicate", () => { describe("duplicate", () => {
describe("duplicates a name ", () => { describe("duplicates a name ", () => {
@ -40,3 +40,64 @@ describe("duplicate", () => {
}) })
}) })
}) })
describe("getSequentialName", () => {
it("handles nullish items", async () => {
const name = getSequentialName(null, "foo", () => {})
expect(name).toBe("foo")
})
it("handles nullish prefix", async () => {
const name = getSequentialName([], null, () => {})
expect(name).toBe(null)
})
it("handles nullish getName function", async () => {
const name = getSequentialName([], "foo", null)
expect(name).toBe(null)
})
it("handles just the prefix", async () => {
const name = getSequentialName(["foo"], "foo", x => x)
expect(name).toBe("foo2")
})
it("handles continuous ranges", async () => {
const name = getSequentialName(["foo", "foo2", "foo3"], "foo", x => x)
expect(name).toBe("foo4")
})
it("handles discontinuous ranges", async () => {
const name = getSequentialName(["foo", "foo3"], "foo", x => x)
expect(name).toBe("foo4")
})
it("handles a space inside the prefix", async () => {
const name = getSequentialName(["foo", "foo 2", "foo 3"], "foo ", x => x)
expect(name).toBe("foo 4")
})
it("handles a space inside the prefix with just the prefix", async () => {
const name = getSequentialName(["foo"], "foo ", x => x)
expect(name).toBe("foo 2")
})
it("handles no matches", async () => {
const name = getSequentialName(["aaa", "bbb"], "foo", x => x)
expect(name).toBe("foo")
})
it("handles similar names", async () => {
const name = getSequentialName(
["fooo1", "2foo", "a3foo4", "5foo5"],
"foo",
x => x
)
expect(name).toBe("foo")
})
it("handles non-string names", async () => {
const name = getSequentialName([null, 4123, [], {}], "foo", x => x)
expect(name).toBe("foo")
})
})

View File

@ -188,7 +188,7 @@
{/if} {/if}
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal}> <Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette /> <CommandPalette />
</Modal> </Modal>

View File

@ -40,7 +40,7 @@
<!-- routify:options index=3 --> <!-- routify:options index=3 -->
<div class="root"> <div class="root">
<AutomationPanel {modal} {webhookModal} /> <AutomationPanel {modal} {webhookModal} />
<div class="content"> <div class="content drawer-container">
{#if $automationStore.automations?.length} {#if $automationStore.automations?.length}
<slot /> <slot />
{:else} {:else}

View File

@ -6,6 +6,7 @@
</script> </script>
<div class="app-panel"> <div class="app-panel">
<div class="drawer-container" />
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<UndoRedoControl store={screenStore.history} /> <UndoRedoControl store={screenStore.history} />
@ -32,7 +33,17 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
padding: 9px var(--spacing-m); padding: 9px 10px 12px 10px;
position: relative;
transition: width 360ms ease-out;
}
.drawer-container {
position: absolute;
height: 100%;
width: 100%;
overflow: hidden;
top: 0;
left: 0;
} }
.header { .header {
display: flex; display: flex;

View File

@ -10,6 +10,7 @@
navigationStore, navigationStore,
selectedScreen, selectedScreen,
hoverStore, hoverStore,
snippets,
} from "stores/builder" } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
@ -68,6 +69,7 @@
hostname: window.location.hostname, hostname: window.location.hostname,
port: window.location.port, port: window.location.port,
}, },
snippets: $snippets,
} }
// Refresh the preview when required // Refresh the preview when required
@ -196,6 +198,16 @@
} else if (type === "add-parent-component") { } else if (type === "add-parent-component") {
const { componentId, parentType } = data const { componentId, parentType } = data
await componentStore.addParent(componentId, parentType) await componentStore.addParent(componentId, parentType)
} else if (type === "provide-context") {
let context = data?.context
if (context) {
try {
context = JSON.parse(context)
} catch (error) {
context = null
}
}
previewStore.setSelectedComponentContext(context)
} else { } else {
console.warn(`Client sent unknown event type: ${type}`) console.warn(`Client sent unknown event type: ${type}`)
} }

View File

@ -3,7 +3,7 @@
import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui" import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui"
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "stores/builder" import { isOnlyUser, appStore } from "stores/builder"
let deleteModal let deleteModal
</script> </script>
@ -67,7 +67,11 @@
</Page> </Page>
</div> </div>
<DeleteModal bind:this={deleteModal} /> <DeleteModal
bind:this={deleteModal}
appId={$appStore.appId}
appName={$appStore.name}
/>
<style> <style>
.delete-action :global(.text) { .delete-action :global(.text) {

View File

@ -1,10 +1,14 @@
<script> <script>
import { apps, sideBarCollapsed } from "stores/portal" import { apps, sideBarCollapsed, auth } from "stores/portal"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import NavHeader from "components/common/NavHeader.svelte" import NavHeader from "components/common/NavHeader.svelte"
import AppRowContext from "components/start/AppRowContext.svelte"
import { AppStatus } from "constants"
import { sdk } from "@budibase/shared-core"
let searchString let searchString
let opened
$: filteredApps = $apps $: filteredApps = $apps
.filter(app => { .filter(app => {
@ -13,6 +17,12 @@
app.name.toLowerCase().includes(searchString.toLowerCase()) app.name.toLowerCase().includes(searchString.toLowerCase())
) )
}) })
.map(app => {
return {
...app,
deployed: app.status === AppStatus.DEPLOYED,
}
})
.sort((a, b) => { .sort((a, b) => {
const lowerA = a.name.toLowerCase() const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase() const lowerB = b.name.toLowerCase()
@ -42,8 +52,22 @@
icon={app.icon?.name || "Apps"} icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color} iconColor={app.icon?.color}
selected={$params.appId === app.appId} selected={$params.appId === app.appId}
highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)} on:click={() => $goto(`./${app.appId}`)}
>
{#if sdk.users.isBuilder($auth.user, app?.devId)}
<AppRowContext
{app}
align="left"
on:open={() => {
opened = app.appId
}}
on:close={() => {
opened = null
}}
/> />
{/if}
</NavItem>
{/each} {/each}
</div> </div>
</div> </div>

View File

@ -18,6 +18,7 @@ import {
} from "./automations.js" } from "./automations.js"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js" import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments.js"
import { snippets } from "./snippets"
// Backend // Backend
import { tables } from "./tables" import { tables } from "./tables"
@ -62,6 +63,7 @@ export {
queries, queries,
flags, flags,
hoverStore, hoverStore,
snippets,
} }
export const reset = () => { export const reset = () => {
@ -101,6 +103,7 @@ export const initialise = async pkg => {
builderStore.init(application) builderStore.init(application)
navigationStore.syncAppNavigation(application?.navigation) navigationStore.syncAppNavigation(application?.navigation)
themeStore.syncAppTheme(application) themeStore.syncAppTheme(application)
snippets.syncMetadata(application)
screenStore.syncAppScreens(pkg) screenStore.syncAppScreens(pkg)
layoutStore.syncAppLayouts(pkg) layoutStore.syncAppLayouts(pkg)
resetBuilderHistory() resetBuilderHistory()

View File

@ -4,6 +4,7 @@ const INITIAL_PREVIEW_STATE = {
previewDevice: "desktop", previewDevice: "desktop",
previewEventHandler: null, previewEventHandler: null,
showPreview: false, showPreview: false,
selectedComponentContext: null,
} }
export const createPreviewStore = () => { export const createPreviewStore = () => {
@ -52,6 +53,17 @@ export const createPreviewStore = () => {
}) })
} }
const setSelectedComponentContext = context => {
store.update(state => {
state.selectedComponentContext = context
return state
})
}
const requestComponentContext = () => {
sendEvent("request-context")
}
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
setDevice, setDevice,
@ -60,6 +72,8 @@ export const createPreviewStore = () => {
startDrag, startDrag,
stopDrag, stopDrag,
showPreview, showPreview,
setSelectedComponentContext,
requestComponentContext,
} }
} }

View File

@ -0,0 +1,41 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { appStore } from "./app"
const createsnippets = () => {
const store = writable([])
const syncMetadata = metadata => {
store.set(metadata?.snippets || [])
}
const saveSnippet = async updatedSnippet => {
const snippets = [
...get(store).filter(snippet => snippet.name !== updatedSnippet.name),
updatedSnippet,
]
const app = await API.saveAppMetadata({
appId: get(appStore).appId,
metadata: { snippets },
})
syncMetadata(app)
}
const deleteSnippet = async snippetName => {
const snippets = get(store).filter(snippet => snippet.name !== snippetName)
const app = await API.saveAppMetadata({
appId: get(appStore).appId,
metadata: { snippets },
})
syncMetadata(app)
}
return {
...store,
syncMetadata,
saveSnippet,
deleteSnippet,
}
}
export const snippets = createsnippets()

View File

@ -6,6 +6,7 @@ import {
themeStore, themeStore,
navigationStore, navigationStore,
deploymentStore, deploymentStore,
snippets,
datasources, datasources,
tables, tables,
} from "stores/builder" } from "stores/builder"
@ -64,6 +65,7 @@ export const createBuilderWebsocket = appId => {
appStore.syncMetadata(metadata) appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata) themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata) navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
}) })
socket.onOther( socket.onOther(
BuilderSocketEvent.AppPublishChange, BuilderSocketEvent.AppPublishChange,

View File

@ -39,6 +39,7 @@
import FreeFooter from "components/FreeFooter.svelte" import FreeFooter from "components/FreeFooter.svelte"
import MaintenanceScreen from "components/MaintenanceScreen.svelte" import MaintenanceScreen from "components/MaintenanceScreen.svelte"
import licensing from "../licensing" import licensing from "../licensing"
import SnippetsProvider from "./context/SnippetsProvider.svelte"
// Provide contexts // Provide contexts
setContext("sdk", SDK) setContext("sdk", SDK)
@ -121,6 +122,7 @@
<StateBindingsProvider> <StateBindingsProvider>
<RowSelectionProvider> <RowSelectionProvider>
<QueryParamsProvider> <QueryParamsProvider>
<SnippetsProvider>
<!-- Settings bar can be rendered outside of device preview --> <!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks --> <!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId} {#key $builderStore.selectedComponentId}
@ -229,6 +231,7 @@
<GridDNDHandler /> <GridDNDHandler />
{/if} {/if}
</div> </div>
</SnippetsProvider>
</QueryParamsProvider> </QueryParamsProvider>
</RowSelectionProvider> </RowSelectionProvider>
</StateBindingsProvider> </StateBindingsProvider>

View File

@ -565,7 +565,8 @@
// If we don't know, check and cache // If we don't know, check and cache
if (used == null) { if (used == null) {
used = bindingString.indexOf(`[${key}]`) !== -1 const searchString = key === "snippets" ? key : `[${key}]`
used = bindingString.indexOf(searchString) !== -1
knownContextKeyMap[key] = used knownContextKeyMap[key] = used
} }
@ -575,6 +576,15 @@
} }
} }
const getDataContext = () => {
const normalContext = get(context)
const additionalContext = ref?.getAdditionalDataContext?.()
return {
...normalContext,
...additionalContext,
}
}
onMount(() => { onMount(() => {
// Register this component instance for external access // Register this component instance for external access
if ($appStore.isDevApp) { if ($appStore.isDevApp) {
@ -583,7 +593,7 @@
component: instance._component, component: instance._component,
getSettings: () => cachedSettings, getSettings: () => cachedSettings,
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
getDataContext: () => get(context), getDataContext,
reload: () => initialise(instance, true), reload: () => initialise(instance, true),
setEphemeralStyles: styles => (ephemeralStyles = styles), setEphemeralStyles: styles => (ephemeralStyles = styles),
state: store, state: store,

View File

@ -30,6 +30,7 @@
ActionTypes, ActionTypes,
createContextStore, createContextStore,
Provider, Provider,
generateGoldenSample,
} = getContext("sdk") } = getContext("sdk")
let grid let grid
@ -48,6 +49,19 @@
}, },
] ]
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const rows = get(grid?.getContext()?.rows)
const goldenRow = generateGoldenSample(rows)
const id = get(component).id
return {
[id]: goldenRow,
eventContext: {
row: goldenRow,
},
}
}
// Parses columns to fix older formats // Parses columns to fix older formats
const getParsedColumns = columns => { const getParsedColumns = columns => {
// If the first element has an active key all elements should be in the new format // If the first element has an active key all elements should be in the new format

View File

@ -4,6 +4,7 @@
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js" import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { get } from "svelte/store"
export let title export let title
export let dataSource export let dataSource
@ -31,7 +32,9 @@
export let linkColumn export let linkColumn
export let noRowsMessage export let noRowsMessage
const { fetchDatasourceSchema } = getContext("sdk") const context = getContext("context")
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
const component = getContext("component")
let formId let formId
let dataProviderId let dataProviderId
@ -62,6 +65,16 @@
}, },
] ]
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const rows = get(context)[dataProviderId]?.rows || []
const goldenRow = generateGoldenSample(rows)
const id = get(component).id
return {
[`${id}-repeater`]: goldenRow,
}
}
// Builds a full details page URL for the card title // Builds a full details page URL for the card title
const buildFullCardUrl = (link, url, repeaterId, linkColumn) => { const buildFullCardUrl = (link, url, repeaterId, linkColumn) => {
if (!link || !url || !repeaterId) { if (!link || !url || !repeaterId) {

View File

@ -5,7 +5,7 @@
import { builderStore } from "stores" import { builderStore } from "stores"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import FormBlockWrapper from "./form/FormBlockWrapper.svelte" import FormBlockWrapper from "./form/FormBlockWrapper.svelte"
import { writable } from "svelte/store" import { get, writable } from "svelte/store"
import FormBlockComponent from "./FormBlockComponent.svelte" import FormBlockComponent from "./FormBlockComponent.svelte"
export let actionType export let actionType
@ -16,7 +16,7 @@
export let buttonPosition = "bottom" export let buttonPosition = "bottom"
export let size export let size
const { fetchDatasourceSchema } = getContext("sdk") const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
const context = getContext("context") const context = getContext("context")
@ -30,6 +30,16 @@
$: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep) $: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep)
$: updateCurrentStep(enrichedSteps, $builderStore, $component) $: updateCurrentStep(enrichedSteps, $builderStore, $component)
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const id = get(component).id
const rows = get(context)[`${id}-provider`]?.rows || []
const goldenRow = generateGoldenSample(rows)
return {
[`${id}-repeater`]: goldenRow,
}
}
const updateCurrentStep = (steps, builderStore, component) => { const updateCurrentStep = (steps, builderStore, component) => {
const { componentId, step } = builderStore.metadata || {} const { componentId, step } = builderStore.metadata || {}

View File

@ -4,6 +4,7 @@
import Placeholder from "components/app/Placeholder.svelte" import Placeholder from "components/app/Placeholder.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { get } from "svelte/store"
export let dataSource export let dataSource
export let filter export let filter
@ -18,8 +19,20 @@
export let gap export let gap
const component = getContext("component") const component = getContext("component")
const context = getContext("context")
const { generateGoldenSample } = getContext("sdk")
let providerId let providerId
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const rows = get(context)[providerId]?.rows || []
const goldenRow = generateGoldenSample(rows)
const id = get(component).id
return {
[`${id}-repeater`]: goldenRow,
}
}
</script> </script>
<Block> <Block>

View File

@ -3,25 +3,35 @@
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { generate } from "shortid" import { generate } from "shortid"
import { get } from "svelte/store"
import { getContext } from "svelte"
export let dataSource export let dataSource
export let height export let height
export let cardTitle export let cardTitle
export let cardSubtitle export let cardSubtitle
export let cardDescription export let cardDescription
export let cardImageURL export let cardImageURL
export let cardSearchField export let cardSearchField
export let detailFields export let detailFields
export let detailTitle export let detailTitle
export let noRowsMessage export let noRowsMessage
const stateKey = generate() const stateKey = generate()
const context = getContext("context")
const { generateGoldenSample } = getContext("sdk")
let listDataProviderId let listDataProviderId
let listRepeaterId let listRepeaterId
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const rows = get(context)[listDataProviderId]?.rows || []
const goldenRow = generateGoldenSample(rows)
return {
[listRepeaterId]: goldenRow,
}
}
</script> </script>
<Block> <Block>

View File

@ -3,6 +3,7 @@
import InnerFormBlock from "./InnerFormBlock.svelte" import InnerFormBlock from "./InnerFormBlock.svelte"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import FormBlockWrapper from "./FormBlockWrapper.svelte" import FormBlockWrapper from "./FormBlockWrapper.svelte"
import { get } from "svelte/store"
export let actionType export let actionType
export let dataSource export let dataSource
@ -11,7 +12,6 @@
export let fields export let fields
export let buttons export let buttons
export let buttonPosition export let buttonPosition
export let title export let title
export let description export let description
export let rowId export let rowId
@ -25,8 +25,56 @@
export let saveButtonLabel export let saveButtonLabel
export let deleteButtonLabel export let deleteButtonLabel
const { fetchDatasourceSchema } = getContext("sdk") const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
const context = getContext("context")
let schema
$: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: fetchSchema(dataSource)
// We could simply spread $$props into the inner form and append our
// additions, but that would create svelte warnings about unused props and
// make maintenance in future more confusing as we typically always have a
// proper mapping of schema settings to component exports, without having to
// search multiple files
$: innerProps = {
dataSource,
actionUrl,
actionType,
size,
disabled,
fields: fieldsOrDefault,
title,
description,
schema,
notificationOverride,
buttons:
buttons ||
Utils.buildFormBlockButtonConfig({
_id: $component.id,
showDeleteButton,
showSaveButton,
saveButtonLabel,
deleteButtonLabel,
notificationOverride,
actionType,
actionUrl,
dataSource,
}),
buttonPosition: buttons ? buttonPosition : "top",
}
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
const id = get(component).id
const rows = get(context)[`${id}-provider`]?.rows || []
const goldenRow = generateGoldenSample(rows)
return {
[`${id}-repeater`]: goldenRow,
}
}
const convertOldFieldFormat = fields => { const convertOldFieldFormat = fields => {
if (!fields) { if (!fields) {
@ -68,42 +116,6 @@
return [...fields, ...defaultFields].filter(field => field.active) return [...fields, ...defaultFields].filter(field => field.active)
} }
let schema
$: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: fetchSchema(dataSource)
// We could simply spread $$props into the inner form and append our
// additions, but that would create svelte warnings about unused props and
// make maintenance in future more confusing as we typically always have a
// proper mapping of schema settings to component exports, without having to
// search multiple files
$: innerProps = {
dataSource,
actionUrl,
actionType,
size,
disabled,
fields: fieldsOrDefault,
title,
description,
schema,
notificationOverride,
buttons:
buttons ||
Utils.buildFormBlockButtonConfig({
_id: $component.id,
showDeleteButton,
showSaveButton,
saveButtonLabel,
deleteButtonLabel,
notificationOverride,
actionType,
actionUrl,
dataSource,
}),
buttonPosition: buttons ? buttonPosition : "top",
}
const fetchSchema = async () => { const fetchSchema = async () => {
schema = (await fetchDatasourceSchema(dataSource)) || {} schema = (await fetchDatasourceSchema(dataSource)) || {}
} }

View File

@ -58,13 +58,13 @@
} }
let clonedSchema = {} let clonedSchema = {}
if (!allowedFields?.length) { if (!allowedFields?.length) {
clonedSchema = schema clonedSchema = schema || {}
} else { } else {
allowedFields?.forEach(field => { allowedFields?.forEach(field => {
if (schema[field.name]) { if (schema?.[field.name]) {
clonedSchema[field.name] = schema[field.name] clonedSchema[field.name] = schema[field.name]
clonedSchema[field.name].displayName = field.displayName clonedSchema[field.name].displayName = field.displayName
} else if (schema[field]) { } else if (schema?.[field]) {
clonedSchema[field] = schema[field] clonedSchema[field] = schema[field]
} }
}) })

View File

@ -0,0 +1,8 @@
<script>
import Provider from "./Provider.svelte"
import { snippets } from "stores"
</script>
<Provider key="snippets" data={$snippets}>
<slot />
</Provider>

View File

@ -42,6 +42,7 @@ const loadBudibase = async () => {
hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"], hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"],
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"], usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
location: window["##BUDIBASE_LOCATION##"], location: window["##BUDIBASE_LOCATION##"],
snippets: window["##BUDIBASE_SNIPPETS##"],
}) })
// Set app ID - this window flag is set by both the preview and the real // Set app ID - this window flag is set by both the preview and the real
@ -84,6 +85,18 @@ const loadBudibase = async () => {
} else { } else {
dndStore.actions.reset() dndStore.actions.reset()
} }
} else if (type === "request-context") {
const { selectedComponentInstance } = get(componentStore)
const context = selectedComponentInstance?.getDataContext()
let stringifiedContext = null
try {
stringifiedContext = JSON.stringify(context)
} catch (error) {
// Ignore - invalid context
}
eventStore.actions.dispatchEvent("provide-context", {
context: stringifiedContext,
})
} else if (type === "hover-component") { } else if (type === "hover-component") {
hoverStore.actions.hoverComponent(data) hoverStore.actions.hoverComponent(data)
} else if (type === "builder-meta") { } else if (type === "builder-meta") {

View File

@ -29,7 +29,12 @@ import { fetchDatasourceSchema } from "./utils/schema.js"
import { getAPIKey } from "./utils/api.js" import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js" import { enrichButtonActions } from "./utils/buttonActions.js"
import { processStringSync, makePropSafe } from "@budibase/string-templates" import { processStringSync, makePropSafe } from "@budibase/string-templates"
import { fetchData, LuceneUtils, Constants } from "@budibase/frontend-core" import {
fetchData,
LuceneUtils,
Constants,
RowUtils,
} from "@budibase/frontend-core"
export default { export default {
API, API,
@ -65,6 +70,7 @@ export default {
processStringSync, processStringSync,
makePropSafe, makePropSafe,
createContextStore, createContextStore,
generateGoldenSample: RowUtils.generateGoldenSample,
// Components // Components
Provider, Provider,

View File

@ -18,6 +18,7 @@ const createBuilderStore = () => {
usedPlugins: null, usedPlugins: null,
eventResolvers: {}, eventResolvers: {},
metadata: null, metadata: null,
snippets: null,
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,

View File

@ -4,3 +4,4 @@
export { currentRole } from "./currentRole.js" export { currentRole } from "./currentRole.js"
export { dndComponentPath } from "./dndComponentPath.js" export { dndComponentPath } from "./dndComponentPath.js"
export { devToolsEnabled } from "./devToolsEnabled.js" export { devToolsEnabled } from "./devToolsEnabled.js"
export { snippets } from "./snippets.js"

View File

@ -0,0 +1,10 @@
import { appStore } from "../app.js"
import { builderStore } from "../builder.js"
import { derivedMemo } from "@budibase/frontend-core"
export const snippets = derivedMemo(
[appStore, builderStore],
([$appStore, $builderStore]) => {
return $builderStore?.snippets || $appStore?.application?.snippets || []
}
)

View File

@ -1,23 +1,5 @@
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { processString, processObjectSync } from "@budibase/string-templates" import { processObjectSync } from "@budibase/string-templates"
// Regex to test inputs with to see if they are likely candidates for template strings
const looksLikeTemplate = /{{.*}}/
/**
* Enriches a given input with a row from the database.
*/
export const enrichDataBinding = async (input, context) => {
// Only accept string inputs
if (!input || typeof input !== "string") {
return input
}
// Do a fast regex check if this looks like a template string
if (!looksLikeTemplate.test(input)) {
return input
}
return processString(input, context)
}
/** /**
* Recursively enriches all props in a props object and returns the new props. * Recursively enriches all props in a props object and returns the new props.

View File

@ -83,6 +83,18 @@ export const buildAppEndpoints = API => ({
}) })
}, },
/**
* Duplicate an existing app
* @param app the app to dupe
*/
duplicateApp: async (app, appId) => {
return await API.post({
url: `/api/applications/${appId}/duplicate`,
body: app,
json: false,
})
},
/** /**
* Update an application using an export - the body * Update an application using an export - the body
* should be of type FormData, with a "file" and a "password" if encrypted. * should be of type FormData, with a "file" and a "password" if encrypted.

View File

@ -317,7 +317,7 @@
align="right" align="right"
offset={0} offset={0}
popoverTarget={document.getElementById(`grid-${rand}`)} popoverTarget={document.getElementById(`grid-${rand}`)}
customZindex={100} customZindex={50}
> >
{#if editIsOpen} {#if editIsOpen}
<div <div

View File

@ -38,7 +38,7 @@
align={$visibleColumns.length ? "right" : "left"} align={$visibleColumns.length ? "right" : "left"}
offset={0} offset={0}
popoverTarget={document.getElementById(`add-column-button`)} popoverTarget={document.getElementById(`add-column-button`)}
customZindex={100} customZindex={50}
> >
<div <div
use:clickOutside={() => { use:clickOutside={() => {

View File

@ -3,6 +3,7 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies" export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles" export * as RoleUtils from "./roles"
export * as Utils from "./utils" export * as Utils from "./utils"
export * as RowUtils from "./rows"
export { memo, derivedMemo } from "./memo" export { memo, derivedMemo } from "./memo"
export { createWebsocket } from "./websocket" export { createWebsocket } from "./websocket"
export * from "./download" export * from "./download"

View File

@ -0,0 +1,48 @@
/**
* Util to check is a given value is "better" than another. "Betterness" is
* defined as presence and length.
*/
const isBetterSample = (newValue, oldValue) => {
// Prefer non-null values
if (oldValue == null && newValue != null) {
return true
}
// Don't change type
const oldType = typeof oldValue
const newType = typeof newValue
if (oldType !== newType) {
return false
}
// Prefer longer values
if (newType === "string" && newValue.length > oldValue.length) {
return true
}
if (
newType === "object" &&
Object.keys(newValue).length > Object.keys(oldValue).length
) {
return true
}
return false
}
/**
* Generates a best-case example object of the provided samples.
* The generated sample does not necessarily exist - it simply is a sample that
* contains "good" examples for every property of all the samples.
* The generate sample will have a value for all keys across all samples.
*/
export const generateGoldenSample = samples => {
let goldenSample = {}
samples?.slice(0, 100).forEach(sample => {
Object.keys(sample).forEach(key => {
if (isBetterSample(sample[key], goldenSample[key])) {
goldenSample[key] = sample[key]
}
})
})
return goldenSample
}

View File

@ -13,9 +13,10 @@
"build": "node ./scripts/build.js", "build": "node ./scripts/build.js",
"postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/", "postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/",
"check:types": "tsc -p tsconfig.json --noEmit --paths null", "check:types": "tsc -p tsconfig.json --noEmit --paths null",
"build:isolated-vm-lib:snippets": "esbuild --minify --bundle src/jsRunner/bundles/snippets.ts --outfile=src/jsRunner/bundles/snippets.ivm.bundle.js --platform=node --format=iife --global-name=snippets",
"build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers", "build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers",
"build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson", "build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson",
"build:isolated-vm-libs": "yarn build:isolated-vm-lib:string-templates && yarn build:isolated-vm-lib:bson", "build:isolated-vm-libs": "yarn build:isolated-vm-lib:string-templates && yarn build:isolated-vm-lib:bson && yarn build:isolated-vm-lib:snippets",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js",
"jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest", "jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest",

View File

@ -26,6 +26,7 @@ import {
env as envCore, env as envCore,
ErrorCode, ErrorCode,
events, events,
HTTPError,
migrations, migrations,
objectStore, objectStore,
roles, roles,
@ -50,6 +51,8 @@ import {
CreateAppRequest, CreateAppRequest,
FetchAppDefinitionResponse, FetchAppDefinitionResponse,
FetchAppPackageResponse, FetchAppPackageResponse,
DuplicateAppRequest,
DuplicateAppResponse,
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -122,7 +125,7 @@ interface AppTemplate {
templateString?: string templateString?: string
useTemplate?: string useTemplate?: string
file?: { file?: {
type: string type?: string
path: string path: string
password?: string password?: string
} }
@ -263,6 +266,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
...(ctx.request.files.templateFile as any), ...(ctx.request.files.templateFile as any),
password: encryptionPassword, password: encryptionPassword,
} }
} else if (typeof ctx.request.body.file?.path === "string") {
instanceConfig.file = {
path: ctx.request.body.file?.path,
}
} }
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
const appId = generateDevAppID(generateAppID(tenantId)) const appId = generateDevAppID(generateAppID(tenantId))
@ -372,12 +379,20 @@ async function creationEvents(request: any, app: App) {
else if (request.files?.templateFile) { else if (request.files?.templateFile) {
creationFns.push(a => events.app.fileImported(a)) creationFns.push(a => events.app.fileImported(a))
} }
// from server file path
else if (request.body.file) {
// explicitly pass in the newly created app id
creationFns.push(a => events.app.duplicated(a, app.appId))
}
// unknown // unknown
else { else {
console.error("Could not determine template creation event") console.error("Could not determine template creation event")
} }
} }
if (!request.duplicate) {
creationFns.push(a => events.app.created(a)) creationFns.push(a => events.app.created(a))
}
for (let fn of creationFns) { for (let fn of creationFns) {
await fn(app) await fn(app)
@ -391,8 +406,10 @@ async function appPostCreate(ctx: UserCtx, app: App) {
tenantId, tenantId,
appId: app.appId, appId: app.appId,
}) })
await creationEvents(ctx.request, app) await creationEvents(ctx.request, app)
// app import & template creation
// app import, template creation and duplication
if (ctx.request.body.useTemplate === "true") { if (ctx.request.body.useTemplate === "true") {
const { rows } = await getUniqueRows([app.appId]) const { rows } = await getUniqueRows([app.appId])
const rowCount = rows ? rows.length : 0 const rowCount = rows ? rows.length : 0
@ -421,7 +438,7 @@ async function appPostCreate(ctx: UserCtx, app: App) {
} }
} }
export async function create(ctx: UserCtx) { export async function create(ctx: UserCtx<CreateAppRequest, App>) {
const newApplication = await quotas.addApp(() => performAppCreate(ctx)) const newApplication = await quotas.addApp(() => performAppCreate(ctx))
await appPostCreate(ctx, newApplication) await appPostCreate(ctx, newApplication)
await cache.bustCache(cache.CacheKey.CHECKLIST) await cache.bustCache(cache.CacheKey.CHECKLIST)
@ -626,6 +643,66 @@ export async function importToApp(ctx: UserCtx) {
ctx.body = { message: "app updated" } ctx.body = { message: "app updated" }
} }
/**
* Create a copy of the latest dev application.
* Performs an export of the app, then imports from the export dir path
*/
export async function duplicateApp(
ctx: UserCtx<DuplicateAppRequest, DuplicateAppResponse>
) {
const { name: appName, url: possibleUrl } = ctx.request.body
const { appId: sourceAppId } = ctx.params
const [app] = await dbCore.getAppsByIDs([sourceAppId])
if (!app) {
ctx.throw(404, "Source app not found")
}
const apps = (await dbCore.getAllApps({ dev: true })) as App[]
checkAppName(ctx, apps, appName)
const url = sdk.applications.getAppUrl({ name: appName, url: possibleUrl })
checkAppUrl(ctx, apps, url)
const tmpPath = await sdk.backups.exportApp(sourceAppId, {
excludeRows: false,
tar: false,
})
const createRequestBody: CreateAppRequest = {
name: appName,
url: possibleUrl,
useTemplate: "true",
// The app export path
file: {
path: tmpPath,
},
}
// Build a new request
const createRequest = {
roleId: ctx.roleId,
user: ctx.user,
request: {
body: createRequestBody,
},
} as UserCtx<CreateAppRequest, App>
// Build the new application
await create(createRequest)
const { body: newApplication } = createRequest
if (!newApplication) {
ctx.throw(500, "There was a problem duplicating the application")
}
ctx.body = {
duplicateAppId: newApplication?.appId,
sourceAppId,
}
ctx.status = 200
}
export async function updateAppPackage( export async function updateAppPackage(
appPackage: Partial<App>, appPackage: Partial<App>,
appId: string appId: string

View File

@ -437,11 +437,11 @@ export class ExternalRequest<T extends Operation> {
return { row: newRow, manyRelationships } return { row: newRow, manyRelationships }
} }
processRelationshipFields( async processRelationshipFields(
table: Table, table: Table,
row: Row, row: Row,
relationships: RelationshipsJson[] relationships: RelationshipsJson[]
): Row { ): Promise<Row> {
for (let relationship of relationships) { for (let relationship of relationships) {
const linkedTable = this.tables[relationship.tableName] const linkedTable = this.tables[relationship.tableName]
if (!linkedTable || !row[relationship.column]) { if (!linkedTable || !row[relationship.column]) {
@ -457,7 +457,7 @@ export class ExternalRequest<T extends Operation> {
} }
// process additional types // process additional types
relatedRow = processDates(table, relatedRow) relatedRow = processDates(table, relatedRow)
relatedRow = processFormulas(linkedTable, relatedRow) relatedRow = await processFormulas(linkedTable, relatedRow)
row[relationship.column][key] = relatedRow row[relationship.column][key] = relatedRow
} }
} }
@ -521,7 +521,7 @@ export class ExternalRequest<T extends Operation> {
return rows return rows
} }
outputProcessing( async outputProcessing(
rows: Row[] = [], rows: Row[] = [],
table: Table, table: Table,
relationships: RelationshipsJson[] relationships: RelationshipsJson[]
@ -561,9 +561,12 @@ export class ExternalRequest<T extends Operation> {
} }
// make sure all related rows are correct // make sure all related rows are correct
let finalRowArray = Object.values(finalRows).map(row => let finalRowArray = []
this.processRelationshipFields(table, row, relationships) for (let row of Object.values(finalRows)) {
finalRowArray.push(
await this.processRelationshipFields(table, row, relationships)
) )
}
// process some additional types // process some additional types
finalRowArray = processDates(table, finalRowArray) finalRowArray = processDates(table, finalRowArray)
@ -934,7 +937,11 @@ export class ExternalRequest<T extends Operation> {
processed.manyRelationships processed.manyRelationships
) )
} }
const output = this.outputProcessing(responseRows, table, relationships) const output = await this.outputProcessing(
responseRows,
table,
relationships
)
// if reading it'll just be an array of rows, return whole thing // if reading it'll just be an array of rows, return whole thing
if (operation === Operation.READ) { if (operation === Operation.READ) {
return ( return (

View File

@ -110,7 +110,7 @@ export async function updateAllFormulasInTable(table: Table) {
(enriched: Row) => enriched._id === row._id (enriched: Row) => enriched._id === row._id
) )
if (enrichedRow) { if (enrichedRow) {
const processed = processFormulas(table, cloneDeep(row), { const processed = await processFormulas(table, cloneDeep(row), {
dynamic: false, dynamic: false,
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
@ -143,7 +143,7 @@ export async function finaliseRow(
squash: false, squash: false,
})) as Row })) as Row
// use enriched row to generate formulas for saving, specifically only use as context // use enriched row to generate formulas for saving, specifically only use as context
row = processFormulas(table, row, { row = await processFormulas(table, row, {
dynamic: false, dynamic: false,
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
@ -179,7 +179,7 @@ export async function finaliseRow(
const response = await db.put(row) const response = await db.put(row)
// for response, calculate the formulas for the enriched row // for response, calculate the formulas for the enriched row
enrichedRow._rev = response.rev enrichedRow._rev = response.rev
enrichedRow = processFormulas(table, enrichedRow, { enrichedRow = await processFormulas(table, enrichedRow, {
dynamic: false, dynamic: false,
}) })
// this updates the related formulas in other rows based on the relations to this row // this updates the related formulas in other rows based on the relations to this row

View File

@ -1,6 +1,6 @@
import { Ctx } from "@budibase/types" import { Ctx } from "@budibase/types"
import { IsolatedVM } from "../../jsRunner/vm" import { IsolatedVM } from "../../jsRunner/vm"
import { iifeWrapper } from "../../jsRunner/utilities" import { iifeWrapper } from "@budibase/string-templates"
export async function execute(ctx: Ctx) { export async function execute(ctx: Ctx) {
const { script, context } = ctx.request.body const { script, context } = ctx.request.body

View File

@ -72,7 +72,8 @@
navigation, navigation,
hiddenComponentIds, hiddenComponentIds,
usedPlugins, usedPlugins,
location location,
snippets
} = parsed } = parsed
// Set some flags so the app knows we're in the builder // Set some flags so the app knows we're in the builder
@ -89,6 +90,7 @@
window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"] = hiddenComponentIds
window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins window["##BUDIBASE_USED_PLUGINS##"] = usedPlugins
window["##BUDIBASE_LOCATION##"] = location window["##BUDIBASE_LOCATION##"] = location
window["##BUDIBASE_SNIPPETS##"] = snippets
// Initialise app // Initialise app
try { try {

View File

@ -55,9 +55,14 @@ router
) )
.delete( .delete(
"/api/applications/:appId", "/api/applications/:appId",
authorized(permissions.GLOBAL_BUILDER), authorized(permissions.BUILDER),
controller.destroy controller.destroy
) )
.post(
"/api/applications/:appId/duplicate",
authorized(permissions.BUILDER),
controller.duplicateApp
)
.post( .post(
"/api/applications/:appId/import", "/api/applications/:appId/import",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),

View File

@ -34,6 +34,96 @@ describe("/applications", () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
// These need to go first for the app totals to make sense
describe("permissions", () => {
it("should only return apps a user has access to", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
user = await config.globalUser({
...user,
builder: {
apps: [config.getProdAppId()],
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it("should only return apps a user has access to through a custom role", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const role = await config.api.roles.save({
name: "Test",
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
user = await config.globalUser({
...user,
roles: {
[config.getProdAppId()]: role.name,
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it("should only return apps a user has access to through a custom role on a group", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const roleName = uuid.v4().replace(/-/g, "")
const role = await config.api.roles.save({
name: roleName,
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
const group = await config.createGroup(role._id!)
user = await config.globalUser({
...user,
userGroups: [group._id!],
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
})
describe("create", () => { describe("create", () => {
it("creates empty app", async () => { it("creates empty app", async () => {
const app = await config.api.application.create({ name: utils.newid() }) const app = await config.api.application.create({ name: utils.newid() })
@ -94,6 +184,20 @@ describe("/applications", () => {
expect(events.app.created).toBeCalledTimes(1) expect(events.app.created).toBeCalledTimes(1)
expect(events.app.fileImported).toBeCalledTimes(1) expect(events.app.fileImported).toBeCalledTimes(1)
}) })
it("should reject with a known name", async () => {
await config.api.application.create(
{ name: app.name },
{ body: { message: "App name is already in use." }, status: 400 }
)
})
it("should reject with a known url", async () => {
await config.api.application.create(
{ name: "made up", url: app?.url! },
{ body: { message: "App URL is already in use." }, status: 400 }
)
})
}) })
describe("fetch", () => { describe("fetch", () => {
@ -229,6 +333,63 @@ describe("/applications", () => {
}) })
}) })
describe("POST /api/applications/:appId/duplicate", () => {
it("should duplicate an existing app", async () => {
const resp = await config.api.application.duplicateApp(
app.appId,
{
name: "to-dupe copy",
url: "/to-dupe-copy",
},
{
status: 200,
}
)
expect(events.app.duplicated).toBeCalled()
expect(resp.duplicateAppId).toBeDefined()
expect(resp.sourceAppId).toEqual(app.appId)
expect(resp.duplicateAppId).not.toEqual(app.appId)
})
it("should reject an unknown app id with a 404", async () => {
await config.api.application.duplicateApp(
app.appId.slice(0, -1) + "a",
{
name: "to-dupe 123",
url: "/to-dupe-123",
},
{
status: 404,
}
)
})
it("should reject with a known name", async () => {
const resp = await config.api.application.duplicateApp(
app.appId,
{
name: app.name,
url: "/known-name",
},
{ body: { message: "App name is already in use." }, status: 400 }
)
expect(events.app.duplicated).not.toBeCalled()
})
it("should reject with a known url", async () => {
const resp = await config.api.application.duplicateApp(
app.appId,
{
name: "this is fine",
url: app.url,
},
{ body: { message: "App URL is already in use." }, status: 400 }
)
expect(events.app.duplicated).not.toBeCalled()
})
})
describe("POST /api/applications/:appId/sync", () => { describe("POST /api/applications/:appId/sync", () => {
it("should not sync automation logs", async () => { it("should not sync automation logs", async () => {
const automation = await config.createAutomation() const automation = await config.createAutomation()
@ -249,93 +410,4 @@ describe("/applications", () => {
expect(devLogs.data.length).toBe(0) expect(devLogs.data.length).toBe(0)
}) })
}) })
describe("permissions", () => {
it("should only return apps a user has access to", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
user = await config.globalUser({
...user,
builder: {
apps: [config.getProdAppId()],
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it("should only return apps a user has access to through a custom role", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const role = await config.api.roles.save({
name: "Test",
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
user = await config.globalUser({
...user,
roles: {
[config.getProdAppId()]: role.name,
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it.only("should only return apps a user has access to through a custom role on a group", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const roleName = uuid.v4().replace(/-/g, "")
const role = await config.api.roles.save({
name: roleName,
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
const group = await config.createGroup(role._id!)
user = await config.globalUser({
...user,
userGroups: [group._id!],
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
})
}) })

View File

@ -2,6 +2,7 @@ import { auth, permissions } from "@budibase/backend-core"
import { DataSourceOperation } from "../../../constants" import { DataSourceOperation } from "../../../constants"
import { WebhookActionType } from "@budibase/types" import { WebhookActionType } from "@budibase/types"
import Joi from "joi" import Joi from "joi"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("") const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
const OPTIONAL_NUMBER = Joi.number().optional().allow(null) const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
@ -226,6 +227,21 @@ export function applicationValidator(opts = { isCreate: true }) {
base.name = appNameValidator.optional() base.name = appNameValidator.optional()
} }
const snippetValidator = Joi.array()
.optional()
.items(
Joi.object({
name: Joi.string()
.pattern(new RegExp(ValidSnippetNameRegex))
.error(
new Error(
"Snippet name cannot include spaces or special characters, and cannot start with a number"
)
),
code: OPTIONAL_STRING,
})
)
return auth.joiValidator.body( return auth.joiValidator.body(
Joi.object({ Joi.object({
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
@ -235,6 +251,7 @@ export function applicationValidator(opts = { isCreate: true }) {
template: Joi.object({ template: Joi.object({
templateString: OPTIONAL_STRING, templateString: OPTIONAL_STRING,
}).unknown(true), }).unknown(true),
snippets: snippetValidator,
}).unknown(true) }).unknown(true)
) )
} }

View File

@ -202,7 +202,8 @@ export async function attachFullLinkedDocs(
table => table._id === linkedTableId table => table._id === linkedTableId
) )
if (linkedTable) { if (linkedTable) {
row[link.fieldName].push(processFormulas(linkedTable, linkedRow)) const processed = await processFormulas(linkedTable, linkedRow)
row[link.fieldName].push(processed)
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More