Merge branch 'master' into feature/app-list-actions

This commit is contained in:
deanhannigan 2024-03-15 09:10:57 +00:00 committed by GitHub
commit 72a671e93b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 2596 additions and 2223 deletions

View File

@ -32,7 +32,7 @@ describe("docWritethrough", () => {
describe("patch", () => { describe("patch", () => {
function generatePatchObject(fieldCount: number) { function generatePatchObject(fieldCount: number) {
const keys = generator.unique(() => generator.word(), fieldCount) const keys = generator.unique(() => generator.guid(), fieldCount)
return keys.reduce((acc, c) => { return keys.reduce((acc, c) => {
acc[c] = generator.word() acc[c] = generator.word()
return acc return acc

View File

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

View File

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

View File

@ -1,28 +1,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> <script>
import Portal from "svelte-portal" import Portal from "svelte-portal"
import Button from "../Button/Button.svelte" import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte" import { setContext, createEventDispatcher, onDestroy } from "svelte"
import Heading from "../Typography/Heading.svelte"
import { setContext, createEventDispatcher } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
export let title export let title
export let fillWidth export let forceModal = false
export let left = "314px"
export let width = "calc(100% - 626px)"
export let headless = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const spacing = 11
let visible = false let visible = false
let drawerId = generate() let drawerId = generate()
$: depth = $openDrawers.length - $openDrawers.indexOf(drawerId) - 1
$: style = getStyle(depth, $drawerLeft, $drawerWidth, $modal)
const getStyle = (depth, left, width, modal) => {
let style = `
--scale-factor: ${getScaleFactor(depth)};
--spacing: ${spacing}px;
`
// Most modal styles are handled by class names
if (modal || left == null || width == null) {
return style
}
// Drawers observing another dom node need custom position styles
return `
${style}
left: ${left + spacing}px;
width: ${width - 2 * spacing}px;
`
}
export function show() { export function show() {
if (visible) { if (visible) {
return return
} }
if (forceModal) {
modal.set(true)
resizable.set(false)
}
observe()
visible = true visible = true
dispatch("drawerShow", drawerId) dispatch("drawerShow", drawerId)
openDrawers.update(state => [...state, drawerId])
} }
export function hide() { export function hide() {
@ -31,12 +112,15 @@
} }
visible = false visible = false
dispatch("drawerHide", drawerId) dispatch("drawerHide", drawerId)
openDrawers.update(state => state.filter(id => id !== drawerId))
unobserve()
} }
setContext("drawer-actions", { setContext("drawer", {
hide, hide,
show, show,
headless, modal,
resizable,
}) })
const easeInOutQuad = x => { const easeInOutQuad = x => {
@ -45,66 +129,127 @@
// Use a custom svelte transition here because the built-in slide // Use a custom svelte transition here because the built-in slide
// transition has a horrible overshoot // transition has a horrible overshoot
const slide = () => { const drawerSlide = () => {
return { return {
duration: 360, duration: 260,
css: t => { css: t => {
const translation = 100 - Math.round(easeInOutQuad(t) * 100) const f = easeInOutQuad(t)
return `transform: translateY(${translation}%);` const yOffset = (1 - f) * 200
return `
transform: translateY(calc(${yOffset}px - 800px * (1 - var(--scale-factor))));
opacity: ${f};
`
}, },
} }
} }
// Custom fade transition because the default svelte one doesn't work any more
// with svelte 4
const drawerFade = () => {
return {
duration: 260,
css: t => {
return `opacity: ${easeInOutQuad(t)};`
},
}
}
const getScaleFactor = depth => {
// Quadratic function approaching a limit of 1 as depth tends to infinity
const lim = 1 - 1 / (depth * depth + 1)
// Scale drawers between 1 and 0.9 as depth approaches infinity
return 1 - lim * 0.1
}
onDestroy(() => {
if (visible) {
hide()
}
})
</script> </script>
{#if visible} {#if visible}
<Portal> <Portal target=".modal-container">
<section <div class="drawer-container">
class:fillWidth <div
class="drawer" class="underlay"
class:headless class:hidden={!$modal}
transition:slide|local transition:drawerFade|local
style={`width: ${width}; left: ${left};`} />
> <div
{#if !headless} class="drawer"
class:stacked={depth > 0}
class:modal={$modal}
transition:drawerSlide|local
{style}
>
<header> <header>
<div class="text"> <div class="text">{title || "Bindings"}</div>
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons"> <div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button> <Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" /> <slot name="buttons" />
</div> </div>
</header> </header>
{/if} <slot name="body" />
<slot name="body" /> <div class="overlay" class:hidden={$modal || depth === 0} />
</section> </div>
</div>
</Portal> </Portal>
{/if} {/if}
<style> <style>
.drawer.headless :global(.drawer-contents) {
height: calc(40vh + 75px);
}
.buttons {
display: flex;
gap: var(--spacing-m);
}
.drawer { .drawer {
position: absolute; position: absolute;
bottom: 0; left: 25vw;
width: 50vw;
bottom: var(--spacing);
height: 420px;
background: var(--background); background: var(--background);
border-top: var(--border-light); border: var(--border-light);
z-index: 3; z-index: 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 { .overlay,
left: 260px !important; .underlay {
width: calc(100% - 260px) !important; 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 { header {
@ -112,10 +257,9 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: var(--border-light); border-bottom: var(--border-light);
padding: var(--spacing-l) var(--spacing-xl); padding: var(--spacing-m) var(--spacing-xl);
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.text { .text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -123,7 +267,6 @@
align-items: flex-start; align-items: flex-start;
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,13 +42,11 @@
} from "@codemirror/commands" } from "@codemirror/commands"
import { Compartment } from "@codemirror/state" import { Compartment } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./" import { EditorModes } from "./"
import { themeStore } from "stores/portal" import { themeStore } from "stores/portal"
export let label export let label
export let completions = [] export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars export let mode = EditorModes.Handlebars
export let value = "" export let value = ""
export let placeholder = null export let placeholder = null
@ -56,6 +54,8 @@
export let autofocus = false export let autofocus = false
export let jsBindingWrapping = true export let jsBindingWrapping = true
const dispatch = createEventDispatcher()
// Export a function to expose caret position // Export a function to expose caret position
export const getCaretPosition = () => { export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0] const selection_range = editor.state.selection.ranges[0]
@ -107,8 +107,6 @@
} }
) )
const dispatch = createEventDispatcher()
// Theming! // Theming!
let currentTheme = $themeStore?.theme let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light") let isDark = !currentTheme.includes("light")
@ -117,7 +115,7 @@
const indentWithTabCustom = { const indentWithTabCustom = {
key: "Tab", key: "Tab",
run: view => { run: view => {
if (completionStatus(view.state) == "active") { if (completionStatus(view.state) === "active") {
acceptCompletion(view) acceptCompletion(view)
return true return true
} }
@ -131,7 +129,7 @@
} }
const buildKeymap = () => { const buildKeymap = () => {
const baseMap = [ return [
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,
@ -139,7 +137,6 @@
...completionKeymap, ...completionKeymap,
indentWithTabCustom, indentWithTabCustom,
] ]
return baseMap
} }
const buildBaseExtensions = () => { const buildBaseExtensions = () => {
@ -154,6 +151,8 @@
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }), syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(), highlightActiveLineGutter(),
highlightSpecialChars(), highlightSpecialChars(),
lineNumbers(),
foldGutter(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of(v => { EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString() const docStr = v.state.doc?.toString()
@ -163,14 +162,7 @@
dispatch("change", docStr) dispatch("change", docStr)
}), }),
keymap.of(buildKeymap()), keymap.of(buildKeymap()),
themeConfig.of([ themeConfig.of([...(isDark ? [oneDark] : [])]),
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
] ]
} }
@ -215,11 +207,9 @@
) )
} }
if (mode.name == "javascript") { if (mode.name === "javascript") {
complete.push(javascript()) complete.push(javascript())
complete.push(highlightWhitespace()) complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
} }
if (placeholder) { if (placeholder) {
@ -249,8 +239,6 @@
} }
} }
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready // Init when all elements are ready
$: if (mounted && !isEditorInitialised) { $: if (mounted && !isEditorInitialised) {
isEditorInitialised = true isEditorInitialised = true
@ -265,14 +253,7 @@
// Issue theme compartment update // Issue theme compartment update
editor.dispatch({ editor.dispatch({
effects: themeConfig.reconfigure([ effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
}) })
} }
} }
@ -298,27 +279,194 @@
</div> </div>
<style> <style>
.code-editor.handlebars :global(.cm-content) { /* Editor */
font-family: var(--font-sans); .code-editor {
font-size: 12px;
height: 100%;
} }
.code-editor :global(.cm-tooltip.cm-completionInfo) { .code-editor :global(.cm-editor) {
padding: var(--spacing-m); height: 100%;
background: var(--spectrum-global-color-gray-50) !important;
outline: none;
border: none;
border-radius: 0;
} }
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) { .code-editor :global(.cm-content) {
border-radius: var( padding: 10px 0;
--spectrum-popover-border-radius, }
var(--spectrum-alias-border-radius-regular) .code-editor > div {
), height: 100%;
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
} }
/* Active line */
.code-editor :global(.cm-line) {
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) { .code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200); font-family: var(--font-mono);
color: var(--spectrum-global-color-gray-700);
font-style: normal;
text-transform: capitalize;
font-size: 10px;
}
/* Live binding value / helper container */
.code-editor :global(.cm-completionInfo) {
margin-left: var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
padding: 4px 6px; background-color: var(--spectrum-global-color-gray-50);
padding: var(--spacing-m);
margin-top: -2px;
}
/* Wrapper around helpers */
.code-editor :global(.info-bubble) {
font-size: var(--font-size-s);
display: flex;
flex-direction: column;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-800);
}
/* Live binding value / helper value */
.code-editor :global(.binding__description) {
color: var(--spectrum-alias-text-color);
font-size: var(--font-size-m);
}
.code-editor :global(.binding__example) {
padding: 0;
margin: 0;
font-size: 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> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,133 @@
<script>
import formatHighlight from "json-format-highlight"
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
import { copyToClipboard } from "@budibase/bbui/helpers"
import { fade } from "svelte/transition"
export let expressionResult
export let evaluating = false
export let expression = null
$: error = expressionResult === "Error while executing JS"
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
const highlight = json => {
if (json == null) {
return ""
}
// Attempt to parse and then stringify, in case this is valid JSON
try {
json = JSON.stringify(JSON.parse(json), null, 2)
} catch (err) {
// Ignore
}
return formatHighlight(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
trueColor: "#d19a66",
falseColor: "#d19a66",
nullColor: "#c678dd",
})
}
const copy = () => {
let clipboardVal = expressionResult
if (typeof clipboardVal === "object") {
clipboardVal = JSON.stringify(clipboardVal, null, 2)
}
copyToClipboard(clipboardVal)
notifications.success("Value copied to clipboard")
}
</script>
<div class="evaluation-side-panel">
<div class="header" class:success class:error>
<div class="header-content">
{#if error}
<Icon name="Alert" color="var(--spectrum-global-color-red-600)" />
<div>Error</div>
{#if evaluating}
<div transition:fade|local={{ duration: 130 }}>
<ProgressCircle size="S" />
</div>
{/if}
<span />
<Icon name="Copy" 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,6 +71,10 @@
await auth.getSelf() await auth.getSelf()
await admin.init() await admin.init()
if ($admin.maintenance.length > 0) {
$redirect("./maintenance")
}
if ($auth.user) { if ($auth.user) {
await licensing.init() await licensing.init()
} }

View File

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

View File

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

View File

@ -196,6 +196,16 @@
} else if (type === "add-parent-component") { } else if (type === "add-parent-component") {
const { componentId, parentType } = data const { componentId, parentType } = data
await componentStore.addParent(componentId, parentType) await componentStore.addParent(componentId, parentType)
} else if (type === "provide-context") {
let context = data?.context
if (context) {
try {
context = JSON.parse(context)
} catch (error) {
context = null
}
}
previewStore.setSelectedComponentContext(context)
} else { } else {
console.warn(`Client sent unknown event type: ${type}`) console.warn(`Client sent unknown event type: ${type}`)
} }

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
adminUser: { checked: false }, adminUser: { checked: false },
sso: { checked: false }, sso: { checked: false },
}, },
maintenance: [],
offlineMode: false, offlineMode: false,
} }
@ -48,6 +49,7 @@ export function createAdminStore() {
store.isDev = environment.isDev store.isDev = environment.isDev
store.baseUrl = environment.baseUrl store.baseUrl = environment.baseUrl
store.offlineMode = environment.offlineMode store.offlineMode = environment.offlineMode
store.maintenance = environment.maintenance
return store return store
}) })
} }

