Allow using JSON field arrays as a data provider source and add data bindings for nested JSON fields

This commit is contained in:
Andrew Kingston 2021-12-06 11:41:17 +00:00
parent b362068d47
commit f898b8c94d
17 changed files with 175 additions and 26 deletions

View File

@ -5,7 +5,7 @@ import {
findComponent, findComponent,
findComponentPath, findComponentPath,
getComponentSettings, getComponentSettings,
} from "./storeUtils" } from "./componentUtils"
import { store } from "builderStore" import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend" import { queries as queriesStores, tables as tablesStore } from "stores/backend"
import { import {
@ -15,6 +15,7 @@ import {
encodeJSBinding, encodeJSBinding,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { convertJSONSchemaToTableSchema } from "./jsonUtils"
// Regex to match all instances of template strings // Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@ -186,6 +187,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
} }
let schema let schema
let table
let readablePrefix let readablePrefix
let runtimeSuffix = context.suffix let runtimeSuffix = context.suffix
@ -209,7 +211,16 @@ const getProviderContextBindings = (asset, dataProviders) => {
} }
const info = getSchemaForDatasource(asset, datasource) const info = getSchemaForDatasource(asset, datasource)
schema = info.schema schema = info.schema
readablePrefix = info.table?.name table = info.table
// For JSON arrays, use the array name as the readable prefix.
// Otherwise use the table name
if (datasource.type === "jsonarray") {
const split = datasource.label.split(".")
readablePrefix = split[split.length - 1]
} else {
readablePrefix = info.table?.name
}
} }
if (!schema) { if (!schema) {
return return
@ -229,7 +240,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
const fieldSchema = schema[key] const fieldSchema = schema[key]
// Make safe runtime binding // Make safe runtime binding
const runtimeBinding = `${safeComponentId}.${makePropSafe(key)}` const safeKey = key.split(".").map(makePropSafe).join(".")
const runtimeBinding = `${safeComponentId}.${safeKey}`
// Optionally use a prefix with readable bindings // Optionally use a prefix with readable bindings
let readableBinding = component._instanceName let readableBinding = component._instanceName
@ -247,6 +259,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// datasource options, based on bindable properties // datasource options, based on bindable properties
fieldSchema, fieldSchema,
providerId, providerId,
// Table ID is used by JSON fields to know what table the field is in
tableId: table?._id,
}) })
}) })
}) })
@ -347,16 +361,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
if (datasource) { if (datasource) {
const { type } = datasource const { type } = datasource
const tables = get(tablesStore).list
// Determine the source table from the datasource type // Determine the entity which backs this datasource.
// "provider" datasources are those targeting another data provider
if (type === "provider") { if (type === "provider") {
const component = findComponent(asset.props, datasource.providerId) const component = findComponent(asset.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component) const source = getDatasourceForProvider(asset, component)
return getSchemaForDatasource(asset, source, isForm) return getSchemaForDatasource(asset, source, isForm)
} else if (type === "query") { }
// "query" datasources are those targeting non-plus datasources or
// custom queries
else if (type === "query") {
const queries = get(queriesStores).list const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource._id) table = queries.find(query => query._id === datasource._id)
} else if (type === "field") { }
// "field" datasources are array-like fields of rows, such as attachments
// or multi-select fields
else if (type === "field") {
table = { name: datasource.fieldName } table = { name: datasource.fieldName }
const { fieldType } = datasource const { fieldType } = datasource
if (fieldType === "attachment") { if (fieldType === "attachment") {
@ -375,12 +399,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
}, },
} }
} }
} else { }
const tables = get(tablesStore).list
// "jsonarray" datasources are arrays inside JSON fields
else if (type === "jsonarray") {
table = tables.find(table => table._id === datasource.tableId)
const keysToSchema = datasource.label.split(".").slice(2)
let jsonSchema = table?.schema
for (let i = 0; i < keysToSchema.length; i++) {
jsonSchema = jsonSchema[keysToSchema[i]].schema
}
schema = convertJSONSchemaToTableSchema(jsonSchema, true)
}
// Otherwise we assume we're targeting an internal table or a plus
// datasource, and we can treat it as a table with a schema
else {
table = tables.find(table => table._id === datasource.tableId) table = tables.find(table => table._id === datasource.tableId)
} }
// Determine the schema from the table if not already determined // Determine the schema from the backing entity if not already determined
if (table && !schema) { if (table && !schema) {
if (type === "view") { if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema) schema = cloneDeep(table.views?.[datasource.name]?.schema)

View File

@ -4,7 +4,7 @@ import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants" import { FrontendTypes, LAYOUT_NAMES } from "../constants"
import { findComponent } from "./storeUtils" import { findComponent } from "./componentUtils"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()

View File

@ -0,0 +1,36 @@
export const convertJSONSchemaToTableSchema = jsonSchema => {
if (!jsonSchema) {
return null
}
if (jsonSchema.schema) {
jsonSchema = jsonSchema.schema
}
const keys = extractJSONSchemaKeys(jsonSchema)
let schema = {}
keys.forEach(({ key, type }) => {
schema[key] = { type, name: key }
})
return schema
}
const extractJSONSchemaKeys = (jsonSchema, squashObjects = false) => {
if (!jsonSchema || !Object.keys(jsonSchema).length) {
return []
}
let keys = []
Object.keys(jsonSchema).forEach(key => {
const type = jsonSchema[key].type
if (type === "json" && squashObjects) {
const childKeys = extractJSONSchemaKeys(jsonSchema[key].schema)
keys = keys.concat(
childKeys.map(childKey => ({
key: `${key}.${childKey.key}`,
type: childKey.type,
}))
)
} else if (type !== "array") {
keys.push({ key, type })
}
})
return keys
}

View File

@ -26,7 +26,7 @@ import {
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
getComponentSettings, getComponentSettings,
} from "../storeUtils" } from "../componentUtils"
import { uuid } from "../uuid" import { uuid } from "../uuid"
import { removeBindings } from "../dataBinding" import { removeBindings } from "../dataBinding"

View File

@ -14,7 +14,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import ErrorSVG from "assets/error.svg?raw" import ErrorSVG from "assets/error.svg?raw"
import { findComponent, findComponentPath } from "builderStore/storeUtils" import { findComponent, findComponentPath } from "builderStore/componentUtils"
let iframe let iframe
let layout let layout

View File

@ -2,7 +2,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { store, currentAsset } from "builderStore" import { store, currentAsset } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { findComponentParent } from "builderStore/storeUtils" import { findComponentParent } from "builderStore/componentUtils"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
export let component export let component

View File

@ -1,6 +1,6 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { store as frontendStore } from "builderStore" import { store as frontendStore } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils" import { findComponentPath } from "builderStore/componentUtils"
export const DropEffect = { export const DropEffect = {
MOVE: "move", MOVE: "move",

View File

@ -16,7 +16,7 @@
import { selectedComponent } from "builderStore" import { selectedComponent } from "builderStore"
import { getComponentForSettingType } from "./componentSettings" import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte" import PropertyControl from "./PropertyControl.svelte"
import { getComponentSettings } from "builderStore/storeUtils" import { getComponentSettings } from "builderStore/componentUtils"
export let conditions = [] export let conditions = []
export let bindings = [] export let bindings = []

View File

@ -2,7 +2,7 @@
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils" import { findComponentPath } from "builderStore/componentUtils"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
export let value export let value

View File

@ -20,7 +20,10 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte" import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import {
makePropSafe,
makePropSafe as safe,
} from "@budibase/string-templates"
export let value = {} export let value = {}
export let otherSources export let otherSources
@ -48,9 +51,7 @@
return [...acc, ...viewsArr] return [...acc, ...viewsArr]
}, []) }, [])
$: queries = $queriesStore.list $: queries = $queriesStore.list
.filter( .filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
query => showAllQueries || query.queryVerb === "read" || query.readable
)
.map(query => ({ .map(query => ({
label: query.name, label: query.name,
name: query.name, name: query.name,
@ -104,13 +105,60 @@
value: `{{ literal ${runtimeBinding} }}`, value: `{{ literal ${runtimeBinding} }}`,
} }
}) })
$: jsonArrays = findJSONArrays(bindings)
function handleSelected(selected) { const findJSONArrays = bindings => {
let arrays = []
const jsonBindings = bindings.filter(x => x.fieldSchema?.type === "json")
jsonBindings.forEach(binding => {
const {
providerId,
readableBinding,
runtimeBinding,
fieldSchema,
tableId,
} = binding
const { name, type } = fieldSchema
const schemaArrays = findArraysInSchema(fieldSchema).map(path => {
const safePath = path.split(".").map(makePropSafe).join(".")
return {
providerId,
label: `${readableBinding}.${path}`,
fieldName: name,
fieldType: type,
tableId,
type: "jsonarray",
value: `{{ literal ${runtimeBinding}.${safePath} }}`,
}
})
arrays = arrays.concat(schemaArrays)
})
return arrays
}
const findArraysInSchema = (schema, path) => {
if (!schema?.schema || !Object.keys(schema.schema).length) {
return []
}
if (schema.type === "array") {
return [path]
}
let arrays = []
Object.keys(schema.schema).forEach(key => {
const newPath = `${path ? `${path}.` : ""}${key}`
const childArrays = findArraysInSchema(schema.schema[key], newPath)
arrays = arrays.concat(childArrays)
})
return arrays
}
const handleSelected = selected => {
dispatch("change", selected) dispatch("change", selected)
dropdownRight.hide() dropdownRight.hide()
} }
function fetchQueryDefinition(query) { const fetchQueryDefinition = query => {
const source = $datasources.list.find( const source = $datasources.list.find(
ds => ds._id === query.datasourceId ds => ds._id === query.datasourceId
).source ).source
@ -227,6 +275,17 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if jsonArrays?.length}
<Divider size="S" />
<div class="title">
<Heading size="XS">Key/Value Arrays</Heading>
</div>
<ul>
{#each jsonArrays as field}
<li on:click={() => handleSelected(field)}>{field.label}</li>
{/each}
</ul>
{/if}
{#if dataProviders?.length} {#if dataProviders?.length}
<Divider size="S" /> <Divider size="S" />
<div class="title"> <div class="title">

View File

@ -5,7 +5,7 @@
getSchemaForDatasource, getSchemaForDatasource,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { currentAsset } from "builderStore" import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils" import { findClosestMatchingComponent } from "builderStore/componentUtils"
export let componentInstance export let componentInstance
export let value export let value

View File

@ -1,7 +1,7 @@
<script> <script>
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils" import { findClosestMatchingComponent } from "builderStore/componentUtils"
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents" import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"

View File

@ -11,7 +11,7 @@
DatePicker, DatePicker,
} from "@budibase/bbui" } from "@budibase/bbui"
import { currentAsset, selectedComponent } from "builderStore" import { currentAsset, selectedComponent } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils" import { findClosestMatchingComponent } from "builderStore/componentUtils"
import { getSchemaForDatasource } from "builderStore/dataBinding" import { getSchemaForDatasource } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid" import { generate } from "shortid"

View File

@ -13,7 +13,7 @@
import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte" import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte"
import { goto, leftover, params } from "@roxi/routify" import { goto, leftover, params } from "@roxi/routify"
import { FrontendTypes } from "constants" import { FrontendTypes } from "constants"
import { findComponent, findComponentPath } from "builderStore/storeUtils" import { findComponent, findComponentPath } from "builderStore/componentUtils"
import { get } from "svelte/store" import { get } from "svelte/store"
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte" import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte" import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"

View File

@ -4,6 +4,7 @@ import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships" import { fetchRelationshipData } from "./relationships"
import { FieldTypes } from "../constants" import { FieldTypes } from "../constants"
import { executeQuery, fetchQueryDefinition } from "./queries" import { executeQuery, fetchQueryDefinition } from "./queries"
import { convertJSONSchemaToTableSchema } from "builder/src/builderStore/jsonUtils"
/** /**
* Fetches all rows for a particular Budibase data source. * Fetches all rows for a particular Budibase data source.
@ -75,6 +76,18 @@ export const fetchDatasourceSchema = async dataSource => {
} }
} }
// JSON arrays need their table definitions fetched.
// We can then extract their schema as a subset of the table schema.
if (type === "jsonarray") {
const table = await fetchTableDefinition(dataSource.tableId)
const keysToSchema = dataSource.label.split(".").slice(2)
let schema = table?.schema
for (let i = 0; i < keysToSchema.length; i++) {
schema = schema[keysToSchema[i]].schema
}
return convertJSONSchemaToTableSchema(schema)
}
// Tables, views and links can be fetched by table ID // Tables, views and links can be fetched by table ID
if ( if (
(type === "table" || type === "view" || type === "link") && (type === "table" || type === "view" || type === "link") &&

View File

@ -203,6 +203,9 @@
} else { } else {
allRows = data allRows = data
} }
} else if (dataSource?.type === "jsonarray") {
// JSON array sources will be available from context
allRows = dataSource?.value || []
} else { } else {
// For other data sources like queries or views, fetch all rows from the // For other data sources like queries or views, fetch all rows from the
// server // server