Merge pull request #3403 from Budibase/cheeks-lab-day

Inline text editing + perf. enhancements + preview enhancements
This commit is contained in:
Andrew Kingston 2021-11-22 10:44:38 +00:00 committed by GitHub
commit 6c0a1e99da
29 changed files with 490 additions and 256 deletions

View File

@ -620,6 +620,9 @@ export const getFrontendStore = () => {
if (!name || !component) { if (!name || !component) {
return return
} }
if (component[name] === value) {
return
}
component[name] = value component[name] = value
store.update(state => { store.update(state => {
state.selectedComponentId = component._id state.selectedComponentId = component._id

View File

@ -36,11 +36,9 @@
// Messages that can be sent from the iframe preview to the builder // Messages that can be sent from the iframe preview to the builder
// Budibase events are and initalisation events // Budibase events are and initalisation events
const MessageTypes = { const MessageTypes = {
IFRAME_LOADED: "iframe-loaded",
READY: "ready", READY: "ready",
ERROR: "error", ERROR: "error",
BUDIBASE: "type", BUDIBASE: "type",
KEYDOWN: "keydown"
} }
// Construct iframe template // Construct iframe template
@ -69,7 +67,7 @@
theme: $store.theme, theme: $store.theme,
customTheme: $store.customTheme, customTheme: $store.customTheme,
previewDevice: $store.previewDevice, previewDevice: $store.previewDevice,
messagePassing: $store.clientFeatures.messagePassing messagePassing: $store.clientFeatures.messagePassing,
} }
// Saving pages and screens to the DB causes them to have _revs. // Saving pages and screens to the DB causes them to have _revs.
@ -111,7 +109,6 @@
loading = false loading = false
error = event.error || "An unknown error occurred" error = event.error || "An unknown error occurred"
}, },
[MessageTypes.KEYDOWN]: handleKeydownEvent
} }
const messageHandler = handlers[message.data.type] || handleBudibaseEvent const messageHandler = handlers[message.data.type] || handleBudibaseEvent
@ -122,15 +119,24 @@
window.addEventListener("message", receiveMessage) window.addEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) { if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB // Legacy - remove in later versions of BB
iframe.contentWindow.addEventListener("ready", () => { iframe.contentWindow.addEventListener(
receiveMessage({ data: { type: MessageTypes.READY }}) "ready",
}, { once: true }) () => {
iframe.contentWindow.addEventListener("error", event => { receiveMessage({ data: { type: MessageTypes.READY } })
receiveMessage({ data: { type: MessageTypes.ERROR, error: event.detail }}) },
}, { once: true }) { once: true }
)
iframe.contentWindow.addEventListener(
"error",
event => {
receiveMessage({
data: { type: MessageTypes.ERROR, error: event.detail },
})
},
{ once: true }
)
// Add listener for events sent by client library in preview // Add listener for events sent by client library in preview
iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent) iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent)
iframe.contentWindow.addEventListener("keydown", handleKeydownEvent)
} }
}) })
@ -140,14 +146,20 @@
window.removeEventListener("message", receiveMessage) window.removeEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) { if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB // Legacy - remove in later versions of BB
iframe.contentWindow.removeEventListener("bb-event", handleBudibaseEvent) iframe.contentWindow.removeEventListener(
iframe.contentWindow.removeEventListener("keydown", handleKeydownEvent) "bb-event",
handleBudibaseEvent
)
} }
} }
}) })
const handleBudibaseEvent = event => { const handleBudibaseEvent = event => {
const { type, data } = event.data || event.detail const { type, data } = event.data || event.detail
if (!type) {
return
}
if (type === "select-component" && data.id) { if (type === "select-component" && data.id) {
store.actions.components.select({ _id: data.id }) store.actions.components.select({ _id: data.id })
} else if (type === "update-prop") { } else if (type === "update-prop") {
@ -183,19 +195,6 @@
} }
} }
const handleKeydownEvent = event => {
const { key } = event.data || event
if (
(key === "Delete" || key === "Backspace") &&
selectedComponentId &&
["input", "textarea"].indexOf(
iframe.contentWindow.document.activeElement?.tagName.toLowerCase()
) === -1
) {
confirmDeleteComponent(selectedComponentId)
}
}
const confirmDeleteComponent = componentId => { const confirmDeleteComponent = componentId => {
idToDelete = componentId idToDelete = componentId
confirmDeleteDialog.show() confirmDeleteDialog.show()

View File

@ -84,7 +84,6 @@ export default `
if (window.loadBudibase) { if (window.loadBudibase) {
window.loadBudibase() window.loadBudibase()
document.documentElement.classList.add("loaded") document.documentElement.classList.add("loaded")
window.parent.postMessage({ type: "iframe-loaded" })
} else { } else {
throw "The client library couldn't be loaded" throw "The client library couldn't be loaded"
} }
@ -94,10 +93,6 @@ export default `
} }
window.addEventListener("message", receiveMessage) window.addEventListener("message", receiveMessage)
window.addEventListener("keydown", evt => {
window.parent.postMessage({ type: "keydown", key: event.key })
})
window.parent.postMessage({ type: "ready" }) window.parent.postMessage({ type: "ready" })
</script> </script>
</head> </head>

View File

@ -13,6 +13,12 @@
$: noChildrenAllowed = !component || !definition?.hasChildren $: noChildrenAllowed = !component || !definition?.hasChildren
$: noPaste = !$store.componentToPaste $: noPaste = !$store.componentToPaste
// "editable" has been repurposed for inline text editing.
// It remains here for legacy compatibility.
// Future components should define "static": true for indicate they should
// not show a context menu.
$: showMenu = definition?.editable !== false && definition?.static !== true
const moveUpComponent = () => { const moveUpComponent = () => {
const asset = get(currentAsset) const asset = get(currentAsset)
const parent = findComponentParent(asset.props, component._id) const parent = findComponentParent(asset.props, component._id)
@ -69,7 +75,7 @@
} }
</script> </script>
{#if definition?.editable !== false} {#if showMenu}
<ActionMenu> <ActionMenu>
<div slot="control" class="icon"> <div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" /> <Icon size="S" hoverable name="MoreSmallList" />

View File

@ -7,7 +7,7 @@
let modal let modal
$: setupComplete = $: setupComplete =
$datasources.list.find(x => (x._id = "bb_internal")).entities.length > 1 || $datasources.list.find(x => (x._id = "bb_internal"))?.entities.length > 1 ||
$datasources.list.length > 1 $datasources.list.length > 1
onMount(() => { onMount(() => {

View File

@ -240,13 +240,15 @@
"name": "Screenslot", "name": "Screenslot",
"icon": "WebPage", "icon": "WebPage",
"description": "Contains your app screens", "description": "Contains your app screens",
"editable": false "static": true
}, },
"button": { "button": {
"name": "Button", "name": "Button",
"description": "A basic html button that is ready for styling", "description": "A basic html button that is ready for styling",
"icon": "Button", "icon": "Button",
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"showSettingsBar": true,
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -255,6 +257,7 @@
}, },
{ {
"type": "select", "type": "select",
"showInBar": true,
"label": "Variant", "label": "Variant",
"key": "type", "key": "type",
"options": [ "options": [
@ -283,6 +286,7 @@
{ {
"type": "select", "type": "select",
"label": "Size", "label": "Size",
"showInBar": true,
"key": "size", "key": "size",
"options": [ "options": [
{ {
@ -307,11 +311,18 @@
{ {
"type": "boolean", "type": "boolean",
"label": "Quiet", "label": "Quiet",
"key": "quiet" "key": "quiet",
"showInBar": true,
"barIcon": "VisibilityOff",
"barTitle": "Quiet variant",
"barSeparator": false
}, },
{ {
"type": "boolean", "type": "boolean",
"label": "Disabled", "label": "Disabled",
"showInBar": true,
"barIcon": "NoEdit",
"barTitle": "Disable button",
"key": "disabled" "key": "disabled"
}, },
{ {
@ -590,6 +601,7 @@
"icon": "TextParagraph", "icon": "TextParagraph",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"showSettingsBar": true, "showSettingsBar": true,
"editable": true,
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -696,6 +708,7 @@
"description": "A component for displaying heading text", "description": "A component for displaying heading text",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"showSettingsBar": true, "showSettingsBar": true,
"editable": true,
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -940,6 +953,7 @@
"description": "A basic link component for internal and external links", "description": "A basic link component for internal and external links",
"icon": "Link", "icon": "Link",
"showSettingsBar": true, "showSettingsBar": true,
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -1831,6 +1845,7 @@
"icon": "Text", "icon": "Text",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"styles": ["size"], "styles": ["size"],
"editable": true,
"settings": [ "settings": [
{ {
"type": "field/string", "type": "field/string",
@ -1869,6 +1884,7 @@
"name": "Number Field", "name": "Number Field",
"icon": "123", "icon": "123",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -1908,6 +1924,7 @@
"name": "Password Field", "name": "Password Field",
"icon": "LockClosed", "icon": "LockClosed",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -1947,6 +1964,7 @@
"name": "Options Picker", "name": "Options Picker",
"icon": "ViewList", "icon": "ViewList",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -2070,6 +2088,7 @@
"name": "Multi-select Picker", "name": "Multi-select Picker",
"icon": "ViewList", "icon": "ViewList",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -2171,6 +2190,7 @@
"booleanfield": { "booleanfield": {
"name": "Checkbox", "name": "Checkbox",
"icon": "Checkmark", "icon": "Checkmark",
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -2234,6 +2254,7 @@
"name": "Rich Text", "name": "Rich Text",
"icon": "TextParagraph", "icon": "TextParagraph",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -2274,6 +2295,7 @@
"name": "Date Picker", "name": "Date Picker",
"icon": "DateInput", "icon": "DateInput",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -2319,6 +2341,7 @@
"name": "Attachment", "name": "Attachment",
"icon": "Attach", "icon": "Attach",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {
@ -2348,6 +2371,7 @@
"name": "Relationship Picker", "name": "Relationship Picker",
"icon": "TaskList", "icon": "TaskList",
"styles": ["size"], "styles": ["size"],
"editable": true,
"illegalChildren": ["section"], "illegalChildren": ["section"],
"settings": [ "settings": [
{ {

View File

@ -1,5 +1,5 @@
<script> <script>
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
import { setContext, onMount } from "svelte" import { setContext, onMount } from "svelte"
import { Layout, Heading, Body } from "@budibase/bbui" import { Layout, Heading, Body } from "@budibase/bbui"
import Component from "./Component.svelte" import Component from "./Component.svelte"
@ -25,6 +25,7 @@
import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
import DNDHandler from "components/preview/DNDHandler.svelte" import DNDHandler from "components/preview/DNDHandler.svelte"
import ErrorSVG from "builder/assets/error.svg" import ErrorSVG from "builder/assets/error.svg"
import KeyboardManager from "components/preview/KeyboardManager.svelte"
// Provide contexts // Provide contexts
setContext("sdk", SDK) setContext("sdk", SDK)
@ -39,7 +40,7 @@
await initialise() await initialise()
await authStore.actions.fetchUser() await authStore.actions.fetchUser()
dataLoaded = true dataLoaded = true
if ($builderStore.inBuilder) { if (get(builderStore).inBuilder) {
builderStore.actions.notifyLoaded() builderStore.actions.notifyLoaded()
} else { } else {
builderStore.actions.pingEndUser() builderStore.actions.pingEndUser()
@ -143,6 +144,7 @@
</UserBindingsProvider> </UserBindingsProvider>
{/if} {/if}
</div> </div>
<KeyboardManager />
{/if} {/if}
<style> <style>

View File

@ -4,7 +4,7 @@
<script> <script>
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable, get } from "svelte/store" import { writable } from "svelte/store"
import * as AppComponents from "components/app" import * as AppComponents from "components/app"
import Router from "./Router.svelte" import Router from "./Router.svelte"
import { enrichProps, propsAreSame } from "utils/componentProps" import { enrichProps, propsAreSame } from "utils/componentProps"
@ -19,15 +19,22 @@
export let isScreen = false export let isScreen = false
export let isBlock = false export let isBlock = false
// Component settings are the un-enriched settings for this component that
// need to be enriched at this level.
// Nested settings are the un-enriched block settings that are to be passed on
// and enriched at a deeper level.
let componentSettings
let nestedSettings
// The enriched component settings // The enriched component settings
let enrichedSettings let enrichedSettings
// Any prop overrides that need to be applied due to conditional UI // Any setting overrides that need to be applied due to conditional UI
let conditionalSettings let conditionalSettings
// Settings are hashed when inside the builder preview and used as a key, // Resultant cached settings which will be passed to the component instance.
// so that components fully remount whenever any settings change // These are a combination of the enriched, nested and conditional settings.
let hash = 0 let cachedSettings
// Latest timestamp that we started a props update. // Latest timestamp that we started a props update.
// Due to enrichment now being async, we need to avoid overwriting newer // Due to enrichment now being async, we need to avoid overwriting newer
@ -63,6 +70,7 @@
$: selected = $: selected =
$builderStore.inBuilder && $builderStore.selectedComponentId === id $builderStore.inBuilder && $builderStore.selectedComponentId === id
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id) $: inSelectedPath = $builderStore.selectedComponentPath?.includes(id)
$: inDragPath = inSelectedPath && $builderStore.editMode
// Interactive components can be selected, dragged and highlighted inside // Interactive components can be selected, dragged and highlighted inside
// the builder preview // the builder preview
@ -70,7 +78,9 @@
$builderStore.inBuilder && $builderStore.inBuilder &&
($builderStore.previewType === "layout" || insideScreenslot) && ($builderStore.previewType === "layout" || insideScreenslot) &&
!isBlock !isBlock
$: draggable = interactive && !isLayout && !isScreen $: editable = definition?.editable
$: editing = editable && selected && $builderStore.editMode
$: draggable = !inDragPath && interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen $: droppable = interactive && !isLayout && !isScreen
// Empty components are those which accept children but do not have any. // Empty components are those which accept children but do not have any.
@ -79,44 +89,39 @@
$: empty = interactive && !children.length && definition?.hasChildren $: empty = interactive && !children.length && definition?.hasChildren
$: emptyState = empty && definition?.showEmptyState !== false $: emptyState = empty && definition?.showEmptyState !== false
// Raw props are all props excluding internal props and children // Raw settings are all settings excluding internal props and children
$: rawSettings = getRawSettings(instance) $: rawSettings = getRawSettings(instance)
$: instanceKey = hashString(JSON.stringify(rawSettings)) $: instanceKey = hashString(JSON.stringify(rawSettings))
// Component settings are those which are intended for this component and // Update and enrich component settings
// which need to be enriched $: updateSettings(rawSettings, instanceKey, settingsDefinition, $context)
$: componentSettings = getComponentSettings(rawSettings, settingsDefinition)
$: enrichComponentSettings(rawSettings, instanceKey, $context)
// Nested settings are those which are intended for child components inside
// blocks and which should not be enriched at this level
$: nestedSettings = getNestedSettings(rawSettings, settingsDefinition)
// Evaluate conditional UI settings and store any component setting changes // Evaluate conditional UI settings and store any component setting changes
// which need to be made // which need to be made
$: evaluateConditions(enrichedSettings?._conditions) $: evaluateConditions(enrichedSettings?._conditions)
// Build up the final settings object to be passed to the component // Build up the final settings object to be passed to the component
$: settings = { $: cacheSettings(enrichedSettings, nestedSettings, conditionalSettings)
...enrichedSettings,
...nestedSettings,
...conditionalSettings,
}
// Render key is used when in the builder preview to fully remount
// components when settings are changed
$: renderKey = `${hash}-${emptyState}`
// Update component context // Update component context
$: componentStore.set({ $: componentStore.set({
id, id,
children: children.length, children: children.length,
styles: { ...instance._styles, id, empty: emptyState, interactive }, styles: {
...instance._styles,
id,
empty: emptyState,
interactive,
draggable,
editable,
},
empty: emptyState, empty: emptyState,
selected, selected,
name, name,
editing,
}) })
// Extracts all settings from the component instance
const getRawSettings = instance => { const getRawSettings = instance => {
let validSettings = {} let validSettings = {}
Object.entries(instance) Object.entries(instance)
@ -137,12 +142,14 @@
return AppComponents[name] return AppComponents[name]
} }
// Gets this component's definition from the manifest
const getComponentDefinition = component => { const getComponentDefinition = component => {
const prefix = "@budibase/standard-components/" const prefix = "@budibase/standard-components/"
const type = component?.replace(prefix, "") const type = component?.replace(prefix, "")
return type ? Manifest[type] : null return type ? Manifest[type] : null
} }
// Gets the definition of this component's settings from the manifest
const getSettingsDefinition = definition => { const getSettingsDefinition = definition => {
if (!definition) { if (!definition) {
return [] return []
@ -162,35 +169,50 @@
return settings return settings
} }
const getComponentSettings = (rawSettings, settingsDefinition) => { // Updates and enriches component settings when raw settings change
let clone = { ...rawSettings } const updateSettings = (settings, key, settingsDefinition, context) => {
settingsDefinition?.forEach(setting => { const instanceChanged = key !== lastInstanceKey
if (setting.nested) {
delete clone[setting.key] // Derive component and nested settings if the instance changed
} if (instanceChanged) {
}) splitRawSettings(settings, settingsDefinition)
return clone
} }
const getNestedSettings = (rawSettings, settingsDefinition) => { // Enrich component settings
let clone = { ...rawSettings } enrichComponentSettings(componentSettings, context, instanceChanged)
// Update instance key
if (instanceChanged) {
lastInstanceKey = key
}
}
// Splits the raw settings into those destined for the component itself
// and nexted settings for child components inside blocks
const splitRawSettings = (rawSettings, settingsDefinition) => {
let newComponentSettings = { ...rawSettings }
let newNestedSettings = { ...rawSettings }
settingsDefinition?.forEach(setting => { settingsDefinition?.forEach(setting => {
if (!setting.nested) { if (setting.nested) {
delete clone[setting.key] delete newComponentSettings[setting.key]
} else {
delete newNestedSettings[setting.key]
} }
}) })
return clone componentSettings = newComponentSettings
nestedSettings = newNestedSettings
} }
// Enriches any string component props using handlebars // Enriches any string component props using handlebars
const enrichComponentSettings = (rawSettings, instanceKey, context) => { const enrichComponentSettings = (rawSettings, context, instanceChanged) => {
const instanceSame = instanceKey === lastInstanceKey const contextChanged = context.key !== lastContextKey
const contextSame = context.key === lastContextKey
if (instanceSame && contextSame) { // Skip enrichment if the context and instance are unchanged
if (!contextChanged) {
if (!instanceChanged) {
return return
}
} else { } else {
lastInstanceKey = instanceKey
lastContextKey = context.key lastContextKey = context.key
} }
@ -206,31 +228,11 @@
return return
} }
// Update the component props. enrichedSettings = newEnrichedSettings
// Most props are deeply compared so that svelte will only trigger reactive
// statements on props that have actually changed.
if (!newEnrichedSettings) {
return
}
let propsChanged = false
if (!enrichedSettings) {
enrichedSettings = {}
propsChanged = true
}
Object.keys(newEnrichedSettings).forEach(key => {
if (!propsAreSame(newEnrichedSettings[key], enrichedSettings[key])) {
propsChanged = true
enrichedSettings[key] = newEnrichedSettings[key]
}
})
// Update the hash if we're in the builder so we can fully remount this
// component
if (get(builderStore).inBuilder && propsChanged) {
hash = hashString(JSON.stringify(enrichedSettings))
}
} }
// Evaluates the list of conditional UI conditions and determines any setting
// or visibility changes required
const evaluateConditions = conditions => { const evaluateConditions = conditions => {
if (!conditions?.length) { if (!conditions?.length) {
return return
@ -250,10 +252,25 @@
conditionalSettings = result.settingUpdates conditionalSettings = result.settingUpdates
visible = nextVisible visible = nextVisible
} }
// Combines and caches all settings which will be passed to the component
// instance. Settings are aggressively memoized to avoid triggering svelte
// reactive statements as much as possible.
const cacheSettings = (enriched, nested, conditional) => {
const allSettings = { ...enriched, ...nested, ...conditional }
if (!cachedSettings) {
cachedSettings = allSettings
} else {
Object.keys(allSettings).forEach(key => {
if (!propsAreSame(allSettings[key], cachedSettings[key])) {
cachedSettings[key] = allSettings[key]
}
})
}
}
</script> </script>
{#key renderKey} {#if constructor && cachedSettings && (visible || inSelectedPath)}
{#if constructor && settings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) --> <!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators --> <!-- and the performance matters for the selection indicators -->
<div <div
@ -262,11 +279,12 @@
class:droppable class:droppable
class:empty class:empty
class:interactive class:interactive
class:editing
class:block={isBlock} class:block={isBlock}
data-id={id} data-id={id}
data-name={name} data-name={name}
> >
<svelte:component this={constructor} {...settings}> <svelte:component this={constructor} {...cachedSettings}>
{#if children.length} {#if children.length}
{#each children as child (child._id)} {#each children as child (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} />
@ -278,8 +296,7 @@
{/if} {/if}
</svelte:component> </svelte:component>
</div> </div>
{/if} {/if}
{/key}
<style> <style>
.component { .component {
@ -291,4 +308,7 @@
.draggable :global(*:hover) { .draggable :global(*:hover) {
cursor: grab; cursor: grab;
} }
.editing :global(*:hover) {
cursor: auto;
}
</style> </style>

View File

@ -79,4 +79,9 @@
scrollbar-color: var(--spectrum-global-color-gray-400) scrollbar-color: var(--spectrum-global-color-gray-400)
var(--spectrum-alias-background-color-default); var(--spectrum-alias-background-color-default);
} }
/* Remove border when editing contenteditable components */
:global(*[contenteditable="true"]:focus) {
outline: none;
}
</style> </style>

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import "@spectrum-css/button/dist/index-vars.css" import "@spectrum-css/button/dist/index-vars.css"
const { styleable } = getContext("sdk") const { styleable, builderStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
export let disabled = false export let disabled = false
@ -11,16 +11,35 @@
export let size = "M" export let size = "M"
export let type = "primary" export let type = "primary"
export let quiet = false export let quiet = false
let node
$: $component.editing && node?.focus()
$: componentText = getComponentText(text, $builderStore, $component)
const getComponentText = (text, builderState, componentState) => {
if (!builderState.inBuilder || componentState.editing) {
return text || " "
}
return text || componentState.name || "Placeholder text"
}
const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent)
}
</script> </script>
<button <button
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`} class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`}
class:spectrum-Button--quiet={quiet} class:spectrum-Button--quiet={quiet}
disabled={disabled || false} {disabled}
use:styleable={$component.styles} use:styleable={$component.styles}
on:click={onClick} on:click={onClick}
contenteditable={$component.editing}
on:blur={$component.editing ? updateText : null}
bind:this={node}
> >
{text || ""} {componentText}
</button> </button>
<style> <style>

View File

@ -34,12 +34,18 @@
let bookmarks = [null] let bookmarks = [null]
let pageNumber = 0 let pageNumber = 0
let query = null let query = null
let queryExtensions = {}
// Sorting can be overridden at run time, so we can't use the prop directly // Sorting can be overridden at run time, so we can't use the prop directly
let currentSortColumn = sortColumn let currentSortColumn = sortColumn
let currentSortOrder = sortOrder let currentSortOrder = sortOrder
$: query = buildLuceneQuery(filter) // Reset the current sort state to props if props change
$: currentSortColumn = sortColumn
$: currentSortOrder = sortOrder
$: defaultQuery = buildLuceneQuery(filter)
$: extendQuery(defaultQuery, queryExtensions)
$: internalTable = dataSource?.type === "table" $: internalTable = dataSource?.type === "table"
$: nestedProvider = dataSource?.type === "provider" $: nestedProvider = dataSource?.type === "provider"
$: hasNextPage = bookmarks[pageNumber + 1] != null $: hasNextPage = bookmarks[pageNumber + 1] != null
@ -91,8 +97,12 @@
metadata: { dataSource }, metadata: { dataSource },
}, },
{ {
type: ActionTypes.SetDataProviderQuery, type: ActionTypes.AddDataProviderQueryExtension,
callback: newQuery => (query = newQuery), callback: addQueryExtension,
},
{
type: ActionTypes.RemoveDataProviderQueryExtension,
callback: removeQueryExtension,
}, },
{ {
type: ActionTypes.SetDataProviderSorting, type: ActionTypes.SetDataProviderSorting,
@ -264,6 +274,38 @@
pageNumber-- pageNumber--
allRows = res.rows allRows = res.rows
} }
const addQueryExtension = (key, operator, field, value) => {
if (!key || !operator || !field) {
return
}
const extension = { operator, field, value }
queryExtensions = { ...queryExtensions, [key]: extension }
}
const removeQueryExtension = key => {
if (!key) {
return
}
const newQueryExtensions = { ...queryExtensions }
delete newQueryExtensions[key]
queryExtensions = newQueryExtensions
}
const extendQuery = (defaultQuery, extensions) => {
const extensionValues = Object.values(extensions || {})
let extendedQuery = { ...defaultQuery }
extensionValues.forEach(({ operator, field, value }) => {
extendedQuery[operator] = {
...extendedQuery[operator],
[field]: value,
}
})
if (JSON.stringify(query) !== JSON.stringify(extendedQuery)) {
query = extendedQuery
}
}
</script> </script>
<div use:styleable={$component.styles} class="container"> <div use:styleable={$component.styles} class="container">

View File

@ -3,7 +3,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import utc from "dayjs/plugin/utc" import utc from "dayjs/plugin/utc"
import { onMount } from "svelte" import { onDestroy } from "svelte"
dayjs.extend(utc) dayjs.extend(utc)
@ -14,7 +14,14 @@
const component = getContext("component") const component = getContext("component")
const { styleable, ActionTypes, getAction } = getContext("sdk") const { styleable, ActionTypes, getAction } = getContext("sdk")
const setQuery = getAction(dataProvider?.id, ActionTypes.SetDataProviderQuery) $: addExtension = getAction(
dataProvider?.id,
ActionTypes.AddDataProviderQueryExtension
)
$: removeExtension = getAction(
dataProvider?.id,
ActionTypes.RemoveDataProviderQueryExtension
)
const options = [ const options = [
"Last 1 day", "Last 1 day",
"Last 7 days", "Last 7 days",
@ -25,44 +32,30 @@
] ]
let value = options.includes(defaultValue) ? defaultValue : "Last 30 days" let value = options.includes(defaultValue) ? defaultValue : "Last 30 days"
const updateDateRange = option => { $: queryExtension = getQueryExtension(value)
const query = dataProvider?.state?.query $: addExtension?.($component.id, "range", field, queryExtension)
if (!query || !setQuery) {
return
}
value = option const getQueryExtension = value => {
let low = dayjs.utc().subtract(1, "year") let low = dayjs.utc().subtract(1, "year")
let high = dayjs.utc().add(1, "day") let high = dayjs.utc().add(1, "day")
if (option === "Last 1 day") { if (value === "Last 1 day") {
low = dayjs.utc().subtract(1, "day") low = dayjs.utc().subtract(1, "day")
} else if (option === "Last 7 days") { } else if (value === "Last 7 days") {
low = dayjs.utc().subtract(7, "days") low = dayjs.utc().subtract(7, "days")
} else if (option === "Last 30 days") { } else if (value === "Last 30 days") {
low = dayjs.utc().subtract(30, "days") low = dayjs.utc().subtract(30, "days")
} else if (option === "Last 3 months") { } else if (value === "Last 3 months") {
low = dayjs.utc().subtract(3, "months") low = dayjs.utc().subtract(3, "months")
} else if (option === "Last 6 months") { } else if (value === "Last 6 months") {
low = dayjs.utc().subtract(6, "months") low = dayjs.utc().subtract(6, "months")
} }
// Update data provider query with the new filter return { low: low.format(), high: high.format() }
setQuery({
...query,
range: {
...query.range,
[field]: {
high: high.format(),
low: low.format(),
},
},
})
} }
// Update the range on mount to the initial value onDestroy(() => {
onMount(() => { removeExtension?.($component.id)
updateDateRange(value)
}) })
</script> </script>
@ -71,6 +64,6 @@
placeholder={null} placeholder={null}
{options} {options}
{value} {value}
on:change={e => updateDateRange(e.detail)} on:change={e => (value = e.detail)}
/> />
</div> </div>

View File

@ -13,10 +13,11 @@
export let underline export let underline
export let size export let size
$: placeholder = $builderStore.inBuilder && !text let node
$: componentText = $builderStore.inBuilder
? text || $component.name || "Placeholder text" $: $component.editing && node?.focus()
: text || "" $: placeholder = $builderStore.inBuilder && !text && !$component.editing
$: componentText = getComponentText(text, $builderStore, $component)
$: sizeClass = `spectrum-Heading--size${size || "M"}` $: sizeClass = `spectrum-Heading--size${size || "M"}`
$: alignClass = `align--${align || "left"}` $: alignClass = `align--${align || "left"}`
@ -24,6 +25,13 @@
// overrides the color when it's passed as inline style. // overrides the color when it's passed as inline style.
$: styles = enrichStyles($component.styles, color) $: styles = enrichStyles($component.styles, color)
const getComponentText = (text, builderState, componentState) => {
if (!builderState.inBuilder || componentState.editing) {
return text || ""
}
return text || componentState.name || "Placeholder text"
}
const enrichStyles = (styles, color) => { const enrichStyles = (styles, color) => {
if (!color) { if (!color) {
return styles return styles
@ -36,15 +44,24 @@
}, },
} }
} }
// Convert contenteditable HTML to text and save
const updateText = e => {
const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n")
builderStore.actions.updateProp("text", sanitized)
}
</script> </script>
<h1 <h1
bind:this={node}
contenteditable={$component.editing}
use:styleable={styles} use:styleable={styles}
class:placeholder class:placeholder
class:bold class:bold
class:italic class:italic
class:underline class:underline
class="spectrum-Heading {sizeClass} {alignClass}" class="spectrum-Heading {sizeClass} {alignClass}"
on:blur={$component.editing ? updateText : null}
> >
{componentText} {componentText}
</h1> </h1>

View File

@ -14,19 +14,25 @@
export let underline export let underline
export let size export let size
$: external = url && typeof url === "string" && !url.startsWith("/") let node
$: $component.editing && node?.focus()
$: externalLink = url && typeof url === "string" && !url.startsWith("/")
$: target = openInNewTab ? "_blank" : "_self" $: target = openInNewTab ? "_blank" : "_self"
$: placeholder = $builderStore.inBuilder && !text $: placeholder = $builderStore.inBuilder && !text
$: componentText = $builderStore.inBuilder $: componentText = getComponentText(text, $builderStore, $component)
? text || "Placeholder link"
: text || ""
// Add color styles to main styles object, otherwise the styleable helper
// overrides the color when it's passed as inline style.
// Add color styles to main styles object, otherwise the styleable helper // Add color styles to main styles object, otherwise the styleable helper
// overrides the color when it's passed as inline style. // overrides the color when it's passed as inline style.
$: styles = enrichStyles($component.styles, color) $: styles = enrichStyles($component.styles, color)
const getComponentText = (text, builderState, componentState) => {
if (!builderState.inBuilder || componentState.editing) {
return text || ""
}
return text || componentState.name || "Placeholder text"
}
const enrichStyles = (styles, color) => { const enrichStyles = (styles, color) => {
if (!color) { if (!color) {
return styles return styles
@ -39,10 +45,27 @@
}, },
} }
} }
const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent)
}
</script> </script>
{#if $builderStore.inBuilder || componentText} {#if $component.editing}
{#if external} <div
bind:this={node}
contenteditable
use:styleable={styles}
class:bold
class:italic
class:underline
class="align--{align || 'left'} size--{size || 'M'}"
on:blur={$component.editing ? updateText : null}
>
{componentText}
</div>
{:else if $builderStore.inBuilder || componentText}
{#if externalLink}
<a <a
{target} {target}
href={url || "/"} href={url || "/"}
@ -72,12 +95,12 @@
{/if} {/if}
<style> <style>
a { a,
div {
color: var(--spectrum-alias-text-color); color: var(--spectrum-alias-text-color);
white-space: nowrap;
transition: color 130ms ease-in-out; transition: color 130ms ease-in-out;
} }
a:hover { a:not(.placeholder):hover {
color: var(--spectrum-link-primary-m-text-color-hover) !important; color: var(--spectrum-link-primary-m-text-color-hover) !important;
} }
.placeholder { .placeholder {

View File

@ -12,10 +12,11 @@
export let underline export let underline
export let size export let size
$: placeholder = $builderStore.inBuilder && !text let node
$: componentText = $builderStore.inBuilder
? text || $component.name || "Placeholder text" $: $component.editing && node?.focus()
: text || "" $: placeholder = $builderStore.inBuilder && !text && !$component.editing
$: componentText = getComponentText(text, $builderStore, $component)
$: sizeClass = `spectrum-Body--size${size || "M"}` $: sizeClass = `spectrum-Body--size${size || "M"}`
$: alignClass = `align--${align || "left"}` $: alignClass = `align--${align || "left"}`
@ -23,6 +24,13 @@
// overrides the color when it's passed as inline style. // overrides the color when it's passed as inline style.
$: styles = enrichStyles($component.styles, color) $: styles = enrichStyles($component.styles, color)
const getComponentText = (text, builderState, componentState) => {
if (!builderState.inBuilder || componentState.editing) {
return text || ""
}
return text || componentState.name || "Placeholder text"
}
const enrichStyles = (styles, color) => { const enrichStyles = (styles, color) => {
if (!color) { if (!color) {
return styles return styles
@ -35,15 +43,24 @@
}, },
} }
} }
// Convert contenteditable HTML to text and save
const updateText = e => {
const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n")
builderStore.actions.updateProp("text", sanitized)
}
</script> </script>
<p <p
bind:this={node}
contenteditable={$component.editing}
use:styleable={styles} use:styleable={styles}
class:placeholder class:placeholder
class:bold class:bold
class:italic class:italic
class:underline class:underline
class="spectrum-Body {sizeClass} {alignClass}" class="spectrum-Body {sizeClass} {alignClass}"
on:blur={$component.editing ? updateText : null}
> >
{componentText} {componentText}
</p> </p>

View File

@ -1,5 +1,5 @@
<script> <script>
import { onMount, getContext } from "svelte" import { getContext } from "svelte"
import Block from "components/Block.svelte" import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { Heading } from "@budibase/bbui" import { Heading } from "@budibase/bbui"
@ -46,6 +46,7 @@
let repeaterId let repeaterId
let schema let schema
$: fetchSchema(dataSource)
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema) $: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema)
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId) $: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId)
$: cardWidth = cardHorizontal ? 420 : 300 $: cardWidth = cardHorizontal ? 420 : 300
@ -107,12 +108,12 @@
return `${split[0]}/{{ ${safe(repeaterId)}.${safe(col)} }}` return `${split[0]}/{{ ${safe(repeaterId)}.${safe(col)} }}`
} }
// Load the datasource schema on mount so we can determine column types // Load the datasource schema so we can determine column types
onMount(async () => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource) {
schema = await API.fetchDatasourceSchema(dataSource) schema = await API.fetchDatasourceSchema(dataSource)
} }
}) }
</script> </script>
<Block> <Block>

View File

@ -1,5 +1,5 @@
<script> <script>
import { onMount, getContext } from "svelte" import { getContext } from "svelte"
import Block from "components/Block.svelte" import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { Heading } from "@budibase/bbui" import { Heading } from "@budibase/bbui"
@ -41,6 +41,7 @@
let dataProviderId let dataProviderId
let schema let schema
$: fetchSchema(dataSource)
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema) $: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema)
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId) $: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId)
$: titleButtonAction = [ $: titleButtonAction = [
@ -85,12 +86,12 @@
return enrichedColumns.slice(0, 3) return enrichedColumns.slice(0, 3)
} }
// Load the datasource schema on mount so we can determine column types // Load the datasource schema so we can determine column types
onMount(async () => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource) {
schema = await API.fetchDatasourceSchema(dataSource) schema = await API.fetchDatasourceSchema(dataSource)
} }
}) }
</script> </script>
<Block> <Block>

View File

@ -17,54 +17,53 @@
const formContext = getContext("form") const formContext = getContext("form")
const formStepContext = getContext("form-step") const formStepContext = getContext("form-step")
const fieldGroupContext = getContext("field-group") const fieldGroupContext = getContext("field-group")
const { styleable } = getContext("sdk") const { styleable, builderStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
// Register field with form // Register field with form
const formApi = formContext?.formApi const formApi = formContext?.formApi
const labelPos = fieldGroupContext?.labelPosition || "above" const labelPos = fieldGroupContext?.labelPosition || "above"
const formField = formApi?.registerField( $: formStep = formStepContext ? $formStepContext || 1 : 1
$: formField = formApi?.registerField(
field, field,
type, type,
defaultValue, defaultValue,
disabled, disabled,
validation, validation,
formStepContext || 1 formStep
) )
// Focus label when editing
let labelNode
$: $component.editing && labelNode?.focus()
// Update form properties in parent component on every store change // Update form properties in parent component on every store change
const unsubscribe = formField?.subscribe(value => { $: unsubscribe = formField?.subscribe(value => {
fieldState = value?.fieldState fieldState = value?.fieldState
fieldApi = value?.fieldApi fieldApi = value?.fieldApi
fieldSchema = value?.fieldSchema fieldSchema = value?.fieldSchema
}) })
onDestroy(() => unsubscribe?.()) onDestroy(() => unsubscribe?.())
// Keep field state up to date with props which might change due to
// conditional UI
$: updateValidation(validation)
$: updateDisabled(disabled)
// Determine label class from position // Determine label class from position
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}` $: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
const updateValidation = validation => { const updateLabel = e => {
fieldApi?.updateValidation(validation) builderStore.actions.updateProp("label", e.target.textContent)
}
const updateDisabled = disabled => {
fieldApi?.setDisabled(disabled)
} }
</script> </script>
<FieldGroupFallback> <FieldGroupFallback>
<div class="spectrum-Form-item" use:styleable={$component.styles}> <div class="spectrum-Form-item" use:styleable={$component.styles}>
<label <label
bind:this={labelNode}
contenteditable={$component.editing}
on:blur={$component.editing ? updateLabel : null}
class:hidden={!label} class:hidden={!label}
for={fieldState?.fieldId} for={fieldState?.fieldId}
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelClass}`} class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelClass}`}
> >
{label || ""} {label || " "}
</label> </label>
<div class="spectrum-Form-itemField"> <div class="spectrum-Form-itemField">
{#if !formContext} {#if !formContext}

View File

@ -1,6 +1,7 @@
<script> <script>
import { getContext, onMount } from "svelte" import { getContext } from "svelte"
import InnerForm from "./InnerForm.svelte" import InnerForm from "./InnerForm.svelte"
import { hashString } from "utils/helpers"
export let dataSource export let dataSource
export let theme export let theme
@ -15,6 +16,8 @@
let schema let schema
let table let table
$: fetchSchema(dataSource)
// Returns the closes data context which isn't a built in context // Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, context) => { const getInitialValues = (type, dataSource, context) => {
// Only inherit values for update forms // Only inherit values for update forms
@ -35,7 +38,7 @@
} }
// Fetches the form schema from this form's dataSource // Fetches the form schema from this form's dataSource
const fetchSchema = async () => { const fetchSchema = async dataSource => {
if (!dataSource) { if (!dataSource) {
schema = {} schema = {}
} }
@ -62,14 +65,15 @@
schema = dataSourceSchema || {} schema = dataSourceSchema || {}
} }
if (!loaded) {
loaded = true loaded = true
} }
}
$: initialValues = getInitialValues(actionType, dataSource, $context) $: initialValues = getInitialValues(actionType, dataSource, $context)
$: resetKey = JSON.stringify(initialValues) $: resetKey = hashString(
JSON.stringify(initialValues) + JSON.stringify(schema)
// Load the form schema on mount )
onMount(fetchSchema)
</script> </script>
{#if loaded} {#if loaded}

View File

@ -1,5 +1,6 @@
<script> <script>
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import Placeholder from "../Placeholder.svelte" import Placeholder from "../Placeholder.svelte"
export let step = 1 export let step = 1
@ -9,7 +10,9 @@
const formContext = getContext("form") const formContext = getContext("form")
// Set form step context so fields know what step they are within // Set form step context so fields know what step they are within
setContext("form-step", step || 1) const stepStore = writable(step || 1)
$: stepStore.set(step || 1)
setContext("form-step", stepStore)
$: formState = formContext?.formState $: formState = formContext?.formState
$: currentStep = $formState?.currentStep $: currentStep = $formState?.currentStep

View File

@ -96,15 +96,14 @@
return return
} }
// If we've already registered this field then wipe any errors and // If we've already registered this field then keep some existing state
// return the existing field let initialValue = initialValues[field] ?? defaultValue
let fieldId = `id-${generateID()}`
const existingField = getField(field) const existingField = getField(field)
if (existingField) { if (existingField) {
existingField.update(state => { const { fieldState } = get(existingField)
state.fieldState.error = null initialValue = fieldState.value ?? initialValue
return state fieldId = fieldState.fieldId
})
return existingField
} }
// Auto columns are always disabled // Auto columns are always disabled
@ -125,8 +124,8 @@
type, type,
step: step || 1, step: step || 1,
fieldState: { fieldState: {
fieldId: `id-${generateID()}`, fieldId,
value: initialValues[field] ?? defaultValue, value: initialValue,
error: null, error: null,
disabled: disabled || fieldDisabled || isAutoColumn, disabled: disabled || fieldDisabled || isAutoColumn,
defaultValue, defaultValue,
@ -137,7 +136,12 @@
}) })
// Add this field // Add this field
if (existingField) {
const otherFields = fields.filter(info => get(info).name !== field)
fields = [...otherFields, fieldInfo]
} else {
fields = [...fields, fieldInfo] fields = [...fields, fieldInfo]
}
return fieldInfo return fieldInfo
}, },
@ -287,7 +291,7 @@
// Provide form step context so that forms without any step components // Provide form step context so that forms without any step components
// register their fields to step 1 // register their fields to step 1
setContext("form-step", 1) setContext("form-step", writable(1))
// Action context to pass to children // Action context to pass to children
const actions = [ const actions = [

View File

@ -17,10 +17,6 @@
const component = getContext("component") const component = getContext("component")
const { styleable, getAction, ActionTypes, routeStore } = getContext("sdk") const { styleable, getAction, ActionTypes, routeStore } = getContext("sdk")
const setSorting = getAction(
dataProvider?.id,
ActionTypes.SetDataProviderSorting
)
const customColumnKey = `custom-${Math.random()}` const customColumnKey = `custom-${Math.random()}`
const customRenderers = [ const customRenderers = [
{ {
@ -29,13 +25,16 @@
}, },
] ]
// Table state
$: hasChildren = $component.children $: hasChildren = $component.children
$: loading = dataProvider?.loading ?? false $: loading = dataProvider?.loading ?? false
$: data = dataProvider?.rows || [] $: data = dataProvider?.rows || []
$: fullSchema = dataProvider?.schema ?? {} $: fullSchema = dataProvider?.schema ?? {}
$: fields = getFields(fullSchema, columns, showAutoColumns) $: fields = getFields(fullSchema, columns, showAutoColumns)
$: schema = getFilteredSchema(fullSchema, fields, hasChildren) $: schema = getFilteredSchema(fullSchema, fields, hasChildren)
$: setSorting = getAction(
dataProvider?.id,
ActionTypes.SetDataProviderSorting
)
const getFields = (schema, customColumns, showAutoColumns) => { const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection // Check for an invalid column selection

View File

@ -17,10 +17,9 @@
<div <div
in:fade={{ in:fade={{
delay: transition ? 50 : 0, delay: transition ? 130 : 0,
duration: transition ? 130 : 0, duration: transition ? 130 : 0,
}} }}
out:fade={{ duration: transition ? 130 : 0 }}
class="indicator" class="indicator"
class:flipped class:flipped
class:line class:line

View File

@ -0,0 +1,35 @@
<script>
import { onMount, onDestroy } from "svelte"
import { get } from "svelte/store"
import { builderStore } from "stores"
onMount(() => {
if (get(builderStore).inBuilder) {
document.addEventListener("keydown", onKeyDown)
}
})
onDestroy(() => {
if (get(builderStore).inBuilder) {
document.removeEventListener("keydown", onKeyDown)
}
})
const onKeyDown = e => {
if (e.key === "Delete" || e.key === "Backspace") {
deleteSelectedComponent()
}
}
const deleteSelectedComponent = () => {
const state = get(builderStore)
if (!state.inBuilder || !state.selectedComponentId || state.editMode) {
return
}
const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1) {
return
}
builderStore.actions.deleteComponent(state.selectedComponentId)
}
</script>

View File

@ -1,11 +1,15 @@
<script> <script>
import { builderStore } from "stores" import { builderStore } from "stores"
import IndicatorSet from "./IndicatorSet.svelte" import IndicatorSet from "./IndicatorSet.svelte"
$: color = $builderStore.editMode
? "var(--spectrum-global-color-static-green-500)"
: "var(--spectrum-global-color-static-blue-600)"
</script> </script>
<IndicatorSet <IndicatorSet
componentId={$builderStore.selectedComponentId} componentId={$builderStore.selectedComponentId}
color="var(--spectrum-global-color-static-blue-600)" {color}
zIndex="910" zIndex="910"
transition transition
/> />

View File

@ -122,7 +122,7 @@
prop={setting.key} prop={setting.key}
value={option.value} value={option.value}
icon={option.barIcon} icon={option.barIcon}
title={option.barTitle} title={option.barTitle || option.label}
/> />
{/each} {/each}
{:else} {:else}
@ -136,7 +136,7 @@
<SettingsButton <SettingsButton
prop={setting.key} prop={setting.key}
icon={setting.barIcon} icon={setting.barIcon}
title={setting.barTitle} title={setting.barTitle || setting.label}
bool bool
/> />
{:else if setting.type === "color"} {:else if setting.type === "color"}

View File

@ -25,7 +25,8 @@ export const UnsortableTypes = [
export const ActionTypes = { export const ActionTypes = {
ValidateForm: "ValidateForm", ValidateForm: "ValidateForm",
RefreshDatasource: "RefreshDatasource", RefreshDatasource: "RefreshDatasource",
SetDataProviderQuery: "SetDataProviderQuery", AddDataProviderQueryExtension: "AddDataProviderQueryExtension",
RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension",
SetDataProviderSorting: "SetDataProviderSorting", SetDataProviderSorting: "SetDataProviderSorting",
ClearForm: "ClearForm", ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep", ChangeFormStep: "ChangeFormStep",

View File

@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store" import { writable, derived, get } from "svelte/store"
import Manifest from "manifest.json" import Manifest from "manifest.json"
import { findComponentById, findComponentPathById } from "../utils/components" import { findComponentById, findComponentPathById } from "../utils/components"
import { pingEndUser } from "../api" import { pingEndUser } from "../api"
@ -14,6 +14,7 @@ const createBuilderStore = () => {
layout: null, layout: null,
screen: null, screen: null,
selectedComponentId: null, selectedComponentId: null,
editMode: false,
previewId: null, previewId: null,
previewType: null, previewType: null,
selectedPath: [], selectedPath: [],
@ -50,6 +51,10 @@ const createBuilderStore = () => {
const actions = { const actions = {
selectComponent: id => { selectComponent: id => {
if (id === get(writableStore).selectedComponentId) {
return
}
writableStore.update(state => ({ ...state, editMode: false }))
dispatchEvent("select-component", { id }) dispatchEvent("select-component", { id })
}, },
updateProp: (prop, value) => { updateProp: (prop, value) => {
@ -65,10 +70,7 @@ const createBuilderStore = () => {
pingEndUser() pingEndUser()
}, },
setSelectedPath: path => { setSelectedPath: path => {
writableStore.update(state => { writableStore.update(state => ({ ...state, selectedPath: path }))
state.selectedPath = path
return state
})
}, },
moveComponent: (componentId, destinationComponentId, mode) => { moveComponent: (componentId, destinationComponentId, mode) => {
dispatchEvent("move-component", { dispatchEvent("move-component", {
@ -78,10 +80,16 @@ const createBuilderStore = () => {
}) })
}, },
setDragging: dragging => { setDragging: dragging => {
writableStore.update(state => { if (dragging === get(writableStore).isDragging) {
state.isDragging = dragging return
return state }
}) writableStore.update(state => ({ ...state, isDragging: dragging }))
},
setEditMode: enabled => {
if (enabled === get(writableStore).editMode) {
return
}
writableStore.update(state => ({ ...state, editMode: enabled }))
}, },
} }
return { return {

View File

@ -21,12 +21,7 @@ export const styleable = (node, styles = {}) => {
let applyNormalStyles let applyNormalStyles
let applyHoverStyles let applyHoverStyles
let selectComponent let selectComponent
let editComponent
// Allow dragging if required
const parent = node.closest(".component")
if (parent && parent.classList.contains("draggable")) {
node.setAttribute("draggable", true)
}
// Creates event listeners and applies initial styles // Creates event listeners and applies initial styles
const setupStyles = (newStyles = {}) => { const setupStyles = (newStyles = {}) => {
@ -45,6 +40,9 @@ export const styleable = (node, styles = {}) => {
...(newStyles.hover || {}), ...(newStyles.hover || {}),
} }
// Allow dragging if required
node.setAttribute("draggable", !!newStyles.draggable)
// Applies a style string to a DOM node // Applies a style string to a DOM node
const applyStyles = styleString => { const applyStyles = styleString => {
node.style = styleString node.style = styleString
@ -69,6 +67,17 @@ export const styleable = (node, styles = {}) => {
return false return false
} }
// Handler to start editing a component (if applicable) when double
// clicking in the builder preview
editComponent = event => {
if (newStyles.interactive && newStyles.editable) {
builderStore.actions.setEditMode(true)
}
event.preventDefault()
event.stopPropagation()
return false
}
// Add listeners to toggle hover styles // Add listeners to toggle hover styles
node.addEventListener("mouseover", applyHoverStyles) node.addEventListener("mouseover", applyHoverStyles)
node.addEventListener("mouseout", applyNormalStyles) node.addEventListener("mouseout", applyNormalStyles)
@ -76,6 +85,7 @@ export const styleable = (node, styles = {}) => {
// Add builder preview click listener // Add builder preview click listener
if (newStyles.interactive) { if (newStyles.interactive) {
node.addEventListener("click", selectComponent, false) node.addEventListener("click", selectComponent, false)
node.addEventListener("dblclick", editComponent, false)
} }
// Apply initial normal styles // Apply initial normal styles
@ -87,6 +97,7 @@ export const styleable = (node, styles = {}) => {
node.removeEventListener("mouseover", applyHoverStyles) node.removeEventListener("mouseover", applyHoverStyles)
node.removeEventListener("mouseout", applyNormalStyles) node.removeEventListener("mouseout", applyNormalStyles)
node.removeEventListener("click", selectComponent) node.removeEventListener("click", selectComponent)
node.removeEventListener("dblclick", editComponent)
} }
// Apply initial styles // Apply initial styles