Merge pull request #1105 from Budibase/form-builder

Form Builder
This commit is contained in:
Andrew Kingston 2021-02-11 10:00:49 +00:00 committed by GitHub
commit 9dc94fbed6
111 changed files with 2833 additions and 894 deletions

View File

@ -63,7 +63,7 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.58.3",
"@budibase/bbui": "^1.58.5",
"@budibase/client": "^0.7.6",
"@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.6",

View File

@ -1,7 +1,7 @@
import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store"
import { backendUiStore, store } from "builderStore"
import { findAllMatchingComponents, findComponentPath } from "./storeUtils"
import { findComponentPath } from "./storeUtils"
import { makePropSafe } from "@budibase/string-templates"
import { TableNames } from "../constants"
@ -12,9 +12,7 @@ const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
* Gets all bindable data context fields and instance fields.
*/
export const getBindableProperties = (rootComponent, componentId) => {
const contextBindings = getContextBindings(rootComponent, componentId)
const componentBindings = getComponentBindings(rootComponent)
return [...contextBindings, ...componentBindings]
return getContextBindings(rootComponent, componentId)
}
/**
@ -37,6 +35,30 @@ export const getDataProviderComponents = (rootComponent, componentId) => {
})
}
/**
* Gets all data provider components above a component.
*/
export const getActionProviderComponents = (
rootComponent,
componentId,
actionType
) => {
if (!rootComponent || !componentId) {
return []
}
// Get the component tree leading up to this component, ignoring the component
// itself
const path = findComponentPath(rootComponent, componentId)
path.pop()
// Filter by only data provider components
return path.filter(component => {
const def = store.actions.components.getDefinition(component._component)
return def?.actions?.includes(actionType)
})
}
/**
* Gets a datasource object for a certain data provider component
*/
@ -47,8 +69,9 @@ export const getDatasourceForProvider = component => {
}
// Extract datasource from component instance
const validSettingTypes = ["datasource", "table", "schema"]
const datasourceSetting = def.settings.find(setting => {
return setting.type === "datasource" || setting.type === "table"
return validSettingTypes.includes(setting.type)
})
if (!datasourceSetting) {
return null
@ -58,15 +81,14 @@ export const getDatasourceForProvider = component => {
// example an actual datasource object, or a table ID string.
// Convert the datasource setting into a proper datasource object so that
// we can use it properly
if (datasourceSetting.type === "datasource") {
return component[datasourceSetting?.key]
} else if (datasourceSetting.type === "table") {
if (datasourceSetting.type === "table") {
return {
tableId: component[datasourceSetting?.key],
type: "table",
}
} else {
return component[datasourceSetting?.key]
}
return null
}
/**
@ -77,21 +99,37 @@ export const getContextBindings = (rootComponent, componentId) => {
// Extract any components which provide data contexts
const dataProviders = getDataProviderComponents(rootComponent, componentId)
let contextBindings = []
// Create bindings for each data provider
dataProviders.forEach(component => {
const isForm = component._component.endsWith("/form")
const datasource = getDatasourceForProvider(component)
if (!datasource) {
let tableName, schema
// Forms are an edge case which do not need table schemas
if (isForm) {
schema = buildFormSchema(component)
tableName = "Schema"
} else {
if (!datasource) {
return
}
// Get schema and table for the datasource
const info = getSchemaForDatasource(datasource, isForm)
schema = info.schema
tableName = info.table?.name
// Add _id and _rev fields for certain types
if (datasource.type === "table" || datasource.type === "link") {
schema["_id"] = { type: "string" }
schema["_rev"] = { type: "string" }
}
}
if (!schema || !tableName) {
return
}
// Get schema and add _id and _rev fields for certain types
let { schema, table } = getSchemaForDatasource(datasource)
if (!schema || !table) {
return
}
if (datasource.type === "table" || datasource.type === "link") {
schema["_id"] = { type: "string" }
schema["_rev"] = { type: "string " }
}
const keys = Object.keys(schema).sort()
// Create bindable properties for each schema field
@ -110,11 +148,11 @@ export const getContextBindings = (rootComponent, componentId) => {
runtimeBinding: `${makePropSafe(component._id)}.${makePropSafe(
runtimeBoundKey
)}`,
readableBinding: `${component._instanceName}.${table.name}.${key}`,
readableBinding: `${component._instanceName}.${tableName}.${key}`,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema,
providerId: component._id,
tableId: datasource.tableId,
field: key,
})
})
})
@ -142,44 +180,20 @@ export const getContextBindings = (rootComponent, componentId) => {
type: "context",
runtimeBinding: `user.${runtimeBoundKey}`,
readableBinding: `Current User.${key}`,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema,
providerId: "user",
tableId: TableNames.USERS,
field: key,
})
})
return contextBindings
}
/**
* Gets all bindable components. These are form components which allow their
* values to be bound to.
*/
export const getComponentBindings = rootComponent => {
if (!rootComponent) {
return []
}
const componentSelector = component => {
const type = component._component
const definition = store.actions.components.getDefinition(type)
return definition?.bindable
}
const components = findAllMatchingComponents(rootComponent, componentSelector)
return components.map(component => {
return {
type: "instance",
providerId: component._id,
runtimeBinding: `${makePropSafe(component._id)}`,
readableBinding: `${component._instanceName}`,
}
})
}
/**
* Gets a schema for a datasource object.
*/
export const getSchemaForDatasource = datasource => {
export const getSchemaForDatasource = (datasource, isForm = false) => {
let schema, table
if (datasource) {
const { type } = datasource
@ -193,6 +207,14 @@ export const getSchemaForDatasource = datasource => {
if (table) {
if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "query" && isForm) {
schema = {}
const params = table.parameters || []
params.forEach(param => {
if (param?.name) {
schema[param.name] = { ...param, type: "string" }
}
})
} else {
schema = cloneDeep(table.schema)
}
@ -201,6 +223,32 @@ export const getSchemaForDatasource = datasource => {
return { schema, table }
}
/**
* Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form.
*/
const buildFormSchema = component => {
let schema = {}
if (!component) {
return schema
}
const def = store.actions.components.getDefinition(component._component)
const fieldSetting = def?.settings?.find(
setting => setting.key === "field" && setting.type.startsWith("field/")
)
if (fieldSetting && component.field) {
const type = fieldSetting.type.split("field/")[1]
if (type) {
schema[component.field] = { name: component.field, type }
}
}
component._children?.forEach(child => {
const childSchema = buildFormSchema(child)
schema = { ...schema, ...childSchema }
})
return schema
}
/**
* utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
*/

View File

@ -416,7 +416,14 @@ export const getFrontendStore = () => {
if (cut) {
state.componentToPaste = null
} else {
componentToPaste._id = uuid()
const randomizeIds = component => {
if (!component) {
return
}
component._id = uuid()
component._children?.forEach(randomizeIds)
}
randomizeIds(componentToPaste)
}
if (mode === "inside") {

View File

@ -9,5 +9,6 @@ const createScreen = () => {
return new Screen()
.mainType("div")
.component("@budibase/standard-components/container")
.instanceName("New Screen")
.json()
}

View File

@ -1,13 +0,0 @@
import { Screen } from "./utils/Screen"
export default {
name: `New Row (Empty)`,
create: () => createScreen(),
}
const createScreen = () => {
return new Screen()
.component("@budibase/standard-components/newrow")
.table("")
.json()
}

View File

@ -1,13 +0,0 @@
import { Screen } from "./utils/Screen"
export default {
name: `Row Detail (Empty)`,
create: () => createScreen(),
}
const createScreen = () => {
return new Screen()
.component("@budibase/standard-components/rowdetail")
.table("")
.json()
}

View File

@ -1,17 +1,12 @@
import newRowScreen from "./newRowScreen"
import rowDetailScreen from "./rowDetailScreen"
import rowListScreen from "./rowListScreen"
import emptyNewRowScreen from "./emptyNewRowScreen"
import createFromScratchScreen from "./createFromScratchScreen"
import emptyRowDetailScreen from "./emptyRowDetailScreen"
const allTemplates = tables => [
createFromScratchScreen,
...newRowScreen(tables),
...rowDetailScreen(tables),
...rowListScreen(tables),
emptyNewRowScreen,
emptyRowDetailScreen,
]
// Allows us to apply common behaviour to all create() functions
@ -22,8 +17,18 @@ const createTemplateOverride = (frontendState, create) => () => {
return screen
}
export default (frontendState, tables) =>
allTemplates(tables).map(template => ({
export default (frontendState, tables) => {
const enrichTemplate = template => ({
...template,
create: createTemplateOverride(frontendState, template.create),
}))
})
const fromScratch = enrichTemplate(createFromScratchScreen)
const tableTemplates = allTemplates(tables).map(enrichTemplate)
return [
fromScratch,
...tableTemplates.sort((templateA, templateB) => {
return templateA.name > templateB.name ? 1 : -1
}),
]
}

View File

@ -1,11 +1,12 @@
import sanitizeUrl from "./utils/sanitizeUrl"
import { Component } from "./utils/Component"
import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
import {
makeBreadcrumbContainer,
makeMainContainer,
makeMainForm,
makeTitleContainer,
makeSaveButton,
makeDatasourceFormComponents,
} from "./utils/commonComponents"
export default function(tables) {
@ -21,29 +22,46 @@ export default function(tables) {
export const newRowUrl = table => sanitizeUrl(`/${table.name}/new/row`)
export const NEW_ROW_TEMPLATE = "NEW_ROW_TEMPLATE"
function generateTitleContainer(table, providerId) {
return makeTitleContainer("New Row").addChild(
makeSaveButton(table, providerId)
)
function generateTitleContainer(table, formId) {
return makeTitleContainer("New Row").addChild(makeSaveButton(table, formId))
}
const createScreen = table => {
const screen = new Screen()
.component("@budibase/standard-components/newrow")
.table(table._id)
.route(newRowUrl(table))
.component("@budibase/standard-components/container")
.instanceName(`${table.name} - New`)
.name("")
.route(newRowUrl(table))
const dataform = new Component(
"@budibase/standard-components/dataformwide"
).instanceName("Form")
const form = makeMainForm()
.instanceName("Form")
.customProps({
theme: "spectrum--lightest",
size: "spectrum--medium",
datasource: {
label: table.name,
tableId: table._id,
type: "table",
},
})
const providerId = screen._json.props._id
const container = makeMainContainer()
const fieldGroup = new Component("@budibase/standard-components/fieldgroup")
.instanceName("Field Group")
.customProps({
labelPosition: "left",
})
// Add all form fields from this schema to the field group
const datasource = { type: "table", tableId: table._id }
makeDatasourceFormComponents(datasource).forEach(component => {
fieldGroup.addChild(component)
})
// Add all children to the form
const formId = form._json._id
form
.addChild(makeBreadcrumbContainer(table.name, "New"))
.addChild(generateTitleContainer(table, providerId))
.addChild(dataform)
.addChild(generateTitleContainer(table, formId))
.addChild(fieldGroup)
return screen.addChild(container).json()
return screen.addChild(form).json()
}

View File

@ -4,20 +4,19 @@ import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
import { makePropSafe } from "@budibase/string-templates"
import {
makeMainContainer,
makeBreadcrumbContainer,
makeTitleContainer,
makeSaveButton,
makeMainForm,
spectrumColor,
makeDatasourceFormComponents,
} from "./utils/commonComponents"
export default function(tables) {
return tables.map(table => {
const heading = table.primaryDisplay
? `{{ data.${makePropSafe(table.primaryDisplay)} }}`
: null
return {
name: `${table.name} - Detail`,
create: () => createScreen(table, heading),
create: () => createScreen(table),
id: ROW_DETAIL_TEMPLATE,
}
})
@ -26,9 +25,9 @@ export default function(tables) {
export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE"
export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`)
function generateTitleContainer(table, title, providerId) {
function generateTitleContainer(table, title, formId) {
// have to override style for this, its missing margin
const saveButton = makeSaveButton(table, providerId).normalStyle({
const saveButton = makeSaveButton(table, formId).normalStyle({
background: "#000000",
"border-width": "0",
"border-style": "None",
@ -54,6 +53,7 @@ function generateTitleContainer(table, title, providerId) {
background: "transparent",
color: "#4285f4",
})
.customStyle(spectrumColor(700))
.text("Delete")
.customProps({
className: "",
@ -61,8 +61,9 @@ function generateTitleContainer(table, title, providerId) {
onClick: [
{
parameters: {
rowId: `{{ ${makePropSafe(providerId)}._id }}`,
revId: `{{ ${makePropSafe(providerId)}._rev }}`,
providerId: formId,
rowId: `{{ ${makePropSafe(formId)}._id }}`,
revId: `{{ ${makePropSafe(formId)}._rev }}`,
tableId: table._id,
},
"##eventHandlerType": "Delete Row",
@ -82,23 +83,47 @@ function generateTitleContainer(table, title, providerId) {
.addChild(saveButton)
}
const createScreen = (table, heading) => {
const createScreen = table => {
const screen = new Screen()
.component("@budibase/standard-components/rowdetail")
.table(table._id)
.instanceName(`${table.name} - Detail`)
.route(rowDetailUrl(table))
.name("")
const dataform = new Component(
"@budibase/standard-components/dataformwide"
).instanceName("Form")
const form = makeMainForm()
.instanceName("Form")
.customProps({
theme: "spectrum--lightest",
size: "spectrum--medium",
datasource: {
label: table.name,
tableId: table._id,
type: "table",
},
})
const providerId = screen._json.props._id
const container = makeMainContainer()
const fieldGroup = new Component("@budibase/standard-components/fieldgroup")
.instanceName("Field Group")
.customProps({
labelPosition: "left",
})
// Add all form fields from this schema to the field group
const datasource = { type: "table", tableId: table._id }
makeDatasourceFormComponents(datasource).forEach(component => {
fieldGroup.addChild(component)
})
// Add all children to the form
const formId = form._json._id
const rowDetailId = screen._json.props._id
const heading = table.primaryDisplay
? `{{ ${makePropSafe(rowDetailId)}.${makePropSafe(table.primaryDisplay)} }}`
: null
form
.addChild(makeBreadcrumbContainer(table.name, heading || "Edit"))
.addChild(generateTitleContainer(table, heading || "Edit Row", providerId))
.addChild(dataform)
.addChild(generateTitleContainer(table, heading || "Edit Row", formId))
.addChild(fieldGroup)
return screen.addChild(container).json()
return screen.addChild(form).json()
}

View File

@ -14,17 +14,11 @@ export class Component extends BaseStructure {
active: {},
selected: {},
},
type: "",
_instanceName: "",
_children: [],
}
}
type(type) {
this._json.type = type
return this
}
normalStyle(styling) {
this._json._styles.normal = styling
return this
@ -35,14 +29,25 @@ export class Component extends BaseStructure {
return this
}
text(text) {
this._json.text = text
customStyle(styling) {
this._json._styles.custom = styling
return this
}
// TODO: do we need this
instanceName(name) {
this._json._instanceName = name
return this
}
// Shorthand for custom props "type"
type(type) {
this._json.type = type
return this
}
// Shorthand for custom props "text"
text(text) {
this._json.text = text
return this
}
}

View File

@ -1,5 +1,15 @@
import { Component } from "./Component"
import { rowListUrl } from "../rowListScreen"
import { getSchemaForDatasource } from "../../../dataBinding"
export function spectrumColor(number) {
// Acorn throws a parsing error in this file if the word g-l-o-b-a-l is found
// (without dashes - I can't even type it in a comment).
// God knows why. It seems to think optional chaining further down the
// file is invalid if the word g-l-o-b-a-l is found - hence the reason this
// statement is split into parts.
return "color: var(--spectrum-glo" + `bal-color-gray-${number});`
}
export function makeLinkComponent(tableName) {
return new Component("@budibase/standard-components/link")
@ -10,6 +20,7 @@ export function makeLinkComponent(tableName) {
.hoverStyle({
color: "#4285f4",
})
.customStyle(spectrumColor(700))
.text(tableName)
.customProps({
url: `/${tableName.toLowerCase()}`,
@ -22,13 +33,12 @@ export function makeLinkComponent(tableName) {
})
}
export function makeMainContainer() {
return new Component("@budibase/standard-components/container")
export function makeMainForm() {
return new Component("@budibase/standard-components/form")
.type("div")
.normalStyle({
width: "700px",
padding: "0px",
background: "white",
"border-radius": "0.5rem",
"box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
margin: "auto",
@ -39,7 +49,7 @@ export function makeMainContainer() {
"padding-left": "48px",
"margin-bottom": "20px",
})
.instanceName("Container")
.instanceName("Form")
}
export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
@ -51,6 +61,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
"margin-right": "4px",
"margin-left": "4px",
})
.customStyle(spectrumColor(700))
.text(">")
.instanceName("Arrow")
@ -63,6 +74,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
const identifierText = new Component("@budibase/standard-components/text")
.type("none")
.normalStyle(textStyling)
.customStyle(spectrumColor(700))
.text(text)
.instanceName("Identifier")
@ -78,7 +90,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
.addChild(identifierText)
}
export function makeSaveButton(table, providerId) {
export function makeSaveButton(table, formId) {
return new Component("@budibase/standard-components/button")
.normalStyle({
background: "#000000",
@ -99,8 +111,14 @@ export function makeSaveButton(table, providerId) {
disabled: false,
onClick: [
{
"##eventHandlerType": "Validate Form",
parameters: {
providerId,
componentId: formId,
},
},
{
parameters: {
providerId: formId,
},
"##eventHandlerType": "Save Row",
},
@ -125,6 +143,7 @@ export function makeTitleContainer(title) {
"margin-left": "0px",
flex: "1 1 auto",
})
.customStyle(spectrumColor(900))
.type("h3")
.instanceName("Title")
.text(title)
@ -142,3 +161,44 @@ export function makeTitleContainer(title) {
.instanceName("Title Container")
.addChild(heading)
}
const fieldTypeToComponentMap = {
string: "stringfield",
number: "numberfield",
options: "optionsfield",
boolean: "booleanfield",
longform: "longformfield",
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
}
export function makeDatasourceFormComponents(datasource) {
const { schema } = getSchemaForDatasource(datasource, true)
let components = []
let fields = Object.keys(schema || {})
fields.forEach(field => {
const fieldSchema = schema[field]
const fieldType =
typeof fieldSchema === "object" ? fieldSchema.type : fieldSchema
const componentType = fieldTypeToComponentMap[fieldType]
const fullComponentType = `@budibase/standard-components/${componentType}`
if (componentType) {
const component = new Component(fullComponentType)
.instanceName(field)
.customProps({
field,
label: field,
placeholder: field,
})
if (fieldType === "options") {
component.customProps({ placeholder: "Choose an option " })
}
if (fieldType === "boolean") {
component.customProps({ text: field, label: "" })
}
components.push(component)
}
})
return components
}

View File

@ -59,8 +59,8 @@ export const findComponentPath = (rootComponent, id, path = []) => {
}
/**
* Recurses through the component tree and finds all components of a certain
* type.
* Recurses through the component tree and finds all components which match
* a certain selector
*/
export const findAllMatchingComponents = (rootComponent, selector) => {
if (!rootComponent || !selector) {
@ -81,6 +81,26 @@ export const findAllMatchingComponents = (rootComponent, selector) => {
return components.reverse()
}
/**
* Finds the closes parent component which matches certain criteria
*/
export const findClosestMatchingComponent = (
rootComponent,
componentId,
selector
) => {
if (!selector) {
return null
}
const componentPath = findComponentPath(rootComponent, componentId).reverse()
for (let component of componentPath) {
if (selector(component)) {
return component
}
}
return null
}
/**
* Recurses through a component tree evaluating a matching function against
* components until a match is found

View File

@ -2,7 +2,6 @@
import groupBy from "lodash/fp/groupBy"
import {
TextArea,
Label,
Input,
Heading,
Body,

View File

@ -36,7 +36,9 @@
{:else if type === 'boolean'}
<Toggle text={label} bind:checked={value} data-cy="{meta.name}-input" />
{:else if type === 'link'}
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
<div>
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
</div>
{:else if type === 'longform'}
<div>
<Label extraSmall grey>{label}</Label>

View File

@ -8,11 +8,16 @@
"name": "Form",
"icon": "ri-file-edit-line",
"children": [
"dataform",
"dataformwide",
"input",
"richtext",
"datepicker"
"form",
"fieldgroup",
"stringfield",
"numberfield",
"optionsfield",
"booleanfield",
"longformfield",
"datetimefield",
"attachmentfield",
"relationshipfield"
]
},
{
@ -56,8 +61,8 @@
"screenslot",
"navigation",
"login",
"rowdetail",
"newrow"
"rowdetail"
]
}
]
]

View File

@ -11,9 +11,6 @@
*, *:before, *:after {
box-sizing: border-box;
}
* {
pointer-events: none;
}
</style>
<script src='/assets/budibase-client.js'></script>
<script>

View File

@ -13,9 +13,8 @@
let dropdown
let anchor
$: noChildrenAllowed =
!component ||
!store.actions.components.getDefinition(component._component)?.hasChildren
$: definition = store.actions.components.getDefinition(component?._component)
$: noChildrenAllowed = !component || !definition?.hasChildren
$: noPaste = !$store.componentToPaste
const lastPartOfName = c => (c ? last(c._component.split("/")) : "")
@ -130,7 +129,7 @@
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you wish to delete this '${lastPartOfName(component)}' component?`}
body={`Are you sure you wish to delete this '${definition?.name}' component?`}
okText="Delete Component"
onOk={deleteComponent} />

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="attachment" />

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="boolean" />

View File

@ -19,6 +19,7 @@
let drawer
export let value = {}
export let otherSources
$: tables = $backendUiStore.tables.map(m => ({
label: m.name,
@ -88,7 +89,7 @@
class="dropdownbutton"
bind:this={anchorRight}
on:click={dropdownRight.show}>
<span>{value?.label ? value.label : 'Choose option'}</span>
<span>{value?.label ?? 'Choose option'}</span>
<Icon name="arrowdown" />
</div>
{#if value?.type === 'query'}
@ -175,6 +176,22 @@
</li>
{/each}
</ul>
{#if otherSources?.length}
<hr />
<div class="title">
<Heading extraSmall>Other</Heading>
</div>
<ul>
{#each otherSources as source}
<li
class:selected={value === source}
on:click={() => handleSelected(source)}>
{source.label}
</li>
{/each}
</ul>
{/if}
</div>
</DropdownMenu>

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="datetime" />

View File

@ -1,15 +1,6 @@
<script>
import {
Button,
Body,
DropdownMenu,
ModalContent,
Spacer,
} from "@budibase/bbui"
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import { Button, DropdownMenu, Spacer } from "@budibase/bbui"
import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
import { automationStore } from "builderStore"
const EVENT_TYPE_KEY = "##eventHandlerType"

View File

@ -15,8 +15,9 @@
)
$: {
// Automatically set rev and table ID based on row ID
if (parameters.rowId) {
parameters.revId = parameters.rowId.replace("_id", "_rev")
if (parameters.providerId) {
parameters.rowId = `{{ ${parameters.providerId}._id }}`
parameters.revId = `{{ ${parameters.providerId}._rev }}`
const providerComponent = dataProviderComponents.find(
provider => provider._id === parameters.providerId
)
@ -37,12 +38,10 @@
</div>
{:else}
<Label size="m" color="dark">Datasource</Label>
<Select secondary bind:value={parameters.rowId}>
<Select secondary bind:value={parameters.providerId}>
<option value="" />
{#each dataProviderComponents as provider}
<option value={`{{ ${provider._id}._id }}`}>
{provider._instanceName}
</option>
<option value={provider._id}>{provider._instanceName}</option>
{/each}
</Select>
{/if}

View File

@ -0,0 +1,37 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getDataProviderComponents } from "builderStore/dataBinding"
export let parameters
$: dataProviders = getDataProviderComponents(
$currentAsset.props,
$store.selectedComponentId
)
</script>
<div class="root">
<Label size="m" color="dark">Form</Label>
<Select secondary bind:value={parameters.componentId}>
<option value="" />
{#if dataProviders}
{#each dataProviders as component}
<option value={component._id}>{component._instanceName}</option>
{/each}
{/if}
</Select>
</div>
<style>
.root {
display: flex;
flex-direction: row;
align-items: baseline;
}
.root :global(> div) {
flex: 1;
margin-left: var(--spacing-l);
}
</style>

View File

@ -32,7 +32,7 @@
// this statement initialises fields from parameters.fields
$: fields =
fields ||
Object.keys(parameterFields || { "": "" }).map(name => ({
Object.keys(parameterFields || {}).map(name => ({
name,
value:
(parameterFields &&

View File

@ -0,0 +1,38 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding"
export let parameters
$: actionProviders = getActionProviderComponents(
$currentAsset.props,
$store.selectedComponentId,
"ValidateForm"
)
</script>
<div class="root">
<Label size="m" color="dark">Form</Label>
<Select secondary bind:value={parameters.componentId}>
<option value="" />
{#if actionProviders}
{#each actionProviders as component}
<option value={component._id}>{component._instanceName}</option>
{/each}
{/if}
</Select>
</div>
<style>
.root {
display: flex;
flex-direction: row;
align-items: baseline;
}
.root :global(> div) {
flex: 1;
margin-left: var(--spacing-l);
}
</style>

View File

@ -3,6 +3,8 @@ import SaveRow from "./SaveRow.svelte"
import DeleteRow from "./DeleteRow.svelte"
import ExecuteQuery from "./ExecuteQuery.svelte"
import TriggerAutomation from "./TriggerAutomation.svelte"
import ValidateForm from "./ValidateForm.svelte"
import RefreshDatasource from "./RefreshDatasource.svelte"
// defines what actions are available, when adding a new one
// the component is the setup panel for the action
@ -30,4 +32,12 @@ export default [
name: "Trigger Automation",
component: TriggerAutomation,
},
{
name: "Validate Form",
component: ValidateForm,
},
{
name: "Refresh Datasource",
component: RefreshDatasource,
},
]

View File

@ -0,0 +1,59 @@
<script>
import { DataList } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
export let componentInstance
export let value
export let onChange
export let type
$: form = findClosestMatchingComponent(
$currentAsset.props,
componentInstance._id,
component => component._component === "@budibase/standard-components/form"
)
$: datasource = getDatasourceForProvider(form)
$: schema = getSchemaForDatasource(datasource, true).schema
$: options = getOptions(schema, type)
const getOptions = (schema, fieldType) => {
let entries = Object.entries(schema ?? {})
if (fieldType) {
entries = entries.filter(entry => entry[1].type === fieldType)
}
return entries.map(entry => entry[0])
}
const handleBlur = () => onChange(value)
</script>
<div>
<DataList
editable
secondary
extraThin
on:blur={handleBlur}
on:change
bind:value>
<option value="" />
{#each options as option}
<option value={option}>{option}</option>
{/each}
</DataList>
</div>
<style>
div {
flex: 1 1 auto;
display: flex;
flex-direction: row;
}
div :global(> div) {
flex: 1 1 auto;
}
</style>

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="longform" />

View File

@ -0,0 +1,5 @@
<script>
import FieldSelect from "./FieldSelect.svelte"
</script>
<FieldSelect {...$$props} multiselect />

View File

@ -1,5 +0,0 @@
<script>
import TableViewFieldSelect from "./TableViewFieldSelect.svelte"
</script>
<TableViewFieldSelect {...$$props} multiselect />

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="number" />

View File

@ -106,7 +106,9 @@
}
$: displayLabel =
selectedOption && selectedOption.label ? selectedOption.label : value || ""
selectedOption && selectedOption.label
? selectedOption.label
: value || "Choose option"
</script>
<div
@ -129,11 +131,16 @@
on:keydown={handleEscape}
class="bb-select-menu">
<ul>
<li
on:click|self={() => handleClick(null)}
class:selected={value == null || value === ''}>
Choose option
</li>
{#if isOptionsObject}
{#each options as { value: v, label }}
<li
{...handleStyleBind(v)}
on:click|self={handleClick(v)}
on:click|self={() => handleClick(v)}
class:selected={value === v}>
{label}
</li>
@ -142,7 +149,7 @@
{#each options as v}
<li
{...handleStyleBind(v)}
on:click|self={handleClick(v)}
on:click|self={() => handleClick(v)}
class:selected={value === v}>
{v}
</li>

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="options" />

View File

@ -144,7 +144,7 @@
align-items: center;
display: flex;
box-sizing: border-box;
padding-left: var(--spacing-xs);
padding-left: 7px;
border-left: 1px solid var(--grey-4);
background-color: var(--grey-2);
border-top-right-radius: var(--border-radius-m);

View File

@ -25,7 +25,7 @@
<DetailSummary name={`${name}${changed ? ' *' : ''}`} on:open show={open} thin>
{#if open}
<div>
{#each properties as prop}
{#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)}
<PropertyControl
bindable={false}
label={`${prop.label}${hasPropChanged(style, prop) ? ' *' : ''}`}

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="link" />

View File

@ -0,0 +1,7 @@
<script>
import DatasourceSelect from "./DatasourceSelect.svelte"
const otherSources = [{ name: "Custom", label: "Custom" }]
</script>
<DatasourceSelect on:change {...$$props} {otherSources} />

View File

@ -0,0 +1,5 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} type="string" />

View File

@ -1,22 +1,35 @@
<script>
import { get } from "lodash"
import { isEmpty } from "lodash/fp"
import { Button } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
import PropertyControl from "./PropertyControls/PropertyControl.svelte"
import Input from "./PropertyControls/Input.svelte"
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
import RoleSelect from "./PropertyControls/RoleSelect.svelte"
import OptionSelect from "./PropertyControls/OptionSelect.svelte"
import MultiTableViewFieldSelect from "./PropertyControls/MultiTableViewFieldSelect.svelte"
import Checkbox from "./PropertyControls/Checkbox.svelte"
import TableSelect from "./PropertyControls/TableSelect.svelte"
import TableViewSelect from "./PropertyControls/TableViewSelect.svelte"
import TableViewFieldSelect from "./PropertyControls/TableViewFieldSelect.svelte"
import DatasourceSelect from "./PropertyControls/DatasourceSelect.svelte"
import FieldSelect from "./PropertyControls/FieldSelect.svelte"
import MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte"
import SchemaSelect from "./PropertyControls/SchemaSelect.svelte"
import EventsEditor from "./PropertyControls/EventsEditor"
import ScreenSelect from "./PropertyControls/ScreenSelect.svelte"
import DetailScreenSelect from "./PropertyControls/DetailScreenSelect.svelte"
import { IconSelect } from "./PropertyControls/IconSelect"
import ColorPicker from "./PropertyControls/ColorPicker.svelte"
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
import NumberFieldSelect from "./PropertyControls/NumberFieldSelect.svelte"
import OptionsFieldSelect from "./PropertyControls/OptionsFieldSelect.svelte"
import BooleanFieldSelect from "./PropertyControls/BooleanFieldSelect.svelte"
import LongFormFieldSelect from "./PropertyControls/LongFormFieldSelect.svelte"
import DateTimeFieldSelect from "./PropertyControls/DateTimeFieldSelect.svelte"
import AttachmentFieldSelect from "./PropertyControls/AttachmentFieldSelect.svelte"
import RelationshipFieldSelect from "./PropertyControls/RelationshipFieldSelect.svelte"
export let componentDefinition = {}
export let componentInstance = {}
@ -39,6 +52,7 @@
"layoutId",
"routing.roleId",
]
let confirmResetFieldsDialog
$: settings = componentDefinition?.settings ?? []
$: isLayout = assetInstance && assetInstance.favicon
@ -47,7 +61,7 @@
const controlMap = {
text: Input,
select: OptionSelect,
datasource: TableViewSelect,
datasource: DatasourceSelect,
screen: ScreenSelect,
detailScreen: DetailScreenSelect,
boolean: Checkbox,
@ -56,8 +70,17 @@
table: TableSelect,
color: ColorPicker,
icon: IconSelect,
field: TableViewFieldSelect,
multifield: MultiTableViewFieldSelect,
field: FieldSelect,
multifield: MultiFieldSelect,
schema: SchemaSelect,
"field/string": StringFieldSelect,
"field/number": NumberFieldSelect,
"field/options": OptionsFieldSelect,
"field/boolean": BooleanFieldSelect,
"field/longform": LongFormFieldSelect,
"field/datetime": DateTimeFieldSelect,
"field/attachment": AttachmentFieldSelect,
"field/link": RelationshipFieldSelect,
}
const getControl = type => {
@ -78,6 +101,20 @@
const onInstanceNameChange = name => {
onChange("_instanceName", name)
}
const resetFormFields = () => {
const form = findClosestMatchingComponent(
$currentAsset.props,
componentInstance._id,
component => component._component.endsWith("/form")
)
const datasource = form?.datasource
const fields = makeDatasourceFormComponents(datasource)
onChange(
"_children",
fields.map(field => field.json())
)
}
</script>
<div class="settings-view-container">
@ -114,7 +151,7 @@
value={componentInstance[setting.key] ?? componentInstance[setting.key]?.defaultValue}
{componentInstance}
onChange={val => onChange(setting.key, val)}
props={{ options: setting.options }} />
props={{ options: setting.options, placeholder: setting.placeholder }} />
{/if}
{/each}
{:else}
@ -122,7 +159,19 @@
This component doesn't have any additional settings.
</div>
{/if}
{#if componentDefinition?.component?.endsWith('/fieldgroup')}
<Button secondary wide on:click={() => confirmResetFieldsDialog?.show()}>
Reset Fields
</Button>
{/if}
</div>
<ConfirmDialog
bind:this={confirmResetFieldsDialog}
body={`All components inside this group will be deleted and replaced with fields to match the schema. Are you sure you want to reset this Field Group?`}
okText="Reset"
onOk={resetFormFields}
title="Confirm Reset Fields" />
<style>
.settings-view-container {

View File

@ -9,7 +9,6 @@ export const layout = [
key: "display",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Block", value: "block" },
{ label: "Inline Block", value: "inline-block" },
{ label: "Flex", value: "flex" },
@ -37,7 +36,6 @@ export const layout = [
key: "justify-content",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Flex Start", value: "flex-start" },
{ label: "Flex End", value: "flex-end" },
{ label: "Center", value: "center" },
@ -51,7 +49,6 @@ export const layout = [
key: "align-items",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Flex Start", value: "flex-start" },
{ label: "Flex End", value: "flex-end" },
{ label: "Center", value: "center" },
@ -64,7 +61,6 @@ export const layout = [
key: "flex-wrap",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Wrap", value: "wrap" },
{ label: "No wrap", value: "nowrap" },
],
@ -74,7 +70,6 @@ export const layout = [
key: "gap",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -93,7 +88,6 @@ export const margin = [
key: "margin",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -113,7 +107,6 @@ export const margin = [
key: "margin-top",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -133,7 +126,6 @@ export const margin = [
key: "margin-right",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -153,7 +145,6 @@ export const margin = [
key: "margin-bottom",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -173,7 +164,6 @@ export const margin = [
key: "margin-left",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -196,7 +186,6 @@ export const padding = [
key: "padding",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -214,7 +203,6 @@ export const padding = [
key: "padding-top",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -232,7 +220,6 @@ export const padding = [
key: "padding-right",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -250,7 +237,6 @@ export const padding = [
key: "padding-bottom",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -268,7 +254,6 @@ export const padding = [
key: "padding-left",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
@ -289,7 +274,6 @@ export const size = [
key: "flex",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Shrink", value: "0 1 auto" },
{ label: "Grow", value: "1 1 auto" },
],
@ -338,7 +322,6 @@ export const position = [
key: "position",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Static", value: "static" },
{ label: "Relative", value: "relative" },
{ label: "Fixed", value: "fixed" },
@ -375,7 +358,6 @@ export const position = [
key: "z-index",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "-9999", value: "-9999" },
{ label: "-3", value: "-3" },
{ label: "-2", value: "-2" },
@ -395,7 +377,6 @@ export const typography = [
key: "font-family",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Arial", value: "Arial" },
{ label: "Arial Black", value: "Arial Black" },
{ label: "Cursive", value: "Cursive" },
@ -418,7 +399,6 @@ export const typography = [
key: "font-weight",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "200", value: "200" },
{ label: "300", value: "300" },
{ label: "400", value: "400" },
@ -434,7 +414,6 @@ export const typography = [
key: "font-size",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "8px", value: "8px" },
{ label: "10px", value: "10px" },
{ label: "12px", value: "12px" },
@ -454,7 +433,6 @@ export const typography = [
key: "line-height",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "1", value: "1" },
{ label: "1.25", value: "1.25" },
{ label: "1.5", value: "1.5" },
@ -496,7 +474,6 @@ export const typography = [
key: "text-decoration-line",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Underline", value: "underline" },
{ label: "Overline", value: "overline" },
{ label: "Line-through", value: "line-through" },
@ -516,7 +493,6 @@ export const background = [
key: "background-image",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "none" },
{
label: "Warm Flame",
@ -603,7 +579,6 @@ export const border = [
key: "border-radius",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0" },
{ label: "X Small", value: "0.125rem" },
{ label: "Small", value: "0.25rem" },
@ -619,7 +594,6 @@ export const border = [
key: "border-width",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0" },
{ label: "X Small", value: "0.5px" },
{ label: "Small", value: "1px" },
@ -638,7 +612,6 @@ export const border = [
key: "border-style",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "none" },
{ label: "Hidden", value: "hidden" },
{ label: "Dotted", value: "dotted" },
@ -659,7 +632,6 @@ export const effects = [
key: "opacity",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "0", value: "0" },
{ label: "0.2", value: "0.2" },
{ label: "0.4", value: "0.4" },
@ -673,7 +645,6 @@ export const effects = [
key: "transform",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "0" },
{ label: "45 deg", value: "rotate(45deg)" },
{ label: "90 deg", value: "rotate(90deg)" },
@ -690,7 +661,6 @@ export const effects = [
key: "box-shadow",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "none" },
{ label: "X Small", value: "0 1px 2px 0 rgba(0, 0, 0, 0.05)" },
{
@ -723,7 +693,6 @@ export const transitions = [
key: "transition-property",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "None", value: "none" },
{ label: "All", value: "all" },
{ label: "Background Color", value: "background color" },
@ -745,7 +714,6 @@ export const transitions = [
control: OptionSelect,
placeholder: "sec",
options: [
{ label: "Choose option", value: "" },
{ label: "0.4s", value: "0.4s" },
{ label: "0.6s", value: "0.6s" },
{ label: "0.8s", value: "0.8s" },
@ -759,7 +727,6 @@ export const transitions = [
key: "transition-timing-function",
control: OptionSelect,
options: [
{ label: "Choose option", value: "" },
{ label: "Linear", value: "linear" },
{ label: "Ease", value: "ease" },
{ label: "Ease in", value: "ease-in" },

View File

@ -842,10 +842,10 @@
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@budibase/bbui@^1.58.3":
version "1.58.3"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.3.tgz#86ad6aa68eec7426e1ccdf1f7e7fc957cb57d3a3"
integrity sha512-PpbxfBhVpmP0EO1nPBhrz486EHCIgtJlXELC/ElzjG+FCQZSCvDSM7mq/97FOW35iYdTiQTlwFgOtvOgT1P8IQ==
"@budibase/bbui@^1.58.5":
version "1.58.5"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.5.tgz#c9ce712941760825c7774a1de77594e989db4561"
integrity sha512-0j1I7BetJ2GzB1BXKyvvlkuFphLmADJh2U/Ihubwxx5qUDY8REoVzLgAB4c24zt0CGVTF9VMmOoMLd0zD0QwdQ==
dependencies:
markdown-it "^12.0.2"
quill "^1.3.7"

View File

@ -18,14 +18,12 @@ const handleError = error => {
const makeApiCall = async ({ method, url, body, json = true }) => {
try {
const requestBody = json ? JSON.stringify(body) : body
let headers = {
const inBuilder = window["##BUDIBASE_IN_BUILDER##"]
const headers = {
Accept: "application/json",
...(json && { "Content-Type": "application/json" }),
"x-budibase-app-id": window["##BUDIBASE_APP_ID##"],
}
if (!window["##BUDIBASE_IN_BUILDER##"]) {
headers["x-budibase-type"] = "client"
...(json && { "Content-Type": "application/json" }),
...(!inBuilder && { "x-budibase-type": "client" }),
}
const response = await fetch(url, {
method,

View File

@ -1,14 +1,8 @@
import { cloneDeep } from "lodash/fp"
import { fetchTableData, searchTableData } from "./tables"
import { fetchTableData } from "./tables"
import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships"
import { executeQuery } from "./queries"
import { enrichRows } from "./rows"
export const searchTable = async ({ tableId, search, pagination }) => {
const rows = await searchTableData({ tableId, search, pagination })
return await enrichRows(rows, tableId)
}
/**
* Fetches all rows for a particular Budibase data source.
@ -33,7 +27,7 @@ export const fetchDatasource = async datasource => {
parameters[param.name] = param.default
}
}
return await executeQuery({ queryId: datasource._id, parameters })
rows = await executeQuery({ queryId: datasource._id, parameters })
} else if (type === "link") {
rows = await fetchRelationshipData({
rowId: datasource.rowId,
@ -42,6 +36,6 @@ export const fetchDatasource = async datasource => {
})
}
// Enrich rows so they can displayed properly
return await enrichRows(rows, tableId)
// Enrich the result is always an array
return Array.isArray(rows) ? rows : []
}

View File

@ -1,10 +1,15 @@
import { notificationStore } from "../store/notification"
import { notificationStore, datasourceStore } from "../store"
import API from "./api"
/**
* Executes a query against an external data connector.
*/
export const executeQuery = async ({ queryId, parameters }) => {
const query = await API.get({ url: `/api/queries/${queryId}` })
if (query?.datasourceId == null) {
notificationStore.danger("That query couldn't be found")
return
}
const res = await API.post({
url: `/api/queries/${queryId}`,
body: {
@ -13,6 +18,9 @@ export const executeQuery = async ({ queryId, parameters }) => {
})
if (res.error) {
notificationStore.danger("An error has occurred")
} else if (!query.readable) {
notificationStore.success("Query executed successfully")
datasourceStore.actions.invalidateDatasource(query.datasourceId)
}
return res
}

View File

@ -1,4 +1,4 @@
import { notificationStore } from "../store/notification"
import { notificationStore, datasourceStore } from "../store"
import API from "./api"
import { fetchTableDefinition } from "./tables"
@ -6,6 +6,9 @@ import { fetchTableDefinition } from "./tables"
* Fetches data about a certain row in a table.
*/
export const fetchRow = async ({ tableId, rowId }) => {
if (!tableId || !rowId) {
return
}
const row = await API.get({
url: `/api/${tableId}/rows/${rowId}`,
})
@ -16,6 +19,9 @@ export const fetchRow = async ({ tableId, rowId }) => {
* Creates a row in a table.
*/
export const saveRow = async row => {
if (!row?.tableId) {
return
}
const res = await API.post({
url: `/api/${row.tableId}/rows`,
body: row,
@ -23,6 +29,10 @@ export const saveRow = async row => {
res.error
? notificationStore.danger("An error has occurred")
: notificationStore.success("Row saved")
// Refresh related datasources
datasourceStore.actions.invalidateDatasource(row.tableId)
return res
}
@ -30,6 +40,9 @@ export const saveRow = async row => {
* Updates a row in a table.
*/
export const updateRow = async row => {
if (!row?.tableId || !row?._id) {
return
}
const res = await API.patch({
url: `/api/${row.tableId}/rows/${row._id}`,
body: row,
@ -37,6 +50,10 @@ export const updateRow = async row => {
res.error
? notificationStore.danger("An error has occurred")
: notificationStore.success("Row updated")
// Refresh related datasources
datasourceStore.actions.invalidateDatasource(row.tableId)
return res
}
@ -44,12 +61,19 @@ export const updateRow = async row => {
* Deletes a row from a table.
*/
export const deleteRow = async ({ tableId, rowId, revId }) => {
if (!tableId || !rowId || !revId) {
return
}
const res = await API.del({
url: `/api/${tableId}/rows/${rowId}/${revId}`,
})
res.error
? notificationStore.danger("An error has occurred")
: notificationStore.success("Row deleted")
// Refresh related datasources
datasourceStore.actions.invalidateDatasource(tableId)
return res
}
@ -57,6 +81,9 @@ export const deleteRow = async ({ tableId, rowId, revId }) => {
* Deletes many rows from a table.
*/
export const deleteRows = async ({ tableId, rows }) => {
if (!tableId || !rows) {
return
}
const res = await API.post({
url: `/api/${tableId}/rows`,
body: {
@ -67,6 +94,10 @@ export const deleteRows = async ({ tableId, rows }) => {
res.error
? notificationStore.danger("An error has occurred")
: notificationStore.success(`${rows.length} row(s) deleted`)
// Refresh related datasources
datasourceStore.actions.invalidateDatasource(tableId)
return res
}
@ -75,7 +106,10 @@ export const deleteRows = async ({ tableId, rows }) => {
* be properly displayed.
*/
export const enrichRows = async (rows, tableId) => {
if (rows && rows.length && tableId) {
if (!Array.isArray(rows)) {
return []
}
if (rows.length && tableId) {
// Fetch table schema so we can check column types
const tableDefinition = await fetchTableDefinition(tableId)
const schema = tableDefinition && tableDefinition.schema

View File

@ -3,14 +3,19 @@
import { setContext, onMount } from "svelte"
import Component from "./Component.svelte"
import NotificationDisplay from "./NotificationDisplay.svelte"
import Provider from "./Provider.svelte"
import SDK from "../sdk"
import { createDataStore, initialise, screenStore, authStore } from "../store"
import {
createContextStore,
initialise,
screenStore,
authStore,
} from "../store"
// Provide contexts
setContext("sdk", SDK)
setContext("component", writable({}))
setContext("data", createDataStore())
setContext("screenslot", false)
setContext("context", createContextStore())
let loaded = false
@ -23,6 +28,8 @@
</script>
{#if loaded && $screenStore.activeLayout}
<Component definition={$screenStore.activeLayout.props} />
<Provider key="user" data={$authStore}>
<Component definition={$screenStore.activeLayout.props} />
<NotificationDisplay />
</Provider>
{/if}
<NotificationDisplay />

View File

@ -1,19 +1,28 @@
<script>
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import * as ComponentLibrary from "@budibase/standard-components"
import Router from "./Router.svelte"
import { enrichProps, propsAreSame } from "../utils/componentProps"
import { authStore, bindingStore, builderStore } from "../store"
import { builderStore } from "../store"
import { hashString } from "../utils/hash"
export let definition = {}
let enrichedProps
// Props that will be passed to the component instance
let componentProps
// Props are hashed when inside the builder preview and used as a key, so that
// components fully remount whenever any props change
let propsHash = 0
// Latest timestamp that we started a props update.
// Due to enrichment now being async, we need to avoid overwriting newer
// props with old ones, depending on how long enrichment takes.
let latestUpdateTime
// Get contexts
const dataContext = getContext("data")
const screenslotContext = getContext("screenslot")
const context = getContext("context")
// Create component context
const componentStore = writable({})
@ -23,39 +32,16 @@
$: constructor = getComponentConstructor(definition._component)
$: children = definition._children || []
$: id = definition._id
$: enrichComponentProps(definition, $dataContext, $bindingStore, $authStore)
$: updateProps(enrichedProps)
$: updateComponentProps(definition, $context)
$: styles = definition._styles
// Allow component selection in the builder preview if we're previewing a
// layout, or we're preview a screen and we're inside the screenslot
$: allowSelection =
$builderStore.previewType === "layout" || screenslotContext
// Update component context
$: componentStore.set({
id,
children: children.length,
styles: { ...styles, id, allowSelection },
styles: { ...styles, id },
})
// Updates the component props.
// Most props are deeply compared so that svelte will only trigger reactive
// statements on props that have actually changed.
const updateProps = props => {
if (!props) {
return
}
if (!componentProps) {
componentProps = {}
}
Object.keys(props).forEach(key => {
if (!propsAreSame(props[key], componentProps[key])) {
componentProps[key] = props[key]
}
})
}
// Gets the component constructor for the specified component
const getComponentConstructor = component => {
const split = component?.split("/")
@ -67,25 +53,53 @@
}
// Enriches any string component props using handlebars
const enrichComponentProps = async (definition, context, bindings, user) => {
enrichedProps = await enrichProps(definition, context, bindings, user)
}
const updateComponentProps = async (definition, context) => {
// Record the timestamp so we can reference it after enrichment
latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime
// Returns a unique key to let svelte know when to remount components.
// If a component is selected we want to remount it every time any props
// change.
const getChildKey = childId => {
const selected = childId === $builderStore.selectedComponentId
return selected ? `${childId}-${$builderStore.previewId}` : childId
// Enrich props with context
const enrichedProps = await enrichProps(definition, context)
// Abandon this update if a newer update has started
if (enrichmentTime !== latestUpdateTime) {
return
}
// Update the component props.
// Most props are deeply compared so that svelte will only trigger reactive
// statements on props that have actually changed.
if (!enrichedProps) {
return
}
let propsChanged = false
if (!componentProps) {
componentProps = {}
propsChanged = true
}
Object.keys(enrichedProps).forEach(key => {
if (!propsAreSame(enrichedProps[key], componentProps[key])) {
propsChanged = true
componentProps[key] = enrichedProps[key]
}
})
// Update the hash if we're in the builder so we can fully remount this
// component
if (get(builderStore).inBuilder && propsChanged) {
propsHash = hashString(JSON.stringify(componentProps))
}
}
</script>
{#if constructor && componentProps}
<svelte:component this={constructor} {...componentProps}>
{#if children.length}
{#each children as child (getChildKey(child._id))}
<svelte:self definition={child} />
{/each}
{/if}
</svelte:component>
{#key propsHash}
<svelte:component this={constructor} {...componentProps}>
{#if children.length}
{#each children as child (child._id)}
<svelte:self definition={child} />
{/each}
{/if}
</svelte:component>
{/key}
{/if}

View File

@ -1,15 +0,0 @@
<script>
import { getContext, setContext } from "svelte"
import { createDataStore } from "../store"
export let row
// Clone and create new data context for this component tree
const dataContext = getContext("data")
const component = getContext("component")
const newData = createDataStore($dataContext)
setContext("data", newData)
$: newData.actions.addContext(row, $component.id)
</script>
<slot />

View File

@ -0,0 +1,55 @@
<script>
import { getContext, setContext, onMount } from "svelte"
import { datasourceStore, createContextStore } from "../store"
import { ActionTypes } from "../constants"
import { generate } from "shortid"
export let data
export let actions
export let key
// Clone and create new data context for this component tree
const context = getContext("context")
const component = getContext("component")
const newContext = createContextStore(context)
setContext("context", newContext)
$: providerKey = key || $component.id
// Add data context
$: newContext.actions.provideData(providerKey, data)
// Instance ID is unique to each instance of a provider
let instanceId
// Add actions context
$: {
if (instanceId) {
actions?.forEach(({ type, callback, metadata }) => {
newContext.actions.provideAction(providerKey, type, callback)
// Register any "refresh datasource" actions with a singleton store
// so we can easily refresh data at all levels for any datasource
if (type === ActionTypes.RefreshDatasource) {
const { datasource } = metadata || {}
datasourceStore.actions.registerDatasource(
datasource,
instanceId,
callback
)
}
})
}
}
onMount(() => {
// Generate a permanent unique ID for this component and use it to register
// any datasource actions
instanceId = generate()
// Unregister all datasource instances when unmounting this provider
return () => datasourceStore.actions.unregisterInstance(instanceId)
})
</script>
<slot />

View File

@ -7,9 +7,6 @@
const { styleable } = getContext("sdk")
const component = getContext("component")
// Set context flag so components know that we're now inside the screenslot
setContext("screenslot", true)
// Only wrap this as an array to take advantage of svelte keying,
// to ensure the svelte-spa-router is fully remounted when route config
// changes

View File

@ -1,3 +1,8 @@
export const TableNames = {
USERS: "ta_users",
}
export const ActionTypes = {
ValidateForm: "ValidateForm",
RefreshDatasource: "RefreshDatasource",
}

View File

@ -4,12 +4,12 @@ import {
notificationStore,
routeStore,
screenStore,
bindingStore,
builderStore,
} from "./store"
import { styleable } from "./utils/styleable"
import { linkable } from "./utils/linkable"
import DataProvider from "./components/DataProvider.svelte"
import Provider from "./components/Provider.svelte"
import { ActionTypes } from "./constants"
export default {
API,
@ -20,6 +20,6 @@ export default {
builderStore,
styleable,
linkable,
DataProvider,
setBindableValue: bindingStore.actions.setBindableValue,
Provider,
ActionTypes,
}

View File

@ -1,21 +0,0 @@
import { writable } from "svelte/store"
const createBindingStore = () => {
const store = writable({})
const setBindableValue = (value, componentId) => {
store.update(state => {
if (componentId) {
state[componentId] = value
}
return state
})
}
return {
subscribe: store.subscribe,
actions: { setBindableValue },
}
}
export const bindingStore = createBindingStore()

View File

@ -13,9 +13,11 @@ const createBuilderStore = () => {
const store = writable(initialState)
const actions = {
selectComponent: id => {
window.dispatchEvent(
new CustomEvent("bb-select-component", { detail: id })
)
if (id) {
window.dispatchEvent(
new CustomEvent("bb-select-component", { detail: id })
)
}
},
}
return {

View File

@ -0,0 +1,42 @@
import { writable, derived } from "svelte/store"
export const createContextStore = oldContext => {
const newContext = writable({})
const contexts = oldContext ? [oldContext, newContext] : [newContext]
const totalContext = derived(contexts, $contexts => {
return $contexts.reduce((total, context) => ({ ...total, ...context }), {})
})
// Adds a data context layer to the tree
const provideData = (providerId, data) => {
if (!providerId || data === undefined) {
return
}
newContext.update(state => {
state[providerId] = data
// Keep track of the closest component ID so we can later hydrate a "data" prop.
// This is only required for legacy bindings that used "data" rather than a
// component ID.
state.closestComponentId = providerId
return state
})
}
// Adds an action context layer to the tree
const provideAction = (providerId, actionType, callback) => {
if (!providerId || !actionType) {
return
}
newContext.update(state => {
state[`${providerId}_${actionType}`] = callback
return state
})
}
return {
subscribe: totalContext.subscribe,
actions: { provideData, provideAction },
}
}

View File

@ -1,26 +0,0 @@
import { writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
export const createDataStore = existingContext => {
const store = writable({ ...existingContext })
// Adds a context layer to the data context tree
const addContext = (row, componentId) => {
store.update(state => {
if (componentId) {
state[componentId] = row
state[`${componentId}_draft`] = cloneDeep(row)
state.closestComponentId = componentId
}
return state
})
}
return {
subscribe: store.subscribe,
update: store.update,
actions: { addContext },
}
}
export const dataStore = createDataStore()

View File

@ -0,0 +1,84 @@
import { writable, get } from "svelte/store"
import { notificationStore } from "./notification"
export const createDatasourceStore = () => {
const store = writable([])
// Registers a new datasource instance
const registerDatasource = (datasource, instanceId, refresh) => {
if (!datasource || !instanceId || !refresh) {
return
}
// Create a list of all relevant datasource IDs which would require that
// this datasource is refreshed
let datasourceIds = []
// Extract table ID
if (datasource.type === "table") {
if (datasource.tableId) {
datasourceIds.push(datasource.tableId)
}
}
// Extract both table IDs from both sides of the relationship
else if (datasource.type === "link") {
if (datasource.rowTableId) {
datasourceIds.push(datasource.rowTableId)
}
if (datasource.tableId) {
datasourceIds.push(datasource.tableId)
}
}
// Extract the datasource ID (not the query ID) for queries
else if (datasource.type === "query") {
if (datasource.datasourceId) {
datasourceIds.push(datasource.datasourceId)
}
}
// Store configs for each relevant datasource ID
if (datasourceIds.length) {
store.update(state => {
datasourceIds.forEach(id => {
state.push({
datasourceId: id,
instanceId,
refresh,
})
})
return state
})
}
}
// Removes all registered datasource instances belonging to a particular
// instance ID
const unregisterInstance = instanceId => {
store.update(state => {
return state.filter(instance => instance.instanceId !== instanceId)
})
}
// Invalidates a specific datasource ID by refreshing all instances
// which depend on data from that datasource
const invalidateDatasource = datasourceId => {
const relatedInstances = get(store).filter(instance => {
return instance.datasourceId === datasourceId
})
if (relatedInstances?.length) {
notificationStore.blockNotifications(1000)
}
relatedInstances?.forEach(instance => {
instance.refresh()
})
}
return {
subscribe: store.subscribe,
actions: { registerDatasource, unregisterInstance, invalidateDatasource },
}
}
export const datasourceStore = createDatasourceStore()

View File

@ -3,10 +3,10 @@ export { notificationStore } from "./notification"
export { routeStore } from "./routes"
export { screenStore } from "./screens"
export { builderStore } from "./builder"
export { bindingStore } from "./binding"
export { datasourceStore } from "./datasource"
// Data stores are layered and duplicated, so it is not a singleton
export { createDataStore, dataStore } from "./data"
// Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context"
// Initialises an app by loading screens and routes
export { initialise } from "./initialise"

View File

@ -5,13 +5,22 @@ const NOTIFICATION_TIMEOUT = 3000
const createNotificationStore = () => {
const _notifications = writable([])
let block = false
const send = (message, type = "default") => {
if (block) {
return
}
_notifications.update(state => {
return [...state, { id: generate(), type, message }]
})
}
const blockNotifications = (timeout = 1000) => {
block = true
setTimeout(() => (block = false), timeout)
}
const notifications = derived(_notifications, ($_notifications, set) => {
set($_notifications)
if ($_notifications.length > 0) {
@ -36,6 +45,7 @@ const createNotificationStore = () => {
warning: msg => send(msg, "warning"),
info: msg => send(msg, "info"),
success: msg => send(msg, "success"),
blockNotifications,
}
}

View File

@ -1,43 +1,34 @@
import { get } from "svelte/store"
import { enrichDataBinding, enrichDataBindings } from "./enrichDataBinding"
import { routeStore, builderStore } from "../store"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "../api"
import { ActionTypes } from "../constants"
const saveRowHandler = async (action, context) => {
const { fields, providerId } = action.parameters
if (providerId) {
let draft = context[`${providerId}_draft`]
let draft = context[providerId]
if (fields) {
for (let [key, entry] of Object.entries(fields)) {
draft[key] = await enrichDataBinding(entry.value, context)
draft[key] = entry.value
}
}
await saveRow(draft)
}
}
const deleteRowHandler = async (action, context) => {
const deleteRowHandler = async action => {
const { tableId, revId, rowId } = action.parameters
if (tableId && revId && rowId) {
const [enrichTable, enrichRow, enrichRev] = await Promise.all([
enrichDataBinding(tableId, context),
enrichDataBinding(rowId, context),
enrichDataBinding(revId, context),
])
await deleteRow({
tableId: enrichTable,
rowId: enrichRow,
revId: enrichRev,
})
await deleteRow({ tableId, rowId, revId })
}
}
const triggerAutomationHandler = async (action, context) => {
const { fields } = action.parameters()
const triggerAutomationHandler = async action => {
const { fields } = action.parameters
if (fields) {
const params = {}
for (let field in fields) {
params[field] = await enrichDataBinding(fields[field].value, context)
params[field] = fields[field].value
}
await triggerAutomation(action.parameters.automationId, params)
}
@ -49,25 +40,46 @@ const navigationHandler = action => {
}
}
const queryExecutionHandler = async (action, context) => {
const queryExecutionHandler = async action => {
const { datasourceId, queryId, queryParams } = action.parameters
const enrichedQueryParameters = await enrichDataBindings(
queryParams || {},
context
)
await executeQuery({
datasourceId,
queryId,
parameters: enrichedQueryParameters,
parameters: queryParams,
})
}
const executeActionHandler = async (context, componentId, actionType) => {
const fn = context[`${componentId}_${actionType}`]
if (fn) {
return await fn()
}
}
const validateFormHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ValidateForm
)
}
const refreshDatasourceHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.RefreshDatasource
)
}
const handlerMap = {
["Save Row"]: saveRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
["Execute Query"]: queryExecutionHandler,
["Trigger Automation"]: triggerAutomationHandler,
["Validate Form"]: validateFormHandler,
["Refresh Datasource"]: refreshDatasourceHandler,
}
/**
@ -82,7 +94,18 @@ export const enrichButtonActions = (actions, context) => {
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => {
for (let i = 0; i < handlers.length; i++) {
await handlers[i](actions[i], context)
try {
const result = await handlers[i](actions[i], context)
// A handler returning `false` is a flag to stop execution of handlers
if (result === false) {
return
}
} catch (error) {
console.error("Error while executing button handler")
console.error(error)
// Stop executing on an error
return
}
}
}
}

View File

@ -21,7 +21,7 @@ export const propsAreSame = (a, b) => {
* Enriches component props.
* Data bindings are enriched, and button actions are enriched.
*/
export const enrichProps = async (props, dataContexts, dataBindings, user) => {
export const enrichProps = async (props, context) => {
// Exclude all private props that start with an underscore
let validProps = {}
Object.entries(props)
@ -32,20 +32,23 @@ export const enrichProps = async (props, dataContexts, dataBindings, user) => {
// Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires
const context = {
...dataContexts,
...dataBindings,
user,
data: dataContexts[dataContexts.closestComponentId],
data_draft: dataContexts[`${dataContexts.closestComponentId}_draft`],
const totalContext = {
...context,
// This is only required for legacy bindings that used "data" rather than a
// component ID.
data: context[context.closestComponentId],
}
// Enrich all data bindings in top level props
let enrichedProps = await enrichDataBindings(validProps, context)
let enrichedProps = await enrichDataBindings(validProps, totalContext)
// Enrich button actions if they exist
if (props._component.endsWith("/button") && enrichedProps.onClick) {
enrichedProps.onClick = enrichButtonActions(enrichedProps.onClick, context)
enrichedProps.onClick = enrichButtonActions(
enrichedProps.onClick,
totalContext
)
}
return enrichedProps

View File

@ -0,0 +1,12 @@
export const hashString = str => {
if (!str) {
return 0
}
let hash = 0
for (let i = 0; i < str.length; i++) {
let char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash // Convert to 32bit integer
}
return hash
}

View File

@ -1,15 +1,12 @@
import { get } from "svelte/store"
import { builderStore } from "../store"
const selectedComponentWidth = 2
const selectedComponentColor = "#4285f4"
/**
* Helper to build a CSS string from a style object.
*/
const buildStyleString = (styleObject, customStyles) => {
let str = ""
Object.entries(styleObject).forEach(([style, value]) => {
Object.entries(styleObject || {}).forEach(([style, value]) => {
if (style && value != null) {
str += `${style}: ${value}; `
}
@ -23,24 +20,14 @@ const buildStyleString = (styleObject, customStyles) => {
* events for any selectable components (overriding the blanket ban on pointer
* events in the iframe HTML).
*/
const addBuilderPreviewStyles = (styleString, componentId, selectable) => {
let str = styleString
// Apply extra styles if we're in the builder preview
const state = get(builderStore)
if (state.inBuilder) {
// Allow pointer events and always enable cursor
if (selectable) {
str += ";pointer-events: all !important; cursor: pointer !important;"
}
// Highlighted selected element
if (componentId === state.selectedComponentId) {
str += `;border: ${selectedComponentWidth}px solid ${selectedComponentColor} !important;`
}
const addBuilderPreviewStyles = (node, styleString, componentId) => {
if (componentId === get(builderStore).selectedComponentId) {
const style = window.getComputedStyle(node)
const property = style?.display === "table-row" ? "outline" : "border"
return styleString + `;${property}: 2px solid #4285f4 !important;`
} else {
return styleString
}
return str
}
/**
@ -52,28 +39,20 @@ export const styleable = (node, styles = {}) => {
let applyHoverStyles
let selectComponent
// Kill JS even bubbling
const blockEvent = event => {
event.preventDefault()
event.stopPropagation()
return false
}
// Creates event listeners and applies initial styles
const setupStyles = newStyles => {
const setupStyles = (newStyles = {}) => {
const componentId = newStyles.id
const selectable = newStyles.allowSelection
const customStyles = newStyles.custom
const normalStyles = newStyles.normal
const customStyles = newStyles.custom || ""
const normalStyles = newStyles.normal || {}
const hoverStyles = {
...normalStyles,
...newStyles.hover,
...(newStyles.hover || {}),
}
// Applies a style string to a DOM node, enriching it for the builder
// preview
// Applies a style string to a DOM node
const applyStyles = styleString => {
node.style = addBuilderPreviewStyles(styleString, componentId, selectable)
node.style = addBuilderPreviewStyles(node, styleString, componentId)
node.dataset.componentId = componentId
}
// Applies the "normal" style definition
@ -89,8 +68,10 @@ export const styleable = (node, styles = {}) => {
// Handler to select a component in the builder when clicking it in the
// builder preview
selectComponent = event => {
builderStore.actions.selectComponent(newStyles.id)
return blockEvent(event)
builderStore.actions.selectComponent(componentId)
event.preventDefault()
event.stopPropagation()
return false
}
// Add listeners to toggle hover styles
@ -100,10 +81,6 @@ export const styleable = (node, styles = {}) => {
// Add builder preview click listener
if (get(builderStore).inBuilder) {
node.addEventListener("click", selectComponent, false)
// Kill other interaction events
node.addEventListener("mousedown", blockEvent)
node.addEventListener("mouseup", blockEvent)
}
// Apply initial normal styles
@ -118,8 +95,6 @@ export const styleable = (node, styles = {}) => {
// Remove builder preview click listener
if (get(builderStore).inBuilder) {
node.removeEventListener("click", selectComponent)
node.removeEventListener("mousedown", blockEvent)
node.removeEventListener("mouseup", blockEvent)
}
}

View File

@ -2,6 +2,20 @@ const { processString } = require("@budibase/string-templates")
const CouchDB = require("../../db")
const { generateQueryID, getQueryParams } = require("../../db/utils")
const { integrations } = require("../../integrations")
const { BaseQueryVerbs } = require("../../constants")
const env = require("../../environment")
// simple function to append "readable" to all read queries
function enrichQueries(input) {
const wasArray = Array.isArray(input)
const queries = wasArray ? input : [input]
for (let query of queries) {
if (query.queryVerb === BaseQueryVerbs.READ) {
query.readable = true
}
}
return wasArray ? queries : queries[0]
}
function formatResponse(resp) {
if (typeof resp === "string") {
@ -21,7 +35,7 @@ exports.fetch = async function(ctx) {
include_docs: true,
})
)
ctx.body = body.rows.map(row => row.doc)
ctx.body = enrichQueries(body.rows.map(row => row.doc))
}
exports.save = async function(ctx) {
@ -61,6 +75,18 @@ async function enrichQueryFields(fields, parameters) {
return enrichedQuery
}
exports.find = async function(ctx) {
const db = new CouchDB(ctx.user.appId)
const query = enrichQueries(await db.get(ctx.params.queryId))
// remove properties that could be dangerous in real app
if (env.CLOUD) {
delete query.fields
delete query.parameters
delete query.schema
}
ctx.body = query
}
exports.preview = async function(ctx) {
const db = new CouchDB(ctx.user.appId)

View File

@ -16,13 +16,6 @@ const {
const router = Router()
const QueryVerb = {
Create: "create",
Read: "read",
Update: "update",
Delete: "delete",
}
function generateQueryValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
@ -36,7 +29,7 @@ function generateQueryValidation() {
name: Joi.string(),
default: Joi.string()
})),
queryVerb: Joi.string().allow(...Object.values(QueryVerb)).required(),
queryVerb: Joi.string().allow().required(),
schema: Joi.object({}).required().unknown(true)
}))
}
@ -45,7 +38,7 @@ function generateQueryPreviewValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
fields: Joi.object().required(),
queryVerb: Joi.string().allow(...Object.values(QueryVerb)).required(),
queryVerb: Joi.string().allow().required(),
datasourceId: Joi.string().required(),
parameters: Joi.object({}).required().unknown(true)
}))
@ -67,6 +60,11 @@ router
generateQueryPreviewValidation(),
queryController.preview
)
.get(
"/api/queries/:queryId",
authorized(PermissionTypes.QUERY, PermissionLevels.READ),
queryController.find
)
.post(
"/api/queries/:queryId",
paramResource("queryId"),

View File

@ -1,10 +1,10 @@
const {
const {
supertest,
createApplication,
defaultHeaders,
builderEndpointShouldBlockNormalUsers,
getDocument,
insertDocument
insertDocument,
} = require("./couchTestUtils")
let { generateDatasourceID, generateQueryID } = require("../../../db/utils")
@ -21,11 +21,11 @@ const TEST_DATASOURCE = {
const TEST_QUERY = {
_id: generateQueryID(DATASOURCE_ID),
datasourceId: DATASOURCE_ID,
name:"New Query",
parameters:[],
fields:{},
schema:{},
queryVerb:"read",
name: "New Query",
parameters: [],
fields: {},
schema: {},
queryVerb: "read",
}
describe("/queries", () => {
@ -37,8 +37,8 @@ describe("/queries", () => {
let query
beforeAll(async () => {
({ request, server } = await supertest())
});
;({ request, server } = await supertest())
})
afterAll(() => {
server.close()
@ -47,7 +47,7 @@ describe("/queries", () => {
beforeEach(async () => {
app = await createApplication(request)
appId = app.instance._id
});
})
async function createDatasource() {
return await insertDocument(appId, TEST_DATASOURCE)
@ -63,65 +63,68 @@ describe("/queries", () => {
.post(`/api/queries`)
.send(TEST_QUERY)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.res.statusMessage).toEqual(`Query ${TEST_QUERY.name} saved successfully.`);
expect(res.body).toEqual({
_rev: res.body._rev,
...TEST_QUERY,
});
expect(res.res.statusMessage).toEqual(
`Query ${TEST_QUERY.name} saved successfully.`
)
expect(res.body).toEqual({
_rev: res.body._rev,
...TEST_QUERY,
})
});
})
})
describe("fetch", () => {
let datasource
beforeEach(async () => {
datasource = await createDatasource()
});
})
afterEach(() => {
delete datasource._rev
});
})
it("returns all the queries from the server", async () => {
const query = await createQuery()
const res = await request
.get(`/api/queries`)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
const queries = res.body;
expect(queries).toEqual([
{
"_rev": query.rev,
...TEST_QUERY
}
]);
const queries = res.body
expect(queries).toEqual([
{
_rev: query.rev,
...TEST_QUERY,
readable: true,
},
])
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/datasources`,
appId: appId,
})
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/datasources`,
appId: appId,
})
});
})
})
describe("destroy", () => {
let datasource;
let datasource
beforeEach(async () => {
datasource = await createDatasource()
});
})
afterEach(() => {
delete datasource._rev
});
})
it("deletes a query and returns a success message", async () => {
const query = await createQuery()
@ -134,10 +137,10 @@ describe("/queries", () => {
const res = await request
.get(`/api/queries`)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual([])
expect(res.body).toEqual([])
})
it("should apply authorization to endpoint", async () => {
@ -148,5 +151,5 @@ describe("/queries", () => {
appId: appId,
})
})
});
});
})
})

View File

@ -44,3 +44,9 @@ exports.USERS_TABLE_SCHEMA = USERS_TABLE_SCHEMA
exports.BUILDER_CONFIG_DB = "builder-config-db"
exports.HOSTING_DOC = "hosting-doc"
exports.OBJ_STORE_DIRECTORY = "/app-assets/assets"
exports.BaseQueryVerbs = {
CREATE: "create",
READ: "read",
UPDATE: "update",
DELETE: "delete",
}

View File

@ -138,6 +138,13 @@ class LinkController {
// iterate through the link IDs in the row field, see if any don't exist already
for (let linkId of rowField) {
if (linkId && linkId !== "" && linkDocIds.indexOf(linkId) === -1) {
// first check the doc we're linking to exists
try {
await this._db.get(linkId)
} catch (err) {
// skip links that don't exist
continue
}
operations.push(
new LinkDocument(
table._id,

View File

@ -16,6 +16,12 @@ const TYPE_TRANSFORM_MAP = {
"": [],
[null]: [],
[undefined]: undefined,
parse: link => {
if (typeof link === "string") {
return [link]
}
return link
},
},
options: {
"": "",
@ -165,15 +171,15 @@ exports.walkDir = (dirPath, callback) => {
* @param {object} type The type fo coerce to
* @returns {object} The coerced value
*/
exports.coerceValue = (value, type) => {
exports.coerceValue = (row, type) => {
// eslint-disable-next-line no-prototype-builtins
if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(value)) {
return TYPE_TRANSFORM_MAP[type][value]
if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(row)) {
return TYPE_TRANSFORM_MAP[type][row]
} else if (TYPE_TRANSFORM_MAP[type].parse) {
return TYPE_TRANSFORM_MAP[type].parse(value)
return TYPE_TRANSFORM_MAP[type].parse(row)
}
return value
return row
}
/**

View File

@ -106,6 +106,7 @@
"styleable": true,
"hasChildren": true,
"dataProvider": true,
"actions": ["RefreshDatasource"],
"settings": [
{
"type": "datasource",
@ -114,8 +115,9 @@
},
{
"type": "text",
"label": "No Rows Message",
"key": "noRowsMessage"
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found."
}
]
},
@ -140,66 +142,15 @@
},
{
"type": "number",
"label": "Rows Per Page",
"label": "Rows/Page",
"defaultValue": 25,
"key": "pageSize"
},
{
"type": "text",
"label": "No Rows Message",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No Rows"
}
]
},
"dataform": {
"name": "Form",
"icon": "ri-file-edit-line",
"styleable": true
},
"dataformwide": {
"name": "Wide Form",
"icon": "ri-file-edit-line",
"styleable": true
},
"input": {
"name": "Text Field",
"description": "A textfield component that allows the user to input text.",
"icon": "ri-edit-box-line",
"styleable": true,
"bindable": true,
"settings": [
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"label": "Type",
"key": "type",
"defaultValue": "text",
"options": ["text", "password"]
}
]
},
"richtext": {
"name": "Rich Text",
"description": "A component that allows the user to enter long form text.",
"icon": "ri-edit-box-line",
"styleable": true,
"bindable": true
},
"datepicker": {
"name": "Date Picker",
"description": "A basic date picker component",
"icon": "ri-calendar-line",
"styleable": true,
"bindable": true,
"settings": [
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
"defaultValue": "No rows found."
}
]
},
@ -1127,5 +1078,262 @@
"defaultValue": true
}
]
},
"form": {
"name": "Form",
"icon": "ri-file-text-line",
"styleable": true,
"hasChildren": true,
"dataProvider": true,
"actions": ["ValidateForm"],
"settings": [
{
"type": "schema",
"label": "Schema",
"key": "datasource"
},
{
"type": "select",
"label": "Theme",
"key": "theme",
"defaultValue": "spectrum--light",
"options": [
{
"label": "Lightest",
"value": "spectrum--lightest"
},
{
"label": "Light",
"value": "spectrum--light"
},
{
"label": "Dark",
"value": "spectrum--dark"
},
{
"label": "Darkest",
"value": "spectrum--darkest"
}
]
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "spectrum--medium",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
]
}
]
},
"fieldgroup": {
"name": "Field Group",
"icon": "ri-layout-row-line",
"styleable": true,
"hasChildren": true,
"settings": [
{
"type": "select",
"label": "Labels",
"key": "labelPosition",
"defaultValue": "above",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Right",
"value": "right"
},
{
"label": "Above",
"value": "above"
}
]
}
]
},
"stringfield": {
"name": "Text Field",
"icon": "ri-t-box-line",
"styleable": true,
"settings": [
{
"type": "field/string",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
}
]
},
"numberfield": {
"name": "Number Field",
"icon": "ri-edit-box-line",
"styleable": true,
"settings": [
{
"type": "field/number",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
}
]
},
"optionsfield": {
"name": "Options Picker",
"icon": "ri-file-list-line",
"styleable": true,
"settings": [
{
"type": "field/options",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder",
"placeholder": "Choose an option"
}
]
},
"booleanfield": {
"name": "Checkbox",
"icon": "ri-checkbox-line",
"styleable": true,
"settings": [
{
"type": "field/boolean",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Text",
"key": "text"
}
]
},
"longformfield": {
"name": "Rich Text",
"icon": "ri-file-edit-line",
"styleable": true,
"settings": [
{
"type": "field/longform",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder",
"placeholder": "Type something..."
}
]
},
"datetimefield": {
"name": "Date Picker",
"icon": "ri-calendar-line",
"styleable": true,
"settings": [
{
"type": "field/datetime",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "boolean",
"label": "Show Time",
"key": "enableTime",
"defaultValue": true
}
]
},
"attachmentfield": {
"name": "Attachment",
"icon": "ri-image-edit-line",
"styleable": true,
"settings": [
{
"type": "field/attachment",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
}
]
},
"relationshipfield": {
"name": "Relationship Picker",
"icon": "ri-links-line",
"styleable": true,
"settings": [
{
"type": "field/link",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
}
]
}
}

View File

@ -27,6 +27,7 @@
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-postcss": "^3.1.5",
"rollup-plugin-svelte": "^6.1.1",
"rollup-plugin-svg": "^2.0.0",
"rollup-plugin-terser": "^7.0.2",
"sirv-cli": "^0.4.4",
"svelte": "^3.30.0"
@ -38,10 +39,25 @@
"license": "MIT",
"gitHead": "1a80b09fd093f2599a68f7db72ad639dd50922dd",
"dependencies": {
"@budibase/bbui": "^1.55.1",
"@adobe/spectrum-css-workflow-icons": "^1.1.0",
"@budibase/bbui": "^1.58.5",
"@budibase/svelte-ag-grid": "^0.0.16",
"@spectrum-css/actionbutton": "^1.0.0-beta.1",
"@spectrum-css/button": "^3.0.0-beta.6",
"@spectrum-css/checkbox": "^3.0.0-beta.6",
"@spectrum-css/fieldlabel": "^3.0.0-beta.7",
"@spectrum-css/icon": "^3.0.0-beta.2",
"@spectrum-css/inputgroup": "^3.0.0-beta.7",
"@spectrum-css/menu": "^3.0.0-beta.5",
"@spectrum-css/page": "^3.0.0-beta.0",
"@spectrum-css/picker": "^1.0.0-beta.3",
"@spectrum-css/popover": "^3.0.0-beta.6",
"@spectrum-css/stepper": "^3.0.0-beta.7",
"@spectrum-css/textfield": "^3.0.0-beta.6",
"@spectrum-css/vars": "^3.0.0-beta.2",
"apexcharts": "^3.22.1",
"flatpickr": "^4.6.6",
"loadicons": "^1.0.0",
"lodash.debounce": "^4.0.8",
"markdown-it": "^12.0.2",
"quill": "^1.3.7",

View File

@ -4,6 +4,7 @@ import svelte from "rollup-plugin-svelte"
import postcss from "rollup-plugin-postcss"
import json from "@rollup/plugin-json"
import { terser } from "rollup-plugin-terser"
import svg from "rollup-plugin-svg"
import builtins from "rollup-plugin-node-builtins"
@ -33,5 +34,6 @@ export default {
}),
commonjs(),
json(),
svg(),
],
}

View File

@ -1,5 +0,0 @@
<script>
import Form from "./Form.svelte"
</script>
<Form wide={false} />

View File

@ -1,5 +0,0 @@
<script>
import Form from "./Form.svelte"
</script>
<Form wide />

View File

@ -1,21 +0,0 @@
<script>
import { DatePicker } from "@budibase/bbui"
import { getContext } from "svelte"
const { styleable, setBindableValue } = getContext("sdk")
const component = getContext("component")
export let placeholder
let value
$: setBindableValue(value, $component.id)
function handleChange(event) {
const [fullDate] = event.detail
value = fullDate
}
</script>
<div use:styleable={$component.styles}>
<DatePicker {placeholder} on:change={handleChange} {value} />
</div>

View File

@ -1,103 +0,0 @@
<script>
import { getContext } from "svelte"
import {
Label,
DatePicker,
Input,
Select,
Toggle,
RichText,
} from "@budibase/bbui"
import Dropzone from "./attachments/Dropzone.svelte"
import LinkedRowSelector from "./LinkedRowSelector.svelte"
import { capitalise } from "./helpers"
const { styleable, API } = getContext("sdk")
const component = getContext("component")
const dataContext = getContext("data")
export let wide = false
let row
let schema
let fields = []
// Fetch info about the closest data context
$: getFormData($dataContext[$dataContext.closestComponentId])
const getFormData = async context => {
if (context) {
const tableDefinition = await API.fetchTableDefinition(context.tableId)
schema = tableDefinition?.schema
fields = Object.keys(schema ?? {})
// Use the draft version for editing
row = $dataContext[`${$dataContext.closestComponentId}_draft`]
}
}
</script>
<div class="form-content" use:styleable={$component.styles}>
<!-- <ErrorsBox errors={$store.saveRowErrors || {}} />-->
{#each fields as field}
<div class="form-field" class:wide>
{#if !(schema[field].type === 'boolean' && !wide)}
<Label extraSmall={!wide} grey>{capitalise(schema[field].name)}</Label>
{/if}
{#if schema[field].type === 'options'}
<Select secondary bind:value={row[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={row[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle
text={wide ? null : capitalise(schema[field].name)}
bind:checked={row[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={row[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={row[field]} />
{:else if schema[field].type === 'longform'}
<RichText bind:value={row[field]} />
{:else if schema[field].type === 'attachment'}
<Dropzone bind:files={row[field]} />
{:else if schema[field].type === 'link'}
<LinkedRowSelector
secondary
showLabel={false}
bind:linkedRows={row[field]}
schema={schema[field]} />
{/if}
</div>
{/each}
</div>
<style>
.form {
display: flex;
flex-direction: column;
align-items: center;
}
.form-content {
display: grid;
gap: var(--spacing-xl);
width: 100%;
}
.form-field {
display: grid;
}
.form-field.wide {
align-items: center;
grid-template-columns: 20% 1fr;
gap: var(--spacing-xl);
}
.form-field.wide :global(label) {
margin-bottom: 0;
}
</style>

View File

@ -1,14 +0,0 @@
<script>
import { getContext } from "svelte"
const { styleable, setBindableValue } = getContext("sdk")
const component = getContext("component")
let value
function onBlur() {
setBindableValue(value, $component.id)
}
</script>
<input bind:value on:blur={onBlur} use:styleable={$component.styles} />

View File

@ -2,16 +2,24 @@
import { getContext } from "svelte"
import { isEmpty } from "lodash/fp"
const { API, styleable, DataProvider, builderStore } = getContext("sdk")
export let datasource
export let noRowsMessage
const { API, styleable, Provider, builderStore, ActionTypes } = getContext(
"sdk"
)
const component = getContext("component")
export let datasource = []
export let noRowsMessage = "Feed me some data"
let rows = []
let loaded = false
$: fetchData(datasource)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchData(datasource),
metadata: { datasource },
},
]
async function fetchData(datasource) {
if (!isEmpty(datasource)) {
@ -21,28 +29,38 @@
}
</script>
<div use:styleable={$component.styles}>
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p>Add some components too</p>
{:else}
{#each rows as row}
<DataProvider {row}>
<slot />
</DataProvider>
{/each}
<Provider {actions}>
<div use:styleable={$component.styles}>
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p><i class="ri-image-line" />Add some components to display.</p>
{:else}
{#each rows as row}
<Provider data={row}>
<slot />
</Provider>
{/each}
{/if}
{:else if loaded && noRowsMessage}
<p><i class="ri-list-check-2" />{noRowsMessage}</p>
{/if}
{:else if loaded && $builderStore.inBuilder}
<p>{noRowsMessage}</p>
{/if}
</div>
</div>
</Provider>
<style>
p {
margin: 0 var(--spacing-m);
background-color: var(--grey-2);
color: var(--grey-6);
font-size: var(--font-size-s);
padding: var(--spacing-l);
border-radius: var(--border-radius-s);
display: grid;
place-items: center;
background: #f5f5f5;
border: #ccc 1px solid;
padding: var(--spacing-m);
}
p i {
margin-bottom: var(--spacing-m);
font-size: 1.5rem;
color: var(--grey-5);
}
</style>

View File

@ -1,9 +1,7 @@
<script>
import { getContext } from "svelte"
const ENTER_KEY = 13
const { authStore, styleable } = getContext("sdk")
const { authStore, styleable, builderStore } = getContext("sdk")
const component = getContext("component")
export let buttonText = "Log In"
@ -25,13 +23,16 @@
}
const login = async () => {
if ($builderStore.inBuilder) {
return
}
loading = true
await authStore.actions.logIn({ email, password })
loading = false
}
function handleKeydown(evt) {
if (evt.keyCode === ENTER_KEY) {
if (evt.key === "Enter") {
login()
}
}

View File

@ -1,12 +1,15 @@
<script>
import { getContext } from "svelte"
const { authStore, linkable, styleable } = getContext("sdk")
const { authStore, linkable, styleable, builderStore } = getContext("sdk")
const component = getContext("component")
export let logoUrl
const logOut = async () => {
if ($builderStore.inBuilder) {
return
}
await authStore.actions.logOut()
}
</script>

View File

@ -1,14 +0,0 @@
<script>
import { getContext } from "svelte"
const { DataProvider, styleable } = getContext("sdk")
const component = getContext("component")
export let table
</script>
<div use:styleable={$component.styles}>
<DataProvider row={{ tableId: table }}>
<slot />
</DataProvider>
</div>

View File

@ -1,29 +0,0 @@
<script>
import { getContext } from "svelte"
import { RichText } from "@budibase/bbui"
const { styleable } = getContext("sdk")
const component = getContext("component")
export let value = ""
// Need to determine what options we want to expose.
let options = {
modules: {
toolbar: [
[
{
header: [1, 2, 3, false],
},
],
["bold", "italic", "underline", "strike"],
],
},
placeholder: "Type something...",
theme: "snow",
}
</script>
<div use:styleable={$component.styles}>
<RichText bind:value {options} />
</div>

View File

@ -1,46 +1,57 @@
<script>
import { onMount, getContext } from "svelte"
const { API, screenStore, routeStore, DataProvider, styleable } = getContext(
"sdk"
)
const component = getContext("component")
export let table
const {
API,
screenStore,
routeStore,
Provider,
styleable,
ActionTypes,
} = getContext("sdk")
const component = getContext("component")
let headers = []
let row
async function fetchFirstRow() {
const rows = await API.fetchTableData(table)
return Array.isArray(rows) && rows.length ? rows[0] : { tableId: table }
const fetchFirstRow = async tableId => {
const rows = await API.fetchTableData(tableId)
return Array.isArray(rows) && rows.length ? rows[0] : { tableId }
}
async function fetchData() {
if (!table) {
const fetchData = async (rowId, tableId) => {
if (!tableId) {
return
}
const pathParts = window.location.pathname.split("/")
const routeParamId = $routeStore.routeParams.id
// if srcdoc, then we assume this is the builder preview
if ((pathParts.length === 0 || pathParts[0] === "srcdoc") && table) {
row = await fetchFirstRow()
} else if (routeParamId) {
row = await API.fetchRow({ tableId: table, rowId: routeParamId })
if ((pathParts.length === 0 || pathParts[0] === "srcdoc") && tableId) {
row = await fetchFirstRow(tableId)
} else if (rowId) {
row = await API.fetchRow({ tableId, rowId })
} else {
throw new Error("Row ID was not supplied to RowDetail")
}
}
onMount(fetchData)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchData($routeStore.routeParams.id, table),
metadata: { datasource: { type: "table", tableId: table } },
},
]
onMount(() => fetchData($routeStore.routeParams.id, table))
</script>
{#if row}
<div use:styleable={$component.styles}>
<DataProvider {row}>
<Provider data={row} {actions}>
<div use:styleable={$component.styles}>
<slot />
</DataProvider>
</div>
</div>
</Provider>
{/if}

View File

@ -1,6 +1,5 @@
<script>
import { getContext } from "svelte"
import { isEmpty } from "lodash/fp"
import {
Button,
DatePicker,
@ -10,10 +9,12 @@
Input,
} from "@budibase/bbui"
const { API, styleable, DataProvider, builderStore } = getContext("sdk")
const { API, styleable, Provider, builderStore, ActionTypes } = getContext(
"sdk"
)
const component = getContext("component")
export let table = []
export let table
export let columns = []
export let pageSize
export let noRowsMessage
@ -34,12 +35,19 @@
search[next] === "" ? acc : { ...acc, [next]: search[next] },
{}
)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchData(table, page),
metadata: { datasource: { type: "table", tableId: table } },
},
]
async function fetchData(table, page) {
if (!isEmpty(table)) {
if (table) {
const tableDef = await API.fetchTableDefinition(table)
schema = tableDef.schema
rows = await API.searchTable({
rows = await API.searchTableData({
tableId: table,
search: parsedSearch,
pagination: {
@ -60,84 +68,92 @@
}
</script>
<div use:styleable={$component.styles}>
<div class="query-builder">
{#if schema}
{#each columns as field}
<div class="form-field">
<Label extraSmall grey>{schema[field].name}</Label>
{#if schema[field].type === 'options'}
<Select secondary bind:value={search[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={search[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle text={schema[field].name} bind:checked={search[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={search[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={search[field]} />
{/if}
</div>
{/each}
{/if}
<div class="actions">
<Button
secondary
on:click={() => {
search = {}
page = 0
}}>
Reset
</Button>
<Button
primary
on:click={() => {
page = 0
fetchData(table, page)
}}>
Search
</Button>
</div>
</div>
{#if loaded}
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p>Add some components too</p>
{:else}
{#each rows as row}
<DataProvider {row}>
<slot />
</DataProvider>
<Provider {actions}>
<div use:styleable={$component.styles}>
<div class="query-builder">
{#if schema}
{#each columns as field}
<div class="form-field">
<Label extraSmall grey>{schema[field].name}</Label>
{#if schema[field].type === 'options'}
<Select secondary bind:value={search[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={search[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle text={schema[field].name} bind:checked={search[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={search[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={search[field]} />
{/if}
</div>
{/each}
{/if}
{:else if $builderStore.inBuilder}
<p>Feed me some data</p>
{:else}
<p>{noRowsMessage}</p>
{/if}
{/if}
<div class="pagination">
{#if page > 0}
<Button primary on:click={previousPage}>Back</Button>
{/if}
{#if rows.length === pageSize}
<Button primary on:click={nextPage}>Next</Button>
<div class="actions">
<Button
secondary
on:click={() => {
search = {}
page = 0
}}>
Reset
</Button>
<Button
primary
on:click={() => {
page = 0
fetchData(table, page)
}}>
Search
</Button>
</div>
</div>
{#if loaded}
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p><i class="ri-image-line" />Add some components to display.</p>
{:else}
{#each rows as row}
<Provider data={row}>
<slot />
</Provider>
{/each}
{/if}
{:else if noRowsMessage}
<p><i class="ri-search-2-line" />{noRowsMessage}</p>
{/if}
{/if}
<div class="pagination">
{#if page > 0}
<Button primary on:click={previousPage}>Back</Button>
{/if}
{#if rows.length === pageSize}
<Button primary on:click={nextPage}>Next</Button>
{/if}
</div>
</div>
</div>
</Provider>
<style>
p {
margin: 0 var(--spacing-m);
background-color: var(--grey-2);
color: var(--grey-6);
font-size: var(--font-size-s);
padding: var(--spacing-l);
border-radius: var(--border-radius-s);
display: grid;
place-items: center;
background: #f5f5f5;
border: #ccc 1px solid;
padding: var(--spacing-m);
}
p i {
margin-bottom: var(--spacing-m);
font-size: 1.5rem;
color: var(--grey-5);
}
.query-builder {

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
export let title
export let datasource

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
export let title
export let datasource

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
// Common props
export let title

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
export let title
export let datasource

View File

@ -0,0 +1,34 @@
<script>
import Field from "./Field.svelte"
import Dropzone from "../attachments/Dropzone.svelte"
import { onMount } from "svelte"
export let field
export let label
let fieldState
let fieldApi
// Update form value from bound value after we've mounted
let value
let mounted = false
$: mounted && fieldApi?.setValue(value)
// Get the fields initial value after initialising
onMount(() => {
value = $fieldState?.value
mounted = true
})
</script>
<Field
{label}
{field}
type="attachment"
bind:fieldState
bind:fieldApi
defaultValue={[]}>
{#if mounted}
<Dropzone bind:files={value} />
{/if}
</Field>

View File

@ -0,0 +1,57 @@
<script>
import "@spectrum-css/checkbox/dist/index-vars.css"
import Field from "./Field.svelte"
export let field
export let label
export let text
let fieldState
let fieldApi
const onChange = event => {
fieldApi.setValue(event.target.checked)
}
</script>
<Field
{label}
{field}
type="boolean"
bind:fieldState
bind:fieldApi
defaultValue={false}>
{#if fieldState}
<div class="spectrum-FieldGroup spectrum-FieldGroup--horizontal">
<label class="spectrum-Checkbox" class:is-invalid={!$fieldState.valid}>
<input
checked={$fieldState.value}
on:change={onChange}
type="checkbox"
class="spectrum-Checkbox-input"
id={$fieldState.fieldId} />
<span class="spectrum-Checkbox-box">
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark75 spectrum-Checkbox-checkmark"
focusable="false"
aria-hidden="true">
<use xlink:href="#spectrum-css-icon-Checkmark75" />
</svg>
<svg
class="spectrum-Icon spectrum-UIIcon-Dash75 spectrum-Checkbox-partialCheckmark"
focusable="false"
aria-hidden="true">
<use xlink:href="#spectrum-css-icon-Dash75" />
</svg>
</span>
<span class="spectrum-Checkbox-label">{text || 'Checkbox'}</span>
</label>
</div>
{/if}
</Field>
<style>
.spectrum-Checkbox {
width: 100%;
}
</style>

View File

@ -0,0 +1,141 @@
<script>
import Flatpickr from "svelte-flatpickr"
import Field from "./Field.svelte"
import "flatpickr/dist/flatpickr.css"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import { generateID } from "../helpers"
export let field
export let label
export let placeholder
export let enableTime
let fieldState
let fieldApi
let open = false
let flatpickr
$: flatpickrId = `${$fieldState?.id}-${generateID()}-wrapper`
$: flatpickrOptions = {
element: `#${flatpickrId}`,
enableTime: enableTime || false,
altInput: true,
altFormat: enableTime ? "F j Y, H:i" : "F j, Y",
}
const handleChange = event => {
const [dates] = event.detail
fieldApi.setValue(dates[0])
}
const clearDateOnBackspace = event => {
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
fieldApi.setValue(null)
flatpickr.close()
}
}
const onOpen = () => {
open = true
document.addEventListener("keyup", clearDateOnBackspace)
}
const onClose = () => {
open = false
document.removeEventListener("keyup", clearDateOnBackspace)
// Manually blur all input fields since flatpickr creates a second
// duplicate input field.
// We need to blur both because the focus styling does not get properly
// applied.
const els = document.querySelectorAll(`#${flatpickrId} input`)
els.forEach(el => el.blur())
}
</script>
<Field {label} {field} type="datetime" bind:fieldState bind:fieldApi>
{#if fieldState}
<Flatpickr
bind:flatpickr
value={$fieldState.value}
on:open={onOpen}
on:close={onClose}
options={flatpickrOptions}
on:change={handleChange}
element={`#${flatpickrId}`}>
<div
id={flatpickrId}
aria-disabled="false"
aria-invalid={!$fieldState.valid}
class:is-invalid={!$fieldState.valid}
class="flatpickr spectrum-InputGroup spectrum-Datepicker"
class:is-focused={open}
aria-readonly="false"
aria-required="false"
aria-haspopup="true">
<div
on:click={flatpickr?.open}
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-invalid={!$fieldState.valid}>
{#if !$fieldState.valid}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true">
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input
data-input
type="text"
class="spectrum-Textfield-input spectrum-InputGroup-input"
aria-invalid={!$fieldState.valid}
{placeholder}
id={$fieldState.fieldId}
value={$fieldState.value} />
</div>
<button
type="button"
class="spectrum-Picker spectrum-InputGroup-button"
tabindex="-1"
class:is-invalid={!$fieldState.valid}
on:click={flatpickr?.open}>
<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="true"
aria-label="Calendar">
<use xlink:href="#spectrum-icon-18-Calendar" />
</svg>
</button>
</div>
</Flatpickr>
{#if open}
<div class="overlay" on:mousedown|self={flatpickr?.close} />
{/if}
{/if}
</Field>
<style>
.spectrum-Textfield-input {
pointer-events: none;
}
.spectrum-Textfield:hover {
cursor: pointer;
}
.flatpickr {
width: 100%;
overflow: hidden;
}
.flatpickr .spectrum-Textfield {
width: 100%;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
}
</style>

View File

@ -0,0 +1,86 @@
<script>
import Placeholder from "./Placeholder.svelte"
import FieldGroupFallback from "./FieldGroupFallback.svelte"
import { getContext } from "svelte"
export let label
export let field
export let fieldState
export let fieldApi
export let fieldSchema
export let defaultValue
export let type
// Get contexts
const formContext = getContext("form")
const fieldGroupContext = getContext("fieldGroup")
const { styleable } = getContext("sdk")
const component = getContext("component")
// Register field with form
const formApi = formContext?.formApi
const labelPosition = fieldGroupContext?.labelPosition || "above"
const formField = formApi?.registerField(field, defaultValue)
// Expose field properties to parent component
fieldState = formField?.fieldState
fieldApi = formField?.fieldApi
fieldSchema = formField?.fieldSchema
// Extract label position from field group context
$: labelPositionClass =
labelPosition === "above" ? "" : `spectrum-FieldLabel--${labelPosition}`
</script>
<FieldGroupFallback>
<div class="spectrum-Form-item" use:styleable={$component.styles}>
<label
for={$fieldState?.fieldId}
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelPositionClass}`}>
{label || ''}
</label>
<div class="spectrum-Form-itemField">
{#if !formContext}
<Placeholder>Form components need to be wrapped in a Form</Placeholder>
{:else if !fieldState}
<Placeholder>
Add the Field setting to start using your component
</Placeholder>
{:else if fieldSchema?.type && fieldSchema?.type !== type}
<Placeholder>
This Field setting is the wrong data type for this component
</Placeholder>
{:else}
<slot />
{#if $fieldState.error}
<div class="error">{$fieldState.error}</div>
{/if}
{/if}
</div>
</div>
</FieldGroupFallback>
<style>
label {
white-space: nowrap;
}
.spectrum-Form-itemField {
position: relative;
width: 100%;
}
.error {
color: var(
--spectrum-semantic-negative-color-default,
var(--spectrum-global-color-red-500)
);
font-size: var(--spectrum-global-dimension-font-size-75);
margin-top: var(--spectrum-global-dimension-size-75);
}
.spectrum-FieldLabel--right,
.spectrum-FieldLabel--left {
padding-right: var(--spectrum-global-dimension-size-200);
}
</style>

View File

@ -0,0 +1,27 @@
<script>
import { getContext, setContext } from "svelte"
export let labelPosition = "above"
const { styleable } = getContext("sdk")
const component = getContext("component")
setContext("fieldGroup", { labelPosition })
</script>
<div class="wrapper" use:styleable={$component.styles}>
<div
class="spectrum-Form"
class:spectrum-Form--labelsAbove={labelPosition === 'above'}>
<slot />
</div>
</div>
<style>
.wrapper {
width: 100%;
position: relative;
}
.spectrum-Form {
width: 100%;
}
</style>

View File

@ -0,0 +1,14 @@
<script>
import { getContext } from "svelte"
const fieldGroupContext = getContext("fieldGroup")
const labelPosition = fieldGroupContext?.labelPosition || "above"
</script>
{#if fieldGroupContext}
<slot />
{:else}
<div class="spectrum-Form--labelsAbove">
<slot />
</div>
{/if}

View File

@ -0,0 +1,173 @@
<script>
import "@spectrum-css/fieldlabel/dist/index-vars.css"
import { setContext, getContext, onMount } from "svelte"
import { writable, get } from "svelte/store"
import { createValidatorFromConstraints } from "./validation"
import { generateID } from "../helpers"
export let datasource
export let theme
export let size
const component = getContext("component")
const context = getContext("context")
const { styleable, API, Provider, ActionTypes } = getContext("sdk")
let loaded = false
let schema
let table
let fieldMap = {}
// Checks if the closest data context matches the model for this forms
// datasource, and use it as the initial form values if so
const getInitialValues = context => {
return context && context.tableId === datasource?.tableId ? context : {}
}
// Use the closest data context as the initial form values if it matches
const initialValues = getInitialValues(
$context[`${$context.closestComponentId}`]
)
// Form state contains observable data about the form
const formState = writable({ values: initialValues, errors: {}, valid: true })
// Form API contains functions to control the form
const formApi = {
registerField: (field, defaultValue = null) => {
if (!field) {
return
}
if (fieldMap[field] != null) {
return fieldMap[field]
}
// Create validation function based on field schema
const constraints = schema?.[field]?.constraints
const validate = createValidatorFromConstraints(constraints, field, table)
fieldMap[field] = {
fieldState: makeFieldState(field, defaultValue),
fieldApi: makeFieldApi(field, defaultValue, validate),
fieldSchema: schema?.[field] ?? {},
}
return fieldMap[field]
},
validate: () => {
const fields = Object.keys(fieldMap)
fields.forEach(field => {
const { fieldApi } = fieldMap[field]
fieldApi.validate()
})
return get(formState).valid
},
}
// Provide both form API and state to children
setContext("form", { formApi, formState })
// Action context to pass to children
$: actions = [{ type: ActionTypes.ValidateForm, callback: formApi.validate }]
// Creates an API for a specific field
const makeFieldApi = (field, defaultValue, validate) => {
const setValue = (value, skipCheck = false) => {
const { fieldState } = fieldMap[field]
// Skip if the value is the same
if (!skipCheck && get(fieldState).value === value) {
return
}
const newValue = value == null ? defaultValue : value
const newError = validate ? validate(newValue) : null
const newValid = !newError
// Update field state
fieldState.update(state => {
state.value = newValue
state.error = newError
state.valid = newValid
return state
})
// Update form state
formState.update(state => {
state.values = { ...state.values, [field]: newValue }
if (newError) {
state.errors = { ...state.errors, [field]: newError }
} else {
delete state.errors[field]
}
state.valid = Object.keys(state.errors).length === 0
return state
})
return newValid
}
return {
setValue,
validate: () => {
const { fieldState } = fieldMap[field]
setValue(get(fieldState).value, true)
},
}
}
// Creates observable state data about a specific field
const makeFieldState = (field, defaultValue) => {
return writable({
field,
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
valid: true,
})
}
// Fetches the form schema from this form's datasource, if one exists
const fetchSchema = async () => {
if (!datasource?.tableId) {
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 || {}
}
}
}
loaded = true
}
// Load the form schema on mount
onMount(fetchSchema)
</script>
<Provider
{actions}
data={{ ...$formState.values, tableId: datasource?.tableId }}>
<div
lang="en"
dir="ltr"
use:styleable={$component.styles}
class={`spectrum ${size || 'spectrum--medium'} ${theme || 'spectrum--light'}`}>
{#if loaded}
<slot />
{/if}
</div>
</Provider>
<style>
div {
padding: 20px;
position: relative;
}
</style>

View File

@ -0,0 +1,71 @@
<script>
import { onMount } from "svelte"
import { RichText } from "@budibase/bbui"
import Field from "./Field.svelte"
export let field
export let label
export let placeholder
let fieldState
let fieldApi
// Update form value from bound value after we've mounted
let value
let mounted = false
$: mounted && fieldApi?.setValue(value)
// Get the fields initial value after initialising
onMount(() => {
value = $fieldState?.value
mounted = true
})
// Options for rich text component
const options = {
modules: {
toolbar: [
[
{
header: [1, 2, 3, false],
},
],
["bold", "italic", "underline", "strike"],
],
},
placeholder: placeholder || "Type something...",
theme: "snow",
}
</script>
<Field
{label}
{field}
type="longform"
bind:fieldState
bind:fieldApi
defaultValue="">
{#if mounted}
<div>
<RichText bind:value {options} />
</div>
{/if}
</Field>
<style>
div {
background-color: white;
}
div :global(> div) {
width: auto !important;
}
div :global(.ql-snow.ql-toolbar:after, .ql-snow .ql-toolbar:after) {
display: none;
}
div :global(.ql-snow .ql-formats:after) {
display: none;
}
div :global(.ql-editor p) {
word-break: break-all;
}
</style>

View File

@ -0,0 +1,5 @@
<script>
import StringField from "./StringField.svelte"
</script>
<StringField {...$$props} type="number" />

View File

@ -0,0 +1,44 @@
<script>
import Field from "./Field.svelte"
import Picker from "./Picker.svelte"
export let field
export let label
export let placeholder
let fieldState
let fieldApi
let fieldSchema
// Picker state
let open = false
$: options = fieldSchema?.constraints?.inclusion ?? []
$: placeholderText = placeholder || "Choose an option"
$: isNull = $fieldState?.value == null || $fieldState?.value === ""
$: fieldText = isNull ? placeholderText : $fieldState?.value
const selectOption = value => {
fieldApi.setValue(value)
open = false
}
</script>
<Field
{field}
{label}
type="options"
bind:fieldState
bind:fieldApi
bind:fieldSchema>
{#if fieldState}
<Picker
bind:open
{fieldState}
{fieldText}
{options}
isPlaceholder={isNull}
placeholderOption={placeholderText}
isOptionSelected={option => option === $fieldState.value}
onSelectOption={selectOption} />
{/if}
</Field>

Some files were not shown because too many files have changed in this diff Show More