+
{/if}
diff --git a/packages/client/src/components/Screen.svelte b/packages/client/src/components/Screen.svelte
index ac17556113..6a65e89afd 100644
--- a/packages/client/src/components/Screen.svelte
+++ b/packages/client/src/components/Screen.svelte
@@ -11,8 +11,11 @@
// Redirect to home page if no matching route
$: screenDefinition == null && routeStore.actions.navigate("/")
+
+ // Make a screen array so we can use keying to properly re-render each screen
+ $: screens = screenDefinition ? [screenDefinition] : []
-{#if screenDefinition}
-
-{/if}
+{#each screens as screen (screen._id)}
+
+{/each}
diff --git a/packages/client/src/index.js b/packages/client/src/index.js
index cb00d4b708..2925e950f6 100644
--- a/packages/client/src/index.js
+++ b/packages/client/src/index.js
@@ -1,11 +1,22 @@
import ClientApp from "./components/ClientApp.svelte"
+import { builderStore } from "./store"
+
+let app
-// Initialise client app
const loadBudibase = () => {
- window.document.body.innerHTML = ""
- new ClientApp({
- target: window.document.body,
+ // Update builder store with any builder flags
+ builderStore.set({
+ inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
+ page: window["##BUDIBASE_PREVIEW_PAGE##"],
+ screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
})
+
+ // Create app if one hasn't been created yet
+ if (!app) {
+ app = new ClientApp({
+ target: window.document.body,
+ })
+ }
}
// Attach to window so the HTML template can call this when it loads
diff --git a/packages/client/src/sdk.js b/packages/client/src/sdk.js
index cad3e20bd5..a0b8aa6719 100644
--- a/packages/client/src/sdk.js
+++ b/packages/client/src/sdk.js
@@ -1,6 +1,7 @@
import * as API from "./api"
-import { authStore, routeStore, screenStore } from "./store"
-import { styleable, getAppId } from "./utils"
+import { authStore, routeStore, screenStore, bindingStore } from "./store"
+import { styleable } from "./utils/styleable"
+import { getAppId } from "./utils/getAppId"
import { link as linkable } from "svelte-spa-router"
import DataProvider from "./components/DataProvider.svelte"
@@ -13,4 +14,5 @@ export default {
linkable,
getAppId,
DataProvider,
+ setBindableValue: bindingStore.actions.setBindableValue,
}
diff --git a/packages/client/src/store/auth.js b/packages/client/src/store/auth.js
index 54ee35e8da..a7c91a0972 100644
--- a/packages/client/src/store/auth.js
+++ b/packages/client/src/store/auth.js
@@ -1,13 +1,10 @@
import * as API from "../api"
-import { getAppId } from "../utils"
+import { getAppId } from "../utils/getAppId"
import { writable } from "svelte/store"
const createAuthStore = () => {
const store = writable("")
- /**
- * Logs a user in.
- */
const logIn = async ({ username, password }) => {
const user = await API.logIn({ username, password })
if (!user.error) {
@@ -15,10 +12,6 @@ const createAuthStore = () => {
location.reload()
}
}
-
- /**
- * Logs a user out.
- */
const logOut = () => {
store.set("")
const appId = getAppId()
diff --git a/packages/client/src/store/binding.js b/packages/client/src/store/binding.js
new file mode 100644
index 0000000000..b279142c32
--- /dev/null
+++ b/packages/client/src/store/binding.js
@@ -0,0 +1,21 @@
+import { writable } from "svelte/store"
+
+const createBindingStore = () => {
+ const store = writable({})
+
+ const setBindableValue = (value, componentId) => {
+ store.update(state => {
+ if (componentId) {
+ state[componentId] = value
+ }
+ return state
+ })
+ }
+
+ return {
+ subscribe: store.subscribe,
+ actions: { setBindableValue },
+ }
+}
+
+export const bindingStore = createBindingStore()
diff --git a/packages/client/src/store/builder.js b/packages/client/src/store/builder.js
new file mode 100644
index 0000000000..4b197596be
--- /dev/null
+++ b/packages/client/src/store/builder.js
@@ -0,0 +1,12 @@
+import { writable } from "svelte/store"
+
+const createBuilderStore = () => {
+ const initialState = {
+ inBuilder: false,
+ page: null,
+ screen: null,
+ }
+ return writable(initialState)
+}
+
+export const builderStore = createBuilderStore()
diff --git a/packages/client/src/store/data.js b/packages/client/src/store/data.js
new file mode 100644
index 0000000000..5ff2b9b631
--- /dev/null
+++ b/packages/client/src/store/data.js
@@ -0,0 +1,26 @@
+import { writable } from "svelte/store"
+import { cloneDeep } from "lodash/fp"
+
+export const createDataStore = existingContext => {
+ const store = writable({ ...existingContext })
+
+ // Adds a context layer to the data context tree
+ const addContext = (row, componentId) => {
+ store.update(state => {
+ if (componentId) {
+ state[componentId] = row
+ state[`${componentId}_draft`] = cloneDeep(row)
+ state.closestComponentId = componentId
+ }
+ return state
+ })
+ }
+
+ return {
+ subscribe: store.subscribe,
+ update: store.update,
+ actions: { addContext },
+ }
+}
+
+export const dataStore = createDataStore()
diff --git a/packages/client/src/store/dataContext.js b/packages/client/src/store/dataContext.js
deleted file mode 100644
index 90acc42664..0000000000
--- a/packages/client/src/store/dataContext.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { writable, get } from "svelte/store"
-import { enrichDataBinding } from "../utils"
-import { cloneDeep } from "lodash/fp"
-
-const initialValue = {
- data: null,
-}
-
-export const createDataContextStore = existingContext => {
- const initial = existingContext ? cloneDeep(existingContext) : initialValue
- const store = writable(initial)
-
- // Adds a context layer to the data context tree
- const addContext = (row, componentId) => {
- store.update(state => {
- if (row && componentId) {
- state[componentId] = row
- state.data = row
- }
- return state
- })
- }
-
- // Enriches props by running mustache and filling in any data bindings present
- // in the prop values
- const enrichDataBindings = props => {
- const state = get(store)
- let enrichedProps = {}
- Object.entries(props).forEach(([key, value]) => {
- enrichedProps[key] = enrichDataBinding(value, state)
- })
- return enrichedProps
- }
-
- return {
- subscribe: store.subscribe,
- actions: { addContext, enrichDataBindings },
- }
-}
diff --git a/packages/client/src/store/index.js b/packages/client/src/store/index.js
index a0683ba47e..58e51b6fc1 100644
--- a/packages/client/src/store/index.js
+++ b/packages/client/src/store/index.js
@@ -1,4 +1,8 @@
export { authStore } from "./auth"
export { routeStore } from "./routes"
export { screenStore } from "./screens"
-export { createDataContextStore } from "./dataContext"
+export { builderStore } from "./builder"
+export { bindingStore } from "./binding"
+
+// Data stores are layered and duplicated, so it is not a singleton
+export { createDataStore, dataStore } from "./data"
diff --git a/packages/client/src/store/screens.js b/packages/client/src/store/screens.js
index a39a67e3d4..2d977ec025 100644
--- a/packages/client/src/store/screens.js
+++ b/packages/client/src/store/screens.js
@@ -1,43 +1,45 @@
import { writable, derived } from "svelte/store"
import { routeStore } from "./routes"
+import { builderStore } from "./builder"
import * as API from "../api"
-import { getAppId } from "../utils"
+import { getAppId } from "../utils/getAppId"
const createScreenStore = () => {
const config = writable({
screens: [],
page: {},
})
- const store = derived([config, routeStore], ([$config, $routeStore]) => {
- const { screens, page } = $config
- const activeScreen =
- screens.length === 1
- ? screens[0]
- : screens.find(
+ const store = derived(
+ [config, routeStore, builderStore],
+ ([$config, $routeStore, $builderStore]) => {
+ let page
+ let activeScreen
+ if ($builderStore.inBuilder) {
+ // Use builder defined definitions if inside the builder preview
+ page = $builderStore.page
+ activeScreen = $builderStore.screen
+ } else {
+ // Otherwise find the correct screen by matching the current route
+ page = $config.page
+ const { screens } = $config
+ if (screens.length === 1) {
+ activeScreen = screens[0]
+ } else {
+ activeScreen = screens.find(
screen => screen.routing.route === $routeStore.activeRoute
)
- return {
- screens,
- page,
- activeScreen,
+ }
+ }
+ return { page, activeScreen }
}
- })
+ )
const fetchScreens = async () => {
- let screens
- let page
- const inBuilder = !!window["##BUDIBASE_IN_BUILDER##"]
- if (inBuilder) {
- // Load screen and page from the window object if in the builder
- screens = [window["##BUDIBASE_PREVIEW_SCREEN##"]]
- page = window["##BUDIBASE_PREVIEW_PAGE##"]
- } else {
- // Otherwise load from API
- const appDefinition = await API.fetchAppDefinition(getAppId())
- screens = appDefinition.screens
- page = appDefinition.page
- }
- config.set({ screens, page })
+ const appDefinition = await API.fetchAppDefinition(getAppId())
+ config.set({
+ screens: appDefinition.screens,
+ page: appDefinition.page,
+ })
}
return {
diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js
new file mode 100644
index 0000000000..2968525188
--- /dev/null
+++ b/packages/client/src/utils/buttonActions.js
@@ -0,0 +1,45 @@
+import { enrichDataBinding } from "./enrichDataBinding"
+import { routeStore } from "../store"
+import { saveRow, deleteRow } from "../api"
+
+const saveRowHandler = async (action, context) => {
+ let draft = context[`${action.parameters.contextPath}_draft`]
+ if (action.parameters.fields) {
+ Object.entries(action.parameters.fields).forEach(([key, entry]) => {
+ draft[key] = enrichDataBinding(entry.value, context)
+ })
+ }
+ await saveRow(draft)
+}
+
+const deleteRowHandler = async (action, context) => {
+ const { tableId, revId, rowId } = action.parameters
+ await deleteRow({
+ tableId: enrichDataBinding(tableId, context),
+ rowId: enrichDataBinding(rowId, context),
+ revId: enrichDataBinding(revId, context),
+ })
+}
+
+const navigationHandler = action => {
+ routeStore.actions.navigate(action.parameters.url)
+}
+
+const handlerMap = {
+ ["Save Row"]: saveRowHandler,
+ ["Delete Row"]: deleteRowHandler,
+ ["Navigate To"]: navigationHandler,
+}
+
+/**
+ * Parses an array of actions and returns a function which will execute the
+ * actions in the current context.
+ */
+export const enrichButtonActions = (actions, context) => {
+ const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
+ return async () => {
+ for (let i = 0; i < handlers.length; i++) {
+ await handlers[i](actions[i], context)
+ }
+ }
+}
diff --git a/packages/client/src/utils/componentProps.js b/packages/client/src/utils/componentProps.js
new file mode 100644
index 0000000000..be65ad2bfe
--- /dev/null
+++ b/packages/client/src/utils/componentProps.js
@@ -0,0 +1,35 @@
+import { enrichDataBindings } from "./enrichDataBinding"
+import { enrichButtonActions } from "./buttonActions"
+
+/**
+ * Enriches component props.
+ * Data bindings are enriched, and button actions are enriched.
+ */
+export const enrichProps = (props, dataContexts, dataBindings) => {
+ // Exclude all private props that start with an underscore
+ let validProps = {}
+ Object.entries(props)
+ .filter(([name]) => !name.startsWith("_"))
+ .forEach(([key, value]) => {
+ validProps[key] = value
+ })
+
+ // Create context of all bindings and data contexts
+ // Duplicate the closest context as "data" which the builder requires
+ const context = {
+ ...dataContexts,
+ ...dataBindings,
+ data: dataContexts[dataContexts.closestComponentId],
+ data_draft: dataContexts[`${dataContexts.closestComponentId}_draft`],
+ }
+
+ // Enrich all data bindings in top level props
+ let enrichedProps = enrichDataBindings(validProps, context)
+
+ // Enrich button actions if they exist
+ if (props._component.endsWith("/button") && enrichedProps.onClick) {
+ enrichedProps.onClick = enrichButtonActions(enrichedProps.onClick, context)
+ }
+
+ return enrichedProps
+}
diff --git a/packages/client/src/utils/enrichDataBinding.js b/packages/client/src/utils/enrichDataBinding.js
index 3b16f70fc9..5de6b31a89 100644
--- a/packages/client/src/utils/enrichDataBinding.js
+++ b/packages/client/src/utils/enrichDataBinding.js
@@ -8,6 +8,9 @@ const entityMap = {
">": ">",
}
mustache.escape = text => {
+ if (text == null || typeof text !== "string") {
+ return text
+ }
return text.replace(/[<>]/g, function fromEntityMap(s) {
return entityMap[s] || s
})
@@ -30,3 +33,14 @@ export const enrichDataBinding = (input, context) => {
}
return mustache.render(input, context)
}
+
+/**
+ * Enriches each prop in a props object
+ */
+export const enrichDataBindings = (props, context) => {
+ let enrichedProps = {}
+ Object.entries(props).forEach(([key, value]) => {
+ enrichedProps[key] = enrichDataBinding(value, context)
+ })
+ return enrichedProps
+}
diff --git a/packages/client/src/utils/index.js b/packages/client/src/utils/index.js
deleted file mode 100644
index 2ccba5dcde..0000000000
--- a/packages/client/src/utils/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-export { getAppId } from "./getAppId"
-export { styleable } from "./styleable"
-export { enrichDataBinding } from "./enrichDataBinding"
diff --git a/packages/client/src/utils/styleable.js b/packages/client/src/utils/styleable.js
index 8f1d7ac3e1..fa81308cb2 100644
--- a/packages/client/src/utils/styleable.js
+++ b/packages/client/src/utils/styleable.js
@@ -1,7 +1,13 @@
+import { getContext } from "svelte"
+import { get } from "svelte/store"
+
+/**
+ * Helper to build a CSS string from a style object
+ */
const buildStyleString = styles => {
let str = ""
Object.entries(styles).forEach(([style, value]) => {
- if (style && value) {
+ if (style && value != null) {
str += `${style}: ${value}; `
}
})
@@ -12,35 +18,52 @@ const buildStyleString = styles => {
* Svelte action to apply correct component styles.
*/
export const styleable = (node, styles = {}) => {
- const normalStyles = styles.normal || {}
- const hoverStyles = {
- ...normalStyles,
- ...styles.hover,
+ let applyNormalStyles
+ let applyHoverStyles
+
+ // Creates event listeners and applies initial styles
+ const setupStyles = newStyles => {
+ const normalStyles = newStyles.normal || {}
+ const hoverStyles = {
+ ...normalStyles,
+ ...newStyles.hover,
+ }
+
+ applyNormalStyles = () => {
+ node.style = buildStyleString(normalStyles)
+ }
+
+ applyHoverStyles = () => {
+ node.style = buildStyleString(hoverStyles)
+ }
+
+ // Add listeners to toggle hover styles
+ node.addEventListener("mouseover", applyHoverStyles)
+ node.addEventListener("mouseout", applyNormalStyles)
+ node.setAttribute("data-bb-id", newStyles.id)
+
+ // Apply initial normal styles
+ applyNormalStyles()
}
- function applyNormalStyles() {
- node.style = buildStyleString(normalStyles)
+ // Removes the current event listeners
+ const removeListeners = () => {
+ node.removeEventListener("mouseover", applyHoverStyles)
+ node.removeEventListener("mouseout", applyNormalStyles)
}
- function applyHoverStyles() {
- node.style = buildStyleString(hoverStyles)
- }
-
- // Add listeners to toggle hover styles
- node.addEventListener("mouseover", applyHoverStyles)
- node.addEventListener("mouseout", applyNormalStyles)
-
- // Apply normal styles initially
- applyNormalStyles()
-
- // Also apply data tags so we know how to reference each component
- node.setAttribute("data-bb-id", styles.id)
+ // Apply initial styles
+ setupStyles(styles)
return {
- // Clean up event listeners when component is destroyed
+ // Clean up old listeners and apply new ones on update
+ update: newStyles => {
+ removeListeners()
+ setupStyles(newStyles)
+ },
+ // Clean up listeners when component is destroyed
destroy: () => {
- node.removeEventListener("mouseover", applyHoverStyles)
- node.removeEventListener("mouseout", applyNormalStyles)
+ removeListeners()
},
}
}
diff --git a/packages/standard-components/src/Button.svelte b/packages/standard-components/src/Button.svelte
index e23adf6c15..e2a98bb74d 100644
--- a/packages/standard-components/src/Button.svelte
+++ b/packages/standard-components/src/Button.svelte
@@ -2,14 +2,19 @@
import { getContext } from "svelte"
const { styleable } = getContext("sdk")
- const styles = getContext("style")
+ const component = getContext("component")
export let className = "default"
export let disabled = false
export let text
+ export let onClick
-