Merge branch 'master' into BUDI-8084/single-attachment-column-setting
This commit is contained in:
commit
bb4b24219b
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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()}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
}}
|
||||
</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 */
|
||||
|
|
|
@ -57,7 +57,6 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
type="string"
|
||||
{bindings}
|
||||
fillWidth={true}
|
||||
updateOnChange={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -52,7 +52,6 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
type="string"
|
||||
{bindings}
|
||||
fillWidth={true}
|
||||
updateOnChange={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -129,8 +129,7 @@
|
|||
/>
|
||||
{:else}
|
||||
<DrawerBindableSlot
|
||||
fillWidth
|
||||
title={value.title}
|
||||
title={value.title || field}
|
||||
panel={AutomationBindingPanel}
|
||||
type={schema.type}
|
||||
{schema}
|
||||
|
|
|
@ -85,8 +85,8 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
type="string"
|
||||
bindings={parsedBindings}
|
||||
fillWidth={true}
|
||||
allowJS={true}
|
||||
updateOnChange={false}
|
||||
title={schema.name}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {{ 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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -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) ❴ ... ❵"
|
||||
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>
|
|
@ -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>
|
|
@ -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}` })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
export let key
|
||||
export let nested
|
||||
export let max
|
||||
export let context
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
placeholder="Default"
|
||||
/>
|
||||
<DrawerBindableInput
|
||||
title="Value"
|
||||
label="Value"
|
||||
value={column.template}
|
||||
on:change={e => (column.template = e.detail)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -105,6 +105,7 @@
|
|||
onChange={handleChange}
|
||||
bindings={allBindings}
|
||||
name={key}
|
||||
title={label}
|
||||
{nested}
|
||||
{key}
|
||||
{type}
|
||||
|
|
|
@ -143,7 +143,6 @@
|
|||
value={field.value}
|
||||
allowJS={false}
|
||||
{allowHelpers}
|
||||
fillWidth={true}
|
||||
drawerLeft={bindingDrawerLeft}
|
||||
/>
|
||||
{:else}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -121,6 +121,7 @@
|
|||
<Input
|
||||
type="password"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Type here..."
|
||||
bind:value={password}
|
||||
error={$validation.errors.password}
|
||||
|
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -188,7 +188,7 @@
|
|||
{/if}
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
<Modal bind:this={commandPaletteModal}>
|
||||
<Modal bind:this={commandPaletteModal} zIndex={999999}>
|
||||
<CommandPalette />
|
||||
</Modal>
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 || {}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)) || {}
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
})
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import Provider from "./Provider.svelte"
|
||||
import { snippets } from "stores"
|
||||
</script>
|
||||
|
||||
<Provider key="snippets" data={$snippets}>
|
||||
<slot />
|
||||
</Provider>
|
|
@ -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") {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -18,6 +18,7 @@ const createBuilderStore = () => {
|
|||
usedPlugins: null,
|
||||
eventResolvers: {},
|
||||
metadata: null,
|
||||
snippets: null,
|
||||
|
||||
// Legacy - allow the builder to specify a layout
|
||||
layout: null,
|
||||
|
|
|
@ -4,3 +4,4 @@
|
|||
export { currentRole } from "./currentRole.js"
|
||||
export { dndComponentPath } from "./dndComponentPath.js"
|
||||
export { devToolsEnabled } from "./devToolsEnabled.js"
|
||||
export { snippets } from "./snippets.js"
|
||||
|
|
|
@ -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 || []
|
||||
}
|
||||
)
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -317,7 +317,7 @@
|
|||
align="right"
|
||||
offset={0}
|
||||
popoverTarget={document.getElementById(`grid-${rand}`)}
|
||||
customZindex={100}
|
||||
customZindex={50}
|
||||
>
|
||||
{#if editIsOpen}
|
||||
<div
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
align={$visibleColumns.length ? "right" : "left"}
|
||||
offset={0}
|
||||
popoverTarget={document.getElementById(`add-column-button`)}
|
||||
customZindex={100}
|
||||
customZindex={50}
|
||||
>
|
||||
<div
|
||||
use:clickOutside={() => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue