diff --git a/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte b/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte index 2e7d719084..292718c665 100644 --- a/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte +++ b/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte @@ -3,57 +3,17 @@ import { store } from "builderStore" import iframeTemplate from "./iframeTemplate" import { Screen } from "builderStore/store/screenTemplates/utils/Screen" - import { Component } from "builderStore/store/screenTemplates/utils/Component" let iframe - // Styles for screenslot placeholder - const headingStyle = { - width: "500px", - padding: "8px", - } - const textStyle = { - ...headingStyle, - "max-width": "", - "text-align": "left", - } - - const heading = new Component("@budibase/standard-components/heading") - .normalStyle(headingStyle) - .type("h1") - .text("Screen Slot") - .instanceName("Heading") - const textScreenDisplay = new Component("@budibase/standard-components/text") - .normalStyle(textStyle) - .instanceName("Text") - .type("none") - .text( - "The screens that you create will be displayed inside this box. This box is just a placeholder, to show you the position of screens." - ) - const container = new Component("@budibase/standard-components/container") - .normalStyle({ - display: "flex", - "flex-direction": "column", - "align-items": "center", - flex: "1 1 auto", - }) - .type("div") - .instanceName("Container") - .addChild(heading) - .addChild(textScreenDisplay) + // Create screen slot placeholder for use when a page is selected rather + // than a screen const screenPlaceholder = new Screen() .name("Screen Placeholder") .route("*") - .component("@budibase/standard-components/container") - .mainType("div") + .component("@budibase/standard-components/screenslotplaceholder") .instanceName("Content Placeholder") - .normalStyle({ - flex: "1 1 auto", - }) - .addChild(container) .json() - // TODO: this ID is attached to how the screen slot is rendered, confusing, would be better a type etc - screenPlaceholder.props._id = "screenslot-placeholder" // Extract data to pass to the iframe $: page = $store.layouts[$store.currentPageName] @@ -75,7 +35,7 @@ } } - // Refrech the preview when required + // Refresh the preview when required $: refreshContent(previewData) // Initialise the app when mounted diff --git a/packages/builder/src/components/userInterface/AppPreview/iframeTemplate.js b/packages/builder/src/components/userInterface/AppPreview/iframeTemplate.js index 2b12267a85..2d25aea92b 100644 --- a/packages/builder/src/components/userInterface/AppPreview/iframeTemplate.js +++ b/packages/builder/src/components/userInterface/AppPreview/iframeTemplate.js @@ -4,29 +4,13 @@ export default `
{ } else if (type === "view") { rows = await fetchViewData(datasource) } else if (type === "link") { + const row = dataContext[datasource.providerId] rows = await fetchRelationshipData({ - rowId: dataContext?.data?._id, - tableId: dataContext?.data?.tableId, + rowId: row?._id, + tableId: row?.tableId, fieldName, }) } diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index a0be2120b5..d9b5f79538 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -1,12 +1,14 @@ {#if constructor} {#if children && children.length} - {#each children as child} + {#each children as child (child._id)} {/each} {/if} diff --git a/packages/client/src/components/DataProvider.svelte b/packages/client/src/components/DataProvider.svelte index 48465599f2..f33f01d425 100644 --- a/packages/client/src/components/DataProvider.svelte +++ b/packages/client/src/components/DataProvider.svelte @@ -1,25 +1,15 @@ -{#if loaded} - -{/if} + diff --git a/packages/client/src/components/Router.svelte b/packages/client/src/components/Router.svelte index 693ec2dd28..0f89924b66 100644 --- a/packages/client/src/components/Router.svelte +++ b/packages/client/src/components/Router.svelte @@ -1,11 +1,11 @@ {#if routerConfig} -
+
{/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 - diff --git a/packages/standard-components/src/Card.svelte b/packages/standard-components/src/Card.svelte index cce45c0bf0..148c6f0a89 100644 --- a/packages/standard-components/src/Card.svelte +++ b/packages/standard-components/src/Card.svelte @@ -3,7 +3,7 @@ import { cssVars } from "./helpers" const { styleable } = getContext("sdk") - const styles = getContext("style") + const component = getContext("component") export const className = "" export let imageUrl = "" @@ -26,7 +26,10 @@ $: showImage = !!imageUrl -
+
{#if showImage}{/if}

{heading}

diff --git a/packages/standard-components/src/CardHorizontal.svelte b/packages/standard-components/src/CardHorizontal.svelte index e681f08823..df47097180 100644 --- a/packages/standard-components/src/CardHorizontal.svelte +++ b/packages/standard-components/src/CardHorizontal.svelte @@ -3,7 +3,7 @@ import { cssVars } from "./helpers" const { styleable } = getContext("sdk") - const styles = getContext("style") + const component = getContext("component") export const className = "" export let imageUrl = "" @@ -29,7 +29,10 @@ $: showImage = !!imageUrl -
+
{#if showImage}{/if}
diff --git a/packages/standard-components/src/Container.svelte b/packages/standard-components/src/Container.svelte index 2a9f469701..4de4aaae84 100644 --- a/packages/standard-components/src/Container.svelte +++ b/packages/standard-components/src/Container.svelte @@ -2,62 +2,62 @@ import { getContext } from "svelte" const { styleable } = getContext("sdk") - const styles = getContext("style") + const component = getContext("component") export let className = "" export let type = "div" {#if type === 'div'} -
+
{:else if type === 'header'} -
+
{:else if type === 'main'} -
+
{:else if type === 'footer'} -