Merge remote-tracking branch 'origin/feature/app-list-actions' into feature/app-favourites

This commit is contained in:
Dean 2024-03-15 09:20:02 +00:00
commit f2dbeed6e8
71 changed files with 2596 additions and 2223 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -42,13 +42,11 @@
} from "@codemirror/commands"
import { Compartment } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./"
import { EditorModes } from "./"
import { themeStore } from "stores/portal"
export let label
export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
@ -56,6 +54,8 @@
export let autofocus = false
export let jsBindingWrapping = true
const dispatch = createEventDispatcher()
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
@ -107,8 +107,6 @@
}
)
const dispatch = createEventDispatcher()
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
@ -117,7 +115,7 @@
const indentWithTabCustom = {
key: "Tab",
run: view => {
if (completionStatus(view.state) == "active") {
if (completionStatus(view.state) === "active") {
acceptCompletion(view)
return true
}
@ -131,7 +129,7 @@
}
const buildKeymap = () => {
const baseMap = [
return [
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
@ -139,7 +137,6 @@
...completionKeymap,
indentWithTabCustom,
]
return baseMap
}
const buildBaseExtensions = () => {
@ -154,6 +151,8 @@
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(),
lineNumbers(),
foldGutter(),
EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
@ -163,14 +162,7 @@
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
themeConfig.of([...(isDark ? [oneDark] : [])]),
]
}
@ -215,11 +207,9 @@
)
}
if (mode.name == "javascript") {
if (mode.name === "javascript") {
complete.push(javascript())
complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
}
if (placeholder) {
@ -249,8 +239,6 @@
}
}
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
@ -265,14 +253,7 @@
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
effects: themeConfig.reconfigure([...(isDark ? [oneDark] : [])]),
})
}
}
@ -298,27 +279,194 @@
</div>
<style>
.code-editor.handlebars :global(.cm-content) {
font-family: var(--font-sans);
/* Editor */
.code-editor {
font-size: 12px;
height: 100%;
}
.code-editor :global(.cm-tooltip.cm-completionInfo) {
padding: var(--spacing-m);
.code-editor :global(.cm-editor) {
height: 100%;
background: var(--spectrum-global-color-gray-50) !important;
outline: none;
border: none;
border-radius: 0;
}
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
border-radius: var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
.code-editor :global(.cm-content) {
padding: 10px 0;
}
.code-editor > div {
height: 100%;
}
/* Active line */
.code-editor :global(.cm-line) {
height: 16px;
padding: 0 var(--spacing-s);
color: var(--spectrum-alias-text-color);
}
.code-editor :global(.cm-activeLine) {
position: relative;
background: transparent;
}
.code-editor :global(.cm-activeLine::before) {
content: "";
position: absolute;
left: 0;
top: 1px;
height: calc(100% - 2px);
width: 100%;
background: var(--spectrum-global-color-gray-100) !important;
z-index: -2;
}
/* Code selection */
.code-editor :global(.cm-selectionBackground) {
background-color: var(--spectrum-global-color-blue-400) !important;
opacity: 0.4;
}
/* Gutters */
.code-editor :global(.cm-gutterElement) {
margin-bottom: 0;
}
.code-editor :global(.cm-gutters) {
background-color: var(--spectrum-global-color-gray-75) !important;
color: var(--spectrum-global-color-gray-500);
}
.code-editor :global(.cm-activeLineGutter::before) {
content: "";
position: absolute;
left: 0;
top: 1px;
height: calc(100% - 2px);
width: 100%;
background: var(--spectrum-global-color-gray-200) !important;
z-index: -2;
}
.code-editor :global(.cm-activeLineGutter) {
color: var(--spectrum-global-color-gray-700);
background: transparent;
position: relative;
}
/* Cursor color */
.code-editor :global(.cm-focused .cm-cursor) {
border-left-color: var(--spectrum-alias-text-color);
}
/* Placeholder */
.code-editor :global(.cm-placeholder) {
color: var(--spectrum-global-color-gray-700);
font-style: italic;
}
/* Highlight bindings */
.code-editor :global(.binding-wrap) {
color: var(--spectrum-global-color-blue-700);
}
/* Completion popover */
.code-editor :global(.cm-tooltip-autocomplete) {
background: var(--spectrum-global-color-gray-75);
border-radius: 4px;
border: 1px solid var(--spectrum-global-color-gray-200);
}
.code-editor :global(.cm-tooltip-autocomplete > ul) {
max-height: 20em;
}
/* Completion section header*/
.code-editor :global(.info-section) {
display: flex;
align-items: center;
padding: var(--spacing-m);
font-family: var(--font-sans);
font-size: var(--font-size-s);
gap: var(--spacing-m);
color: var(--spectrum-alias-text-color);
font-weight: 600;
}
.code-editor :global(.info-section:not(:first-of-type)) {
border-top: 1px solid var(--spectrum-global-color-gray-200);
}
/* Completion item container */
.code-editor :global(.autocomplete-option) {
padding: var(--spacing-s) var(--spacing-m) !important;
padding-left: calc(16px + 2 * var(--spacing-m)) !important;
display: flex;
gap: var(--spacing-m);
align-items: center;
color: var(--spectrum-alias-text-color);
}
/* Highlighted completion item */
.code-editor :global(.autocomplete-option[aria-selected]) {
background: var(--spectrum-global-color-blue-400);
color: white;
}
.code-editor
:global(.autocomplete-option[aria-selected] .cm-completionDetail) {
color: white;
}
/* Completion item label */
.code-editor :global(.cm-completionLabel) {
flex: 1 1 auto;
font-size: var(--font-size-s);
font-family: var(--font-sans);
text-transform: capitalize;
}
/* Completion item type */
.code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200);
font-family: var(--font-mono);
color: var(--spectrum-global-color-gray-700);
font-style: normal;
text-transform: capitalize;
font-size: 10px;
}
/* Live binding value / helper container */
.code-editor :global(.cm-completionInfo) {
margin-left: var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: var(--border-radius-s);
padding: 4px 6px;
background-color: var(--spectrum-global-color-gray-50);
padding: var(--spacing-m);
margin-top: -2px;
}
/* Wrapper around helpers */
.code-editor :global(.info-bubble) {
font-size: var(--font-size-s);
display: flex;
flex-direction: column;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-800);
}
/* Live binding value / helper value */
.code-editor :global(.binding__description) {
color: var(--spectrum-alias-text-color);
font-size: var(--font-size-m);
}
.code-editor :global(.binding__example) {
padding: 0;
margin: 0;
font-size: var(--font-size-s);
font-family: var(--font-mono);
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
max-height: 480px;
}
.code-editor :global(.binding__example) {
color: var(--spectrum-global-color-blue-700);
}
.code-editor :global(.binding__example span) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
</style>

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,133 @@
<script>
import formatHighlight from "json-format-highlight"
import { Icon, ProgressCircle, notifications } from "@budibase/bbui"
import { copyToClipboard } from "@budibase/bbui/helpers"
import { fade } from "svelte/transition"
export let expressionResult
export let evaluating = false
export let expression = null
$: error = expressionResult === "Error while executing JS"
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
const highlight = json => {
if (json == null) {
return ""
}
// Attempt to parse and then stringify, in case this is valid JSON
try {
json = JSON.stringify(JSON.parse(json), null, 2)
} catch (err) {
// Ignore
}
return formatHighlight(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
trueColor: "#d19a66",
falseColor: "#d19a66",
nullColor: "#c678dd",
})
}
const copy = () => {
let clipboardVal = expressionResult
if (typeof clipboardVal === "object") {
clipboardVal = JSON.stringify(clipboardVal, null, 2)
}
copyToClipboard(clipboardVal)
notifications.success("Value copied to clipboard")
}
</script>
<div class="evaluation-side-panel">
<div class="header" class:success class:error>
<div class="header-content">
{#if error}
<Icon name="Alert" color="var(--spectrum-global-color-red-600)" />
<div>Error</div>
{#if evaluating}
<div transition:fade|local={{ duration: 130 }}>
<ProgressCircle size="S" />
</div>
{/if}
<span />
<Icon name="Copy" 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>
import { Icon, Input, Modal, Body, ModalContent } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ServerBindingPanel
export let value = ""
export let bindings = []
export let title = "Bindings"
export let placeholder
export let label
export let allowJS = false
export let updateOnChange = true
const dispatch = createEventDispatcher()
let bindingModal
let valid = true
$: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
$: isJS = isJSBinding(value)
const saveBinding = () => {
onChange(tempValue)
bindingModal.hide()
}
const onChange = input => {
dispatch("change", readableToRuntimeBinding(bindings, input))
}
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control">
<Input
{label}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
on:change={event => onChange(event.detail)}
{placeholder}
{updateOnChange}
/>
<div class="icon" on:click={bindingModal.show}>
<Icon size="S" name="FlashOn" />
</div>
</div>
<Modal bind:this={bindingModal}>
<ModalContent {title} onConfirm={saveBinding} disabled={!valid} size="XL">
<Body extraSmall grey>
Add the objects on the left to enrich your text.
</Body>
<div class="panel-wrapper">
<svelte:component
this={panel}
serverSide
value={readableValue}
bind:valid
on:change={e => (tempValue = e.detail)}
{bindings}
{allowJS}
/>
</div>
</ModalContent>
</Modal>
<style>
.control {
flex: 1;
position: relative;
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.panel-wrapper {
border: var(--border-light);
border-radius: 4px;
}
.control :global(.spectrum-Textfield-input) {
padding-right: 40px;
}
</style>
<DrawerBindableInput
{...$$props}
forceModal
on:change
on:blur
on:drawerHide
on:drawerShow
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,36 @@
import { Expectations, TestAPI } from "./base"
import { Row } from "@budibase/types"
import { Row, View, ViewCalculation } from "@budibase/types"
export class LegacyViewAPI extends TestAPI {
get = async (id: string, expectations?: Expectations) => {
return await this._get<Row[]>(`/api/views/${id}`, { expectations })
get = async (
id: string,
query?: { calculation: ViewCalculation; group?: string },
expectations?: Expectations
) => {
return await this._get<Row[]>(`/api/views/${id}`, { query, expectations })
}
save = async (body: View, expectations?: Expectations) => {
return await this._post<View>(`/api/views/`, { body, expectations })
}
fetch = async (expectations?: Expectations) => {
return await this._get<View[]>(`/api/views`, { expectations })
}
destroy = async (id: string, expectations?: Expectations) => {
return await this._delete<View>(`/api/views/${id}`, { expectations })
}
export = async (
viewName: string,
format: "json" | "csv" | "jsonWithSchema",
expectations?: Expectations
) => {
const response = await this._requestRaw("get", `/api/views/export`, {
query: { view: viewName, format },
expectations,
})
return response.text
}
}

View File

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

View File

@ -30,6 +30,7 @@ export interface View {
map?: string
reduce?: any
meta?: ViewTemplateOpts
groupBy?: string
}
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 as coreEnv } from "@budibase/backend-core"
import nodeFetch from "node-fetch"
// When we come to move to SQS fully and move away from Clouseau, we will need
// to flip this to true (or remove it entirely). This will then be used to
// determine if we should show the maintenance page that links to the SQS
// migration docs.
const sqsRequired = false
let sqsAvailable: boolean
async function isSqsAvailable() {
// We cache this value for the duration of the Node process because we don't
// want every page load to be making this relatively expensive check.
if (sqsAvailable !== undefined) {
return sqsAvailable
}
@ -21,6 +29,10 @@ async function isSqsAvailable() {
}
}
async function isSqsMissing() {
return sqsRequired && !(await isSqsAvailable())
}
export const fetch = async (ctx: Ctx) => {
ctx.body = {
multiTenancy: !!env.MULTI_TENANCY,
@ -30,11 +42,12 @@ export const fetch = async (ctx: Ctx) => {
disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL,
baseUrl: env.PLATFORM_URL,
isDev: env.isDev() && !env.isTest(),
maintenance: [],
}
if (env.SELF_HOSTED) {
ctx.body.infrastructure = {
sqs: await isSqsAvailable(),
if (await isSqsMissing()) {
ctx.body.maintenance.push({ type: MaintenanceType.SQS_MISSING })
}
}
}

View File

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

View File

@ -8242,11 +8242,16 @@ codemirror-spell-checker@1.1.2:
dependencies:
typo-js "*"
codemirror@^5.59.0, codemirror@^5.63.1:
codemirror@^5.63.1:
version "5.65.12"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.12.tgz#294fdf097d10ac5b56a9e011a91eff252afc73ae"
integrity sha512-z2jlHBocElRnPYysN2HAuhXbO3DNB0bcSKmNz3hcWR2Js2Dkhc1bEOxG93Z3DeUrnm+qx56XOY5wQmbP5KY0sw==
codemirror@^5.65.16:
version "5.65.16"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.16.tgz#efc0661be6bf4988a6a1c2fe6893294638cdb334"
integrity sha512-br21LjYmSlVL0vFCPWPfhzUCT34FM/pAdK7rRIZwa0rrtrIdotvP4Oh4GUHsu2E3IrQMCfRkL/fN3ytMNxVQvg==
collect-v8-coverage@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"
@ -13944,6 +13949,11 @@ json-buffer@3.0.1:
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-format-highlight@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/json-format-highlight/-/json-format-highlight-1.0.4.tgz#2e44277edabcec79a3d2c84e984c62e2258037b9"
integrity sha512-RqenIjKr1I99XfXPAml9G7YlEZg/GnsH7emWyWJh2yuGXqHW8spN7qx6/ME+MoIBb35/fxrMC9Jauj6nvGe4Mg==
json-parse-better-errors@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"