From 1e857f101a278328d97ba302656263b8165ef3d0 Mon Sep 17 00:00:00 2001
From: Andrew Kingston <aptkingston@ssl-mail.com>
Date: Wed, 25 Nov 2020 09:50:51 +0000
Subject: [PATCH] Add button actions, simplify contexts and tidy up

---
 .../userInterface/TableViewSelect.svelte      | 17 +++--
 packages/client/src/api/api.js                |  2 +-
 packages/client/src/api/datasources.js        |  5 +-
 .../client/src/components/ClientApp.svelte    |  4 +-
 .../client/src/components/Component.svelte    | 66 +++++--------------
 .../client/src/components/DataProvider.svelte |  6 +-
 packages/client/src/sdk.js                    |  3 +-
 packages/client/src/store/auth.js             |  2 +-
 packages/client/src/store/data.js             | 13 ++--
 packages/client/src/store/index.js            |  2 +-
 packages/client/src/store/screens.js          |  2 +-
 packages/client/src/utils/buttonActions.js    | 45 +++++++++++++
 packages/client/src/utils/componentProps.js   | 35 ++++++++++
 .../client/src/utils/enrichDataBinding.js     | 11 ++++
 packages/client/src/utils/index.js            |  3 -
 .../standard-components/src/Button.svelte     |  4 +-
 packages/standard-components/src/Form.svelte  | 16 +++--
 packages/standard-components/src/List.svelte  |  3 +-
 .../src/charts/BarChart.svelte                |  3 +-
 .../src/charts/CandleStickChart.svelte        |  3 +-
 .../src/charts/LineChart.svelte               |  3 +-
 .../src/charts/PieChart.svelte                |  3 +-
 .../src/grid/Component.svelte                 |  2 +-
 23 files changed, 156 insertions(+), 97 deletions(-)
 create mode 100644 packages/client/src/utils/buttonActions.js
 create mode 100644 packages/client/src/utils/componentProps.js
 delete mode 100644 packages/client/src/utils/index.js

diff --git a/packages/builder/src/components/userInterface/TableViewSelect.svelte b/packages/builder/src/components/userInterface/TableViewSelect.svelte
index 4fdb477cbf..0b8e26abe8 100644
--- a/packages/builder/src/components/userInterface/TableViewSelect.svelte
+++ b/packages/builder/src/components/userInterface/TableViewSelect.svelte
@@ -40,13 +40,16 @@
 
   $: links = bindableProperties
     .filter(x => x.fieldSchema?.type === "link")
-    .map(property => ({
-      label: property.readableBinding,
-      fieldName: property.fieldSchema.name,
-      name: `all_${property.fieldSchema.tableId}`,
-      tableId: property.fieldSchema.tableId,
-      type: "link",
-    }))
+    .map(property => {
+      return {
+        providerId: property.instance._id,
+        label: property.readableBinding,
+        fieldName: property.fieldSchema.name,
+        name: `all_${property.fieldSchema.tableId}`,
+        tableId: property.fieldSchema.tableId,
+        type: "link",
+      }
+    })
 </script>
 
 <div
