Merge pull request #15403 from Budibase/bindings-panel

Bindings explorer panel
This commit is contained in:
Andrew Kingston 2025-01-23 15:52:08 +00:00 committed by GitHub
commit 5139485eb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 622 additions and 224 deletions

View File

@ -80,7 +80,7 @@
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"easymde": "^2.16.1", "easymde": "^2.16.1",
"svelte-dnd-action": "^0.9.8", "svelte-dnd-action": "^0.9.8",
"svelte-portal": "^1.0.0" "svelte-portal": "^2.2.1"
}, },
"resolutions": { "resolutions": {
"loader-utils": "1.4.1" "loader-utils": "1.4.1"

View File

@ -1,3 +1,17 @@
type ClickOutsideCallback = (event: MouseEvent) => void | undefined
interface ClickOutsideOpts {
callback?: ClickOutsideCallback
anchor?: HTMLElement
}
interface Handler {
id: number
element: HTMLElement
anchor: HTMLElement
callback?: ClickOutsideCallback
}
// These class names will never trigger a callback if clicked, no matter what // These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [ const ignoredClasses = [
".download-js-link", ".download-js-link",
@ -14,18 +28,20 @@ const conditionallyIgnoredClasses = [
".drawer-wrapper", ".drawer-wrapper",
".spectrum-Popover", ".spectrum-Popover",
] ]
let clickHandlers = [] let clickHandlers: Handler[] = []
let candidateTarget let candidateTarget: HTMLElement | undefined
// Processes a "click outside" event and invokes callbacks if our source element // Processes a "click outside" event and invokes callbacks if our source element
// is valid // is valid
const handleClick = event => { const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
// Ignore click if this is an ignored class // Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) { if (target.closest('[data-ignore-click-outside="true"]')) {
return return
} }
for (let className of ignoredClasses) { for (let className of ignoredClasses) {
if (event.target.closest(className)) { if (target.closest(className)) {
return return
} }
} }
@ -33,41 +49,41 @@ const handleClick = event => {
// Process handlers // Process handlers
clickHandlers.forEach(handler => { clickHandlers.forEach(handler => {
// Check that the click isn't inside the target // Check that the click isn't inside the target
if (handler.element.contains(event.target)) { if (handler.element.contains(target)) {
return return
} }
// Ignore clicks for certain classes unless we're nested inside them // Ignore clicks for certain classes unless we're nested inside them
for (let className of conditionallyIgnoredClasses) { for (let className of conditionallyIgnoredClasses) {
const sourceInside = handler.anchor.closest(className) != null const sourceInside = handler.anchor.closest(className) != null
const clickInside = event.target.closest(className) != null const clickInside = target.closest(className) != null
if (clickInside && !sourceInside) { if (clickInside && !sourceInside) {
return return
} }
} }
handler.callback?.(event) handler.callback?.(e)
}) })
} }
// On mouse up we only trigger a "click outside" callback if we targetted the // On mouse up we only trigger a "click outside" callback if we targetted the
// same element that we did on mouse down. This fixes all sorts of issues where // same element that we did on mouse down. This fixes all sorts of issues where
// we get annoying callbacks firing when we drag to select text. // we get annoying callbacks firing when we drag to select text.
const handleMouseUp = e => { const handleMouseUp = (e: MouseEvent) => {
if (candidateTarget === e.target) { if (candidateTarget === e.target) {
handleClick(e) handleClick(e)
} }
candidateTarget = null candidateTarget = undefined
} }
// On mouse down we store which element was targetted for comparison later // On mouse down we store which element was targetted for comparison later
const handleMouseDown = e => { const handleMouseDown = (e: MouseEvent) => {
// Only handle the primary mouse button here. // Only handle the primary mouse button here.
// We handle context menu (right click) events in another handler. // We handle context menu (right click) events in another handler.
if (e.button !== 0) { if (e.button !== 0) {
return return
} }
candidateTarget = e.target candidateTarget = e.target as HTMLElement
// Clear any previous listeners in case of multiple down events, and register // Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener // a single mouse up listener
@ -82,7 +98,12 @@ document.addEventListener("contextmenu", handleClick)
/** /**
* Adds or updates a click handler * Adds or updates a click handler
*/ */
const updateHandler = (id, element, anchor, callback) => { const updateHandler = (
id: number,
element: HTMLElement,
anchor: HTMLElement,
callback: ClickOutsideCallback | undefined
) => {
let existingHandler = clickHandlers.find(x => x.id === id) let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) { if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback }) clickHandlers.push({ id, element, anchor, callback })
@ -94,27 +115,52 @@ const updateHandler = (id, element, anchor, callback) => {
/** /**
* Removes a click handler * Removes a click handler
*/ */
const removeHandler = id => { const removeHandler = (id: number) => {
clickHandlers = clickHandlers.filter(x => x.id !== id) clickHandlers = clickHandlers.filter(x => x.id !== id)
} }
/** /**
* Svelte action to apply a click outside handler for a certain element * Svelte action to apply a click outside handler for a certain element.
* opts.anchor is an optional param specifying the real root source of the * opts.anchor is an optional param specifying the real root source of the
* component being observed. This is required for things like popovers, where * component being observed. This is required for things like popovers, where
* the element using the clickoutside action is the popover, but the popover is * the element using the clickoutside action is the popover, but the popover is
* rendered at the root of the DOM somewhere, whereas the popover anchor is the * rendered at the root of the DOM somewhere, whereas the popover anchor is the
* element we actually want to consider when determining the source component. * element we actually want to consider when determining the source component.
*/ */
export default (element, opts) => { export default (
element: HTMLElement,
opts?: ClickOutsideOpts | ClickOutsideCallback
) => {
const id = Math.random() const id = Math.random()
const update = newOpts => {
const callback = const isCallback = (
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null) opts?: ClickOutsideOpts | ClickOutsideCallback
const anchor = newOpts?.anchor || element ): opts is ClickOutsideCallback => {
return typeof opts === "function"
}
const isOpts = (
opts?: ClickOutsideOpts | ClickOutsideCallback
): opts is ClickOutsideOpts => {
return opts != null && typeof opts === "object"
}
const update = (newOpts?: ClickOutsideOpts | ClickOutsideCallback) => {
let callback: ClickOutsideCallback | undefined
let anchor = element
if (isCallback(newOpts)) {
callback = newOpts
} else if (isOpts(newOpts)) {
callback = newOpts.callback
if (newOpts.anchor) {
anchor = newOpts.anchor
}
}
updateHandler(id, element, anchor, callback) updateHandler(id, element, anchor, callback)
} }
update(opts) update(opts)
return { return {
update, update,
destroy: () => removeHandler(id), destroy: () => removeHandler(id),

View File

@ -1,10 +1,4 @@
/** import { PopoverAlignment } from "../constants"
* Valid alignment options are
* - left
* - right
* - left-outside
* - right-outside
**/
// Strategies are defined as [Popover]To[Anchor]. // Strategies are defined as [Popover]To[Anchor].
// They can apply for both horizontal and vertical alignment. // They can apply for both horizontal and vertical alignment.
@ -149,20 +143,29 @@ export default function positionDropdown(element, opts) {
} }
// Determine X strategy // Determine X strategy
if (align === "right") { if (align === PopoverAlignment.Right) {
applyXStrategy(Strategies.EndToEnd) applyXStrategy(Strategies.EndToEnd)
} else if (align === "right-outside" || align === "right-context-menu") { } else if (
align === PopoverAlignment.RightOutside ||
align === PopoverAlignment.RightContextMenu
) {
applyXStrategy(Strategies.StartToEnd) applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside" || align === "left-context-menu") { } else if (
align === PopoverAlignment.LeftOutside ||
align === PopoverAlignment.LeftContextMenu
) {
applyXStrategy(Strategies.EndToStart) applyXStrategy(Strategies.EndToStart)
} else if (align === "center") { } else if (align === PopoverAlignment.Center) {
applyXStrategy(Strategies.MidPoint) applyXStrategy(Strategies.MidPoint)
} else { } else {
applyXStrategy(Strategies.StartToStart) applyXStrategy(Strategies.StartToStart)
} }
// Determine Y strategy // Determine Y strategy
if (align === "right-outside" || align === "left-outside") { if (
align === PopoverAlignment.RightOutside ||
align === PopoverAlignment.LeftOutside
) {
applyYStrategy(Strategies.MidPoint) applyYStrategy(Strategies.MidPoint)
} else if ( } else if (
align === "right-context-menu" || align === "right-context-menu" ||

View File

@ -1,13 +1,10 @@
<script lang="ts"> <script lang="ts">
import { import AbsTooltip from "../Tooltip/AbsTooltip.svelte"
default as AbsTooltip, import { TooltipPosition, TooltipType } from "../constants"
TooltipPosition,
TooltipType,
} from "../Tooltip/AbsTooltip.svelte"
export let name: string = "Add" export let name: string = "Add"
export let size: "XS" | "S" | "M" | "L" | "XL" = "M"
export let hidden: boolean = false export let hidden: boolean = false
export let size = "M"
export let hoverable: boolean = false export let hoverable: boolean = false
export let disabled: boolean = false export let disabled: boolean = false
export let color: string | undefined = undefined export let color: string | undefined = undefined
@ -81,17 +78,6 @@
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;
} }
.tooltip {
position: absolute;
pointer-events: none;
left: 50%;
bottom: calc(100% + 4px);
transform: translateX(-50%);
text-align: center;
z-index: 1;
}
.spectrum-Icon--sizeXS { .spectrum-Icon--sizeXS {
width: var(--spectrum-global-dimension-size-150); width: var(--spectrum-global-dimension-size-150);
height: var(--spectrum-global-dimension-size-150); height: var(--spectrum-global-dimension-size-150);

View File

@ -9,8 +9,8 @@
export let primary = false export let primary = false
export let secondary = false export let secondary = false
export let overBackground = false export let overBackground = false
export let target export let target = undefined
export let download export let download = undefined
export let disabled = false export let disabled = false
export let tooltip = null export let tooltip = null

View File

@ -1,4 +1,11 @@
<script> <script context="module" lang="ts">
export interface PopoverAPI {
show: () => void
hide: () => void
}
</script>
<script lang="ts">
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { createEventDispatcher, getContext, onDestroy } from "svelte" import { createEventDispatcher, getContext, onDestroy } from "svelte"
@ -6,34 +13,39 @@
import clickOutside from "../Actions/click_outside" import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import Context from "../context" import Context from "../context"
import { PopoverAlignment } from "../constants"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let anchor export let anchor: HTMLElement | undefined = undefined
export let align = "right" export let align: PopoverAlignment = PopoverAlignment.Right
export let portalTarget export let portalTarget: string | undefined = undefined
export let minWidth export let minWidth: number | undefined = undefined
export let maxWidth export let maxWidth: number | undefined = undefined
export let maxHeight export let maxHeight: number | undefined = undefined
export let open = false export let open: boolean = false
export let useAnchorWidth = false export let useAnchorWidth: boolean = false
export let dismissible = true export let dismissible: boolean = true
export let offset = 4 export let offset: number = 4
export let customHeight export let customHeight: string | undefined = undefined
export let animate = true export let animate: boolean = true
export let customZindex export let customZIndex: number | undefined = undefined
export let handlePostionUpdate export let handlePositionUpdate: Function | undefined = undefined
export let showPopover = true export let showPopover: boolean = true
export let clickOutsideOverride = false export let clickOutsideOverride: boolean = false
export let resizable = true export let resizable: boolean = true
export let wrap = false export let wrap: boolean = false
const animationDuration = 260 const animationDuration = 260
let timeout let timeout: ReturnType<typeof setTimeout> | undefined
let blockPointerEvents = false let blockPointerEvents = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" // Portal library lacks types, so we have to type this as any even though it's
// actually a string
$: target = (portalTarget ||
getContext(Context.PopoverRoot) ||
".spectrum") as any
$: { $: {
// Disable pointer events for the initial part of the animation, because we // Disable pointer events for the initial part of the animation, because we
// fly from top to bottom and initially can be positioned under the cursor, // fly from top to bottom and initially can be positioned under the cursor,
@ -65,13 +77,13 @@
} }
} }
const handleOutsideClick = e => { const handleOutsideClick = (e: MouseEvent) => {
if (clickOutsideOverride) { if (clickOutsideOverride) {
return return
} }
if (open) { if (open) {
// Stop propagation if the source is the anchor // Stop propagation if the source is the anchor
let node = e.target let node = e.target as Node
let fromAnchor = false let fromAnchor = false
while (!fromAnchor && node && node.parentNode) { while (!fromAnchor && node && node.parentNode) {
fromAnchor = node === anchor fromAnchor = node === anchor
@ -86,7 +98,7 @@
} }
} }
function handleEscape(e) { function handleEscape(e: KeyboardEvent) {
if (!clickOutsideOverride) { if (!clickOutsideOverride) {
return return
} }
@ -113,7 +125,7 @@
minWidth, minWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
customUpdate: handlePostionUpdate, customUpdate: handlePositionUpdate,
resizable, resizable,
wrap, wrap,
}} }}
@ -123,11 +135,11 @@
}} }}
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class="spectrum-Popover is-open"
class:customZindex class:customZIndex
class:hidden={!showPopover} class:hidden={!showPopover}
class:blockPointerEvents class:blockPointerEvents
role="presentation" role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};" style="height: {customHeight}; --customZIndex: {customZIndex};"
transition:fly|local={{ transition:fly|local={{
y: -20, y: -20,
duration: animate ? animationDuration : 0, duration: animate ? animationDuration : 0,
@ -157,7 +169,7 @@
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.customZindex { .customZIndex {
z-index: var(--customZindex) !important; z-index: var(--customZIndex) !important;
} }
</style> </style>

View File

@ -1,38 +1,24 @@
<script context="module"> <script lang="ts">
export const TooltipPosition = {
Top: "top",
Right: "right",
Bottom: "bottom",
Left: "left",
}
export const TooltipType = {
Default: "default",
Info: "info",
Positive: "positive",
Negative: "negative",
}
</script>
<script>
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import "@spectrum-css/tooltip/dist/index-vars.css" import "@spectrum-css/tooltip/dist/index-vars.css"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { TooltipPosition, TooltipType } from "../constants"
export let position = TooltipPosition.Top export let position: TooltipPosition = TooltipPosition.Top
export let type = TooltipType.Default export let type: TooltipType = TooltipType.Default
export let text = "" export let text: string = ""
export let fixed = false export let fixed: boolean = false
export let color = "" export let color: string | undefined = undefined
export let noWrap = false export let noWrap: boolean = false
let wrapper let wrapper: HTMLElement | undefined
let hovered = false let hovered = false
let left let left: number | undefined
let top let top: number | undefined
let visible = false let visible = false
let timeout let timeout: ReturnType<typeof setTimeout> | undefined
let interval let interval: ReturnType<typeof setInterval> | undefined
$: { $: {
if (hovered || fixed) { if (hovered || fixed) {
@ -49,8 +35,8 @@
const updateTooltipPosition = () => { const updateTooltipPosition = () => {
const node = wrapper?.children?.[0] const node = wrapper?.children?.[0]
if (!node) { if (!node) {
left = null left = undefined
top = null top = undefined
return return
} }
const bounds = node.getBoundingClientRect() const bounds = node.getBoundingClientRect()

View File

@ -0,0 +1,23 @@
export enum PopoverAlignment {
Left = "left",
Right = "right",
LeftOutside = "left-outside",
RightOutside = "right-outside",
Center = "center",
RightContextMenu = "right-context-menu",
LeftContextMenu = "left-context-menu",
}
export enum TooltipPosition {
Top = "top",
Right = "right",
Bottom = "bottom",
Left = "left",
}
export enum TooltipType {
Default = "default",
Info = "info",
Positive = "positive",
Negative = "negative",
}

View File

@ -1,8 +1,9 @@
import "./bbui.css" import "./bbui.css"
// Spectrum icons
import "@spectrum-css/icon/dist/index-vars.css" import "@spectrum-css/icon/dist/index-vars.css"
// Constants
export * from "./constants"
// Form components // Form components
export { default as Input } from "./Form/Input.svelte" export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte" export { default as Stepper } from "./Form/Stepper.svelte"
@ -45,7 +46,7 @@ export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon } from "./Icon/Icon.svelte" export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover, type PopoverAPI } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte" export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte"
export { default as Label } from "./Label/Label.svelte" export { default as Label } from "./Label/Label.svelte"
@ -92,7 +93,6 @@ export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte" export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte" export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte" export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
// Renderers // Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"

View File

@ -16,4 +16,4 @@
}, },
"include": ["./src/**/*"], "include": ["./src/**/*"],
"exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"] "exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"]
} }

View File

@ -81,7 +81,7 @@
"shortid": "2.2.15", "shortid": "2.2.15",
"svelte-dnd-action": "^0.9.8", "svelte-dnd-action": "^0.9.8",
"svelte-loading-spinners": "^0.1.1", "svelte-loading-spinners": "^0.1.1",
"svelte-portal": "1.0.0", "svelte-portal": "^2.2.1",
"yup": "^0.32.11" "yup": "^0.32.11"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,14 +1,20 @@
<script> <script lang="ts">
import { Popover, Icon } from "@budibase/bbui" import {
Popover,
PopoverAlignment,
Icon,
type PopoverAPI,
} from "@budibase/bbui"
export let title export let title: string = ""
export let align = "left" export let subtitle: string | undefined = undefined
export let showPopover export let align: PopoverAlignment = PopoverAlignment.Left
export let width export let showPopover: boolean = true
export let width: number | undefined = undefined
let popover let popover: PopoverAPI | undefined
let anchor let anchor: HTMLElement | undefined
let open let open: boolean = false
export const show = () => popover?.show() export const show = () => popover?.show()
export const hide = () => popover?.hide() export const hide = () => popover?.hide()
@ -30,20 +36,24 @@
{showPopover} {showPopover}
on:open on:open
on:close on:close
customZindex={100} customZIndex={100}
> >
<div class="detail-popover"> <div class="detail-popover">
<div class="detail-popover__header"> <div class="detail-popover__header">
<div class="detail-popover__title"> <div class="detail-popover__title">
{title} {title}
<Icon
name="Close"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={hide}
size="S"
/>
</div> </div>
<Icon {#if subtitle}
name="Close" <div class="detail-popover__subtitle">{subtitle}</div>
hoverable {/if}
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectum-global-color-gray-900)"
on:click={hide}
/>
</div> </div>
<div class="detail-popover__body"> <div class="detail-popover__body">
<slot /> <slot />
@ -56,14 +66,18 @@
background-color: var(--spectrum-alias-background-color-primary); background-color: var(--spectrum-alias-background-color-primary);
} }
.detail-popover__header { .detail-popover__header {
display: flex;
flex-direction: column;
align-items: stretch;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
padding: var(--spacing-l) var(--spacing-xl);
gap: var(--spacing-s);
}
.detail-popover__title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
padding: var(--spacing-l) var(--spacing-xl);
}
.detail-popover__title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
} }

