Merge branch 'develop' of github.com:Budibase/budibase into feature/mssql-plus

This commit is contained in:
mike12345567 2021-11-09 11:20:17 +00:00
commit 2155e5f5eb
40 changed files with 1354 additions and 190 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/auth", "name": "@budibase/auth",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"description": "Authentication middlewares for budibase builder and apps", "description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -21,6 +21,7 @@
<label <label
class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}" class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}"
class:is-invalid={!!error} class:is-invalid={!!error}
class:checked={value}
> >
<input <input
checked={value} checked={value}
@ -50,6 +51,16 @@
</label> </label>
<style> <style>
.spectrum-Checkbox--sizeL .spectrum-Checkbox-checkmark {
transform: scale(1.1);
left: 55%;
top: 55%;
}
.spectrum-Checkbox--sizeXL .spectrum-Checkbox-checkmark {
transform: scale(1.2);
left: 60%;
top: 60%;
}
.spectrum-Checkbox-input { .spectrum-Checkbox-input {
opacity: 0; opacity: 0;
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.180-alpha.0", "@budibase/bbui": "^0.9.180-alpha.1",
"@budibase/client": "^0.9.180-alpha.0", "@budibase/client": "^0.9.180-alpha.1",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^0.9.180-alpha.0", "@budibase/string-templates": "^0.9.180-alpha.1",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -4,6 +4,7 @@ import {
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
findComponentPath, findComponentPath,
getComponentSettings,
} from "./storeUtils" } from "./storeUtils"
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"
@ -38,6 +39,25 @@ export const getBindableProperties = (asset, componentId) => {
] ]
} }
/**
* Gets the bindable properties exposed by a certain component.
*/
export const getComponentBindableProperties = (asset, componentId) => {
if (!asset || !componentId) {
return []
}
// Ensure that the component exists and exposes context
const component = findComponent(asset.props, componentId)
const def = store.actions.components.getDefinition(component?._component)
if (!def?.context) {
return []
}
// Get the bindings for the component
return getProviderContextBindings(asset, component)
}
/** /**
* Gets all data provider components above a component. * Gets all data provider components above a component.
*/ */
@ -82,13 +102,10 @@ export const getActionProviderComponents = (asset, componentId, actionType) => {
* Gets a datasource object for a certain data provider component * Gets a datasource object for a certain data provider component
*/ */
export const getDatasourceForProvider = (asset, component) => { export const getDatasourceForProvider = (asset, component) => {
const def = store.actions.components.getDefinition(component?._component) const settings = getComponentSettings(component?._component)
if (!def) {
return null
}
// If this component has a dataProvider setting, go up the stack and use it // If this component has a dataProvider setting, go up the stack and use it
const dataProviderSetting = def.settings.find(setting => { const dataProviderSetting = settings.find(setting => {
return setting.type === "dataProvider" return setting.type === "dataProvider"
}) })
if (dataProviderSetting) { if (dataProviderSetting) {
@ -100,7 +117,7 @@ export const getDatasourceForProvider = (asset, component) => {
// Extract datasource from component instance // Extract datasource from component instance
const validSettingTypes = ["dataSource", "table", "schema"] const validSettingTypes = ["dataSource", "table", "schema"]
const datasourceSetting = def.settings.find(setting => { const datasourceSetting = settings.find(setting => {
return validSettingTypes.includes(setting.type) return validSettingTypes.includes(setting.type)
}) })
if (!datasourceSetting) { if (!datasourceSetting) {
@ -127,9 +144,26 @@ export const getDatasourceForProvider = (asset, component) => {
const getContextBindings = (asset, componentId) => { const getContextBindings = (asset, componentId) => {
// Extract any components which provide data contexts // Extract any components which provide data contexts
const dataProviders = getDataProviderComponents(asset, componentId) const dataProviders = getDataProviderComponents(asset, componentId)
let bindings = []
// Generate bindings for all matching components
return getProviderContextBindings(asset, dataProviders)
}
/**
* Gets the context bindings exposed by a set of data provider components.
*/
const getProviderContextBindings = (asset, dataProviders) => {
if (!asset || !dataProviders) {
return []
}
// Ensure providers is an array
if (!Array.isArray(dataProviders)) {
dataProviders = [dataProviders]
}
// Create bindings for each data provider // Create bindings for each data provider
let bindings = []
dataProviders.forEach(component => { dataProviders.forEach(component => {
const def = store.actions.components.getDefinition(component._component) const def = store.actions.components.getDefinition(component._component)
const contexts = Array.isArray(def.context) ? def.context : [def.context] const contexts = Array.isArray(def.context) ? def.context : [def.context]
@ -142,6 +176,7 @@ const getContextBindings = (asset, componentId) => {
let schema let schema
let readablePrefix let readablePrefix
let runtimeSuffix = context.suffix
if (context.type === "form") { if (context.type === "form") {
// Forms do not need table schemas // Forms do not need table schemas
@ -171,8 +206,14 @@ const getContextBindings = (asset, componentId) => {
const keys = Object.keys(schema).sort() const keys = Object.keys(schema).sort()
// Generate safe unique runtime prefix
let runtimeId = component._id
if (runtimeSuffix) {
runtimeId += `-${runtimeSuffix}`
}
const safeComponentId = makePropSafe(runtimeId)
// Create bindable properties for each schema field // Create bindable properties for each schema field
const safeComponentId = makePropSafe(component._id)
keys.forEach(key => { keys.forEach(key => {
const fieldSchema = schema[key] const fieldSchema = schema[key]
@ -184,6 +225,7 @@ const getContextBindings = (asset, componentId) => {
} else if (fieldSchema.type === "attachment") { } else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first` runtimeBoundKey = `${key}_first`
} }
const runtimeBinding = `${safeComponentId}.${makePropSafe( const runtimeBinding = `${safeComponentId}.${makePropSafe(
runtimeBoundKey runtimeBoundKey
)}` )}`
@ -374,8 +416,8 @@ const buildFormSchema = component => {
if (!component) { if (!component) {
return schema return schema
} }
const def = store.actions.components.getDefinition(component._component) const settings = getComponentSettings(component._component)
const fieldSetting = def?.settings?.find( const fieldSetting = settings.find(
setting => setting.key === "field" && setting.type.startsWith("field/") setting => setting.key === "field" && setting.type.startsWith("field/")
) )
if (fieldSetting && component.field) { if (fieldSetting && component.field) {

View File

@ -25,6 +25,7 @@ import {
findClosestMatchingComponent, findClosestMatchingComponent,
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
getComponentSettings,
} from "../storeUtils" } from "../storeUtils"
import { uuid } from "../uuid" import { uuid } from "../uuid"
import { removeBindings } from "../dataBinding" import { removeBindings } from "../dataBinding"
@ -368,14 +369,13 @@ export const getFrontendStore = () => {
} }
// Generate default props // Generate default props
const settings = getComponentSettings(componentName)
let props = { ...presetProps } let props = { ...presetProps }
if (definition.settings) { settings.forEach(setting => {
definition.settings.forEach(setting => { if (setting.defaultValue !== undefined) {
if (setting.defaultValue !== undefined) { props[setting.key] = setting.defaultValue
props[setting.key] = setting.defaultValue }
} })
})
}
// Add any extra properties the component needs // Add any extra properties the component needs
let extras = {} let extras = {}

View File

@ -1,3 +1,5 @@
import { store } from "./index"
/** /**
* Recursively searches for a specific component ID * Recursively searches for a specific component ID
*/ */
@ -123,3 +125,20 @@ const searchComponentTree = (rootComponent, matchComponent) => {
} }
return null return null
} }
/**
* Searches a component's definition for a setting matching a certin predicate.
*/
export const getComponentSettings = componentType => {
const def = store.actions.components.getDefinition(componentType)
if (!def) {
return []
}
let settings = def.settings?.filter(setting => !setting.section) ?? []
def.settings
?.filter(setting => setting.section)
.forEach(section => {
settings = settings.concat(section.settings || [])
})
return settings
}

View File

@ -1,4 +1,12 @@
[ [
{
"name": "Blocks",
"icon": "Article",
"children": [
"tablewithsearch",
"cardlistwithsearch"
]
},
"section", "section",
"container", "container",
"dataprovider", "dataprovider",

View File

@ -12,6 +12,7 @@
export let componentInstance export let componentInstance
export let assetInstance export let assetInstance
export let bindings export let bindings
export let componentBindings
const layoutDefinition = [] const layoutDefinition = []
const screenDefinition = [ const screenDefinition = [
@ -21,10 +22,24 @@
{ key: "layoutId", label: "Layout", control: LayoutSelect }, { key: "layoutId", label: "Layout", control: LayoutSelect },
] ]
$: settings = componentDefinition?.settings ?? [] $: sections = getSections(componentDefinition)
$: isLayout = assetInstance && assetInstance.favicon $: isLayout = assetInstance && assetInstance.favicon
$: assetDefinition = isLayout ? layoutDefinition : screenDefinition $: assetDefinition = isLayout ? layoutDefinition : screenDefinition
const getSections = definition => {
const settings = definition?.settings ?? []
const generalSettings = settings.filter(setting => !setting.section)
const customSections = settings.filter(setting => setting.section)
return [
{
name: "General",
info: componentDefinition?.info,
settings: generalSettings,
},
...(customSections || []),
]
}
const updateProp = store.actions.components.updateProp const updateProp = store.actions.components.updateProp
const canRenderControl = setting => { const canRenderControl = setting => {
@ -59,19 +74,19 @@
} }
</script> </script>
<DetailSummary name="General" collapsible={false}> {#each sections as section, idx (section.name)}
{#if !componentInstance._component.endsWith("/layout")} <DetailSummary name={section.name} collapsible={false}>
<PropertyControl {#if idx === 0 && !componentInstance._component.endsWith("/layout")}
bindable={false} <PropertyControl
control={Input} bindable={false}
label="Name" control={Input}
key="_instanceName" label="Name"
value={componentInstance._instanceName} key="_instanceName"
onChange={val => updateProp("_instanceName", val)} value={componentInstance._instanceName}
/> onChange={val => updateProp("_instanceName", val)}
{/if} />
{#if settings && settings.length > 0} {/if}
{#each settings as setting (setting.key)} {#each section.settings as setting (setting.key)}
{#if canRenderControl(setting)} {#if canRenderControl(setting)}
<PropertyControl <PropertyControl
type={setting.type} type={setting.type}
@ -80,7 +95,7 @@
key={setting.key} key={setting.key}
value={componentInstance[setting.key] ?? value={componentInstance[setting.key] ??
componentInstance[setting.key]?.defaultValue} componentInstance[setting.key]?.defaultValue}
{componentInstance} nested={setting.nested}
onChange={val => updateProp(setting.key, val)} onChange={val => updateProp(setting.key, val)}
props={{ props={{
options: setting.options || [], options: setting.options || [],
@ -89,20 +104,22 @@
max: setting.max || null, max: setting.max || null,
}} }}
{bindings} {bindings}
{componentBindings}
{componentInstance}
{componentDefinition} {componentDefinition}
/> />
{/if} {/if}
{/each} {/each}
{/if} {#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
{#if componentDefinition?.component?.endsWith("/fieldgroup")} <ResetFieldsButton {componentInstance} />
<ResetFieldsButton {componentInstance} /> {/if}
{/if} {#if section?.info}
{#if componentDefinition?.info} <div class="text">
<div class="text"> {@html section.info}
{@html componentDefinition?.info} </div>
</div> {/if}
{/if} </DetailSummary>
</DetailSummary> {/each}
<style> <style>
.text { .text {

View File

@ -6,13 +6,20 @@
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte" import ConditionalUISection from "./ConditionalUISection.svelte"
import { getBindableProperties } from "builderStore/dataBinding" import {
getBindableProperties,
getComponentBindableProperties,
} from "builderStore/dataBinding"
$: componentInstance = $selectedComponent $: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition( $: componentDefinition = store.actions.components.getDefinition(
$selectedComponent?._component $selectedComponent?._component
) )
$: bindings = getBindableProperties($currentAsset, $store.selectedComponentId) $: bindings = getBindableProperties($currentAsset, $store.selectedComponentId)
$: componentBindings = getComponentBindableProperties(
$currentAsset,
$store.selectedComponentId
)
</script> </script>
<Tabs selected="Settings" noPadding> <Tabs selected="Settings" noPadding>
@ -28,6 +35,7 @@
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}
{bindings} {bindings}
{componentBindings}
/> />
<DesignSection {componentInstance} {componentDefinition} {bindings} /> <DesignSection {componentInstance} {componentDefinition} {bindings} />
<CustomStylesSection <CustomStylesSection

View File

@ -17,14 +17,24 @@
export let props = {} export let props = {}
export let onChange = () => {} export let onChange = () => {}
export let bindings = [] export let bindings = []
export let componentBindings = []
export let nested = false
let bindingDrawer let bindingDrawer
let anchor let anchor
let valid let valid
$: safeValue = getSafeValue(value, props.defaultValue, bindings) $: allBindings = getAllBindings(bindings, componentBindings, nested)
$: safeValue = getSafeValue(value, props.defaultValue, allBindings)
$: tempValue = safeValue $: tempValue = safeValue
$: replaceBindings = val => readableToRuntimeBinding(bindings, val) $: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
const getAllBindings = (bindings, componentBindings, nested) => {
if (!nested) {
return bindings
}
return [...(bindings || []), ...(componentBindings || [])]
}
const handleClose = () => { const handleClose = () => {
handleChange(tempValue) handleChange(tempValue)
@ -78,7 +88,7 @@
updateOnChange={false} updateOnChange={false}
on:change={handleChange} on:change={handleChange}
onChange={handleChange} onChange={handleChange}
{bindings} bindings={allBindings}
name={key} name={key}
text={label} text={label}
{type} {type}
@ -104,7 +114,7 @@
bind:valid bind:valid
value={safeValue} value={safeValue}
on:change={e => (tempValue = e.detail)} on:change={e => (tempValue = e.detail)}
bindableProperties={bindings} bindableProperties={allBindings}
allowJS allowJS
/> />
</Drawer> </Drawer>
@ -122,7 +132,6 @@
} }
.label { .label {
text-transform: capitalize;
padding-bottom: var(--spectrum-global-dimension-size-65); padding-bottom: var(--spectrum-global-dimension-size-65);
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {

View File

@ -2457,12 +2457,12 @@
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
"label": "Provider", "label": "Data provider",
"key": "dataProvider" "key": "dataProvider"
}, },
{ {
"type": "number", "type": "number",
"label": "Row Count", "label": "Row count",
"key": "rowCount", "key": "rowCount",
"defaultValue": 8 "defaultValue": 8
}, },
@ -2496,9 +2496,36 @@
}, },
{ {
"type": "boolean", "type": "boolean",
"label": "Auto Columns", "label": "Show auto columns",
"key": "showAutoColumns", "key": "showAutoColumns",
"defaultValue": false "defaultValue": false
},
{
"type": "boolean",
"label": "Link table rows",
"key": "linkRows"
},
{
"type": "boolean",
"label": "Open link screens in modal",
"key": "linkPeek"
},
{
"type": "url",
"label": "Link screen",
"key": "linkURL"
},
{
"section": true,
"name": "Advanced",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
}
]
} }
], ],
"context": { "context": {
@ -2572,7 +2599,297 @@
"type": "boolean", "type": "boolean",
"key": "horizontal", "key": "horizontal",
"label": "Horizontal" "label": "Horizontal"
},
{
"type": "boolean",
"label": "Show button",
"key": "showButton"
},
{
"type": "text",
"key": "buttonText",
"label": "Button text"
},
{
"type": "event",
"label": "Button action",
"key": "buttonOnClick"
} }
] ]
},
"tablewithsearch": {
"block": true,
"name": "Table with search",
"icon": "Table",
"styles": ["size"],
"info": "Only the first 3 search columns will be used.",
"settings": [
{
"type": "text",
"label": "Title",
"key": "title"
},
{
"type": "dataSource",
"label": "Data",
"key": "dataSource"
},
{
"type": "multifield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
},
{
"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": "select",
"label": "Size",
"key": "size",
"defaultValue": "spectrum--medium",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
]
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
},
{
"section": true,
"name": "Table",
"settings": [
{
"type": "number",
"label": "Row Count",
"key": "rowCount",
"defaultValue": 8
},
{
"type": "multifield",
"label": "Table Columns",
"key": "tableColumns",
"dependsOn": "dataSource",
"placeholder": "All columns"
},
{
"type": "boolean",
"label": "Quiet table variant",
"key": "quiet"
},
{
"type": "boolean",
"label": "Show auto columns",
"key": "showAutoColumns"
},
{
"type": "boolean",
"label": "Link table rows",
"key": "linkRows"
},
{
"type": "boolean",
"label": "Open link in modal",
"key": "linkPeek"
},
{
"type": "url",
"label": "Link screen",
"key": "linkURL"
}
]
},
{
"section": true,
"name": "Title button",
"settings": [
{
"type": "boolean",
"key": "showTitleButton",
"label": "Show button",
"defaultValue": false
},
{
"type": "text",
"key": "titleButtonText",
"label": "Button text"
},
{
"type": "event",
"label": "Button action",
"key": "titleButtonOnClick"
}
]
},
{
"section": true,
"name": "Advanced",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
}
]
}
]
},
"cardlistwithsearch": {
"block": true,
"name": "Card list with search",
"icon": "Table",
"styles": ["size"],
"info": "Only the first 3 search columns will be used.",
"settings": [
{
"type": "text",
"label": "Title",
"key": "title"
},
{
"type": "dataSource",
"label": "Data",
"key": "dataSource"
},
{
"type": "multifield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
},
{
"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": 8
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate"
},
{
"section": true,
"name": "Cards",
"settings": [
{
"type": "text",
"key": "cardTitle",
"label": "Title",
"nested": true
},
{
"type": "text",
"key": "cardSubtitle",
"label": "Subtitle",
"nested": true
},
{
"type": "text",
"key": "cardDescription",
"label": "Description",
"nested": true
},
{
"type": "text",
"key": "cardImageURL",
"label": "Image URL",
"nested": true
},
{
"type": "boolean",
"key": "cardHorizontal",
"label": "Horizontal"
},
{
"type": "boolean",
"label": "Show button",
"key": "showCardButton"
},
{
"type": "text",
"key": "cardButtonText",
"label": "Button text",
"nested": true
},
{
"type": "event",
"label": "Button action",
"key": "cardButtonOnClick",
"nested": true
}
]
},
{
"section": true,
"name": "Title button",
"settings": [
{
"type": "boolean",
"key": "showTitleButton",
"label": "Show button"
},
{
"type": "text",
"key": "titleButtonText",
"label": "Button text"
},
{
"type": "event",
"label": "Button action",
"key": "titleButtonOnClick"
}
]
}
],
"context": {
"type": "schema",
"suffix": "repeater"
}
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.180-alpha.0", "@budibase/bbui": "^0.9.180-alpha.1",
"@budibase/standard-components": "^0.9.139", "@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^0.9.180-alpha.0", "@budibase/string-templates": "^0.9.180-alpha.1",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5" "svelte-spa-router": "^3.0.5"

View File

@ -1,9 +1,9 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { fetchTableData } from "./tables" import { fetchTableData, fetchTableDefinition } from "./tables"
import { fetchViewData } from "./views" import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships" import { fetchRelationshipData } from "./relationships"
import { executeQuery } from "./queries"
import { FieldTypes } from "../constants" import { FieldTypes } from "../constants"
import { executeQuery, fetchQueryDefinition } from "./queries"
/** /**
* Fetches all rows for a particular Budibase data source. * Fetches all rows for a particular Budibase data source.
@ -40,3 +40,35 @@ export const fetchDatasource = async dataSource => {
// Enrich the result is always an array // Enrich the result is always an array
return Array.isArray(rows) ? rows : [] return Array.isArray(rows) ? rows : []
} }
/**
* Fetches the schema of any kind of datasource.
*/
export const fetchDatasourceSchema = async dataSource => {
if (!dataSource) {
return null
}
const { type } = dataSource
// Nested providers should already have exposed their own schema
if (type === "provider") {
return dataSource.value?.schema
}
// Tables, views and links can be fetched by table ID
if (
(type === "table" || type === "view" || type === "link") &&
dataSource.tableId
) {
const table = await fetchTableDefinition(dataSource.tableId)
return table?.schema
}
// Queries can be fetched by query ID
if (type === "query" && dataSource._id) {
const definition = await fetchQueryDefinition(dataSource._id)
return definition?.schema
}
return null
}

View File

@ -5,7 +5,7 @@ import API from "./api"
* Executes a query against an external data connector. * Executes a query against an external data connector.
*/ */
export const executeQuery = async ({ queryId, parameters }) => { export const executeQuery = async ({ queryId, parameters }) => {
const query = await API.get({ url: `/api/queries/${queryId}` }) const query = await fetchQueryDefinition(queryId)
if (query?.datasourceId == null) { if (query?.datasourceId == null) {
notificationStore.actions.error("That query couldn't be found") notificationStore.actions.error("That query couldn't be found")
return return
@ -24,3 +24,10 @@ export const executeQuery = async ({ queryId, parameters }) => {
} }
return res return res
} }
/**
* Fetches the definition of an external query.
*/
export const fetchQueryDefinition = async queryId => {
return await API.get({ url: `/api/queries/${queryId}`, cache: true })
}

View File

@ -0,0 +1,12 @@
<script>
import { getContext, setContext } from "svelte"
const component = getContext("component")
// We need to set a block context to know we're inside a block, but also
// to be able to reference the actual component ID of the block from
// any depth
setContext("block", { id: $component.id })
</script>
<slot />

View File

@ -0,0 +1,35 @@
<script>
import { getContext } from "svelte"
import { generate } from "shortid"
import Component from "components/Component.svelte"
export let type
export let props
export let styles
export let context
// ID is only exposed as a prop so that it can be bound to from parent
// block components
export let id
const block = getContext("block")
const rand = generate()
// Create a fake component instance so that we can use the core Component
// to render this part of the block, taking advantage of binding enrichment
$: id = `${block.id}-${context ?? rand}`
$: instance = {
_component: `@budibase/standard-components/${type}`,
_id: id,
_styles: {
normal: {
...styles,
},
},
...props,
}
</script>
<Component {instance}>
<slot />
</Component>

View File

@ -1,3 +1,7 @@
<script context="module">
let SettingsDefinitionCache = {}
</script>
<script> <script>
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
@ -20,13 +24,13 @@
// Any prop overrides that need to be applied due to conditional UI // Any prop overrides that need to be applied due to conditional UI
let conditionalSettings let conditionalSettings
// Props are hashed when inside the builder preview and used as a key, so that // Settings are hashed when inside the builder preview and used as a key,
// components fully remount whenever any props change // so that components fully remount whenever any settings change
let propsHash = 0 let hash = 0
// Latest timestamp that we started a props update. // Latest timestamp that we started a props update.
// Due to enrichment now being async, we need to avoid overwriting newer // Due to enrichment now being async, we need to avoid overwriting newer
// props with old ones, depending on how long enrichment takes. // settings with old ones, depending on how long enrichment takes.
let latestUpdateTime let latestUpdateTime
// Keep track of stringified representations of context and instance // Keep track of stringified representations of context and instance
@ -40,6 +44,7 @@
// 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({})
@ -48,24 +53,59 @@
// Extract component instance info // Extract component instance info
$: constructor = getComponentConstructor(instance._component) $: constructor = getComponentConstructor(instance._component)
$: definition = getComponentDefinition(instance._component) $: definition = getComponentDefinition(instance._component)
$: settingsDefinition = getSettingsDefinition(definition)
$: children = instance._children || [] $: children = instance._children || []
$: id = instance._id $: id = instance._id
$: name = instance._instanceName $: name = instance._instanceName
// Determine if the component is selected or is part of the critical path
// leading to the selected component
$: selected =
$builderStore.inBuilder && $builderStore.selectedComponentId === id
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id)
// Interactive components can be selected, dragged and highlighted inside
// the builder preview
$: interactive = $: interactive =
$builderStore.inBuilder && $builderStore.inBuilder &&
($builderStore.previewType === "layout" || insideScreenslot) ($builderStore.previewType === "layout" || insideScreenslot) &&
!insideBlock
$: draggable = interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen
// Empty components are those which accept children but do not have any.
// Empty states can be shown for these components, but can be disabled
// in the component manifest.
$: empty = interactive && !children.length && definition?.hasChildren $: empty = interactive && !children.length && definition?.hasChildren
$: emptyState = empty && definition?.showEmptyState !== false $: emptyState = empty && definition?.showEmptyState !== false
$: rawProps = getRawProps(instance)
$: instanceKey = JSON.stringify(rawProps) // Raw props are all props excluding internal props and children
$: updateComponentProps(rawProps, instanceKey, $context) $: rawSettings = getRawSettings(instance)
$: selected = $: instanceKey = hashString(JSON.stringify(rawSettings))
$builderStore.inBuilder &&
$builderStore.selectedComponentId === instance._id // Component settings are those which are intended for this component and
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id) // which need to be enriched
$: componentSettings = getComponentSettings(rawSettings, settingsDefinition)
$: enrichComponentSettings(rawSettings, instanceKey, $context)
// Nested settings are those which are intended for child components inside
// blocks and which should not be enriched at this level
$: nestedSettings = getNestedSettings(rawSettings, settingsDefinition)
// Evaluate conditional UI settings and store any component setting changes
// which need to be made
$: evaluateConditions(enrichedSettings?._conditions) $: evaluateConditions(enrichedSettings?._conditions)
$: componentSettings = { ...enrichedSettings, ...conditionalSettings }
$: renderKey = `${propsHash}-${emptyState}` // Build up the final settings object to be passed to the component
$: settings = {
...enrichedSettings,
...nestedSettings,
...conditionalSettings,
}
// Render key is used when in the builder preview to fully remount
// components when settings are changed
$: renderKey = `${hash}-${emptyState}`
// Update component context // Update component context
$: componentStore.set({ $: componentStore.set({
@ -77,14 +117,14 @@
name, name,
}) })
const getRawProps = instance => { const getRawSettings = instance => {
let validProps = {} let validSettings = {}
Object.entries(instance) Object.entries(instance)
.filter(([name]) => name === "_conditions" || !name.startsWith("_")) .filter(([name]) => name === "_conditions" || !name.startsWith("_"))
.forEach(([key, value]) => { .forEach(([key, value]) => {
validProps[key] = value validSettings[key] = value
}) })
return validProps return validSettings
} }
// Gets the component constructor for the specified component // Gets the component constructor for the specified component
@ -103,8 +143,47 @@
return type ? Manifest[type] : null return type ? Manifest[type] : null
} }
const getSettingsDefinition = definition => {
if (!definition) {
return []
}
if (SettingsDefinitionCache[definition.name]) {
return SettingsDefinitionCache[definition.name]
}
let settings = []
definition.settings?.forEach(setting => {
if (setting.section) {
settings = settings.concat(setting.settings || [])
} else {
settings.push(setting)
}
})
SettingsDefinitionCache[definition] = settings
return settings
}
const getComponentSettings = (rawSettings, settingsDefinition) => {
let clone = { ...rawSettings }
settingsDefinition?.forEach(setting => {
if (setting.nested) {
delete clone[setting.key]
}
})
return clone
}
const getNestedSettings = (rawSettings, settingsDefinition) => {
let clone = { ...rawSettings }
settingsDefinition?.forEach(setting => {
if (!setting.nested) {
delete clone[setting.key]
}
})
return clone
}
// Enriches any string component props using handlebars // Enriches any string component props using handlebars
const updateComponentProps = (rawProps, instanceKey, context) => { const enrichComponentSettings = (rawSettings, instanceKey, context) => {
const instanceSame = instanceKey === lastInstanceKey const instanceSame = instanceKey === lastInstanceKey
const contextSame = context.key === lastContextKey const contextSame = context.key === lastContextKey
@ -119,8 +198,8 @@
latestUpdateTime = Date.now() latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime const enrichmentTime = latestUpdateTime
// Enrich props with context // Enrich settings with context
const enrichedProps = enrichProps(rawProps, context) const newEnrichedSettings = enrichProps(rawSettings, context)
// Abandon this update if a newer update has started // Abandon this update if a newer update has started
if (enrichmentTime !== latestUpdateTime) { if (enrichmentTime !== latestUpdateTime) {
@ -130,7 +209,7 @@
// Update the component props. // Update the component props.
// Most props are deeply compared so that svelte will only trigger reactive // Most props are deeply compared so that svelte will only trigger reactive
// statements on props that have actually changed. // statements on props that have actually changed.
if (!enrichedProps) { if (!newEnrichedSettings) {
return return
} }
let propsChanged = false let propsChanged = false
@ -138,17 +217,17 @@
enrichedSettings = {} enrichedSettings = {}
propsChanged = true propsChanged = true
} }
Object.keys(enrichedProps).forEach(key => { Object.keys(newEnrichedSettings).forEach(key => {
if (!propsAreSame(enrichedProps[key], enrichedSettings[key])) { if (!propsAreSame(newEnrichedSettings[key], enrichedSettings[key])) {
propsChanged = true propsChanged = true
enrichedSettings[key] = enrichedProps[key] enrichedSettings[key] = newEnrichedSettings[key]
} }
}) })
// Update the hash if we're in the builder so we can fully remount this // Update the hash if we're in the builder so we can fully remount this
// component // component
if (get(builderStore).inBuilder && propsChanged) { if (get(builderStore).inBuilder && propsChanged) {
propsHash = hashString(JSON.stringify(enrichedSettings)) hash = hashString(JSON.stringify(enrichedSettings))
} }
} }
@ -171,14 +250,10 @@
conditionalSettings = result.settingUpdates conditionalSettings = result.settingUpdates
visible = nextVisible visible = nextVisible
} }
// Drag and drop helper tags
$: draggable = interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen
</script> </script>
{#key renderKey} {#key renderKey}
{#if constructor && componentSettings && (visible || inSelectedPath)} {#if constructor && settings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) --> <!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators --> <!-- and the performance matters for the selection indicators -->
<div <div
@ -190,13 +265,15 @@
data-id={id} data-id={id}
data-name={name} data-name={name}
> >
<svelte:component this={constructor} {...componentSettings}> <svelte:component this={constructor} {...settings}>
{#if children.length} {#if children.length}
{#each children as child (child._id)} {#each children as child (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} />
{/each} {/each}
{:else if emptyState} {:else if emptyState}
<Placeholder /> <Placeholder />
{:else if insideBlock}
<slot />
{/if} {/if}
</svelte:component> </svelte:component>
</div> </div>

View File

@ -27,6 +27,9 @@
button { button {
width: fit-content; width: fit-content;
width: -moz-fit-content; width: -moz-fit-content;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.spectrum-Button--overBackground:hover { .spectrum-Button--overBackground:hover {
color: #555; color: #555;

View File

@ -35,28 +35,36 @@
let pageNumber = 0 let pageNumber = 0
let query = null let query = null
// Sorting can be overridden at run time, so we can't use the prop directly
let currentSortColumn = sortColumn
let currentSortOrder = sortOrder
$: query = buildLuceneQuery(filter) $: query = buildLuceneQuery(filter)
$: internalTable = dataSource?.type === "table" $: internalTable = dataSource?.type === "table"
$: nestedProvider = dataSource?.type === "provider" $: nestedProvider = dataSource?.type === "provider"
$: hasNextPage = bookmarks[pageNumber + 1] != null $: hasNextPage = bookmarks[pageNumber + 1] != null
$: hasPrevPage = pageNumber > 0 $: hasPrevPage = pageNumber > 0
$: getSchema(dataSource) $: getSchema(dataSource)
$: sortType = getSortType(schema, sortColumn) $: sortType = getSortType(schema, currentSortColumn)
// Wait until schema loads before loading data, so that we can determine
// the correct sort type first time
$: { $: {
// Wait until schema loads before loading data, so that we can determine
// the correct sort type first time
if (schemaLoaded) { if (schemaLoaded) {
fetchData( fetchData(
dataSource, dataSource,
schema,
query, query,
limit, limit,
sortColumn, currentSortColumn,
sortOrder, currentSortOrder,
sortType, sortType,
paginate paginate
) )
} }
} }
// Reactively filter and sort rows if required
$: { $: {
if (internalTable) { if (internalTable) {
// Internal tables are already processed server-side // Internal tables are already processed server-side
@ -65,10 +73,17 @@
// For anything else we use client-side implementations to filter, sort // For anything else we use client-side implementations to filter, sort
// and limit // and limit
const filtered = luceneQuery(allRows, query) const filtered = luceneQuery(allRows, query)
const sorted = luceneSort(filtered, sortColumn, sortOrder, sortType) const sorted = luceneSort(
filtered,
currentSortColumn,
currentSortOrder,
sortType
)
rows = luceneLimit(sorted, limit) rows = luceneLimit(sorted, limit)
} }
} }
// Build our action context
$: actions = [ $: actions = [
{ {
type: ActionTypes.RefreshDatasource, type: ActionTypes.RefreshDatasource,
@ -79,7 +94,20 @@
type: ActionTypes.SetDataProviderQuery, type: ActionTypes.SetDataProviderQuery,
callback: newQuery => (query = newQuery), callback: newQuery => (query = newQuery),
}, },
{
type: ActionTypes.SetDataProviderSorting,
callback: ({ column, order }) => {
if (column) {
currentSortColumn = column
}
if (order) {
currentSortOrder = order
}
},
},
] ]
// Build our data context
$: dataContext = { $: dataContext = {
rows, rows,
schema, schema,
@ -88,7 +116,11 @@
// Undocumented properties. These aren't supposed to be used in builder // Undocumented properties. These aren't supposed to be used in builder
// bindings, but are used internally by other components // bindings, but are used internally by other components
id: $component?.id, id: $component?.id,
state: { query }, state: {
query,
sortColumn: currentSortColumn,
sortOrder: currentSortOrder,
},
loaded, loaded,
} }
@ -104,10 +136,11 @@
if (schemaLoaded && !nestedProvider) { if (schemaLoaded && !nestedProvider) {
fetchData( fetchData(
dataSource, dataSource,
schema,
query, query,
limit, limit,
sortColumn, currentSortColumn,
sortOrder, currentSortOrder,
sortType, sortType,
paginate paginate
) )
@ -116,6 +149,7 @@
const fetchData = async ( const fetchData = async (
dataSource, dataSource,
schema,
query, query,
limit, limit,
sortColumn, sortColumn,
@ -125,12 +159,16 @@
) => { ) => {
loading = true loading = true
if (dataSource?.type === "table") { if (dataSource?.type === "table") {
// Sanity check sort column, as using a non-existant column will prevent
// results coming back at all
const sort = schema?.[sortColumn] ? sortColumn : undefined
// For internal tables we use server-side processing // For internal tables we use server-side processing
const res = await API.searchTable({ const res = await API.searchTable({
tableId: dataSource.tableId, tableId: dataSource.tableId,
query, query,
limit, limit,
sort: sortColumn, sort,
sortOrder: sortOrder?.toLowerCase() ?? "ascending", sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType, sortType,
paginate, paginate,
@ -156,34 +194,24 @@
} }
const getSchema = async dataSource => { const getSchema = async dataSource => {
if (dataSource?.schema) { let newSchema = (await API.fetchDatasourceSchema(dataSource)) || {}
schema = dataSource.schema
} else if (dataSource?.tableId) {
const definition = await API.fetchTableDefinition(dataSource.tableId)
schema = definition?.schema ?? {}
} else if (dataSource?.type === "provider") {
schema = dataSource.value?.schema ?? {}
} else {
schema = {}
}
// Ensure there are "name" properties for all fields and that field schema // Ensure there are "name" properties for all fields and that field schema
// are objects // are objects
let fixedSchema = {} Object.entries(newSchema).forEach(([fieldName, fieldSchema]) => {
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
if (typeof fieldSchema === "string") { if (typeof fieldSchema === "string") {
fixedSchema[fieldName] = { newSchema[fieldName] = {
type: fieldSchema, type: fieldSchema,
name: fieldName, name: fieldName,
} }
} else { } else {
fixedSchema[fieldName] = { newSchema[fieldName] = {
...fieldSchema, ...fieldSchema,
name: fieldName, name: fieldName,
} }
} }
}) })
schema = fixedSchema schema = newSchema
schemaLoaded = true schemaLoaded = true
} }
@ -191,13 +219,14 @@
if (!hasNextPage || !internalTable) { if (!hasNextPage || !internalTable) {
return return
} }
const sort = schema?.[currentSortColumn] ? currentSortColumn : undefined
const res = await API.searchTable({ const res = await API.searchTable({
tableId: dataSource?.tableId, tableId: dataSource?.tableId,
query, query,
bookmark: bookmarks[pageNumber + 1], bookmark: bookmarks[pageNumber + 1],
limit, limit,
sort: sortColumn, sort,
sortOrder: sortOrder?.toLowerCase() ?? "ascending", sortOrder: currentSortOrder?.toLowerCase() ?? "ascending",
sortType, sortType,
paginate: true, paginate: true,
}) })
@ -212,13 +241,14 @@
if (!hasPrevPage || !internalTable) { if (!hasPrevPage || !internalTable) {
return return
} }
const sort = schema?.[currentSortColumn] ? currentSortColumn : undefined
const res = await API.searchTable({ const res = await API.searchTable({
tableId: dataSource?.tableId, tableId: dataSource?.tableId,
query, query,
bookmark: bookmarks[pageNumber - 1], bookmark: bookmarks[pageNumber - 1],
limit, limit,
sort: sortColumn, sort,
sortOrder: sortOrder?.toLowerCase() ?? "ascending", sortOrder: currentSortOrder?.toLowerCase() ?? "ascending",
sortType, sortType,
paginate: true, paginate: true,
}) })
@ -234,7 +264,7 @@
<ProgressCircle /> <ProgressCircle />
</div> </div>
{:else} {:else}
{#if !$component.children} {#if $component.emptyState}
<Placeholder /> <Placeholder />
{:else} {:else}
<slot /> <slot />

View File

@ -38,6 +38,7 @@
padding: var(--spacing-l); padding: var(--spacing-l);
display: grid; display: grid;
place-items: center; place-items: center;
grid-column: 1 / -1;
} }
.noRows i { .noRows i {
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);

View File

@ -1,6 +1,7 @@
<script> <script>
import "@spectrum-css/card/dist/index-vars.css" import "@spectrum-css/card/dist/index-vars.css"
import { getContext } from "svelte" import { getContext } from "svelte"
import { Button } from "@budibase/bbui"
export let title export let title
export let subtitle export let subtitle
@ -8,6 +9,9 @@
export let imageURL export let imageURL
export let linkURL export let linkURL
export let horizontal export let horizontal
export let showButton
export let buttonText
export let buttonOnClick
const { styleable, linkable } = getContext("sdk") const { styleable, linkable } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -60,12 +64,17 @@
{description} {description}
</div> </div>
{/if} {/if}
{#if showButton}
<div class="spectrum-Card-footer button-container">
<Button on:click={buttonOnClick} secondary>{buttonText || ""}</Button>
</div>
{/if}
</div> </div>
</div> </div>
<style> <style>
.spectrum-Card { .spectrum-Card {
width: 240px; width: 300px;
border-color: var(--spectrum-global-color-gray-300) !important; border-color: var(--spectrum-global-color-gray-300) !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -76,6 +85,9 @@
flex-direction: row; flex-direction: row;
width: 420px; width: 420px;
} }
.spectrum-Card-container {
padding: var(--spectrum-global-dimension-size-50) 0;
}
.spectrum-Card-title :global(a) { .spectrum-Card-title :global(a) {
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
@ -114,6 +126,19 @@
.spectrum-Card-footer { .spectrum-Card-footer {
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
padding-bottom: 0;
margin-top: -8px; margin-top: -8px;
margin-bottom: var(
--spectrum-card-body-padding-bottom,
var(--spectrum-global-dimension-size-300)
);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.button-container {
margin-bottom: var(--spectrum-global-dimension-size-300);
} }
</style> </style>

View File

@ -0,0 +1,237 @@
<script>
import { onMount, getContext } from "svelte"
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { Heading } from "@budibase/bbui"
export let title
export let dataSource
export let searchColumns
export let filter
export let sortColumn
export let sortOrder
export let paginate
export let limit
export let showTitleButton
export let titleButtonText
export let titleButtonOnClick
export let cardTitle
export let cardSubtitle
export let cardDescription
export let cardImageURL
export let cardHorizontal
export let showCardButton
export let cardButtonText
export let cardButtonOnClick
const { API, styleable } = getContext("sdk")
const context = getContext("context")
const component = getContext("component")
const schemaComponentMap = {
string: "stringfield",
options: "optionsfield",
number: "numberfield",
datetime: "datetimefield",
boolean: "booleanfield",
}
let formId
let dataProviderId
let schema
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema)
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId)
$: cardWidth = cardHorizontal ? 420 : 300
// Enrich the default filter with the specified search fields
const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])]
columns?.forEach(column => {
enrichedFilter.push({
field: column.name,
operator: "equal",
type: "string",
valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`,
})
})
return enrichedFilter
}
// Determine data types for search fields and only use those that are valid
const enrichSearchColumns = (searchColumns, schema) => {
let enrichedColumns = []
searchColumns?.forEach(column => {
const schemaType = schema?.[column]?.type
const componentType = schemaComponentMap[schemaType]
if (componentType) {
enrichedColumns.push({
name: column,
componentType,
})
}
})
return enrichedColumns.slice(0, 3)
}
// Load the datasource schema on mount so we can determine column types
onMount(async () => {
if (dataSource) {
schema = await API.fetchDatasourceSchema(dataSource)
}
})
</script>
<Block>
<div class="card-list" use:styleable={$component.styles}>
<BlockComponent type="form" bind:id={formId} props={{ dataSource }}>
{#if title || enrichedSearchColumns?.length || showTitleButton}
<div class="header" class:mobile={$context.device.mobile}>
<div class="title">
<Heading>{title || ""}</Heading>
</div>
<div class="controls">
{#if enrichedSearchColumns?.length}
<div
class="search"
style="--cols:{enrichedSearchColumns?.length}"
>
{#each enrichedSearchColumns as column}
<BlockComponent
type={column.componentType}
props={{
field: column.name,
placeholder: column.name,
text: column.name,
autoWidth: true,
}}
/>
{/each}
</div>
{/if}
{#if showTitleButton}
<BlockComponent
type="button"
props={{
onClick: titleButtonOnClick,
text: titleButtonText,
type: "cta",
}}
/>
{/if}
</div>
</div>
{/if}
<BlockComponent
type="dataprovider"
bind:id={dataProviderId}
props={{
dataSource,
filter: enrichedFilter,
sortColumn,
sortOrder,
paginate,
limit,
}}
>
<BlockComponent
type="repeater"
context="repeater"
props={{
dataProvider: `{{ literal [${dataProviderId}] }}`,
direction: "row",
hAlign: "stretch",
vAlign: "top",
gap: "M",
noRowsMessage: "No rows found",
}}
styles={{
display: "grid",
"grid-template-columns": `repeat(auto-fill, minmax(${cardWidth}px, 1fr))`,
}}
>
<BlockComponent
type="spectrumcard"
props={{
title: cardTitle,
subtitle: cardSubtitle,
description: cardDescription,
imageURL: cardImageURL,
horizontal: cardHorizontal,
showButton: showCardButton,
buttonText: cardButtonText,
buttonOnClick: cardButtonOnClick,
}}
styles={{
width: "auto",
}}
/>
</BlockComponent>
</BlockComponent>
</BlockComponent>
</div>
</Block>
<style>
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 20px;
}
.title {
overflow: hidden;
}
.title :global(.spectrum-Heading) {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
flex: 0 1 auto;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 20px;
}
.controls :global(.spectrum-InputGroup .spectrum-InputGroup-input) {
width: 100%;
}
.search {
flex: 0 1 auto;
gap: 10px;
max-width: 100%;
display: grid;
grid-template-columns: repeat(var(--cols), minmax(120px, 200px));
}
.search :global(.spectrum-InputGroup) {
min-width: 0;
}
/* Mobile styles */
.mobile {
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.mobile .controls {
flex-direction: column-reverse;
justify-content: flex-start;
align-items: stretch;
}
.mobile .search {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
position: relative;
width: 100%;
}
</style>

View File

@ -0,0 +1,218 @@
<script>
import { onMount, getContext } from "svelte"
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { Heading } from "@budibase/bbui"
export let title
export let dataSource
export let searchColumns
export let filter
export let sortColumn
export let sortOrder
export let paginate
export let tableColumns
export let showAutoColumns
export let rowCount
export let quiet
export let size
export let linkRows
export let linkURL
export let linkColumn
export let linkPeek
export let showTitleButton
export let titleButtonText
export let titleButtonOnClick
const { API, styleable } = getContext("sdk")
const context = getContext("context")
const component = getContext("component")
const schemaComponentMap = {
string: "stringfield",
options: "optionsfield",
number: "numberfield",
datetime: "datetimefield",
boolean: "booleanfield",
}
let formId
let dataProviderId
let schema
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema)
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId)
// Enrich the default filter with the specified search fields
const enrichFilter = (filter, columns, formId) => {
let enrichedFilter = [...(filter || [])]
columns?.forEach(column => {
enrichedFilter.push({
field: column.name,
operator: "equal",
type: "string",
valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`,
})
})
return enrichedFilter
}
// Determine data types for search fields and only use those that are valid
const enrichSearchColumns = (searchColumns, schema) => {
let enrichedColumns = []
searchColumns?.forEach(column => {
const schemaType = schema?.[column]?.type
const componentType = schemaComponentMap[schemaType]
if (componentType) {
enrichedColumns.push({
name: column,
componentType,
})
}
})
return enrichedColumns.slice(0, 3)
}
// Load the datasource schema on mount so we can determine column types
onMount(async () => {
if (dataSource) {
schema = await API.fetchDatasourceSchema(dataSource)
}
})
</script>
<Block>
<div class={size} use:styleable={$component.styles}>
<BlockComponent type="form" bind:id={formId} props={{ dataSource }}>
{#if title || enrichedSearchColumns?.length || showTitleButton}
<div class="header" class:mobile={$context.device.mobile}>
<div class="title">
<Heading>{title || ""}</Heading>
</div>
<div class="controls">
{#if enrichedSearchColumns?.length}
<div
class="search"
style="--cols:{enrichedSearchColumns?.length}"
>
{#each enrichedSearchColumns as column}
<BlockComponent
type={column.componentType}
props={{
field: column.name,
placeholder: column.name,
text: column.name,
autoWidth: true,
}}
/>
{/each}
</div>
{/if}
{#if showTitleButton}
<BlockComponent
type="button"
props={{
onClick: titleButtonOnClick,
text: titleButtonText,
type: "cta",
}}
/>
{/if}
</div>
</div>
{/if}
<BlockComponent
type="dataprovider"
bind:id={dataProviderId}
props={{
dataSource,
filter: enrichedFilter,
sortColumn,
sortOrder,
paginate,
limit: rowCount,
}}
>
<BlockComponent
type="table"
props={{
dataProvider: `{{ literal [${dataProviderId}] }}`,
columns: tableColumns,
showAutoColumns,
rowCount,
quiet,
size,
linkRows,
linkURL,
linkColumn,
linkPeek,
}}
/>
</BlockComponent>
</BlockComponent>
</div>
</Block>
<style>
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 20px;
margin-bottom: 20px;
}
.title {
overflow: hidden;
}
.title :global(.spectrum-Heading) {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
flex: 0 1 auto;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 20px;
}
.controls :global(.spectrum-InputGroup .spectrum-InputGroup-input) {
width: 100%;
}
.search {
flex: 0 1 auto;
gap: 10px;
max-width: 100%;
display: grid;
grid-template-columns: repeat(var(--cols), minmax(120px, 200px));
}
.search :global(.spectrum-InputGroup) {
min-width: 0;
}
/* Mobile styles */
.mobile {
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.mobile .controls {
flex-direction: column-reverse;
justify-content: flex-start;
align-items: stretch;
}
.mobile .search {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
position: relative;
width: 100%;
}
</style>

View File

@ -0,0 +1,2 @@
export { default as tablewithsearch } from "./TableWithSearch.svelte"
export { default as cardlistwithsearch } from "./CardListWithSearch.svelte"

View File

@ -34,25 +34,34 @@
return closestContext || {} return closestContext || {}
} }
// Fetches the form schema from this form's dataSource, if one exists // Fetches the form schema from this form's dataSource
const fetchSchema = async () => { const fetchSchema = async () => {
if (!dataSource?.tableId) { if (!dataSource) {
schema = {} schema = {}
table = null
} else {
table = await API.fetchTableDefinition(dataSource?.tableId)
if (table) {
if (dataSource?.type === "query") {
schema = {}
const params = table.parameters || []
params.forEach(param => {
schema[param.name] = { ...param, type: "string" }
})
} else {
schema = table.schema || {}
}
}
} }
// If the datasource is a query, then we instead use a schema of the query
// parameters rather than the output schema
else if (
dataSource.type === "query" &&
dataSource._id &&
actionType === "Create"
) {
const query = await API.fetchQueryDefinition(dataSource._id)
let paramSchema = {}
const params = query.parameters || []
params.forEach(param => {
paramSchema[param.name] = { ...param, type: "string" }
})
schema = paramSchema
}
// For all other cases, just grab the normal schema
else {
const dataSourceSchema = await API.fetchDatasourceSchema(dataSource)
schema = dataSourceSchema || {}
}
loaded = true loaded = true
} }

View File

@ -32,6 +32,7 @@ export { default as spectrumcard } from "./SpectrumCard.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"
export * from "./table" export * from "./table"
export * from "./blocks"
// Deprecated component left for compatibility in old apps // Deprecated component left for compatibility in old apps
export { default as navigation } from "./deprecated/Navigation.svelte" export { default as navigation } from "./deprecated/Navigation.svelte"

View File

@ -10,9 +10,17 @@
export let rowCount export let rowCount
export let quiet export let quiet
export let size export let size
export let linkRows
export let linkURL
export let linkColumn
export let linkPeek
const component = getContext("component") const component = getContext("component")
const { styleable } = getContext("sdk") const { styleable, getAction, ActionTypes, routeStore } = getContext("sdk")
const setSorting = getAction(
dataProvider?.id,
ActionTypes.SetDataProviderSorting
)
const customColumnKey = `custom-${Math.random()}` const customColumnKey = `custom-${Math.random()}`
const customRenderers = [ const customRenderers = [
{ {
@ -75,6 +83,26 @@
}) })
return newSchema return newSchema
} }
const onSort = e => {
setSorting({
column: e.detail.column,
order: e.detail.order,
})
}
const onClick = e => {
if (!linkRows || !linkURL) {
return
}
const col = linkColumn || "_id"
const id = e.detail?.[col]
if (!id) {
return
}
const split = linkURL.split("/:")
routeStore.actions.navigate(`${split[0]}/${id}`, linkPeek)
}
</script> </script>
<div use:styleable={$component.styles} class={size}> <div use:styleable={$component.styles} class={size}>
@ -89,6 +117,9 @@
allowEditRows={false} allowEditRows={false}
allowEditColumns={false} allowEditColumns={false}
showAutoColumns={true} showAutoColumns={true}
disableSorting
on:sort={onSort}
on:click={onClick}
> >
<slot /> <slot />
</Table> </Table>

View File

@ -26,6 +26,7 @@ export const ActionTypes = {
ValidateForm: "ValidateForm", ValidateForm: "ValidateForm",
RefreshDatasource: "RefreshDatasource", RefreshDatasource: "RefreshDatasource",
SetDataProviderQuery: "SetDataProviderQuery", SetDataProviderQuery: "SetDataProviderQuery",
SetDataProviderSorting: "SetDataProviderSorting",
ClearForm: "ClearForm", ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep", ChangeFormStep: "ChangeFormStep",
} }

View File

@ -1,4 +1,5 @@
import { writable, derived } from "svelte/store" import { writable, derived } from "svelte/store"
import { hashString } from "../utils/helpers"
export const createContextStore = oldContext => { export const createContextStore = oldContext => {
const newContext = writable({}) const newContext = writable({})
@ -9,7 +10,7 @@ export const createContextStore = oldContext => {
for (let i = 0; i < $contexts.length - 1; i++) { for (let i = 0; i < $contexts.length - 1; i++) {
key += $contexts[i].key key += $contexts[i].key
} }
key += JSON.stringify($contexts[$contexts.length - 1]) key = hashString(key + JSON.stringify($contexts[$contexts.length - 1]))
// Reduce global state // Reduce global state
const reducer = (total, context) => ({ ...total, ...context }) const reducer = (total, context) => ({ ...total, ...context })

View File

@ -1,6 +1,8 @@
import { writable } from "svelte/store" import { get, writable } from "svelte/store"
import { push } from "svelte-spa-router" import { push } from "svelte-spa-router"
import * as API from "../api" import * as API from "../api"
import { peekStore } from "./peek"
import { builderStore } from "./builder"
const createRouteStore = () => { const createRouteStore = () => {
const initialState = { const initialState = {
@ -59,7 +61,25 @@ const createRouteStore = () => {
return state return state
}) })
} }
const navigate = push const navigate = (url, peek) => {
if (get(builderStore).inBuilder) {
return
}
if (url) {
// If we're already peeking, don't peek again
const isPeeking = get(store).queryParams?.peek
if (peek && !isPeeking) {
peekStore.actions.showPeek(url)
} else {
const external = !url.startsWith("/")
if (external) {
window.location.href = url
} else {
push(url)
}
}
}
}
const setRouterLoaded = () => { const setRouterLoaded = () => {
store.update(state => ({ ...state, routerLoaded: true })) store.update(state => ({ ...state, routerLoaded: true }))
} }

View File

@ -4,7 +4,6 @@ import {
builderStore, builderStore,
confirmationStore, confirmationStore,
authStore, authStore,
peekStore,
stateStore, stateStore,
} from "stores" } from "stores"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api" import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
@ -42,20 +41,7 @@ const triggerAutomationHandler = async action => {
const navigationHandler = action => { const navigationHandler = action => {
const { url, peek } = action.parameters const { url, peek } = action.parameters
if (url) { routeStore.actions.navigate(url, peek)
// If we're already peeking, don't peek again
const isPeeking = get(routeStore).queryParams?.peek
if (peek && !isPeeking) {
peekStore.actions.showPeek(url)
} else {
const external = !url.startsWith("/")
if (external) {
window.location.href = url
} else {
routeStore.actions.navigate(action.parameters.url)
}
}
}
} }
const queryExecutionHandler = async action => { const queryExecutionHandler = async action => {
@ -161,9 +147,15 @@ const confirmTextMap = {
*/ */
export const enrichButtonActions = (actions, context) => { export const enrichButtonActions = (actions, context) => {
// Prevent button actions in the builder preview // Prevent button actions in the builder preview
if (get(builderStore).inBuilder) { if (!actions || get(builderStore).inBuilder) {
return () => {} return () => {}
} }
// If this is a function then it has already been enriched
if (typeof actions === "function") {
return actions
}
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]]) const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => { return async () => {
for (let i = 0; i < handlers.length; i++) { for (let i = 0; i < handlers.length; i++) {

View File

@ -36,17 +36,19 @@ export const enrichProps = (props, context) => {
let enrichedProps = enrichDataBindings(props, totalContext) let enrichedProps = enrichDataBindings(props, totalContext)
// Enrich click actions if they exist // Enrich click actions if they exist
if (enrichedProps.onClick) { Object.keys(enrichedProps).forEach(prop => {
enrichedProps.onClick = enrichButtonActions( if (prop?.toLowerCase().includes("onclick")) {
enrichedProps.onClick, enrichedProps[prop] = enrichButtonActions(
totalContext enrichedProps[prop],
) totalContext
} )
}
})
// Enrich any click actions in conditions // Enrich any click actions in conditions
if (enrichedProps._conditions) { if (enrichedProps._conditions) {
enrichedProps._conditions.forEach(condition => { enrichedProps._conditions.forEach(condition => {
if (condition.setting === "onClick") { if (condition.setting?.toLowerCase().includes("onclick")) {
condition.settingValue = enrichButtonActions( condition.settingValue = enrichButtonActions(
condition.settingValue, condition.settingValue,
totalContext totalContext

View File

@ -1,4 +1,3 @@
import { get } from "svelte/store"
import { builderStore } from "stores" import { builderStore } from "stores"
/** /**
@ -64,9 +63,7 @@ export const styleable = (node, styles = {}) => {
// Handler to select a component in the builder when clicking it in the // Handler to select a component in the builder when clicking it in the
// builder preview // builder preview
selectComponent = event => { selectComponent = event => {
if (newStyles.interactive) { builderStore.actions.selectComponent(componentId)
builderStore.actions.selectComponent(componentId)
}
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return false return false
@ -77,7 +74,7 @@ export const styleable = (node, styles = {}) => {
node.addEventListener("mouseout", applyNormalStyles) node.addEventListener("mouseout", applyNormalStyles)
// Add builder preview click listener // Add builder preview click listener
if (get(builderStore).inBuilder) { if (newStyles.interactive) {
node.addEventListener("click", selectComponent, false) node.addEventListener("click", selectComponent, false)
} }
@ -89,11 +86,7 @@ export const styleable = (node, styles = {}) => {
const removeListeners = () => { const removeListeners = () => {
node.removeEventListener("mouseover", applyHoverStyles) node.removeEventListener("mouseover", applyHoverStyles)
node.removeEventListener("mouseout", applyNormalStyles) node.removeEventListener("mouseout", applyNormalStyles)
node.removeEventListener("click", selectComponent)
// Remove builder preview click listener
if (get(builderStore).inBuilder) {
node.removeEventListener("click", selectComponent)
}
} }
// Apply initial styles // Apply initial styles

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.js", "main": "src/index.js",
"repository": { "repository": {
@ -68,9 +68,9 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.9.180-alpha.0", "@budibase/auth": "^0.9.180-alpha.1",
"@budibase/client": "^0.9.180-alpha.0", "@budibase/client": "^0.9.180-alpha.1",
"@budibase/string-templates": "^0.9.180-alpha.0", "@budibase/string-templates": "^0.9.180-alpha.1",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
"@koa/router": "8.0.0", "@koa/router": "8.0.0",
"@sendgrid/mail": "7.1.1", "@sendgrid/mail": "7.1.1",

View File

@ -61,7 +61,11 @@ exports.validate = async ({ appId, tableId, row, table }) => {
let res let res
// Validate.js doesn't seem to handle array // Validate.js doesn't seem to handle array
if (table.schema[fieldName].type === FieldTypes.ARRAY) { if (
table.schema[fieldName].type === FieldTypes.ARRAY &&
row[fieldName] &&
row[fieldName].length
) {
row[fieldName].map(val => { row[fieldName].map(val => {
if (!constraints.inclusion.includes(val)) { if (!constraints.inclusion.includes(val)) {
errors[fieldName] = "Field not in list" errors[fieldName] = "Field not in list"

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/string-templates", "name": "@budibase/string-templates",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"description": "Handlebars wrapper for Budibase templating.", "description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs", "main": "src/index.cjs",
"module": "dist/bundle.mjs", "module": "dist/bundle.mjs",

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/worker", "name": "@budibase/worker",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "0.9.180-alpha.0", "version": "0.9.180-alpha.1",
"description": "Budibase background service", "description": "Budibase background service",
"main": "src/index.js", "main": "src/index.js",
"repository": { "repository": {
@ -29,8 +29,8 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.9.180-alpha.0", "@budibase/auth": "^0.9.180-alpha.1",
"@budibase/string-templates": "^0.9.180-alpha.0", "@budibase/string-templates": "^0.9.180-alpha.1",
"@koa/router": "^8.0.0", "@koa/router": "^8.0.0",
"@sentry/node": "^6.0.0", "@sentry/node": "^6.0.0",
"@techpass/passport-openidconnect": "^0.3.0", "@techpass/passport-openidconnect": "^0.3.0",