Merge remote-tracking branch 'origin/feature/app-list-actions' into feature/app-favourites
This commit is contained in:
commit
f2dbeed6e8
|
@ -32,7 +32,7 @@ describe("docWritethrough", () => {
|
|||
|
||||
describe("patch", () => {
|
||||
function generatePatchObject(fieldCount: number) {
|
||||
const keys = generator.unique(() => generator.word(), fieldCount)
|
||||
const keys = generator.unique(() => generator.guid(), fieldCount)
|
||||
return keys.reduce((acc, c) => {
|
||||
acc[c] = generator.word()
|
||||
return acc
|
||||
|
|
|
@ -32,6 +32,13 @@ const handleClick = event => {
|
|||
return
|
||||
}
|
||||
|
||||
// Ignore clicks for drawers, unless the handler is registered from a drawer
|
||||
const sourceInDrawer = handler.anchor.closest(".drawer-container") != null
|
||||
const clickInDrawer = event.target.closest(".drawer-container") != null
|
||||
if (clickInDrawer && !sourceInDrawer) {
|
||||
return
|
||||
}
|
||||
|
||||
handler.callback?.(event)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ export default function positionDropdown(element, opts) {
|
|||
align,
|
||||
maxHeight,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
useAnchorWidth,
|
||||
offset = 5,
|
||||
customUpdate,
|
||||
|
@ -28,7 +29,7 @@ export default function positionDropdown(element, opts) {
|
|||
const elementBounds = element.getBoundingClientRect()
|
||||
let styles = {
|
||||
maxHeight: null,
|
||||
minWidth: null,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
left: null,
|
||||
top: null,
|
||||
|
@ -41,8 +42,13 @@ export default function positionDropdown(element, opts) {
|
|||
})
|
||||
} else {
|
||||
// Determine vertical styles
|
||||
if (align === "right-outside") {
|
||||
styles.top = anchorBounds.top
|
||||
if (align === "right-outside" || align === "left-outside") {
|
||||
styles.top =
|
||||
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2
|
||||
styles.maxHeight = maxHeight
|
||||
if (styles.top + elementBounds.height > window.innerHeight) {
|
||||
styles.top = window.innerHeight - elementBounds.height
|
||||
}
|
||||
} else if (
|
||||
window.innerHeight - anchorBounds.bottom <
|
||||
(maxHeight || 100)
|
||||
|
|
|
@ -1,28 +1,109 @@
|
|||
<script context="module">
|
||||
import { writable, get } from "svelte/store"
|
||||
|
||||
// Observe this class name if possible in order to know how to size the
|
||||
// drawer. If this doesn't exist we'll use a fixed size.
|
||||
const drawerContainer = "drawer-container"
|
||||
|
||||
// Context level stores to keep drawers in sync
|
||||
const openDrawers = writable([])
|
||||
const modal = writable(false)
|
||||
const resizable = writable(true)
|
||||
const drawerLeft = writable(null)
|
||||
const drawerWidth = writable(null)
|
||||
|
||||
// Resize observer to keep track of size changes
|
||||
let observer
|
||||
|
||||
// Starts observing the target node to watching to size changes.
|
||||
// Invoked when the first drawer of a chain is rendered.
|
||||
const observe = () => {
|
||||
const target = document.getElementsByClassName(drawerContainer)[0]
|
||||
if (observer || !target) {
|
||||
return
|
||||
}
|
||||
observer = new ResizeObserver(entries => {
|
||||
if (!entries?.[0]) {
|
||||
return
|
||||
}
|
||||
const bounds = entries[0].target.getBoundingClientRect()
|
||||
drawerLeft.set(bounds.left)
|
||||
drawerWidth.set(bounds.width)
|
||||
})
|
||||
observer.observe(target)
|
||||
|
||||
// Manually measure once to ensure that we have dimensions for the initial
|
||||
// paint
|
||||
const bounds = target.getBoundingClientRect()
|
||||
drawerLeft.set(bounds.left)
|
||||
drawerWidth.set(bounds.width)
|
||||
}
|
||||
|
||||
// Stops observing the target node.
|
||||
// Invoked when the last drawer of a chain is removed.
|
||||
const unobserve = () => {
|
||||
if (get(openDrawers).length) {
|
||||
return
|
||||
}
|
||||
observer?.disconnect()
|
||||
|
||||
// Reset state
|
||||
observer = null
|
||||
modal.set(false)
|
||||
resizable.set(true)
|
||||
drawerLeft.set(null)
|
||||
drawerWidth.set(null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Portal from "svelte-portal"
|
||||
import Button from "../Button/Button.svelte"
|
||||
import Body from "../Typography/Body.svelte"
|
||||
import Heading from "../Typography/Heading.svelte"
|
||||
import { setContext, createEventDispatcher } from "svelte"
|
||||
import { setContext, createEventDispatcher, onDestroy } from "svelte"
|
||||
import { generate } from "shortid"
|
||||
|
||||
export let title
|
||||
export let fillWidth
|
||||
export let left = "314px"
|
||||
export let width = "calc(100% - 626px)"
|
||||
export let headless = false
|
||||
export let forceModal = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const spacing = 11
|
||||
|
||||
let visible = false
|
||||
let drawerId = generate()
|
||||
|
||||
$: depth = $openDrawers.length - $openDrawers.indexOf(drawerId) - 1
|
||||
$: style = getStyle(depth, $drawerLeft, $drawerWidth, $modal)
|
||||
|
||||
const getStyle = (depth, left, width, modal) => {
|
||||
let style = `
|
||||
--scale-factor: ${getScaleFactor(depth)};
|
||||
--spacing: ${spacing}px;
|
||||
`
|
||||
// Most modal styles are handled by class names
|
||||
if (modal || left == null || width == null) {
|
||||
return style
|
||||
}
|
||||
|
||||
// Drawers observing another dom node need custom position styles
|
||||
return `
|
||||
${style}
|
||||
left: ${left + spacing}px;
|
||||
width: ${width - 2 * spacing}px;
|
||||
`
|
||||
}
|
||||
|
||||
export function show() {
|
||||
if (visible) {
|
||||
return
|
||||
}
|
||||
if (forceModal) {
|
||||
modal.set(true)
|
||||
resizable.set(false)
|
||||
}
|
||||
observe()
|
||||
visible = true
|
||||
dispatch("drawerShow", drawerId)
|
||||
openDrawers.update(state => [...state, drawerId])
|
||||
}
|
||||
|
||||
export function hide() {
|
||||
|
@ -31,12 +112,15 @@
|
|||
}
|
||||
visible = false
|
||||
dispatch("drawerHide", drawerId)
|
||||
openDrawers.update(state => state.filter(id => id !== drawerId))
|
||||
unobserve()
|
||||
}
|
||||
|
||||
setContext("drawer-actions", {
|
||||
setContext("drawer", {
|
||||
hide,
|
||||
show,
|
||||
headless,
|
||||
modal,
|
||||
resizable,
|
||||
})
|
||||
|
||||
const easeInOutQuad = x => {
|
||||
|
@ -45,66 +129,127 @@
|
|||
|
||||
// Use a custom svelte transition here because the built-in slide
|
||||
// transition has a horrible overshoot
|
||||
const slide = () => {
|
||||
const drawerSlide = () => {
|
||||
return {
|
||||
duration: 360,
|
||||
duration: 260,
|
||||
css: t => {
|
||||
const translation = 100 - Math.round(easeInOutQuad(t) * 100)
|
||||
return `transform: translateY(${translation}%);`
|
||||
const f = easeInOutQuad(t)
|
||||
const yOffset = (1 - f) * 200
|
||||
return `
|
||||
transform: translateY(calc(${yOffset}px - 800px * (1 - var(--scale-factor))));
|
||||
opacity: ${f};
|
||||
`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Custom fade transition because the default svelte one doesn't work any more
|
||||
// with svelte 4
|
||||
const drawerFade = () => {
|
||||
return {
|
||||
duration: 260,
|
||||
css: t => {
|
||||
return `opacity: ${easeInOutQuad(t)};`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const getScaleFactor = depth => {
|
||||
// Quadratic function approaching a limit of 1 as depth tends to infinity
|
||||
const lim = 1 - 1 / (depth * depth + 1)
|
||||
// Scale drawers between 1 and 0.9 as depth approaches infinity
|
||||
return 1 - lim * 0.1
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (visible) {
|
||||
hide()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<Portal>
|
||||
<section
|
||||
class:fillWidth
|
||||
class="drawer"
|
||||
class:headless
|
||||
transition:slide|local
|
||||
style={`width: ${width}; left: ${left};`}
|
||||
>
|
||||
{#if !headless}
|
||||
<Portal target=".modal-container">
|
||||
<div class="drawer-container">
|
||||
<div
|
||||
class="underlay"
|
||||
class:hidden={!$modal}
|
||||
transition:drawerFade|local
|
||||
/>
|
||||
<div
|
||||
class="drawer"
|
||||
class:stacked={depth > 0}
|
||||
class:modal={$modal}
|
||||
transition:drawerSlide|local
|
||||
{style}
|
||||
>
|
||||
<header>
|
||||
<div class="text">
|
||||
<Heading size="XS">{title}</Heading>
|
||||
<Body size="S">
|
||||
<slot name="description" />
|
||||
</Body>
|
||||
</div>
|
||||
<div class="text">{title || "Bindings"}</div>
|
||||
<div class="buttons">
|
||||
<Button secondary quiet on:click={hide}>Cancel</Button>
|
||||
<slot name="buttons" />
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<slot name="body" />
|
||||
</section>
|
||||
<slot name="body" />
|
||||
<div class="overlay" class:hidden={$modal || depth === 0} />
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.drawer.headless :global(.drawer-contents) {
|
||||
height: calc(40vh + 75px);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 25vw;
|
||||
width: 50vw;
|
||||
bottom: var(--spacing);
|
||||
height: 420px;
|
||||
background: var(--background);
|
||||
border-top: var(--border-light);
|
||||
z-index: 3;
|
||||
border: var(--border-light);
|
||||
z-index: 999;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
transition: transform 260ms ease-out, bottom 260ms ease-out,
|
||||
left 260ms ease-out, width 260ms ease-out, height 260ms ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.drawer.modal {
|
||||
left: 15vw;
|
||||
width: 70vw;
|
||||
bottom: 15vh;
|
||||
height: 70vh;
|
||||
}
|
||||
.drawer.stacked {
|
||||
transform: translateY(calc(-1 * 1024px * (1 - var(--scale-factor))))
|
||||
scale(var(--scale-factor));
|
||||
}
|
||||
|
||||
.fillWidth {
|
||||
left: 260px !important;
|
||||
width: calc(100% - 260px) !important;
|
||||
.overlay,
|
||||
.underlay {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
display: block;
|
||||
transition: opacity 260ms ease-out;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute;
|
||||
background: var(--background);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.underlay {
|
||||
position: fixed;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.underlay.hidden,
|
||||
.overlay.hidden {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
|
@ -112,10 +257,9 @@
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-light);
|
||||
padding: var(--spacing-l) var(--spacing-xl);
|
||||
padding: var(--spacing-m) var(--spacing-xl);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -123,7 +267,6 @@
|
|||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
<div class="drawer-contents">
|
||||
<script>
|
||||
export let padding = true
|
||||
</script>
|
||||
|
||||
<div class="drawer-contents" class:padding>
|
||||
<div class:no-sidebar={!$$slots.sidebar} class="container">
|
||||
{#if $$slots.sidebar}
|
||||
<div class="sidebar">
|
||||
|
@ -13,8 +17,9 @@
|
|||
|
||||
<style>
|
||||
.drawer-contents {
|
||||
height: 40vh;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
}
|
||||
.container {
|
||||
height: 100%;
|
||||
|
@ -27,14 +32,22 @@
|
|||
.sidebar {
|
||||
border-right: var(--border-light);
|
||||
overflow: auto;
|
||||
padding: var(--spacing-xl);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.padding .sidebar {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
.sidebar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.padding .main {
|
||||
padding: var(--spacing-xl);
|
||||
height: calc(100% - var(--spacing-xl) * 2);
|
||||
}
|
||||
.main :global(textarea) {
|
||||
min-height: 200px;
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
export let inline = false
|
||||
export let disableCancel = false
|
||||
export let autoFocus = true
|
||||
export let zIndex = 999
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let visible = fixed || inline
|
||||
|
@ -101,7 +102,11 @@
|
|||
<Portal target=".modal-container">
|
||||
{#if visible}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="spectrum-Underlay is-open" on:mousedown|self={cancel}>
|
||||
<div
|
||||
class="spectrum-Underlay is-open"
|
||||
on:mousedown|self={cancel}
|
||||
style="z-index:{zIndex || 999}"
|
||||
>
|
||||
<div
|
||||
class="background"
|
||||
in:fade={{ duration: 200 }}
|
||||
|
@ -132,7 +137,6 @@
|
|||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
background: transparent;
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
export let anchor
|
||||
export let align = "right"
|
||||
export let portalTarget
|
||||
export let minWidth
|
||||
export let maxWidth
|
||||
export let maxHeight
|
||||
export let open = false
|
||||
|
@ -21,7 +22,6 @@
|
|||
export let customHeight
|
||||
export let animate = true
|
||||
export let customZindex
|
||||
|
||||
export let handlePostionUpdate
|
||||
export let showPopover = true
|
||||
export let clickOutsideOverride = false
|
||||
|
@ -87,6 +87,7 @@
|
|||
align,
|
||||
maxHeight,
|
||||
maxWidth,
|
||||
minWidth,
|
||||
useAnchorWidth,
|
||||
offset,
|
||||
customUpdate: handlePostionUpdate,
|
||||
|
@ -102,6 +103,8 @@
|
|||
role="presentation"
|
||||
style="height: {customHeight}; --customZindex: {customZindex};"
|
||||
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -66,10 +66,11 @@
|
|||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
"@zerodevx/svelte-json-view": "^1.0.7",
|
||||
"codemirror": "^5.59.0",
|
||||
"codemirror": "^5.65.16",
|
||||
"dayjs": "^1.10.8",
|
||||
"downloadjs": "1.4.7",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"json-format-highlight": "^1.0.4",
|
||||
"lodash": "4.17.21",
|
||||
"posthog-js": "^1.36.0",
|
||||
"remixicon": "2.5.0",
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
Drawer,
|
||||
Modal,
|
||||
notifications,
|
||||
Icon,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
} from "@budibase/bbui"
|
||||
|
@ -31,7 +30,7 @@
|
|||
import Editor from "components/integration/QueryEditor.svelte"
|
||||
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
|
||||
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
|
||||
import BindingPicker from "components/common/bindings/BindingPicker.svelte"
|
||||
import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte"
|
||||
import { BindingHelpers } from "components/common/bindings/utils"
|
||||
import {
|
||||
bindingsToCompletions,
|
||||
|
@ -52,11 +51,12 @@
|
|||
export let testData
|
||||
export let schemaProperties
|
||||
export let isTestModal = false
|
||||
|
||||
let webhookModal
|
||||
let drawer
|
||||
let fillWidth = true
|
||||
let inputData
|
||||
let insertAtPos, getCaretPosition
|
||||
|
||||
$: filters = lookForFilters(schemaProperties) || []
|
||||
$: tempFilters = filters
|
||||
$: stepId = block.stepId
|
||||
|
@ -80,7 +80,6 @@
|
|||
})
|
||||
$: editingJs = codeMode === EditorModes.JS
|
||||
$: requiredProperties = block.schema.inputs.required || []
|
||||
|
||||
$: stepCompletions =
|
||||
codeMode === EditorModes.Handlebars
|
||||
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
|
||||
|
@ -377,12 +376,13 @@
|
|||
<div class="fields">
|
||||
{#each schemaProperties as [key, value]}
|
||||
{#if canShowField(key, value)}
|
||||
{@const label = getFieldLabel(key, value)}
|
||||
<div class:block-field={shouldRenderField(value)}>
|
||||
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
||||
<Label
|
||||
tooltip={value.title === "Binding / Value"
|
||||
? "If using the String input type, please use a comma or newline separated string"
|
||||
: null}>{getFieldLabel(key, value)}</Label
|
||||
: null}>{label}</Label
|
||||
>
|
||||
{/if}
|
||||
<div class:field-width={shouldRenderField(value)}>
|
||||
|
@ -415,8 +415,7 @@
|
|||
</div>
|
||||
{:else if value.type === "date"}
|
||||
<DrawerBindableSlot
|
||||
fillWidth
|
||||
title={value.title}
|
||||
title={value.title ?? label}
|
||||
panel={AutomationBindingPanel}
|
||||
type={"date"}
|
||||
value={inputData[key]}
|
||||
|
@ -439,7 +438,7 @@
|
|||
/>
|
||||
{:else if value.customType === "filters"}
|
||||
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
|
||||
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
|
||||
<Drawer bind:this={drawer} title="Filtering">
|
||||
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
||||
Save
|
||||
</Button>
|
||||
|
@ -450,7 +449,6 @@
|
|||
{schemaFields}
|
||||
datasource={{ type: "table", tableId }}
|
||||
panel={AutomationBindingPanel}
|
||||
fillWidth
|
||||
on:change={e => (tempFilters = e.detail)}
|
||||
/>
|
||||
</Drawer>
|
||||
|
@ -463,19 +461,17 @@
|
|||
{:else if value.customType === "email"}
|
||||
{#if isTestModal}
|
||||
<ModalBindableInput
|
||||
title={value.title}
|
||||
title={value.title ?? label}
|
||||
value={inputData[key]}
|
||||
panel={AutomationBindingPanel}
|
||||
type="email"
|
||||
on:change={e => onChange(e, key)}
|
||||
{bindings}
|
||||
fillWidth
|
||||
updateOnChange={false}
|
||||
/>
|
||||
{:else}
|
||||
<DrawerBindableInput
|
||||
fillWidth
|
||||
title={value.title}
|
||||
title={value.title ?? label}
|
||||
panel={AutomationBindingPanel}
|
||||
type="email"
|
||||
value={inputData[key]}
|
||||
|
@ -550,7 +546,7 @@
|
|||
{:else if value.customType === "code"}
|
||||
<CodeEditorModal>
|
||||
<div class:js-editor={editingJs}>
|
||||
<div class:js-code={editingJs} style="width: 100%">
|
||||
<div class:js-code={editingJs} style="width:100%;height:500px;">
|
||||
<CodeEditor
|
||||
value={inputData[key]}
|
||||
on:change={e => {
|
||||
|
@ -563,24 +559,14 @@
|
|||
autocompleteEnabled={codeMode !== EditorModes.JS}
|
||||
bind:getCaretPosition
|
||||
bind:insertAtPos
|
||||
height={500}
|
||||
placeholder={codeMode === EditorModes.Handlebars
|
||||
? "Add bindings by typing {{"
|
||||
: null}
|
||||
/>
|
||||
<div class="messaging">
|
||||
{#if codeMode === EditorModes.Handlebars}
|
||||
<Icon name="FlashOn" />
|
||||
<div class="messaging-wrap">
|
||||
<div>
|
||||
Add available bindings by typing <strong>
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if editingJs}
|
||||
<div class="js-binding-picker">
|
||||
<BindingPicker
|
||||
<BindingSidePanel
|
||||
{bindings}
|
||||
allowHelpers={false}
|
||||
addBinding={binding =>
|
||||
|
@ -609,7 +595,7 @@
|
|||
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
|
||||
{#if isTestModal}
|
||||
<ModalBindableInput
|
||||
title={value.title}
|
||||
title={value.title || label}
|
||||
value={inputData[key]}
|
||||
panel={AutomationBindingPanel}
|
||||
type={value.customType}
|
||||
|
@ -620,8 +606,7 @@
|
|||
{:else}
|
||||
<div class="test">
|
||||
<DrawerBindableInput
|
||||
fillWidth={true}
|
||||
title={value.title}
|
||||
title={value.title ?? label}
|
||||
panel={AutomationBindingPanel}
|
||||
type={value.customType}
|
||||
value={inputData[key]}
|
||||
|
@ -654,11 +639,6 @@
|
|||
width: 320px;
|
||||
}
|
||||
|
||||
.messaging {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -670,7 +650,6 @@
|
|||
.block-field {
|
||||
display: flex; /* Use Flexbox */
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row; /* Arrange label and field side by side */
|
||||
align-items: center; /* Align vertically in the center */
|
||||
gap: 10px; /* Add some space between label and field */
|
||||
|
|
|
@ -57,7 +57,6 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
type="string"
|
||||
{bindings}
|
||||
fillWidth={true}
|
||||
updateOnChange={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -52,7 +52,6 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
type="string"
|
||||
{bindings}
|
||||
fillWidth={true}
|
||||
updateOnChange={false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -129,8 +129,7 @@
|
|||
/>
|
||||
{:else}
|
||||
<DrawerBindableSlot
|
||||
fillWidth
|
||||
title={value.title}
|
||||
title={value.title || field}
|
||||
panel={AutomationBindingPanel}
|
||||
type={schema.type}
|
||||
{schema}
|
||||
|
|
|
@ -85,8 +85,8 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
type="string"
|
||||
bindings={parsedBindings}
|
||||
fillWidth={true}
|
||||
allowJS={true}
|
||||
updateOnChange={false}
|
||||
title={schema.name}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
import { ValidColumnNameRegex } from "@budibase/shared-core"
|
||||
import { FieldType, FieldSubtype, SourceName } from "@budibase/types"
|
||||
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
||||
import { RowUtils } from "@budibase/frontend-core"
|
||||
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||
|
||||
const AUTO_TYPE = FIELDS.AUTO.type
|
||||
const FORMULA_TYPE = FIELDS.FORMULA.type
|
||||
|
@ -49,43 +51,21 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
|
||||
const { dispatch: gridDispatch } = getContext("grid")
|
||||
const { dispatch: gridDispatch, rows } = getContext("grid")
|
||||
|
||||
export let field
|
||||
|
||||
let mounted = false
|
||||
const fieldDefinitions = Object.values(FIELDS).reduce(
|
||||
// Storing the fields by complex field id
|
||||
(acc, field) => ({
|
||||
...acc,
|
||||
[makeFieldId(field.type, field.subtype)]: field,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
function makeFieldId(type, subtype, autocolumn) {
|
||||
// don't make field IDs for auto types
|
||||
if (type === AUTO_TYPE || autocolumn) {
|
||||
return type.toUpperCase()
|
||||
} else {
|
||||
return `${type}${subtype || ""}`.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
let originalName
|
||||
let linkEditDisabled
|
||||
let primaryDisplay
|
||||
let indexes = [...($tables.selected.indexes || [])]
|
||||
let isCreating = undefined
|
||||
|
||||
let relationshipPart1 = PrettyRelationshipDefinitions.Many
|
||||
let relationshipPart2 = PrettyRelationshipDefinitions.One
|
||||
|
||||
let relationshipTableIdPrimary = null
|
||||
let relationshipTableIdSecondary = null
|
||||
|
||||
let table = $tables.selected
|
||||
|
||||
let confirmDeleteDialog
|
||||
let savingColumn
|
||||
let deleteColName
|
||||
|
@ -99,11 +79,6 @@
|
|||
}
|
||||
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
|
||||
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
|
||||
|
||||
$: if (primaryDisplay) {
|
||||
editableColumn.constraints.presence = { allowEmpty: false }
|
||||
}
|
||||
|
||||
let relationshipMap = {
|
||||
[RelationshipType.ONE_TO_MANY]: {
|
||||
part1: PrettyRelationshipDefinitions.MANY,
|
||||
|
@ -118,7 +93,12 @@
|
|||
part2: PrettyRelationshipDefinitions.MANY,
|
||||
},
|
||||
}
|
||||
let autoColumnInfo = getAutoColumnInformation()
|
||||
|
||||
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
|
||||
$: if (primaryDisplay) {
|
||||
editableColumn.constraints.presence = { allowEmpty: false }
|
||||
}
|
||||
$: {
|
||||
// this parses any changes the user has made when creating a new internal relationship
|
||||
// into what we expect the schema to look like
|
||||
|
@ -148,6 +128,74 @@
|
|||
editableColumn.tableId = relationshipTableIdSecondary
|
||||
}
|
||||
}
|
||||
$: initialiseField(field, savingColumn)
|
||||
$: checkConstraints(editableColumn)
|
||||
$: required = !!editableColumn?.constraints?.presence || primaryDisplay
|
||||
$: uneditable =
|
||||
$tables.selected?._id === TableNames.USERS &&
|
||||
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
|
||||
$: invalid =
|
||||
!editableColumn?.name ||
|
||||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
||||
Object.keys(errors).length !== 0
|
||||
$: errors = checkErrors(editableColumn)
|
||||
$: datasource = $datasources.list.find(
|
||||
source => source._id === table?.sourceId
|
||||
)
|
||||
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
|
||||
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
|
||||
if (!tableAutoColumnsTypes.includes(key)) {
|
||||
acc[key] = autoColumnInfo[key]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
$: availableAutoColumnKeys = availableAutoColumns
|
||||
? Object.keys(availableAutoColumns)
|
||||
: []
|
||||
$: autoColumnOptions = editableColumn.autocolumn
|
||||
? autoColumnInfo
|
||||
: availableAutoColumns
|
||||
// used to select what different options can be displayed for column type
|
||||
$: canBeDisplay =
|
||||
editableColumn?.type !== LINK_TYPE &&
|
||||
editableColumn?.type !== AUTO_TYPE &&
|
||||
editableColumn?.type !== JSON_TYPE &&
|
||||
!editableColumn.autocolumn
|
||||
$: canBeRequired =
|
||||
editableColumn?.type !== LINK_TYPE &&
|
||||
!uneditable &&
|
||||
editableColumn?.type !== AUTO_TYPE &&
|
||||
!editableColumn.autocolumn
|
||||
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
|
||||
// in the case of internal tables the sourceId will just be undefined
|
||||
$: tableOptions = $tables.list.filter(
|
||||
opt =>
|
||||
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
|
||||
)
|
||||
$: typeEnabled =
|
||||
!originalName ||
|
||||
(originalName &&
|
||||
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
|
||||
!editableColumn?.autocolumn)
|
||||
|
||||
const fieldDefinitions = Object.values(FIELDS).reduce(
|
||||
// Storing the fields by complex field id
|
||||
(acc, field) => ({
|
||||
...acc,
|
||||
[makeFieldId(field.type, field.subtype)]: field,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
function makeFieldId(type, subtype, autocolumn) {
|
||||
// don't make field IDs for auto types
|
||||
if (type === AUTO_TYPE || autocolumn) {
|
||||
return type.toUpperCase()
|
||||
} else {
|
||||
return `${type}${subtype || ""}`.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
const initialiseField = (field, savingColumn) => {
|
||||
isCreating = !field
|
||||
if (field && !savingColumn) {
|
||||
|
@ -187,22 +235,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: initialiseField(field, savingColumn)
|
||||
|
||||
$: checkConstraints(editableColumn)
|
||||
$: required = !!editableColumn?.constraints?.presence || primaryDisplay
|
||||
$: uneditable =
|
||||
$tables.selected?._id === TableNames.USERS &&
|
||||
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
|
||||
$: invalid =
|
||||
!editableColumn?.name ||
|
||||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
||||
Object.keys(errors).length !== 0
|
||||
$: errors = checkErrors(editableColumn)
|
||||
$: datasource = $datasources.list.find(
|
||||
source => source._id === table?.sourceId
|
||||
)
|
||||
|
||||
const getTableAutoColumnTypes = table => {
|
||||
return Object.keys(table?.schema).reduce((acc, key) => {
|
||||
let fieldSchema = table?.schema[key]
|
||||
|
@ -213,47 +245,6 @@
|
|||
}, [])
|
||||
}
|
||||
|
||||
let autoColumnInfo = getAutoColumnInformation()
|
||||
|
||||
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
|
||||
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
|
||||
if (!tableAutoColumnsTypes.includes(key)) {
|
||||
acc[key] = autoColumnInfo[key]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
$: availableAutoColumnKeys = availableAutoColumns
|
||||
? Object.keys(availableAutoColumns)
|
||||
: []
|
||||
|
||||
$: autoColumnOptions = editableColumn.autocolumn
|
||||
? autoColumnInfo
|
||||
: availableAutoColumns
|
||||
|
||||
// used to select what different options can be displayed for column type
|
||||
$: canBeDisplay =
|
||||
editableColumn?.type !== LINK_TYPE &&
|
||||
editableColumn?.type !== AUTO_TYPE &&
|
||||
editableColumn?.type !== JSON_TYPE &&
|
||||
!editableColumn.autocolumn
|
||||
$: canBeRequired =
|
||||
editableColumn?.type !== LINK_TYPE &&
|
||||
!uneditable &&
|
||||
editableColumn?.type !== AUTO_TYPE &&
|
||||
!editableColumn.autocolumn
|
||||
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
|
||||
// in the case of internal tables the sourceId will just be undefined
|
||||
$: tableOptions = $tables.list.filter(
|
||||
opt =>
|
||||
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
|
||||
)
|
||||
$: typeEnabled =
|
||||
!originalName ||
|
||||
(originalName &&
|
||||
SWITCHABLE_TYPES.indexOf(editableColumn.type) !== -1 &&
|
||||
!editableColumn?.autocolumn)
|
||||
|
||||
async function saveColumn() {
|
||||
savingColumn = true
|
||||
if (errors?.length) {
|
||||
|
@ -679,6 +670,7 @@
|
|||
</div>
|
||||
<div class="input-length">
|
||||
<ModalBindableInput
|
||||
panel={ServerBindingPanel}
|
||||
title="Formula"
|
||||
value={editableColumn.formula}
|
||||
on:change={e => {
|
||||
|
@ -689,6 +681,7 @@
|
|||
}}
|
||||
bindings={getBindings({ table })}
|
||||
allowJS
|
||||
context={rowGoldenSample}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -42,13 +42,11 @@
|
|||
} from "@codemirror/commands"
|
||||
import { Compartment } from "@codemirror/state"
|
||||
import { javascript } from "@codemirror/lang-javascript"
|
||||
import { EditorModes, getDefaultTheme } from "./"
|
||||
import { EditorModes } from "./"
|
||||
import { themeStore } from "stores/portal"
|
||||
|
||||
export let label
|
||||
export let completions = []
|
||||
export let height = 200
|
||||
export let resize = "none"
|
||||
export let mode = EditorModes.Handlebars
|
||||
export let value = ""
|
||||
export let placeholder = null
|
||||
|
@ -56,6 +54,8 @@
|
|||
export let autofocus = false
|
||||
export let jsBindingWrapping = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Export a function to expose caret position
|
||||
export const getCaretPosition = () => {
|
||||
const selection_range = editor.state.selection.ranges[0]
|
||||
|
@ -107,8 +107,6 @@
|
|||
}
|
||||
)
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Theming!
|
||||
let currentTheme = $themeStore?.theme
|
||||
let isDark = !currentTheme.includes("light")
|
||||
|
@ -117,7 +115,7 @@
|
|||
const indentWithTabCustom = {
|
||||
key: "Tab",
|
||||
run: view => {
|
||||
if (completionStatus(view.state) == "active") {
|
||||
if (completionStatus(view.state) === "active") {
|
||||
acceptCompletion(view)
|
||||
return true
|
||||
}
|
||||
|
@ -131,7 +129,7 @@
|
|||
}
|
||||
|
||||
const buildKeymap = () => {
|
||||
const baseMap = [
|
||||
return [
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
|
@ -139,7 +137,6 @@
|
|||
...completionKeymap,
|
||||
indentWithTabCustom,
|
||||
]
|
||||
return baseMap
|
||||
}
|
||||
|
||||
const buildBaseExtensions = () => {
|
||||
|
@ -154,6 +151,8 @@
|
|||
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
lineNumbers(),
|
||||
foldGutter(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of(v => {
|
||||
const docStr = v.state.doc?.toString()
|
||||
|
@ -163,14 +162,7 @@
|
|||
dispatch("change", docStr)
|
||||
}),
|
||||
keymap.of(buildKeymap()),
|
||||
themeConfig.of([
|
||||
getDefaultTheme({
|
||||
height: editorHeight,
|
||||
resize,
|
||||
dark: isDark,
|
||||
}),
|
||||
...(isDark ? [oneDark] : []),
|
||||
]),
|
||||
themeConfig.of([...(isDark ? [oneDark] : [])]),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -215,11 +207,9 @@
|
|||
)
|
||||
}
|
||||
|
||||
if (mode.name == "javascript") {
|
||||
if (mode.name === "javascript") {
|
||||
complete.push(javascript())
|
||||
complete.push(highlightWhitespace())
|
||||
complete.push(lineNumbers())
|
||||
complete.push(foldGutter())
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
|
@ -249,8 +239,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: editorHeight = typeof height === "number" ? `${height}px` : height
|
||||
|
||||
// Init when all elements are ready
|
||||
$: if (mounted && !isEditorInitialised) {
|
||||
isEditorInitialised = true
|
||||
|
@ -265,14 +253,7 @@
|
|||
|
||||
// Issue theme compartment update
|
||||
editor.dispatch({
|
||||
effects: themeConfig.reconfigure([
|
||||
getDefaultTheme({
|
||||
height: editorHeight,
|
||||
resize,
|
||||
dark: isDark,
|
||||
}),
|
||||
...(isDark ? [oneDark] : []),
|
||||
]),
|
||||
effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -298,27 +279,194 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.code-editor.handlebars :global(.cm-content) {
|
||||
font-family: var(--font-sans);
|
||||
/* Editor */
|
||||
.code-editor {
|
||||
font-size: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
.code-editor :global(.cm-tooltip.cm-completionInfo) {
|
||||
padding: var(--spacing-m);
|
||||
.code-editor :global(.cm-editor) {
|
||||
height: 100%;
|
||||
background: var(--spectrum-global-color-gray-50) !important;
|
||||
outline: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
|
||||
border-radius: var(
|
||||
--spectrum-popover-border-radius,
|
||||
var(--spectrum-alias-border-radius-regular)
|
||||
),
|
||||
var(
|
||||
--spectrum-popover-border-radius,
|
||||
var(--spectrum-alias-border-radius-regular)
|
||||
),
|
||||
0, 0;
|
||||
.code-editor :global(.cm-content) {
|
||||
padding: 10px 0;
|
||||
}
|
||||
.code-editor > div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Active line */
|
||||
.code-editor :global(.cm-line) {
|
||||
height: 16px;
|
||||
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 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 */
|
||||
.code-editor :global(.binding-wrap) {
|
||||
color: var(--spectrum-global-color-blue-700);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Highlighted completion item */
|
||||
.code-editor :global(.autocomplete-option[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;
|
||||
}
|
||||
|
||||
/* Completion item type */
|
||||
.code-editor :global(.autocomplete-option .cm-completionDetail) {
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
font-style: normal;
|
||||
text-transform: capitalize;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Live binding value / helper container */
|
||||
.code-editor :global(.cm-completionInfo) {
|
||||
margin-left: var(--spacing-s);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: var(--border-radius-s);
|
||||
padding: 4px 6px;
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
padding: var(--spacing-m);
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
/* Wrapper around helpers */
|
||||
.code-editor :global(.info-bubble) {
|
||||
font-size: var(--font-size-s);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
}
|
||||
|
||||
/* Live binding value / helper value */
|
||||
.code-editor :global(.binding__description) {
|
||||
color: var(--spectrum-alias-text-color);
|
||||
font-size: var(--font-size-m);
|
||||
}
|
||||
.code-editor :global(.binding__example) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-s);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-height: 480px;
|
||||
}
|
||||
.code-editor :global(.binding__example) {
|
||||
color: var(--spectrum-global-color-blue-700);
|
||||
}
|
||||
.code-editor :global(.binding__example span) {
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { EditorView } from "@codemirror/view"
|
||||
import { getManifest } from "@budibase/string-templates"
|
||||
import sanitizeHtml from "sanitize-html"
|
||||
import { groupBy } from "lodash"
|
||||
|
@ -27,123 +26,33 @@ export const SECTIONS = {
|
|||
},
|
||||
}
|
||||
|
||||
export const getDefaultTheme = opts => {
|
||||
const { height, resize, dark } = opts
|
||||
return EditorView.theme(
|
||||
{
|
||||
"&.cm-focused .cm-cursor": {
|
||||
borderLeftColor: "var(--spectrum-alias-text-color)",
|
||||
},
|
||||
"&": {
|
||||
height: height ? `${height}` : "",
|
||||
lineHeight: "1.3",
|
||||
border:
|
||||
"var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)",
|
||||
borderRadius: "var(--border-radius-s)",
|
||||
backgroundColor:
|
||||
"var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )",
|
||||
resize: resize ? `${resize}` : "",
|
||||
overflow: "hidden",
|
||||
color: "var(--spectrum-alias-text-color)",
|
||||
},
|
||||
"& .cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
fontFamily:
|
||||
"var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))",
|
||||
maxHeight: "16em",
|
||||
},
|
||||
"& .cm-placeholder": {
|
||||
color: "var(--spectrum-alias-text-color)",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
borderColor: "var(--spectrum-alias-border-color-mouse-focus)",
|
||||
},
|
||||
// AUTO COMPLETE
|
||||
"& .cm-completionDetail": {
|
||||
fontStyle: "unset",
|
||||
textTransform: "uppercase",
|
||||
fontSize: "10px",
|
||||
backgroundColor: "var(--spectrum-global-color-gray-100)",
|
||||
color: "var(--spectrum-global-color-gray-600)",
|
||||
},
|
||||
"& .cm-completionLabel": {
|
||||
marginLeft:
|
||||
"calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))",
|
||||
},
|
||||
"& .info-bubble": {
|
||||
fontSize: "var(--font-size-s)",
|
||||
display: "grid",
|
||||
gridGap: "var(--spacing-s)",
|
||||
gridTemplateColumns: "1fr",
|
||||
color: "var(--spectrum-global-color-gray-800)",
|
||||
},
|
||||
"& .cm-tooltip": {
|
||||
marginLeft: "var(--spacing-s)",
|
||||
border: "1px solid var(--spectrum-global-color-gray-300)",
|
||||
borderRadius:
|
||||
"var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )",
|
||||
backgroundColor: "var(--spectrum-global-color-gray-50)",
|
||||
},
|
||||
// Section header
|
||||
"& .info-section": {
|
||||
display: "flex",
|
||||
padding: "var(--spacing-s)",
|
||||
gap: "var(--spacing-m)",
|
||||
borderBottom: "1px solid var(--spectrum-global-color-gray-200)",
|
||||
color: "var(--spectrum-global-color-gray-800)",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
"& .info-section .spectrum-Icon": {
|
||||
color: "var(--spectrum-global-color-gray-600)",
|
||||
},
|
||||
// Autocomplete Option
|
||||
"& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "var(--spectrum-alias-font-size-default)",
|
||||
padding: "var(--spacing-s)",
|
||||
color: "var(--spectrum-global-color-gray-800)",
|
||||
},
|
||||
"& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": {
|
||||
backgroundColor: "var(--spectrum-global-color-gray-200)",
|
||||
},
|
||||
"& .binding-wrap": {
|
||||
color: "var(--spectrum-global-color-blue-700)",
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
},
|
||||
{ dark }
|
||||
)
|
||||
}
|
||||
|
||||
export const buildHelperInfoNode = (completion, helper) => {
|
||||
const ele = document.createElement("div")
|
||||
ele.classList.add("info-bubble")
|
||||
|
||||
const exampleNodeHtml = helper.example
|
||||
? `<div class="binding__example">${helper.example}</div>`
|
||||
? `<div class="binding__example helper">${helper.example}</div>`
|
||||
: ""
|
||||
const descriptionMarkup = sanitizeHtml(helper.description, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {},
|
||||
})
|
||||
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
|
||||
const descriptionNodeHtml = `<div class="binding__description helper">${descriptionMarkup}</div>`
|
||||
|
||||
ele.innerHTML = `
|
||||
${exampleNodeHtml}
|
||||
${descriptionNodeHtml}
|
||||
${exampleNodeHtml}
|
||||
`
|
||||
return ele
|
||||
}
|
||||
|
||||
const toSpectrumIcon = name => {
|
||||
return `<svg
|
||||
class="spectrum-Icon spectrum-Icon--sizeM"
|
||||
class="spectrum-Icon spectrum-Icon--sizeS"
|
||||
focusable="false"
|
||||
aria-hidden="false"
|
||||
aria-label="${name}-section-icon"
|
||||
style="color:var(--spectrum-global-color-gray-700)"
|
||||
>
|
||||
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
|
||||
</svg>`
|
||||
|
@ -152,7 +61,9 @@ const toSpectrumIcon = name => {
|
|||
export const buildSectionHeader = (type, sectionName, icon, rank) => {
|
||||
const ele = document.createElement("div")
|
||||
ele.classList.add("info-section")
|
||||
ele.classList.add(type)
|
||||
if (type) {
|
||||
ele.classList.add(type)
|
||||
}
|
||||
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
|
||||
return {
|
||||
name: sectionName,
|
||||
|
@ -174,7 +85,7 @@ export const helpersToCompletion = (helpers, mode) => {
|
|||
},
|
||||
type: "helper",
|
||||
section: helperSection,
|
||||
detail: "FUNCTION",
|
||||
detail: "Function",
|
||||
apply: (view, completion, from, to) => {
|
||||
insertBinding(view, from, to, key, mode)
|
||||
},
|
||||
|
@ -252,21 +163,12 @@ export const jsAutocomplete = baseCompletions => {
|
|||
}
|
||||
|
||||
export const buildBindingInfoNode = (completion, binding) => {
|
||||
if (!binding.valueHTML || binding.value == null) {
|
||||
return null
|
||||
}
|
||||
const ele = document.createElement("div")
|
||||
ele.classList.add("info-bubble")
|
||||
|
||||
const exampleNodeHtml = binding.readableBinding
|
||||
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
|
||||
: ""
|
||||
|
||||
const descriptionNodeHtml = binding.description
|
||||
? `<div class="binding__description">${binding.description}</div>`
|
||||
: ""
|
||||
|
||||
ele.innerHTML = `
|
||||
${exampleNodeHtml}
|
||||
${descriptionNodeHtml}
|
||||
`
|
||||
ele.innerHTML = `<div class="binding__example">${binding.valueHTML}</div>`
|
||||
return ele
|
||||
}
|
||||
|
||||
|
|
|
@ -1,27 +1,19 @@
|
|||
<script>
|
||||
import {
|
||||
DrawerContent,
|
||||
Tabs,
|
||||
Tab,
|
||||
ActionButton,
|
||||
Icon,
|
||||
Heading,
|
||||
Body,
|
||||
Button,
|
||||
ActionButton,
|
||||
Heading,
|
||||
Icon,
|
||||
} from "@budibase/bbui"
|
||||
import { createEventDispatcher, onMount, getContext } from "svelte"
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import {
|
||||
isValid,
|
||||
decodeJSBinding,
|
||||
encodeJSBinding,
|
||||
convertToJS,
|
||||
processStringSync,
|
||||
} from "@budibase/string-templates"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "dataBinding"
|
||||
|
||||
import { admin } from "stores/portal"
|
||||
import { readableToRuntimeBinding } from "dataBinding"
|
||||
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
|
||||
import {
|
||||
getHelperCompletions,
|
||||
|
@ -30,45 +22,118 @@
|
|||
EditorModes,
|
||||
bindingsToCompletions,
|
||||
} from "../CodeEditor"
|
||||
import BindingPicker from "./BindingPicker.svelte"
|
||||
import BindingSidePanel from "./BindingSidePanel.svelte"
|
||||
import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
|
||||
import { BindingHelpers } from "./utils"
|
||||
import formatHighlight from "json-format-highlight"
|
||||
import { capitalise } from "helpers"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let bindings
|
||||
// jsValue/hbsValue are the state of the value that is being built
|
||||
// within this binding panel - the value should not be updated until
|
||||
// the binding panel is saved. This is the default value of the
|
||||
// expression when the binding panel is opened, but shouldn't be updated.
|
||||
export let value = ""
|
||||
export let valid
|
||||
export let allowJS = false
|
||||
export let allowHelpers = true
|
||||
export let context = null
|
||||
export let autofocusEditor = false
|
||||
|
||||
const drawerActions = getContext("drawer-actions")
|
||||
const bindingDrawerActions = getContext("binding-drawer-actions")
|
||||
const drawerContext = getContext("drawer")
|
||||
const Modes = {
|
||||
Text: "Text",
|
||||
JavaScript: "JavaScript",
|
||||
}
|
||||
const SidePanels = {
|
||||
Bindings: "FlashOn",
|
||||
Evaluation: "Play",
|
||||
}
|
||||
|
||||
let initialValueJS = value?.startsWith?.("{{ js ")
|
||||
let mode = initialValueJS ? Modes.JavaScript : Modes.Text
|
||||
let sidePanel = SidePanels.Bindings
|
||||
let getCaretPosition
|
||||
let insertAtPos
|
||||
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
|
||||
let mode = initialValueJS ? "JavaScript" : "Text"
|
||||
let jsValue = initialValueJS ? value : null
|
||||
let hbsValue = initialValueJS ? null : value
|
||||
let sidebar = true
|
||||
let targetMode = null
|
||||
let expressionResult
|
||||
let drawerIsModal
|
||||
let evaluating = false
|
||||
|
||||
$: usingJS = mode === "JavaScript"
|
||||
$: drawerContext?.modal.subscribe(val => (drawerIsModal = val))
|
||||
$: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text]
|
||||
$: sideTabs = context
|
||||
? [SidePanels.Evaluation, SidePanels.Bindings]
|
||||
: [SidePanels.Bindings]
|
||||
$: enrichedBindings = enrichBindings(bindings, context)
|
||||
$: usingJS = mode === Modes.JavaScript
|
||||
$: editorMode =
|
||||
mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
|
||||
$: bindingCompletions = bindingsToCompletions(bindings, editorMode)
|
||||
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
|
||||
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
|
||||
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
|
||||
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
|
||||
$: requestUpdateEvaluation(runtimeExpression, context)
|
||||
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
|
||||
$: hbsCompletions = [
|
||||
hbAutocomplete([
|
||||
...bindingCompletions,
|
||||
...getHelperCompletions(EditorModes.Handlebars),
|
||||
]),
|
||||
]
|
||||
$: jsCompletions = [
|
||||
jsAutocomplete([
|
||||
...bindingCompletions,
|
||||
...getHelperCompletions(EditorModes.JS),
|
||||
]),
|
||||
]
|
||||
|
||||
const debouncedUpdateEvaluation = Utils.debounce((expression, context) => {
|
||||
expressionResult = processStringSync(expression || "", context)
|
||||
evaluating = false
|
||||
}, 260)
|
||||
|
||||
const requestUpdateEvaluation = (expression, context) => {
|
||||
evaluating = true
|
||||
debouncedUpdateEvaluation(expression, context)
|
||||
}
|
||||
|
||||
const getBindingValue = (binding, context) => {
|
||||
const js = `return $("${binding.runtimeBinding}")`
|
||||
const hbs = encodeJSBinding(js)
|
||||
const res = processStringSync(hbs, context)
|
||||
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) => {
|
||||
return bindings.map(binding => {
|
||||
if (!context) {
|
||||
return binding
|
||||
}
|
||||
const value = getBindingValue(binding, context)
|
||||
return {
|
||||
...binding,
|
||||
value,
|
||||
valueHTML: highlightJSON(value),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateValue = val => {
|
||||
valid = isValid(readableToRuntimeBinding(bindings, val))
|
||||
if (valid) {
|
||||
dispatch("change", val)
|
||||
}
|
||||
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
|
||||
dispatch("change", val)
|
||||
requestUpdateEvaluation(runtimeExpression, context)
|
||||
}
|
||||
|
||||
const onSelectHelper = (helper, js) => {
|
||||
|
@ -80,9 +145,27 @@
|
|||
bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js })
|
||||
}
|
||||
|
||||
const onChangeMode = e => {
|
||||
mode = e.detail
|
||||
updateValue(mode === "JavaScript" ? jsValue : hbsValue)
|
||||
const changeMode = newMode => {
|
||||
if (targetMode || newMode === mode) {
|
||||
return
|
||||
}
|
||||
if (editorValue) {
|
||||
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 => {
|
||||
|
@ -94,375 +177,177 @@
|
|||
jsValue = encodeJSBinding(e.detail)
|
||||
updateValue(jsValue)
|
||||
}
|
||||
|
||||
const switchMode = () => {
|
||||
if (targetMode == "Text") {
|
||||
jsValue = null
|
||||
updateValue(jsValue)
|
||||
} else {
|
||||
hbsValue = null
|
||||
updateValue(hbsValue)
|
||||
}
|
||||
mode = targetMode + ""
|
||||
targetMode = null
|
||||
}
|
||||
|
||||
const convert = () => {
|
||||
const runtime = readableToRuntimeBinding(bindings, hbsValue)
|
||||
const runtimeJs = encodeJSBinding(convertToJS(runtime))
|
||||
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
|
||||
hbsValue = null
|
||||
mode = "JavaScript"
|
||||
onSelectBinding("", { forceJS: true })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
valid = isValid(readableToRuntimeBinding(bindings, value))
|
||||
})
|
||||
</script>
|
||||
|
||||
<span class="binding-drawer">
|
||||
<DrawerContent>
|
||||
<DrawerContent padding={false}>
|
||||
<div class="binding-panel">
|
||||
<div class="main">
|
||||
<Tabs
|
||||
selected={mode}
|
||||
on:select={onChangeMode}
|
||||
beforeSwitch={selectedMode => {
|
||||
if (selectedMode == mode) {
|
||||
return true
|
||||
}
|
||||
|
||||
//Get the current mode value
|
||||
const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
|
||||
|
||||
if (editorValue) {
|
||||
targetMode = selectedMode
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}}
|
||||
>
|
||||
<Tab title="Text">
|
||||
<div class="main-content" class:binding-panel={sidebar}>
|
||||
<div class="editor">
|
||||
<div class="overlay-wrap">
|
||||
{#if targetMode}
|
||||
<div class="mode-overlay">
|
||||
<div class="prompt-body">
|
||||
<Heading size="S">
|
||||
{`Switch to ${targetMode}?`}
|
||||
</Heading>
|
||||
<Body>This will discard anything in your binding</Body>
|
||||
<div class="switch-actions">
|
||||
<Button
|
||||
secondary
|
||||
size="S"
|
||||
on:click={() => {
|
||||
targetMode = null
|
||||
}}
|
||||
>
|
||||
No - keep text
|
||||
</Button>
|
||||
<Button cta size="S" on:click={switchMode}>
|
||||
Yes - discard text
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<CodeEditor
|
||||
value={hbsValue}
|
||||
on:change={onChangeHBSValue}
|
||||
bind:getCaretPosition
|
||||
bind:insertAtPos
|
||||
completions={[
|
||||
hbAutocomplete([
|
||||
...bindingCompletions,
|
||||
...getHelperCompletions(editorMode),
|
||||
]),
|
||||
]}
|
||||
placeholder=""
|
||||
height="100%"
|
||||
autofocus={autofocusEditor}
|
||||
/>
|
||||
</div>
|
||||
<div class="binding-footer">
|
||||
<div class="messaging">
|
||||
{#if !valid}
|
||||
<div class="syntax-error">
|
||||
Current Handlebars syntax is invalid, please check the
|
||||
guide
|
||||
<a href="https://handlebarsjs.com/guide/" target="_blank"
|
||||
>here</a
|
||||
>
|
||||
for more details.
|
||||
</div>
|
||||
{:else}
|
||||
<Icon name="FlashOn" />
|
||||
<div class="messaging-wrap">
|
||||
<div>
|
||||
Add available bindings by typing {{ or use the
|
||||
menu on the right
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if $admin.isDev && allowJS}
|
||||
<ActionButton
|
||||
secondary
|
||||
on:click={() => {
|
||||
convert()
|
||||
targetMode = null
|
||||
}}
|
||||
>
|
||||
Convert To JS
|
||||
</ActionButton>
|
||||
{/if}
|
||||
<ActionButton
|
||||
secondary
|
||||
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
|
||||
on:click={() => {
|
||||
sidebar = !sidebar
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sidebar}
|
||||
<div class="binding-picker">
|
||||
<BindingPicker
|
||||
{bindings}
|
||||
{allowHelpers}
|
||||
addHelper={onSelectHelper}
|
||||
addBinding={onSelectBinding}
|
||||
mode={editorMode}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</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
|
||||
<div class="tabs">
|
||||
<div class="editor-tabs">
|
||||
{#each editorTabs as tab}
|
||||
<ActionButton
|
||||
size="M"
|
||||
quiet
|
||||
on:click={() => {
|
||||
drawerActions.hide()
|
||||
}}
|
||||
selected={mode === tab}
|
||||
on:click={() => changeMode(tab)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{/if}
|
||||
{#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
|
||||
<Button
|
||||
cta
|
||||
disabled={!valid}
|
||||
on:click={() => {
|
||||
bindingDrawerActions.save()
|
||||
}}
|
||||
{capitalise(tab)}
|
||||
</ActionButton>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="side-tabs">
|
||||
{#each sideTabs as tab}
|
||||
<ActionButton
|
||||
size="M"
|
||||
quiet
|
||||
selected={sidePanel === tab}
|
||||
on:click={() => changeSidePanel(tab)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Icon name={tab} size="S" />
|
||||
</ActionButton>
|
||||
{/each}
|
||||
{#if drawerContext && get(drawerContext.resizable)}
|
||||
<ActionButton
|
||||
size="M"
|
||||
quiet
|
||||
selected={drawerIsModal}
|
||||
on:click={() => drawerContext.modal.set(!drawerIsModal)}
|
||||
>
|
||||
<Icon name={drawerIsModal ? "Minimize" : "Maximize"} size="S" />
|
||||
</ActionButton>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="editor">
|
||||
{#if mode === Modes.Text}
|
||||
{#key hbsCompletions}
|
||||
<CodeEditor
|
||||
value={hbsValue}
|
||||
on:change={onChangeHBSValue}
|
||||
bind:getCaretPosition
|
||||
bind:insertAtPos
|
||||
completions={hbsCompletions}
|
||||
autofocus={autofocusEditor}
|
||||
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="Add bindings by typing $ or use the menu on the right"
|
||||
jsBindingWrapping
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{#if targetMode}
|
||||
<div class="mode-overlay">
|
||||
<div class="prompt-body">
|
||||
<Heading size="S">
|
||||
Switch to {targetMode}?
|
||||
</Heading>
|
||||
<Body>This will discard anything in your binding</Body>
|
||||
<div class="switch-actions">
|
||||
<Button
|
||||
secondary
|
||||
size="S"
|
||||
on:click={() => {
|
||||
targetMode = null
|
||||
}}
|
||||
>
|
||||
No - keep {mode}
|
||||
</Button>
|
||||
<Button cta size="S" on:click={confirmChangeMode}>
|
||||
Yes - discard {mode}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</span>
|
||||
<div class="side" class:visible={!!sidePanel}>
|
||||
{#if sidePanel === SidePanels.Bindings}
|
||||
<BindingSidePanel
|
||||
bindings={enrichedBindings}
|
||||
{allowHelpers}
|
||||
{context}
|
||||
addHelper={onSelectHelper}
|
||||
addBinding={onSelectBinding}
|
||||
mode={editorMode}
|
||||
/>
|
||||
{:else if sidePanel === SidePanels.Evaluation}
|
||||
<EvaluationSidePanel
|
||||
{expressionResult}
|
||||
{evaluating}
|
||||
expression={editorValue}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
<style>
|
||||
.binding-drawer :global(.container > .main) {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.binding-drawer :global(.container > .main > .main) {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.binding-drawer :global(.spectrum-Tabs-content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.binding-drawer :global(.spectrum-Tabs-content > div),
|
||||
.binding-drawer :global(.spectrum-Tabs-content > div > div),
|
||||
.binding-drawer :global(.spectrum-Tabs-content .main-content) {
|
||||
.binding-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.binding-drawer .main-content {
|
||||
grid-template-rows: unset;
|
||||
}
|
||||
|
||||
.messaging {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.messaging-wrap {
|
||||
overflow: hidden;
|
||||
}
|
||||
.messaging-wrap > div {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.main :global(textarea) {
|
||||
min-height: 202px !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: var(--spacing-s) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.main :global(.spectrum-Tabs div.drawer-actions) {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main :global(.spectrum-Tabs-content),
|
||||
.main :global(.spectrum-Tabs-content .main-content) {
|
||||
margin-top: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.main :global(.spectrum-Tabs) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.syntax-error {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
.syntax-error a {
|
||||
color: var(--red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.binding-footer {
|
||||
width: 100%;
|
||||
.binding-panel,
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
}
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 380px;
|
||||
}
|
||||
.main-content.binding-panel {
|
||||
grid-template-columns: 1fr 320px;
|
||||
}
|
||||
.binding-picker {
|
||||
border-left: 2px solid var(--border-light);
|
||||
border-left: var(--border-light);
|
||||
overflow: scroll;
|
||||
height: 100%;
|
||||
}
|
||||
.editor {
|
||||
padding: var(--spacing-xl);
|
||||
min-width: 0;
|
||||
.main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
overflow: hidden;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.overlay-wrap {
|
||||
.side {
|
||||
overflow: hidden;
|
||||
flex: 0 0 360px;
|
||||
margin-right: -360px;
|
||||
transition: margin-right 130ms ease-out;
|
||||
}
|
||||
.side.visible {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
padding: var(--spacing-m);
|
||||
border-bottom: var(--border-light);
|
||||
}
|
||||
.editor-tabs,
|
||||
.side-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.side-tabs :global(.icon) {
|
||||
width: 16px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.editor {
|
||||
flex: 1 1 auto;
|
||||
height: 0;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Overlay */
|
||||
.mode-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -471,6 +356,7 @@
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(
|
||||
|
@ -490,9 +376,4 @@
|
|||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.binding-drawer :global(.code-editor),
|
||||
.binding-drawer :global(.code-editor > div) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,399 +0,0 @@
|
|||
<script>
|
||||
import groupBy from "lodash/fp/groupBy"
|
||||
import { convertToJS } from "@budibase/string-templates"
|
||||
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
|
||||
import { handlebarsCompletions } from "constants/completions"
|
||||
|
||||
export let addHelper
|
||||
export let addBinding
|
||||
export let bindings
|
||||
export let mode
|
||||
export let allowHelpers
|
||||
export let noPaddingTop = false
|
||||
|
||||
let search = ""
|
||||
let popover
|
||||
let popoverAnchor
|
||||
let hoverTarget
|
||||
let helpers = handlebarsCompletions()
|
||||
|
||||
let selectedCategory
|
||||
|
||||
$: searchRgx = new RegExp(search, "ig")
|
||||
|
||||
// Icons
|
||||
$: bindingIcons = bindings?.reduce((acc, ele) => {
|
||||
if (ele.icon) {
|
||||
acc[ele.category] = acc[ele.category] || ele.icon
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
|
||||
|
||||
$: categories = Object.entries(groupBy("category", bindings))
|
||||
$: categoryNames = getCategoryNames(categories)
|
||||
|
||||
$: filteredCategories = categories
|
||||
.map(([name, categoryBindings]) => ({
|
||||
name,
|
||||
bindings: categoryBindings?.filter(binding => {
|
||||
return !search || binding.readableBinding.match(searchRgx)
|
||||
}),
|
||||
}))
|
||||
.filter(category => {
|
||||
return (
|
||||
category.bindings?.length > 0 &&
|
||||
(!selectedCategory ? true : selectedCategory === category.name)
|
||||
)
|
||||
})
|
||||
$: filteredHelpers = helpers?.filter(helper => {
|
||||
return (
|
||||
(!search ||
|
||||
helper.label.match(searchRgx) ||
|
||||
helper.description.match(searchRgx)) &&
|
||||
(mode.name !== "javascript" || helper.allowsJs)
|
||||
)
|
||||
})
|
||||
|
||||
const getHelperExample = (helper, js) => {
|
||||
let example = helper.example || ""
|
||||
if (js) {
|
||||
example = convertToJS(example).split("\n")[0].split("= ")[1]
|
||||
if (example === "null;") {
|
||||
example = ""
|
||||
}
|
||||
}
|
||||
return example || ""
|
||||
}
|
||||
|
||||
const getCategoryNames = categories => {
|
||||
let names = [...categories.map(cat => cat[0])]
|
||||
if (allowHelpers) {
|
||||
names.push("Helpers")
|
||||
}
|
||||
return names
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="detailPopover">
|
||||
<Popover
|
||||
align="left-outside"
|
||||
bind:this={popover}
|
||||
anchor={popoverAnchor}
|
||||
maxWidth={300}
|
||||
maxHeight={300}
|
||||
dismissible={false}
|
||||
>
|
||||
<Layout gap="S">
|
||||
<div class="helper">
|
||||
{#if hoverTarget.title}
|
||||
<div class="helper__name">{hoverTarget.title}</div>
|
||||
{/if}
|
||||
{#if hoverTarget.description}
|
||||
<div class="helper__description">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
{@html hoverTarget.description}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hoverTarget.example}
|
||||
<pre class="helper__example">{hoverTarget.example}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
</Popover>
|
||||
</span>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
||||
<Layout noPadding gap="S">
|
||||
{#if selectedCategory}
|
||||
<div class="sub-section-back">
|
||||
<ActionButton
|
||||
secondary
|
||||
icon={"ArrowLeft"}
|
||||
on:click={() => {
|
||||
selectedCategory = null
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</ActionButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !selectedCategory}
|
||||
<div class="search">
|
||||
<span class="search-input">
|
||||
<Input
|
||||
placeholder={"Search for bindings"}
|
||||
autocomplete="off"
|
||||
bind:value={search}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="search-input-icon"
|
||||
on:click={() => {
|
||||
search = null
|
||||
}}
|
||||
class:searching={search}
|
||||
>
|
||||
<Icon name={search ? "Close" : "Search"} />
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !selectedCategory && !search}
|
||||
<ul class="category-list">
|
||||
{#each categoryNames as categoryName}
|
||||
<li
|
||||
on:click={() => {
|
||||
selectedCategory = categoryName
|
||||
}}
|
||||
>
|
||||
<Icon name={categoryIcons[categoryName]} />
|
||||
<span class="category-name">{categoryName} </span>
|
||||
<span class="category-chevron"><Icon name="ChevronRight" /></span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if selectedCategory || search}
|
||||
{#each filteredCategories as category}
|
||||
{#if category.bindings?.length}
|
||||
<div class="sub-section">
|
||||
<div class="cat-heading">
|
||||
<Icon name={categoryIcons[category.name]} />{category.name}
|
||||
</div>
|
||||
<ul>
|
||||
{#each category.bindings as binding}
|
||||
<li
|
||||
class="binding"
|
||||
on:mouseenter={e => {
|
||||
popoverAnchor = e.target
|
||||
if (!binding.description) {
|
||||
return
|
||||
}
|
||||
hoverTarget = {
|
||||
title: binding.display?.name || binding.fieldSchema?.name,
|
||||
description: binding.description,
|
||||
}
|
||||
popover.show()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
popover.hide()
|
||||
popoverAnchor = null
|
||||
hoverTarget = null
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
on:blur={() => {}}
|
||||
on:click={() => addBinding(binding)}
|
||||
>
|
||||
<span class="binding__label">
|
||||
{#if binding.display?.name}
|
||||
{binding.display.name}
|
||||
{:else if binding.fieldSchema?.name}
|
||||
{binding.fieldSchema?.name}
|
||||
{:else}
|
||||
{binding.readableBinding}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if binding.display?.type || binding.fieldSchema?.type}
|
||||
<span class="binding__typeWrap">
|
||||
<span class="binding__type">
|
||||
{binding.display?.type || binding.fieldSchema?.type}
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if selectedCategory === "Helpers" || search}
|
||||
{#if filteredHelpers?.length}
|
||||
<div class="sub-section">
|
||||
<div class="cat-heading">Helpers</div>
|
||||
<ul class="helpers">
|
||||
{#each filteredHelpers as helper}
|
||||
<li
|
||||
class="binding"
|
||||
on:click={() => addHelper(helper, mode.name == "javascript")}
|
||||
on:mouseenter={e => {
|
||||
popoverAnchor = e.target
|
||||
if (!helper.displayText && helper.description) {
|
||||
return
|
||||
}
|
||||
hoverTarget = {
|
||||
title: helper.displayText,
|
||||
description: helper.description,
|
||||
example: getHelperExample(
|
||||
helper,
|
||||
mode.name == "javascript"
|
||||
),
|
||||
}
|
||||
popover.show()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
popover.hide()
|
||||
popoverAnchor = null
|
||||
hoverTarget = null
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
on:blur={() => {}}
|
||||
>
|
||||
<span class="binding__label">{helper.displayText}</span>
|
||||
<span class="binding__typeWrap">
|
||||
<span class="binding__type">function</span>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.search :global(input) {
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
background: none;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-top: 0px;
|
||||
border-bottom: var(--border-light);
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
margin-right: 1px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--background);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-input-icon.searching {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul.category-list {
|
||||
padding: 0px var(--spacing-l);
|
||||
padding-bottom: var(--spacing-l);
|
||||
}
|
||||
.sub-section {
|
||||
padding: var(--spacing-l);
|
||||
padding-top: 0px;
|
||||
}
|
||||
.sub-section-back {
|
||||
padding: var(--spacing-l);
|
||||
padding-top: var(--spacing-xl);
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
.cat-heading {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
ul.helpers li * {
|
||||
pointer-events: none;
|
||||
}
|
||||
ul.category-list li {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
align-items: center;
|
||||
}
|
||||
ul.category-list .category-name {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
ul.category-list .category-chevron {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
ul.category-list .category-chevron :global(div.icon),
|
||||
.cat-heading :global(div.icon) {
|
||||
display: inline-block;
|
||||
}
|
||||
li.binding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
li.binding .binding__typeWrap {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:global(.drawer-actions) {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.cat-heading {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
|
||||
.cat-heading {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--spacing-m);
|
||||
border-radius: 4px;
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
||||
border-color 130ms ease-in-out;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
li:not(:last-of-type) {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
li :global(*) {
|
||||
transition: color 130ms ease-in-out;
|
||||
}
|
||||
li:hover {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.binding__label {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.binding__type {
|
||||
font-family: var(--font-mono);
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
border-radius: var(--border-radius-s);
|
||||
padding: 2px 4px;
|
||||
margin-left: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,422 @@
|
|||
<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 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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
</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 -->
|
||||
<!-- svelte-ignore a11y-no-static-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">
|
||||
<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 size="S" name={search ? "Close" : "Search"} />
|
||||
</span>
|
||||
</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 {
|
||||
flex: 1;
|
||||
}
|
||||
.search-input-icon.searching {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul.category-list {
|
||||
padding: 0 var(--spacing-l);
|
||||
padding-bottom: var(--spacing-l);
|
||||
}
|
||||
.sub-section {
|
||||
padding: var(--spacing-l);
|
||||
padding-top: 0;
|
||||
}
|
||||
ul.helpers li * {
|
||||
pointer-events: none;
|
||||
}
|
||||
ul.category-list li {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
align-items: center;
|
||||
}
|
||||
ul.category-list :global(.spectrum-Icon) {
|
||||
margin: -4px 0;
|
||||
}
|
||||
ul.category-list .category-name {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
ul.category-list .category-chevron {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
ul.category-list .category-chevron :global(div.icon),
|
||||
.cat-heading :global(div.icon) {
|
||||
display: inline-block;
|
||||
}
|
||||
li.binding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
li.binding .binding__typeWrap {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:global(.drawer-actions) {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.cat-heading {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 600;
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.cat-heading {
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--spacing-m);
|
||||
border-radius: 4px;
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
transition: background-color 130ms ease-out, color 130ms ease-out,
|
||||
border-color 130ms ease-out;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
li:not(:last-of-type) {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
li :global(*) {
|
||||
transition: color 130ms ease-out;
|
||||
}
|
||||
li:hover {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.binding__label {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.binding__type {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
|
||||
.binding-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
padding: var(--spacing-m);
|
||||
}
|
||||
.binding-popover pre {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
.binding-popover.helper pre {
|
||||
color: var(--spectrum-global-color-blue-700);
|
||||
}
|
||||
.binding-popover pre :global(span) {
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.binding-popover :global(p) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.binding-popover.helper :global(code) {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,9 @@
|
|||
<script>
|
||||
import BindingPanel from "./BindingPanel.svelte"
|
||||
import { previewStore } from "stores/builder"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
export let bindings = []
|
||||
export let valid
|
||||
export let value = ""
|
||||
export let allowJS = false
|
||||
export let allowHelpers = true
|
||||
|
@ -20,11 +21,13 @@
|
|||
type: null,
|
||||
}))
|
||||
}
|
||||
|
||||
onMount(previewStore.requestComponentContext)
|
||||
</script>
|
||||
|
||||
<BindingPanel
|
||||
bind:valid
|
||||
bindings={enrichedBindings}
|
||||
context={$previewStore.selectedComponentContext}
|
||||
{value}
|
||||
{allowJS}
|
||||
{allowHelpers}
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
let bindingDrawer
|
||||
let valid = true
|
||||
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
$: tempValue = readableValue
|
||||
|
@ -79,20 +78,13 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<Drawer bind:this={bindingDrawer} {title} headless>
|
||||
<svelte:fragment slot="description">
|
||||
Add the objects on the left to enrich your text.
|
||||
</svelte:fragment>
|
||||
|
||||
<Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
|
||||
Save
|
||||
</Button>
|
||||
<Drawer bind:this={bindingDrawer} title={title ?? placeholder ?? "Bindings"}>
|
||||
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
|
||||
<svelte:component
|
||||
this={panel}
|
||||
slot="body"
|
||||
value={readableValue}
|
||||
close={handleClose}
|
||||
bind:valid
|
||||
on:change={event => (tempValue = event.detail)}
|
||||
{bindings}
|
||||
{allowJS}
|
||||
|
|
|
@ -13,21 +13,21 @@
|
|||
export let panel = ClientBindingPanel
|
||||
export let value = ""
|
||||
export let bindings = []
|
||||
export let title = "Bindings"
|
||||
export let title
|
||||
export let placeholder
|
||||
export let label
|
||||
export let disabled = false
|
||||
export let fillWidth
|
||||
export let allowJS = true
|
||||
export let allowHelpers = true
|
||||
export let updateOnChange = true
|
||||
export let drawerLeft
|
||||
export let key
|
||||
export let disableBindings = false
|
||||
export let forceModal = false
|
||||
export let context = null
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let bindingDrawer
|
||||
let valid = true
|
||||
let currentVal = value
|
||||
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
|
@ -88,27 +88,20 @@
|
|||
<Drawer
|
||||
on:drawerHide={onDrawerHide}
|
||||
on:drawerShow
|
||||
{fillWidth}
|
||||
bind:this={bindingDrawer}
|
||||
{title}
|
||||
left={drawerLeft}
|
||||
headless
|
||||
title={title ?? placeholder ?? "Bindings"}
|
||||
{forceModal}
|
||||
>
|
||||
<svelte:fragment slot="description">
|
||||
Add the objects on the left to enrich your text.
|
||||
</svelte:fragment>
|
||||
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
|
||||
Save
|
||||
</Button>
|
||||
<Button cta slot="buttons" on:click={saveBinding}>Save</Button>
|
||||
<svelte:component
|
||||
this={panel}
|
||||
slot="body"
|
||||
bind:valid
|
||||
value={readableValue}
|
||||
on:change={event => (tempValue = event.detail)}
|
||||
{bindings}
|
||||
{allowJS}
|
||||
{allowHelpers}
|
||||
{context}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@
|
|||
export let placeholder
|
||||
export let label
|
||||
export let disabled = false
|
||||
export let fillWidth
|
||||
export let allowJS = true
|
||||
export let allowHelpers = true
|
||||
export let updateOnChange = true
|
||||
|
@ -26,7 +25,6 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
let bindingDrawer
|
||||
let valid = true
|
||||
let currentVal = value
|
||||
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
|
@ -173,22 +171,14 @@
|
|||
<Drawer
|
||||
on:drawerHide
|
||||
on:drawerShow
|
||||
{fillWidth}
|
||||
bind:this={bindingDrawer}
|
||||
{title}
|
||||
title={title ?? placeholder ?? "Bindings"}
|
||||
left={drawerLeft}
|
||||
headless
|
||||
>
|
||||
<svelte:fragment slot="description">
|
||||
Add the objects on the left to enrich your text.
|
||||
</svelte:fragment>
|
||||
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
|
||||
Save
|
||||
</Button>
|
||||
<Button cta slot="buttons" on:click={saveBinding}>Save</Button>
|
||||
<svelte:component
|
||||
this={panel}
|
||||
slot="body"
|
||||
bind:valid
|
||||
value={readableValue}
|
||||
on:change={event => (tempValue = event.detail)}
|
||||
{bindings}
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
<script>
|
||||
import formatHighlight from "json-format-highlight"
|
||||
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
|
||||
import { copyToClipboard } from "@budibase/bbui/helpers"
|
||||
import { fade } from "svelte/transition"
|
||||
|
||||
export let expressionResult
|
||||
export let evaluating = false
|
||||
export let expression = null
|
||||
|
||||
$: error = expressionResult === "Error while executing JS"
|
||||
$: empty = expression == null || expression?.trim() === ""
|
||||
$: success = !error && !empty
|
||||
$: highlightedResult = highlight(expressionResult)
|
||||
|
||||
const highlight = json => {
|
||||
if (json == null) {
|
||||
return ""
|
||||
}
|
||||
// Attempt to parse and then stringify, in case this is valid JSON
|
||||
try {
|
||||
json = JSON.stringify(JSON.parse(json), null, 2)
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return formatHighlight(json, {
|
||||
keyColor: "#e06c75",
|
||||
numberColor: "#e5c07b",
|
||||
stringColor: "#98c379",
|
||||
trueColor: "#d19a66",
|
||||
falseColor: "#d19a66",
|
||||
nullColor: "#c678dd",
|
||||
})
|
||||
}
|
||||
|
||||
const copy = () => {
|
||||
let clipboardVal = expressionResult
|
||||
if (typeof clipboardVal === "object") {
|
||||
clipboardVal = JSON.stringify(clipboardVal, null, 2)
|
||||
}
|
||||
copyToClipboard(clipboardVal)
|
||||
notifications.success("Value copied to clipboard")
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="evaluation-side-panel">
|
||||
<div class="header" class:success class:error>
|
||||
<div class="header-content">
|
||||
{#if error}
|
||||
<Icon name="Alert" color="var(--spectrum-global-color-red-600)" />
|
||||
<div>Error</div>
|
||||
{#if evaluating}
|
||||
<div transition:fade|local={{ duration: 130 }}>
|
||||
<ProgressCircle size="S" />
|
||||
</div>
|
||||
{/if}
|
||||
<span />
|
||||
<Icon name="Copy" 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" hoverable on:click={copy} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if empty}
|
||||
Your expression will be evaluated here
|
||||
{:else}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
{@html highlightedResult}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.evaluation-side-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
border-left: var(--border-light);
|
||||
}
|
||||
.header {
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
border-bottom: var(--border-light);
|
||||
}
|
||||
.header-content {
|
||||
height: var(--spectrum-alias-item-height-m);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.header-content span {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.header.error::before {
|
||||
content: "";
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
opacity: 10%;
|
||||
}
|
||||
.header.error::before {
|
||||
background: var(--spectrum-global-color-red-400);
|
||||
}
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,115 +1,12 @@
|
|||
<script>
|
||||
import { Icon, Input, Modal, Body, ModalContent } from "@budibase/bbui"
|
||||
import {
|
||||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "dataBinding"
|
||||
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { isJSBinding } from "@budibase/string-templates"
|
||||
|
||||
export let panel = ServerBindingPanel
|
||||
export let value = ""
|
||||
export let bindings = []
|
||||
export let title = "Bindings"
|
||||
export let placeholder
|
||||
export let label
|
||||
export let allowJS = false
|
||||
export let updateOnChange = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let bindingModal
|
||||
let valid = true
|
||||
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
$: tempValue = readableValue
|
||||
$: isJS = isJSBinding(value)
|
||||
|
||||
const saveBinding = () => {
|
||||
onChange(tempValue)
|
||||
bindingModal.hide()
|
||||
}
|
||||
|
||||
const onChange = input => {
|
||||
dispatch("change", readableToRuntimeBinding(bindings, input))
|
||||
}
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="control">
|
||||
<Input
|
||||
{label}
|
||||
readonly={isJS}
|
||||
value={isJS ? "(JavaScript function)" : readableValue}
|
||||
on:change={event => onChange(event.detail)}
|
||||
{placeholder}
|
||||
{updateOnChange}
|
||||
/>
|
||||
<div class="icon" on:click={bindingModal.show}>
|
||||
<Icon size="S" name="FlashOn" />
|
||||
</div>
|
||||
</div>
|
||||
<Modal bind:this={bindingModal}>
|
||||
<ModalContent {title} onConfirm={saveBinding} disabled={!valid} size="XL">
|
||||
<Body extraSmall grey>
|
||||
Add the objects on the left to enrich your text.
|
||||
</Body>
|
||||
<div class="panel-wrapper">
|
||||
<svelte:component
|
||||
this={panel}
|
||||
serverSide
|
||||
value={readableValue}
|
||||
bind:valid
|
||||
on:change={e => (tempValue = e.detail)}
|
||||
{bindings}
|
||||
{allowJS}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.control {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
position: absolute;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
border-left: 1px solid var(--spectrum-alias-border-color);
|
||||
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
|
||||
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
|
||||
width: 31px;
|
||||
color: var(--spectrum-alias-text-color);
|
||||
background-color: var(--spectrum-global-color-gray-75);
|
||||
transition: background-color
|
||||
var(--spectrum-global-animation-duration-100, 130ms),
|
||||
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
|
||||
border-color var(--spectrum-global-animation-duration-100, 130ms);
|
||||
height: calc(var(--spectrum-alias-item-height-m) - 2px);
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
cursor: pointer;
|
||||
color: var(--spectrum-alias-text-color-hover);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
border-color: var(--spectrum-alias-border-color-hover);
|
||||
}
|
||||
|
||||
.panel-wrapper {
|
||||
border: var(--border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.control :global(.spectrum-Textfield-input) {
|
||||
padding-right: 40px;
|
||||
}
|
||||
</style>
|
||||
<DrawerBindableInput
|
||||
{...$$props}
|
||||
forceModal
|
||||
on:change
|
||||
on:blur
|
||||
on:drawerHide
|
||||
on:drawerShow
|
||||
/>
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
import BindingPanel from "./BindingPanel.svelte"
|
||||
|
||||
export let bindings = []
|
||||
export let valid
|
||||
export let value = ""
|
||||
export let allowJS = false
|
||||
export let context = null
|
||||
|
||||
$: enrichedBindings = enrichBindings(bindings)
|
||||
|
||||
|
@ -19,9 +19,9 @@
|
|||
</script>
|
||||
|
||||
<BindingPanel
|
||||
bind:valid
|
||||
bindings={enrichedBindings}
|
||||
{value}
|
||||
{allowJS}
|
||||
{context}
|
||||
on:change
|
||||
/>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
export let bindings
|
||||
export let nested
|
||||
export let componentInstance
|
||||
export let title = "Actions"
|
||||
|
||||
let drawer
|
||||
let tmpValue
|
||||
|
@ -37,7 +38,7 @@
|
|||
<ActionButton on:click={openDrawer}>{actionText}</ActionButton>
|
||||
</div>
|
||||
|
||||
<Drawer bind:this={drawer} title={"Actions"} on:drawerHide on:drawerShow>
|
||||
<Drawer bind:this={drawer} {title} on:drawerHide on:drawerShow>
|
||||
<svelte:fragment slot="description">
|
||||
Define what actions to run.
|
||||
</svelte:fragment>
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
<Label small>Row IDs</Label>
|
||||
<DrawerBindableInput
|
||||
{bindings}
|
||||
title="Rows to delete"
|
||||
title="Row IDs to delete"
|
||||
value={parameters.rowId}
|
||||
on:change={value => (parameters.rowId = value.detail)}
|
||||
/>
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
<Label small>Row ID</Label>
|
||||
<DrawerBindableInput
|
||||
{bindings}
|
||||
title="Row ID to Fetch"
|
||||
title="Row ID"
|
||||
value={parameters.rowId}
|
||||
on:change={value => (parameters.rowId = value.detail)}
|
||||
/>
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
{/if}
|
||||
<Label small>{valueLabel}</Label>
|
||||
<DrawerBindableInput
|
||||
title={`Value for "${field[0]}"`}
|
||||
title={field[0]}
|
||||
value={field[1]}
|
||||
{bindings}
|
||||
on:change={event => updateFieldValue(idx, event.detail)}
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
<Select bind:value={parameters.type} options={types} placeholder={null} />
|
||||
<Label>Message</Label>
|
||||
<DrawerBindableInput
|
||||
title="Message"
|
||||
{bindings}
|
||||
value={parameters.message}
|
||||
on:change={e => (parameters.message = e.detail)}
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
{#if parameters.type === "set"}
|
||||
<Label small>Value</Label>
|
||||
<DrawerBindableInput
|
||||
title="Field value"
|
||||
{bindings}
|
||||
value={parameters.value}
|
||||
on:change={e => (parameters.value = e.detail)}
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
{#if parameters.type === "set"}
|
||||
<Label small>Value</Label>
|
||||
<DrawerBindableInput
|
||||
title="State value"
|
||||
{bindings}
|
||||
value={parameters.value}
|
||||
on:change={e => (parameters.value = e.detail)}
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
export let key
|
||||
export let nested
|
||||
export let max
|
||||
export let context
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
placeholder="Default"
|
||||
/>
|
||||
<DrawerBindableInput
|
||||
title="Value"
|
||||
label="Value"
|
||||
value={column.template}
|
||||
on:change={e => (column.template = e.detail)}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
</script>
|
||||
|
||||
<Icon name="Settings" hoverable size="S" on:click={open} />
|
||||
<Drawer bind:this={drawer} title="Table Columns">
|
||||
<Drawer bind:this={drawer} title={column.name}>
|
||||
<svelte:fragment slot="description">
|
||||
"{column.name}" column settings
|
||||
</svelte:fragment>
|
||||
|
|
|
@ -48,7 +48,6 @@
|
|||
let drawer
|
||||
let tmpQueryParams
|
||||
let tmpCustomData
|
||||
let customDataValid = true
|
||||
let modal
|
||||
|
||||
$: text = value?.label ?? "Choose an option"
|
||||
|
@ -267,14 +266,11 @@
|
|||
<Drawer title="Custom data" bind:this={drawer}>
|
||||
<div slot="buttons" style="display:contents">
|
||||
<Button primary on:click={promptForCSV}>Load CSV</Button>
|
||||
<Button cta on:click={saveCustomData} disabled={!customDataValid}>
|
||||
Save
|
||||
</Button>
|
||||
<Button cta on:click={saveCustomData}>Save</Button>
|
||||
</div>
|
||||
<div slot="description">Provide a JSON array to use as data</div>
|
||||
<ClientBindingPanel
|
||||
slot="body"
|
||||
bind:valid={customDataValid}
|
||||
value={tmpCustomData}
|
||||
on:change={event => (tmpCustomData = event.detail)}
|
||||
{bindings}
|
||||
|
|
|
@ -26,7 +26,6 @@
|
|||
export let bindings = []
|
||||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
export let fillWidth = false
|
||||
export let datasource
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -260,13 +259,12 @@
|
|||
{#if filter.field && filter.valueType === "Binding"}
|
||||
<DrawerBindableInput
|
||||
disabled={filter.noValue}
|
||||
title={`Value for "${filter.field}"`}
|
||||
title={filter.field}
|
||||
value={filter.value}
|
||||
placeholder="Value"
|
||||
{panel}
|
||||
{bindings}
|
||||
on:change={event => (filter.value = event.detail)}
|
||||
{fillWidth}
|
||||
/>
|
||||
{:else if ["string", "longform", "number", "bigint", "formula"].includes(filter.type)}
|
||||
<Input disabled={filter.noValue} bind:value={filter.value} />
|
||||
|
|
|
@ -105,6 +105,7 @@
|
|||
onChange={handleChange}
|
||||
bindings={allBindings}
|
||||
name={key}
|
||||
title={label}
|
||||
{nested}
|
||||
{key}
|
||||
{type}
|
||||
|
|
|
@ -143,7 +143,6 @@
|
|||
value={field.value}
|
||||
allowJS={false}
|
||||
{allowHelpers}
|
||||
fillWidth={true}
|
||||
drawerLeft={bindingDrawerLeft}
|
||||
/>
|
||||
{:else}
|
||||
|
|
|
@ -71,6 +71,10 @@
|
|||
await auth.getSelf()
|
||||
await admin.init()
|
||||
|
||||
if ($admin.maintenance.length > 0) {
|
||||
$redirect("./maintenance")
|
||||
}
|
||||
|
||||
if ($auth.user) {
|
||||
await licensing.init()
|
||||
}
|
||||
|
|
|
@ -188,7 +188,7 @@
|
|||
{/if}
|
||||
|
||||
<svelte:window on:keydown={handleKeyDown} />
|
||||
<Modal bind:this={commandPaletteModal}>
|
||||
<Modal bind:this={commandPaletteModal} zIndex={999999}>
|
||||
<CommandPalette />
|
||||
</Modal>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
</script>
|
||||
|
||||
<div class="app-panel">
|
||||
<div class="drawer-container" />
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<UndoRedoControl store={screenStore.history} />
|
||||
|
@ -32,7 +33,17 @@
|
|||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
padding: 9px var(--spacing-m);
|
||||
padding: 9px 10px 12px 10px;
|
||||
position: relative;
|
||||
transition: width 360ms ease-out;
|
||||
}
|
||||
.drawer-container {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
|
|
@ -196,6 +196,16 @@
|
|||
} else if (type === "add-parent-component") {
|
||||
const { componentId, parentType } = data
|
||||
await componentStore.addParent(componentId, parentType)
|
||||
} else if (type === "provide-context") {
|
||||
let context = data?.context
|
||||
if (context) {
|
||||
try {
|
||||
context = JSON.parse(context)
|
||||
} catch (error) {
|
||||
context = null
|
||||
}
|
||||
}
|
||||
previewStore.setSelectedComponentContext(context)
|
||||
} else {
|
||||
console.warn(`Client sent unknown event type: ${type}`)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
<script>
|
||||
import { MaintenanceType } from "@budibase/types"
|
||||
import { Heading, Body, Button, Layout } from "@budibase/bbui"
|
||||
import { admin } from "stores/portal"
|
||||
import BudibaseLogo from "../portal/_components/BudibaseLogo.svelte"
|
||||
|
||||
$: {
|
||||
if ($admin.maintenance.length === 0) {
|
||||
window.location = "/builder"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="main">
|
||||
<div class="content">
|
||||
<div class="hero">
|
||||
<BudibaseLogo />
|
||||
</div>
|
||||
<div class="inner-content">
|
||||
{#each $admin.maintenance as maintenance}
|
||||
{#if maintenance.type === MaintenanceType.SQS_MISSING}
|
||||
<Layout>
|
||||
<Heading>Please upgrade your Budibase installation</Heading>
|
||||
<Body>
|
||||
We've detected that the version of Budibase you're using depends
|
||||
on a more recent version of the CouchDB database than what you
|
||||
have installed.
|
||||
</Body>
|
||||
<Body>
|
||||
To resolve this, you can either rollback to a previous version of
|
||||
Budibase, or follow the migration guide to update to a later
|
||||
version of CouchDB.
|
||||
</Body>
|
||||
</Layout>
|
||||
<Button
|
||||
on:click={() => (window.location = "https://docs.budibase.com")}
|
||||
>Migration guide</Button
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
max-width: 700px;
|
||||
margin: auto;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-l);
|
||||
}
|
||||
.hero {
|
||||
margin: var(--spacing-l);
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.inner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.main {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
import Logo from "assets/bb-emblem.svg"
|
||||
import { goto } from "@roxi/routify"
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<img src={Logo} alt="Budibase Logo" on:click={() => $goto("./apps")} />
|
||||
|
||||
<style>
|
||||
img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
</style>
|
|
@ -4,6 +4,7 @@ const INITIAL_PREVIEW_STATE = {
|
|||
previewDevice: "desktop",
|
||||
previewEventHandler: null,
|
||||
showPreview: false,
|
||||
selectedComponentContext: null,
|
||||
}
|
||||
|
||||
export const createPreviewStore = () => {
|
||||
|
@ -52,6 +53,17 @@ export const createPreviewStore = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const setSelectedComponentContext = context => {
|
||||
store.update(state => {
|
||||
state.selectedComponentContext = context
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
const requestComponentContext = () => {
|
||||
sendEvent("request-context")
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: store.subscribe,
|
||||
setDevice,
|
||||
|
@ -60,6 +72,8 @@ export const createPreviewStore = () => {
|
|||
startDrag,
|
||||
stopDrag,
|
||||
showPreview,
|
||||
setSelectedComponentContext,
|
||||
requestComponentContext,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
|
|||
adminUser: { checked: false },
|
||||
sso: { checked: false },
|
||||
},
|
||||
maintenance: [],
|
||||
offlineMode: false,
|
||||
}
|
||||
|
||||
|
@ -48,6 +49,7 @@ export function createAdminStore() {
|
|||
store.isDev = environment.isDev
|
||||
store.baseUrl = environment.baseUrl
|
||||
store.offlineMode = environment.offlineMode
|
||||
store.maintenance = environment.maintenance
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
appStore,
|
||||
devToolsStore,
|
||||
devToolsEnabled,
|
||||
environmentStore,
|
||||
} from "stores"
|
||||
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
|
||||
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
|
||||
|
@ -36,6 +37,7 @@
|
|||
import DevToolsHeader from "components/devtools/DevToolsHeader.svelte"
|
||||
import DevTools from "components/devtools/DevTools.svelte"
|
||||
import FreeFooter from "components/FreeFooter.svelte"
|
||||
import MaintenanceScreen from "components/MaintenanceScreen.svelte"
|
||||
import licensing from "../licensing"
|
||||
|
||||
// Provide contexts
|
||||
|
@ -111,122 +113,128 @@
|
|||
class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}"
|
||||
class:builder={$builderStore.inBuilder}
|
||||
>
|
||||
<DeviceBindingsProvider>
|
||||
<UserBindingsProvider>
|
||||
<StateBindingsProvider>
|
||||
<RowSelectionProvider>
|
||||
<QueryParamsProvider>
|
||||
<!-- Settings bar can be rendered outside of device preview -->
|
||||
<!-- Key block needs to be outside the if statement or it breaks -->
|
||||
{#key $builderStore.selectedComponentId}
|
||||
{#if $builderStore.inBuilder}
|
||||
<SettingsBar />
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<!-- Clip boundary for selection indicators -->
|
||||
<div
|
||||
id="clip-root"
|
||||
class:preview={$builderStore.inBuilder}
|
||||
class:tablet-preview={$builderStore.previewDevice === "tablet"}
|
||||
class:mobile-preview={$builderStore.previewDevice === "mobile"}
|
||||
>
|
||||
<!-- Actual app -->
|
||||
<div id="app-root">
|
||||
{#if showDevTools}
|
||||
<DevToolsHeader />
|
||||
{#if $environmentStore.maintenance.length > 0}
|
||||
<MaintenanceScreen maintenanceList={$environmentStore.maintenance} />
|
||||
{:else}
|
||||
<DeviceBindingsProvider>
|
||||
<UserBindingsProvider>
|
||||
<StateBindingsProvider>
|
||||
<RowSelectionProvider>
|
||||
<QueryParamsProvider>
|
||||
<!-- Settings bar can be rendered outside of device preview -->
|
||||
<!-- Key block needs to be outside the if statement or it breaks -->
|
||||
{#key $builderStore.selectedComponentId}
|
||||
{#if $builderStore.inBuilder}
|
||||
<SettingsBar />
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<div id="app-body">
|
||||
{#if permissionError}
|
||||
<div class="error">
|
||||
<Layout justifyItems="center" gap="S">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html ErrorSVG}
|
||||
<Heading size="L">
|
||||
You don't have permission to use this app
|
||||
</Heading>
|
||||
<Body size="S">
|
||||
Ask your administrator to grant you access
|
||||
</Body>
|
||||
</Layout>
|
||||
</div>
|
||||
{:else if !$screenStore.activeLayout}
|
||||
<div class="error">
|
||||
<Layout justifyItems="center" gap="S">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html ErrorSVG}
|
||||
<Heading size="L">
|
||||
Something went wrong rendering your app
|
||||
</Heading>
|
||||
<Body size="S">
|
||||
Get in touch with support if this issue persists
|
||||
</Body>
|
||||
</Layout>
|
||||
</div>
|
||||
{:else if embedNoScreens}
|
||||
<div class="error">
|
||||
<Layout justifyItems="center" gap="S">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html ErrorSVG}
|
||||
<Heading size="L">
|
||||
This Budibase app is not publicly accessible
|
||||
</Heading>
|
||||
</Layout>
|
||||
</div>
|
||||
{:else}
|
||||
<CustomThemeWrapper>
|
||||
{#key $screenStore.activeLayout._id}
|
||||
<Component
|
||||
isLayout
|
||||
instance={$screenStore.activeLayout.props}
|
||||
/>
|
||||
{/key}
|
||||
<!-- Clip boundary for selection indicators -->
|
||||
<div
|
||||
id="clip-root"
|
||||
class:preview={$builderStore.inBuilder}
|
||||
class:tablet-preview={$builderStore.previewDevice ===
|
||||
"tablet"}
|
||||
class:mobile-preview={$builderStore.previewDevice ===
|
||||
"mobile"}
|
||||
>
|
||||
<!-- Actual app -->
|
||||
<div id="app-root">
|
||||
{#if showDevTools}
|
||||
<DevToolsHeader />
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
<div id="app-body">
|
||||
{#if permissionError}
|
||||
<div class="error">
|
||||
<Layout justifyItems="center" gap="S">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html ErrorSVG}
|
||||
<Heading size="L">
|
||||
You don't have permission to use this app
|
||||
</Heading>
|
||||
<Body size="S">
|
||||
Ask your administrator to grant you access
|
||||
</Body>
|
||||
</Layout>
|
||||
</div>
|
||||
{:else if !$screenStore.activeLayout}
|
||||
<div class="error">
|
||||
<Layout justifyItems="center" gap="S">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html ErrorSVG}
|
||||
<Heading size="L">
|
||||
Something went wrong rendering your app
|
||||
</Heading>
|
||||
<Body size="S">
|
||||
Get in touch with support if this issue persists
|
||||
</Body>
|
||||
</Layout>
|
||||
</div>
|
||||
{:else if embedNoScreens}
|
||||
<div class="error">
|
||||
<Layout justifyItems="center" gap="S">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html ErrorSVG}
|
||||
<Heading size="L">
|
||||
This Budibase app is not publicly accessible
|
||||
</Heading>
|
||||
</Layout>
|
||||
</div>
|
||||
{:else}
|
||||
<CustomThemeWrapper>
|
||||
{#key $screenStore.activeLayout._id}
|
||||
<Component
|
||||
isLayout
|
||||
instance={$screenStore.activeLayout.props}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<!--
|
||||
Flatpickr needs to be inside the theme wrapper.
|
||||
It also needs its own container because otherwise it hijacks
|
||||
key events on the whole page. It is painful to work with.
|
||||
-->
|
||||
<div id="flatpickr-root" />
|
||||
<div id="flatpickr-root" />
|
||||
|
||||
<!-- Modal container to ensure they sit on top -->
|
||||
<div class="modal-container" />
|
||||
<!-- Modal container to ensure they sit on top -->
|
||||
<div class="modal-container" />
|
||||
|
||||
<!-- Layers on top of app -->
|
||||
<NotificationDisplay />
|
||||
<ConfirmationDisplay />
|
||||
<PeekScreenDisplay />
|
||||
</CustomThemeWrapper>
|
||||
{/if}
|
||||
<!-- Layers on top of app -->
|
||||
<NotificationDisplay />
|
||||
<ConfirmationDisplay />
|
||||
<PeekScreenDisplay />
|
||||
</CustomThemeWrapper>
|
||||
{/if}
|
||||
|
||||
{#if showDevTools}
|
||||
<DevTools />
|
||||
{#if showDevTools}
|
||||
<DevTools />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
|
||||
<FreeFooter />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
|
||||
<FreeFooter />
|
||||
<!-- Preview and dev tools utilities -->
|
||||
{#if $appStore.isDevApp}
|
||||
<SelectionIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
|
||||
<HoverIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder}
|
||||
<DNDHandler />
|
||||
<GridDNDHandler />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview and dev tools utilities -->
|
||||
{#if $appStore.isDevApp}
|
||||
<SelectionIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
|
||||
<HoverIndicator />
|
||||
{/if}
|
||||
{#if $builderStore.inBuilder}
|
||||
<DNDHandler />
|
||||
<GridDNDHandler />
|
||||
{/if}
|
||||
</div>
|
||||
</QueryParamsProvider>
|
||||
</RowSelectionProvider>
|
||||
</StateBindingsProvider>
|
||||
</UserBindingsProvider>
|
||||
</DeviceBindingsProvider>
|
||||
</QueryParamsProvider>
|
||||
</RowSelectionProvider>
|
||||
</StateBindingsProvider>
|
||||
</UserBindingsProvider>
|
||||
</DeviceBindingsProvider>
|
||||
{/if}
|
||||
</div>
|
||||
<KeyboardManager />
|
||||
{/if}
|
||||
|
|
|
@ -575,6 +575,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
const getDataContext = () => {
|
||||
const normalContext = get(context)
|
||||
const additionalContext = ref?.getAdditionalDataContext?.()
|
||||
return {
|
||||
...normalContext,
|
||||
...additionalContext,
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Register this component instance for external access
|
||||
if ($appStore.isDevApp) {
|
||||
|
@ -583,7 +592,7 @@
|
|||
component: instance._component,
|
||||
getSettings: () => cachedSettings,
|
||||
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
|
||||
getDataContext: () => get(context),
|
||||
getDataContext,
|
||||
reload: () => initialise(instance, true),
|
||||
setEphemeralStyles: styles => (ephemeralStyles = styles),
|
||||
state: store,
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
<!--
|
||||
This is the public facing maintenance screen. It is displayed when there is
|
||||
required maintenance to be done on the Budibase installation. We only use this
|
||||
if we detect that the Budibase installation is in a state where the vast
|
||||
majority of apps would not function correctly.
|
||||
|
||||
The builder-facing maintenance screen is in
|
||||
packages/builder/src/pages/builder/maintenance/index.svelte, and tends to
|
||||
contain more detailed information and actions for the installation owner to
|
||||
take.
|
||||
-->
|
||||
<script>
|
||||
import { MaintenanceType } from "@budibase/types"
|
||||
import { Heading, Body, Layout } from "@budibase/bbui"
|
||||
|
||||
export let maintenanceList
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
{#each maintenanceList as maintenance}
|
||||
{#if maintenance.type === MaintenanceType.SQS_MISSING}
|
||||
<Layout>
|
||||
<Heading>Budibase installation requires maintenance</Heading>
|
||||
<Body>
|
||||
The administrator of this Budibase installation needs to take actions
|
||||
to update components that are out of date. Please contact them and
|
||||
show them this warning. More information will be available when they
|
||||
log into their account.
|
||||
</Body>
|
||||
</Layout>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
max-width: 700px;
|
||||
margin: auto;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-l);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -30,6 +30,7 @@
|
|||
ActionTypes,
|
||||
createContextStore,
|
||||
Provider,
|
||||
generateGoldenSample,
|
||||
} = getContext("sdk")
|
||||
|
||||
let grid
|
||||
|
@ -48,6 +49,19 @@
|
|||
},
|
||||
]
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
const rows = get(grid?.getContext()?.rows)
|
||||
const goldenRow = generateGoldenSample(rows)
|
||||
const id = get(component).id
|
||||
return {
|
||||
[id]: goldenRow,
|
||||
eventContext: {
|
||||
row: goldenRow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Parses columns to fix older formats
|
||||
const getParsedColumns = columns => {
|
||||
// If the first element has an active key all elements should be in the new format
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import BlockComponent from "components/BlockComponent.svelte"
|
||||
import { makePropSafe as safe } from "@budibase/string-templates"
|
||||
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
export let title
|
||||
export let dataSource
|
||||
|
@ -31,7 +32,9 @@
|
|||
export let linkColumn
|
||||
export let noRowsMessage
|
||||
|
||||
const { fetchDatasourceSchema } = getContext("sdk")
|
||||
const context = getContext("context")
|
||||
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
|
||||
const component = getContext("component")
|
||||
|
||||
let formId
|
||||
let dataProviderId
|
||||
|
@ -62,6 +65,16 @@
|
|||
},
|
||||
]
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
const rows = get(context)[dataProviderId]?.rows || []
|
||||
const goldenRow = generateGoldenSample(rows)
|
||||
const id = get(component).id
|
||||
return {
|
||||
[`${id}-repeater`]: goldenRow,
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a full details page URL for the card title
|
||||
const buildFullCardUrl = (link, url, repeaterId, linkColumn) => {
|
||||
if (!link || !url || !repeaterId) {
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import { builderStore } from "stores"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
import FormBlockWrapper from "./form/FormBlockWrapper.svelte"
|
||||
import { writable } from "svelte/store"
|
||||
import { get, writable } from "svelte/store"
|
||||
|
||||
export let actionType
|
||||
export let rowId
|
||||
|
@ -15,7 +15,7 @@
|
|||
export let buttonPosition = "bottom"
|
||||
export let size
|
||||
|
||||
const { fetchDatasourceSchema } = getContext("sdk")
|
||||
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
|
||||
const component = getContext("component")
|
||||
const context = getContext("context")
|
||||
|
||||
|
@ -45,6 +45,16 @@
|
|||
$: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep)
|
||||
$: updateCurrentStep(enrichedSteps, $builderStore, $component)
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
const id = get(component).id
|
||||
const rows = get(context)[`${id}-provider`]?.rows || []
|
||||
const goldenRow = generateGoldenSample(rows)
|
||||
return {
|
||||
[`${id}-repeater`]: goldenRow,
|
||||
}
|
||||
}
|
||||
|
||||
const updateCurrentStep = (steps, builderStore, component) => {
|
||||
const { componentId, step } = builderStore.metadata || {}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import Placeholder from "components/app/Placeholder.svelte"
|
||||
import { getContext } from "svelte"
|
||||
import { makePropSafe as safe } from "@budibase/string-templates"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
export let dataSource
|
||||
export let filter
|
||||
|
@ -18,8 +19,20 @@
|
|||
export let gap
|
||||
|
||||
const component = getContext("component")
|
||||
const context = getContext("context")
|
||||
const { generateGoldenSample } = getContext("sdk")
|
||||
|
||||
let providerId
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
const rows = get(context)[providerId]?.rows || []
|
||||
const goldenRow = generateGoldenSample(rows)
|
||||
const id = get(component).id
|
||||
return {
|
||||
[`${id}-repeater`]: goldenRow,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Block>
|
||||
|
|
|
@ -3,25 +3,35 @@
|
|||
import BlockComponent from "components/BlockComponent.svelte"
|
||||
import { makePropSafe as safe } from "@budibase/string-templates"
|
||||
import { generate } from "shortid"
|
||||
import { get } from "svelte/store"
|
||||
import { getContext } from "svelte"
|
||||
|
||||
export let dataSource
|
||||
export let height
|
||||
|
||||
export let cardTitle
|
||||
export let cardSubtitle
|
||||
export let cardDescription
|
||||
export let cardImageURL
|
||||
export let cardSearchField
|
||||
|
||||
export let detailFields
|
||||
export let detailTitle
|
||||
|
||||
export let noRowsMessage
|
||||
|
||||
const stateKey = generate()
|
||||
const context = getContext("context")
|
||||
const { generateGoldenSample } = getContext("sdk")
|
||||
|
||||
let listDataProviderId
|
||||
let listRepeaterId
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
const rows = get(context)[listDataProviderId]?.rows || []
|
||||
const goldenRow = generateGoldenSample(rows)
|
||||
return {
|
||||
[listRepeaterId]: goldenRow,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Block>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import InnerFormBlock from "./InnerFormBlock.svelte"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
import FormBlockWrapper from "./FormBlockWrapper.svelte"
|
||||
import { get } from "svelte/store"
|
||||
|
||||
export let actionType
|
||||
export let dataSource
|
||||
|
@ -11,7 +12,6 @@
|
|||
export let fields
|
||||
export let buttons
|
||||
export let buttonPosition
|
||||
|
||||
export let title
|
||||
export let description
|
||||
export let rowId
|
||||
|
@ -25,8 +25,56 @@
|
|||
export let saveButtonLabel
|
||||
export let deleteButtonLabel
|
||||
|
||||
const { fetchDatasourceSchema } = getContext("sdk")
|
||||
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
|
||||
const component = getContext("component")
|
||||
const context = getContext("context")
|
||||
|
||||
let schema
|
||||
|
||||
$: formattedFields = convertOldFieldFormat(fields)
|
||||
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
|
||||
$: fetchSchema(dataSource)
|
||||
// We could simply spread $$props into the inner form and append our
|
||||
// additions, but that would create svelte warnings about unused props and
|
||||
// make maintenance in future more confusing as we typically always have a
|
||||
// proper mapping of schema settings to component exports, without having to
|
||||
// search multiple files
|
||||
$: innerProps = {
|
||||
dataSource,
|
||||
actionUrl,
|
||||
actionType,
|
||||
size,
|
||||
disabled,
|
||||
fields: fieldsOrDefault,
|
||||
title,
|
||||
description,
|
||||
schema,
|
||||
notificationOverride,
|
||||
buttons:
|
||||
buttons ||
|
||||
Utils.buildFormBlockButtonConfig({
|
||||
_id: $component.id,
|
||||
showDeleteButton,
|
||||
showSaveButton,
|
||||
saveButtonLabel,
|
||||
deleteButtonLabel,
|
||||
notificationOverride,
|
||||
actionType,
|
||||
actionUrl,
|
||||
dataSource,
|
||||
}),
|
||||
buttonPosition: buttons ? buttonPosition : "top",
|
||||
}
|
||||
|
||||
// Provide additional data context for live binding eval
|
||||
export const getAdditionalDataContext = () => {
|
||||
const id = get(component).id
|
||||
const rows = get(context)[`${id}-provider`]?.rows || []
|
||||
const goldenRow = generateGoldenSample(rows)
|
||||
return {
|
||||
[`${id}-repeater`]: goldenRow,
|
||||
}
|
||||
}
|
||||
|
||||
const convertOldFieldFormat = fields => {
|
||||
if (!fields) {
|
||||
|
@ -68,42 +116,6 @@
|
|||
return [...fields, ...defaultFields].filter(field => field.active)
|
||||
}
|
||||
|
||||
let schema
|
||||
|
||||
$: formattedFields = convertOldFieldFormat(fields)
|
||||
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
|
||||
$: fetchSchema(dataSource)
|
||||
// We could simply spread $$props into the inner form and append our
|
||||
// additions, but that would create svelte warnings about unused props and
|
||||
// make maintenance in future more confusing as we typically always have a
|
||||
// proper mapping of schema settings to component exports, without having to
|
||||
// search multiple files
|
||||
$: innerProps = {
|
||||
dataSource,
|
||||
actionUrl,
|
||||
actionType,
|
||||
size,
|
||||
disabled,
|
||||
fields: fieldsOrDefault,
|
||||
title,
|
||||
description,
|
||||
schema,
|
||||
notificationOverride,
|
||||
buttons:
|
||||
buttons ||
|
||||
Utils.buildFormBlockButtonConfig({
|
||||
_id: $component.id,
|
||||
showDeleteButton,
|
||||
showSaveButton,
|
||||
saveButtonLabel,
|
||||
deleteButtonLabel,
|
||||
notificationOverride,
|
||||
actionType,
|
||||
actionUrl,
|
||||
dataSource,
|
||||
}),
|
||||
buttonPosition: buttons ? buttonPosition : "top",
|
||||
}
|
||||
const fetchSchema = async () => {
|
||||
schema = (await fetchDatasourceSchema(dataSource)) || {}
|
||||
}
|
||||
|
|
|
@ -58,13 +58,13 @@
|
|||
}
|
||||
let clonedSchema = {}
|
||||
if (!allowedFields?.length) {
|
||||
clonedSchema = schema
|
||||
clonedSchema = schema || {}
|
||||
} else {
|
||||
allowedFields?.forEach(field => {
|
||||
if (schema[field.name]) {
|
||||
if (schema?.[field.name]) {
|
||||
clonedSchema[field.name] = schema[field.name]
|
||||
clonedSchema[field.name].displayName = field.displayName
|
||||
} else if (schema[field]) {
|
||||
} else if (schema?.[field]) {
|
||||
clonedSchema[field] = schema[field]
|
||||
}
|
||||
})
|
||||
|
|
|
@ -84,6 +84,18 @@ const loadBudibase = async () => {
|
|||
} else {
|
||||
dndStore.actions.reset()
|
||||
}
|
||||
} else if (type === "request-context") {
|
||||
const { selectedComponentInstance } = get(componentStore)
|
||||
const context = selectedComponentInstance?.getDataContext()
|
||||
let stringifiedContext = null
|
||||
try {
|
||||
stringifiedContext = JSON.stringify(context)
|
||||
} catch (error) {
|
||||
// Ignore - invalid context
|
||||
}
|
||||
eventStore.actions.dispatchEvent("provide-context", {
|
||||
context: stringifiedContext,
|
||||
})
|
||||
} else if (type === "hover-component") {
|
||||
hoverStore.actions.hoverComponent(data)
|
||||
} else if (type === "builder-meta") {
|
||||
|
|
|
@ -29,7 +29,12 @@ import { fetchDatasourceSchema } from "./utils/schema.js"
|
|||
import { getAPIKey } from "./utils/api.js"
|
||||
import { enrichButtonActions } from "./utils/buttonActions.js"
|
||||
import { processStringSync, makePropSafe } from "@budibase/string-templates"
|
||||
import { fetchData, LuceneUtils, Constants } from "@budibase/frontend-core"
|
||||
import {
|
||||
fetchData,
|
||||
LuceneUtils,
|
||||
Constants,
|
||||
RowUtils,
|
||||
} from "@budibase/frontend-core"
|
||||
|
||||
export default {
|
||||
API,
|
||||
|
@ -65,6 +70,7 @@ export default {
|
|||
processStringSync,
|
||||
makePropSafe,
|
||||
createContextStore,
|
||||
generateGoldenSample: RowUtils.generateGoldenSample,
|
||||
|
||||
// Components
|
||||
Provider,
|
||||
|
|
|
@ -3,6 +3,7 @@ export * as JSONUtils from "./json"
|
|||
export * as CookieUtils from "./cookies"
|
||||
export * as RoleUtils from "./roles"
|
||||
export * as Utils from "./utils"
|
||||
export * as RowUtils from "./rows"
|
||||
export { memo, derivedMemo } from "./memo"
|
||||
export { createWebsocket } from "./websocket"
|
||||
export * from "./download"
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Util to check is a given value is "better" than another. "Betterness" is
|
||||
* defined as presence and length.
|
||||
*/
|
||||
const isBetterSample = (newValue, oldValue) => {
|
||||
// Prefer non-null values
|
||||
if (oldValue == null && newValue != null) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't change type
|
||||
const oldType = typeof oldValue
|
||||
const newType = typeof newValue
|
||||
if (oldType !== newType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Prefer longer values
|
||||
if (newType === "string" && newValue.length > oldValue.length) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
newType === "object" &&
|
||||
Object.keys(newValue).length > Object.keys(oldValue).length
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a best-case example object of the provided samples.
|
||||
* The generated sample does not necessarily exist - it simply is a sample that
|
||||
* contains "good" examples for every property of all the samples.
|
||||
* The generate sample will have a value for all keys across all samples.
|
||||
*/
|
||||
export const generateGoldenSample = samples => {
|
||||
let goldenSample = {}
|
||||
samples?.slice(0, 100).forEach(sample => {
|
||||
Object.keys(sample).forEach(key => {
|
||||
if (isBetterSample(sample[key], goldenSample[key])) {
|
||||
goldenSample[key] = sample[key]
|
||||
}
|
||||
})
|
||||
})
|
||||
return goldenSample
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`/views query returns data for the created view 1`] = `
|
||||
[
|
||||
{
|
||||
"avg": 2333.3333333333335,
|
||||
"count": 3,
|
||||
"group": null,
|
||||
"max": 4000,
|
||||
"min": 1000,
|
||||
"sum": 7000,
|
||||
"sumsqr": 21000000,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`/views query returns data for the created view using a group by 1`] = `
|
||||
[
|
||||
{
|
||||
"avg": 1500,
|
||||
"count": 2,
|
||||
"group": "One",
|
||||
"max": 2000,
|
||||
"min": 1000,
|
||||
"sum": 3000,
|
||||
"sumsqr": 5000000,
|
||||
},
|
||||
{
|
||||
"avg": 4000,
|
||||
"count": 1,
|
||||
"group": "Two",
|
||||
"max": 4000,
|
||||
"min": 4000,
|
||||
"sum": 4000,
|
||||
"sumsqr": 16000000,
|
||||
},
|
||||
]
|
||||
`;
|
File diff suppressed because it is too large
Load Diff
|
@ -1,30 +1,38 @@
|
|||
const setup = require("./utilities")
|
||||
const { events } = require("@budibase/backend-core")
|
||||
import { events } from "@budibase/backend-core"
|
||||
import * as setup from "./utilities"
|
||||
import {
|
||||
FieldType,
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
SaveTableRequest,
|
||||
Table,
|
||||
TableSourceType,
|
||||
View,
|
||||
ViewCalculation,
|
||||
} from "@budibase/types"
|
||||
|
||||
function priceTable() {
|
||||
return {
|
||||
name: "table",
|
||||
type: "table",
|
||||
key: "name",
|
||||
schema: {
|
||||
Price: {
|
||||
type: "number",
|
||||
constraints: {},
|
||||
},
|
||||
Category: {
|
||||
const priceTable: SaveTableRequest = {
|
||||
name: "table",
|
||||
type: "table",
|
||||
sourceId: INTERNAL_TABLE_SOURCE_ID,
|
||||
sourceType: TableSourceType.INTERNAL,
|
||||
schema: {
|
||||
Price: {
|
||||
name: "Price",
|
||||
type: FieldType.NUMBER,
|
||||
},
|
||||
Category: {
|
||||
name: "Category",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: "string",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
describe("/views", () => {
|
||||
let request = setup.getRequest()
|
||||
let config = setup.getConfig()
|
||||
let table
|
||||
let table: Table
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
|
@ -33,38 +41,34 @@ describe("/views", () => {
|
|||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
table = await config.createTable(priceTable())
|
||||
table = await config.api.table.save(priceTable)
|
||||
})
|
||||
|
||||
const saveView = async view => {
|
||||
const viewToSave = {
|
||||
const saveView = async (view?: Partial<View>) => {
|
||||
const viewToSave: View = {
|
||||
name: "TestView",
|
||||
field: "Price",
|
||||
calculation: "stats",
|
||||
tableId: table._id,
|
||||
calculation: ViewCalculation.STATISTICS,
|
||||
tableId: table._id!,
|
||||
filters: [],
|
||||
schema: {},
|
||||
...view,
|
||||
}
|
||||
return request
|
||||
.post(`/api/views`)
|
||||
.send(viewToSave)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
return config.api.legacyView.save(viewToSave)
|
||||
}
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a success message when the view is successfully created", async () => {
|
||||
const res = await saveView()
|
||||
expect(res.body.tableId).toBe(table._id)
|
||||
expect(events.view.created).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it("creates a view with a calculation", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const res = await saveView({ calculation: "count" })
|
||||
const view = await saveView({ calculation: ViewCalculation.COUNT })
|
||||
|
||||
expect(res.body.tableId).toBe(table._id)
|
||||
expect(view.tableId).toBe(table._id)
|
||||
expect(events.view.created).toBeCalledTimes(1)
|
||||
expect(events.view.updated).not.toBeCalled()
|
||||
expect(events.view.calculationCreated).toBeCalledTimes(1)
|
||||
|
@ -78,8 +82,8 @@ describe("/views", () => {
|
|||
it("creates a view with a filter", async () => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const res = await saveView({
|
||||
calculation: null,
|
||||
const view = await saveView({
|
||||
calculation: undefined,
|
||||
filters: [
|
||||
{
|
||||
value: "1",
|
||||
|
@ -89,7 +93,7 @@ describe("/views", () => {
|
|||
],
|
||||
})
|
||||
|
||||
expect(res.body.tableId).toBe(table._id)
|
||||
expect(view.tableId).toBe(table._id)
|
||||
expect(events.view.created).toBeCalledTimes(1)
|
||||
expect(events.view.updated).not.toBeCalled()
|
||||
expect(events.view.calculationCreated).not.toBeCalled()
|
||||
|
@ -101,52 +105,41 @@ describe("/views", () => {
|
|||
})
|
||||
|
||||
it("updates the table row with the new view metadata", async () => {
|
||||
const res = await request
|
||||
.post(`/api/views`)
|
||||
.send({
|
||||
name: "TestView",
|
||||
field: "Price",
|
||||
calculation: "stats",
|
||||
tableId: table._id,
|
||||
await saveView()
|
||||
const updatedTable = await config.api.table.get(table._id!)
|
||||
expect(updatedTable.views).toEqual(
|
||||
expect.objectContaining({
|
||||
TestView: expect.objectContaining({
|
||||
field: "Price",
|
||||
calculation: "stats",
|
||||
tableId: table._id,
|
||||
filters: [],
|
||||
schema: {
|
||||
sum: {
|
||||
type: "number",
|
||||
},
|
||||
min: {
|
||||
type: "number",
|
||||
},
|
||||
max: {
|
||||
type: "number",
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
},
|
||||
sumsqr: {
|
||||
type: "number",
|
||||
},
|
||||
avg: {
|
||||
type: "number",
|
||||
},
|
||||
field: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.tableId).toBe(table._id)
|
||||
|
||||
const updatedTable = await config.getTable(table._id)
|
||||
const expectedObj = expect.objectContaining({
|
||||
TestView: expect.objectContaining({
|
||||
field: "Price",
|
||||
calculation: "stats",
|
||||
tableId: table._id,
|
||||
filters: [],
|
||||
schema: {
|
||||
sum: {
|
||||
type: "number",
|
||||
},
|
||||
min: {
|
||||
type: "number",
|
||||
},
|
||||
max: {
|
||||
type: "number",
|
||||
},
|
||||
count: {
|
||||
type: "number",
|
||||
},
|
||||
sumsqr: {
|
||||
type: "number",
|
||||
},
|
||||
avg: {
|
||||
type: "number",
|
||||
},
|
||||
field: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(updatedTable.views).toEqual(expectedObj)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -168,10 +161,10 @@ describe("/views", () => {
|
|||
})
|
||||
|
||||
it("updates a view calculation", async () => {
|
||||
await saveView({ calculation: "sum" })
|
||||
await saveView({ calculation: ViewCalculation.SUM })
|
||||
jest.clearAllMocks()
|
||||
|
||||
await saveView({ calculation: "count" })
|
||||
await saveView({ calculation: ViewCalculation.COUNT })
|
||||
|
||||
expect(events.view.created).not.toBeCalled()
|
||||
expect(events.view.updated).toBeCalledTimes(1)
|
||||
|
@ -184,10 +177,10 @@ describe("/views", () => {
|
|||
})
|
||||
|
||||
it("deletes a view calculation", async () => {
|
||||
await saveView({ calculation: "sum" })
|
||||
await saveView({ calculation: ViewCalculation.SUM })
|
||||
jest.clearAllMocks()
|
||||
|
||||
await saveView({ calculation: null })
|
||||
await saveView({ calculation: undefined })
|
||||
|
||||
expect(events.view.created).not.toBeCalled()
|
||||
expect(events.view.updated).toBeCalledTimes(1)
|
||||
|
@ -258,100 +251,98 @@ describe("/views", () => {
|
|||
|
||||
describe("fetch", () => {
|
||||
beforeEach(async () => {
|
||||
table = await config.createTable(priceTable())
|
||||
table = await config.api.table.save(priceTable)
|
||||
})
|
||||
|
||||
it("returns only custom views", async () => {
|
||||
await config.createLegacyView({
|
||||
await saveView({
|
||||
name: "TestView",
|
||||
field: "Price",
|
||||
calculation: "stats",
|
||||
calculation: ViewCalculation.STATISTICS,
|
||||
tableId: table._id,
|
||||
})
|
||||
const res = await request
|
||||
.get(`/api/views`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.length).toBe(1)
|
||||
expect(res.body.find(({ name }) => name === "TestView")).toBeDefined()
|
||||
const views = await config.api.legacyView.fetch()
|
||||
expect(views.length).toBe(1)
|
||||
expect(views.find(({ name }) => name === "TestView")).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query", () => {
|
||||
it("returns data for the created view", async () => {
|
||||
await config.createLegacyView({
|
||||
await saveView({
|
||||
name: "TestView",
|
||||
field: "Price",
|
||||
calculation: "stats",
|
||||
tableId: table._id,
|
||||
calculation: ViewCalculation.STATISTICS,
|
||||
tableId: table._id!,
|
||||
})
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
await config.api.row.save(table._id!, {
|
||||
Price: 1000,
|
||||
})
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
await config.api.row.save(table._id!, {
|
||||
Price: 2000,
|
||||
})
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
await config.api.row.save(table._id!, {
|
||||
Price: 4000,
|
||||
})
|
||||
const res = await request
|
||||
.get(`/api/views/TestView?calculation=stats`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.length).toBe(1)
|
||||
expect(res.body).toMatchSnapshot()
|
||||
const rows = await config.api.legacyView.get("TestView", {
|
||||
calculation: ViewCalculation.STATISTICS,
|
||||
})
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0]).toEqual({
|
||||
avg: 2333.3333333333335,
|
||||
count: 3,
|
||||
group: null,
|
||||
max: 4000,
|
||||
min: 1000,
|
||||
sum: 7000,
|
||||
sumsqr: 21000000,
|
||||
})
|
||||
})
|
||||
|
||||
it("returns data for the created view using a group by", async () => {
|
||||
await config.createLegacyView({
|
||||
calculation: "stats",
|
||||
await saveView({
|
||||
calculation: ViewCalculation.STATISTICS,
|
||||
name: "TestView",
|
||||
field: "Price",
|
||||
groupBy: "Category",
|
||||
tableId: table._id,
|
||||
})
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
await config.api.row.save(table._id!, {
|
||||
Price: 1000,
|
||||
Category: "One",
|
||||
})
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
await config.api.row.save(table._id!, {
|
||||
Price: 2000,
|
||||
Category: "One",
|
||||
})
|
||||
await config.createRow({
|
||||
tableId: table._id,
|
||||
await config.api.row.save(table._id!, {
|
||||
Price: 4000,
|
||||
Category: "Two",
|
||||
})
|
||||
const res = await request
|
||||
.get(`/api/views/TestView?calculation=stats&group=Category`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.length).toBe(2)
|
||||
expect(res.body).toMatchSnapshot()
|
||||
const rows = await config.api.legacyView.get("TestView", {
|
||||
calculation: ViewCalculation.STATISTICS,
|
||||
group: "Category",
|
||||
})
|
||||
expect(rows.length).toBe(2)
|
||||
expect(rows[0]).toEqual({
|
||||
avg: 1500,
|
||||
count: 2,
|
||||
group: "One",
|
||||
max: 2000,
|
||||
min: 1000,
|
||||
sum: 3000,
|
||||
sumsqr: 5000000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("destroy", () => {
|
||||
it("should be able to delete a view", async () => {
|
||||
const table = await config.createTable(priceTable())
|
||||
const view = await config.createLegacyView()
|
||||
const res = await request
|
||||
.delete(`/api/views/${view.name}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
expect(res.body.map).toBeDefined()
|
||||
expect(res.body.meta.tableId).toEqual(table._id)
|
||||
const table = await config.api.table.save(priceTable)
|
||||
const view = await saveView({ tableId: table._id })
|
||||
const deletedView = await config.api.legacyView.destroy(view.name!)
|
||||
expect(deletedView.map).toBeDefined()
|
||||
expect(deletedView.meta?.tableId).toEqual(table._id)
|
||||
expect(events.view.deleted).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
@ -362,33 +353,44 @@ describe("/views", () => {
|
|||
})
|
||||
|
||||
const setupExport = async () => {
|
||||
const table = await config.createTable()
|
||||
await config.createRow({ name: "test-name", description: "ùúûü" })
|
||||
const table = await config.api.table.save({
|
||||
name: "test-table",
|
||||
type: "table",
|
||||
sourceId: INTERNAL_TABLE_SOURCE_ID,
|
||||
sourceType: TableSourceType.INTERNAL,
|
||||
schema: {
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
description: {
|
||||
name: "description",
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
})
|
||||
await config.api.row.save(table._id!, {
|
||||
name: "test-name",
|
||||
description: "ùúûü",
|
||||
})
|
||||
return table
|
||||
}
|
||||
|
||||
const exportView = async (viewName, format) => {
|
||||
return request
|
||||
.get(`/api/views/export?view=${viewName}&format=${format}`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
const assertJsonExport = res => {
|
||||
const rows = JSON.parse(res.text)
|
||||
const assertJsonExport = (res: string) => {
|
||||
const rows = JSON.parse(res)
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0].name).toBe("test-name")
|
||||
expect(rows[0].description).toBe("ùúûü")
|
||||
}
|
||||
|
||||
const assertCSVExport = res => {
|
||||
expect(res.text).toBe(`"name","description"\n"test-name","ùúûü"`)
|
||||
const assertCSVExport = (res: string) => {
|
||||
expect(res).toBe(`"name","description"\n"test-name","ùúûü"`)
|
||||
}
|
||||
|
||||
it("should be able to export a table as JSON", async () => {
|
||||
const table = await setupExport()
|
||||
|
||||
const res = await exportView(table._id, "json")
|
||||
const res = await config.api.legacyView.export(table._id!, "json")
|
||||
|
||||
assertJsonExport(res)
|
||||
expect(events.table.exported).toBeCalledTimes(1)
|
||||
|
@ -398,7 +400,7 @@ describe("/views", () => {
|
|||
it("should be able to export a table as CSV", async () => {
|
||||
const table = await setupExport()
|
||||
|
||||
const res = await exportView(table._id, "csv")
|
||||
const res = await config.api.legacyView.export(table._id!, "csv")
|
||||
|
||||
assertCSVExport(res)
|
||||
expect(events.table.exported).toBeCalledTimes(1)
|
||||
|
@ -407,10 +409,15 @@ describe("/views", () => {
|
|||
|
||||
it("should be able to export a view as JSON", async () => {
|
||||
let table = await setupExport()
|
||||
const view = await config.createLegacyView()
|
||||
table = await config.getTable(table._id)
|
||||
const view = await config.api.legacyView.save({
|
||||
name: "test-view",
|
||||
tableId: table._id!,
|
||||
filters: [],
|
||||
schema: {},
|
||||
})
|
||||
table = await config.api.table.get(table._id!)
|
||||
|
||||
let res = await exportView(view.name, "json")
|
||||
let res = await config.api.legacyView.export(view.name!, "json")
|
||||
|
||||
assertJsonExport(res)
|
||||
expect(events.view.exported).toBeCalledTimes(1)
|
||||
|
@ -419,10 +426,15 @@ describe("/views", () => {
|
|||
|
||||
it("should be able to export a view as CSV", async () => {
|
||||
let table = await setupExport()
|
||||
const view = await config.createLegacyView()
|
||||
table = await config.getTable(table._id)
|
||||
const view = await config.api.legacyView.save({
|
||||
name: "test-view",
|
||||
tableId: table._id!,
|
||||
filters: [],
|
||||
schema: {},
|
||||
})
|
||||
table = await config.api.table.get(table._id!)
|
||||
|
||||
let res = await exportView(view.name, "csv")
|
||||
let res = await config.api.legacyView.export(view.name!, "csv")
|
||||
|
||||
assertCSVExport(res)
|
||||
expect(events.view.exported).toBeCalledTimes(1)
|
|
@ -1,8 +1,36 @@
|
|||
import { Expectations, TestAPI } from "./base"
|
||||
import { Row } from "@budibase/types"
|
||||
import { Row, View, ViewCalculation } from "@budibase/types"
|
||||
|
||||
export class LegacyViewAPI extends TestAPI {
|
||||
get = async (id: string, expectations?: Expectations) => {
|
||||
return await this._get<Row[]>(`/api/views/${id}`, { expectations })
|
||||
get = async (
|
||||
id: string,
|
||||
query?: { calculation: ViewCalculation; group?: string },
|
||||
expectations?: Expectations
|
||||
) => {
|
||||
return await this._get<Row[]>(`/api/views/${id}`, { query, expectations })
|
||||
}
|
||||
|
||||
save = async (body: View, expectations?: Expectations) => {
|
||||
return await this._post<View>(`/api/views/`, { body, expectations })
|
||||
}
|
||||
|
||||
fetch = async (expectations?: Expectations) => {
|
||||
return await this._get<View[]>(`/api/views`, { expectations })
|
||||
}
|
||||
|
||||
destroy = async (id: string, expectations?: Expectations) => {
|
||||
return await this._delete<View>(`/api/views/${id}`, { expectations })
|
||||
}
|
||||
|
||||
export = async (
|
||||
viewName: string,
|
||||
format: "json" | "csv" | "jsonWithSchema",
|
||||
expectations?: Expectations
|
||||
) => {
|
||||
const response = await this._requestRaw("get", `/api/views/export`, {
|
||||
query: { view: viewName, format },
|
||||
expectations,
|
||||
})
|
||||
return response.text
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,3 +2,7 @@ export enum ServiceType {
|
|||
WORKER = "worker",
|
||||
APPS = "apps",
|
||||
}
|
||||
|
||||
export enum MaintenanceType {
|
||||
SQS_MISSING = "sqs_missing",
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ export interface View {
|
|||
map?: string
|
||||
reduce?: any
|
||||
meta?: ViewTemplateOpts
|
||||
groupBy?: string
|
||||
}
|
||||
|
||||
export interface ViewV2 {
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
import { Ctx } from "@budibase/types"
|
||||
import { Ctx, MaintenanceType } from "@budibase/types"
|
||||
import env from "../../../environment"
|
||||
import { env as coreEnv } from "@budibase/backend-core"
|
||||
import nodeFetch from "node-fetch"
|
||||
|
||||
// When we come to move to SQS fully and move away from Clouseau, we will need
|
||||
// to flip this to true (or remove it entirely). This will then be used to
|
||||
// determine if we should show the maintenance page that links to the SQS
|
||||
// migration docs.
|
||||
const sqsRequired = false
|
||||
|
||||
let sqsAvailable: boolean
|
||||
async function isSqsAvailable() {
|
||||
// We cache this value for the duration of the Node process because we don't
|
||||
// want every page load to be making this relatively expensive check.
|
||||
if (sqsAvailable !== undefined) {
|
||||
return sqsAvailable
|
||||
}
|
||||
|
@ -21,6 +29,10 @@ async function isSqsAvailable() {
|
|||
}
|
||||
}
|
||||
|
||||
async function isSqsMissing() {
|
||||
return sqsRequired && !(await isSqsAvailable())
|
||||
}
|
||||
|
||||
export const fetch = async (ctx: Ctx) => {
|
||||
ctx.body = {
|
||||
multiTenancy: !!env.MULTI_TENANCY,
|
||||
|
@ -30,11 +42,12 @@ export const fetch = async (ctx: Ctx) => {
|
|||
disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL,
|
||||
baseUrl: env.PLATFORM_URL,
|
||||
isDev: env.isDev() && !env.isTest(),
|
||||
maintenance: [],
|
||||
}
|
||||
|
||||
if (env.SELF_HOSTED) {
|
||||
ctx.body.infrastructure = {
|
||||
sqs: await isSqsAvailable(),
|
||||
if (await isSqsMissing()) {
|
||||
ctx.body.maintenance.push({ type: MaintenanceType.SQS_MISSING })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ describe("/api/system/environment", () => {
|
|||
multiTenancy: true,
|
||||
baseUrl: "http://localhost:10000",
|
||||
offlineMode: false,
|
||||
maintenance: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -40,9 +41,7 @@ describe("/api/system/environment", () => {
|
|||
multiTenancy: true,
|
||||
baseUrl: "http://localhost:10000",
|
||||
offlineMode: false,
|
||||
infrastructure: {
|
||||
sqs: false,
|
||||
},
|
||||
maintenance: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -8242,11 +8242,16 @@ codemirror-spell-checker@1.1.2:
|
|||
dependencies:
|
||||
typo-js "*"
|
||||
|
||||
codemirror@^5.59.0, codemirror@^5.63.1:
|
||||
codemirror@^5.63.1:
|
||||
version "5.65.12"
|
||||
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.12.tgz#294fdf097d10ac5b56a9e011a91eff252afc73ae"
|
||||
integrity sha512-z2jlHBocElRnPYysN2HAuhXbO3DNB0bcSKmNz3hcWR2Js2Dkhc1bEOxG93Z3DeUrnm+qx56XOY5wQmbP5KY0sw==
|
||||
|
||||
codemirror@^5.65.16:
|
||||
version "5.65.16"
|
||||
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.16.tgz#efc0661be6bf4988a6a1c2fe6893294638cdb334"
|
||||
integrity sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg==
|
||||
|
||||
collect-v8-coverage@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"
|
||||
|
@ -13944,6 +13949,11 @@ json-buffer@3.0.1:
|
|||
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
|
||||
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
|
||||
|
||||
json-format-highlight@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/json-format-highlight/-/json-format-highlight-1.0.4.tgz#2e44277edabcec79a3d2c84e984c62e2258037b9"
|
||||
integrity sha512-RqenIjKr1I99XfXPAml9G7YlEZg/GnsH7emWyWJh2yuGXqHW8spN7qx6/ME+MoIBb35/fxrMC9Jauj6nvGe4Mg==
|
||||
|
||||
json-parse-better-errors@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
|
||||
|
|
Loading…
Reference in New Issue