Merge branch 'master' into chore/stringtemplates-to-esm

This commit is contained in:
Adria Navarro 2024-03-15 13:22:50 +01:00
commit ecde5add83
166 changed files with 4028 additions and 1930 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,58 +1,54 @@
<script context="module">
export const directions = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]
</script>
<script> <script>
import Tooltip from "../Tooltip/Tooltip.svelte" import {
import { fade } from "svelte/transition" default as AbsTooltip,
TooltipPosition,
TooltipType,
} from "../Tooltip/AbsTooltip.svelte"
export let direction = "n"
export let name = "Add" export let name = "Add"
export let hidden = false export let hidden = false
export let size = "M" export let size = "M"
export let hoverable = false export let hoverable = false
export let disabled = false export let disabled = false
export let color export let color
export let hoverColor
export let tooltip export let tooltip
export let tooltipPosition = TooltipPosition.Bottom
$: rotation = getRotation(direction) export let tooltipType = TooltipType.Default
export let tooltipColor
let showTooltip = false export let tooltipWrap = true
export let newStyles = false
const getRotation = direction => {
return directions.indexOf(direction) * 45
}
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <AbsTooltip
<!-- svelte-ignore a11y-click-events-have-key-events --> text={tooltip}
<div type={tooltipType}
class="icon" position={tooltipPosition}
on:mouseover={() => (showTooltip = true)} color={tooltipColor}
on:focus={() => (showTooltip = true)} noWrap={tooltipWrap}
on:mouseleave={() => (showTooltip = false)}
on:click={() => (showTooltip = false)}
> >
<svg <div class="icon" class:newStyles>
on:click <svg
class:hoverable on:click
class:disabled class:hoverable
class="spectrum-Icon spectrum-Icon--size{size}" class:disabled
focusable="false" class="spectrum-Icon spectrum-Icon--size{size}"
aria-hidden={hidden} focusable="false"
aria-label={name} aria-hidden={hidden}
style={`transform: rotate(${rotation}deg); ${ aria-label={name}
color ? `color: ${color};` : "" style={`${color ? `color: ${color};` : ""} ${
}`} hoverColor
> ? `--hover-color: ${hoverColor}`
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" /> : "--hover-color: var(--spectrum-alias-icon-color-selected-hover)"
</svg> }`}
{#if tooltip && showTooltip} >
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}> <use
<Tooltip textWrapping direction="top" text={tooltip} /> style="pointer-events: none;"
</div> xlink:href="#spectrum-icon-18-{name}"
{/if} />
</div> </svg>
</div>
</AbsTooltip>
<style> <style>
.icon { .icon {
@ -60,19 +56,25 @@
display: grid; display: grid;
place-items: center; place-items: center;
} }
.newStyles {
color: var(--spectrum-global-color-gray-700);
}
svg.hoverable { svg.hoverable {
pointer-events: all; pointer-events: all;
transition: color var(--spectrum-global-animation-duration-100, 130ms); transition: color var(--spectrum-global-animation-duration-100, 130ms);
} }
svg.hoverable:hover { svg.hoverable:hover {
color: var(--spectrum-alias-icon-color-selected-hover) !important; color: var(--hover-color) !important;
cursor: pointer; cursor: pointer;
} }
svg.hoverable:active { svg.hoverable:active {
color: var(--spectrum-global-color-blue-400) !important; color: var(--spectrum-global-color-blue-400) !important;
} }
.newStyles svg.hoverable:hover,
.newStyles svg.hoverable:active {
color: var(--spectrum-global-color-gray-900) !important;
}
svg.disabled { svg.disabled {
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;

View File

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

View File

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

View File

@ -24,6 +24,7 @@
export let text = "" export let text = ""
export let fixed = false export let fixed = false
export let color = null export let color = null
export let noWrap = false
let wrapper let wrapper
let hovered = false let hovered = false
@ -105,6 +106,7 @@
<Portal target=".spectrum"> <Portal target=".spectrum">
<span <span
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open" class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
class:noWrap
style={`left:${left}px;top:${top}px;${tooltipStyle}`} style={`left:${left}px;top:${top}px;${tooltipStyle}`}
transition:fade|local={{ duration: 130 }} transition:fade|local={{ duration: 130 }}
> >
@ -118,6 +120,9 @@
.abs-tooltip { .abs-tooltip {
display: contents; display: contents;
} }
.spectrum-Tooltip.noWrap .spectrum-Tooltip-label {
width: max-content;
}
.spectrum-Tooltip { .spectrum-Tooltip {
position: absolute; position: absolute;
z-index: 9999; z-index: 9999;

View File

@ -19,7 +19,7 @@ export { default as ActionMenu } from "./ActionMenu/ActionMenu.svelte"
export { default as Button } from "./Button/Button.svelte" export { default as Button } from "./Button/Button.svelte"
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte" export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon, directions } from "./Icon/Icon.svelte" export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as Toggle } from "./Form/Toggle.svelte" export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@
class="searchButton" class="searchButton"
class:hide={search} class:hide={search}
> >
<Icon size="S" name="Search" /> <Icon size="S" name="Search" hoverable hoverColor="var(--ink)" />
</div> </div>
<div <div
@ -68,7 +68,7 @@
class="addButton" class="addButton"
class:rotate={search} class:rotate={search}
> >
<Icon name="Add" /> <Icon name="Add" hoverable hoverColor="var(--ink)" />
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@
export let iconTooltip export let iconTooltip
export let withArrow = false export let withArrow = false
export let withActions = true export let withActions = true
export let showActions = false
export let indentLevel = 0 export let indentLevel = 0
export let text export let text
export let border = true export let border = true
@ -68,6 +69,8 @@
class:border class:border
class:selected class:selected
class:withActions class:withActions
class:showActions
class:actionsOpen={highlighted && withActions}
class:scrollable class:scrollable
class:highlighted class:highlighted
class:selectedBy class:selectedBy
@ -168,8 +171,10 @@
--avatars-background: var(--spectrum-global-color-gray-300); --avatars-background: var(--spectrum-global-color-gray-300);
} }
.nav-item:hover .actions, .nav-item:hover .actions,
.hovering .actions { .hovering .actions,
visibility: visible; .nav-item.withActions.actionsOpen .actions,
.nav-item.withActions.showActions .actions {
opacity: 1;
} }
.nav-item-content { .nav-item-content {
flex: 1 1 auto; flex: 1 1 auto;
@ -272,7 +277,6 @@
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
visibility: hidden;
order: 3; order: 3;
opacity: 0; opacity: 0;
width: 20px; width: 20px;

View File

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

View File

@ -1,399 +0,0 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
import { handlebarsCompletions } from "constants/completions"
export let addHelper
export let addBinding
export let bindings
export let mode
export let allowHelpers
export let noPaddingTop = false
let search = ""
let popover
let popoverAnchor
let hoverTarget
let helpers = handlebarsCompletions()
let selectedCategory
$: searchRgx = new RegExp(search, "ig")
// Icons
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return !search || binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return (
(!search ||
helper.label.match(searchRgx) ||
helper.description.match(searchRgx)) &&
(mode.name !== "javascript" || helper.allowsJs)
)
})
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
</script>
<span class="detailPopover">
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
maxHeight={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
<Layout noPadding gap="S">
{#if selectedCategory}
<div class="sub-section-back">
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="search">
<span class="search-input">
<Input
placeholder={"Search for bindings"}
autocomplete="off"
bind:value={search}
/>
</span>
<span
class="search-input-icon"
on:click={() => {
search = null
}}
class:searching={search}
>
<Icon name={search ? "Close" : "Search"} />
</span>
</div>
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="sub-section">
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="sub-section">
<div class="cat-heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, mode.name == "javascript")}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(
helper,
mode.name == "javascript"
),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</Layout>
<style>
.search :global(input) {
border: none;
border-radius: 0px;
background: none;
padding: 0px;
}
.search {
padding: var(--spacing-m) var(--spacing-l);
display: flex;
align-items: center;
border-top: 0px;
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
position: sticky;
top: 0;
background-color: var(--background);
z-index: 2;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
ul.category-list {
padding: 0px var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.sub-section {
padding: var(--spacing-l);
padding-top: 0px;
}
.sub-section-back {
padding: var(--spacing-l);
padding-top: var(--spacing-xl);
padding-bottom: 0px;
}
.cat-heading {
margin-bottom: var(--spacing-l);
}
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
:global(.drawer-actions) {
display: flex;
gap: var(--spacing-m);
}
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { API } from "api" import { API } from "api"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { import {
previewStore, previewStore,
builderStore, builderStore,
@ -45,7 +45,7 @@
let appActionPopoverAnchor let appActionPopoverAnchor
let publishing = false let publishing = false
$: filteredApps = $apps.filter(app => app.devId === application) $: filteredApps = $appsStore.apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: latestDeployments = $deploymentStore $: latestDeployments = $deploymentStore
.filter(deployment => deployment.status === "SUCCESS") .filter(deployment => deployment.status === "SUCCESS")
@ -129,7 +129,7 @@
} }
try { try {
await API.unpublishApp(selectedApp.prodId) await API.unpublishApp(selectedApp.prodId)
await apps.load() await appsStore.load()
notifications.send("App unpublished", { notifications.send("App unpublished", {
type: "success", type: "success",
icon: "GlobeStrike", icon: "GlobeStrike",
@ -141,7 +141,7 @@
const completePublish = async () => { const completePublish = async () => {
try { try {
await apps.load() await appsStore.load()
await deploymentStore.load() await deploymentStore.load()
} catch (err) { } catch (err) {
notifications.error("Error refreshing app") notifications.error("Error refreshing app")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,10 +5,14 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import AppRowContext from "./AppRowContext.svelte"
import FavouriteAppButton from "pages/builder/portal/apps/FavouriteAppButton.svelte"
export let app export let app
export let lockedAction export let lockedAction
let actionsOpen = false
$: editing = app.sessions?.length $: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
$: unclickable = !isBuilder && !app.deployed $: unclickable = !isBuilder && !app.deployed
@ -42,8 +46,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="app-row" class="app-row"
on:click={lockedAction || handleDefaultClick}
class:unclickable class:unclickable
class:actionsOpen
class:favourite={app.favourite}
on:click={lockedAction || handleDefaultClick}
> >
<div class="title"> <div class="title">
<div class="app-icon"> <div class="app-icon">
@ -74,21 +80,35 @@
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body> <Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
</div> </div>
{#if isBuilder} <div class="actions-wrap">
<div class="app-row-actions"> <div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToOverview}> {#if isBuilder}
Manage <div class="row-action">
</Button> <Button size="S" secondary on:click={lockedAction || goToBuilder}>
<Button size="S" primary on:click={lockedAction || goToBuilder}> Edit
Edit </Button>
</Button> </div>
<div class="row-action">
<AppRowContext
{app}
on:open={() => {
actionsOpen = true
}}
on:close={() => {
actionsOpen = false
}}
/>
</div>
{:else}
<!-- this can happen if an app builder has app user access to an app -->
<Button size="S" secondary>View</Button>
{/if}
</div> </div>
{:else if app.deployed}
<!-- this can happen if an app builder has app user access to an app --> <div class="favourite-icon">
<div class="app-row-actions"> <FavouriteAppButton {app} noWrap />
<Button size="S" secondary>View</Button>
</div> </div>
{/if} </div>
</div> </div>
<style> <style>
@ -108,6 +128,16 @@
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
} }
.app-row .favourite-icon {
display: none;
}
.app-row:hover .favourite-icon,
.app-row.favourite .favourite-icon,
.app-row.actionsOpen .favourite-icon {
display: flex;
}
.updated { .updated {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
display: flex; display: flex;
@ -143,11 +173,23 @@
} }
.app-row-actions { .app-row-actions {
display: none;
}
.app-row:hover .app-row-actions,
.app-row.actionsOpen .app-row-actions {
gap: var(--spacing-m); gap: var(--spacing-m);
display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
display: flex;
}
.actions-wrap {
gap: var(--spacing-m);
display: flex;
justify-content: flex-end;
min-height: var(--spectrum-alias-item-height-s);
} }
.name { .name {

View File

@ -0,0 +1,108 @@
<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 { onMount } from "svelte"
import { licensing } from "stores/portal"
export let app
export let align = "right"
export let options
let deleteModal
let exportModal
let duplicateModal
let exportPublishedVersion = false
let loaded = false
const getActions = app => {
if (!loaded) {
return []
}
return [
{
id: "duplicate",
icon: "Copy",
onClick: duplicateModal.show,
body: "Duplicate",
},
{
id: "exportDev",
icon: "Export",
onClick: () => {
exportPublishedVersion = false
exportModal.show()
},
body: "Export latest edited app",
},
{
id: "exportProd",
icon: "Export",
onClick: () => {
exportPublishedVersion = true
exportModal.show()
},
body: "Export latest published app",
},
{
id: "delete",
icon: "Delete",
onClick: deleteModal.show,
body: "Delete",
},
].filter(action => {
if (action.id === "exportProd" && app.deployed !== true) {
return false
} else if (Array.isArray(options) && !options.includes(action.id)) {
return false
}
return true
})
}
$: actions = getActions(app, loaded)
onMount(() => {
loaded = true
})
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>
{#each actions as action}
<MenuItem icon={action.icon} on:click={action.onClick}>
{action.body}
</MenuItem>
{/each}
</ActionMenu>

View File

@ -6,7 +6,7 @@
Label, Label,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let app export let app
@ -49,7 +49,7 @@
return return
} }
try { try {
await apps.update(app.instance._id, { await appsStore.save(app.instance._id, {
icon: { name, color }, icon: { name, color },
}) })
} catch (error) { } catch (error) {

View File

@ -9,13 +9,14 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { initialise } from "stores/builder" import { initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import { apps, admin, auth } from "stores/portal" import { appsStore, admin, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
import TemplateCard from "components/common/TemplateCard.svelte" import TemplateCard from "components/common/TemplateCard.svelte"
import { lowercase } from "helpers" import { lowercase } from "helpers"
import { sdk } from "@budibase/shared-core"
export let template export let template
@ -92,7 +93,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(appsStore).apps
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications }) appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template }) appValidation.file(validation, { template })
@ -141,6 +142,11 @@
// Create user // Create user
await auth.setInitInfo({}) await auth.setInitInfo({})
if (!sdk.users.isBuilder($auth.user, createdApp?.appId)) {
// Refresh for access to created applications
await auth.getSelf()
}
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) { } catch (error) {
creating = false creating = false

View File

@ -0,0 +1,163 @@
<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 { appsStore, auth } from "stores/portal"
import { onMount } from "svelte"
import { API } from "api"
import { sdk } from "@budibase/shared-core"
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 {
const app = await API.duplicateApp(data, appId)
appsStore.load()
if (!sdk.users.isBuilder($auth.user, app?.duplicateAppId)) {
// Refresh for access to created applications
await auth.getSelf()
}
onDuplicateSuccess()
notifications.success("App duplicated successfully")
} catch (err) {
notifications.error("Error duplicating app")
duplicating = false
}
}
const setupValidation = async () => {
const applications = get(appsStore).apps
appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications })
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
$: appUrl = `${window.location.origin}${
$values.url
? `${appPrefix}${$values.url}`
: `${appPrefix}${resolveAppUrl($values.name)}`
}`
onMount(async () => {
nameToUrl($values.name)
await setupValidation()
})
</script>
<ModalContent
title={"Duplicate App"}
onConfirm={async () => {
validation.check({
...$values,
})
if ($validation.valid) {
await duplicateApp()
} else {
return keepOpen
}
}}
>
<Layout gap="S" noPadding>
<Input
autofocus={true}
bind:value={$values.name}
disabled={duplicating}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
placeholder={defaultAppName}
/>
<span>
<Input
bind:value={$values.url}
disabled={duplicating}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.url
? $values.url
: `/${resolveAppUrl($values.name)}`}
/>
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if}
</span>
</Layout>
</ModalContent>
<style>
.app-server {
color: var(--spectrum-global-color-gray-600);
margin-top: 10px;
width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

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

View File

@ -7,7 +7,7 @@
Layout, Layout,
Label, Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
@ -37,7 +37,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(appsStore).apps
appValidation.name(validation, { appValidation.name(validation, {
apps: applications, apps: applications,
currentApp: { currentApp: {
@ -62,7 +62,7 @@
async function updateApp() { async function updateApp() {
try { try {
await apps.update(app.appId, { await appsStore.save(app.appId, {
name: $values.name?.trim(), name: $values.name?.trim(),
url: $values.url?.trim(), url: $values.url?.trim(),
icon: { icon: {

View File

@ -22,6 +22,7 @@ body {
--grey-7: var(--spectrum-global-color-gray-700); --grey-7: var(--spectrum-global-color-gray-700);
--grey-8: var(--spectrum-global-color-gray-800); --grey-8: var(--spectrum-global-color-gray-800);
--grey-9: var(--spectrum-global-color-gray-900); --grey-9: var(--spectrum-global-color-gray-900);
--spectrum-global-color-yellow-1000: #d8b500;
color: var(--ink); color: var(--ink);
background-color: var(--background-alt); background-color: var(--background-alt);

View File

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

View File

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

View File

@ -15,7 +15,14 @@
FancySelect, FancySelect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { builderStore, appStore, roles } from "stores/builder" import { builderStore, appStore, roles } from "stores/builder"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" import {
groups,
licensing,
appsStore,
users,
auth,
admin,
} from "stores/portal"
import { import {
fetchData, fetchData,
Constants, Constants,
@ -54,7 +61,7 @@
let inviteFailureResponse = "" let inviteFailureResponse = ""
$: validEmail = emailValidator(email) === true $: validEmail = emailValidator(email) === true
$: prodAppId = apps.getProdAppID($appStore.appId) $: prodAppId = appsStore.getProdAppID($appStore.appId)
$: promptInvite = showInvite( $: promptInvite = showInvite(
filteredInvites, filteredInvites,
filteredUsers, filteredUsers,

View File

@ -8,7 +8,7 @@
userStore, userStore,
deploymentStore, deploymentStore,
} from "stores/builder" } from "stores/builder"
import { auth, apps } from "stores/portal" import { auth, appsStore } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
Icon, Icon,
@ -52,7 +52,7 @@
const pkg = await API.fetchAppPackage(application) const pkg = await API.fetchAppPackage(application)
await initialise(pkg) await initialise(pkg)
await apps.load() await appsStore.load()
await deploymentStore.load() await deploymentStore.load()
loaded = true loaded = true
@ -188,7 +188,7 @@
{/if} {/if}
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal}> <Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette /> <CommandPalette />
</Modal> </Modal>

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte" import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import { auth, licensing, admin, apps } from "stores/portal" import { auth, licensing, admin, appsStore } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal" import Portal from "svelte-portal"
@ -36,7 +36,7 @@
let status = null let status = null
let timeRange = null let timeRange = null
let loaded = false let loaded = false
$: app = $apps.find(app => $appStore.appId?.includes(app.appId)) $: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
@ -129,7 +129,7 @@
async function save({ detail }) { async function save({ detail }) {
try { try {
await apps.update($appStore.appId, { await appsStore.save($appStore.appId, {
automations: { automations: {
chainAutomations: detail, chainAutomations: detail,
}, },

View File

@ -10,10 +10,10 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appUrl = `${window.origin}/embed${app?.url}` $: appUrl = `${window.origin}/embed${app?.url}`
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -8,12 +8,12 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
import ExportAppModal from "components/start/ExportAppModal.svelte" import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte" import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId === $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId === $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -11,13 +11,13 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { appStore, initialise } from "stores/builder" import { appStore, initialise } from "stores/builder"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { API } from "api" import { API } from "api"
let updatingModal let updatingModal
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -12,7 +12,14 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, organisation, auth, groups, licensing } from "stores/portal" import {
appsStore,
organisation,
auth,
groups,
licensing,
enrichedApps,
} from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
@ -31,7 +38,9 @@
$: userGroups = $groups.filter(group => $: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id) group.users.find(user => user._id === $auth.user?._id)
) )
$: publishedApps = $apps.filter(app => app.status === AppStatus.DEPLOYED) $: publishedApps = $enrichedApps.filter(
app => app.status === AppStatus.DEPLOYED
)
$: userApps = getUserApps(publishedApps, userGroups, $auth.user) $: userApps = getUserApps(publishedApps, userGroups, $auth.user)
function getUserApps(publishedApps, userGroups, user) { function getUserApps(publishedApps, userGroups, user) {
@ -46,12 +55,12 @@
return userGroups.find(group => { return userGroups.find(group => {
return groups.actions return groups.actions
.getGroupAppIds(group) .getGroupAppIds(group)
.map(role => apps.extractAppId(role)) .map(role => appsStore.extractAppId(role))
.includes(app.appId) .includes(app.appId)
}) })
} else { } else {
return Object.keys($auth.user?.roles) return Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x)) .map(x => appsStore.extractAppId(x))
.includes(app.appId) .includes(app.appId)
} }
}) })
@ -76,7 +85,7 @@
onMount(async () => { onMount(async () => {
try { try {
await organisation.init() await organisation.init()
await apps.load() await appsStore.load()
await groups.actions.init() await groups.actions.init()
} catch (error) { } catch (error) {
notifications.error("Error loading apps") notifications.error("Error loading apps")

View File

@ -1,7 +1,7 @@
<script> <script>
import { isActive, redirect, goto, url } from "@roxi/routify" import { isActive, redirect, goto, url } from "@roxi/routify"
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui" import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
import { organisation, auth, menu, apps } from "stores/portal" import { organisation, auth, menu, appsStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import UpgradeButton from "./_components/UpgradeButton.svelte" import UpgradeButton from "./_components/UpgradeButton.svelte"
import MobileMenu from "./_components/MobileMenu.svelte" import MobileMenu from "./_components/MobileMenu.svelte"
@ -16,7 +16,8 @@
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: isOnboarding = !$apps.length && sdk.users.hasBuilderPermissions($auth.user) $: isOnboarding =
!$appsStore.apps.length && sdk.users.hasBuilderPermissions($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -40,7 +41,7 @@
} else { } else {
try { try {
// We need to load apps to know if we need to show onboarding fullscreen // We need to load apps to know if we need to show onboarding fullscreen
await Promise.all([apps.load(), organisation.init()]) await Promise.all([appsStore.load(), organisation.init()])
} catch (error) { } catch (error) {
notifications.error("Error getting org config") notifications.error("Error getting org config")
} }

View File

@ -18,7 +18,7 @@
Divider, Divider,
ActionButton, ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { licensing, users, apps, auditLogs } from "stores/portal" import { licensing, users, appsStore, auditLogs } from "stores/portal"
import LockedFeature from "../../_components/LockedFeature.svelte" import LockedFeature from "../../_components/LockedFeature.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
@ -102,7 +102,7 @@
enrich(parseEventObject($auditLogs.events), selectedEvents, "id"), enrich(parseEventObject($auditLogs.events), selectedEvents, "id"),
"id" "id"
) )
$: sortedApps = sort(enrich($apps, selectedApps, "appId"), "name") $: sortedApps = sort(enrich($appsStore.apps, selectedApps, "appId"), "name")
const debounce = value => { const debounce = value => {
clearTimeout(timer) clearTimeout(timer)

View File

@ -0,0 +1,43 @@
<script>
import { Icon, TooltipPosition, TooltipType } from "@budibase/bbui"
import { auth } from "stores/portal"
export let app
export let size = "S"
export let position = TooltipPosition.Top
export let noWrap = false
export let hoverColor = "var(--ink)"
</script>
<Icon
name={app?.favourite ? "Star" : "StarOutline"}
hoverable
color={app?.favourite ? "var(--spectrum-global-color-yellow-1000)" : null}
tooltip={app?.favourite ? "Remove from favourites" : "Add to favourites"}
tooltipType={TooltipType.Info}
tooltipPosition={position}
tooltipWrap={noWrap}
{hoverColor}
{size}
on:click={async e => {
e.stopPropagation()
const userAppFavourites = new Set([...($auth.user.appFavourites || [])])
let processedAppIds = []
if ($auth.user.appFavourites && app?.appId) {
if (userAppFavourites.has(app.appId)) {
userAppFavourites.delete(app.appId)
} else {
userAppFavourites.add(app.appId)
}
processedAppIds = [...userAppFavourites]
} else {
processedAppIds = [app.appId]
}
await auth.updateSelf({
appFavourites: processedAppIds,
})
}}
disabled={!app}
/>

View File

@ -1,8 +1,8 @@
<script> <script>
import { params, redirect } from "@roxi/routify" import { params, redirect } from "@roxi/routify"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
$: app = $apps.find(app => app.appId === $params.appId) $: app = $appsStore.apps.find(app => app.appId === $params.appId)
$: { $: {
if (!app) { if (!app) {
$redirect("../") $redirect("../")

View File

@ -1,12 +1,21 @@
<script> <script>
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { apps, auth, sideBarCollapsed } from "stores/portal" import { auth, sideBarCollapsed, enrichedApps } from "stores/portal"
import { Link, Body, ActionButton } from "@budibase/bbui" import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import {
Link,
Body,
Button,
Icon,
TooltipPosition,
TooltipType,
} from "@budibase/bbui"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import ErrorSVG from "./ErrorSVG.svelte" import ErrorSVG from "./ErrorSVG.svelte"
$: app = $apps.find(app => app.appId === $params.appId) $: app = $enrichedApps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app) $: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
@ -30,42 +39,63 @@
$: fetchScreens(app?.devId) $: fetchScreens(app?.devId)
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="container"> <div class="container">
<div class="header"> <div class="header">
{#if $sideBarCollapsed} {#if $sideBarCollapsed}
<ActionButton <div class="headerButton" on:click={() => sideBarCollapsed.set(false)}>
quiet <Icon
icon="Rail" name={"Rail"}
on:click={() => sideBarCollapsed.set(false)} hoverable
> tooltip="Expand"
Menu tooltipPosition={TooltipPosition.Right}
</ActionButton> tooltipType={TooltipType.Info}
hoverColor={"var(--ink)"}
/>
</div>
{:else} {:else}
<ActionButton <div class="headerButton" on:click={() => sideBarCollapsed.set(true)}>
quiet <Icon
icon="RailRightOpen" name={"RailRightOpen"}
on:click={() => sideBarCollapsed.set(true)} hoverable
> tooltip="Collapse"
Collapse tooltipType={TooltipType.Info}
</ActionButton> tooltipPosition={TooltipPosition.Top}
hoverColor={"var(--ink)"}
size="S"
/>
</div>
{/if} {/if}
{#if isBuilder} {#if isBuilder}
<ActionButton <Button
quiet size="M"
icon="Edit" secondary
on:click={() => $goto(`/builder/app/${app.devId}`)} on:click={() => $goto(`/builder/app/${app.devId}`)}
> >
Edit Edit
</ActionButton> </Button>
{/if} {/if}
<ActionButton <div class="headerButton">
disabled={noScreens} <FavouriteAppButton {app} />
quiet </div>
icon="LinkOut" <div class="headerButton" on:click={() => window.open(iframeUrl, "_blank")}>
on:click={() => window.open(iframeUrl, "_blank")} <Icon
> name="LinkOut"
Fullscreen disabled={noScreens}
</ActionButton> hoverable
tooltip="Open in new tab"
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverColor={"var(--ink)"}
size="S"
/>
</div>
<AppRowContext
{app}
options={["duplicate", "delete", "exportDev", "exportProd"]}
align="left"
/>
</div> </div>
{#if noScreens} {#if noScreens}
<div class="noScreens"> <div class="noScreens">
@ -83,6 +113,15 @@
</div> </div>
<style> <style>
.headerButton {
color: var(--grey-7);
cursor: pointer;
}
.headerButton:hover {
color: var(--ink);
}
.container { .container {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
@ -96,7 +135,7 @@
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xl);
flex: 0 0 50px; flex: 0 0 50px;
} }

View File

@ -1,23 +1,21 @@
<script> <script>
import { apps, sideBarCollapsed } from "stores/portal" import { sideBarCollapsed, enrichedApps, auth } from "stores/portal"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import NavHeader from "components/common/NavHeader.svelte" import NavHeader from "components/common/NavHeader.svelte"
import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import { sdk } from "@budibase/shared-core"
let searchString let searchString
let opened
$: filteredApps = $apps $: filteredApps = $enrichedApps.filter(app => {
.filter(app => { return (
return ( !searchString ||
!searchString || app.name.toLowerCase().includes(searchString.toLowerCase())
app.name.toLowerCase().includes(searchString.toLowerCase()) )
) })
})
.sort((a, b) => {
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1
})
</script> </script>
<div class="side-bar" class:collapsed={$sideBarCollapsed}> <div class="side-bar" class:collapsed={$sideBarCollapsed}>
@ -37,13 +35,40 @@
selected={!$params.appId} selected={!$params.appId}
/> />
{#each filteredApps as app} {#each filteredApps as app}
<NavItem <span
text={app.name} class="side-bar-app-entry"
icon={app.icon?.name || "Apps"} class:favourite={app.favourite}
iconColor={app.icon?.color} class:actionsOpen={opened == app.appId}
selected={$params.appId === app.appId} >
on:click={() => $goto(`./${app.appId}`)} <NavItem
/> text={app.name}
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)}
withActions
showActions
>
<div class="app-entry-actions">
{#if sdk.users.isBuilder($auth.user, app?.devId)}
<AppRowContext
{app}
align="left"
on:open={() => {
opened = app.appId
}}
on:close={() => {
opened = null
}}
/>
{/if}
</div>
<div class="favourite-icon">
<FavouriteAppButton {app} size="XS" />
</div>
</NavItem>
</span>
{/each} {/each}
</div> </div>
</div> </div>
@ -86,4 +111,23 @@
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.side-bar-app-entry :global(.nav-item-content .actions) {
width: auto;
display: flex;
gap: var(--spacing-s);
}
.side-bar-app-entry:hover .app-entry-actions,
.side-bar-app-entry:hover .favourite-icon,
.side-bar-app-entry.favourite .favourite-icon,
.side-bar-app-entry.actionsOpen .app-entry-actions,
.side-bar-app-entry.actionsOpen .favourite-icon {
opacity: 1;
}
.side-bar-app-entry .app-entry-actions,
.side-bar-app-entry .favourite-icon {
opacity: 0;
}
</style> </style>

View File

@ -2,7 +2,7 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { import {
admin, admin,
apps, appsStore,
templates, templates,
licensing, licensing,
groups, groups,
@ -14,7 +14,7 @@
import PortalSideBar from "./_components/PortalSideBar.svelte" import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = !!$apps?.length let loaded = !!$appsStore.apps?.length
onMount(async () => { onMount(async () => {
try { try {
@ -34,7 +34,10 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) { if (
!$appsStore.apps.length &&
sdk.users.hasBuilderPermissions($auth.user)
) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {
@ -46,7 +49,7 @@
{#if loaded} {#if loaded}
<div class="page"> <div class="page">
{#if $apps.length > 0} {#if $appsStore.apps.length > 0}
<PortalSideBar /> <PortalSideBar />
{/if} {/if}
<slot /> <slot />

View File

@ -5,7 +5,7 @@
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import TemplateDisplay from "components/common/TemplateDisplay.svelte" import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { apps, templates, licensing } from "stores/portal" import { appsStore, templates, licensing } from "stores/portal"
import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page" import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page"
let template let template
@ -35,7 +35,7 @@
} }
</script> </script>
{#if !$apps.length} {#if !$appsStore.apps.length}
<FirstAppOnboarding /> <FirstAppOnboarding />
{:else} {:else}
<Page> <Page>

View File

@ -19,13 +19,18 @@
import { automationStore, initialise } from "stores/builder" import { automationStore, initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, auth, admin, licensing, environment } from "stores/portal" import {
appsStore,
auth,
admin,
licensing,
environment,
enrichedApps,
} from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants"
import Logo from "assets/bb-space-man.svg" import Logo from "assets/bb-space-man.svg"
let sortBy = "name"
let template let template
let creationModal let creationModal
let appLimitModal let appLimitModal
@ -33,56 +38,27 @@
let searchTerm = "" let searchTerm = ""
let creatingFromTemplate = false let creatingFromTemplate = false
let automationErrors let automationErrors
let accessFilterList = null
$: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}` $: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}`
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: filteredApps = filterApps($enrichedApps, searchTerm)
$: filteredApps = enrichedApps.filter( $: automationErrors = getAutomationErrors(filteredApps || [])
app =>
(searchTerm
? app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
: true) &&
(accessFilterList !== null
? accessFilterList?.includes(
`${app?.type}_${app?.tenantId}_${app?.appId}`
)
: true)
)
$: automationErrors = getAutomationErrors(enrichedApps)
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
const filterApps = (apps, searchTerm) => {
return apps?.filter(app => {
const query = searchTerm?.trim()?.replace(/\s/g, "")
if (query) {
return app?.name?.toLowerCase().includes(query.toLowerCase())
} else {
return true
}
})
}
const usersLimitLockAction = $licensing?.errUserLimit const usersLimitLockAction = $licensing?.errUserLimit
? () => accountLockedModal.show() ? () => accountLockedModal.show()
: null : null
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
...app,
deployed: app.status === AppStatus.DEPLOYED,
lockedYou: app.lockedBy && app.lockedBy.email === user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== user?.email,
}))
if (sortBy === "status") {
return enrichedApps.sort((a, b) => {
if (a.status === b.status) {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
}
return a.status === AppStatus.DEPLOYED ? -1 : 1
})
} else if (sortBy === "updated") {
return enrichedApps.sort((a, b) => {
const aUpdated = a.updatedAt || "9999"
const bUpdated = b.updatedAt || "9999"
return aUpdated < bUpdated ? 1 : -1
})
} else {
return enrichedApps.sort((a, b) => {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
})
}
}
const getAutomationErrors = apps => { const getAutomationErrors = apps => {
const automationErrors = {} const automationErrors = {}
for (let app of apps) { for (let app of apps) {
@ -117,7 +93,7 @@
const initiateAppCreation = async () => { const initiateAppCreation = async () => {
if ($licensing?.usageMetrics?.apps >= 100) { if ($licensing?.usageMetrics?.apps >= 100) {
appLimitModal.show() appLimitModal.show()
} else if ($apps?.length) { } else if ($appsStore.apps?.length) {
$goto("/builder/portal/apps/create") $goto("/builder/portal/apps/create")
} else { } else {
template = null template = null
@ -136,7 +112,7 @@
const templateKey = template.key.split("/")[1] const templateKey = template.key.split("/")[1]
let appName = templateKey.replace(/-/g, " ") let appName = templateKey.replace(/-/g, " ")
const appsWithSameName = $apps.filter(app => const appsWithSameName = $appsStore.apps.filter(app =>
app.name?.startsWith(appName) app.name?.startsWith(appName)
) )
appName = `${appName} ${appsWithSameName.length + 1}` appName = `${appName} ${appsWithSameName.length + 1}`
@ -217,7 +193,7 @@
: "View error"} : "View error"}
on:dismiss={async () => { on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId }) await automationStore.actions.clearLogErrors({ appId })
await apps.load() await appsStore.load()
}} }}
message={automationErrorMessage(appId)} message={automationErrorMessage(appId)}
/> />
@ -233,7 +209,7 @@
</div> </div>
</div> </div>
{#if enrichedApps.length} {#if $appsStore.apps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.canCreateApps($auth.user)} {#if $auth.user && sdk.users.canCreateApps($auth.user)}
@ -245,7 +221,7 @@
> >
Create new app Create new app
</Button> </Button>
{#if $apps?.length > 0 && !$admin.offlineMode} {#if $appsStore.apps?.length > 0 && !$admin.offlineMode}
<Button <Button
size="M" size="M"
secondary secondary
@ -255,7 +231,7 @@
View templates View templates
</Button> </Button>
{/if} {/if}
{#if !$apps?.length} {#if !$appsStore.apps?.length}
<Button <Button
size="L" size="L"
quiet quiet
@ -267,11 +243,14 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if enrichedApps.length > 1} {#if $appsStore.apps.length > 1}
<div class="app-actions"> <div class="app-actions">
<Select <Select
autoWidth autoWidth
bind:value={sortBy} value={$appsStore.sortBy}
on:change={e => {
appsStore.updateSort(e.detail)
}}
placeholder={null} placeholder={null}
options={[ options={[
{ label: "Sort by name", value: "name" }, { label: "Sort by name", value: "name" },
@ -279,7 +258,17 @@
{ label: "Sort by status", value: "status" }, { label: "Sort by status", value: "status" },
]} ]}
/> />
<Search placeholder="Search" bind:value={searchTerm} /> <Search
placeholder="Search"
on:input={e => {
searchTerm = e.target.value
}}
on:change={e => {
if (!e.detail) {
searchTerm = null
}
}}
/>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -13,7 +13,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { Breadcrumb, Breadcrumbs } from "components/portal/page" import { Breadcrumb, Breadcrumbs } from "components/portal/page"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import { apps, auth, groups } from "stores/portal" import { appsStore, auth, groups } from "stores/portal"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
@ -51,17 +51,17 @@
$: isScimGroup = group?.scimInfo?.isSync $: isScimGroup = group?.scimInfo?.isSync
$: isAdmin = sdk.users.isAdmin($auth.user) $: isAdmin = sdk.users.isAdmin($auth.user)
$: readonly = !isAdmin || isScimGroup $: readonly = !isAdmin || isScimGroup
$: groupApps = $apps $: groupApps = $appsStore.apps
.filter(app => .filter(app =>
groups.actions groups.actions
.getGroupAppIds(group) .getGroupAppIds(group)
.includes(apps.getProdAppID(app.devId)) .includes(appsStore.getProdAppID(app.devId))
) )
.map(app => ({ .map(app => ({
...app, ...app,
role: group?.builder?.apps.includes(apps.getProdAppID(app.devId)) role: group?.builder?.apps.includes(appsStore.getProdAppID(app.devId))
? Constants.Roles.CREATOR ? Constants.Roles.CREATOR
: group?.roles?.[apps.getProdAppID(app.devId)], : group?.roles?.[appsStore.getProdAppID(app.devId)],
})) }))
$: { $: {
@ -93,7 +93,7 @@
} }
const removeApp = async app => { const removeApp = async app => {
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId)) await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId))
} }
setContext("roles", { setContext("roles", {
updateRole: () => {}, updateRole: () => {},

View File

@ -1,12 +1,12 @@
<script> <script>
import { keepOpen, Body, ModalContent, Select } from "@budibase/bbui" import { keepOpen, Body, ModalContent, Select } from "@budibase/bbui"
import { apps, groups } from "stores/portal" import { appsStore, groups } from "stores/portal"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
export let group export let group
$: appOptions = $apps.map(app => ({ $: appOptions = $appsStore.apps.map(app => ({
label: app.name, label: app.name,
value: app, value: app,
})) }))
@ -16,7 +16,7 @@
let selectingRole = false let selectingRole = false
async function appSelected() { async function appSelected() {
const prodAppId = apps.getProdAppID(selectedApp.devId) const prodAppId = appsStore.getProdAppID(selectedApp.devId)
if (!selectingRole) { if (!selectingRole) {
selectingRole = true selectingRole = true
await roles.fetchByAppId(prodAppId) await roles.fetchByAppId(prodAppId)

View File

@ -18,7 +18,7 @@
Table, Table,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
import { users, auth, groups, apps, licensing } from "stores/portal" import { users, auth, groups, appsStore, licensing } from "stores/portal"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
@ -97,7 +97,7 @@
$: privileged = sdk.users.isAdminOrGlobalBuilder(user) $: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups(internalGroups, searchTerm) $: filteredGroups = getFilteredGroups(internalGroups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($appsStore.apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
return x.users?.find(y => { return x.users?.find(y => {
return y._id === userId return y._id === userId
@ -111,12 +111,12 @@
availableApps = availableApps.filter(x => { availableApps = availableApps.filter(x => {
let roleKeys = Object.keys(roles || {}) let roleKeys = Object.keys(roles || {})
return roleKeys.concat(user?.builder?.apps).find(y => { return roleKeys.concat(user?.builder?.apps).find(y => {
return x.appId === apps.extractAppId(y) return x.appId === appsStore.extractAppId(y)
}) })
}) })
} }
return availableApps.map(app => { return availableApps.map(app => {
const prodAppId = apps.getProdAppID(app.devId) const prodAppId = appsStore.getProdAppID(app.devId)
return { return {
name: app.name, name: app.name,
devId: app.devId, devId: app.devId,

View File

@ -1,6 +1,6 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
export let value export let value
@ -10,7 +10,7 @@
const getCount = () => { const getCount = () => {
if (priviliged) { if (priviliged) {
return $apps.length return $appsStore.apps.length
} else { } else {
return sdk.users.hasAppBuilderPermissions(row) return sdk.users.hasAppBuilderPermissions(row)
? row?.builder?.apps?.length + ? row?.builder?.apps?.length +

View File

@ -1,5 +1,5 @@
import { API } from "api" import { API } from "api"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_APP_META_STATE = { export const INITIAL_APP_META_STATE = {
appId: "", appId: "",

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { createBuilderWebsocket } from "./websocket.js" import { createBuilderWebsocket } from "./websocket.js"
import { BuilderSocketEvent } from "@budibase/shared-core" import { BuilderSocketEvent } from "@budibase/shared-core"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore.js"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
export const INITIAL_BUILDER_STATE = { export const INITIAL_BUILDER_STATE = {

View File

@ -27,7 +27,7 @@ import {
DB_TYPE_INTERNAL, DB_TYPE_INTERNAL,
DB_TYPE_EXTERNAL, DB_TYPE_EXTERNAL,
} from "constants/backend" } from "constants/backend"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"

View File

@ -1,6 +1,6 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { previewStore } from "stores/builder" import { previewStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_HOVER_STATE = { export const INITIAL_HOVER_STATE = {
componentId: null, componentId: null,

View File

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

View File

@ -1,6 +1,6 @@
import { derived, get } from "svelte/store" import { derived, get } from "svelte/store"
import { componentStore } from "stores/builder" import { componentStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
import { API } from "api" import { API } from "api"
export const INITIAL_LAYOUT_STATE = { export const INITIAL_LAYOUT_STATE = {

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_NAVIGATION_STATE = { export const INITIAL_NAVIGATION_STATE = {
navigation: "Top", navigation: "Top",

View File

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

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