View File

@ -0,0 +1,282 @@
<script context="module" lang="ts">
interface JSONViewerClickContext {
label: string | undefined
value: any
path: (string | number)[]
}
export interface JSONViewerClickEvent {
detail: JSONViewerClickContext
}
</script>
<script lang="ts">
import { Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
export let label: string | undefined = undefined
export let value: any = undefined
export let root: boolean = true
export let path: (string | number)[] = []
export let showCopyIcon: boolean = false
const dispatch = createEventDispatcher()
const Colors = {
Array: "var(--spectrum-global-color-gray-600)",
Object: "var(--spectrum-global-color-gray-600)",
Other: "var(--spectrum-global-color-blue-700)",
Undefined: "var(--spectrum-global-color-gray-600)",
Null: "var(--spectrum-global-color-yellow-700)",
String: "var(--spectrum-global-color-orange-700)",
Number: "var(--spectrum-global-color-purple-700)",
True: "var(--spectrum-global-color-celery-700)",
False: "var(--spectrum-global-color-red-700)",
Date: "var(--spectrum-global-color-green-700)",
}
let expanded = false
let valueExpanded = false
let clickContext: JSONViewerClickContext
$: isArray = Array.isArray(value)
$: isObject = value?.toString?.() === "[object Object]"
$: primitive = !(isArray || isObject)
$: keys = getKeys(isArray, isObject, value)
$: expandable = keys.length > 0
$: displayValue = getDisplayValue(isArray, isObject, keys, value)
$: style = getStyle(isArray, isObject, value)
$: clickContext = { value, label, path }
const getKeys = (isArray: boolean, isObject: boolean, value: any) => {
if (isArray) {
return [...value.keys()]
}
if (isObject) {
return Object.keys(value).sort()
}
return []
}
const pluralise = (text: string, number: number) => {
return number === 1 ? text : text + "s"
}
const getDisplayValue = (
isArray: boolean,
isObject: boolean,
keys: any[],
value: any
) => {
if (isArray) {
return `[] ${keys.length} ${pluralise("item", keys.length)}`
}
if (isObject) {
return `{} ${keys.length} ${pluralise("key", keys.length)}`
}
if (typeof value === "object" && typeof value?.toString === "function") {
return value.toString()
} else {
return JSON.stringify(value, null, 2)
}
}
const getStyle = (isArray: boolean, isObject: boolean, value: any) => {
return `color:${getColor(isArray, isObject, value)};`
}
const getColor = (isArray: boolean, isObject: boolean, value: any) => {
if (isArray) {
return Colors.Array
}
if (isObject) {
return Colors.Object
}
if (value instanceof Date) {
return Colors.Date
}
switch (value) {
case undefined:
return Colors.Undefined
case null:
return Colors.Null
case true:
return Colors.True
case false:
return Colors.False
}
switch (typeof value) {
case "string":
return Colors.String
case "number":
return Colors.Number
}
return Colors.Other
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="binding-node">
{#if label != null}
<div class="binding-text">
<div class="binding-arrow" class:expanded>
{#if expandable}
<Icon
name="Play"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={() => (expanded = !expanded)}
/>
{/if}
</div>
<div
class="binding-label"
class:primitive
class:expandable
on:click={() => (expanded = !expanded)}
on:click={() => dispatch("click-label", clickContext)}
>
{label}
</div>
<div
class="binding-value"
class:primitive
class:expanded={valueExpanded}
{style}
on:click={() => (valueExpanded = !valueExpanded)}
on:click={() => dispatch("click-value", clickContext)}
>
{displayValue}
</div>
{#if showCopyIcon}
<div class="copy-value-icon">
<Icon
name="Copy"
size="XS"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectrum-global-color-gray-900)"
on:click={() => dispatch("click-copy", clickContext)}
/>
</div>
{/if}
</div>
{/if}
{#if expandable && (expanded || label == null)}
<div class="binding-children" class:root>
{#each keys as key}
<svelte:self
label={key}
value={value[key]}
root={false}
path={[...path, key]}
{showCopyIcon}
on:click-label
on:click-value
on:click-copy
/>
{/each}
</div>
{/if}
</div>
<style>
.binding-node {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
/* Expand arrow */
.binding-arrow {
margin: -3px 6px -2px 4px;
flex: 0 0 9px;
transition: transform 130ms ease-out;
}
.binding-arrow :global(svg) {
width: 9px;
}
.binding-arrow.expanded {
transform: rotate(90deg);
}
/* Main text wrapper */
.binding-text {
display: flex;
flex-direction: row;
font-family: monospace;
font-size: 12px;
align-items: flex-start;
width: 100%;
}
/* Size label and value according to type */
.binding-label {
flex: 0 1 auto;
margin-right: 8px;
transition: color 130ms ease-out;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.binding-label.expandable:hover {
cursor: pointer;
color: var(--spectrum-global-color-gray-900);
}
.binding-value {
flex: 0 0 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition: filter 130ms ease-out;
user-select: none;
}
.binding-value.primitive:hover {
filter: brightness(1.25);
cursor: pointer;
}
.binding-value.expanded {
word-break: break-all;
white-space: wrap;
}
.binding-label.primitive {
flex: 0 0 auto;
max-width: 75%;
}
.binding-value.primitive {
flex: 0 1 auto;
}
/* Trim spans in the highlighted HTML */
.binding-value :global(span) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
/* Copy icon for value */
.copy-value-icon {
display: none;
margin-left: 8px;
}
.binding-text:hover .copy-value-icon {
display: block;
}
/* Children wrapper */
.binding-children {
display: flex;
flex-direction: column;
gap: 8px;
border-left: 1px solid var(--spectrum-global-color-gray-400);
margin-left: 20px;
padding-left: 3px;
}
.binding-children.root {
border-left: none;
margin-left: 0;
padding-left: 0;
}
</style>

View File

@ -25,7 +25,7 @@
</div> </div>
<Popover <Popover
customZindex={998} customZIndex={998}
bind:this={formPopover} bind:this={formPopover}
align="center" align="center"
anchor={formPopoverAnchor} anchor={formPopoverAnchor}

View File

@ -96,8 +96,8 @@
maxWidth={300} maxWidth={300}
dismissible={false} dismissible={false}
offset={12} offset={12}
handlePostionUpdate={tourStep?.positionHandler} handlePositionUpdate={tourStep?.positionHandler}
customZindex={3} customZIndex={3}
> >
<div class="tour-content"> <div class="tour-content">
<Layout noPadding gap="M"> <Layout noPadding gap="M">

View File

@ -30,7 +30,7 @@
if (id === `${$screenStore.selectedScreenId}-screen`) return true if (id === `${$screenStore.selectedScreenId}-screen`) return true
if (id === `${$screenStore.selectedScreenId}-navigation`) return true if (id === `${$screenStore.selectedScreenId}-navigation`) return true
return !!findComponent($selectedScreen.props, id) return !!findComponent($selectedScreen?.props, id)
} }
// Keep URL and state in sync for selected component ID // Keep URL and state in sync for selected component ID

View File

@ -3,9 +3,6 @@
import AppPreview from "./AppPreview.svelte" import AppPreview from "./AppPreview.svelte"
import { screenStore, appStore } from "@/stores/builder" import { screenStore, appStore } from "@/stores/builder"
import UndoRedoControl from "@/components/common/UndoRedoControl.svelte" import UndoRedoControl from "@/components/common/UndoRedoControl.svelte"
import { ActionButton } from "@budibase/bbui"
import BindingsPanel from "./BindingsPanel.svelte"
import StatePanel from "./StatePanel.svelte"
</script> </script>
<div class="app-panel"> <div class="app-panel">
@ -13,14 +10,12 @@
<div class="header"> <div class="header">
<div class="header-left"> <div class="header-left">
<UndoRedoControl store={screenStore.history} /> <UndoRedoControl store={screenStore.history} />
</div>
<div class="header-right">
{#if $appStore.clientFeatures.devicePreview} {#if $appStore.clientFeatures.devicePreview}
<DevicePreviewSelect /> <DevicePreviewSelect />
{/if} {/if}
</div> </div>
<div class="header-right">
<BindingsPanel />
<StatePanel />
</div>
</div> </div>
<div class="content"> <div class="content">
{#key $appStore.version} {#key $appStore.version}

View File

@ -1,14 +1,99 @@
<script> <script lang="ts">
import { ActionButton, Modal, ModalContent } from "@budibase/bbui" import { onMount } from "svelte"
import { Link, Body, Helpers, Layout, notifications } from "@budibase/bbui"
import { processObjectSync } from "@budibase/string-templates"
import {
previewStore,
selectedScreen,
componentStore,
snippets,
} from "@/stores/builder"
import { getBindableProperties } from "@/dataBinding"
import JSONViewer, {
type JSONViewerClickEvent,
} from "@/components/common/JSONViewer.svelte"
let visible = false // Minimal typing for the real data binding structure, as none exists
let modal type DataBinding = {
category: string
runtimeBinding: string
readableBinding: string
}
$: previewContext = $previewStore.selectedComponentContext || {}
$: selectedComponentId = $componentStore.selectedComponentId
$: context = makeContext(previewContext, bindings)
$: bindings = getBindableProperties($selectedScreen, selectedComponentId)
const makeContext = (
previewContext: Record<string, any>,
bindings: DataBinding[]
) => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
// Account for nasty hardcoded HBS bindings for roles, for legacy
// compatibility
return `{{ ${binding.runtimeBinding} }}`
} else {
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvaluations = processObjectSync(bindingStrings, {
...previewContext,
snippets: $snippets,
}) as any[]
// Deeply set values for all readable bindings
const enrichedBindings: any[] = bindings.map((binding, idx) => {
return {
...binding,
value: bindingEvaluations[idx],
}
})
let context = {}
for (let binding of enrichedBindings) {
Helpers.deepSet(context, binding.readableBinding, binding.value)
}
return context
}
const copyBinding = (e: JSONViewerClickEvent) => {
const readableBinding = `{{ ${e.detail.path.join(".")} }}`
Helpers.copyToClipboard(readableBinding)
notifications.success("Binding copied to clipboard")
}
onMount(previewStore.requestComponentContext)
</script> </script>
<ActionButton on:click={modal.show}>Bindings</ActionButton> <div class="bindings-panel">
<Layout noPadding gap="S">
<div class="text">
<Body size="S">Showing all available bindings.</Body>
<Link
target="_blank"
href="https://docs.budibase.com/docs/introduction-to-bindings"
>
Learn more.
</Link>
</div>
<JSONViewer value={context} showCopyIcon on:click-copy={copyBinding} />
</Layout>
</div>
<Modal bind:this={modal}> <style>
<ModalContent title="Bindings" showConfirmButton={false} cancelText="Close"> .bindings-panel {
Some awesome bindings content. flex: 1 1 auto;
</ModalContent> height: 0;
</Modal> overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-l);
}
.text {
display: flex;
flex-direction: row;
gap: 4px;
flex-wrap: wrap;
}
</style>

View File

@ -1,6 +1,5 @@
<script> <script>
import { notifications, Icon, Body } from "@budibase/bbui" import { notifications, Icon } from "@budibase/bbui"
import { isActive, goto } from "@roxi/routify"
import { import {
selectedScreen, selectedScreen,
screenStore, screenStore,
@ -13,23 +12,12 @@
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import { dndStore, DropPosition } from "./dndStore.js" import { dndStore, DropPosition } from "./dndStore.js"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte" import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
import getScreenContextMenuItems from "./getScreenContextMenuItems" import getScreenContextMenuItems from "./getScreenContextMenuItems"
let scrolling = false
$: screenComponentId = `${$screenStore.selectedScreenId}-screen` $: screenComponentId = `${$screenStore.selectedScreenId}-screen`
$: navComponentId = `${$screenStore.selectedScreenId}-navigation` $: navComponentId = `${$screenStore.selectedScreenId}-navigation`
const toNewComponentRoute = () => {
if ($isActive(`./:componentId/new`)) {
$goto(`./:componentId`)
} else {
$goto(`./:componentId/new`)
}
}
const onDrop = async () => { const onDrop = async () => {
try { try {
await dndStore.actions.drop() await dndStore.actions.drop()
@ -39,10 +27,6 @@
} }
} }
const handleScroll = e => {
scrolling = e.target.scrollTop !== 0
}
const hover = hoverStore.hover const hover = hoverStore.hover
// showCopy is used to hide the copy button when the user right-clicks the empty // showCopy is used to hide the copy button when the user right-clicks the empty
@ -72,17 +56,9 @@
} }
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="components"> <div class="components">
<div class="header" class:scrolling>
<Body size="S">Components</Body>
<div on:click={toNewComponentRoute} class="addButton">
<Icon name="Add" />
</div>
</div>
<div class="list-panel"> <div class="list-panel">
<ComponentScrollWrapper on:scroll={handleScroll}> <ComponentScrollWrapper>
<ul <ul
class="componentTree" class="componentTree"
on:contextmenu={e => openScreenContextMenu(e, false)} on:contextmenu={e => openScreenContextMenu(e, false)}
@ -159,7 +135,6 @@
</ul> </ul>
</ComponentScrollWrapper> </ComponentScrollWrapper>
</div> </div>
<ComponentKeyHandler />
</div> </div>
<style> <style>
@ -168,35 +143,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
} padding-top: var(--spacing-l);
.header {
height: 50px;
box-sizing: border-box;
padding: var(--spacing-l);
display: flex;
align-items: center;
border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out;
}
.header.scrolling {
border-bottom: var(--border-light);
} }
.components :global(.nav-item) { .components :global(.nav-item) {
padding-right: 8px !important; padding-right: 8px !important;
} }
.addButton {
margin-left: auto;
color: var(--grey-7);
cursor: pointer;
}
.addButton:hover {
color: var(--ink);
}
.list-panel { .list-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -3,13 +3,15 @@
import ComponentList from "./ComponentList/index.svelte" import ComponentList from "./ComponentList/index.svelte"
import { getHorizontalResizeActions } from "@/components/common/resizable" import { getHorizontalResizeActions } from "@/components/common/resizable"
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import BindingsPanel from "./BindingsPanel.svelte"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
const [resizable, resizableHandle] = getHorizontalResizeActions() const [resizable, resizableHandle] = getHorizontalResizeActions()
enum Tabs { const Tabs = {
Components = "Components", Components: "Components",
Bindings = "Bindings", Bindings: "Bindings",
State = "State", State: "State",
} }
let activeTab = Tabs.Components let activeTab = Tabs.Components
@ -32,7 +34,7 @@
{#if activeTab === Tabs.Components} {#if activeTab === Tabs.Components}
<ComponentList /> <ComponentList />
{:else if activeTab === Tabs.Bindings} {:else if activeTab === Tabs.Bindings}
<div class="tab-content">Bindings</div> <BindingsPanel />
{:else if activeTab === Tabs.State} {:else if activeTab === Tabs.State}
<div class="tab-content">State</div> <div class="tab-content">State</div>
{/if} {/if}
@ -41,6 +43,7 @@
<div class="dividerClickExtender" role="separator" use:resizableHandle /> <div class="dividerClickExtender" role="separator" use:resizableHandle />
</div> </div>
</div> </div>
<ComponentKeyHandler />
<style> <style>
.panel { .panel {
@ -68,6 +71,10 @@
border-bottom: var(--border-light); border-bottom: var(--border-light);
} }
.tab-content { .tab-content {
flex: 1 1 auto;
height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-l); padding: var(--spacing-l);
} }

View File

@ -1,7 +1,6 @@
<script> <script>
import { ActionButton, Modal, ModalContent } from "@budibase/bbui" import { ActionButton, Modal, ModalContent } from "@budibase/bbui"
let visible = false
let modal let modal
</script> </script>

View File

@ -40,26 +40,33 @@ function setupEnv(hosting, features = {}, flags = {}) {
describe("AISettings", () => { describe("AISettings", () => {
let instance = null let instance = null
const setupDOM = () => {
instance = render(AISettings, {})
const modalContainer = document.createElement("div")
modalContainer.classList.add("modal-container")
instance.baseElement.appendChild(modalContainer)
}
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it("that the AISettings is rendered", () => { it("that the AISettings is rendered", () => {
instance = render(AISettings, {}) setupDOM()
expect(instance).toBeDefined() expect(instance).toBeDefined()
}) })
describe("Licensing", () => { describe("Licensing", () => {
it("should show the premium label on self host for custom configs", async () => { it("should show the premium label on self host for custom configs", async () => {
setupEnv(Hosting.Self) setupEnv(Hosting.Self)
instance = render(AISettings, {}) setupDOM()
const premiumTag = instance.queryByText("Premium") const premiumTag = instance.queryByText("Premium")
expect(premiumTag).toBeInTheDocument() expect(premiumTag).toBeInTheDocument()
}) })
it("should show the enterprise label on cloud for custom configs", async () => { it("should show the enterprise label on cloud for custom configs", async () => {
setupEnv(Hosting.Cloud) setupEnv(Hosting.Cloud)
instance = render(AISettings, {}) setupDOM()
const enterpriseTag = instance.queryByText("Enterprise") const enterpriseTag = instance.queryByText("Enterprise")
expect(enterpriseTag).toBeInTheDocument() expect(enterpriseTag).toBeInTheDocument()
}) })
@ -69,7 +76,7 @@ describe("AISettings", () => {
let configModal let configModal
setupEnv(Hosting.Cloud) setupEnv(Hosting.Cloud)
instance = render(AISettings) setupDOM()
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton) await fireEvent.click(addConfigurationButton)
@ -86,7 +93,7 @@ describe("AISettings", () => {
{ customAIConfigsEnabled: true }, { customAIConfigsEnabled: true },
{ AI_CUSTOM_CONFIGS: true } { AI_CUSTOM_CONFIGS: true }
) )
instance = render(AISettings) setupDOM()
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton) await fireEvent.click(addConfigurationButton)
@ -103,7 +110,7 @@ describe("AISettings", () => {
{ customAIConfigsEnabled: true }, { customAIConfigsEnabled: true },
{ AI_CUSTOM_CONFIGS: true } { AI_CUSTOM_CONFIGS: true }
) )
instance = render(AISettings) setupDOM()
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton) await fireEvent.click(addConfigurationButton)

View File

@ -18997,10 +18997,10 @@ svelte-loading-spinners@^0.1.1:
resolved "https://registry.yarnpkg.com/svelte-loading-spinners/-/svelte-loading-spinners-0.1.7.tgz#3fa6fa0ef67ab635773bf20b07d0b071debbadaa" resolved "https://registry.yarnpkg.com/svelte-loading-spinners/-/svelte-loading-spinners-0.1.7.tgz#3fa6fa0ef67ab635773bf20b07d0b071debbadaa"
integrity sha512-EKCId1DjVL2RSUVJJsvtNcqQHox03XIgh4xh/4p7r6ST7d8mut6INY9/LqK4A17PFU64+3quZmqiSfOlf480CA== integrity sha512-EKCId1DjVL2RSUVJJsvtNcqQHox03XIgh4xh/4p7r6ST7d8mut6INY9/LqK4A17PFU64+3quZmqiSfOlf480CA==
svelte-portal@1.0.0, svelte-portal@^1.0.0: svelte-portal@^2.2.1:
version "1.0.0" version "2.2.1"
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-1.0.0.tgz#36a47c5578b1a4d9b4dc60fa32a904640ec4cdd3" resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-2.2.1.tgz#b1d7bed78e56318db245996beb5483d8de6b9740"
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q== integrity sha512-uF7is5sM4aq5iN7QF/67XLnTUvQCf2iiG/B1BHTqLwYVY1dsVmTeXZ/LeEyU6dLjApOQdbEG9lkqHzxiQtOLEQ==
svelte-spa-router@^4.0.1: svelte-spa-router@^4.0.1:
version "4.0.1" version "4.0.1"