diff --git a/packages/client/src/api/api.js b/packages/client/src/api/api.js
index e338b42b9c..ea88787865 100644
--- a/packages/client/src/api/api.js
+++ b/packages/client/src/api/api.js
@@ -1,4 +1,4 @@
-import { getAppId } from "../utils"
+import { getAppId } from "../utils/getAppId"
 
 /**
  * API cache for cached request responses.
diff --git a/packages/client/src/api/datasources.js b/packages/client/src/api/datasources.js
index e422d34983..a22b52d034 100644
--- a/packages/client/src/api/datasources.js
+++ b/packages/client/src/api/datasources.js
@@ -19,9 +19,10 @@ export const fetchDatasource = async (datasource, dataContext) => {
   } else if (type === "view") {
     rows = await fetchViewData(datasource)
   } else if (type === "link") {
+    const row = dataContext[datasource.providerId]
     rows = await fetchRelationshipData({
-      rowId: dataContext?._id,
-      tableId: dataContext?.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 f4af80ab83..d9b5f79538 100644
--- a/packages/client/src/components/ClientApp.svelte
+++ b/packages/client/src/components/ClientApp.svelte
@@ -1,11 +1,13 @@
 <script>
+  import { writable } from "svelte/store"
   import { setContext, onMount } from "svelte"
   import Component from "./Component.svelte"
   import SDK from "../sdk"
-  import { routeStore, screenStore, createDataStore } from "../store"
+  import { createDataStore, routeStore, screenStore } from "../store"
 
   // Provide contexts
   setContext("sdk", SDK)
+  setContext("component", writable({}))
   setContext("data", createDataStore())
 
   let loaded = false
diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte
index ebda18a2ab..30c7ad75b3 100644
--- a/packages/client/src/components/Component.svelte
+++ b/packages/client/src/components/Component.svelte
@@ -3,69 +3,35 @@
   import { writable } from "svelte/store"
   import * as ComponentLibrary from "@budibase/standard-components"
   import Router from "./Router.svelte"
-  import { enrichDataBinding } from "../utils"
+  import { enrichProps } from "../utils/componentProps"
   import { bindingStore } from "../store"
 
   export let definition = {}
 
-  // Extracts the actual component name from the library name
-  const extractComponentName = name => {
-    const split = name?.split("/")
-    return split?.[split.length - 1]
-  }
+  // Get local data binding context
+  const dataStore = getContext("data")
 
-  // Extracts valid props to pass to the real svelte component
-  const extractValidProps = component => {
-    let props = {}
-    Object.entries(component)
-      .filter(([name]) => !name.startsWith("_"))
-      .forEach(([key, value]) => {
-        props[key] = value
-      })
-    return props
-  }
-
-  // Enriches data bindings to real values based on data context
-  const enrichDataBindings = (dataContexts, dataBindings, props) => {
-    const state = {
-      ...dataContexts,
-      ...dataBindings,
-    }
-    let enrichedProps = {}
-    Object.entries(props).forEach(([key, value]) => {
-      enrichedProps[key] = enrichDataBinding(value, state)
-    })
-    return enrichedProps
-  }
-
-  // Gets the component constructor for the specified component
-  const getComponentConstructor = name => {
-    return name === "screenslot" ? Router : ComponentLibrary[componentName]
-  }
+  // Create component context
+  const componentStore = writable({})
+  setContext("component", componentStore)
 
   // Extract component definition info
-  $: componentName = extractComponentName(definition._component)
-  $: constructor = getComponentConstructor(componentName)
-  $: componentProps = extractValidProps(definition)
+  $: constructor = getComponentConstructor(definition._component)
   $: children = definition._children
   $: id = definition._id
-  $: dataContext = getContext("data")
-  $: enrichedProps = enrichDataBindings(
-    $dataContext,
-    $bindingStore,
-    componentProps
-  )
+  $: enrichedProps = enrichProps(definition, $dataStore, $bindingStore)
 
   // Update component context
   // ID is duplicated inside style so that the "styleable" helper can set
   // an ID data tag for unique reference to components
-  const componentStore = writable({})
-  setContext("component", componentStore)
-  $: componentStore.set({
-    id,
-    styles: { ...definition._styles, id },
-    dataContext: $dataContext.data,
-  })
+  $: componentStore.set({ id, styles: { ...definition._styles, id } })
+
+  // Gets the component constructor for the specified component
+  const getComponentConstructor = component => {
+    const split = component?.split("/")
+    const name = split?.[split.length - 1]
+    return name === "screenslot" ? Router : ComponentLibrary[name]
+  }
 </script>
 
 {#if constructor}
diff --git a/packages/client/src/components/DataProvider.svelte b/packages/client/src/components/DataProvider.svelte
index 4ee04d722c..f33f01d425 100644
--- a/packages/client/src/components/DataProvider.svelte
+++ b/packages/client/src/components/DataProvider.svelte
@@ -4,15 +4,11 @@
 
   export let row
 
-  // Get current contexts
+  // Clone and create new data context for this component tree
   const data = getContext("data")
   const component = getContext("component")
-
-  // Clone current context to this context
   const newData = createDataStore($data)
   setContext("data", newData)
-
-  // Add additional layer to context
   $: newData.actions.addContext(row, $component.id)
 </script>
 
diff --git a/packages/client/src/sdk.js b/packages/client/src/sdk.js
index 3a5a3f80ac..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, bindingStore } from "./store"
-import { styleable, getAppId } from "./utils"
+import { styleable } from "./utils/styleable"
+import { getAppId } from "./utils/getAppId"
 import { link as linkable } from "svelte-spa-router"
 import DataProvider from "./components/DataProvider.svelte"
 
diff --git a/packages/client/src/store/auth.js b/packages/client/src/store/auth.js
index ac616e9240..a7c91a0972 100644
--- a/packages/client/src/store/auth.js
+++ b/packages/client/src/store/auth.js
@@ -1,5 +1,5 @@
 import * as API from "../api"
-import { getAppId } from "../utils"
+import { getAppId } from "../utils/getAppId"
 import { writable } from "svelte/store"
 
 const createAuthStore = () => {
diff --git a/packages/client/src/store/data.js b/packages/client/src/store/data.js
index 9cd44eebb4..5ff2b9b631 100644
--- a/packages/client/src/store/data.js
+++ b/packages/client/src/store/data.js
@@ -1,20 +1,16 @@
 import { writable } from "svelte/store"
 import { cloneDeep } from "lodash/fp"
 
-const initialValue = {
-  data: null,
-}
-
 export const createDataStore = existingContext => {
-  const initial = existingContext ? cloneDeep(existingContext) : initialValue
-  const store = writable(initial)
+  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.data = row
+        state[`${componentId}_draft`] = cloneDeep(row)
+        state.closestComponentId = componentId
       }
       return state
     })
@@ -22,6 +18,9 @@ export const createDataStore = existingContext => {
 
   return {
     subscribe: store.subscribe,
+    update: store.update,
     actions: { addContext },
   }
 }
+
+export const dataStore = createDataStore()
diff --git a/packages/client/src/store/index.js b/packages/client/src/store/index.js
index 3730f39ee0..58e51b6fc1 100644
--- a/packages/client/src/store/index.js
+++ b/packages/client/src/store/index.js
@@ -5,4 +5,4 @@ export { builderStore } from "./builder"
 export { bindingStore } from "./binding"
 
 // Data stores are layered and duplicated, so it is not a singleton
-export { createDataStore } from "./data"
+export { createDataStore, dataStore } from "./data"
diff --git a/packages/client/src/store/screens.js b/packages/client/src/store/screens.js
index f19736756a..2d977ec025 100644
--- a/packages/client/src/store/screens.js
+++ b/packages/client/src/store/screens.js
@@ -2,7 +2,7 @@ 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({
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 93d51daf10..5de6b31a89 100644
--- a/packages/client/src/utils/enrichDataBinding.js
+++ b/packages/client/src/utils/enrichDataBinding.js
@@ -33,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/standard-components/src/Button.svelte b/packages/standard-components/src/Button.svelte
index 1d64c5e8bd..e2a98bb74d 100644
--- a/packages/standard-components/src/Button.svelte
+++ b/packages/standard-components/src/Button.svelte
@@ -7,12 +7,14 @@
   export let className = "default"
   export let disabled = false
   export let text
+  export let onClick
 </script>
 
 <button
   class="default"
   disabled={disabled || false}
-  use:styleable={$component.styles}>
+  use:styleable={$component.styles}
+  on:click={onClick}>
   {text}
 </button>
 
diff --git a/packages/standard-components/src/Form.svelte b/packages/standard-components/src/Form.svelte
index 8639fbde1a..0a64eeb490 100644
--- a/packages/standard-components/src/Form.svelte
+++ b/packages/standard-components/src/Form.svelte
@@ -5,8 +5,9 @@
   import LinkedRowSelector from "./LinkedRowSelector.svelte"
   import { capitalise } from "./helpers"
 
-  const { styleable, screenStore, API } = getContext("sdk")
+  const { styleable, API } = getContext("sdk")
   const component = getContext("component")
+  const data = getContext("data")
 
   export let wide = false
 
@@ -14,14 +15,17 @@
   let schema
   let fields = []
 
-  $: getContextDetails($component.dataContext)
+  // Fetch info about the closest data context
+  $: getFormData($data[$data.closestComponentId])
 
-  const getContextDetails = async dataContext => {
-    if (dataContext) {
-      row = dataContext
-      const tableDefinition = await API.fetchTableDefinition(row.tableId)
+  const getFormData = async context => {
+    if (context) {
+      const tableDefinition = await API.fetchTableDefinition(context.tableId)
       schema = tableDefinition.schema
       fields = Object.keys(schema)
+
+      // Use the draft version for editing
+      row = $data[`${$data.closestComponentId}_draft`]
     }
   }
 </script>
diff --git a/packages/standard-components/src/List.svelte b/packages/standard-components/src/List.svelte
index a305bff04d..226287490f 100644
--- a/packages/standard-components/src/List.svelte
+++ b/packages/standard-components/src/List.svelte
@@ -4,6 +4,7 @@
 
   const { API, styleable, DataProvider } = getContext("sdk")
   const component = getContext("component")
+  const data = getContext("data")
 
   export let datasource = []
 
@@ -11,7 +12,7 @@
 
   onMount(async () => {
     if (!isEmpty(datasource)) {
-      rows = await API.fetchDatasource(datasource, $component.dataContext)
+      rows = await API.fetchDatasource(datasource, $data)
     }
   })
 </script>
diff --git a/packages/standard-components/src/charts/BarChart.svelte b/packages/standard-components/src/charts/BarChart.svelte
index a4937d70df..872f7e624d 100644
--- a/packages/standard-components/src/charts/BarChart.svelte
+++ b/packages/standard-components/src/charts/BarChart.svelte
@@ -5,7 +5,6 @@
   import { isEmpty } from "lodash/fp"
 
   const { API } = getContext("sdk")
-  const component = getContext("component")
 
   export let title
   export let datasource
@@ -35,7 +34,7 @@
 
     // Fetch, filter and sort data
     const schema = (await API.fetchTableDefinition(datasource.tableId)).schema
-    const result = await API.fetchDatasource(datasource, $component.dataContext)
+    const result = await API.fetchDatasource(datasource)
     const reducer = row => (valid, column) => valid && row[column] != null
     const hasAllColumns = row => allCols.reduce(reducer(row), true)
     const data = result
diff --git a/packages/standard-components/src/charts/CandleStickChart.svelte b/packages/standard-components/src/charts/CandleStickChart.svelte
index cb5b6d2607..c5ca0ebd49 100644
--- a/packages/standard-components/src/charts/CandleStickChart.svelte
+++ b/packages/standard-components/src/charts/CandleStickChart.svelte
@@ -5,7 +5,6 @@
   import { isEmpty } from "lodash/fp"
 
   const { API } = getContext("sdk")
-  const component = getContext("component")
 
   export let title
   export let datasource
@@ -33,7 +32,7 @@
 
     // Fetch, filter and sort data
     const schema = (await API.fetchTableDefinition(datasource.tableId)).schema
-    const result = await API.fetchDatasource(datasource, $component.dataContext)
+    const result = await API.fetchDatasource(datasource)
     const reducer = row => (valid, column) => valid && row[column] != null
     const hasAllColumns = row => allCols.reduce(reducer(row), true)
     const data = result
diff --git a/packages/standard-components/src/charts/LineChart.svelte b/packages/standard-components/src/charts/LineChart.svelte
index 5eff91a324..cdfb4f5fbf 100644
--- a/packages/standard-components/src/charts/LineChart.svelte
+++ b/packages/standard-components/src/charts/LineChart.svelte
@@ -5,7 +5,6 @@
   import { isEmpty } from "lodash/fp"
 
   const { API } = getContext("sdk")
-  const component = getContext("component")
 
   // Common props
   export let title
@@ -41,7 +40,7 @@
 
     // Fetch, filter and sort data
     const schema = (await API.fetchTableDefinition(datasource.tableId)).schema
-    const result = await API.fetchDatasource(datasource, $component.dataContext)
+    const result = await API.fetchDatasource(datasource)
     const reducer = row => (valid, column) => valid && row[column] != null
     const hasAllColumns = row => allCols.reduce(reducer(row), true)
     const data = result
diff --git a/packages/standard-components/src/charts/PieChart.svelte b/packages/standard-components/src/charts/PieChart.svelte
index 584b88bbec..7b8df25b01 100644
--- a/packages/standard-components/src/charts/PieChart.svelte
+++ b/packages/standard-components/src/charts/PieChart.svelte
@@ -5,7 +5,6 @@
   import { isEmpty } from "lodash/fp"
 
   const { API } = getContext("sdk")
-  const component = getContext("component")
 
   export let title
   export let datasource
@@ -31,7 +30,7 @@
 
     // Fetch, filter and sort data
     const schema = (await API.fetchTableDefinition(datasource.tableId)).schema
-    const result = await API.fetchDatasource(datasource, $component.dataContext)
+    const result = await API.fetchDatasource(datasource)
     const data = result
       .filter(row => row[labelColumn] != null && row[valueColumn] != null)
       .slice(0, 20)
diff --git a/packages/standard-components/src/grid/Component.svelte b/packages/standard-components/src/grid/Component.svelte
index 3911501735..69f7954898 100644
--- a/packages/standard-components/src/grid/Component.svelte
+++ b/packages/standard-components/src/grid/Component.svelte
@@ -58,7 +58,7 @@
 
   onMount(async () => {
     if (!isEmpty(datasource)) {
-      data = await API.fetchDatasource(datasource, $component.dataContext)
+      data = await API.fetchDatasource(datasource)
       let schema
 
       // Get schema for datasource