Merge branch 'master' of github.com:Budibase/budibase into feature/pre-empt-data-source-deletion

This commit is contained in:
mike12345567 2025-02-04 15:44:53 +00:00
commit 6c0e33a33c
92 changed files with 1769 additions and 824 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.3.6", "version": "3.4.1",
"npmClient": "yarn", "npmClient": "yarn",
"concurrency": 20, "concurrency": 20,
"command": { "command": {

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,13 +1,7 @@
/**
* 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.
import { PopoverAlignment } from "../constants"
type Strategy = type Strategy =
| "StartToStart" | "StartToStart"
| "EndToEnd" | "EndToEnd"
@ -33,7 +27,7 @@ export type UpdateHandler = (
interface Opts { interface Opts {
anchor?: HTMLElement anchor?: HTMLElement
align: string align: PopoverAlignment
maxHeight?: number maxHeight?: number
maxWidth?: number maxWidth?: number
minWidth?: number minWidth?: number
@ -174,24 +168,33 @@ export default function positionDropdown(element: HTMLElement, opts: Opts) {
} }
// Determine X strategy // Determine X strategy
if (align === "right") { if (align === PopoverAlignment.Right) {
applyXStrategy("EndToEnd") applyXStrategy("EndToEnd")
} else if (align === "right-outside" || align === "right-context-menu") { } else if (
align === PopoverAlignment.RightOutside ||
align === PopoverAlignment.RightContextMenu
) {
applyXStrategy("StartToEnd") applyXStrategy("StartToEnd")
} else if (align === "left-outside" || align === "left-context-menu") { } else if (
align === PopoverAlignment.LeftOutside ||
align === PopoverAlignment.LeftContextMenu
) {
applyXStrategy("EndToStart") applyXStrategy("EndToStart")
} else if (align === "center") { } else if (align === PopoverAlignment.Center) {
applyXStrategy("MidPoint") applyXStrategy("MidPoint")
} else { } else {
applyXStrategy("StartToStart") applyXStrategy("StartToStart")
} }
// Determine Y strategy // Determine Y strategy
if (align === "right-outside" || align === "left-outside") { if (
align === PopoverAlignment.RightOutside ||
align === PopoverAlignment.LeftOutside
) {
applyYStrategy("MidPoint") applyYStrategy("MidPoint")
} else if ( } else if (
align === "right-context-menu" || align === PopoverAlignment.RightContextMenu ||
align === "left-context-menu" align === PopoverAlignment.LeftContextMenu
) { ) {
applyYStrategy("StartToStart") applyYStrategy("StartToStart")
if (styles.top) { if (styles.top) {
@ -204,11 +207,11 @@ export default function positionDropdown(element: HTMLElement, opts: Opts) {
// Handle screen overflow // Handle screen overflow
if (doesXOverflow()) { if (doesXOverflow()) {
// Swap left to right // Swap left to right
if (align === "left") { if (align === PopoverAlignment.Left) {
applyXStrategy("EndToEnd") applyXStrategy("EndToEnd")
} }
// Swap right-outside to left-outside // Swap right-outside to left-outside
else if (align === "right-outside") { else if (align === PopoverAlignment.RightOutside) {
applyXStrategy("EndToStart") applyXStrategy("EndToStart")
} }
} }
@ -225,10 +228,13 @@ export default function positionDropdown(element: HTMLElement, opts: Opts) {
applyXStrategy("EndToStart") applyXStrategy("EndToStart")
} }
} }
// Othewise invert as normal // Otherwise invert as normal
else { else {
// If using an outside strategy then lock to the bottom of the screen // If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") { if (
align === PopoverAlignment.LeftOutside ||
align === PopoverAlignment.RightOutside
) {
applyYStrategy("ScreenEdge") applyYStrategy("ScreenEdge")
} }
// Otherwise flip above // Otherwise flip above

View File

@ -11,6 +11,7 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside" import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte" import Popover from "../../Popover/Popover.svelte"
import { PopoverAlignment } from "../../constants"
export let value: string | undefined = undefined export let value: string | undefined = undefined
export let id: string | undefined = undefined export let id: string | undefined = undefined
@ -97,11 +98,16 @@
<Popover <Popover
{anchor} {anchor}
{open} {open}
align="left" align={PopoverAlignment.Left}
on:close={() => (open = false)} on:close={() => (open = false)}
useAnchorWidth useAnchorWidth
> >
<div class="popover-content" use:clickOutside={() => (open = false)}> <div
class="popover-content"
use:clickOutside={() => {
open = false
}}
>
<ul class="spectrum-Menu" role="listbox"> <ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)} {#if options && Array.isArray(options)}
{#each options as option} {#each options as option}

View File

@ -1,4 +1,9 @@
<script> <script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts">
import "@spectrum-css/picker/dist/index-vars.css" import "@spectrum-css/picker/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css"
@ -11,43 +16,55 @@
import Tags from "../../Tags/Tags.svelte" import Tags from "../../Tags/Tags.svelte"
import Tag from "../../Tags/Tag.svelte" import Tag from "../../Tags/Tag.svelte"
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte" import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
import { PopoverAlignment } from "../../constants"
export let id = null export let id: string | undefined = undefined
export let disabled = false export let disabled: boolean = false
export let fieldText = "" export let fieldText: string = ""
export let fieldIcon = "" export let fieldIcon: string = ""
export let fieldColour = "" export let fieldColour: string = ""
export let isPlaceholder = false export let isPlaceholder: boolean = false
export let placeholderOption = null export let placeholderOption: string | undefined | boolean = undefined
export let options = [] export let options: O[] = []
export let isOptionSelected = () => false export let isOptionSelected = (option: O) => option as unknown as boolean
export let isOptionEnabled = () => true export let isOptionEnabled = (option: O, _index?: number) =>
export let onSelectOption = () => {} option as unknown as boolean
export let getOptionLabel = option => option export let onSelectOption: (_value: V) => void = () => {}
export let getOptionValue = option => option export let getOptionLabel = (option: O, _index?: number) => `${option}`
export let getOptionIcon = () => null export let getOptionValue = (option: O, _index?: number) =>
option as unknown as V
export let getOptionIcon = (option: O, _index?: number) =>
option?.icon ?? undefined
export let getOptionColour = (option: O, _index?: number) =>
option?.colour ?? undefined
export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle ?? undefined
export let useOptionIconImage = false export let useOptionIconImage = false
export let getOptionColour = () => null export let open: boolean = false
export let getOptionSubtitle = () => null export let readonly: boolean = false
export let open = false export let quiet: boolean = false
export let readonly = false export let autoWidth: boolean | undefined = false
export let quiet = false export let autocomplete: boolean = false
export let autoWidth = false export let sort: boolean = false
export let autocomplete = false export let searchTerm: string | null = null
export let sort = false export let customPopoverHeight: string | undefined = undefined
export let searchTerm = null export let align: PopoverAlignment | undefined = PopoverAlignment.Left
export let customPopoverHeight export let footer: string | undefined = undefined
export let align = "left" export let customAnchor: HTMLElement | undefined = undefined
export let footer = null export let loading: boolean = false
export let customAnchor = null export let onOptionMouseenter: (
export let loading _e: MouseEvent,
export let onOptionMouseenter = () => {} _option: any
export let onOptionMouseleave = () => {} ) => void = () => {}
export let onOptionMouseleave: (
_e: MouseEvent,
_option: any
) => void = () => {}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let button let button: any
let component let component: any
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort) $: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
$: filteredOptions = getFilteredOptions( $: filteredOptions = getFilteredOptions(
@ -56,7 +73,7 @@
getOptionLabel getOptionLabel
) )
const onClick = e => { const onClick = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
dispatch("click") dispatch("click")
@ -67,7 +84,11 @@
open = !open open = !open
} }
const getSortedOptions = (options, getLabel, sort) => { const getSortedOptions = (
options: any[],
getLabel: (_option: any) => string,
sort: boolean
) => {
if (!options?.length || !Array.isArray(options)) { if (!options?.length || !Array.isArray(options)) {
return [] return []
} }
@ -81,17 +102,21 @@
}) })
} }
const getFilteredOptions = (options, term, getLabel) => { const getFilteredOptions = (
options: any[],
term: string | null,
getLabel: (_option: any) => string
) => {
if (autocomplete && term) { if (autocomplete && term) {
const lowerCaseTerm = term.toLowerCase() const lowerCaseTerm = term.toLowerCase()
return options.filter(option => { return options.filter((option: any) => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm) return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
}) })
} }
return options return options
} }
const onScroll = e => { const onScroll = (e: any) => {
const scrollPxThreshold = 100 const scrollPxThreshold = 100
const scrollPositionFromBottom = const scrollPositionFromBottom =
e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop
@ -151,18 +176,20 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover <Popover
anchor={customAnchor ? customAnchor : button} anchor={customAnchor ? customAnchor : button}
align={align || "left"} align={align || PopoverAlignment.Left}
{open} {open}
on:close={() => (open = false)} on:close={() => (open = false)}
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : undefined}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
maxHeight={360} maxHeight={360}
> >
<div <div
class="popover-content" class="popover-content"
class:auto-width={autoWidth} class:auto-width={autoWidth}
use:clickOutside={() => (open = false)} use:clickOutside={() => {
open = false
}}
> >
{#if autocomplete} {#if autocomplete}
<Search <Search

View File

@ -1,19 +1,19 @@
<script> <script lang="ts">
import "@spectrum-css/search/dist/index-vars.css" import "@spectrum-css/search/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = null export let value: any = null
export let placeholder = null export let placeholder: string | undefined = undefined
export let disabled = false export let disabled = false
export let id = null export let id = null
export let updateOnChange = true export let updateOnChange = true
export let quiet = false export let quiet = false
export let inputRef export let inputRef: HTMLElement | undefined = undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let focus = false let focus = false
const updateValue = value => { const updateValue = (value: any) => {
dispatch("change", value) dispatch("change", value)
} }
@ -21,19 +21,19 @@
focus = true focus = true
} }
const onBlur = event => { const onBlur = (event: any) => {
focus = false focus = false
updateValue(event.target.value) updateValue(event.target.value)
} }
const onInput = event => { const onInput = (event: any) => {
if (!updateOnChange) { if (!updateOnChange) {
return return
} }
updateValue(event.target.value) updateValue(event.target.value)
} }
const updateValueOnEnter = event => { const updateValueOnEnter = (event: any) => {
if (event.key === "Enter") { if (event.key === "Enter") {
updateValue(event.target.value) updateValue(event.target.value)
} }

View File

@ -1,33 +1,44 @@
<script> <script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import Picker from "./Picker.svelte" import Picker from "./Picker.svelte"
import { PopoverAlignment } from "../../constants"
export let value = null export let value: V | null = null
export let id = null export let id: string | undefined = undefined
export let placeholder = "Choose an option" export let placeholder: string | boolean = "Choose an option"
export let disabled = false export let disabled: boolean = false
export let options = [] export let options: O[] = []
export let getOptionLabel = option => option export let getOptionLabel = (option: O, _index?: number) => `${option}`
export let getOptionValue = option => option export let getOptionValue = (option: O, _index?: number) =>
export let getOptionIcon = () => null option as unknown as V
export let getOptionColour = () => null export let getOptionIcon = (option: O, _index?: number) =>
export let getOptionSubtitle = () => null option?.icon ?? undefined
export let compare = null export let getOptionColour = (option: O, _index?: number) =>
option?.colour ?? undefined
export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle ?? undefined
export let compare = (option: O, value: V) => option === value
export let useOptionIconImage = false export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled = (option: O, _index?: number) =>
export let readonly = false option as unknown as boolean
export let quiet = false export let readonly: boolean = false
export let autoWidth = false export let quiet: boolean = false
export let autocomplete = false export let autoWidth: boolean = false
export let sort = false export let autocomplete: boolean = false
export let align export let sort: boolean = false
export let footer = null export let align: PopoverAlignment | undefined = PopoverAlignment.Left
export let open = false export let footer: string | undefined = undefined
export let tag = null export let open: boolean = false
export let searchTerm = null export let searchTerm: string | undefined = undefined
export let loading export let loading: boolean | undefined = undefined
export let onOptionMouseenter = () => {} export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {} export let onOptionMouseleave = () => {}
export let customPopoverHeight: string | undefined = undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -35,24 +46,28 @@
$: fieldIcon = getFieldAttribute(getOptionIcon, value, options) $: fieldIcon = getFieldAttribute(getOptionIcon, value, options)
$: fieldColour = getFieldAttribute(getOptionColour, value, options) $: fieldColour = getFieldAttribute(getOptionColour, value, options)
function compareOptionAndValue(option, value) { function compareOptionAndValue(option: O, value: V) {
return typeof compare === "function" return typeof compare === "function"
? compare(option, value) ? compare(option, value)
: option === value : option === value
} }
const getFieldAttribute = (getAttribute, value, options) => { const getFieldAttribute = (getAttribute: any, value: V[], options: O[]) => {
// Wait for options to load if there is a value but no options // Wait for options to load if there is a value but no options
if (!options?.length) { if (!options?.length) {
return "" return ""
} }
const index = options.findIndex((option, idx) => const index = options.findIndex((option: any, idx: number) =>
compareOptionAndValue(getOptionValue(option, idx), value) compareOptionAndValue(getOptionValue(option, idx), value)
) )
return index !== -1 ? getAttribute(options[index], index) : null return index !== -1 ? getAttribute(options[index], index) : null
} }
const getFieldText = (value, options, placeholder) => { const getFieldText = (
value: any,
options: any,
placeholder: boolean | string
) => {
if (value == null || value === "") { if (value == null || value === "") {
// Explicit false means use no placeholder and allow an empty fields // Explicit false means use no placeholder and allow an empty fields
if (placeholder === false) { if (placeholder === false) {
@ -67,7 +82,7 @@
) )
} }
const selectOption = value => { const selectOption = (value: V) => {
dispatch("change", value) dispatch("change", value)
open = false open = false
} }
@ -98,14 +113,14 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{sort} {sort}
{tag}
{onOptionMouseenter} {onOptionMouseenter}
{onOptionMouseleave} {onOptionMouseleave}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false placeholderOption={placeholder === false
? null ? undefined
: placeholder || "Choose an option"} : placeholder || "Choose an option"}
isOptionSelected={option => compareOptionAndValue(option, value)} isOptionSelected={option => compareOptionAndValue(option, value)}
onSelectOption={selectOption} onSelectOption={selectOption}
{loading} {loading}
{customPopoverHeight}
/> />

View File

@ -4,23 +4,23 @@
import type { UIEvent } from "@budibase/types" import type { UIEvent } from "@budibase/types"
export let value: string | null = null export let value: string | null = null
export let placeholder: string | null = null export let placeholder: string | undefined = undefined
export let type = "text" export let type = "text"
export let disabled = false export let disabled = false
export let id = null export let id = null
export let readonly = false export let readonly = false
export let updateOnChange = true export let updateOnChange = true
export let quiet = false export let quiet = false
export let align: string | null = null export let align: "left" | "right" | "center" | undefined = undefined
export let autofocus: boolean | null = false export let autofocus: boolean | null = false
export let autocomplete: string | null = null export let autocomplete: boolean | undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let field: any let field: any
let focus = false let focus = false
const updateValue = (newValue: string | number | null) => { const updateValue = (newValue: any) => {
if (readonly || disabled) { if (readonly || disabled) {
return return
} }
@ -69,6 +69,13 @@
return type === "number" ? "decimal" : "text" return type === "number" ? "decimal" : "text"
} }
$: autocompleteValue =
typeof autocomplete === "boolean"
? autocomplete
? "on"
: "off"
: undefined
onMount(async () => { onMount(async () => {
if (disabled) return if (disabled) return
focus = autofocus || false focus = autofocus || false
@ -105,7 +112,7 @@
class="spectrum-Textfield-input" class="spectrum-Textfield-input"
style={align ? `text-align: ${align};` : ""} style={align ? `text-align: ${align};` : ""}
inputmode={getInputMode(type)} inputmode={getInputMode(type)}
{autocomplete} autocomplete={autocompleteValue}
/> />
</div> </div>

View File

@ -3,12 +3,12 @@
import FieldLabel from "./FieldLabel.svelte" import FieldLabel from "./FieldLabel.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
export let id: string | null = null export let id: string | undefined = undefined
export let label: string | null = null export let label: string | undefined = undefined
export let labelPosition = "above" export let labelPosition: string = "above"
export let error: string | null = null export let error: string | undefined = undefined
export let helpText: string | null = null export let helpText: string | undefined = undefined
export let tooltip = "" export let tooltip: string | undefined = undefined
</script> </script>
<div class="spectrum-Form-item" class:above={labelPosition === "above"}> <div class="spectrum-Form-item" class:above={labelPosition === "above"}>

View File

@ -3,19 +3,19 @@
import TextField from "./Core/TextField.svelte" import TextField from "./Core/TextField.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value: string | null = null export let value: any = undefined
export let label: string | null = null export let label: string | undefined = undefined
export let labelPosition = "above" export let labelPosition = "above"
export let placeholder: string | null = null export let placeholder: string | undefined = undefined
export let type = "text" export let type = "text"
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let error = null export let error: string | undefined = undefined
export let updateOnChange = true export let updateOnChange = true
export let quiet = false export let quiet = false
export let autofocus: boolean | null = null export let autofocus: boolean | undefined = undefined
export let autocomplete: string | null = null export let autocomplete: boolean | undefined = undefined
export let helpText = null export let helpText: string | undefined = undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = (e: any) => { const onChange = (e: any) => {

View File

@ -1,44 +1,54 @@
<script> <script lang="ts" context="module">
type O = any
type V = any
</script>
<script lang="ts">
import Field from "./Field.svelte" import Field from "./Field.svelte"
import Select from "./Core/Select.svelte" import Select from "./Core/Select.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { PopoverAlignment } from "../constants"
export let value = null export let value: V | undefined = undefined
export let label = undefined export let label: string | undefined = undefined
export let disabled = false export let disabled: boolean = false
export let readonly = false export let readonly: boolean = false
export let labelPosition = "above" export let labelPosition: string = "above"
export let error = null export let error: string | undefined = undefined
export let placeholder = "Choose an option" export let placeholder: string | boolean = "Choose an option"
export let options = [] export let options: O[] = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = (option: O, _index?: number) =>
export let getOptionValue = option => extractProperty(option, "value") extractProperty(option, "label")
export let getOptionSubtitle = option => option?.subtitle export let getOptionValue = (option: O, _index?: number) =>
export let getOptionIcon = option => option?.icon extractProperty(option, "value")
export let getOptionColour = option => option?.colour export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle
export let getOptionIcon = (option: O, _index?: number) => option?.icon
export let getOptionColour = (option: O, _index?: number) => option?.colour
export let useOptionIconImage = false export let useOptionIconImage = false
export let isOptionEnabled = undefined export let isOptionEnabled:
export let quiet = false | ((_option: O, _index?: number) => boolean)
export let autoWidth = false | undefined = undefined
export let sort = false export let quiet: boolean = false
export let tooltip = "" export let autoWidth: boolean = false
export let autocomplete = false export let sort: boolean = false
export let customPopoverHeight = undefined export let tooltip: string | undefined = undefined
export let align = undefined export let autocomplete: boolean = false
export let footer = null export let customPopoverHeight: string | undefined = undefined
export let tag = null export let align: PopoverAlignment | undefined = PopoverAlignment.Left
export let helpText = null export let footer: string | undefined = undefined
export let compare = undefined export let helpText: string | undefined = undefined
export let compare: any = undefined
export let onOptionMouseenter = () => {} export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {} export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = (e: CustomEvent<any>) => {
value = e.detail value = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
const extractProperty = (value, property) => { const extractProperty = (value: any, property: any) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
} }
@ -49,7 +59,6 @@
<Field {helpText} {label} {labelPosition} {error} {tooltip}> <Field {helpText} {label} {labelPosition} {error} {tooltip}>
<Select <Select
{quiet} {quiet}
{error}
{disabled} {disabled}
{readonly} {readonly}
{value} {value}
@ -68,7 +77,6 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
{tag}
{compare} {compare}
{onOptionMouseenter} {onOptionMouseenter}
{onOptionMouseleave} {onOptionMouseleave}

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 = null export let download = undefined
export let disabled = false export let disabled = false
export let tooltip = null export let tooltip = null

View File

@ -1,6 +1,12 @@
<script context="module" lang="ts">
export interface PopoverAPI {
show: () => void
hide: () => void
}
</script>
<script lang="ts"> <script lang="ts">
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
// @ts-expect-error no types for the version of svelte-portal we're on.
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { createEventDispatcher, getContext, onDestroy } from "svelte" import { createEventDispatcher, getContext, onDestroy } from "svelte"
import positionDropdown, { import positionDropdown, {
@ -10,12 +16,10 @@
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import Context from "../context" import Context from "../context"
import type { KeyboardEventHandler } from "svelte/elements" import type { KeyboardEventHandler } from "svelte/elements"
import { PopoverAlignment } from "../constants"
const dispatch = createEventDispatcher<{ open: void; close: void }>()
export let anchor: HTMLElement export let anchor: HTMLElement
export let align: "left" | "right" | "left-outside" | "right-outside" = export let align: PopoverAlignment = PopoverAlignment.Right
"right"
export let portalTarget: string | undefined = undefined export let portalTarget: string | undefined = undefined
export let minWidth: number | undefined = undefined export let minWidth: number | undefined = undefined
export let maxWidth: number | undefined = undefined export let maxWidth: number | undefined = undefined
@ -26,19 +30,24 @@
export let offset = 4 export let offset = 4
export let customHeight: string | undefined = undefined export let customHeight: string | undefined = undefined
export let animate = true export let animate = true
export let customZindex: string | undefined = undefined export let customZIndex: number | undefined = undefined
export let handlePostionUpdate: UpdateHandler | undefined = undefined export let handlePositionUpdate: UpdateHandler | undefined = undefined
export let showPopover = true export let showPopover = true
export let clickOutsideOverride = false export let clickOutsideOverride = false
export let resizable = true export let resizable = true
export let wrap = false export let wrap = false
const dispatch = createEventDispatcher<{ open: void; close: void }>()
const animationDuration = 260 const animationDuration = 260
let timeout: ReturnType<typeof setTimeout> let timeout: ReturnType<typeof setTimeout>
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,
@ -118,7 +127,7 @@
minWidth, minWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
customUpdate: handlePostionUpdate, customUpdate: handlePositionUpdate,
resizable, resizable,
wrap, wrap,
}} }}
@ -128,11 +137,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,
@ -162,7 +171,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,120 +0,0 @@
<script>
import { View } from "svench";
import Popover from "./Popover.svelte";
import Button from "../Button/Button.svelte";
import TextButton from "../Button/TextButton.svelte";
import Icon from "../Icons/Icon.svelte";
import Input from "../Form/Input.svelte";
import Select from "../Form/Select.svelte";
let anchorRight;
let anchorLeft;
let dropdownRight;
let dropdownLeft;
const options = ["Column 1", "Column 2", "Super cool column"];
const option1s = ["Is", "Is not", "Contains" , "Does not contain"];
</script>
<style>
.button-group {
margin-top: var(--spacing-l);
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
h6 {
font-size: var(--font-size-m);
margin: 0 0 var(--spacing-l) 0;
font-weight: 600;
}
.input-group-column {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.input-group-row {
display: grid;
grid-template-columns: [boolean-start] 60px [boolean-end property-start] 120px [property-end opererator-start] 110px [operator-end value-start] auto [value-end menu-start] 32px [menu-end];
gap: var(--spacing-s);
margin-bottom: var(--spacing-l);
align-items: center;
}
p {
margin:0;
font-size: var(--font-size-xs);
}
</style>
<View name="Simple popover">
<div bind:this={anchorLeft}>
<Button text on:click={dropdownLeft.show}>
<Icon name="view" />
Add View
</Button>
</div>
<Popover bind:this={dropdownLeft} anchor={anchorLeft} align="left">
<h6>Add New View</h6>
<Input thin placeholder="Enter your name" />
<div class="button-group">
<Button secondary on:click={() => alert('Clicked!')}>Cancel</Button>
<Button primary on:click={() => alert('Clicked!')}>Add New View</Button>
</div>
</Popover>
</View>
<View name="Stacked columns">
<div bind:this={anchorRight}>
<Button text on:click={dropdownRight.show}>
<Icon name="addrow" />
Add Row
</Button>
</div>
<Popover bind:this={dropdownRight} anchor={anchorRight}>
<h6>Add New Row</h6>
<div class="input-group-column">
<Input thin placeholder="Enter your string" />
<Input thin placeholder="Enter your string" />
<Input thin placeholder="Enter your string" />
</div>
<div class="button-group">
<Button secondary on:click={() => alert('Clicked!')}>Cancel</Button>
<Button primary on:click={() => alert('Clicked!')}>Add New Row</Button>
</div>
</Popover>
</View>
<View name="Multiple inputs in a row">
<div bind:this={anchorLeft}>
<Button text on:click={dropdownLeft.show}>
<Icon name="filter" />
Add Filter
</Button>
</div>
<Popover bind:this={dropdownLeft} anchor={anchorLeft} align="left">
<h6>Add New Filter</h6>
<div class="input-group-row">
<p>Where</p>
<Select secondary thin name="Test">
{#each options as option}
<option value={option}>{option}</option>
{/each}
</Select>
<Select secondary thin name="Test">
{#each option1s as option1}
<option value={option1}>{option1}</option>
{/each}
</Select>
<Input thin placeholder="Enter your name" />
<Button text on:click={() => alert('Clicked!')}>
<Icon name="close" />
</Button>
</div>
<Button text on:click={() => alert('Clicked!')}>Add Filter</Button>
</Popover>
</View>

View File

@ -1,25 +1,25 @@
<script> <script lang="ts">
import "@spectrum-css/statuslight" import "@spectrum-css/statuslight"
export let size = "M" export let size: string = "M"
export let celery = false export let celery: boolean = false
export let yellow = false export let yellow: boolean = false
export let fuchsia = false export let fuchsia: boolean = false
export let indigo = false export let indigo: boolean = false
export let seafoam = false export let seafoam: boolean = false
export let chartreuse = false export let chartreuse: boolean = false
export let magenta = false export let magenta: boolean = false
export let purple = false export let purple: boolean = false
export let neutral = false export let neutral: boolean = false
export let info = false export let info: boolean = false
export let positive = false export let positive: boolean = false
export let notice = false export let notice: boolean = false
export let negative = false export let negative: boolean = false
export let disabled = false export let disabled: boolean = false
export let active = false export let active: boolean = false
export let color = null export let color: string | undefined = undefined
export let square = false export let square: boolean = false
export let hoverable = false export let hoverable: boolean = false
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->

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,
Icon,
PopoverAlignment,
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,281 @@
<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;
}
.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

@ -1,4 +1,4 @@
<script> <script lang="ts">
import { Icon, Input, Drawer, Button } from "@budibase/bbui" import { Icon, Input, Drawer, Button } from "@budibase/bbui"
import { import {
readableToRuntimeBinding, readableToRuntimeBinding,
@ -10,25 +10,25 @@
import { builderStore } from "@/stores/builder" import { builderStore } from "@/stores/builder"
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let value = "" export let value: any = ""
export let bindings = [] export let bindings: any[] = []
export let title export let title: string | undefined = undefined
export let placeholder export let placeholder: string | undefined = undefined
export let label export let label: string | undefined = undefined
export let disabled = false export let disabled: boolean = false
export let allowHBS = true export let allowHBS: boolean = true
export let allowJS = true export let allowJS: boolean = true
export let allowHelpers = true export let allowHelpers: boolean = true
export let updateOnChange = true export let updateOnChange: boolean = true
export let key export let key: string | null = null
export let disableBindings = false export let disableBindings: boolean = false
export let forceModal = false export let forceModal: boolean = false
export let context = null export let context = null
export let autocomplete export let autocomplete: boolean | undefined = undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer: any
let currentVal = value let currentVal = value
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
@ -38,7 +38,7 @@
const saveBinding = () => { const saveBinding = () => {
onChange(tempValue) onChange(tempValue)
onBlur() onBlur()
builderStore.propertyFocus() builderStore.propertyFocus(null)
bindingDrawer.hide() bindingDrawer.hide()
} }
@ -46,7 +46,7 @@
save: saveBinding, save: saveBinding,
}) })
const onChange = value => { const onChange = (value: any) => {
currentVal = readableToRuntimeBinding(bindings, value) currentVal = readableToRuntimeBinding(bindings, value)
dispatch("change", currentVal) dispatch("change", currentVal)
} }
@ -55,8 +55,8 @@
dispatch("blur", currentVal) dispatch("blur", currentVal)
} }
const onDrawerHide = e => { const onDrawerHide = (e: any) => {
builderStore.propertyFocus() builderStore.propertyFocus(null)
dispatch("drawerHide", e.detail) dispatch("drawerHide", e.detail)
} }
</script> </script>

View File

@ -61,6 +61,7 @@
anchor={primaryDisplayColumnAnchor} anchor={primaryDisplayColumnAnchor}
item={columns.primary} item={columns.primary}
on:change={e => columns.update(e.detail)} on:change={e => columns.update(e.detail)}
{bindings}
/> />
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@
export let item export let item
export let anchor export let anchor
export let bindings
let draggableStore = writable({ let draggableStore = writable({
selected: null, selected: null,
@ -48,6 +49,7 @@
componentInstance={item} componentInstance={item}
{parseSettings} {parseSettings}
on:change on:change
{bindings}
> >
<div slot="header" class="type-icon"> <div slot="header" class="type-icon">
<Icon name={icon} /> <Icon name={icon} />

View File

@ -69,6 +69,7 @@ const toGridFormat = draggableListColumns => {
active: entry.active, active: entry.active,
width: entry.width, width: entry.width,
conditions: entry.conditions, conditions: entry.conditions,
format: entry.format,
})) }))
} }
@ -85,6 +86,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
columnType: column.columnType || schema[column.field].type, columnType: column.columnType || schema[column.field].type,
width: column.width, width: column.width,
conditions: column.conditions, conditions: column.conditions,
format: column.format,
}, },
{} {}
) )

View File

@ -5,7 +5,6 @@
runtimeToReadableBinding, runtimeToReadableBinding,
} from "@/dataBinding" } from "@/dataBinding"
import { builderStore } from "@/stores/builder" import { builderStore } from "@/stores/builder"
import { onDestroy } from "svelte"
export let label = "" export let label = ""
export let labelHidden = false export let labelHidden = false
@ -32,7 +31,7 @@
$: safeValue = getSafeValue(value, defaultValue, allBindings) $: safeValue = getSafeValue(value, defaultValue, allBindings)
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val) $: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
$: if (!Array.isArray(value)) { $: if (value) {
highlightType = highlightType =
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : "" highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
} }
@ -75,12 +74,6 @@
? defaultValue ? defaultValue
: enriched : enriched
} }
onDestroy(() => {
if (highlightedProp) {
builderStore.highlightSetting(null)
}
})
</script> </script>
<div <div
@ -150,10 +143,10 @@
.property-control.highlighted { .property-control.highlighted {
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-static-red-600); border-color: var(--spectrum-global-color-static-red-600);
margin-top: -3.5px; margin-top: -4px;
margin-bottom: -3.5px; margin-bottom: -4px;
padding-bottom: 3.5px; padding-bottom: 4px;
padding-top: 3.5px; padding-top: 4px;
} }
.property-control.property-focus :global(input) { .property-control.property-focus :global(input) {
@ -172,7 +165,7 @@
} }
.text { .text {
font-size: var(--spectrum-global-dimension-font-size-75); font-size: var(--spectrum-global-dimension-font-size-75);
color: var(--grey-6); color: var(--spectrum-global-color-gray-700);
grid-column: 2 / 2; grid-column: 2 / 2;
} }

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

@ -1159,10 +1159,17 @@ export const buildFormSchema = (component, asset) => {
* Returns an array of the keys of any state variables which are set anywhere * Returns an array of the keys of any state variables which are set anywhere
* in the app. * in the app.
*/ */
export const getAllStateVariables = () => { export const getAllStateVariables = screen => {
// Find all button action settings in all components let assets = []
if (screen) {
// only include state variables from a specific screen
assets.push(screen)
} else {
// otherwise include state variables from all screens
assets = getAllAssets()
}
let eventSettings = [] let eventSettings = []
getAllAssets().forEach(asset => { assets.forEach(asset => {
findAllMatchingComponents(asset.props, component => { findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._component) const settings = componentStore.getComponentSettings(component._component)
const nestedTypes = [ const nestedTypes = [
@ -1214,11 +1221,17 @@ export const getAllStateVariables = () => {
}) })
// Add on load settings from screens // Add on load settings from screens
get(screenStore).screens.forEach(screen => { if (screen) {
if (screen.onLoad) { if (screen.onLoad) {
eventSettings.push(screen.onLoad) eventSettings.push(screen.onLoad)
} }
}) } else {
get(screenStore).screens.forEach(screen => {
if (screen.onLoad) {
eventSettings.push(screen.onLoad)
}
})
}
// Extract all state keys from any "update state" actions in each setting // Extract all state keys from any "update state" actions in each setting
let bindingSet = new Set() let bindingSet = new Set()

View File

@ -16,6 +16,7 @@
} from "@/dataBinding" } from "@/dataBinding"
import { ActionButton, notifications } from "@budibase/bbui" import { ActionButton, notifications } from "@budibase/bbui"
import { capitalise } from "@/helpers" import { capitalise } from "@/helpers"
import { builderStore } from "@/stores/builder"
import TourWrap from "@/components/portal/onboarding/TourWrap.svelte" import TourWrap from "@/components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "@/components/portal/onboarding/tours.js" import { TOUR_STEP_KEYS } from "@/components/portal/onboarding/tours.js"
@ -55,6 +56,17 @@
$: id = $selectedComponent?._id $: id = $selectedComponent?._id
$: id, (section = tabs[0]) $: id, (section = tabs[0])
$: componentName = getComponentName(componentInstance) $: componentName = getComponentName(componentInstance)
$: highlightedSetting = $builderStore.highlightedSetting
$: if (highlightedSetting) {
if (highlightedSetting.key === "_conditions") {
section = "conditions"
} else if (highlightedSetting.key === "_styles") {
section = "styles"
} else {
section = "settings"
}
}
</script> </script>
{#if $selectedComponent} {#if $selectedComponent}
@ -98,7 +110,7 @@
{/each} {/each}
</div> </div>
</span> </span>
{#if section == "settings"} {#if section === "settings"}
<TourWrap <TourWrap
stepKeys={[ stepKeys={[
BUILDER_FORM_CREATE_STEPS, BUILDER_FORM_CREATE_STEPS,
@ -115,7 +127,7 @@
/> />
</TourWrap> </TourWrap>
{/if} {/if}
{#if section == "styles"} {#if section === "styles"}
<DesignSection <DesignSection
{componentInstance} {componentInstance}
{componentBindings} {componentBindings}
@ -130,7 +142,7 @@
componentTitle={title} componentTitle={title}
/> />
{/if} {/if}
{#if section == "conditions"} {#if section === "conditions"}
<ConditionalUISection <ConditionalUISection
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}

View File

@ -190,7 +190,7 @@
<Icon name="DragHandle" size="XL" /> <Icon name="DragHandle" size="XL" />
</div> </div>
<Select <Select
placeholder={null} placeholder={false}
options={actionOptions} options={actionOptions}
bind:value={condition.action} bind:value={condition.action}
/> />
@ -227,7 +227,7 @@
on:change={e => (condition.newValue = e.detail)} on:change={e => (condition.newValue = e.detail)}
/> />
<Select <Select
placeholder={null} placeholder={false}
options={getOperatorOptions(condition)} options={getOperatorOptions(condition)}
bind:value={condition.operator} bind:value={condition.operator}
on:change={e => onOperatorChange(condition, e.detail)} on:change={e => onOperatorChange(condition, e.detail)}
@ -236,7 +236,7 @@
disabled={condition.noValue || condition.operator === "oneOf"} disabled={condition.noValue || condition.operator === "oneOf"}
options={valueTypeOptions} options={valueTypeOptions}
bind:value={condition.valueType} bind:value={condition.valueType}
placeholder={null} placeholder={false}
on:change={e => onValueTypeChange(condition, e.detail)} on:change={e => onValueTypeChange(condition, e.detail)}
/> />
{#if ["string", "number"].includes(condition.valueType)} {#if ["string", "number"].includes(condition.valueType)}

View File

@ -9,6 +9,7 @@
import { componentStore } from "@/stores/builder" import { componentStore } from "@/stores/builder"
import ConditionalUIDrawer from "./ConditionalUIDrawer.svelte" import ConditionalUIDrawer from "./ConditionalUIDrawer.svelte"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import { builderStore } from "@/stores/builder"
export let componentInstance export let componentInstance
export let componentDefinition export let componentDefinition
@ -18,6 +19,8 @@
let tempValue let tempValue
let drawer let drawer
$: highlighted = $builderStore.highlightedSetting?.key === "_conditions"
const openDrawer = () => { const openDrawer = () => {
tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? [])) tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? []))
drawer.show() drawer.show()
@ -52,7 +55,9 @@
/> />
<DetailSummary name={"Conditions"} collapsible={false}> <DetailSummary name={"Conditions"} collapsible={false}>
<ActionButton on:click={openDrawer}>{conditionText}</ActionButton> <div class:highlighted>
<ActionButton fullWidth on:click={openDrawer}>{conditionText}</ActionButton>
</div>
</DetailSummary> </DetailSummary>
<Drawer bind:this={drawer} title="Conditions"> <Drawer bind:this={drawer} title="Conditions">
<svelte:fragment slot="description"> <svelte:fragment slot="description">
@ -61,3 +66,13 @@
<Button cta slot="buttons" on:click={() => save()}>Save</Button> <Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} /> <ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
</Drawer> </Drawer>
<style>
.highlighted {
background: var(--spectrum-global-color-gray-300);
border-left: 4px solid var(--spectrum-semantic-informative-color-background);
transition: background 130ms ease-out, border-color 130ms ease-out;
margin: -4px calc(-1 * var(--spacing-xl));
padding: 4px var(--spacing-xl) 4px calc(var(--spacing-xl) - 4px);
}
</style>

View File

@ -16,6 +16,7 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "@/dataBinding" } from "@/dataBinding"
import { builderStore } from "@/stores/builder"
export let componentInstance export let componentInstance
export let componentDefinition export let componentDefinition
@ -32,6 +33,8 @@
$: icon = componentDefinition?.icon $: icon = componentDefinition?.icon
$: highlighted = $builderStore.highlightedSetting?.key === "_styles"
const openDrawer = () => { const openDrawer = () => {
tempValue = runtimeToReadableBinding( tempValue = runtimeToReadableBinding(
bindings, bindings,
@ -55,7 +58,7 @@
name={`Custom CSS${componentInstance?._styles?.custom ? " *" : ""}`} name={`Custom CSS${componentInstance?._styles?.custom ? " *" : ""}`}
collapsible={false} collapsible={false}
> >
<div> <div class:highlighted>
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton> <ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
</div> </div>
</DetailSummary> </DetailSummary>
@ -97,4 +100,12 @@
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.highlighted {
background: var(--spectrum-global-color-gray-300);
border-left: 4px solid var(--spectrum-semantic-informative-color-background);
transition: background 130ms ease-out, border-color 130ms ease-out;
margin: -4px calc(-1 * var(--spacing-xl));
padding: 4px var(--spacing-xl) 4px calc(var(--spacing-xl) - 4px);
}
</style> </style>

View File

@ -33,7 +33,7 @@
{/each} {/each}
</div> </div>
</div> </div>
<Layout gap="XS" paddingX="L" paddingY="XL"> <Layout gap="XS" paddingX="XL" paddingY="XL">
{#if activeTab === "theme"} {#if activeTab === "theme"}
<ThemePanel /> <ThemePanel />
{:else} {:else}

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

@ -50,6 +50,9 @@
margin-bottom: 9px; margin-bottom: 9px;
} }
.header-left {
display: flex;
}
.header-left :global(div) { .header-left :global(div) {
border-right: none; border-right: none;
} }

View File

@ -0,0 +1,83 @@
<script lang="ts">
import { onMount } from "svelte"
import { Helpers, 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"
// Minimal typing for the real data binding structure, as none exists
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>
<div class="bindings-panel">
<JSONViewer value={context} showCopyIcon on:click-copy={copyBinding} />
</div>
<style>
.bindings-panel {
flex: 1 1 auto;
height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-xl) var(--spacing-l) var(--spacing-l)
var(--spacing-l);
}
</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

@ -1,25 +1,60 @@
<script> <script lang="ts">
import ScreenList from "./ScreenList/index.svelte" import ScreenList from "./ScreenList/index.svelte"
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 StatePanel from "./StatePanel.svelte"
import BindingsPanel from "./BindingsPanel.svelte"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
const [resizable, resizableHandle] = getHorizontalResizeActions() const [resizable, resizableHandle] = getHorizontalResizeActions()
const Tabs = {
Components: "Components",
Bindings: "Bindings",
State: "State",
}
let activeTab = Tabs.Components
</script> </script>
<div class="panel" use:resizable> <div class="panel" use:resizable>
<div class="content"> <div class="content">
<ScreenList /> <ScreenList />
<ComponentList /> <div class="tabs">
{#each Object.values(Tabs) as tab}
<ActionButton
quiet
selected={activeTab === tab}
on:click={() => (activeTab = tab)}
>
<div class="tab-label">
{tab}
{#if tab !== Tabs.Components}
<div class="new">NEW</div>
{/if}
</div>
</ActionButton>
{/each}
</div>
{#if activeTab === Tabs.Components}
<ComponentList />
{:else if activeTab === Tabs.Bindings}
<BindingsPanel />
{:else if activeTab === Tabs.State}
<div class="tab-content"><StatePanel /></div>
{/if}
</div> </div>
<div class="divider"> <div class="divider">
<div class="dividerClickExtender" role="separator" use:resizableHandle /> <div class="dividerClickExtender" role="separator" use:resizableHandle />
</div> </div>
</div> </div>
<ComponentKeyHandler />
<style> <style>
.panel { .panel {
display: flex; display: flex;
min-width: 270px; min-width: 310px;
width: 310px; width: 310px;
height: 100%; height: 100%;
} }
@ -34,6 +69,34 @@
position: relative; position: relative;
} }
.tabs {
display: flex;
flex-direction: row;
gap: 8px;
padding: var(--spacing-m) var(--spacing-l);
border-bottom: var(--border-light);
}
.tab-content {
flex: 1 1 auto;
height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--spacing-l);
}
.tab-label {
display: flex;
align-items: center;
gap: 4px;
}
.new {
font-size: 8px;
background: var(--bb-indigo);
border-radius: 2px;
padding: 1px 3px;
color: white;
font-weight: bold;
}
.divider { .divider {
position: relative; position: relative;
height: 100%; height: 100%;
@ -45,7 +108,6 @@
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
cursor: row-resize; cursor: row-resize;
} }
.dividerClickExtender { .dividerClickExtender {
position: absolute; position: absolute;
cursor: col-resize; cursor: col-resize;

View File

@ -0,0 +1,336 @@
<script lang="ts">
import { onMount } from "svelte"
import { Select } from "@budibase/bbui"
import type {
Component,
ComponentCondition,
ComponentSetting,
EventHandler,
Screen,
} from "@budibase/types"
import { getAllStateVariables, getBindableProperties } from "@/dataBinding"
import {
componentStore,
selectedScreen,
builderStore,
previewStore,
} from "@/stores/builder"
import {
decodeJSBinding,
findHBSBlocks,
isJSBinding,
processStringSync,
} from "@budibase/string-templates"
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
interface ComponentUsingState {
id: string
name: string
setting: string
}
let selectedKey: string | undefined = undefined
let componentsUsingState: ComponentUsingState[] = []
let componentsUpdatingState: ComponentUsingState[] = []
let editorValue: string = ""
$: selectStateKey($selectedScreen, selectedKey)
$: keyOptions = getAllStateVariables($selectedScreen)
$: bindings = getBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
// Auto-select first valid state key
$: {
if (keyOptions.length && !keyOptions.includes(selectedKey)) {
selectedKey = keyOptions[0]
} else if (!keyOptions.length) {
selectedKey = undefined
}
}
const selectStateKey = (
screen: Screen | undefined,
key: string | undefined
) => {
if (screen && key) {
searchComponents(screen, key)
editorValue = $previewStore.selectedComponentContext?.state?.[key] ?? ""
} else {
editorValue = ""
componentsUsingState = []
componentsUpdatingState = []
}
}
const searchComponents = (screen: Screen, stateKey: string) => {
const { props, onLoad, _id } = screen
componentsUsingState = findComponentsUsingState(props, stateKey)
componentsUpdatingState = findComponentsUpdatingState(props, stateKey)
// Check screen load actions which are outside the component hierarchy
if (eventUpdatesState(onLoad, stateKey)) {
componentsUpdatingState.push({
id: _id!,
name: "Screen - On load",
setting: "onLoad",
})
}
}
// Checks if an event setting updates a certain state key
const eventUpdatesState = (
handlers: EventHandler[] | undefined,
stateKey: string
) => {
return handlers?.some(handler => {
return (
handler["##eventHandlerType"] === "Update State" &&
handler.parameters?.key === stateKey
)
})
}
// Checks if a setting for the given component updates a certain state key
const settingUpdatesState = (
component: Record<string, any>,
setting: ComponentSetting,
stateKey: string
) => {
if (setting.type === "event") {
return eventUpdatesState(component[setting.key], stateKey)
} else if (setting.type === "buttonConfiguration") {
const buttons = component[setting.key]
if (Array.isArray(buttons)) {
for (let button of buttons) {
if (eventUpdatesState(button.onClick, stateKey)) {
return true
}
}
}
}
return false
}
// Checks if a condition updates a certain state key
const conditionUpdatesState = (
condition: ComponentCondition,
settings: ComponentSetting[],
stateKey: string
) => {
const setting = settings.find(s => s.key === condition.setting)
if (!setting) {
return false
}
const component = { [setting.key]: condition.settingValue }
return settingUpdatesState(component, setting, stateKey)
}
const findComponentsUpdatingState = (
component: Component,
stateKey: string,
foundComponents: ComponentUsingState[] = []
): ComponentUsingState[] => {
const { _children, _conditions, _component, _instanceName, _id } = component
const settings = componentStore
.getComponentSettings(_component)
.filter(s => s.type === "event" || s.type === "buttonConfiguration")
// Check all settings of this component
settings.forEach(setting => {
if (settingUpdatesState(component, setting, stateKey)) {
const label = setting.label || setting.key
foundComponents.push({
id: _id!,
name: `${_instanceName} - ${label}`,
setting: setting.key,
})
}
})
// Check if conditions update these settings to update this state key
if (_conditions?.some(c => conditionUpdatesState(c, settings, stateKey))) {
foundComponents.push({
id: _id!,
name: `${_instanceName} - Conditions`,
setting: "_conditions",
})
}
// Check children
_children?.forEach(child => {
findComponentsUpdatingState(child, stateKey, foundComponents)
})
return foundComponents
}
const findComponentsUsingState = (
component: Component,
stateKey: string,
componentsUsingState: ComponentUsingState[] = []
): ComponentUsingState[] => {
const settings = componentStore.getComponentSettings(component._component)
// Check all settings of this component
const settingsWithState = getSettingsUsingState(component, stateKey)
settingsWithState.forEach(setting => {
// Get readable label for this setting
let label = settings.find(s => s.key === setting)?.label || setting
if (setting === "_conditions") {
label = "Conditions"
} else if (setting === "_styles") {
label = "Styles"
}
componentsUsingState.push({
id: component._id!,
name: `${component._instanceName} - ${label}`,
setting,
})
})
// Check children
component._children?.forEach(child => {
findComponentsUsingState(child, stateKey, componentsUsingState)
})
return componentsUsingState
}
const getSettingsUsingState = (
component: Component,
stateKey: string
): string[] => {
return Object.entries(component)
.filter(([key]) => key !== "_children")
.filter(([_, value]) => hasStateBinding(JSON.stringify(value), stateKey))
.map(([key]) => key)
}
const hasStateBinding = (value: string, stateKey: string): boolean => {
const bindings = findHBSBlocks(value).map(binding => {
const sanitizedBinding = binding.replace(/\\"/g, '"')
return isJSBinding(sanitizedBinding)
? decodeJSBinding(sanitizedBinding)
: sanitizedBinding
})
return bindings.join(" ").includes(stateKey)
}
const onClickComponentLink = (component: ComponentUsingState) => {
componentStore.select(component.id)
builderStore.highlightSetting(component.setting)
}
const handleStateInspectorChange = (e: CustomEvent) => {
if (!selectedKey || !$previewStore.selectedComponentContext) {
return
}
const stateUpdate = {
[selectedKey]: processStringSync(
e.detail,
$previewStore.selectedComponentContext
),
}
previewStore.updateState(stateUpdate)
editorValue = e.detail
}
onMount(() => {
previewStore.requestComponentContext()
})
</script>
<div class="state-panel">
<Select
label="State variable"
bind:value={selectedKey}
placeholder={keyOptions.length > 0 ? false : "No state variables found"}
options={keyOptions}
/>
{#if selectedKey && keyOptions.length > 0}
<DrawerBindableInput
value={editorValue}
title={`Set value for "${selectedKey}"`}
placeholder="Enter a value"
label="Set temporary value for design preview"
on:change={e => handleStateInspectorChange(e)}
{bindings}
/>
{/if}
{#if componentsUsingState.length > 0}
<div class="section">
<span class="text">Updates</span>
<div class="updates-section">
{#each componentsUsingState as component}
<button
class="component-link updates-colour"
on:click={() => onClickComponentLink(component)}
>
{component.name}
</button>
{/each}
</div>
</div>
{/if}
{#if componentsUpdatingState.length > 0}
<div class="section">
<span class="text">Controlled by</span>
<div class="updates-section">
{#each componentsUpdatingState as component}
<button
class="component-link controlled-by-colour"
on:click={() => onClickComponentLink(component)}
>
{component.name}
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
.state-panel {
background-color: var(--spectrum-alias-background-color-primary);
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
margin-top: var(--spacing-s);
}
.text {
color: var(--spectrum-global-color-gray-700);
font-size: 12px;
}
.updates-colour {
color: var(--bb-indigo-light);
}
.controlled-by-colour {
color: var(--spectrum-global-color-orange-700);
}
.component-link {
display: inline-block;
border: none;
background: none;
text-decoration: underline;
cursor: pointer;
padding: 0;
white-space: nowrap;
font-size: 12px;
transition: filter 130ms ease-out;
}
.component-link:hover {
text-decoration: underline;
filter: brightness(1.2);
}
.updates-section {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-s);
}
</style>

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

@ -20,6 +20,7 @@ import {
previewStore, previewStore,
tables, tables,
componentTreeNodesStore, componentTreeNodesStore,
builderStore,
screenComponents, screenComponents,
} from "@/stores/builder" } from "@/stores/builder"
import { buildFormSchema, getSchemaForDatasource } from "@/dataBinding" import { buildFormSchema, getSchemaForDatasource } from "@/dataBinding"
@ -32,7 +33,10 @@ import {
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { import {
ComponentDefinition,
ComponentSetting,
Component as ComponentType, Component as ComponentType,
ComponentCondition,
FieldType, FieldType,
Screen, Screen,
Table, Table,
@ -53,29 +57,6 @@ export interface ComponentState {
selectedScreenId?: string | null selectedScreenId?: string | null
} }
export interface ComponentDefinition {
component: string
name: string
friendlyName?: string
hasChildren?: boolean
settings?: ComponentSetting[]
features?: Record<string, boolean>
typeSupportPresets?: Record<string, any>
legalDirectChildren: string[]
illegalChildren: string[]
}
export interface ComponentSetting {
key: string
type: string
section?: string
name?: string
defaultValue?: any
selectAllFields?: boolean
resetOn?: string | string[]
settings?: ComponentSetting[]
}
export const INITIAL_COMPONENTS_STATE: ComponentState = { export const INITIAL_COMPONENTS_STATE: ComponentState = {
components: {}, components: {},
customComponents: [], customComponents: [],
@ -743,14 +724,16 @@ export class ComponentStore extends BudiStore<ComponentState> {
} }
} }
/** select(id: string) {
*
* @param {string} componentId
*/
select(componentId: string) {
this.update(state => { this.update(state => {
state.selectedComponentId = componentId // Only clear highlights if selecting a different component
return state if (!id.includes(state.selectedComponentId!)) {
builderStore.highlightSetting()
}
return {
...state,
selectedComponentId: id,
}
}) })
} }
@ -1132,7 +1115,7 @@ export class ComponentStore extends BudiStore<ComponentState> {
}) })
} }
async updateConditions(conditions: Record<string, any>) { async updateConditions(conditions: ComponentCondition[]) {
await this.patch((component: Component) => { await this.patch((component: Component) => {
component._conditions = conditions component._conditions = conditions
}) })

View File

@ -16,7 +16,11 @@ import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments.js"
import { contextMenuStore } from "./contextMenu.js" import { contextMenuStore } from "./contextMenu.js"
import { snippets } from "./snippets" import { snippets } from "./snippets"
import { screenComponents, screenComponentErrors } from "./screenComponent" import {
screenComponents,
screenComponentErrors,
findComponentsBySettingsType,
} from "./screenComponent"
// Backend // Backend
import { tables } from "./tables" import { tables } from "./tables"
@ -70,6 +74,7 @@ export {
appPublished, appPublished,
screenComponents, screenComponents,
screenComponentErrors, screenComponentErrors,
findComponentsBySettingsType,
} }
export const reset = () => { export const reset = () => {

View File

@ -82,6 +82,10 @@ export class PreviewStore extends BudiStore<PreviewState> {
})) }))
} }
updateState(data: Record<string, any>) {
this.sendEvent("builder-state", data)
}
requestComponentContext() { requestComponentContext() {
this.sendEvent("request-context") this.sendEvent("request-context")
} }

View File

@ -6,14 +6,17 @@ import {
UIDatasourceType, UIDatasourceType,
Screen, Screen,
Component, Component,
UIComponentError,
ScreenProps, ScreenProps,
ComponentDefinition,
} from "@budibase/types" } from "@budibase/types"
import { queries } from "./queries" import { queries } from "./queries"
import { views } from "./views" import { views } from "./views"
import { bindings, featureFlag } from "@/helpers"
import { getBindableProperties } from "@/dataBinding"
import { componentStore, ComponentDefinition } from "./components"
import { findAllComponents } from "@/helpers/components" import { findAllComponents } from "@/helpers/components"
import { bindings } from "@/helpers"
import { getBindableProperties } from "@/dataBinding"
import { componentStore } from "./components"
import { getSettingsDefinition } from "@budibase/frontend-core"
function reduceBy<TItem extends {}, TKey extends keyof TItem>( function reduceBy<TItem extends {}, TKey extends keyof TItem>(
key: TKey, key: TKey,
@ -52,61 +55,10 @@ export const screenComponentErrors = derived(
$viewsV2, $viewsV2,
$queries, $queries,
$componentStore, $componentStore,
]): Record<string, string[]> => { ]): Record<string, UIComponentError[]> => {
if (!featureFlag.isEnabled("CHECK_COMPONENT_SETTINGS_ERRORS")) { if (!$selectedScreen) {
return {} return {}
} }
function getInvalidDatasources(
screen: Screen,
datasources: Record<string, any>
) {
const result: Record<string, string[]> = {}
for (const { component, setting } of findComponentsBySettingsType(
screen,
["table", "dataSource"],
$componentStore.components
)) {
const componentSettings = component[setting.key]
if (!componentSettings) {
continue
}
const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType
const validationKey = validationKeyByType[type]
if (!validationKey) {
continue
}
const componentBindings = getBindableProperties(
$selectedScreen,
component._id
)
const componentDatasources = {
...reduceBy(
"rowId",
bindings.extractRelationships(componentBindings)
),
...reduceBy("value", bindings.extractFields(componentBindings)),
...reduceBy(
"value",
bindings.extractJSONArrayFields(componentBindings)
),
}
const resourceId = componentSettings[validationKey]
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
const friendlyTypeName = friendlyNameByType[type] ?? type
result[component._id!] = [
`The ${friendlyTypeName} named "${label}" could not be found`,
]
}
}
return result
}
const datasources = { const datasources = {
...reduceBy("_id", $tables.list), ...reduceBy("_id", $tables.list),
@ -115,16 +67,170 @@ export const screenComponentErrors = derived(
...reduceBy("_id", $queries.list), ...reduceBy("_id", $queries.list),
} }
if (!$selectedScreen) { const { components: definitions } = $componentStore
// Skip validation if a screen is not selected.
return {}
}
return getInvalidDatasources($selectedScreen, datasources) const errors = {
...getInvalidDatasources($selectedScreen, datasources, definitions),
...getMissingAncestors($selectedScreen, definitions),
...getMissingRequiredSettings($selectedScreen, definitions),
}
return errors
} }
) )
function findComponentsBySettingsType( function getInvalidDatasources(
screen: Screen,
datasources: Record<string, any>,
definitions: Record<string, ComponentDefinition>
) {
const result: Record<string, UIComponentError[]> = {}
for (const { component, setting } of findComponentsBySettingsType(
screen,
["table", "dataSource"],
definitions
)) {
const componentSettings = component[setting.key]
if (!componentSettings) {
continue
}
const { label } = componentSettings
const type = componentSettings.type as UIDatasourceType
const validationKey = validationKeyByType[type]
if (!validationKey) {
continue
}
const componentBindings = getBindableProperties(screen, component._id)
const componentDatasources = {
...reduceBy("rowId", bindings.extractRelationships(componentBindings)),
...reduceBy("value", bindings.extractFields(componentBindings)),
...reduceBy("value", bindings.extractJSONArrayFields(componentBindings)),
}
const resourceId = componentSettings[validationKey]
if (!{ ...datasources, ...componentDatasources }[resourceId]) {
const friendlyTypeName = friendlyNameByType[type] ?? type
result[component._id!] = [
{
key: setting.key,
message: `The ${friendlyTypeName} named "${label}" could not be found`,
errorType: "setting",
},
]
}
}
return result
}
function getMissingRequiredSettings(
screen: Screen,
definitions: Record<string, ComponentDefinition>
) {
const allComponents = findAllComponents(screen.props) as Component[]
const result: Record<string, UIComponentError[]> = {}
for (const component of allComponents) {
const definition = definitions[component._component]
const settings = getSettingsDefinition(definition)
const missingRequiredSettings = settings.filter((setting: any) => {
let empty =
component[setting.key] == null || component[setting.key] === ""
let missing = setting.required && empty
// Check if this setting depends on another, as it may not be required
if (setting.dependsOn) {
const dependsOnKey = setting.dependsOn.setting || setting.dependsOn
const dependsOnValue = setting.dependsOn.value
const realDependentValue = component[dependsOnKey]
const sectionDependsOnKey =
setting.sectionDependsOn?.setting || setting.sectionDependsOn
const sectionDependsOnValue = setting.sectionDependsOn?.value
const sectionRealDependentValue = component[sectionDependsOnKey]
if (dependsOnValue == null && realDependentValue == null) {
return false
}
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
return false
}
if (
sectionDependsOnValue != null &&
sectionDependsOnValue !== sectionRealDependentValue
) {
return false
}
}
return missing
})
if (missingRequiredSettings?.length) {
result[component._id!] = missingRequiredSettings.map((s: any) => ({
key: s.key,
message: `Add the <mark>${s.label}</mark> setting to start using your component`,
errorType: "setting",
}))
}
}
return result
}
const BudibasePrefix = "@budibase/standard-components/"
function getMissingAncestors(
screen: Screen,
definitions: Record<string, ComponentDefinition>
) {
const result: Record<string, UIComponentError[]> = {}
function checkMissingAncestors(component: Component, ancestors: string[]) {
for (const child of component._children || []) {
checkMissingAncestors(child, [...ancestors, component._component])
}
const definition = definitions[component._component]
if (!definition?.requiredAncestors?.length) {
return
}
const missingAncestors = definition.requiredAncestors.filter(
ancestor => !ancestors.includes(`${BudibasePrefix}${ancestor}`)
)
if (missingAncestors.length) {
const pluralise = (name: string) => {
return name.endsWith("s") ? `${name}'` : `${name}s`
}
result[component._id!] = missingAncestors.map(ancestor => {
const ancestorDefinition = definitions[`${BudibasePrefix}${ancestor}`]
return {
message: `${pluralise(definition.name)} need to be inside a
<mark>${ancestorDefinition.name}</mark>`,
errorType: "ancestor-setting",
ancestor: {
name: ancestorDefinition.name,
fullType: `${BudibasePrefix}${ancestor}`,
},
}
})
}
}
checkMissingAncestors(screen.props, [])
return result
}
export function findComponentsBySettingsType(
screen: Screen, screen: Screen,
type: string | string[], type: string | string[],
definitions: Record<string, ComponentDefinition> definitions: Record<string, ComponentDefinition>
@ -149,10 +255,10 @@ function findComponentsBySettingsType(
const setting = definition?.settings?.find((s: any) => const setting = definition?.settings?.find((s: any) =>
typesArray.includes(s.type) typesArray.includes(s.type)
) )
if (setting && "type" in setting) { if (setting) {
result.push({ result.push({
component, component,
setting: { type: setting.type!, key: setting.key! }, setting: { type: setting.type, key: setting.key },
}) })
} }
component._children?.forEach(child => { component._children?.forEach(child => {

View File

@ -19,8 +19,8 @@ import {
Screen, Screen,
Component, Component,
SaveScreenResponse, SaveScreenResponse,
ComponentDefinition,
} from "@budibase/types" } from "@budibase/types"
import { ComponentDefinition } from "./components"
interface ScreenState { interface ScreenState {
screens: Screen[] screens: Screen[]

View File

@ -3089,6 +3089,12 @@
"type": "tableConditions", "type": "tableConditions",
"label": "Conditions", "label": "Conditions",
"key": "conditions" "key": "conditions"
},
{
"type": "text",
"label": "Format",
"key": "format",
"info": "Changing format will display values as text"
} }
] ]
}, },
@ -7685,7 +7691,8 @@
{ {
"type": "columns/grid", "type": "columns/grid",
"key": "columns", "key": "columns",
"resetOn": "table" "resetOn": "table",
"nested": true
} }
] ]
}, },

View File

@ -11,11 +11,8 @@
<script> <script>
import { getContext, setContext, onMount } from "svelte" import { getContext, setContext, onMount } from "svelte"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { import { enrichProps, propsAreSame } from "utils/componentProps"
enrichProps, import { getSettingsDefinition } from "@budibase/frontend-core"
propsAreSame,
getSettingsDefinition,
} from "utils/componentProps"
import { import {
builderStore, builderStore,
devToolsStore, devToolsStore,
@ -29,7 +26,6 @@
import EmptyPlaceholder from "components/app/EmptyPlaceholder.svelte" import EmptyPlaceholder from "components/app/EmptyPlaceholder.svelte"
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte" import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte" import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
import { BudibasePrefix } from "../stores/components.js"
import { import {
decodeJSBinding, decodeJSBinding,
findHBSBlocks, findHBSBlocks,
@ -102,8 +98,6 @@
let definition let definition
let settingsDefinition let settingsDefinition
let settingsDefinitionMap let settingsDefinitionMap
let missingRequiredSettings = false
let componentErrors = false
// Temporary styles which can be added in the app preview for things like // Temporary styles which can be added in the app preview for things like
// DND. We clear these whenever a new instance is received. // DND. We clear these whenever a new instance is received.
@ -141,18 +135,11 @@
$: componentErrors = instance?._meta?.errors $: componentErrors = instance?._meta?.errors
$: hasChildren = !!definition?.hasChildren $: hasChildren = !!definition?.hasChildren
$: showEmptyState = definition?.showEmptyState !== false $: showEmptyState = definition?.showEmptyState !== false
$: hasMissingRequiredSettings = missingRequiredSettings?.length > 0 $: hasMissingRequiredSettings = !!componentErrors?.find(
e => e.errorType === "setting"
)
$: editable = !!definition?.editable && !hasMissingRequiredSettings $: editable = !!definition?.editable && !hasMissingRequiredSettings
$: hasComponentErrors = componentErrors?.length > 0 $: hasComponentErrors = componentErrors?.length > 0
$: requiredAncestors = definition?.requiredAncestors || []
$: missingRequiredAncestors = requiredAncestors.filter(
ancestor => !$component.ancestors.includes(`${BudibasePrefix}${ancestor}`)
)
$: hasMissingRequiredAncestors = missingRequiredAncestors?.length > 0
$: errorState =
hasMissingRequiredSettings ||
hasMissingRequiredAncestors ||
hasComponentErrors
// Interactive components can be selected, dragged and highlighted inside // Interactive components can be selected, dragged and highlighted inside
// the builder preview // the builder preview
@ -218,7 +205,7 @@
styles: normalStyles, styles: normalStyles,
draggable, draggable,
definition, definition,
errored: errorState, errored: hasComponentErrors,
} }
// When dragging and dropping, pad components to allow dropping between // When dragging and dropping, pad components to allow dropping between
@ -251,9 +238,8 @@
name, name,
editing, editing,
type: instance._component, type: instance._component,
errorState, errorState: hasComponentErrors,
parent: id, parent: id,
ancestors: [...($component?.ancestors ?? []), instance._component],
path: [...($component?.path ?? []), id], path: [...($component?.path ?? []), id],
darkMode, darkMode,
}) })
@ -310,40 +296,6 @@
staticSettings = instanceSettings.staticSettings staticSettings = instanceSettings.staticSettings
dynamicSettings = instanceSettings.dynamicSettings dynamicSettings = instanceSettings.dynamicSettings
// Check if we have any missing required settings
missingRequiredSettings = settingsDefinition.filter(setting => {
let empty = instance[setting.key] == null || instance[setting.key] === ""
let missing = setting.required && empty
// Check if this setting depends on another, as it may not be required
if (setting.dependsOn) {
const dependsOnKey = setting.dependsOn.setting || setting.dependsOn
const dependsOnValue = setting.dependsOn.value
const realDependentValue = instance[dependsOnKey]
const sectionDependsOnKey =
setting.sectionDependsOn?.setting || setting.sectionDependsOn
const sectionDependsOnValue = setting.sectionDependsOn?.value
const sectionRealDependentValue = instance[sectionDependsOnKey]
if (dependsOnValue == null && realDependentValue == null) {
return false
}
if (dependsOnValue != null && dependsOnValue !== realDependentValue) {
return false
}
if (
sectionDependsOnValue != null &&
sectionDependsOnValue !== sectionRealDependentValue
) {
return false
}
}
return missing
})
// When considering bindings we can ignore children, so we remove that // When considering bindings we can ignore children, so we remove that
// before storing the reference stringified version // before storing the reference stringified version
const noChildren = JSON.stringify({ ...instance, _children: null }) const noChildren = JSON.stringify({ ...instance, _children: null })
@ -686,7 +638,7 @@
class:pad class:pad
class:parent={hasChildren} class:parent={hasChildren}
class:block={isBlock} class:block={isBlock}
class:error={errorState} class:error={hasComponentErrors}
class:root={isRoot} class:root={isRoot}
data-id={id} data-id={id}
data-name={name} data-name={name}
@ -694,12 +646,8 @@
data-parent={$component.id} data-parent={$component.id}
use:gridLayout={gridMetadata} use:gridLayout={gridMetadata}
> >
{#if errorState} {#if hasComponentErrors}
<ComponentErrorState <ComponentErrorState {componentErrors} />
{missingRequiredSettings}
{missingRequiredAncestors}
{componentErrors}
/>
{:else} {:else}
<svelte:component this={constructor} bind:this={ref} {...initialSettings}> <svelte:component this={constructor} bind:this={ref} {...initialSettings}>
{#if children.length} {#if children.length}

View File

@ -5,6 +5,7 @@
import { get, derived, readable } from "svelte/store" import { get, derived, readable } from "svelte/store"
import { featuresStore } from "stores" import { featuresStore } from "stores"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
import { processStringSync } from "@budibase/string-templates"
// table is actually any datasource, but called table for legacy compatibility // table is actually any datasource, but called table for legacy compatibility
export let table export let table
@ -42,6 +43,7 @@
let gridContext let gridContext
let minHeight = 0 let minHeight = 0
$: id = $component.id
$: currentTheme = $context?.device?.theme $: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light") $: darkMode = !currentTheme?.includes("light")
$: parsedColumns = getParsedColumns(columns) $: parsedColumns = getParsedColumns(columns)
@ -65,7 +67,6 @@
const clean = gridContext?.rows.actions.cleanRow || (x => x) const clean = gridContext?.rows.actions.cleanRow || (x => x)
const cleaned = rows.map(clean) const cleaned = rows.map(clean)
const goldenRow = generateGoldenSample(cleaned) const goldenRow = generateGoldenSample(cleaned)
const id = get(component).id
return { return {
// Not sure what this one is for... // Not sure what this one is for...
[id]: goldenRow, [id]: goldenRow,
@ -104,6 +105,7 @@
order: idx, order: idx,
conditions: column.conditions, conditions: column.conditions,
visible: !!column.active, visible: !!column.active,
format: createFormatter(column),
} }
if (column.width) { if (column.width) {
overrides[column.field].width = column.width overrides[column.field].width = column.width
@ -112,6 +114,13 @@
return overrides return overrides
} }
const createFormatter = column => {
if (typeof column.format !== "string" || !column.format.trim().length) {
return null
}
return row => processStringSync(column.format, { [id]: row })
}
const enrichButtons = buttons => { const enrichButtons = buttons => {
if (!buttons?.length) { if (!buttons?.length) {
return null return null

View File

@ -1,8 +1,8 @@
<script> <script>
import { Layout, Toggle } from "@budibase/bbui" import { Layout, Toggle } from "@budibase/bbui"
import { getSettingsDefinition } from "@budibase/frontend-core"
import DevToolsStat from "./DevToolsStat.svelte" import DevToolsStat from "./DevToolsStat.svelte"
import { componentStore } from "stores/index.js" import { componentStore } from "stores/index.js"
import { getSettingsDefinition } from "utils/componentProps.js"
let showEnrichedSettings = true let showEnrichedSettings = true

View File

@ -1,21 +1,15 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte" import { getContext } from "svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import MissingRequiredSetting from "./MissingRequiredSetting.svelte" import { UIComponentError } from "@budibase/types"
import MissingRequiredAncestor from "./MissingRequiredAncestor.svelte" import ComponentErrorStateCta from "./ComponentErrorStateCTA.svelte"
export let missingRequiredSettings: export let componentErrors: UIComponentError[] | undefined
| { key: string; label: string }[]
| undefined
export let missingRequiredAncestors: string[] | undefined
export let componentErrors: string[] | undefined
const component = getContext("component") const component = getContext("component")
const { styleable, builderStore } = getContext("sdk") const { styleable, builderStore } = getContext("sdk")
$: styles = { ...$component.styles, normal: {}, custom: null, empty: true } $: styles = { ...$component.styles, normal: {}, custom: null, empty: true }
$: requiredSetting = missingRequiredSettings?.[0]
$: requiredAncestor = missingRequiredAncestors?.[0]
$: errorMessage = componentErrors?.[0] $: errorMessage = componentErrors?.[0]
</script> </script>
@ -23,12 +17,10 @@
{#if $component.errorState} {#if $component.errorState}
<div class="component-placeholder" use:styleable={styles}> <div class="component-placeholder" use:styleable={styles}>
<Icon name="Alert" color="var(--spectrum-global-color-static-red-600)" /> <Icon name="Alert" color="var(--spectrum-global-color-static-red-600)" />
{#if requiredAncestor} {#if errorMessage}
<MissingRequiredAncestor {requiredAncestor} /> <!-- eslint-disable-next-line svelte/no-at-html-tags-->
{:else if errorMessage} {@html errorMessage.message}
{errorMessage} <ComponentErrorStateCta error={errorMessage} />
{:else if requiredSetting}
<MissingRequiredSetting {requiredSetting} />
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,40 @@
<script lang="ts">
import { getContext } from "svelte"
import { UIComponentError } from "@budibase/types"
export let error: UIComponentError | undefined
const component = getContext("component")
const { builderStore } = getContext("sdk")
</script>
{#if error}
{#if error.errorType === "setting"}
<span>-</span>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="spectrum-Link"
on:click={() => {
builderStore.actions.highlightSetting(error.key)
}}
>
Show me
</span>
{:else if error.errorType === "ancestor-setting"}
<span>-</span>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="spectrum-Link"
on:click={() => {
builderStore.actions.addParentComponent(
$component.id,
error.ancestor.fullType
)
}}
>
Add {error.ancestor.name}
</span>
{/if}
{/if}

View File

@ -1,43 +0,0 @@
<script>
import { getContext } from "svelte"
import { BudibasePrefix } from "stores/components"
export let requiredAncestor
const component = getContext("component")
const { builderStore, componentStore } = getContext("sdk")
$: definition = componentStore.actions.getComponentDefinition($component.type)
$: fullAncestorType = `${BudibasePrefix}${requiredAncestor}`
$: ancestorDefinition =
componentStore.actions.getComponentDefinition(fullAncestorType)
$: pluralName = getPluralName(definition?.name, $component.type)
$: ancestorName = getAncestorName(ancestorDefinition?.name, requiredAncestor)
const getPluralName = (name, type) => {
if (!name) {
name = type.replace(BudibasePrefix, "")
}
return name.endsWith("s") ? `${name}'` : `${name}s`
}
const getAncestorName = name => {
return name || requiredAncestor
}
</script>
<span>
{pluralName} need to be inside a
<mark>{ancestorName}</mark>
</span>
<span>-</span>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="spectrum-Link"
on:click={() => {
builderStore.actions.addParentComponent($component.id, fullAncestorType)
}}
>
Add {ancestorName}
</span>

View File

@ -1,22 +0,0 @@
<script>
import { getContext } from "svelte"
export let requiredSetting
const { builderStore } = getContext("sdk")
</script>
<span>
Add the <mark>{requiredSetting.label}</mark> setting to start using your component
</span>
<span>-</span>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="spectrum-Link"
on:click={() => {
builderStore.actions.highlightSetting(requiredSetting.key)
}}
>
Show me
</span>

View File

@ -15,6 +15,7 @@ export const ActionTypes = {
export const DNDPlaceholderID = "dnd-placeholder" export const DNDPlaceholderID = "dnd-placeholder"
export const ScreenslotType = "screenslot" export const ScreenslotType = "screenslot"
export const ScreenslotID = "screenslot"
export const GridRowHeight = 24 export const GridRowHeight = 24
export const GridColumns = 12 export const GridColumns = 12
export const GridSpacing = 4 export const GridSpacing = 4

View File

@ -9,6 +9,7 @@ import {
dndStore, dndStore,
eventStore, eventStore,
hoverStore, hoverStore,
stateStore,
} from "./stores" } from "./stores"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-vite.js" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-vite.js"
import { get } from "svelte/store" import { get } from "svelte/store"
@ -87,8 +88,10 @@ const loadBudibase = async () => {
dndStore.actions.reset() dndStore.actions.reset()
} }
} else if (type === "request-context") { } else if (type === "request-context") {
const { selectedComponentInstance } = get(componentStore) const { selectedComponentInstance, screenslotInstance } =
const context = selectedComponentInstance?.getDataContext() get(componentStore)
const instance = selectedComponentInstance || screenslotInstance
const context = instance?.getDataContext()
let stringifiedContext = null let stringifiedContext = null
try { try {
stringifiedContext = JSON.stringify(context) stringifiedContext = JSON.stringify(context)
@ -102,6 +105,9 @@ const loadBudibase = async () => {
hoverStore.actions.hoverComponent(data, false) hoverStore.actions.hoverComponent(data, false)
} else if (type === "builder-meta") { } else if (type === "builder-meta") {
builderStore.actions.setMetadata(data) builderStore.actions.setMetadata(data)
} else if (type === "builder-state") {
const [[key, value]] = Object.entries(data)
stateStore.actions.setValue(key, value)
} }
} }

View File

@ -11,7 +11,15 @@ export interface SDK {
generateGoldenSample: any generateGoldenSample: any
builderStore: Readable<{ builderStore: Readable<{
inBuilder: boolean inBuilder: boolean
}> }> & {
actions: {
highlightSetting: (key: string) => void
addParentComponent: (
componentId: string,
fullAncestorType: string
) => void
}
}
} }
export type Component = Readable<{ export type Component = Readable<{

View File

@ -6,7 +6,7 @@ import { screenStore } from "./screens"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import Router from "../components/Router.svelte" import Router from "../components/Router.svelte"
import * as AppComponents from "../components/app/index.js" import * as AppComponents from "../components/app/index.js"
import { ScreenslotType } from "../constants" import { ScreenslotID, ScreenslotType } from "../constants"
export const BudibasePrefix = "@budibase/standard-components/" export const BudibasePrefix = "@budibase/standard-components/"
@ -43,6 +43,7 @@ const createComponentStore = () => {
selectedComponentDefinition: definition, selectedComponentDefinition: definition,
selectedComponentPath: selectedPath?.map(component => component._id), selectedComponentPath: selectedPath?.map(component => component._id),
mountedComponentCount: Object.keys($store.mountedComponents).length, mountedComponentCount: Object.keys($store.mountedComponents).length,
screenslotInstance: $store.mountedComponents[ScreenslotID],
} }
} }
) )

View File

@ -7,7 +7,7 @@ import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { findComponentById, findComponentParent } from "../utils/components.js" import { findComponentById, findComponentParent } from "../utils/components.js"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { DNDPlaceholderID } from "constants" import { DNDPlaceholderID, ScreenslotID, ScreenslotType } from "constants"
const createScreenStore = () => { const createScreenStore = () => {
const store = derived( const store = derived(
@ -171,8 +171,8 @@ const createScreenStore = () => {
_component: "@budibase/standard-components/layout", _component: "@budibase/standard-components/layout",
_children: [ _children: [
{ {
_component: "screenslot", _component: ScreenslotType,
_id: "screenslot", _id: ScreenslotID,
_styles: { _styles: {
normal: { normal: {
flex: "1 1 auto", flex: "1 1 auto",

View File

@ -97,26 +97,3 @@ export const propsUseBinding = (props, bindingKey) => {
} }
return false return false
} }
/**
* Gets the definition of this component's settings from the manifest
*/
export const getSettingsDefinition = definition => {
if (!definition) {
return []
}
let settings = []
definition.settings?.forEach(setting => {
if (setting.section) {
settings = settings.concat(
(setting.settings || [])?.map(childSetting => ({
...childSetting,
sectionDependsOn: setting.dependsOn,
}))
)
} else {
settings.push(setting)
}
})
return settings
}

View File

@ -3,6 +3,7 @@
import GridCell from "./GridCell.svelte" import GridCell from "./GridCell.svelte"
import { getCellRenderer } from "../lib/renderers" import { getCellRenderer } from "../lib/renderers"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import TextCell from "./TextCell.svelte"
const { const {
rows, rows,
@ -36,11 +37,17 @@
let api let api
// Get the appropriate cell renderer and value
$: hasCustomFormat = column.format && !row._isNewRow
$: renderer = hasCustomFormat ? TextCell : getCellRenderer(column)
$: value = hasCustomFormat ? row.__formatted?.[column.name] : row[column.name]
// Get the error for this cell if the cell is focused or selected // Get the error for this cell if the cell is focused or selected
$: error = getErrorStore(rowFocused, cellId) $: error = getErrorStore(rowFocused, cellId)
// Determine if the cell is editable // Determine if the cell is editable
$: readonly = $: readonly =
hasCustomFormat ||
columns.actions.isReadonly(column) || columns.actions.isReadonly(column) ||
(!$config.canEditRows && !row._isNewRow) (!$config.canEditRows && !row._isNewRow)
@ -69,7 +76,7 @@
onKeyDown: (...params) => api?.onKeyDown?.(...params), onKeyDown: (...params) => api?.onKeyDown?.(...params),
isReadonly: () => readonly, isReadonly: () => readonly,
getType: () => column.schema.type, getType: () => column.schema.type,
getValue: () => row[column.name], getValue: () => value,
setValue: (value, options = { apply: true }) => { setValue: (value, options = { apply: true }) => {
validation.actions.setError(cellId, null) validation.actions.setError(cellId, null)
updateValue({ updateValue({
@ -136,9 +143,9 @@
}} }}
> >
<svelte:component <svelte:component
this={getCellRenderer(column)} this={renderer}
bind:api bind:api
value={row[column.name]} {value}
schema={column.schema} schema={column.schema}
onChange={cellAPI.setValue} onChange={cellAPI.setValue}
{focused} {focused}

View File

@ -53,7 +53,6 @@ export const getCellRenderer = (column: UIColumn) => {
if (column.calculationType) { if (column.calculationType) {
return NumberCell return NumberCell
} }
return ( return (
getCellRendererByType(column.schema?.cellRenderType) || getCellRendererByType(column.schema?.cellRenderType) ||
getCellRendererByType(column.schema?.type) || getCellRendererByType(column.schema?.type) ||

View File

@ -188,6 +188,7 @@ export const initialise = (context: StoreContext) => {
conditions: fieldSchema.conditions, conditions: fieldSchema.conditions,
related: fieldSchema.related, related: fieldSchema.related,
calculationType: fieldSchema.calculationType, calculationType: fieldSchema.calculationType,
format: fieldSchema.format,
__left: undefined as any, // TODO __left: undefined as any, // TODO
__idx: undefined as any, // TODO __idx: undefined as any, // TODO
} }

View File

@ -16,6 +16,7 @@ import { Store as StoreContext } from "."
interface IndexedUIRow extends UIRow { interface IndexedUIRow extends UIRow {
__idx: number __idx: number
__formatted: Record<string, any>
} }
interface RowStore { interface RowStore {
@ -114,26 +115,44 @@ export const createStores = (): RowStore => {
export const deriveStores = (context: StoreContext): RowDerivedStore => { export const deriveStores = (context: StoreContext): RowDerivedStore => {
const { rows, enrichedSchema } = context const { rows, enrichedSchema } = context
// Enrich rows with an index property and any pending changes // Enrich rows with an index property and additional values
const enrichedRows = derived( const enrichedRows = derived(
[rows, enrichedSchema], [rows, enrichedSchema],
([$rows, $enrichedSchema]) => { ([$rows, $enrichedSchema]) => {
const customColumns = Object.values($enrichedSchema || {}).filter( // Find columns which require additional processing
f => f.related const cols = Object.values($enrichedSchema || {})
) const relatedColumns = cols.filter(col => col.related)
return $rows.map<IndexedUIRow>((row, idx) => ({ const formattedColumns = cols.filter(col => col.format)
...row,
__idx: idx, return $rows.map<IndexedUIRow>((row, idx) => {
...customColumns.reduce<Record<string, string>>((map, column) => { // Derive any values that need enriched from related rows
const fromField = $enrichedSchema![column.related!.field] const relatedValues = relatedColumns.reduce<Record<string, string>>(
map[column.name] = getRelatedTableValues( (map, column) => {
row, const fromField = $enrichedSchema![column.related!.field]
{ ...column, related: column.related! }, map[column.name] = getRelatedTableValues(
fromField row,
) { ...column, related: column.related! },
return map fromField
}, {}), )
})) return map
},
{}
)
// Derive any display-only formatted values for this row
const formattedValues = formattedColumns.reduce<Record<string, any>>(
(map, column) => {
map[column.name] = column.format!(row)
return map
},
{}
)
return {
...row,
...relatedValues,
__formatted: formattedValues,
__idx: idx,
}
})
} }
) )
@ -791,6 +810,7 @@ export const createActions = (context: StoreContext): RowActionStore => {
let clone: Row = { ...row } let clone: Row = { ...row }
delete clone.__idx delete clone.__idx
delete clone.__metadata delete clone.__metadata
delete clone.__formatted
if (!get(hasBudibaseIdentifiers) && isGeneratedRowID(clone._id!)) { if (!get(hasBudibaseIdentifiers) && isGeneratedRowID(clone._id!)) {
delete clone._id delete clone._id
} }

View File

@ -0,0 +1,26 @@
import { ComponentDefinition, ComponentSetting } from "@budibase/types"
/**
* Gets the definition of this component's settings from the manifest
*/
export const getSettingsDefinition = (
definition: ComponentDefinition
): ComponentSetting[] => {
if (!definition) {
return []
}
let settings: ComponentSetting[] = []
definition.settings?.forEach(setting => {
if (setting.section) {
settings = settings.concat(
(setting.settings || [])?.map(childSetting => ({
...childSetting,
sectionDependsOn: setting.dependsOn,
}))
)
} else {
settings.push(setting)
}
})
return settings
}

View File

@ -13,3 +13,4 @@ export * from "./download"
export * from "./settings" export * from "./settings"
export * from "./relatedColumns" export * from "./relatedColumns"
export * from "./table" export * from "./table"
export * from "./components"

View File

@ -1,7 +1,7 @@
import emitter from "../events/index" import emitter from "../events/index"
import { getAutomationParams, isDevAppID } from "../db/utils" import { getAutomationParams, isDevAppID } from "../db/utils"
import { coerce } from "../utilities/rowProcessor" import { coerce } from "../utilities/rowProcessor"
import { definitions } from "./triggerInfo" import { automations } from "@budibase/shared-core"
// need this to call directly, so we can get a response // need this to call directly, so we can get a response
import { automationQueue } from "./bullboard" import { automationQueue } from "./bullboard"
import { checkTestFlag } from "../utilities/redis" import { checkTestFlag } from "../utilities/redis"
@ -26,7 +26,7 @@ import {
import { executeInThread } from "../threads/automation" import { executeInThread } from "../threads/automation"
import { dataFilters, sdk } from "@budibase/shared-core" import { dataFilters, sdk } from "@budibase/shared-core"
export const TRIGGER_DEFINITIONS = definitions export const TRIGGER_DEFINITIONS = automations.triggers.definitions
const JOB_OPTS = { const JOB_OPTS = {
removeOnComplete: true, removeOnComplete: true,
removeOnFail: true, removeOnFail: true,
@ -273,8 +273,8 @@ async function checkTriggerFilters(
} }
if ( if (
trigger.stepId === definitions.ROW_UPDATED.stepId || trigger.stepId === automations.triggers.definitions.ROW_UPDATED.stepId ||
trigger.stepId === definitions.ROW_SAVED.stepId trigger.stepId === automations.triggers.definitions.ROW_SAVED.stepId
) { ) {
const newRow = await automationUtils.cleanUpRow(tableId, event.row) const newRow = await automationUtils.cleanUpRow(tableId, event.row)
return rowPassesFilters(newRow, filters) return rowPassesFilters(newRow, filters)

View File

@ -1,5 +1,5 @@
import { Thread, ThreadType } from "../threads" import { Thread, ThreadType } from "../threads"
import { definitions } from "./triggerInfo" import { automations } from "@budibase/shared-core"
import { automationQueue } from "./bullboard" import { automationQueue } from "./bullboard"
import { updateEntityMetadata } from "../utilities" import { updateEntityMetadata } from "../utilities"
import { context, db as dbCore, utils } from "@budibase/backend-core" import { context, db as dbCore, utils } from "@budibase/backend-core"
@ -19,7 +19,7 @@ import { automationsEnabled } from "../features"
import { helpers, REBOOT_CRON } from "@budibase/shared-core" import { helpers, REBOOT_CRON } from "@budibase/shared-core"
import tracer from "dd-trace" import tracer from "dd-trace"
const CRON_STEP_ID = definitions.CRON.stepId const CRON_STEP_ID = automations.triggers.definitions.CRON.stepId
let Runner: Thread let Runner: Thread
if (automationsEnabled()) { if (automationsEnabled()) {
Runner = new Thread(ThreadType.AUTOMATION) Runner = new Thread(ThreadType.AUTOMATION)
@ -255,7 +255,10 @@ export async function cleanupAutomations(appId: any) {
* @return if it is recurring (cron). * @return if it is recurring (cron).
*/ */
export function isRecurring(automation: Automation) { export function isRecurring(automation: Automation) {
return automation.definition.trigger.stepId === definitions.CRON.stepId return (
automation.definition.trigger.stepId ===
automations.triggers.definitions.CRON.stepId
)
} }
export function isErrorInOutput(output: { export function isErrorInOutput(output: {

View File

@ -13,7 +13,7 @@ import {
HTTPError, HTTPError,
db as dbCore, db as dbCore,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { definitions } from "../../../automations/triggerInfo" import { automations as sharedAutomations } from "@budibase/shared-core"
import automations from "." import automations from "."
export interface PersistedAutomation extends Automation { export interface PersistedAutomation extends Automation {
@ -202,7 +202,7 @@ export async function remove(automationId: string, rev: string) {
* written to DB (this does not write to DB as it would be wasteful to repeat). * written to DB (this does not write to DB as it would be wasteful to repeat).
*/ */
async function checkForWebhooks({ oldAuto, newAuto }: any) { async function checkForWebhooks({ oldAuto, newAuto }: any) {
const WH_STEP_ID = definitions.WEBHOOK.stepId const WH_STEP_ID = sharedAutomations.triggers.definitions.WEBHOOK.stepId
const appId = context.getAppId() const appId = context.getAppId()
if (!appId) { if (!appId) {

View File

@ -9,7 +9,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { generateRowActionsID } from "../../../db/utils" import { generateRowActionsID } from "../../../db/utils"
import automations from "../automations" import automations from "../automations"
import { definitions as TRIGGER_DEFINITIONS } from "../../../automations/triggerInfo" import { automations as sharedAutomations } from "@budibase/shared-core"
import * as triggers from "../../../automations/triggers" import * as triggers from "../../../automations/triggers"
import sdk from "../.." import sdk from "../.."
@ -59,7 +59,7 @@ export async function create(tableId: string, rowAction: { name: string }) {
definition: { definition: {
trigger: { trigger: {
id: "trigger", id: "trigger",
...TRIGGER_DEFINITIONS.ROW_ACTION, ...sharedAutomations.triggers.definitions.ROW_ACTION,
stepId: AutomationTriggerStepId.ROW_ACTION, stepId: AutomationTriggerStepId.ROW_ACTION,
inputs: { inputs: {
tableId, tableId,

View File

@ -11,7 +11,7 @@ import { replaceFakeBindings } from "../automations/loopUtils"
import { dataFilters, helpers, utils } from "@budibase/shared-core" import { dataFilters, helpers, utils } from "@budibase/shared-core"
import { default as AutomationEmitter } from "../events/AutomationEmitter" import { default as AutomationEmitter } from "../events/AutomationEmitter"
import { generateAutomationMetadataID, isProdAppID } from "../db/utils" import { generateAutomationMetadataID, isProdAppID } from "../db/utils"
import { definitions as triggerDefs } from "../automations/triggerInfo" import { automations } from "@budibase/shared-core"
import { AutomationErrors, MAX_AUTOMATION_RECURRING_ERRORS } from "../constants" import { AutomationErrors, MAX_AUTOMATION_RECURRING_ERRORS } from "../constants"
import { storeLog } from "../automations/logging" import { storeLog } from "../automations/logging"
import { import {
@ -50,7 +50,7 @@ import env from "../environment"
import tracer from "dd-trace" import tracer from "dd-trace"
threadUtils.threadSetup() threadUtils.threadSetup()
const CRON_STEP_ID = triggerDefs.CRON.stepId const CRON_STEP_ID = automations.triggers.definitions.CRON.stepId
const STOPPED_STATUS = { success: true, status: AutomationStatus.STOPPED } const STOPPED_STATUS = { success: true, status: AutomationStatus.STOPPED }
function getLoopIterations(loopStep: LoopStep) { function getLoopIterations(loopStep: LoopStep) {

View File

@ -1 +1,2 @@
export * as steps from "./steps/index" export * as steps from "./steps/index"
export * as triggers from "./triggers/index"

View File

@ -1,9 +1,22 @@
import { Document } from "../document" import { Document } from "../document"
import { BasicOperator } from "../../sdk"
export interface Component extends Document { export interface Component extends Document {
_instanceName: string _instanceName: string
_styles: { [key: string]: any } _styles: { [key: string]: any }
_component: string _component: string
_children?: Component[] _children?: Component[]
_conditions?: ComponentCondition[]
[key: string]: any [key: string]: any
} }
export interface ComponentCondition {
id: string
operator: BasicOperator
action: "update" | "show" | "hide"
valueType: "string" | "number" | "datetime" | "boolean"
newValue?: any
referenceValue?: any
setting?: string
settingValue?: any
}

View File

@ -23,6 +23,7 @@ export interface Screen extends Document {
props: ScreenProps props: ScreenProps
name?: string name?: string
pluginAdded?: boolean pluginAdded?: boolean
onLoad?: EventHandler[]
} }
export interface ScreenRoutesViewOutput extends Document { export interface ScreenRoutesViewOutput extends Document {
@ -36,3 +37,14 @@ export type ScreenRoutingJson = Record<
subpaths: Record<string, any> subpaths: Record<string, any>
} }
> >
export interface EventHandler {
parameters: {
key: string
type: string
value: string
persist: any | null
}
"##eventHandlerType": string
id: string
}

View File

@ -1,6 +1,5 @@
export enum FeatureFlag { export enum FeatureFlag {
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR", USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
CHECK_COMPONENT_SETTINGS_ERRORS = "CHECK_COMPONENT_SETTINGS_ERRORS",
// Account-portal // Account-portal
DIRECT_LOGIN_TO_ACCOUNT_PORTAL = "DIRECT_LOGIN_TO_ACCOUNT_PORTAL", DIRECT_LOGIN_TO_ACCOUNT_PORTAL = "DIRECT_LOGIN_TO_ACCOUNT_PORTAL",
@ -8,7 +7,6 @@ export enum FeatureFlag {
export const FeatureFlagDefaults = { export const FeatureFlagDefaults = {
[FeatureFlag.USE_ZOD_VALIDATOR]: false, [FeatureFlag.USE_ZOD_VALIDATOR]: false,
[FeatureFlag.CHECK_COMPONENT_SETTINGS_ERRORS]: false,
// Account-portal // Account-portal
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false, [FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,

View File

@ -0,0 +1,20 @@
interface BaseUIComponentError {
message: string
}
interface UISettingComponentError extends BaseUIComponentError {
errorType: "setting"
key: string
}
interface UIAncestorComponentError extends BaseUIComponentError {
errorType: "ancestor-setting"
ancestor: {
name: string
fullType: string
}
}
export type UIComponentError =
| UISettingComponentError
| UIAncestorComponentError

View File

@ -1,2 +1,34 @@
export * from "./sidepanel" export * from "./sidepanel"
export * from "./codeEditor" export * from "./codeEditor"
export * from "./errors"
export interface ComponentDefinition {
component: string
name: string
friendlyName?: string
hasChildren?: boolean
settings?: ComponentSetting[]
features?: Record<string, boolean>
typeSupportPresets?: Record<string, any>
legalDirectChildren: string[]
requiredAncestors?: string[]
illegalChildren: string[]
}
export interface ComponentSetting {
key: string
type: string
label?: string
section?: string
name?: string
defaultValue?: any
selectAllFields?: boolean
resetOn?: string | string[]
settings?: ComponentSetting[]
dependsOn?:
| string
| {
setting: string
value: string
}
}

View File

@ -1,9 +1,10 @@
import { CalculationType, FieldSchema, FieldType } from "@budibase/types" import { CalculationType, FieldSchema, FieldType, UIRow } from "@budibase/types"
export type UIColumn = FieldSchema & { export type UIColumn = FieldSchema & {
label: string label: string
readonly: boolean readonly: boolean
conditions: any conditions: any
format?: (row: UIRow) => any
related?: { related?: {
field: string field: string
subField: string subField: string

View File

@ -5,6 +5,7 @@ import {
RelationSchemaField, RelationSchemaField,
SortOrder, SortOrder,
Table, Table,
UIRow,
UISearchFilter, UISearchFilter,
} from "@budibase/types" } from "@budibase/types"
@ -27,6 +28,7 @@ export type UIFieldSchema = FieldSchema &
columns?: Record<string, UIRelationSchemaField> columns?: Record<string, UIRelationSchemaField>
cellRenderType?: string cellRenderType?: string
disabled?: boolean disabled?: boolean
format?: (row: UIRow) => any
} }
interface UIRelationSchemaField extends RelationSchemaField { interface UIRelationSchemaField extends RelationSchemaField {

View File

@ -18993,10 +18993,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"