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": { "dependencies": {
"@budibase/bbui": "^1.58.3", "@budibase/bbui": "^1.58.5",
"@budibase/client": "^0.7.6", "@budibase/client": "^0.7.6",
"@budibase/colorpicker": "1.0.1", "@budibase/colorpicker": "1.0.1",
"@budibase/string-templates": "^0.7.6", "@budibase/string-templates": "^0.7.6",

View File

@ -1,7 +1,7 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store" import { get } from "svelte/store"
import { backendUiStore, store } from "builderStore" import { backendUiStore, store } from "builderStore"
import { findAllMatchingComponents, findComponentPath } from "./storeUtils" import { findComponentPath } from "./storeUtils"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { TableNames } from "../constants" import { TableNames } from "../constants"
@ -12,9 +12,7 @@ const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
* Gets all bindable data context fields and instance fields. * Gets all bindable data context fields and instance fields.
*/ */
export const getBindableProperties = (rootComponent, componentId) => { export const getBindableProperties = (rootComponent, componentId) => {
const contextBindings = getContextBindings(rootComponent, componentId) return getContextBindings(rootComponent, componentId)
const componentBindings = getComponentBindings(rootComponent)
return [...contextBindings, ...componentBindings]
} }
/** /**
@ -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 * Gets a datasource object for a certain data provider component
*/ */
@ -47,8 +69,9 @@ export const getDatasourceForProvider = component => {
} }
// Extract datasource from component instance // Extract datasource from component instance
const validSettingTypes = ["datasource", "table", "schema"]
const datasourceSetting = def.settings.find(setting => { const datasourceSetting = def.settings.find(setting => {
return setting.type === "datasource" || setting.type === "table" return validSettingTypes.includes(setting.type)
}) })
if (!datasourceSetting) { if (!datasourceSetting) {
return null return null
@ -58,15 +81,14 @@ export const getDatasourceForProvider = component => {
// example an actual datasource object, or a table ID string. // example an actual datasource object, or a table ID string.
// Convert the datasource setting into a proper datasource object so that // Convert the datasource setting into a proper datasource object so that
// we can use it properly // we can use it properly
if (datasourceSetting.type === "datasource") { if (datasourceSetting.type === "table") {
return component[datasourceSetting?.key]
} else if (datasourceSetting.type === "table") {
return { return {
tableId: component[datasourceSetting?.key], tableId: component[datasourceSetting?.key],
type: "table", 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 // Extract any components which provide data contexts
const dataProviders = getDataProviderComponents(rootComponent, componentId) const dataProviders = getDataProviderComponents(rootComponent, componentId)
let contextBindings = [] let contextBindings = []
// Create bindings for each data provider
dataProviders.forEach(component => { dataProviders.forEach(component => {
const isForm = component._component.endsWith("/form")
const datasource = getDatasourceForProvider(component) 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 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() const keys = Object.keys(schema).sort()
// Create bindable properties for each schema field // Create bindable properties for each schema field
@ -110,11 +148,11 @@ export const getContextBindings = (rootComponent, componentId) => {
runtimeBinding: `${makePropSafe(component._id)}.${makePropSafe( runtimeBinding: `${makePropSafe(component._id)}.${makePropSafe(
runtimeBoundKey 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, fieldSchema,
providerId: component._id, providerId: component._id,
tableId: datasource.tableId,
field: key,
}) })
}) })
}) })
@ -142,44 +180,20 @@ export const getContextBindings = (rootComponent, componentId) => {
type: "context", type: "context",
runtimeBinding: `user.${runtimeBoundKey}`, runtimeBinding: `user.${runtimeBoundKey}`,
readableBinding: `Current User.${key}`, readableBinding: `Current User.${key}`,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema, fieldSchema,
providerId: "user", providerId: "user",
tableId: TableNames.USERS,
field: key,
}) })
}) })
return contextBindings 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. * Gets a schema for a datasource object.
*/ */
export const getSchemaForDatasource = datasource => { export const getSchemaForDatasource = (datasource, isForm = false) => {
let schema, table let schema, table
if (datasource) { if (datasource) {
const { type } = datasource const { type } = datasource
@ -193,6 +207,14 @@ export const getSchemaForDatasource = datasource => {
if (table) { if (table) {
if (type === "view") { if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema) schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "query" && isForm) {
schema = {}
const params = table.parameters || []
params.forEach(param => {
if (param?.name) {
schema[param.name] = { ...param, type: "string" }
}
})
} else { } else {
schema = cloneDeep(table.schema) schema = cloneDeep(table.schema)
} }
@ -201,6 +223,32 @@ export const getSchemaForDatasource = datasource => {
return { schema, table } 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. * utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
*/ */

View File

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

View File

@ -9,5 +9,6 @@ const createScreen = () => {
return new Screen() return new Screen()
.mainType("div") .mainType("div")
.component("@budibase/standard-components/container") .component("@budibase/standard-components/container")
.instanceName("New Screen")
.json() .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 newRowScreen from "./newRowScreen"
import rowDetailScreen from "./rowDetailScreen" import rowDetailScreen from "./rowDetailScreen"
import rowListScreen from "./rowListScreen" import rowListScreen from "./rowListScreen"
import emptyNewRowScreen from "./emptyNewRowScreen"
import createFromScratchScreen from "./createFromScratchScreen" import createFromScratchScreen from "./createFromScratchScreen"
import emptyRowDetailScreen from "./emptyRowDetailScreen"
const allTemplates = tables => [ const allTemplates = tables => [
createFromScratchScreen,
...newRowScreen(tables), ...newRowScreen(tables),
...rowDetailScreen(tables), ...rowDetailScreen(tables),
...rowListScreen(tables), ...rowListScreen(tables),
emptyNewRowScreen,
emptyRowDetailScreen,
] ]
// Allows us to apply common behaviour to all create() functions // Allows us to apply common behaviour to all create() functions
@ -22,8 +17,18 @@ const createTemplateOverride = (frontendState, create) => () => {
return screen return screen
} }
export default (frontendState, tables) => export default (frontendState, tables) => {
allTemplates(tables).map(template => ({ const enrichTemplate = template => ({
...template, ...template,
create: createTemplateOverride(frontendState, template.create), 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 sanitizeUrl from "./utils/sanitizeUrl"
import { Component } from "./utils/Component"
import { Screen } from "./utils/Screen" import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
import { import {
makeBreadcrumbContainer, makeBreadcrumbContainer,
makeMainContainer, makeMainForm,
makeTitleContainer, makeTitleContainer,
makeSaveButton, makeSaveButton,
makeDatasourceFormComponents,
} from "./utils/commonComponents" } from "./utils/commonComponents"
export default function(tables) { export default function(tables) {
@ -21,29 +22,46 @@ export default function(tables) {
export const newRowUrl = table => sanitizeUrl(`/${table.name}/new/row`) export const newRowUrl = table => sanitizeUrl(`/${table.name}/new/row`)
export const NEW_ROW_TEMPLATE = "NEW_ROW_TEMPLATE" export const NEW_ROW_TEMPLATE = "NEW_ROW_TEMPLATE"
function generateTitleContainer(table, providerId) { function generateTitleContainer(table, formId) {
return makeTitleContainer("New Row").addChild( return makeTitleContainer("New Row").addChild(makeSaveButton(table, formId))
makeSaveButton(table, providerId)
)
} }
const createScreen = table => { const createScreen = table => {
const screen = new Screen() const screen = new Screen()
.component("@budibase/standard-components/newrow") .component("@budibase/standard-components/container")
.table(table._id)
.route(newRowUrl(table))
.instanceName(`${table.name} - New`) .instanceName(`${table.name} - New`)
.name("") .route(newRowUrl(table))
const dataform = new Component( const form = makeMainForm()
"@budibase/standard-components/dataformwide" .instanceName("Form")
).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 fieldGroup = new Component("@budibase/standard-components/fieldgroup")
const container = makeMainContainer() .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(makeBreadcrumbContainer(table.name, "New"))
.addChild(generateTitleContainer(table, providerId)) .addChild(generateTitleContainer(table, formId))
.addChild(dataform) .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 { Component } from "./utils/Component"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { import {
makeMainContainer,
makeBreadcrumbContainer, makeBreadcrumbContainer,
makeTitleContainer, makeTitleContainer,
makeSaveButton, makeSaveButton,
makeMainForm,
spectrumColor,
makeDatasourceFormComponents,
} from "./utils/commonComponents" } from "./utils/commonComponents"
export default function(tables) { export default function(tables) {
return tables.map(table => { return tables.map(table => {
const heading = table.primaryDisplay
? `{{ data.${makePropSafe(table.primaryDisplay)} }}`
: null
return { return {
name: `${table.name} - Detail`, name: `${table.name} - Detail`,
create: () => createScreen(table, heading), create: () => createScreen(table),
id: ROW_DETAIL_TEMPLATE, id: ROW_DETAIL_TEMPLATE,
} }
}) })
@ -26,9 +25,9 @@ export default function(tables) {
export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE" export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE"
export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`) 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 // have to override style for this, its missing margin
const saveButton = makeSaveButton(table, providerId).normalStyle({ const saveButton = makeSaveButton(table, formId).normalStyle({
background: "#000000", background: "#000000",
"border-width": "0", "border-width": "0",
"border-style": "None", "border-style": "None",
@ -54,6 +53,7 @@ function generateTitleContainer(table, title, providerId) {
background: "transparent", background: "transparent",
color: "#4285f4", color: "#4285f4",
}) })
.customStyle(spectrumColor(700))
.text("Delete") .text("Delete")
.customProps({ .customProps({
className: "", className: "",
@ -61,8 +61,9 @@ function generateTitleContainer(table, title, providerId) {
onClick: [ onClick: [
{ {
parameters: { parameters: {
rowId: `{{ ${makePropSafe(providerId)}._id }}`, providerId: formId,
revId: `{{ ${makePropSafe(providerId)}._rev }}`, rowId: `{{ ${makePropSafe(formId)}._id }}`,
revId: `{{ ${makePropSafe(formId)}._rev }}`,
tableId: table._id, tableId: table._id,
}, },
"##eventHandlerType": "Delete Row", "##eventHandlerType": "Delete Row",
@ -82,23 +83,47 @@ function generateTitleContainer(table, title, providerId) {
.addChild(saveButton) .addChild(saveButton)
} }
const createScreen = (table, heading) => { const createScreen = table => {
const screen = new Screen() const screen = new Screen()
.component("@budibase/standard-components/rowdetail") .component("@budibase/standard-components/rowdetail")
.table(table._id) .table(table._id)
.instanceName(`${table.name} - Detail`) .instanceName(`${table.name} - Detail`)
.route(rowDetailUrl(table)) .route(rowDetailUrl(table))
.name("")
const dataform = new Component( const form = makeMainForm()
"@budibase/standard-components/dataformwide" .instanceName("Form")
).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 fieldGroup = new Component("@budibase/standard-components/fieldgroup")
const container = makeMainContainer() .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(makeBreadcrumbContainer(table.name, heading || "Edit"))
.addChild(generateTitleContainer(table, heading || "Edit Row", providerId)) .addChild(generateTitleContainer(table, heading || "Edit Row", formId))
.addChild(dataform) .addChild(fieldGroup)
return screen.addChild(container).json() return screen.addChild(form).json()
} }

View File

@ -14,17 +14,11 @@ export class Component extends BaseStructure {
active: {}, active: {},
selected: {}, selected: {},
}, },
type: "",
_instanceName: "", _instanceName: "",
_children: [], _children: [],
} }
} }
type(type) {
this._json.type = type
return this
}
normalStyle(styling) { normalStyle(styling) {
this._json._styles.normal = styling this._json._styles.normal = styling
return this return this
@ -35,14 +29,25 @@ export class Component extends BaseStructure {
return this return this
} }
text(text) { customStyle(styling) {
this._json.text = text this._json._styles.custom = styling
return this return this
} }
// TODO: do we need this
instanceName(name) { instanceName(name) {
this._json._instanceName = name this._json._instanceName = name
return this 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 { Component } from "./Component"
import { rowListUrl } from "../rowListScreen" 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) { export function makeLinkComponent(tableName) {
return new Component("@budibase/standard-components/link") return new Component("@budibase/standard-components/link")
@ -10,6 +20,7 @@ export function makeLinkComponent(tableName) {
.hoverStyle({ .hoverStyle({
color: "#4285f4", color: "#4285f4",
}) })
.customStyle(spectrumColor(700))
.text(tableName) .text(tableName)
.customProps({ .customProps({
url: `/${tableName.toLowerCase()}`, url: `/${tableName.toLowerCase()}`,
@ -22,13 +33,12 @@ export function makeLinkComponent(tableName) {
}) })
} }
export function makeMainContainer() { export function makeMainForm() {
return new Component("@budibase/standard-components/container") return new Component("@budibase/standard-components/form")
.type("div") .type("div")
.normalStyle({ .normalStyle({
width: "700px", width: "700px",
padding: "0px", padding: "0px",
background: "white",
"border-radius": "0.5rem", "border-radius": "0.5rem",
"box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)", "box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
margin: "auto", margin: "auto",
@ -39,7 +49,7 @@ export function makeMainContainer() {
"padding-left": "48px", "padding-left": "48px",
"margin-bottom": "20px", "margin-bottom": "20px",
}) })
.instanceName("Container") .instanceName("Form")
} }
export function makeBreadcrumbContainer(tableName, text, capitalise = false) { export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
@ -51,6 +61,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
"margin-right": "4px", "margin-right": "4px",
"margin-left": "4px", "margin-left": "4px",
}) })
.customStyle(spectrumColor(700))
.text(">") .text(">")
.instanceName("Arrow") .instanceName("Arrow")
@ -63,6 +74,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
const identifierText = new Component("@budibase/standard-components/text") const identifierText = new Component("@budibase/standard-components/text")
.type("none") .type("none")
.normalStyle(textStyling) .normalStyle(textStyling)
.customStyle(spectrumColor(700))
.text(text) .text(text)
.instanceName("Identifier") .instanceName("Identifier")
@ -78,7 +90,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
.addChild(identifierText) .addChild(identifierText)
} }
export function makeSaveButton(table, providerId) { export function makeSaveButton(table, formId) {
return new Component("@budibase/standard-components/button") return new Component("@budibase/standard-components/button")
.normalStyle({ .normalStyle({
background: "#000000", background: "#000000",
@ -99,8 +111,14 @@ export function makeSaveButton(table, providerId) {
disabled: false, disabled: false,
onClick: [ onClick: [
{ {
"##eventHandlerType": "Validate Form",
parameters: { parameters: {
providerId, componentId: formId,
},
},
{
parameters: {
providerId: formId,
}, },
"##eventHandlerType": "Save Row", "##eventHandlerType": "Save Row",
}, },
@ -125,6 +143,7 @@ export function makeTitleContainer(title) {
"margin-left": "0px", "margin-left": "0px",
flex: "1 1 auto", flex: "1 1 auto",
}) })
.customStyle(spectrumColor(900))
.type("h3") .type("h3")
.instanceName("Title") .instanceName("Title")
.text(title) .text(title)
@ -142,3 +161,44 @@ export function makeTitleContainer(title) {
.instanceName("Title Container") .instanceName("Title Container")
.addChild(heading) .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 * Recurses through the component tree and finds all components which match
* type. * a certain selector
*/ */
export const findAllMatchingComponents = (rootComponent, selector) => { export const findAllMatchingComponents = (rootComponent, selector) => {
if (!rootComponent || !selector) { if (!rootComponent || !selector) {
@ -81,6 +81,26 @@ export const findAllMatchingComponents = (rootComponent, selector) => {
return components.reverse() 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 * Recurses through a component tree evaluating a matching function against
* components until a match is found * components until a match is found

View File

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

View File

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

View File

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

View File

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

View File

@ -13,9 +13,8 @@
let dropdown let dropdown
let anchor let anchor
$: noChildrenAllowed = $: definition = store.actions.components.getDefinition(component?._component)
!component || $: noChildrenAllowed = !component || !definition?.hasChildren
!store.actions.components.getDefinition(component._component)?.hasChildren
$: noPaste = !$store.componentToPaste $: noPaste = !$store.componentToPaste
const lastPartOfName = c => (c ? last(c._component.split("/")) : "") const lastPartOfName = c => (c ? last(c._component.split("/")) : "")
@ -130,7 +129,7 @@
<ConfirmDialog <ConfirmDialog
bind:this={confirmDeleteDialog} bind:this={confirmDeleteDialog}
title="Confirm Deletion" 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" okText="Delete Component"
onOk={deleteComponent} /> 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 let drawer
export let value = {} export let value = {}
export let otherSources
$: tables = $backendUiStore.tables.map(m => ({ $: tables = $backendUiStore.tables.map(m => ({
label: m.name, label: m.name,
@ -88,7 +89,7 @@
class="dropdownbutton" class="dropdownbutton"
bind:this={anchorRight} bind:this={anchorRight}
on:click={dropdownRight.show}> on:click={dropdownRight.show}>
<span>{value?.label ? value.label : 'Choose option'}</span> <span>{value?.label ?? 'Choose option'}</span>
<Icon name="arrowdown" /> <Icon name="arrowdown" />
</div> </div>
{#if value?.type === 'query'} {#if value?.type === 'query'}
@ -175,6 +176,22 @@
</li> </li>
{/each} {/each}
</ul> </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> </div>
</DropdownMenu> </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> <script>
import { import { Button, DropdownMenu, Spacer } from "@budibase/bbui"
Button,
Body,
DropdownMenu,
ModalContent,
Spacer,
} from "@budibase/bbui"
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import actionTypes from "./actions" import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
import { automationStore } from "builderStore"
const EVENT_TYPE_KEY = "##eventHandlerType" const EVENT_TYPE_KEY = "##eventHandlerType"

View File

@ -15,8 +15,9 @@
) )
$: { $: {
// Automatically set rev and table ID based on row ID // Automatically set rev and table ID based on row ID
if (parameters.rowId) { if (parameters.providerId) {
parameters.revId = parameters.rowId.replace("_id", "_rev") parameters.rowId = `{{ ${parameters.providerId}._id }}`
parameters.revId = `{{ ${parameters.providerId}._rev }}`
const providerComponent = dataProviderComponents.find( const providerComponent = dataProviderComponents.find(
provider => provider._id === parameters.providerId provider => provider._id === parameters.providerId
) )
@ -37,12 +38,10 @@
</div> </div>
{:else} {:else}
<Label size="m" color="dark">Datasource</Label> <Label size="m" color="dark">Datasource</Label>
<Select secondary bind:value={parameters.rowId}> <Select secondary bind:value={parameters.providerId}>
<option value="" /> <option value="" />
{#each dataProviderComponents as provider} {#each dataProviderComponents as provider}
<option value={`{{ ${provider._id}._id }}`}> <option value={provider._id}>{provider._instanceName}</option>
{provider._instanceName}
</option>
{/each} {/each}
</Select> </Select>
{/if} {/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 // this statement initialises fields from parameters.fields
$: fields = $: fields =
fields || fields ||
Object.keys(parameterFields || { "": "" }).map(name => ({ Object.keys(parameterFields || {}).map(name => ({
name, name,
value: value:
(parameterFields && (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 DeleteRow from "./DeleteRow.svelte"
import ExecuteQuery from "./ExecuteQuery.svelte" import ExecuteQuery from "./ExecuteQuery.svelte"
import TriggerAutomation from "./TriggerAutomation.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 // defines what actions are available, when adding a new one
// the component is the setup panel for the action // the component is the setup panel for the action
@ -30,4 +32,12 @@ export default [
name: "Trigger Automation", name: "Trigger Automation",
component: TriggerAutomation, 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 = $: displayLabel =
selectedOption && selectedOption.label ? selectedOption.label : value || "" selectedOption && selectedOption.label
? selectedOption.label
: value || "Choose option"
</script> </script>
<div <div
@ -129,11 +131,16 @@
on:keydown={handleEscape} on:keydown={handleEscape}
class="bb-select-menu"> class="bb-select-menu">
<ul> <ul>
<li
on:click|self={() => handleClick(null)}
class:selected={value == null || value === ''}>
Choose option
</li>
{#if isOptionsObject} {#if isOptionsObject}
{#each options as { value: v, label }} {#each options as { value: v, label }}
<li <li
{...handleStyleBind(v)} {...handleStyleBind(v)}
on:click|self={handleClick(v)} on:click|self={() => handleClick(v)}
class:selected={value === v}> class:selected={value === v}>
{label} {label}
</li> </li>
@ -142,7 +149,7 @@
{#each options as v} {#each options as v}
<li <li
{...handleStyleBind(v)} {...handleStyleBind(v)}
on:click|self={handleClick(v)} on:click|self={() => handleClick(v)}
class:selected={value === v}> class:selected={value === v}>
{v} {v}
</li> </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; align-items: center;
display: flex; display: flex;
box-sizing: border-box; box-sizing: border-box;
padding-left: var(--spacing-xs); padding-left: 7px;
border-left: 1px solid var(--grey-4); border-left: 1px solid var(--grey-4);
background-color: var(--grey-2); background-color: var(--grey-2);
border-top-right-radius: var(--border-radius-m); border-top-right-radius: var(--border-radius-m);

View File

@ -25,7 +25,7 @@
<DetailSummary name={`${name}${changed ? ' *' : ''}`} on:open show={open} thin> <DetailSummary name={`${name}${changed ? ' *' : ''}`} on:open show={open} thin>
{#if open} {#if open}
<div> <div>
{#each properties as prop} {#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)}
<PropertyControl <PropertyControl
bindable={false} bindable={false}
label={`${prop.label}${hasPropChanged(style, prop) ? ' *' : ''}`} 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> <script>
import { get } from "lodash" import { get } from "lodash"
import { isEmpty } from "lodash/fp" 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 PropertyControl from "./PropertyControls/PropertyControl.svelte"
import Input from "./PropertyControls/Input.svelte" import Input from "./PropertyControls/Input.svelte"
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte" import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
import RoleSelect from "./PropertyControls/RoleSelect.svelte" import RoleSelect from "./PropertyControls/RoleSelect.svelte"
import OptionSelect from "./PropertyControls/OptionSelect.svelte" import OptionSelect from "./PropertyControls/OptionSelect.svelte"
import MultiTableViewFieldSelect from "./PropertyControls/MultiTableViewFieldSelect.svelte"
import Checkbox from "./PropertyControls/Checkbox.svelte" import Checkbox from "./PropertyControls/Checkbox.svelte"
import TableSelect from "./PropertyControls/TableSelect.svelte" import TableSelect from "./PropertyControls/TableSelect.svelte"
import TableViewSelect from "./PropertyControls/TableViewSelect.svelte" import DatasourceSelect from "./PropertyControls/DatasourceSelect.svelte"
import TableViewFieldSelect from "./PropertyControls/TableViewFieldSelect.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 EventsEditor from "./PropertyControls/EventsEditor"
import ScreenSelect from "./PropertyControls/ScreenSelect.svelte" import ScreenSelect from "./PropertyControls/ScreenSelect.svelte"
import DetailScreenSelect from "./PropertyControls/DetailScreenSelect.svelte" import DetailScreenSelect from "./PropertyControls/DetailScreenSelect.svelte"
import { IconSelect } from "./PropertyControls/IconSelect" import { IconSelect } from "./PropertyControls/IconSelect"
import ColorPicker from "./PropertyControls/ColorPicker.svelte" 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 componentDefinition = {}
export let componentInstance = {} export let componentInstance = {}
@ -39,6 +52,7 @@
"layoutId", "layoutId",
"routing.roleId", "routing.roleId",
] ]
let confirmResetFieldsDialog
$: settings = componentDefinition?.settings ?? [] $: settings = componentDefinition?.settings ?? []
$: isLayout = assetInstance && assetInstance.favicon $: isLayout = assetInstance && assetInstance.favicon
@ -47,7 +61,7 @@
const controlMap = { const controlMap = {
text: Input, text: Input,
select: OptionSelect, select: OptionSelect,
datasource: TableViewSelect, datasource: DatasourceSelect,
screen: ScreenSelect, screen: ScreenSelect,
detailScreen: DetailScreenSelect, detailScreen: DetailScreenSelect,
boolean: Checkbox, boolean: Checkbox,
@ -56,8 +70,17 @@
table: TableSelect, table: TableSelect,
color: ColorPicker, color: ColorPicker,
icon: IconSelect, icon: IconSelect,
field: TableViewFieldSelect, field: FieldSelect,
multifield: MultiTableViewFieldSelect, 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 => { const getControl = type => {
@ -78,6 +101,20 @@
const onInstanceNameChange = name => { const onInstanceNameChange = name => {
onChange("_instanceName", 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> </script>
<div class="settings-view-container"> <div class="settings-view-container">
@ -114,7 +151,7 @@
value={componentInstance[setting.key] ?? componentInstance[setting.key]?.defaultValue} value={componentInstance[setting.key] ?? componentInstance[setting.key]?.defaultValue}
{componentInstance} {componentInstance}
onChange={val => onChange(setting.key, val)} onChange={val => onChange(setting.key, val)}
props={{ options: setting.options }} /> props={{ options: setting.options, placeholder: setting.placeholder }} />
{/if} {/if}
{/each} {/each}
{:else} {:else}
@ -122,7 +159,19 @@
This component doesn't have any additional settings. This component doesn't have any additional settings.
</div> </div>
{/if} {/if}
{#if componentDefinition?.component?.endsWith('/fieldgroup')}
<Button secondary wide on:click={() => confirmResetFieldsDialog?.show()}>
Reset Fields
</Button>
{/if}
</div> </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> <style>
.settings-view-container { .settings-view-container {

View File

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

View File

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

View File

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

View File

@ -1,14 +1,8 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { fetchTableData, searchTableData } from "./tables" import { fetchTableData } from "./tables"
import { fetchViewData } from "./views" import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships" import { fetchRelationshipData } from "./relationships"
import { executeQuery } from "./queries" 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. * Fetches all rows for a particular Budibase data source.
@ -33,7 +27,7 @@ export const fetchDatasource = async datasource => {
parameters[param.name] = param.default parameters[param.name] = param.default
} }
} }
return await executeQuery({ queryId: datasource._id, parameters }) rows = await executeQuery({ queryId: datasource._id, parameters })
} else if (type === "link") { } else if (type === "link") {
rows = await fetchRelationshipData({ rows = await fetchRelationshipData({
rowId: datasource.rowId, rowId: datasource.rowId,
@ -42,6 +36,6 @@ export const fetchDatasource = async datasource => {
}) })
} }
// Enrich rows so they can displayed properly // Enrich the result is always an array
return await enrichRows(rows, tableId) 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" import API from "./api"
/** /**
* Executes a query against an external data connector. * Executes a query against an external data connector.
*/ */
export const executeQuery = async ({ queryId, parameters }) => { export const executeQuery = async ({ queryId, parameters }) => {
const query = await API.get({ url: `/api/queries/${queryId}` })
if (query?.datasourceId == null) {
notificationStore.danger("That query couldn't be found")
return
}
const res = await API.post({ const res = await API.post({
url: `/api/queries/${queryId}`, url: `/api/queries/${queryId}`,
body: { body: {
@ -13,6 +18,9 @@ export const executeQuery = async ({ queryId, parameters }) => {
}) })
if (res.error) { if (res.error) {
notificationStore.danger("An error has occurred") notificationStore.danger("An error has occurred")
} else if (!query.readable) {
notificationStore.success("Query executed successfully")
datasourceStore.actions.invalidateDatasource(query.datasourceId)
} }
return res return res
} }

View File

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

View File

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

View File

@ -1,19 +1,28 @@
<script> <script>
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
import * as ComponentLibrary from "@budibase/standard-components" import * as ComponentLibrary from "@budibase/standard-components"
import Router from "./Router.svelte" import Router from "./Router.svelte"
import { enrichProps, propsAreSame } from "../utils/componentProps" import { enrichProps, propsAreSame } from "../utils/componentProps"
import { authStore, bindingStore, builderStore } from "../store" import { builderStore } from "../store"
import { hashString } from "../utils/hash"
export let definition = {} export let definition = {}
let enrichedProps // Props that will be passed to the component instance
let componentProps 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 // Get contexts
const dataContext = getContext("data") const context = getContext("context")
const screenslotContext = getContext("screenslot")
// Create component context // Create component context
const componentStore = writable({}) const componentStore = writable({})
@ -23,39 +32,16 @@
$: constructor = getComponentConstructor(definition._component) $: constructor = getComponentConstructor(definition._component)
$: children = definition._children || [] $: children = definition._children || []
$: id = definition._id $: id = definition._id
$: enrichComponentProps(definition, $dataContext, $bindingStore, $authStore) $: updateComponentProps(definition, $context)
$: updateProps(enrichedProps)
$: styles = definition._styles $: 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 // Update component context
$: componentStore.set({ $: componentStore.set({
id, id,
children: children.length, 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 // Gets the component constructor for the specified component
const getComponentConstructor = component => { const getComponentConstructor = component => {
const split = component?.split("/") const split = component?.split("/")
@ -67,25 +53,53 @@
} }
// Enriches any string component props using handlebars // Enriches any string component props using handlebars
const enrichComponentProps = async (definition, context, bindings, user) => { const updateComponentProps = async (definition, context) => {
enrichedProps = await enrichProps(definition, context, bindings, user) // 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. // Enrich props with context
// If a component is selected we want to remount it every time any props const enrichedProps = await enrichProps(definition, context)
// change.
const getChildKey = childId => { // Abandon this update if a newer update has started
const selected = childId === $builderStore.selectedComponentId if (enrichmentTime !== latestUpdateTime) {
return selected ? `${childId}-${$builderStore.previewId}` : childId 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> </script>
{#if constructor && componentProps} {#if constructor && componentProps}
<svelte:component this={constructor} {...componentProps}> {#key propsHash}
{#if children.length} <svelte:component this={constructor} {...componentProps}>
{#each children as child (getChildKey(child._id))} {#if children.length}
<svelte:self definition={child} /> {#each children as child (child._id)}
{/each} <svelte:self definition={child} />
{/if} {/each}
</svelte:component> {/if}
</svelte:component>
{/key}
{/if} {/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 { styleable } = getContext("sdk")
const component = getContext("component") 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, // Only wrap this as an array to take advantage of svelte keying,
// to ensure the svelte-spa-router is fully remounted when route config // to ensure the svelte-spa-router is fully remounted when route config
// changes // changes

View File

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

View File

@ -4,12 +4,12 @@ import {
notificationStore, notificationStore,
routeStore, routeStore,
screenStore, screenStore,
bindingStore,
builderStore, builderStore,
} from "./store" } from "./store"
import { styleable } from "./utils/styleable" import { styleable } from "./utils/styleable"
import { linkable } from "./utils/linkable" import { linkable } from "./utils/linkable"
import DataProvider from "./components/DataProvider.svelte" import Provider from "./components/Provider.svelte"
import { ActionTypes } from "./constants"
export default { export default {
API, API,
@ -20,6 +20,6 @@ export default {
builderStore, builderStore,
styleable, styleable,
linkable, linkable,
DataProvider, Provider,
setBindableValue: bindingStore.actions.setBindableValue, 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 store = writable(initialState)
const actions = { const actions = {
selectComponent: id => { selectComponent: id => {
window.dispatchEvent( if (id) {
new CustomEvent("bb-select-component", { detail: id }) window.dispatchEvent(
) new CustomEvent("bb-select-component", { detail: id })
)
}
}, },
} }
return { 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 { routeStore } from "./routes"
export { screenStore } from "./screens" export { screenStore } from "./screens"
export { builderStore } from "./builder" export { builderStore } from "./builder"
export { bindingStore } from "./binding" export { datasourceStore } from "./datasource"
// Data stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton
export { createDataStore, dataStore } from "./data" export { createContextStore } from "./context"
// Initialises an app by loading screens and routes // Initialises an app by loading screens and routes
export { initialise } from "./initialise" export { initialise } from "./initialise"

View File

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

View File

@ -1,43 +1,34 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { enrichDataBinding, enrichDataBindings } from "./enrichDataBinding"
import { routeStore, builderStore } from "../store" import { routeStore, builderStore } from "../store"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "../api" import { saveRow, deleteRow, executeQuery, triggerAutomation } from "../api"
import { ActionTypes } from "../constants"
const saveRowHandler = async (action, context) => { const saveRowHandler = async (action, context) => {
const { fields, providerId } = action.parameters const { fields, providerId } = action.parameters
if (providerId) { if (providerId) {
let draft = context[`${providerId}_draft`] let draft = context[providerId]
if (fields) { if (fields) {
for (let [key, entry] of Object.entries(fields)) { for (let [key, entry] of Object.entries(fields)) {
draft[key] = await enrichDataBinding(entry.value, context) draft[key] = entry.value
} }
} }
await saveRow(draft) await saveRow(draft)
} }
} }
const deleteRowHandler = async (action, context) => { const deleteRowHandler = async action => {
const { tableId, revId, rowId } = action.parameters const { tableId, revId, rowId } = action.parameters
if (tableId && revId && rowId) { if (tableId && revId && rowId) {
const [enrichTable, enrichRow, enrichRev] = await Promise.all([ await deleteRow({ tableId, rowId, revId })
enrichDataBinding(tableId, context),
enrichDataBinding(rowId, context),
enrichDataBinding(revId, context),
])
await deleteRow({
tableId: enrichTable,
rowId: enrichRow,
revId: enrichRev,
})
} }
} }
const triggerAutomationHandler = async (action, context) => { const triggerAutomationHandler = async action => {
const { fields } = action.parameters() const { fields } = action.parameters
if (fields) { if (fields) {
const params = {} const params = {}
for (let field in fields) { for (let field in fields) {
params[field] = await enrichDataBinding(fields[field].value, context) params[field] = fields[field].value
} }
await triggerAutomation(action.parameters.automationId, params) 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 { datasourceId, queryId, queryParams } = action.parameters
const enrichedQueryParameters = await enrichDataBindings(
queryParams || {},
context
)
await executeQuery({ await executeQuery({
datasourceId, datasourceId,
queryId, 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 = { const handlerMap = {
["Save Row"]: saveRowHandler, ["Save Row"]: saveRowHandler,
["Delete Row"]: deleteRowHandler, ["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler, ["Navigate To"]: navigationHandler,
["Execute Query"]: queryExecutionHandler, ["Execute Query"]: queryExecutionHandler,
["Trigger Automation"]: triggerAutomationHandler, ["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"]]) const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => { return async () => {
for (let i = 0; i < handlers.length; i++) { for (let i = 0; i < handlers.length; i++) {
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. * Enriches component props.
* Data bindings are enriched, and button actions are enriched. * 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 // Exclude all private props that start with an underscore
let validProps = {} let validProps = {}
Object.entries(props) Object.entries(props)
@ -32,20 +32,23 @@ export const enrichProps = async (props, dataContexts, dataBindings, user) => {
// Create context of all bindings and data contexts // Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires // Duplicate the closest context as "data" which the builder requires
const context = { const totalContext = {
...dataContexts, ...context,
...dataBindings,
user, // This is only required for legacy bindings that used "data" rather than a
data: dataContexts[dataContexts.closestComponentId], // component ID.
data_draft: dataContexts[`${dataContexts.closestComponentId}_draft`], data: context[context.closestComponentId],
} }
// Enrich all data bindings in top level props // 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 // Enrich button actions if they exist
if (props._component.endsWith("/button") && enrichedProps.onClick) { if (props._component.endsWith("/button") && enrichedProps.onClick) {
enrichedProps.onClick = enrichButtonActions(enrichedProps.onClick, context) enrichedProps.onClick = enrichButtonActions(
enrichedProps.onClick,
totalContext
)
} }
return enrichedProps 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 { get } from "svelte/store"
import { builderStore } from "../store" import { builderStore } from "../store"
const selectedComponentWidth = 2
const selectedComponentColor = "#4285f4"
/** /**
* Helper to build a CSS string from a style object. * Helper to build a CSS string from a style object.
*/ */
const buildStyleString = (styleObject, customStyles) => { const buildStyleString = (styleObject, customStyles) => {
let str = "" let str = ""
Object.entries(styleObject).forEach(([style, value]) => { Object.entries(styleObject || {}).forEach(([style, value]) => {
if (style && value != null) { if (style && value != null) {
str += `${style}: ${value}; ` str += `${style}: ${value}; `
} }
@ -23,24 +20,14 @@ const buildStyleString = (styleObject, customStyles) => {
* events for any selectable components (overriding the blanket ban on pointer * events for any selectable components (overriding the blanket ban on pointer
* events in the iframe HTML). * events in the iframe HTML).
*/ */
const addBuilderPreviewStyles = (styleString, componentId, selectable) => { const addBuilderPreviewStyles = (node, styleString, componentId) => {
let str = styleString if (componentId === get(builderStore).selectedComponentId) {
const style = window.getComputedStyle(node)
// Apply extra styles if we're in the builder preview const property = style?.display === "table-row" ? "outline" : "border"
const state = get(builderStore) return styleString + `;${property}: 2px solid #4285f4 !important;`
if (state.inBuilder) { } else {
// Allow pointer events and always enable cursor return styleString
if (selectable) {
str += ";pointer-events: all !important; cursor: pointer !important;"
}
// Highlighted selected element
if (componentId === state.selectedComponentId) {
str += `;border: ${selectedComponentWidth}px solid ${selectedComponentColor} !important;`
}
} }
return str
} }
/** /**
@ -52,28 +39,20 @@ export const styleable = (node, styles = {}) => {
let applyHoverStyles let applyHoverStyles
let selectComponent let selectComponent
// Kill JS even bubbling
const blockEvent = event => {
event.preventDefault()
event.stopPropagation()
return false
}
// Creates event listeners and applies initial styles // Creates event listeners and applies initial styles
const setupStyles = newStyles => { const setupStyles = (newStyles = {}) => {
const componentId = newStyles.id const componentId = newStyles.id
const selectable = newStyles.allowSelection const customStyles = newStyles.custom || ""
const customStyles = newStyles.custom const normalStyles = newStyles.normal || {}
const normalStyles = newStyles.normal
const hoverStyles = { const hoverStyles = {
...normalStyles, ...normalStyles,
...newStyles.hover, ...(newStyles.hover || {}),
} }
// Applies a style string to a DOM node, enriching it for the builder // Applies a style string to a DOM node
// preview
const applyStyles = styleString => { const applyStyles = styleString => {
node.style = addBuilderPreviewStyles(styleString, componentId, selectable) node.style = addBuilderPreviewStyles(node, styleString, componentId)
node.dataset.componentId = componentId
} }
// Applies the "normal" style definition // 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 // Handler to select a component in the builder when clicking it in the
// builder preview // builder preview
selectComponent = event => { selectComponent = event => {
builderStore.actions.selectComponent(newStyles.id) builderStore.actions.selectComponent(componentId)
return blockEvent(event) event.preventDefault()
event.stopPropagation()
return false
} }
// Add listeners to toggle hover styles // Add listeners to toggle hover styles
@ -100,10 +81,6 @@ export const styleable = (node, styles = {}) => {
// Add builder preview click listener // Add builder preview click listener
if (get(builderStore).inBuilder) { if (get(builderStore).inBuilder) {
node.addEventListener("click", selectComponent, false) node.addEventListener("click", selectComponent, false)
// Kill other interaction events
node.addEventListener("mousedown", blockEvent)
node.addEventListener("mouseup", blockEvent)
} }
// Apply initial normal styles // Apply initial normal styles
@ -118,8 +95,6 @@ export const styleable = (node, styles = {}) => {
// Remove builder preview click listener // Remove builder preview click listener
if (get(builderStore).inBuilder) { if (get(builderStore).inBuilder) {
node.removeEventListener("click", selectComponent) 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 CouchDB = require("../../db")
const { generateQueryID, getQueryParams } = require("../../db/utils") const { generateQueryID, getQueryParams } = require("../../db/utils")
const { integrations } = require("../../integrations") 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) { function formatResponse(resp) {
if (typeof resp === "string") { if (typeof resp === "string") {
@ -21,7 +35,7 @@ exports.fetch = async function(ctx) {
include_docs: true, 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) { exports.save = async function(ctx) {
@ -61,6 +75,18 @@ async function enrichQueryFields(fields, parameters) {
return enrichedQuery 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) { exports.preview = async function(ctx) {
const db = new CouchDB(ctx.user.appId) const db = new CouchDB(ctx.user.appId)

View File

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

View File

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

View File

@ -44,3 +44,9 @@ exports.USERS_TABLE_SCHEMA = USERS_TABLE_SCHEMA
exports.BUILDER_CONFIG_DB = "builder-config-db" exports.BUILDER_CONFIG_DB = "builder-config-db"
exports.HOSTING_DOC = "hosting-doc" exports.HOSTING_DOC = "hosting-doc"
exports.OBJ_STORE_DIRECTORY = "/app-assets/assets" 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 // iterate through the link IDs in the row field, see if any don't exist already
for (let linkId of rowField) { for (let linkId of rowField) {
if (linkId && linkId !== "" && linkDocIds.indexOf(linkId) === -1) { 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( operations.push(
new LinkDocument( new LinkDocument(
table._id, table._id,

View File

@ -16,6 +16,12 @@ const TYPE_TRANSFORM_MAP = {
"": [], "": [],
[null]: [], [null]: [],
[undefined]: undefined, [undefined]: undefined,
parse: link => {
if (typeof link === "string") {
return [link]
}
return link
},
}, },
options: { options: {
"": "", "": "",
@ -165,15 +171,15 @@ exports.walkDir = (dirPath, callback) => {
* @param {object} type The type fo coerce to * @param {object} type The type fo coerce to
* @returns {object} The coerced value * @returns {object} The coerced value
*/ */
exports.coerceValue = (value, type) => { exports.coerceValue = (row, type) => {
// eslint-disable-next-line no-prototype-builtins // eslint-disable-next-line no-prototype-builtins
if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(value)) { if (TYPE_TRANSFORM_MAP[type].hasOwnProperty(row)) {
return TYPE_TRANSFORM_MAP[type][value] return TYPE_TRANSFORM_MAP[type][row]
} else if (TYPE_TRANSFORM_MAP[type].parse) { } 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, "styleable": true,
"hasChildren": true, "hasChildren": true,
"dataProvider": true, "dataProvider": true,
"actions": ["RefreshDatasource"],
"settings": [ "settings": [
{ {
"type": "datasource", "type": "datasource",
@ -114,8 +115,9 @@
}, },
{ {
"type": "text", "type": "text",
"label": "No Rows Message", "label": "Empty Text",
"key": "noRowsMessage" "key": "noRowsMessage",
"defaultValue": "No rows found."
} }
] ]
}, },
@ -140,66 +142,15 @@
}, },
{ {
"type": "number", "type": "number",
"label": "Rows Per Page", "label": "Rows/Page",
"defaultValue": 25, "defaultValue": 25,
"key": "pageSize" "key": "pageSize"
}, },
{ {
"type": "text", "type": "text",
"label": "No Rows Message", "label": "Empty Text",
"key": "noRowsMessage", "key": "noRowsMessage",
"defaultValue": "No Rows" "defaultValue": "No rows found."
}
]
},
"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"
} }
] ]
}, },
@ -1127,5 +1078,262 @@
"defaultValue": true "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-node-builtins": "^2.1.2",
"rollup-plugin-postcss": "^3.1.5", "rollup-plugin-postcss": "^3.1.5",
"rollup-plugin-svelte": "^6.1.1", "rollup-plugin-svelte": "^6.1.1",
"rollup-plugin-svg": "^2.0.0",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"sirv-cli": "^0.4.4", "sirv-cli": "^0.4.4",
"svelte": "^3.30.0" "svelte": "^3.30.0"
@ -38,10 +39,25 @@
"license": "MIT", "license": "MIT",
"gitHead": "1a80b09fd093f2599a68f7db72ad639dd50922dd", "gitHead": "1a80b09fd093f2599a68f7db72ad639dd50922dd",
"dependencies": { "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", "@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", "apexcharts": "^3.22.1",
"flatpickr": "^4.6.6", "flatpickr": "^4.6.6",
"loadicons": "^1.0.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"markdown-it": "^12.0.2", "markdown-it": "^12.0.2",
"quill": "^1.3.7", "quill": "^1.3.7",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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