View File

@ -17,6 +17,7 @@
appStore, appStore,
devToolsStore, devToolsStore,
devToolsEnabled, devToolsEnabled,
environmentStore,
} from "stores" } from "stores"
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
@ -36,6 +37,7 @@
import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" import DevToolsHeader from "components/devtools/DevToolsHeader.svelte"
import DevTools from "components/devtools/DevTools.svelte" import DevTools from "components/devtools/DevTools.svelte"
import FreeFooter from "components/FreeFooter.svelte" import FreeFooter from "components/FreeFooter.svelte"
import MaintenanceScreen from "components/MaintenanceScreen.svelte"
import licensing from "../licensing" import licensing from "../licensing"
// Provide contexts // Provide contexts
@ -111,122 +113,128 @@
class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}" class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}"
class:builder={$builderStore.inBuilder} class:builder={$builderStore.inBuilder}
> >
<DeviceBindingsProvider> {#if $environmentStore.maintenance.length > 0}
<UserBindingsProvider> <MaintenanceScreen maintenanceList={$environmentStore.maintenance} />
<StateBindingsProvider> {:else}
<RowSelectionProvider> <DeviceBindingsProvider>
<QueryParamsProvider> <UserBindingsProvider>
<!-- Settings bar can be rendered outside of device preview --> <StateBindingsProvider>
<!-- Key block needs to be outside the if statement or it breaks --> <RowSelectionProvider>
{#key $builderStore.selectedComponentId} <QueryParamsProvider>
{#if $builderStore.inBuilder} <!-- Settings bar can be rendered outside of device preview -->
<SettingsBar /> <!-- Key block needs to be outside the if statement or it breaks -->
{/if} {#key $builderStore.selectedComponentId}
{/key} {#if $builderStore.inBuilder}
<SettingsBar />
<!-- 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} {/if}
{/key}
<div id="app-body"> <!-- Clip boundary for selection indicators -->
{#if permissionError} <div
<div class="error"> id="clip-root"
<Layout justifyItems="center" gap="S"> class:preview={$builderStore.inBuilder}
<!-- eslint-disable-next-line svelte/no-at-html-tags --> class:tablet-preview={$builderStore.previewDevice ===
{@html ErrorSVG} "tablet"}
<Heading size="L"> class:mobile-preview={$builderStore.previewDevice ===
You don't have permission to use this app "mobile"}
</Heading> >
<Body size="S"> <!-- Actual app -->
Ask your administrator to grant you access <div id="app-root">
</Body> {#if showDevTools}
</Layout> <DevToolsHeader />
</div> {/if}
{: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}
<!-- <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. Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with. 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 --> <!-- Modal container to ensure they sit on top -->
<div class="modal-container" /> <div class="modal-container" />
<!-- Layers on top of app --> <!-- Layers on top of app -->
<NotificationDisplay /> <NotificationDisplay />
<ConfirmationDisplay /> <ConfirmationDisplay />
<PeekScreenDisplay /> <PeekScreenDisplay />
</CustomThemeWrapper> </CustomThemeWrapper>
{/if} {/if}
{#if showDevTools} {#if showDevTools}
<DevTools /> <DevTools />
{/if}
</div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
<FreeFooter />
{/if} {/if}
</div> </div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()} <!-- Preview and dev tools utilities -->
<FreeFooter /> {#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if} {/if}
</div> </div>
</QueryParamsProvider>
<!-- Preview and dev tools utilities --> </RowSelectionProvider>
{#if $appStore.isDevApp} </StateBindingsProvider>
<SelectionIndicator /> </UserBindingsProvider>
{/if} </DeviceBindingsProvider>
{#if $builderStore.inBuilder || $devToolsStore.allowSelection} {/if}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if}
</div>
</QueryParamsProvider>
</RowSelectionProvider>
</StateBindingsProvider>
</UserBindingsProvider>
</DeviceBindingsProvider>
</div> </div>
<KeyboardManager /> <KeyboardManager />
{/if} {/if}

View File

@ -575,6 +575,15 @@
} }
} }
const getDataContext = () => {
const normalContext = get(context)
const additionalContext = ref?.getAdditionalDataContext?.()
return {
...normalContext,
...additionalContext,
}
}
onMount(() => { onMount(() => {
// Register this component instance for external access // Register this component instance for external access
if ($appStore.isDevApp) { if ($appStore.isDevApp) {
@ -583,7 +592,7 @@
component: instance._component, component: instance._component,
getSettings: () => cachedSettings, getSettings: () => cachedSettings,
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
getDataContext: () => get(context), getDataContext,
reload: () => initialise(instance, true), reload: () => initialise(instance, true),
setEphemeralStyles: styles => (ephemeralStyles = styles), setEphemeralStyles: styles => (ephemeralStyles = styles),
state: store, state: store,

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -84,6 +84,18 @@ const loadBudibase = async () => {
} else { } else {
dndStore.actions.reset() dndStore.actions.reset()
} }
} else if (type === "request-context") {
const { selectedComponentInstance } = get(componentStore)
const context = selectedComponentInstance?.getDataContext()
let stringifiedContext = null
try {
stringifiedContext = JSON.stringify(context)
} catch (error) {
// Ignore - invalid context
}
eventStore.actions.dispatchEvent("provide-context", {
context: stringifiedContext,
})
} else if (type === "hover-component") { } else if (type === "hover-component") {
hoverStore.actions.hoverComponent(data) hoverStore.actions.hoverComponent(data)
} else if (type === "builder-meta") { } else if (type === "builder-meta") {

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -1,30 +1,38 @@
const setup = require("./utilities") import { events } from "@budibase/backend-core"
const { events } = require("@budibase/backend-core") import * as setup from "./utilities"
import {
FieldType,
INTERNAL_TABLE_SOURCE_ID,
SaveTableRequest,
Table,
TableSourceType,
View,
ViewCalculation,
} from "@budibase/types"
function priceTable() { const priceTable: SaveTableRequest = {
return { name: "table",
name: "table", type: "table",
type: "table", sourceId: INTERNAL_TABLE_SOURCE_ID,
key: "name", sourceType: TableSourceType.INTERNAL,
schema: { schema: {
Price: { Price: {
type: "number", name: "Price",
constraints: {}, type: FieldType.NUMBER,
}, },
Category: { Category: {
name: "Category",
type: FieldType.STRING,
constraints: {
type: "string", type: "string",
constraints: {
type: "string",
},
}, },
}, },
} },
} }
describe("/views", () => { describe("/views", () => {
let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let table let table: Table
afterAll(setup.afterAll) afterAll(setup.afterAll)
@ -33,38 +41,34 @@ describe("/views", () => {
}) })
beforeEach(async () => { beforeEach(async () => {
table = await config.createTable(priceTable()) table = await config.api.table.save(priceTable)
}) })
const saveView = async view => { const saveView = async (view?: Partial<View>) => {
const viewToSave = { const viewToSave: View = {
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats", calculation: ViewCalculation.STATISTICS,
tableId: table._id, tableId: table._id!,
filters: [],
schema: {},
...view, ...view,
} }
return request return config.api.legacyView.save(viewToSave)
.post(`/api/views`)
.send(viewToSave)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
} }
describe("create", () => { describe("create", () => {
it("returns a success message when the view is successfully created", async () => { it("returns a success message when the view is successfully created", async () => {
const res = await saveView() const res = await saveView()
expect(res.body.tableId).toBe(table._id)
expect(events.view.created).toBeCalledTimes(1) expect(events.view.created).toBeCalledTimes(1)
}) })
it("creates a view with a calculation", async () => { it("creates a view with a calculation", async () => {
jest.clearAllMocks() 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.created).toBeCalledTimes(1)
expect(events.view.updated).not.toBeCalled() expect(events.view.updated).not.toBeCalled()
expect(events.view.calculationCreated).toBeCalledTimes(1) expect(events.view.calculationCreated).toBeCalledTimes(1)
@ -78,8 +82,8 @@ describe("/views", () => {
it("creates a view with a filter", async () => { it("creates a view with a filter", async () => {
jest.clearAllMocks() jest.clearAllMocks()
const res = await saveView({ const view = await saveView({
calculation: null, calculation: undefined,
filters: [ filters: [
{ {
value: "1", 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.created).toBeCalledTimes(1)
expect(events.view.updated).not.toBeCalled() expect(events.view.updated).not.toBeCalled()
expect(events.view.calculationCreated).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 () => { it("updates the table row with the new view metadata", async () => {
const res = await request await saveView()
.post(`/api/views`) const updatedTable = await config.api.table.get(table._id!)
.send({ expect(updatedTable.views).toEqual(
name: "TestView", expect.objectContaining({
field: "Price", TestView: expect.objectContaining({
calculation: "stats", field: "Price",
tableId: table._id, 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 () => { it("updates a view calculation", async () => {
await saveView({ calculation: "sum" }) await saveView({ calculation: ViewCalculation.SUM })
jest.clearAllMocks() jest.clearAllMocks()
await saveView({ calculation: "count" }) await saveView({ calculation: ViewCalculation.COUNT })
expect(events.view.created).not.toBeCalled() expect(events.view.created).not.toBeCalled()
expect(events.view.updated).toBeCalledTimes(1) expect(events.view.updated).toBeCalledTimes(1)
@ -184,10 +177,10 @@ describe("/views", () => {
}) })
it("deletes a view calculation", async () => { it("deletes a view calculation", async () => {
await saveView({ calculation: "sum" }) await saveView({ calculation: ViewCalculation.SUM })
jest.clearAllMocks() jest.clearAllMocks()
await saveView({ calculation: null }) await saveView({ calculation: undefined })
expect(events.view.created).not.toBeCalled() expect(events.view.created).not.toBeCalled()
expect(events.view.updated).toBeCalledTimes(1) expect(events.view.updated).toBeCalledTimes(1)
@ -258,100 +251,98 @@ describe("/views", () => {
describe("fetch", () => { describe("fetch", () => {
beforeEach(async () => { beforeEach(async () => {
table = await config.createTable(priceTable()) table = await config.api.table.save(priceTable)
}) })
it("returns only custom views", async () => { it("returns only custom views", async () => {
await config.createLegacyView({ await saveView({
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats", calculation: ViewCalculation.STATISTICS,
tableId: table._id, tableId: table._id,
}) })
const res = await request const views = await config.api.legacyView.fetch()
.get(`/api/views`) expect(views.length).toBe(1)
.set(config.defaultHeaders()) expect(views.find(({ name }) => name === "TestView")).toBeDefined()
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toBe(1)
expect(res.body.find(({ name }) => name === "TestView")).toBeDefined()
}) })
}) })
describe("query", () => { describe("query", () => {
it("returns data for the created view", async () => { it("returns data for the created view", async () => {
await config.createLegacyView({ await saveView({
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats", calculation: ViewCalculation.STATISTICS,
tableId: table._id, tableId: table._id!,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 1000, Price: 1000,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 2000, Price: 2000,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 4000, Price: 4000,
}) })
const res = await request const rows = await config.api.legacyView.get("TestView", {
.get(`/api/views/TestView?calculation=stats`) calculation: ViewCalculation.STATISTICS,
.set(config.defaultHeaders()) })
.expect("Content-Type", /json/) expect(rows.length).toBe(1)
.expect(200) expect(rows[0]).toEqual({
expect(res.body.length).toBe(1) avg: 2333.3333333333335,
expect(res.body).toMatchSnapshot() count: 3,
group: null,
max: 4000,
min: 1000,
sum: 7000,
sumsqr: 21000000,
})
}) })
it("returns data for the created view using a group by", async () => { it("returns data for the created view using a group by", async () => {
await config.createLegacyView({ await saveView({
calculation: "stats", calculation: ViewCalculation.STATISTICS,
name: "TestView", name: "TestView",
field: "Price", field: "Price",
groupBy: "Category", groupBy: "Category",
tableId: table._id, tableId: table._id,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 1000, Price: 1000,
Category: "One", Category: "One",
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 2000, Price: 2000,
Category: "One", Category: "One",
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 4000, Price: 4000,
Category: "Two", Category: "Two",
}) })
const res = await request const rows = await config.api.legacyView.get("TestView", {
.get(`/api/views/TestView?calculation=stats&group=Category`) calculation: ViewCalculation.STATISTICS,
.set(config.defaultHeaders()) group: "Category",
.expect("Content-Type", /json/) })
.expect(200) expect(rows.length).toBe(2)
expect(rows[0]).toEqual({
expect(res.body.length).toBe(2) avg: 1500,
expect(res.body).toMatchSnapshot() count: 2,
group: "One",
max: 2000,
min: 1000,
sum: 3000,
sumsqr: 5000000,
})
}) })
}) })
describe("destroy", () => { describe("destroy", () => {
it("should be able to delete a view", async () => { it("should be able to delete a view", async () => {
const table = await config.createTable(priceTable()) const table = await config.api.table.save(priceTable)
const view = await config.createLegacyView() const view = await saveView({ tableId: table._id })
const res = await request const deletedView = await config.api.legacyView.destroy(view.name!)
.delete(`/api/views/${view.name}`) expect(deletedView.map).toBeDefined()
.set(config.defaultHeaders()) expect(deletedView.meta?.tableId).toEqual(table._id)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.map).toBeDefined()
expect(res.body.meta.tableId).toEqual(table._id)
expect(events.view.deleted).toBeCalledTimes(1) expect(events.view.deleted).toBeCalledTimes(1)
}) })
}) })
@ -362,33 +353,44 @@ describe("/views", () => {
}) })
const setupExport = async () => { const setupExport = async () => {
const table = await config.createTable() const table = await config.api.table.save({
await config.createRow({ name: "test-name", description: "ùúûü" }) 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 return table
} }
const exportView = async (viewName, format) => { const assertJsonExport = (res: string) => {
return request const rows = JSON.parse(res)
.get(`/api/views/export?view=${viewName}&format=${format}`)
.set(config.defaultHeaders())
.expect(200)
}
const assertJsonExport = res => {
const rows = JSON.parse(res.text)
expect(rows.length).toBe(1) expect(rows.length).toBe(1)
expect(rows[0].name).toBe("test-name") expect(rows[0].name).toBe("test-name")
expect(rows[0].description).toBe("ùúûü") expect(rows[0].description).toBe("ùúûü")
} }
const assertCSVExport = res => { const assertCSVExport = (res: string) => {
expect(res.text).toBe(`"name","description"\n"test-name","ùúûü"`) expect(res).toBe(`"name","description"\n"test-name","ùúûü"`)
} }
it("should be able to export a table as JSON", async () => { it("should be able to export a table as JSON", async () => {
const table = await setupExport() const table = await setupExport()
const res = await exportView(table._id, "json") const res = await config.api.legacyView.export(table._id!, "json")
assertJsonExport(res) assertJsonExport(res)
expect(events.table.exported).toBeCalledTimes(1) expect(events.table.exported).toBeCalledTimes(1)
@ -398,7 +400,7 @@ describe("/views", () => {
it("should be able to export a table as CSV", async () => { it("should be able to export a table as CSV", async () => {
const table = await setupExport() const table = await setupExport()
const res = await exportView(table._id, "csv") const res = await config.api.legacyView.export(table._id!, "csv")
assertCSVExport(res) assertCSVExport(res)
expect(events.table.exported).toBeCalledTimes(1) expect(events.table.exported).toBeCalledTimes(1)
@ -407,10 +409,15 @@ describe("/views", () => {
it("should be able to export a view as JSON", async () => { it("should be able to export a view as JSON", async () => {
let table = await setupExport() let table = await setupExport()
const view = await config.createLegacyView() const view = await config.api.legacyView.save({
table = await config.getTable(table._id) 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) assertJsonExport(res)
expect(events.view.exported).toBeCalledTimes(1) expect(events.view.exported).toBeCalledTimes(1)
@ -419,10 +426,15 @@ describe("/views", () => {
it("should be able to export a view as CSV", async () => { it("should be able to export a view as CSV", async () => {
let table = await setupExport() let table = await setupExport()
const view = await config.createLegacyView() const view = await config.api.legacyView.save({
table = await config.getTable(table._id) 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) assertCSVExport(res)
expect(events.view.exported).toBeCalledTimes(1) expect(events.view.exported).toBeCalledTimes(1)

View File

@ -1,8 +1,36 @@
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
import { Row } from "@budibase/types" import { Row, View, ViewCalculation } from "@budibase/types"
export class LegacyViewAPI extends TestAPI { export class LegacyViewAPI extends TestAPI {
get = async (id: string, expectations?: Expectations) => { get = async (
return await this._get<Row[]>(`/api/views/${id}`, { expectations }) 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
} }
} }

View File

@ -2,3 +2,7 @@ export enum ServiceType {
WORKER = "worker", WORKER = "worker",
APPS = "apps", APPS = "apps",
} }
export enum MaintenanceType {
SQS_MISSING = "sqs_missing",
}

View File

@ -30,6 +30,7 @@ export interface View {
map?: string map?: string
reduce?: any reduce?: any
meta?: ViewTemplateOpts meta?: ViewTemplateOpts
groupBy?: string
} }
export interface ViewV2 { export interface ViewV2 {

View File

@ -1,10 +1,18 @@
import { Ctx } from "@budibase/types" import { Ctx, MaintenanceType } from "@budibase/types"
import env from "../../../environment" import env from "../../../environment"
import { env as coreEnv } from "@budibase/backend-core" import { env as coreEnv } from "@budibase/backend-core"
import nodeFetch from "node-fetch" 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 let sqsAvailable: boolean
async function isSqsAvailable() { 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) { if (sqsAvailable !== undefined) {
return sqsAvailable return sqsAvailable
} }
@ -21,6 +29,10 @@ async function isSqsAvailable() {
} }
} }
async function isSqsMissing() {
return sqsRequired && !(await isSqsAvailable())
}
export const fetch = async (ctx: Ctx) => { export const fetch = async (ctx: Ctx) => {
ctx.body = { ctx.body = {
multiTenancy: !!env.MULTI_TENANCY, multiTenancy: !!env.MULTI_TENANCY,
@ -30,11 +42,12 @@ export const fetch = async (ctx: Ctx) => {
disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL, disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL,
baseUrl: env.PLATFORM_URL, baseUrl: env.PLATFORM_URL,
isDev: env.isDev() && !env.isTest(), isDev: env.isDev() && !env.isTest(),
maintenance: [],
} }
if (env.SELF_HOSTED) { if (env.SELF_HOSTED) {
ctx.body.infrastructure = { if (await isSqsMissing()) {
sqs: await isSqsAvailable(), ctx.body.maintenance.push({ type: MaintenanceType.SQS_MISSING })
} }
} }
} }

View File

@ -27,6 +27,7 @@ describe("/api/system/environment", () => {
multiTenancy: true, multiTenancy: true,
baseUrl: "http://localhost:10000", baseUrl: "http://localhost:10000",
offlineMode: false, offlineMode: false,
maintenance: [],
}) })
}) })
@ -40,9 +41,7 @@ describe("/api/system/environment", () => {
multiTenancy: true, multiTenancy: true,
baseUrl: "http://localhost:10000", baseUrl: "http://localhost:10000",
offlineMode: false, offlineMode: false,
infrastructure: { maintenance: [],
sqs: false,
},
}) })
}) })
}) })

View File

@ -8242,11 +8242,16 @@ codemirror-spell-checker@1.1.2:
dependencies: dependencies:
typo-js "*" typo-js "*"
codemirror@^5.59.0, codemirror@^5.63.1: codemirror@^5.63.1:
version "5.65.12" version "5.65.12"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.12.tgz#294fdf097d10ac5b56a9e011a91eff252afc73ae" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.12.tgz#294fdf097d10ac5b56a9e011a91eff252afc73ae"
integrity sha512-z2jlHBocElRnPYysN2HAuhXbO3DNB0bcSKmNz3hcWR2Js2Dkhc1bEOxG93Z3DeUrnm+qx56XOY5wQmbP5KY0sw== 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: collect-v8-coverage@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59" 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" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== 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: json-parse-better-errors@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"