Merge pull request #3355 from Budibase/repeater-array

Data block + array fields as data sources
This commit is contained in:
Andrew Kingston 2021-11-16 11:03:30 +00:00 committed by GitHub
commit 3db13562d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 459 additions and 96 deletions

View File

@ -207,11 +207,11 @@ const getProviderContextBindings = (asset, dataProviders) => {
const keys = Object.keys(schema).sort() const keys = Object.keys(schema).sort()
// Generate safe unique runtime prefix // Generate safe unique runtime prefix
let runtimeId = component._id let providerId = component._id
if (runtimeSuffix) { if (runtimeSuffix) {
runtimeId += `-${runtimeSuffix}` providerId += `-${runtimeSuffix}`
} }
const safeComponentId = makePropSafe(runtimeId) const safeComponentId = makePropSafe(providerId)
// Create bindable properties for each schema field // Create bindable properties for each schema field
keys.forEach(key => { keys.forEach(key => {
@ -235,7 +235,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
// Field schema and provider are required to construct relationship // Field schema and provider are required to construct relationship
// datasource options, based on bindable properties // datasource options, based on bindable properties
fieldSchema, fieldSchema,
providerId: component._id, providerId,
}) })
}) })
}) })
@ -333,8 +333,11 @@ const getUrlBindings = asset => {
*/ */
export const getSchemaForDatasource = (asset, datasource, isForm = false) => { export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
let schema, table let schema, table
if (datasource) { if (datasource) {
const { type } = datasource const { type } = datasource
// Determine the source table from the datasource type
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)
@ -342,11 +345,32 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
} else if (type === "query") { } 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") {
table = { name: datasource.fieldName }
const { fieldType } = datasource
if (fieldType === "attachment") {
schema = {
url: {
type: "string",
},
name: {
type: "string",
},
}
} else if (fieldType === "array") {
schema = {
value: {
type: "string",
},
}
}
} else { } else {
const tables = get(tablesStore).list const tables = get(tablesStore).list
table = tables.find(table => table._id === datasource.tableId) table = tables.find(table => table._id === datasource.tableId)
} }
if (table) {
// Determine the schema from the table if not already determined
if (table && !schema) {
if (type === "view") { if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema) schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "query" && isForm) { } else if (type === "query" && isForm) {
@ -525,7 +549,7 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
* {{ literal [componentId] }} * {{ literal [componentId] }}
*/ */
function extractLiteralHandlebarsID(value) { function extractLiteralHandlebarsID(value) {
return value?.match(/{{\s*literal[\s[]+([a-fA-F0-9]+)[\s\]]*}}/)?.[1] return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
} }
/** /**

View File

@ -4,7 +4,8 @@
"icon": "Article", "icon": "Article",
"children": [ "children": [
"tableblock", "tableblock",
"cardsblock" "cardsblock",
"repeaterblock"
] ]
}, },
"section", "section",

View File

@ -11,10 +11,7 @@
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}` const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset.props, $store.selectedComponentId) $: path = findComponentPath($currentAsset.props, $store.selectedComponentId)
$: providers = path.filter( $: providers = path.filter(c => c._component?.endsWith("/dataprovider"))
component =>
component._component === "@budibase/standard-components/dataprovider"
)
// Set initial value to closest data provider // Set initial value to closest data provider
onMount(() => { onMount(() => {

View File

@ -20,16 +20,18 @@
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"
const dispatch = createEventDispatcher()
let anchorRight, dropdownRight
let drawer
export let value = {} export let value = {}
export let otherSources export let otherSources
export let showAllQueries export let showAllQueries
export let bindings = [] export let bindings = []
const dispatch = createEventDispatcher()
const arrayTypes = ["attachment", "array"]
let anchorRight, dropdownRight
let drawer
$: text = value?.label ?? "Choose an option" $: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list.map(m => ({ $: tables = $tablesStore.list.map(m => ({
label: m.name, label: m.name,
@ -54,8 +56,6 @@
name: query.name, name: query.name,
tableId: query._id, tableId: query._id,
...query, ...query,
schema: query.schema,
parameters: query.parameters,
type: "query", type: "query",
})) }))
$: dataProviders = getDataProviderComponents( $: dataProviders = getDataProviderComponents(
@ -65,29 +65,40 @@
label: provider._instanceName, label: provider._instanceName,
name: provider._instanceName, name: provider._instanceName,
providerId: provider._id, providerId: provider._id,
value: `{{ literal [${provider._id}] }}`, value: `{{ literal ${safe(provider._id)} }}`,
type: "provider", type: "provider",
schema: provider.schema,
}))
$: queryBindableProperties = bindings.map(property => ({
...property,
category: property.type === "instance" ? "Component" : "Table",
label: property.readableBinding,
path: property.readableBinding,
})) }))
$: links = bindings $: links = bindings
.filter(x => x.fieldSchema?.type === "link") .filter(x => x.fieldSchema?.type === "link")
.map(property => { .map(binding => {
const { providerId, readableBinding, fieldSchema } = binding || {}
const { name, tableId } = fieldSchema || {}
const safeProviderId = safe(providerId)
return { return {
providerId: property.providerId, providerId,
label: property.readableBinding, label: readableBinding,
fieldName: property.fieldSchema.name, fieldName: name,
tableId: property.fieldSchema.tableId, tableId,
type: "link", type: "link",
// These properties will be enriched by the client library and provide // These properties will be enriched by the client library and provide
// details of the parent row of the relationship field, from context // details of the parent row of the relationship field, from context
rowId: `{{ ${property.providerId}._id }}`, rowId: `{{ ${safeProviderId}.${safe("_id")} }}`,
rowTableId: `{{ ${property.providerId}.tableId }}`, rowTableId: `{{ ${safeProviderId}.${safe("tableId")} }}`,
}
})
$: fields = bindings
.filter(x => arrayTypes.includes(x.fieldSchema?.type))
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
type: "field",
value: `{{ literal ${runtimeBinding} }}`,
} }
}) })
@ -102,6 +113,14 @@
).source ).source
return $integrations[source].query[query.queryVerb] return $integrations[source].query[query.queryVerb]
} }
const getQueryParams = query => {
return $queriesStore.list.find(q => q._id === query?._id)?.parameters || []
}
const getQueryDatasource = query => {
return $datasources.list.find(ds => ds._id === query?.datasourceId)
}
</script> </script>
<div class="container" bind:this={anchorRight}> <div class="container" bind:this={anchorRight}>
@ -127,11 +146,10 @@
</Button> </Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<Layout noPadding> <Layout noPadding>
{#if value.parameters.length > 0} {#if getQueryParams(value._id).length > 0}
<ParameterBuilder <ParameterBuilder
bind:customParams={value.queryParams} bind:customParams={value.queryParams}
parameters={queries.find(query => query._id === value._id) parameters={getQueryParams(value)}
.parameters}
{bindings} {bindings}
/> />
{/if} {/if}
@ -139,9 +157,7 @@
height={200} height={200}
query={value} query={value}
schema={fetchQueryDefinition(value)} schema={fetchQueryDefinition(value)}
datasource={$datasources.list.find( datasource={getQueryDatasource(value)}
ds => ds._id === value.datasourceId
)}
editable={false} editable={false}
/> />
</Layout> </Layout>
@ -159,52 +175,71 @@
<li on:click={() => handleSelected(table)}>{table.label}</li> <li on:click={() => handleSelected(table)}>{table.label}</li>
{/each} {/each}
</ul> </ul>
<Divider size="S" /> {#if views?.length}
<div class="title"> <Divider size="S" />
<Heading size="XS">Views</Heading> <div class="title">
</div> <Heading size="XS">Views</Heading>
<ul> </div>
{#each views as view} <ul>
<li on:click={() => handleSelected(view)}>{view.label}</li> {#each views as view}
{/each} <li on:click={() => handleSelected(view)}>{view.label}</li>
</ul> {/each}
<Divider size="S" /> </ul>
<div class="title"> {/if}
<Heading size="XS">Relationships</Heading> {#if queries?.length}
</div> <Divider size="S" />
<ul> <div class="title">
{#each links as link} <Heading size="XS">Queries</Heading>
<li on:click={() => handleSelected(link)}>{link.label}</li> </div>
{/each} <ul>
</ul> {#each queries as query}
<Divider size="S" /> <li
<div class="title"> class:selected={value === query}
<Heading size="XS">Queries</Heading> on:click={() => handleSelected(query)}
</div> >
<ul> {query.label}
{#each queries as query} </li>
<li {/each}
class:selected={value === query} </ul>
on:click={() => handleSelected(query)} {/if}
> {#if links?.length}
{query.label} <Divider size="S" />
</li> <div class="title">
{/each} <Heading size="XS">Relationships</Heading>
</ul> </div>
<Divider size="S" /> <ul>
<div class="title"> {#each links as link}
<Heading size="XS">Data Providers</Heading> <li on:click={() => handleSelected(link)}>{link.label}</li>
</div> {/each}
<ul> </ul>
{#each dataProviders as provider} {/if}
<li {#if fields?.length}
class:selected={value === provider} <Divider size="S" />
on:click={() => handleSelected(provider)} <div class="title">
> <Heading size="XS">Fields</Heading>
{provider.label} </div>
</li> <ul>
{/each} {#each fields as field}
</ul> <li on:click={() => handleSelected(field)}>{field.label}</li>
{/each}
</ul>
{/if}
{#if dataProviders?.length}
<Divider size="S" />
<div class="title">
<Heading size="XS">Data Providers</Heading>
</div>
<ul>
{#each dataProviders as provider}
<li
class:selected={value === provider}
on:click={() => handleSelected(provider)}
>
{provider.label}
</li>
{/each}
</ul>
{/if}
{#if otherSources?.length} {#if otherSources?.length}
<Divider size="S" /> <Divider size="S" />
<div class="title"> <div class="title">

View File

@ -6,7 +6,6 @@
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
export let label = "" export let label = ""
export let bindable = true
export let componentInstance = {} export let componentInstance = {}
export let control = null export let control = null
export let key = "" export let key = ""

View File

@ -2933,5 +2933,203 @@
"type": "schema", "type": "schema",
"suffix": "repeater" "suffix": "repeater"
} }
},
"repeaterblock": {
"name": "Repeater block",
"icon": "ViewList",
"illegalChildren": ["section"],
"hasChildren": true,
"showSettingsBar": true,
"settings": [
{
"type": "dataSource",
"label": "Data",
"key": "dataSource"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": ["Ascending", "Descending"],
"defaultValue": "Descending"
},
{
"type": "number",
"label": "Limit",
"key": "limit",
"defaultValue": 10
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate"
},
{
"section": true,
"name": "Layout settings",
"settings": [
{
"type": "text",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found"
},
{
"type": "select",
"label": "Direction",
"key": "direction",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Column",
"value": "column",
"barIcon": "ViewRow",
"barTitle": "Column layout"
},
{
"label": "Row",
"value": "row",
"barIcon": "ViewColumn",
"barTitle": "Row layout"
}
],
"defaultValue": "column"
},
{
"type": "select",
"label": "Horiz. Align",
"key": "hAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Left",
"value": "left",
"barIcon": "AlignLeft",
"barTitle": "Align left"
},
{
"label": "Center",
"value": "center",
"barIcon": "AlignCenter",
"barTitle": "Align center"
},
{
"label": "Right",
"value": "right",
"barIcon": "AlignRight",
"barTitle": "Align right"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveLeftRight",
"barTitle": "Align stretched horizontally"
}
],
"defaultValue": "stretch"
},
{
"type": "select",
"label": "Vert. Align",
"key": "vAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Top",
"value": "top",
"barIcon": "AlignTop",
"barTitle": "Align top"
},
{
"label": "Middle",
"value": "middle",
"barIcon": "AlignMiddle",
"barTitle": "Align middle"
},
{
"label": "Bottom",
"value": "bottom",
"barIcon": "AlignBottom",
"barTitle": "Align bottom"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveUpDown",
"barTitle": "Align stretched vertically"
}
],
"defaultValue": "top"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
}
]
}
],
"context": [
{
"type": "static",
"suffix": "provider",
"values": [
{
"label": "Rows",
"key": "rows"
},
{
"label": "Rows Length",
"key": "rowsLength"
},
{
"label": "Schema",
"key": "schema"
},
{
"label": "Page Number",
"key": "pageNumber"
}
]
},
{
"type": "schema",
"suffix": "repeater"
}
]
} }
} }

View File

@ -55,6 +55,26 @@ export const fetchDatasourceSchema = async dataSource => {
return dataSource.value?.schema return dataSource.value?.schema
} }
// Field sources have their schema statically defined
if (type === "field") {
if (dataSource.fieldType === "attachment") {
return {
url: {
type: "string",
},
name: {
type: "string",
},
}
} else if (dataSource.fieldType === "array") {
return {
value: {
type: "string",
},
}
}
}
// 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

@ -30,6 +30,6 @@
} }
</script> </script>
<Component {instance}> <Component {instance} isBlock>
<slot /> <slot />
</Component> </Component>

View File

@ -17,6 +17,7 @@
export let instance = {} export let instance = {}
export let isLayout = false export let isLayout = false
export let isScreen = false export let isScreen = false
export let isBlock = false
// The enriched component settings // The enriched component settings
let enrichedSettings let enrichedSettings
@ -44,7 +45,6 @@
// Get contexts // Get contexts
const context = getContext("context") const context = getContext("context")
const insideScreenslot = !!getContext("screenslot") const insideScreenslot = !!getContext("screenslot")
const insideBlock = !!getContext("block")
// Create component context // Create component context
const componentStore = writable({}) const componentStore = writable({})
@ -69,7 +69,7 @@
$: interactive = $: interactive =
$builderStore.inBuilder && $builderStore.inBuilder &&
($builderStore.previewType === "layout" || insideScreenslot) && ($builderStore.previewType === "layout" || insideScreenslot) &&
!insideBlock !isBlock
$: draggable = interactive && !isLayout && !isScreen $: draggable = interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen $: droppable = interactive && !isLayout && !isScreen
@ -262,6 +262,7 @@
class:droppable class:droppable
class:empty class:empty
class:interactive class:interactive
class:block={isBlock}
data-id={id} data-id={id}
data-name={name} data-name={name}
> >
@ -272,7 +273,7 @@
{/each} {/each}
{:else if emptyState} {:else if emptyState}
<Placeholder /> <Placeholder />
{:else if insideBlock} {:else if isBlock}
<slot /> <slot />
{/if} {/if}
</svelte:component> </svelte:component>

View File

@ -183,7 +183,16 @@
} else if (dataSource?.type === "provider") { } else if (dataSource?.type === "provider") {
// For providers referencing another provider, just use the rows it // For providers referencing another provider, just use the rows it
// provides // provides
allRows = dataSource?.value?.rows ?? [] allRows = dataSource?.value?.rows || []
} else if (dataSource?.type === "field") {
// Field sources will be available from context.
// Enrich non object elements into object to ensure a valid schema.
const data = dataSource?.value || []
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
allRows = data.map(value => ({ value }))
} else {
allRows = data
}
} 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

View File

@ -3,6 +3,7 @@
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"
import { makePropSafe as safe } from "@budibase/string-templates"
export let title export let title
export let dataSource export let dataSource
@ -103,7 +104,7 @@
} }
const col = linkColumn || "_id" const col = linkColumn || "_id"
const split = url.split("/:") const split = url.split("/:")
return `${split[0]}/{{ [${repeaterId}].[${col}] }}` return `${split[0]}/{{ ${safe(repeaterId)}.${safe(col)} }}`
} }
// Load the datasource schema on mount so we can determine column types // Load the datasource schema on mount so we can determine column types
@ -171,7 +172,7 @@
bind:id={repeaterId} bind:id={repeaterId}
context="repeater" context="repeater"
props={{ props={{
dataProvider: `{{ literal [${dataProviderId}] }}`, dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
direction: "row", direction: "row",
hAlign: "stretch", hAlign: "stretch",
vAlign: "top", vAlign: "top",

View File

@ -0,0 +1,61 @@
<script>
import BlockComponent from "components/BlockComponent.svelte"
import Block from "components/Block.svelte"
import Placeholder from "components/app/Placeholder.svelte"
import { getContext } from "svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
export let dataSource
export let filter
export let sortColumn
export let sortOrder
export let limit
export let paginate
export let noRowsMessage
export let direction
export let hAlign
export let vAlign
export let gap
let providerId
const component = getContext("component")
const { styleable } = getContext("sdk")
</script>
<Block>
<div use:styleable={$component.styles}>
<BlockComponent
type="dataprovider"
context="provider"
bind:id={providerId}
props={{
dataSource,
filter,
sortColumn,
sortOrder,
limit,
paginate,
}}
>
{#if $component.empty}
<Placeholder text={$component.name} />
{:else}
<BlockComponent
type="repeater"
context="repeater"
props={{
dataProvider: `{{ literal ${safe(providerId)} }}`,
noRowsMessage,
direction,
hAlign,
vAlign,
gap,
}}
>
<slot />
</BlockComponent>
{/if}
</BlockComponent>
</div>
</Block>

View File

@ -3,6 +3,7 @@
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"
import { makePropSafe as safe } from "@budibase/string-templates"
export let title export let title
export let dataSource export let dataSource
@ -61,7 +62,7 @@
operator: column.type === "string" ? "string" : "equal", operator: column.type === "string" ? "string" : "equal",
type: "string", type: "string",
valueType: "Binding", valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`, value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
}) })
}) })
return enrichedFilter return enrichedFilter
@ -147,7 +148,7 @@
<BlockComponent <BlockComponent
type="table" type="table"
props={{ props={{
dataProvider: `{{ literal [${dataProviderId}] }}`, dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
columns: tableColumns, columns: tableColumns,
showAutoColumns, showAutoColumns,
rowCount, rowCount,

View File

@ -1,2 +1,3 @@
export { default as tableblock } from "./TableBlock.svelte" export { default as tableblock } from "./TableBlock.svelte"
export { default as cardsblock } from "./CardsBlock.svelte" export { default as cardsblock } from "./CardsBlock.svelte"
export { default as repeaterblock } from "./RepeaterBlock.svelte"

View File

@ -147,7 +147,7 @@
return return
} }
const element = e.target.closest(".component") const element = e.target.closest(".component:not(.block)")
if ( if (
element && element &&
element.classList.contains("droppable") && element.classList.contains("droppable") &&

View File

@ -17,7 +17,19 @@
$: definition = $builderStore.selectedComponentDefinition $: definition = $builderStore.selectedComponentDefinition
$: showBar = definition?.showSettingsBar && !$builderStore.isDragging $: showBar = definition?.showSettingsBar && !$builderStore.isDragging
$: settings = definition?.settings?.filter(setting => setting.showInBar) ?? [] $: settings = getBarSettings(definition)
const getBarSettings = definition => {
let allSettings = []
definition?.settings?.forEach(setting => {
if (setting.section) {
allSettings = allSettings.concat(setting.settings || [])
} else {
allSettings.push(setting)
}
})
return allSettings.filter(setting => setting.showInBar)
}
const updatePosition = () => { const updatePosition = () => {
if (!showBar) { if (!showBar) {

View File

@ -46,6 +46,9 @@ const HELPERS = [
}), }),
// adds a note for post-processor // adds a note for post-processor
new Helper(HelperFunctionNames.LITERAL, value => { new Helper(HelperFunctionNames.LITERAL, value => {
if (value === undefined) {
return ""
}
const type = typeof value const type = typeof value
const outputVal = type === "object" ? JSON.stringify(value) : value const outputVal = type === "object" ? JSON.stringify(value) : value
return `{{${LITERAL_MARKER} ${type}-${outputVal}}}` return `{{${LITERAL_MARKER} ${type}-${outputVal}}}`