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/backend-core/coverage
packages/server/client
packages/server/coverage
packages/builder/.routify
packages/sdk/sdk
packages/account-portal/packages/server/build

View File

@ -10,7 +10,7 @@ import {
StaticDatabases,
DEFAULT_TENANT_ID,
} from "../constants"
import { Database, IdentityContext } from "@budibase/types"
import { Database, IdentityContext, Snippet, App } from "@budibase/types"
import { ContextMap } from "./types"
let TEST_APP_ID: string | null = null
@ -122,10 +122,10 @@ export async function doInAutomationContext<T>(params: {
automationId: string
task: () => T
}): Promise<T> {
const tenantId = getTenantIDFromAppID(params.appId)
await ensureSnippetContext()
return newContext(
{
tenantId,
tenantId: getTenantIDFromAppID(params.appId),
appId: params.appId,
automationId: params.automationId,
},
@ -281,6 +281,27 @@ export function doInScimContext(task: any) {
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() {
const context = Context.get()
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
export type ContextMap = {
@ -11,4 +11,5 @@ export type ContextMap = {
isMigrating?: boolean
vm?: VM
cleanup?: (() => void | Promise<void>)[]
snippets?: Snippet[]
}

View File

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

View File

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

View File

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

View File

@ -32,6 +32,13 @@ const handleClick = event => {
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)
})
}

View File

@ -15,6 +15,7 @@ export default function positionDropdown(element, opts) {
align,
maxHeight,
maxWidth,
minWidth,
useAnchorWidth,
offset = 5,
customUpdate,
@ -28,7 +29,7 @@ export default function positionDropdown(element, opts) {
const elementBounds = element.getBoundingClientRect()
let styles = {
maxHeight: null,
minWidth: null,
minWidth,
maxWidth,
left: null,
top: null,
@ -41,8 +42,13 @@ export default function positionDropdown(element, opts) {
})
} else {
// Determine vertical styles
if (align === "right-outside") {
styles.top = anchorBounds.top
if (align === "right-outside" || align === "left-outside") {
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 (
window.innerHeight - anchorBounds.bottom <
(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>
import Portal from "svelte-portal"
import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte"
import { setContext, createEventDispatcher } from "svelte"
import Icon from "../Icon/Icon.svelte"
import ActionButton from "../ActionButton/ActionButton.svelte"
import Portal from "svelte-portal"
import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid"
export let title
export let fillWidth
export let left = "314px"
export let width = "calc(100% - 626px)"
export let headless = false
export let forceModal = false
const dispatch = createEventDispatcher()
const spacing = 11
let visible = false
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() {
if (visible) {
return
}
if (forceModal) {
modal.set(true)
resizable.set(false)
}
observe()
visible = true
dispatch("drawerShow", drawerId)
openDrawers.update(state => [...state, drawerId])
}
export function hide() {
@ -31,12 +114,15 @@
}
visible = false
dispatch("drawerHide", drawerId)
openDrawers.update(state => state.filter(id => id !== drawerId))
unobserve()
}
setContext("drawer-actions", {
setContext("drawer", {
hide,
show,
headless,
modal,
resizable,
})
const easeInOutQuad = x => {
@ -45,66 +131,142 @@
// Use a custom svelte transition here because the built-in slide
// transition has a horrible overshoot
const slide = () => {
const drawerSlide = () => {
return {
duration: 360,
duration: 260,
css: t => {
const translation = 100 - Math.round(easeInOutQuad(t) * 100)
return `transform: translateY(${translation}%);`
const f = easeInOutQuad(t)
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>
{#if visible}
<Portal>
<section
class:fillWidth
class="drawer"
class:headless
transition:slide|local
style={`width: ${width}; left: ${left};`}
>
{#if !headless}
<Portal target=".modal-container">
<!-- This class is unstyled, but needed by click_outside -->
<div class="drawer-wrapper">
<div
class="underlay"
class:hidden={!$modal}
transition:drawerFade|local
/>
<div
class="drawer"
class:stacked={depth > 0}
class:modal={$modal}
transition:drawerSlide|local
{style}
>
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
{#if $$slots.title}
<slot name="title" />
{:else}
<div class="text">{title || "Bindings"}</div>
{/if}
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<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>
</header>
{/if}
<slot name="body" />
</section>
<slot name="body" />
<div class="overlay" class:hidden={$modal || depth === 0} />
</div>
</div>
</Portal>
{/if}
<style>
.drawer.headless :global(.drawer-contents) {
height: calc(40vh + 75px);
}
.buttons {
display: flex;
gap: var(--spacing-m);
}
.drawer {
position: absolute;
bottom: 0;
left: 25vw;
width: 50vw;
bottom: var(--spacing);
height: 420px;
background: var(--background);
border-top: var(--border-light);
z-index: 3;
border: var(--border-light);
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 {
left: 260px !important;
width: calc(100% - 260px) !important;
.overlay,
.underlay {
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 {
@ -112,10 +274,9 @@
justify-content: space-between;
align-items: center;
border-bottom: var(--border-light);
padding: var(--spacing-l) var(--spacing-xl);
padding: var(--spacing-m) var(--spacing-xl);
gap: var(--spacing-xl);
}
.text {
display: flex;
flex-direction: column;
@ -123,7 +284,6 @@
align-items: flex-start;
gap: var(--spacing-xs);
}
.buttons {
display: flex;
flex-direction: row;
@ -131,4 +291,8 @@
align-items: center;
gap: var(--spacing-m);
}
.buttons :global(.icon) {
width: 16px;
display: flex;
}
</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">
{#if $$slots.sidebar}
<div class="sidebar">
@ -13,8 +17,8 @@
<style>
.drawer-contents {
height: 40vh;
overflow-y: auto;
flex: 1 1 auto;
}
.container {
height: 100%;
@ -27,14 +31,22 @@
.sidebar {
border-right: var(--border-light);
overflow: auto;
padding: var(--spacing-xl);
scrollbar-width: none;
}
.padding .sidebar {
padding: var(--spacing-xl);
}
.sidebar::-webkit-scrollbar {
display: none;
}
.main {
height: 100%;
overflow: auto;
overflow-x: hidden;
}
.padding .main {
padding: var(--spacing-xl);
height: calc(100% - var(--spacing-xl) * 2);
}
.main :global(textarea) {
min-height: 200px;

View File

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

View File

@ -10,6 +10,7 @@
export let inline = false
export let disableCancel = false
export let autoFocus = true
export let zIndex = 999
const dispatch = createEventDispatcher()
let visible = fixed || inline
@ -101,7 +102,11 @@
<Portal target=".modal-container">
{#if visible}
<!-- 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
class="background"
in:fade={{ duration: 200 }}
@ -132,7 +137,6 @@
flex-direction: row;
justify-content: center;
align-items: center;
z-index: 999;
overflow: auto;
overflow-x: hidden;
background: transparent;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,6 +36,8 @@
import { ValidColumnNameRegex } from "@budibase/shared-core"
import { FieldType, FieldSubtype, SourceName } from "@budibase/types"
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 FORMULA_TYPE = FIELDS.FORMULA.type
@ -49,43 +51,21 @@
const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { dispatch: gridDispatch } = getContext("grid")
const { dispatch: gridDispatch, rows } = getContext("grid")
export let field
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 linkEditDisabled
let primaryDisplay
let indexes = [...($tables.selected.indexes || [])]
let isCreating = undefined
let relationshipPart1 = PrettyRelationshipDefinitions.Many
let relationshipPart2 = PrettyRelationshipDefinitions.One
let relationshipTableIdPrimary = null
let relationshipTableIdSecondary = null
let table = $tables.selected
let confirmDeleteDialog
let savingColumn
let deleteColName
@ -99,11 +79,6 @@
}
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
let relationshipMap = {
[RelationshipType.ONE_TO_MANY]: {
part1: PrettyRelationshipDefinitions.MANY,
@ -118,7 +93,12 @@
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
// into what we expect the schema to look like
@ -148,6 +128,74 @@
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) => {
isCreating = !field
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 => {
return Object.keys(table?.schema).reduce((acc, 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() {
savingColumn = true
if (errors?.length) {
@ -679,6 +670,7 @@
</div>
<div class="input-length">
<ModalBindableInput
panel={ServerBindingPanel}
title="Formula"
value={editableColumn.formula}
on:change={e => {
@ -689,6 +681,7 @@
}}
bindings={getBindings({ table })}
allowJS
context={rowGoldenSample}
/>
</div>
</div>

View File

@ -40,21 +40,22 @@
indentMore,
indentLess,
} from "@codemirror/commands"
import { Compartment } from "@codemirror/state"
import { Compartment, EditorState } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./"
import { EditorModes } from "./"
import { themeStore } from "stores/portal"
export let label
export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
export let autocompleteEnabled = true
export let autofocus = false
export let jsBindingWrapping = true
export let readonly = false
const dispatch = createEventDispatcher()
// Export a function to expose caret position
export const getCaretPosition = () => {
@ -82,8 +83,8 @@
})
}
// For handlebars only.
const bindStyle = new MatchDecorator({
// Match decoration for HBS bindings
const hbsMatchDeco = new MatchDecorator({
regexp: FIND_ANY_HBS_REGEX,
decoration: () => {
return Decoration.mark({
@ -94,12 +95,11 @@
})
},
})
let plugin = ViewPlugin.define(
const hbsMatchDecoPlugin = ViewPlugin.define(
view => ({
decorations: bindStyle.createDeco(view),
decorations: hbsMatchDeco.createDeco(view),
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!
let currentTheme = $themeStore?.theme
@ -117,7 +139,7 @@
const indentWithTabCustom = {
key: "Tab",
run: view => {
if (completionStatus(view.state) == "active") {
if (completionStatus(view.state) === "active") {
acceptCompletion(view)
return true
}
@ -131,7 +153,7 @@
}
const buildKeymap = () => {
const baseMap = [
return [
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
@ -139,43 +161,25 @@
...completionKeymap,
indentWithTabCustom,
]
return baseMap
}
const buildBaseExtensions = () => {
return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(),
EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
themeConfig.of([...(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 complete = [...base]
let complete = [...base]
if (autocompleteEnabled) {
complete.push(
@ -183,7 +187,10 @@
override: [...completions],
closeOnBlur: true,
icons: false,
optionClass: () => "autocomplete-option",
optionClass: completion =>
completion.simple
? "autocomplete-option-simple"
: "autocomplete-option",
})
)
complete.push(
@ -209,22 +216,49 @@
view.dispatch(tr)
return true
}
return false
})
)
}
if (mode.name == "javascript") {
// JS only plugins
if (mode.name === "javascript") {
complete.push(snippetMatchDecoPlugin)
complete.push(javascript())
complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
if (!readonly) {
complete.push(highlightWhitespace())
}
}
// HBS only plugins
else {
complete.push(hbsMatchDecoPlugin)
}
if (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
}
@ -249,8 +283,6 @@
}
}
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
@ -265,14 +297,7 @@
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
})
}
}
@ -298,27 +323,207 @@
</div>
<style>
.code-editor.handlebars :global(.cm-content) {
font-family: var(--font-sans);
/* Editor */
.code-editor {
font-size: 12px;
height: 100%;
}
.code-editor :global(.cm-tooltip.cm-completionInfo) {
padding: var(--spacing-m);
.code-editor :global(.cm-editor) {
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]) {
border-radius: var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
.code-editor :global(.cm-content) {
padding: 10px 0;
}
.code-editor > div {
height: 100%;
}
/* 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) {
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);
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>

View File

@ -1,4 +1,3 @@
import { EditorView } from "@codemirror/view"
import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html"
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) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = helper.example
? `<div class="binding__example">${helper.example}</div>`
? `<div class="binding__example helper">${helper.example}</div>`
: ""
const descriptionMarkup = sanitizeHtml(helper.description, {
allowedTags: [],
allowedAttributes: {},
})
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
const descriptionNodeHtml = `<div class="binding__description helper">${descriptionMarkup}</div>`
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
${exampleNodeHtml}
`
return ele
}
const toSpectrumIcon = name => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeM"
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="false"
aria-label="${name}-section-icon"
style="color:var(--spectrum-global-color-gray-700)"
>
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
</svg>`
@ -152,7 +61,9 @@ const toSpectrumIcon = name => {
export const buildSectionHeader = (type, sectionName, icon, rank) => {
const ele = document.createElement("div")
ele.classList.add("info-section")
ele.classList.add(type)
if (type) {
ele.classList.add(type)
}
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
return {
name: sectionName,
@ -174,7 +85,7 @@ export const helpersToCompletion = (helpers, mode) => {
},
type: "helper",
section: helperSection,
detail: "FUNCTION",
detail: "Function",
apply: (view, completion, from, to) => {
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) => {
return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase()
@ -252,21 +186,12 @@ export const jsAutocomplete = baseCompletions => {
}
export const buildBindingInfoNode = (completion, binding) => {
if (!binding.valueHTML || binding.value == null) {
return null
}
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = binding.readableBinding
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
: ""
const descriptionNodeHtml = binding.description
? `<div class="binding__description">${binding.description}</div>`
: ""
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
ele.innerHTML = `<div class="binding__example">${binding.valueHTML}</div>`
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) => {
const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => {

View File

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

View File

@ -1,74 +1,194 @@
<script>
import {
DrawerContent,
Tabs,
Tab,
ActionButton,
Icon,
Heading,
Body,
Button,
ActionButton,
Heading,
Icon,
} from "@budibase/bbui"
import { createEventDispatcher, onMount, getContext } from "svelte"
import { createEventDispatcher, onMount } from "svelte"
import {
isValid,
decodeJSBinding,
encodeJSBinding,
convertToJS,
processStringSync,
} from "@budibase/string-templates"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import { admin } from "stores/portal"
import { readableToRuntimeBinding } from "dataBinding"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
hbAutocomplete,
snippetAutoComplete,
EditorModes,
bindingsToCompletions,
} 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 formatHighlight from "json-format-highlight"
import { capitalise } from "helpers"
import { Utils } from "@budibase/frontend-core"
import { licensing } from "stores/portal"
const dispatch = createEventDispatcher()
export let bindings
// 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 bindings = []
export let value = ""
export let valid
export let allowHBS = true
export let allowJS = false
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let snippets = null
export let autofocusEditor = false
export let placeholder = null
export let showTabBar = true
const drawerActions = getContext("drawer-actions")
const bindingDrawerActions = getContext("binding-drawer-actions")
const Modes = {
Text: "Text",
JavaScript: "JavaScript",
}
const SidePanels = {
Bindings: "FlashOn",
Evaluation: "Play",
Snippets: "Code",
}
let getCaretPosition
let insertAtPos
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
let mode = initialValueJS ? "JavaScript" : "Text"
let mode
let sidePanel
let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let sidebar = true
let getCaretPosition
let insertAtPos
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 =
mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
$: bindingCompletions = bindingsToCompletions(bindings, editorMode)
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
$: hbsCompletions = getHBSCompletions(bindingCompletions)
$: jsCompletions = getJSCompletions(bindingCompletions, snippets, useSnippets)
$: {
// Ensure a valid side panel option is always selected
if (sidePanel && !sidePanelOptions.includes(sidePanel)) {
sidePanel = sidePanelOptions[0]
}
}
const getHBSCompletions = bindingCompletions => {
return [
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.Handlebars),
]),
]
}
const getJSCompletions = (bindingCompletions, snippets, useSnippets) => {
const completions = [
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
if (useSnippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS, allowJS) => {
let options = []
if (allowHBS) {
options.push(Modes.Text)
}
if (allowJS) {
options.push(Modes.JavaScript)
}
return options
}
const getSidePanelOptions = (bindings, context, useSnippets, mode) => {
let options = []
if (bindings?.length) {
options.push(SidePanels.Bindings)
}
if (context) {
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 => {
valid = isValid(readableToRuntimeBinding(bindings, val))
if (valid) {
dispatch("change", val)
}
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
dispatch("change", val)
requestEval(runtimeExpression, context, snippets)
}
const onSelectHelper = (helper, js) => {
@ -80,9 +200,34 @@
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
}
const onChangeMode = e => {
mode = e.detail
updateValue(mode === "JavaScript" ? jsValue : hbsValue)
const changeMode = newMode => {
if (targetMode || newMode === mode) {
return
}
// Get the raw editor value to see if we are abandoning changes
let rawValue = editorValue
if (mode === Modes.JavaScript) {
rawValue = decodeJSBinding(rawValue)
}
if (rawValue?.length) {
targetMode = newMode
} else {
mode = newMode
}
}
const confirmChangeMode = () => {
jsValue = null
hbsValue = null
updateValue(null)
mode = targetMode
targetMode = null
}
const changeSidePanel = newSidePanel => {
sidePanel = newSidePanel === sidePanel ? null : newSidePanel
}
const onChangeHBSValue = e => {
@ -95,374 +240,188 @@
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(() => {
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>
<span class="binding-drawer">
<DrawerContent>
<DrawerContent padding={false}>
<div class="binding-panel">
<div class="main">
<Tabs
selected={mode}
on:select={onChangeMode}
beforeSwitch={selectedMode => {
if (selectedMode == mode) {
return true
}
//Get the current mode value
const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
if (editorValue) {
targetMode = selectedMode
return false
}
return true
}}
>
<Tab title="Text">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<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>
{/if}
<CodeEditor
value={hbsValue}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
completions={[
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
placeholder=""
height="100%"
autofocus={autofocusEditor}
/>
</div>
<div class="binding-footer">
<div class="messaging">
{#if !valid}
<div class="syntax-error">
Current Handlebars syntax is invalid, please check the
guide
<a href="https://handlebarsjs.com/guide/" target="_blank"
>here</a
>
for more details.
</div>
{:else}
<Icon name="FlashOn" />
<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>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
{#if showTabBar}
<div class="tabs">
<div class="editor-tabs">
{#each editorModeOptions as editorMode}
<ActionButton
size="M"
quiet
selected={mode === editorMode}
on:click={() => changeMode(editorMode)}
>
{capitalise(editorMode)}
</ActionButton>
{/each}
</div>
<div class="side-tabs">
{#each sidePanelOptions as panel}
<ActionButton
size="M"
quiet
selected={sidePanel === panel}
on:click={() => changeSidePanel(panel)}
>
<Icon name={panel} size="S" />
</ActionButton>
{/each}
</div>
</Tab>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<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 javascript
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard javascript
</Button>
</div>
</div>
</div>
{/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 class="actions">
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
<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>
</Tabs>
{/if}
<div class="editor">
{#if mode === Modes.Text}
{#key hbsCompletions}
<CodeEditor
value={hbsValue}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
completions={hbsCompletions}
autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"}
jsBindingWrapping={false}
/>
{/key}
{:else if mode === Modes.JavaScript}
{#key jsCompletions}
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
completions={jsCompletions}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping
/>
{/key}
{/if}
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
Switch to {targetMode}?
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep {mode}
</Button>
<Button cta size="S" on:click={confirmChangeMode}>
Yes - discard {mode}
</Button>
</div>
</div>
</div>
{/if}
</div>
</div>
</DrawerContent>
</span>
<div class="side" class:visible={!!sidePanel}>
{#if sidePanel === SidePanels.Bindings}
<BindingSidePanel
bindings={enrichedBindings}
{allowHelpers}
{context}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
{:else if sidePanel === SidePanels.Evaluation}
<EvaluationSidePanel
{expressionResult}
{evaluating}
expression={editorValue}
/>
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
/>
{/if}
</div>
</div>
</DrawerContent>
<style>
.binding-drawer :global(.container > .main) {
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) {
.binding-panel {
height: 100%;
}
.binding-drawer .main-content {
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%;
.binding-panel,
.tabs {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
}
.main-content {
display: grid;
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;
.main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
overflow: hidden;
justify-content: flex-start;
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;
flex: 1;
overflow: hidden;
}
/* Overlay */
.mode-overlay {
position: absolute;
top: 0;
@ -471,6 +430,7 @@
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(
@ -490,9 +450,4 @@
display: flex;
gap: var(--spacing-l);
}
.binding-drawer :global(.code-editor),
.binding-drawer :global(.code-editor > div) {
height: 100%;
}
</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>
import BindingPanel from "./BindingPanel.svelte"
import { previewStore, snippets } from "stores/builder"
import { onMount } from "svelte"
export let bindings = []
export let valid
export let value = ""
export let allowJS = false
export let allowHelpers = true
@ -20,11 +21,14 @@
type: null,
}))
}
onMount(previewStore.requestComponentContext)
</script>
<BindingPanel
bind:valid
bindings={enrichedBindings}
context={$previewStore.selectedComponentContext}
snippets={$snippets}
{value}
{allowJS}
{allowHelpers}

View File

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

View File

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

View File

@ -16,7 +16,6 @@
export let placeholder
export let label
export let disabled = false
export let fillWidth
export let allowJS = true
export let allowHelpers = true
export let updateOnChange = true
@ -26,7 +25,6 @@
const dispatch = createEventDispatcher()
let bindingDrawer
let valid = true
let currentVal = value
$: readableValue = runtimeToReadableBinding(bindings, value)
@ -173,22 +171,14 @@
<Drawer
on:drawerHide
on:drawerShow
{fillWidth}
bind:this={bindingDrawer}
{title}
title={title ?? placeholder ?? "Bindings"}
left={drawerLeft}
headless
>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
Save
</Button>
<Button cta slot="buttons" on:click={saveBinding}>Save</Button>
<svelte:component
this={panel}
slot="body"
bind:valid
value={readableValue}
on:change={event => (tempValue = event.detail)}
{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>
import { Icon, Input, Modal, Body, ModalContent } from "@budibase/bbui"
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))
}
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control">
<Input
{label}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
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>
<DrawerBindableInput
{...$$props}
forceModal
on:change
on:blur
on:drawerHide
on:drawerShow
/>

View File

@ -1,10 +1,11 @@
<script>
import BindingPanel from "./BindingPanel.svelte"
import { snippets } from "stores/builder"
export let bindings = []
export let valid
export let value = ""
export let allowJS = false
export let context = null
$: enrichedBindings = enrichBindings(bindings)
@ -19,9 +20,10 @@
</script>
<BindingPanel
bind:valid
bindings={enrichedBindings}
snippets={$snippets}
{value}
{allowJS}
{context}
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 })
}
}
// 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 ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { apps } from "stores/portal"
import { appStore } from "stores/builder"
import { API } from "api"
export let appId
export let appName
export let onDeleteSuccess = () => {
$goto("/builder")
}
let deleting = false
export const show = () => {
deletionModal.show()
}
@ -17,32 +24,52 @@
let deletionModal
let deletionConfirmationAppName
const copyName = () => {
deletionConfirmationAppName = appName
}
const deleteApp = async () => {
if (!appId) {
console.error("No app id provided")
return
}
deleting = true
try {
await API.deleteApp($appStore.appId)
await API.deleteApp(appId)
apps.load()
notifications.success("App deleted successfully")
$goto("/builder")
onDeleteSuccess()
} catch (err) {
notifications.error("Error deleting app")
deleting = false
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<ConfirmDialog
bind:this={deletionModal}
title="Delete app"
okText="Delete"
onOk={deleteApp}
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 />
Please enter the app name below to confirm.
<br /><br />
<Input
bind:value={deletionConfirmationAppName}
placeholder={$appStore.name}
/>
<Input bind:value={deletionConfirmationAppName} placeholder={appName} />
</ConfirmDialog>
<style>
.app-name {
cursor: pointer;
font-weight: bold;
display: inline-block;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@
</script>
<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">
"{column.name}" column settings
</svelte:fragment>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
import AppRowContext from "./AppRowContext.svelte"
export let app
export let lockedAction
@ -76,12 +77,10 @@
{#if isBuilder}
<div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToOverview}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
<Button size="S" secondary on:click={lockedAction || goToBuilder}>
Edit
</Button>
<AppRowContext {app} />
</div>
{:else if app.deployed}
<!-- 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
type="password"
label="Password"
autocomplete="new-password"
placeholder="Type here..."
bind:value={password}
error={$validation.errors.password}

View File

@ -48,3 +48,53 @@ export const duplicateName = (name, allNames) => {
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 { duplicateName } from "../duplicate"
import { duplicateName, getSequentialName } from "../duplicate"
describe("duplicate", () => {
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}
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal}>
<Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette />
</Modal>

View File

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

View File

@ -6,6 +6,7 @@
</script>
<div class="app-panel">
<div class="drawer-container" />
<div class="header">
<div class="header-left">
<UndoRedoControl store={screenStore.history} />
@ -32,7 +33,17 @@
flex-direction: column;
justify-content: flex-start;
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 {
display: flex;

View File

@ -10,6 +10,7 @@
navigationStore,
selectedScreen,
hoverStore,
snippets,
} from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import {
@ -68,6 +69,7 @@
hostname: window.location.hostname,
port: window.location.port,
},
snippets: $snippets,
}
// Refresh the preview when required
@ -196,6 +198,16 @@
} else if (type === "add-parent-component") {
const { componentId, parentType } = data
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 {
console.warn(`Client sent unknown event type: ${type}`)
}

View File

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

View File

@ -1,10 +1,14 @@
<script>
import { apps, sideBarCollapsed } from "stores/portal"
import { apps, sideBarCollapsed, auth } from "stores/portal"
import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.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 opened
$: filteredApps = $apps
.filter(app => {
@ -13,6 +17,12 @@
app.name.toLowerCase().includes(searchString.toLowerCase())
)
})
.map(app => {
return {
...app,
deployed: app.status === AppStatus.DEPLOYED,
}
})
.sort((a, b) => {
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
@ -42,8 +52,22 @@
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
highlighted={opened == 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}
</div>
</div>

View File

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

View File

@ -4,6 +4,7 @@ const INITIAL_PREVIEW_STATE = {
previewDevice: "desktop",
previewEventHandler: null,
showPreview: false,
selectedComponentContext: null,
}
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 {
subscribe: store.subscribe,
setDevice,
@ -60,6 +72,8 @@ export const createPreviewStore = () => {
startDrag,
stopDrag,
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,
navigationStore,
deploymentStore,
snippets,
datasources,
tables,
} from "stores/builder"
@ -64,6 +65,7 @@ export const createBuilderWebsocket = appId => {
appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
})
socket.onOther(
BuilderSocketEvent.AppPublishChange,

View File

@ -39,6 +39,7 @@
import FreeFooter from "components/FreeFooter.svelte"
import MaintenanceScreen from "components/MaintenanceScreen.svelte"
import licensing from "../licensing"
import SnippetsProvider from "./context/SnippetsProvider.svelte"
// Provide contexts
setContext("sdk", SDK)
@ -121,114 +122,116 @@
<StateBindingsProvider>
<RowSelectionProvider>
<QueryParamsProvider>
<!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder}
<SettingsBar />
{/if}
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice ===
"tablet"}
class:mobile-preview={$builderStore.previewDevice ===
"mobile"}
>
<!-- Actual app -->
<div id="app-root">
{#if showDevTools}
<DevToolsHeader />
<SnippetsProvider>
<!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder}
<SettingsBar />
{/if}
{/key}
<div id="app-body">
{#if permissionError}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
You don't have permission to use this app
</Heading>
<Body size="S">
Ask your administrator to grant you access
</Body>
</Layout>
</div>
{:else if !$screenStore.activeLayout}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
Something went wrong rendering your app
</Heading>
<Body size="S">
Get in touch with support if this issue persists
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice ===
"tablet"}
class:mobile-preview={$builderStore.previewDevice ===
"mobile"}
>
<!-- Actual app -->
<div id="app-root">
{#if showDevTools}
<DevToolsHeader />
{/if}
<!--
<div id="app-body">
{#if permissionError}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
You don't have permission to use this app
</Heading>
<Body size="S">
Ask your administrator to grant you access
</Body>
</Layout>
</div>
{:else if !$screenStore.activeLayout}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
Something went wrong rendering your app
</Heading>
<Body size="S">
Get in touch with support if this issue persists
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!--
Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with.
-->
<div id="flatpickr-root" />
<div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
{/if}
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
{/if}
{#if showDevTools}
<DevTools />
{#if showDevTools}
<DevTools />
{/if}
</div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
<FreeFooter />
{/if}
</div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
<FreeFooter />
<!-- Preview and dev tools utilities -->
{#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if}
</div>
<!-- Preview and dev tools utilities -->
{#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if}
</div>
</SnippetsProvider>
</QueryParamsProvider>
</RowSelectionProvider>
</StateBindingsProvider>

View File

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

View File

@ -30,6 +30,7 @@
ActionTypes,
createContextStore,
Provider,
generateGoldenSample,
} = getContext("sdk")
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
const getParsedColumns = columns => {
// 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 { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { get } from "svelte/store"
export let title
export let dataSource
@ -31,7 +32,9 @@
export let linkColumn
export let noRowsMessage
const { fetchDatasourceSchema } = getContext("sdk")
const context = getContext("context")
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
const component = getContext("component")
let formId
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
const buildFullCardUrl = (link, url, repeaterId, linkColumn) => {
if (!link || !url || !repeaterId) {

View File

@ -5,7 +5,7 @@
import { builderStore } from "stores"
import { Utils } from "@budibase/frontend-core"
import FormBlockWrapper from "./form/FormBlockWrapper.svelte"
import { writable } from "svelte/store"
import { get, writable } from "svelte/store"
import FormBlockComponent from "./FormBlockComponent.svelte"
export let actionType
@ -16,7 +16,7 @@
export let buttonPosition = "bottom"
export let size
const { fetchDatasourceSchema } = getContext("sdk")
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
const component = getContext("component")
const context = getContext("context")
@ -30,6 +30,16 @@
$: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep)
$: 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 { componentId, step } = builderStore.metadata || {}

View File

@ -4,6 +4,7 @@
import Placeholder from "components/app/Placeholder.svelte"
import { getContext } from "svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { get } from "svelte/store"
export let dataSource
export let filter
@ -18,8 +19,20 @@
export let gap
const component = getContext("component")
const context = getContext("context")
const { generateGoldenSample } = getContext("sdk")
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>
<Block>

View File

@ -3,25 +3,35 @@
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { generate } from "shortid"
import { get } from "svelte/store"
import { getContext } from "svelte"
export let dataSource
export let height
export let cardTitle
export let cardSubtitle
export let cardDescription
export let cardImageURL
export let cardSearchField
export let detailFields
export let detailTitle
export let noRowsMessage
const stateKey = generate()
const context = getContext("context")
const { generateGoldenSample } = getContext("sdk")
let listDataProviderId
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>
<Block>

View File

@ -3,6 +3,7 @@
import InnerFormBlock from "./InnerFormBlock.svelte"
import { Utils } from "@budibase/frontend-core"
import FormBlockWrapper from "./FormBlockWrapper.svelte"
import { get } from "svelte/store"
export let actionType
export let dataSource
@ -11,7 +12,6 @@
export let fields
export let buttons
export let buttonPosition
export let title
export let description
export let rowId
@ -25,8 +25,56 @@
export let saveButtonLabel
export let deleteButtonLabel
const { fetchDatasourceSchema } = getContext("sdk")
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
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 => {
if (!fields) {
@ -68,42 +116,6 @@
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 () => {
schema = (await fetchDatasourceSchema(dataSource)) || {}
}

View File

@ -58,13 +58,13 @@
}
let clonedSchema = {}
if (!allowedFields?.length) {
clonedSchema = schema
clonedSchema = schema || {}
} else {
allowedFields?.forEach(field => {
if (schema[field.name]) {
if (schema?.[field.name]) {
clonedSchema[field.name] = schema[field.name]
clonedSchema[field.name].displayName = field.displayName
} else if (schema[field]) {
} else if (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##"],
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
location: window["##BUDIBASE_LOCATION##"],
snippets: window["##BUDIBASE_SNIPPETS##"],
})
// Set app ID - this window flag is set by both the preview and the real
@ -84,6 +85,18 @@ const loadBudibase = async () => {
} else {
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") {
hoverStore.actions.hoverComponent(data)
} else if (type === "builder-meta") {

View File

@ -29,7 +29,12 @@ import { fetchDatasourceSchema } from "./utils/schema.js"
import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js"
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 {
API,
@ -65,6 +70,7 @@ export default {
processStringSync,
makePropSafe,
createContextStore,
generateGoldenSample: RowUtils.generateGoldenSample,
// Components
Provider,

View File

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

View File

@ -4,3 +4,4 @@
export { currentRole } from "./currentRole.js"
export { dndComponentPath } from "./dndComponentPath.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 { processString, 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)
}
import { processObjectSync } from "@budibase/string-templates"
/**
* 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
* should be of type FormData, with a "file" and a "password" if encrypted.

View File

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

View File

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

View File

@ -3,6 +3,7 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles"
export * as Utils from "./utils"
export * as RowUtils from "./rows"
export { memo, derivedMemo } from "./memo"
export { createWebsocket } from "./websocket"
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",
"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",
"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: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",
"debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js",
"jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest",

View File

@ -26,6 +26,7 @@ import {
env as envCore,
ErrorCode,
events,
HTTPError,
migrations,
objectStore,
roles,
@ -50,6 +51,8 @@ import {
CreateAppRequest,
FetchAppDefinitionResponse,
FetchAppPackageResponse,
DuplicateAppRequest,
DuplicateAppResponse,
} from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk"
@ -122,7 +125,7 @@ interface AppTemplate {
templateString?: string
useTemplate?: string
file?: {
type: string
type?: string
path: string
password?: string
}
@ -263,6 +266,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
...(ctx.request.files.templateFile as any),
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 appId = generateDevAppID(generateAppID(tenantId))
@ -372,12 +379,20 @@ async function creationEvents(request: any, app: App) {
else if (request.files?.templateFile) {
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
else {
console.error("Could not determine template creation event")
}
}
creationFns.push(a => events.app.created(a))
if (!request.duplicate) {
creationFns.push(a => events.app.created(a))
}
for (let fn of creationFns) {
await fn(app)
@ -391,8 +406,10 @@ async function appPostCreate(ctx: UserCtx, app: App) {
tenantId,
appId: app.appId,
})
await creationEvents(ctx.request, app)
// app import & template creation
// app import, template creation and duplication
if (ctx.request.body.useTemplate === "true") {
const { rows } = await getUniqueRows([app.appId])
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))
await appPostCreate(ctx, newApplication)
await cache.bustCache(cache.CacheKey.CHECKLIST)
@ -626,6 +643,66 @@ export async function importToApp(ctx: UserCtx) {
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(
appPackage: Partial<App>,
appId: string

View File

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

View File

@ -110,7 +110,7 @@ export async function updateAllFormulasInTable(table: Table) {
(enriched: Row) => enriched._id === row._id
)
if (enrichedRow) {
const processed = processFormulas(table, cloneDeep(row), {
const processed = await processFormulas(table, cloneDeep(row), {
dynamic: false,
contextRows: [enrichedRow],
})
@ -143,7 +143,7 @@ export async function finaliseRow(
squash: false,
})) as Row
// use enriched row to generate formulas for saving, specifically only use as context
row = processFormulas(table, row, {
row = await processFormulas(table, row, {
dynamic: false,
contextRows: [enrichedRow],
})
@ -179,7 +179,7 @@ export async function finaliseRow(
const response = await db.put(row)
// for response, calculate the formulas for the enriched row
enrichedRow._rev = response.rev
enrichedRow = processFormulas(table, enrichedRow, {
enrichedRow = await processFormulas(table, enrichedRow, {
dynamic: false,
})
// 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 { IsolatedVM } from "../../jsRunner/vm"
import { iifeWrapper } from "../../jsRunner/utilities"
import { iifeWrapper } from "@budibase/string-templates"
export async function execute(ctx: Ctx) {
const { script, context } = ctx.request.body

View File

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

View File

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

View File

@ -34,6 +34,96 @@ describe("/applications", () => {
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", () => {
it("creates empty app", async () => {
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.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", () => {
@ -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", () => {
it("should not sync automation logs", async () => {
const automation = await config.createAutomation()
@ -249,93 +410,4 @@ describe("/applications", () => {
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 { WebhookActionType } from "@budibase/types"
import Joi from "joi"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
@ -226,6 +227,21 @@ export function applicationValidator(opts = { isCreate: true }) {
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(
Joi.object({
_id: OPTIONAL_STRING,
@ -235,6 +251,7 @@ export function applicationValidator(opts = { isCreate: true }) {
template: Joi.object({
templateString: OPTIONAL_STRING,
}).unknown(true),
snippets: snippetValidator,
}).unknown(true)
)
}

View File

@ -202,7 +202,8 @@ export async function attachFullLinkedDocs(
table => table._id === linkedTableId
)
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