Merge develop into editable text lab day

This commit is contained in:
Andrew Kingston 2021-11-16 11:14:46 +00:00
commit 2adbaea026
167 changed files with 5550 additions and 1335 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ builder/*
.temp/ .temp/
packages/server/runtime_apps/ packages/server/runtime_apps/
.idea/ .idea/
bb-airgapped.tar.gz
# Logs # Logs
logs logs

View File

@ -45,6 +45,7 @@ services:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_URL: http://minio-service:9000 MINIO_URL: http://minio-service:9000
APPS_URL: http://app-service:4002
COUCH_DB_USERNAME: ${COUCH_DB_USER} COUCH_DB_USERNAME: ${COUCH_DB_USER}
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD} COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984 COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984

View File

@ -113,6 +113,8 @@ spec:
value: {{ .Values.globals.smtp.port | quote }} value: {{ .Values.globals.smtp.port | quote }}
- name: SMTP_FROM_ADDRESS - name: SMTP_FROM_ADDRESS
value: {{ .Values.globals.smtp.from | quote }} value: {{ .Values.globals.smtp.from | quote }}
- name: APPS_URL
value: http://app-service:{{ .Values.services.apps.port }}
image: budibase/worker image: budibase/worker
imagePullPolicy: Always imagePullPolicy: Always
name: bbworker name: bbworker

View File

@ -0,0 +1,51 @@
const fs = require("fs")
const { execSync } = require("child_process")
const path = require("path")
const IMAGES = {
worker: "budibase/worker",
apps: "budibase/apps",
proxy: "envoyproxy/envoy:v1.16-latest",
minio: "minio/minio",
couch: "ibmcom/couchdb3",
curl: "curlimages/curl",
redis: "redis",
watchtower: "containrrr/watchtower"
}
const FILES = {
COMPOSE: "docker-compose.yaml",
ENVOY: "envoy.yaml",
PROPERTIES: "hosting.properties"
}
const OUTPUT_DIR = path.join(__dirname, "../", "bb-airgapped")
function copyFile(file) {
fs.copyFileSync(
path.join(__dirname, "../", "../", file),
path.join(OUTPUT_DIR, file)
)
}
// create output dir
console.log(`Creating ${OUTPUT_DIR} for build..`)
fs.rmdirSync(OUTPUT_DIR, { recursive: true })
fs.mkdirSync(OUTPUT_DIR)
// package images into tar files
for (let image in IMAGES) {
console.log(`Creating tar for ${image}..`)
execSync(`docker save ${IMAGES[image]} -o ${OUTPUT_DIR}/${image}.tar`)
}
// copy config files
copyFile(FILES.COMPOSE)
copyFile(FILES.ENVOY)
copyFile(FILES.PROPERTIES)
// compress
execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`)
// clean up
fs.rmdirSync(OUTPUT_DIR, { recursive: true })

View File

@ -1,5 +1,5 @@
{ {
"version": "0.9.173-alpha.5", "version": "0.9.185-alpha.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -46,6 +46,7 @@
"build:docker": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -", "build:docker": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:production": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION release && cd -", "build:docker:production": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION release && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:develop": "node scripts/pinVersions && lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"release:helm": "./scripts/release_helm_chart.sh", "release:helm": "./scripts/release_helm_chart.sh",
"env:multi:enable": "lerna run env:multi:enable", "env:multi:enable": "lerna run env:multi:enable",
"env:multi:disable": "lerna run env:multi:disable", "env:multi:disable": "lerna run env:multi:disable",

View File

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

View File

@ -6,6 +6,7 @@ exports.UserStatus = {
exports.Cookies = { exports.Cookies = {
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
Auth: "budibase:auth", Auth: "budibase:auth",
Init: "budibase:init",
OIDC_CONFIG: "budibase:oidc:config", OIDC_CONFIG: "budibase:oidc:config",
} }

View File

@ -152,6 +152,17 @@ exports.getDeployedAppID = appId => {
return appId return appId
} }
/**
* Convert a deployed app ID to a development app ID.
*/
exports.getDevelopmentAppID = appId => {
if (!appId.startsWith(exports.APP_DEV_PREFIX)) {
const id = appId.split(exports.APP_PREFIX)[1]
return `${exports.APP_DEV_PREFIX}${id}`
}
return appId
}
exports.getCouchUrl = () => { exports.getCouchUrl = () => {
if (!env.COUCH_DB_URL) return if (!env.COUCH_DB_URL) return
@ -248,6 +259,24 @@ exports.getAllApps = async (CouchDB, { dev, all, idsOnly } = {}) => {
} }
} }
/**
* Utility function for getAllApps but filters to production apps only.
*/
exports.getDeployedAppIDs = async CouchDB => {
return (await exports.getAllApps(CouchDB, { idsOnly: true })).filter(
id => !exports.isDevAppID(id)
)
}
/**
* Utility function for the inverse of above.
*/
exports.getDevAppIDs = async CouchDB => {
return (await exports.getAllApps(CouchDB, { idsOnly: true })).filter(id =>
exports.isDevAppID(id)
)
}
exports.dbExists = async (CouchDB, dbName) => { exports.dbExists = async (CouchDB, dbName) => {
let exists = false let exists = false
try { try {

View File

@ -9,6 +9,8 @@ const { createASession } = require("../../security/sessions")
const { getTenantId } = require("../../tenancy") const { getTenantId } = require("../../tenancy")
const INVALID_ERR = "Invalid Credentials" const INVALID_ERR = "Invalid Credentials"
const SSO_NO_PASSWORD = "SSO user does not have a password set"
const EXPIRED = "This account has expired. Please reset your password"
exports.options = { exports.options = {
passReqToCallback: true, passReqToCallback: true,
@ -36,6 +38,19 @@ exports.authenticate = async function (ctx, email, password, done) {
return authError(done, INVALID_ERR) return authError(done, INVALID_ERR)
} }
// check that the user has a stored password before proceeding
if (!dbUser.password) {
if (
(dbUser.account && dbUser.account.authType === "sso") || // root account sso
dbUser.thirdPartyProfile // internal sso
) {
return authError(done, SSO_NO_PASSWORD)
}
console.error("Non SSO usser has no password set", dbUser)
return authError(done, EXPIRED)
}
// authenticate // authenticate
if (await compare(password, dbUser.password)) { if (await compare(password, dbUser.password)) {
const sessionId = newid() const sessionId = newid()

View File

@ -181,8 +181,8 @@ exports.saveUser = async (
// check budibase users in other tenants // check budibase users in other tenants
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {
dbUser = await getTenantUser(email) const tenantUser = await getTenantUser(email)
if (dbUser != null && dbUser.tenantId !== tenantId) { if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw `Email address ${email} already in use.` throw `Email address ${email} already in use.`
} }
} }

View File

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

View File

@ -36,5 +36,7 @@
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
on:change={onChange} on:change={onChange}
on:pick
on:type
/> />
</Field> </Field>

View File

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

View File

@ -40,8 +40,15 @@
open = false open = false
} }
const onChange = e => { const onType = e => {
selectOption(e.target.value) const value = e.target.value
dispatch("type", value)
selectOption(value)
}
const onPick = value => {
dispatch("pick", value)
selectOption(value)
} }
</script> </script>
@ -62,7 +69,7 @@
type="text" type="text"
on:focus={() => (focus = true)} on:focus={() => (focus = true)}
on:blur={() => (focus = false)} on:blur={() => (focus = false)}
on:change={onChange} on:change={onType}
value={value || ""} value={value || ""}
placeholder={placeholder || ""} placeholder={placeholder || ""}
{disabled} {disabled}
@ -99,7 +106,7 @@
role="option" role="option"
aria-selected="true" aria-selected="true"
tabindex="0" tabindex="0"
on:click={() => selectOption(getOptionValue(option))} on:click={() => onPick(getOptionValue(option))}
> >
<span class="spectrum-Menu-itemLabel" <span class="spectrum-Menu-itemLabel"
>{getOptionLabel(option)}</span >{getOptionLabel(option)}</span

View File

@ -47,5 +47,6 @@
--spectrum-semantic-positive-border-color: #2d9d78; --spectrum-semantic-positive-border-color: #2d9d78;
--spectrum-semantic-positive-icon-color: #2d9d78; --spectrum-semantic-positive-icon-color: #2d9d78;
--spectrum-semantic-negative-icon-color: #e34850; --spectrum-semantic-negative-icon-color: #e34850;
min-width: 100px;
} }
</style> </style>

View File

@ -11,6 +11,7 @@ it("should rename an unpublished application", () => {
renameApp(appRename) renameApp(appRename)
cy.searchForApplication(appRename) cy.searchForApplication(appRename)
cy.get(".appGrid").find(".wrapper").should("have.length", 1) cy.get(".appGrid").find(".wrapper").should("have.length", 1)
cy.deleteApp(appRename)
}) })
xit("Should rename a published application", () => { xit("Should rename a published application", () => {

View File

@ -43,24 +43,26 @@ Cypress.Commands.add("createApp", name => {
}) })
}) })
Cypress.Commands.add("deleteApp", () => { Cypress.Commands.add("deleteApp", appName => {
cy.visit(`localhost:${Cypress.env("PORT")}/builder`) cy.visit(`localhost:${Cypress.env("PORT")}/builder`)
cy.wait(1000) cy.wait(1000)
cy.request(`localhost:${Cypress.env("PORT")}/api/applications?status=all`) cy.request(`localhost:${Cypress.env("PORT")}/api/applications?status=all`)
.its("body") .its("body")
.then(val => { .then(val => {
console.log(val)
if (val.length > 0) { if (val.length > 0) {
cy.get(".title > :nth-child(3) > .spectrum-Icon").click() cy.get(".title > :nth-child(3) > .spectrum-Icon").click()
cy.contains("Delete").click() cy.contains("Delete").click()
cy.get(".spectrum-Modal").within(() => {
cy.get("input").type(appName)
cy.get(".spectrum-Button--warning").click() cy.get(".spectrum-Button--warning").click()
})
} }
}) })
}) })
Cypress.Commands.add("createTestApp", () => { Cypress.Commands.add("createTestApp", () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.deleteApp() cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.") cy.createApp(appName, "This app is used for Cypress testing.")
}) })

View File

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

View File

@ -12,7 +12,7 @@ export default class PosthogClient {
posthog.init(this.token, { posthog.init(this.token, {
autocapture: false, autocapture: false,
capture_pageview: false, capture_pageview: true,
api_host: this.url, api_host: this.url,
}) })
posthog.set_config({ persistence: "cookie" }) posthog.set_config({ persistence: "cookie" })

View File

@ -4,6 +4,7 @@ import {
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
findComponentPath, findComponentPath,
getComponentSettings,
} from "./storeUtils" } from "./storeUtils"
import { store } from "builderStore" import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend" import { queries as queriesStores, tables as tablesStore } from "stores/backend"
@ -30,14 +31,33 @@ export const getBindableProperties = (asset, componentId) => {
const deviceBindings = getDeviceBindings() const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings() const stateBindings = getStateBindings()
return [ return [
...stateBindings,
...deviceBindings,
...urlBindings,
...contextBindings, ...contextBindings,
...urlBindings,
...stateBindings,
...userBindings, ...userBindings,
...deviceBindings,
] ]
} }
/**
* Gets the bindable properties exposed by a certain component.
*/
export const getComponentBindableProperties = (asset, componentId) => {
if (!asset || !componentId) {
return []
}
// Ensure that the component exists and exposes context
const component = findComponent(asset.props, componentId)
const def = store.actions.components.getDefinition(component?._component)
if (!def?.context) {
return []
}
// Get the bindings for the component
return getProviderContextBindings(asset, component)
}
/** /**
* Gets all data provider components above a component. * Gets all data provider components above a component.
*/ */
@ -82,13 +102,10 @@ export const getActionProviderComponents = (asset, componentId, actionType) => {
* Gets a datasource object for a certain data provider component * Gets a datasource object for a certain data provider component
*/ */
export const getDatasourceForProvider = (asset, component) => { export const getDatasourceForProvider = (asset, component) => {
const def = store.actions.components.getDefinition(component?._component) const settings = getComponentSettings(component?._component)
if (!def) {
return null
}
// If this component has a dataProvider setting, go up the stack and use it // If this component has a dataProvider setting, go up the stack and use it
const dataProviderSetting = def.settings.find(setting => { const dataProviderSetting = settings.find(setting => {
return setting.type === "dataProvider" return setting.type === "dataProvider"
}) })
if (dataProviderSetting) { if (dataProviderSetting) {
@ -100,7 +117,7 @@ export const getDatasourceForProvider = (asset, component) => {
// Extract datasource from component instance // Extract datasource from component instance
const validSettingTypes = ["dataSource", "table", "schema"] const validSettingTypes = ["dataSource", "table", "schema"]
const datasourceSetting = def.settings.find(setting => { const datasourceSetting = settings.find(setting => {
return validSettingTypes.includes(setting.type) return validSettingTypes.includes(setting.type)
}) })
if (!datasourceSetting) { if (!datasourceSetting) {
@ -127,9 +144,26 @@ export const getDatasourceForProvider = (asset, component) => {
const getContextBindings = (asset, componentId) => { const getContextBindings = (asset, componentId) => {
// Extract any components which provide data contexts // Extract any components which provide data contexts
const dataProviders = getDataProviderComponents(asset, componentId) const dataProviders = getDataProviderComponents(asset, componentId)
let bindings = []
// Generate bindings for all matching components
return getProviderContextBindings(asset, dataProviders)
}
/**
* Gets the context bindings exposed by a set of data provider components.
*/
const getProviderContextBindings = (asset, dataProviders) => {
if (!asset || !dataProviders) {
return []
}
// Ensure providers is an array
if (!Array.isArray(dataProviders)) {
dataProviders = [dataProviders]
}
// Create bindings for each data provider // Create bindings for each data provider
let bindings = []
dataProviders.forEach(component => { dataProviders.forEach(component => {
const def = store.actions.components.getDefinition(component._component) const def = store.actions.components.getDefinition(component._component)
const contexts = Array.isArray(def.context) ? def.context : [def.context] const contexts = Array.isArray(def.context) ? def.context : [def.context]
@ -142,6 +176,7 @@ const getContextBindings = (asset, componentId) => {
let schema let schema
let readablePrefix let readablePrefix
let runtimeSuffix = context.suffix
if (context.type === "form") { if (context.type === "form") {
// Forms do not need table schemas // Forms do not need table schemas
@ -171,22 +206,19 @@ const getContextBindings = (asset, componentId) => {
const keys = Object.keys(schema).sort() const keys = Object.keys(schema).sort()
// Generate safe unique runtime prefix
let providerId = component._id
if (runtimeSuffix) {
providerId += `-${runtimeSuffix}`
}
const safeComponentId = makePropSafe(providerId)
// Create bindable properties for each schema field // Create bindable properties for each schema field
const safeComponentId = makePropSafe(component._id)
keys.forEach(key => { keys.forEach(key => {
const fieldSchema = schema[key] const fieldSchema = schema[key]
// Make safe runtime binding and replace certain bindings with a // Make safe runtime binding
// new property to help display components const runtimeBinding = `${safeComponentId}.${makePropSafe(key)}`
let runtimeBoundKey = key
if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first`
}
const runtimeBinding = `${safeComponentId}.${makePropSafe(
runtimeBoundKey
)}`
// Optionally use a prefix with readable bindings // Optionally use a prefix with readable bindings
let readableBinding = component._instanceName let readableBinding = component._instanceName
@ -203,7 +235,7 @@ const getContextBindings = (asset, componentId) => {
// Field schema and provider are required to construct relationship // Field schema and provider are required to construct relationship
// datasource options, based on bindable properties // datasource options, based on bindable properties
fieldSchema, fieldSchema,
providerId: component._id, providerId,
}) })
}) })
}) })
@ -225,17 +257,9 @@ const getUserBindings = () => {
const safeUser = makePropSafe("user") const safeUser = makePropSafe("user")
keys.forEach(key => { keys.forEach(key => {
const fieldSchema = schema[key] const fieldSchema = schema[key]
// Replace certain bindings with a new property to help display components
let runtimeBoundKey = key
if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first`
}
bindings.push({ bindings.push({
type: "context", type: "context",
runtimeBinding: `${safeUser}.${makePropSafe(runtimeBoundKey)}`, runtimeBinding: `${safeUser}.${makePropSafe(key)}`,
readableBinding: `Current User.${key}`, readableBinding: `Current User.${key}`,
// Field schema and provider are required to construct relationship // Field schema and provider are required to construct relationship
// datasource options, based on bindable properties // datasource options, based on bindable properties
@ -309,8 +333,11 @@ const getUrlBindings = asset => {
*/ */
export const getSchemaForDatasource = (asset, datasource, isForm = false) => { export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
let schema, table let schema, table
if (datasource) { if (datasource) {
const { type } = datasource const { type } = datasource
// Determine the source table from the datasource type
if (type === "provider") { if (type === "provider") {
const component = findComponent(asset.props, datasource.providerId) const component = findComponent(asset.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component) const source = getDatasourceForProvider(asset, component)
@ -318,11 +345,32 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
} else if (type === "query") { } else if (type === "query") {
const queries = get(queriesStores).list const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource._id) table = queries.find(query => query._id === datasource._id)
} else if (type === "field") {
table = { name: datasource.fieldName }
const { fieldType } = datasource
if (fieldType === "attachment") {
schema = {
url: {
type: "string",
},
name: {
type: "string",
},
}
} else if (fieldType === "array") {
schema = {
value: {
type: "string",
},
}
}
} else { } else {
const tables = get(tablesStore).list const tables = get(tablesStore).list
table = tables.find(table => table._id === datasource.tableId) table = tables.find(table => table._id === datasource.tableId)
} }
if (table) {
// Determine the schema from the table if not already determined
if (table && !schema) {
if (type === "view") { if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema) schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "query" && isForm) { } else if (type === "query" && isForm) {
@ -374,8 +422,8 @@ const buildFormSchema = component => {
if (!component) { if (!component) {
return schema return schema
} }
const def = store.actions.components.getDefinition(component._component) const settings = getComponentSettings(component._component)
const fieldSetting = def?.settings?.find( const fieldSetting = settings.find(
setting => setting.key === "field" && setting.type.startsWith("field/") setting => setting.key === "field" && setting.type.startsWith("field/")
) )
if (fieldSetting && component.field) { if (fieldSetting && component.field) {
@ -501,7 +549,7 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
* {{ literal [componentId] }} * {{ literal [componentId] }}
*/ */
function extractLiteralHandlebarsID(value) { function extractLiteralHandlebarsID(value) {
return value?.match(/{{\s*literal[\s[]+([a-fA-F0-9]+)[\s\]]*}}/)?.[1] return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
} }
/** /**

View File

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

View File

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

View File

@ -202,7 +202,7 @@
display: inline-block; display: inline-block;
} }
.block { .block {
width: 360px; width: 480px;
font-size: 16px; font-size: 16px;
background-color: var(--background); background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);

View File

@ -8,10 +8,13 @@
let name let name
let selectedTrigger let selectedTrigger
let nameTouched = false
let triggerVal let triggerVal
export let webhookModal export let webhookModal
$: instanceId = $database._id $: instanceId = $database._id
$: nameError =
nameTouched && !name ? "Please specify a name for the automation." : null
async function createAutomation() { async function createAutomation() {
await automationStore.actions.create({ await automationStore.actions.create({
@ -51,13 +54,18 @@
confirmText="Save" confirmText="Save"
size="M" size="M"
onConfirm={createAutomation} onConfirm={createAutomation}
disabled={!selectedTrigger} disabled={!selectedTrigger || !name}
> >
<Body size="XS" <Body size="XS"
>Please name your automation, then select a trigger. Every automation must >Please name your automation, then select a trigger. Every automation must
start with a trigger. start with a trigger.
</Body> </Body>
<Input bind:value={name} label="Name" /> <Input
bind:value={name}
on:change={() => (nameTouched = true)}
bind:error={nameError}
label="Name"
/>
<Layout noPadding> <Layout noPadding>
<Body size="S">Triggers</Body> <Body size="S">Triggers</Body>

View File

@ -234,7 +234,8 @@
<Editor <Editor
mode="javascript" mode="javascript"
on:change={e => { on:change={e => {
onChange(e, key) // need to pass without the value inside
onChange({ detail: e.detail.value }, key)
inputData[key] = e.detail.value inputData[key] = e.detail.value
}} }}
value={inputData[key]} value={inputData[key]}

View File

@ -4,7 +4,9 @@
import CreateRowButton from "./buttons/CreateRowButton.svelte" import CreateRowButton from "./buttons/CreateRowButton.svelte"
import CreateColumnButton from "./buttons/CreateColumnButton.svelte" import CreateColumnButton from "./buttons/CreateColumnButton.svelte"
import CreateViewButton from "./buttons/CreateViewButton.svelte" import CreateViewButton from "./buttons/CreateViewButton.svelte"
import ExistingRelationshipButton from "./buttons/ExistingRelationshipButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte" import ExportButton from "./buttons/ExportButton.svelte"
import ImportButton from "./buttons/ImportButton.svelte"
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte" import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
@ -98,9 +100,7 @@
on:updatecolumns={onUpdateColumns} on:updatecolumns={onUpdateColumns}
on:updaterows={onUpdateRows} on:updaterows={onUpdateRows}
> >
{#if isInternal}
<CreateColumnButton on:updatecolumns={onUpdateColumns} /> <CreateColumnButton on:updatecolumns={onUpdateColumns} />
{/if}
{#if schema && Object.keys(schema).length > 0} {#if schema && Object.keys(schema).length > 0}
{#if !isUsersTable} {#if !isUsersTable}
<CreateRowButton <CreateRowButton
@ -116,9 +116,19 @@
{#if isUsersTable} {#if isUsersTable}
<EditRolesButton /> <EditRolesButton />
{/if} {/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
on:updatecolumns={onUpdateColumns}
/>
{/if}
<HideAutocolumnButton bind:hideAutocolumns /> <HideAutocolumnButton bind:hideAutocolumns />
<!-- always have the export last --> <!-- always have the export last -->
<ExportButton view={$tables.selected?._id} /> <ExportButton view={$tables.selected?._id} />
<ImportButton
tableId={$tables.selected?._id}
on:updaterows={onUpdateRows}
/>
{#key id} {#key id}
<TableFilterButton {schema} on:change={onFilter} /> <TableFilterButton {schema} on:change={onFilter} />
{/key} {/key}

View File

@ -16,8 +16,8 @@
export let value = defaultValue || (meta.type === "boolean" ? false : "") export let value = defaultValue || (meta.type === "boolean" ? false : "")
export let readonly export let readonly
$: type = meta.type $: type = meta?.type
$: label = capitalise(meta.name) $: label = meta.name ? capitalise(meta.name) : ""
</script> </script>
{#if type === "options"} {#if type === "options"}

View File

@ -8,7 +8,11 @@
import CreateEditRow from "./modals/CreateEditRow.svelte" import CreateEditRow from "./modals/CreateEditRow.svelte"
import CreateEditUser from "./modals/CreateEditUser.svelte" import CreateEditUser from "./modals/CreateEditUser.svelte"
import CreateEditColumn from "./modals/CreateEditColumn.svelte" import CreateEditColumn from "./modals/CreateEditColumn.svelte"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import {
TableNames,
UNEDITABLE_USER_FIELDS,
UNSORTABLE_TYPES,
} from "constants"
import RoleCell from "./cells/RoleCell.svelte" import RoleCell from "./cells/RoleCell.svelte"
export let schema = {} export let schema = {}
@ -33,6 +37,15 @@
$: isUsersTable = tableId === TableNames.USERS $: isUsersTable = tableId === TableNames.USERS
$: data && resetSelectedRows() $: data && resetSelectedRows()
$: editRowComponent = isUsersTable ? CreateEditUser : CreateEditRow $: editRowComponent = isUsersTable ? CreateEditUser : CreateEditRow
$: {
UNSORTABLE_TYPES.forEach(type => {
Object.values(schema).forEach(col => {
if (col.type === type) {
col.sortable = false
}
})
})
}
$: { $: {
if (isUsersTable) { if (isUsersTable) {
customRenderers = [ customRenderers = [
@ -129,7 +142,7 @@
bind:selectedRows bind:selectedRows
allowSelectRows={allowEditing && !isUsersTable} allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing} allowEditRows={allowEditing}
allowEditColumns={allowEditing && isInternal} allowEditColumns={allowEditing}
showAutoColumns={!hideAutocolumns} showAutoColumns={!hideAutocolumns}
on:editcolumn={e => editColumn(e.detail)} on:editcolumn={e => editColumn(e.detail)}
on:editrow={e => editRow(e.detail)} on:editrow={e => editRow(e.detail)}

View File

@ -0,0 +1,54 @@
<script>
import { ActionButton, Modal, notifications } from "@budibase/bbui"
import CreateEditRelationship from "../../Datasources/CreateEditRelationship.svelte"
import { datasources, tables } from "../../../../stores/backend"
import { createEventDispatcher } from "svelte"
export let table
const dispatch = createEventDispatcher()
$: plusTables = datasource?.plus
? Object.values(datasource?.entities || {})
: []
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
)
let modal
async function saveRelationship() {
try {
// Create datasource
await datasources.save(datasource)
notifications.success(`Relationship information saved.`)
const tableList = await tables.fetch()
await tables.select(tableList.find(tbl => tbl._id === table._id))
dispatch("updatecolumns")
} catch (err) {
notifications.error(`Error saving relationship info: ${err}`)
}
}
</script>
{#if table.sourceId}
<div>
<ActionButton
icon="DataCorrelated"
primary
size="S"
quiet
on:click={modal.show}
>
Define existing relationship
</ActionButton>
</div>
<Modal bind:this={modal}>
<CreateEditRelationship
{datasource}
save={saveRelationship}
close={modal.hide}
{plusTables}
selectedFromTable={table}
/>
</Modal>
{/if}

View File

@ -7,7 +7,7 @@
let modal let modal
</script> </script>
<ActionButton icon="Download" size="S" quiet on:click={modal.show}> <ActionButton icon="DataDownload" size="S" quiet on:click={modal.show}>
Export Export
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -0,0 +1,15 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import ImportModal from "../modals/ImportModal.svelte"
export let tableId
let modal
</script>
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show}>
Import
</ActionButton>
<Modal bind:this={modal}>
<ImportModal {tableId} on:updaterows />
</Modal>

View File

@ -18,6 +18,11 @@
FIELDS, FIELDS,
AUTO_COLUMN_SUB_TYPES, AUTO_COLUMN_SUB_TYPES,
RelationshipTypes, RelationshipTypes,
ALLOWABLE_STRING_OPTIONS,
ALLOWABLE_NUMBER_OPTIONS,
ALLOWABLE_STRING_TYPES,
ALLOWABLE_NUMBER_TYPES,
SWITCHABLE_TYPES,
} from "constants/backend" } from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils" import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -31,6 +36,9 @@
const AUTO_TYPE = "auto" const AUTO_TYPE = "auto"
const FORMULA_TYPE = FIELDS.FORMULA.type const FORMULA_TYPE = FIELDS.FORMULA.type
const LINK_TYPE = FIELDS.LINK.type const LINK_TYPE = FIELDS.LINK.type
const STRING_TYPE = FIELDS.STRING.type
const NUMBER_TYPE = FIELDS.NUMBER.type
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { hide } = getContext(Context.Modal) const { hide } = getContext(Context.Modal)
@ -55,9 +63,7 @@
let confirmDeleteDialog let confirmDeleteDialog
let deletion let deletion
$: tableOptions = $tables.list.filter( $: checkConstraints(field)
table => table._id !== $tables.draft._id && table.type !== "external"
)
$: required = !!field?.constraints?.presence || primaryDisplay $: required = !!field?.constraints?.presence || primaryDisplay
$: uneditable = $: uneditable =
$tables.selected?._id === TableNames.USERS && $tables.selected?._id === TableNames.USERS &&
@ -83,6 +89,17 @@
$: canBeRequired = $: canBeRequired =
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE
$: relationshipOptions = getRelationshipOptions(field) $: relationshipOptions = getRelationshipOptions(field)
$: external = table.type === "external"
// in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter(
opt =>
opt._id !== $tables.draft._id &&
opt.type === table.type &&
table.sourceId === opt.sourceId
)
$: typeEnabled =
!originalName ||
(originalName && SWITCHABLE_TYPES.indexOf(field.type) !== -1)
async function saveColumn() { async function saveColumn() {
if (field.type === AUTO_TYPE) { if (field.type === AUTO_TYPE) {
@ -169,7 +186,7 @@
if (!field || !field.tableId) { if (!field || !field.tableId) {
return null return null
} }
const linkTable = tableOptions.find(table => table._id === field.tableId) const linkTable = tableOptions?.find(table => table._id === field.tableId)
if (!linkTable) { if (!linkTable) {
return null return null
} }
@ -193,6 +210,52 @@
}, },
] ]
} }
function getAllowedTypes() {
if (originalName && ALLOWABLE_STRING_TYPES.indexOf(field.type) !== -1) {
return ALLOWABLE_STRING_OPTIONS
} else if (
originalName &&
ALLOWABLE_NUMBER_TYPES.indexOf(field.type) !== -1
) {
return ALLOWABLE_NUMBER_OPTIONS
} else if (!external) {
return [
...Object.values(fieldDefinitions),
{ name: "Auto Column", type: AUTO_TYPE },
]
} else {
return [
FIELDS.STRING,
FIELDS.LONGFORM,
FIELDS.OPTIONS,
FIELDS.DATETIME,
FIELDS.NUMBER,
FIELDS.BOOLEAN,
FIELDS.ARRAY,
FIELDS.FORMULA,
FIELDS.LINK,
]
}
}
function checkConstraints(fieldToCheck) {
// most types need this, just make sure its always present
if (fieldToCheck && !fieldToCheck.constraints) {
fieldToCheck.constraints = {}
}
// some string types may have been built by server, may not always have constraints
if (fieldToCheck.type === STRING_TYPE && !fieldToCheck.constraints.length) {
fieldToCheck.constraints.length = {}
}
// some number types made server-side will be missing constraints
if (
fieldToCheck.type === NUMBER_TYPE &&
!fieldToCheck.constraints.numericality
) {
fieldToCheck.constraints.numericality = {}
}
}
</script> </script>
<ModalContent <ModalContent
@ -211,14 +274,11 @@
/> />
<Select <Select
disabled={originalName} disabled={!typeEnabled}
label="Type" label="Type"
bind:value={field.type} bind:value={field.type}
on:change={handleTypeChange} on:change={handleTypeChange}
options={[ options={getAllowedTypes()}
...Object.values(fieldDefinitions),
{ name: "Auto Column", type: AUTO_TYPE },
]}
getOptionLabel={field => field.name} getOptionLabel={field => field.name}
getOptionValue={field => field.type} getOptionValue={field => field.type}
/> />
@ -245,7 +305,7 @@
</div> </div>
{/if} {/if}
{#if canBeSearched} {#if canBeSearched && !external}
<div> <div>
<Label grey small>Search Indexes</Label> <Label grey small>Search Indexes</Label>
<Toggle <Toggle

View File

@ -0,0 +1,43 @@
<script>
import { ModalContent, Label, notifications, Body } from "@budibase/bbui"
import TableDataImport from "../../TableNavigator/TableDataImport.svelte"
import api from "builderStore/api"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let tableId
let dataImport
$: valid = dataImport?.csvString != null && dataImport?.valid
async function importData() {
const response = await api.post(`/api/tables/${tableId}/import`, {
dataImport,
})
if (response.status !== 200) {
const error = await response.text()
notifications.error(`Unable to import data - ${error}`)
} else {
notifications.success("Rows successfully imported.")
}
dispatch("updaterows")
}
</script>
<ModalContent
title="Import Data"
confirmText="Import"
onConfirm={importData}
disabled={!valid}
>
<Body
>Import rows to an existing table from a CSV. Only columns from the CSV
which exist in the table will be imported.</Body
>
<Label grey extraSmall>CSV to import</Label>
<TableDataImport bind:dataImport bind:existingTableId={tableId} />
</ModalContent>
<style>
</style>

View File

@ -18,10 +18,19 @@
export let fromRelationship = {} export let fromRelationship = {}
export let toRelationship = {} export let toRelationship = {}
export let close export let close
export let selectedFromTable
let originalFromName = fromRelationship.name, let originalFromName = fromRelationship.name,
originalToName = toRelationship.name originalToName = toRelationship.name
if (fromRelationship && !fromRelationship.relationshipType) {
fromRelationship.relationshipType = RelationshipTypes.MANY_TO_ONE
}
if (toRelationship && selectedFromTable) {
toRelationship.tableId = selectedFromTable._id
}
function inSchema(table, prop, ogName) { function inSchema(table, prop, ogName) {
if (!table || !prop || prop === ogName) { if (!table || !prop || prop === ogName) {
return false return false
@ -114,6 +123,7 @@
}, },
] ]
$: updateRelationshipType(fromRelationship?.relationshipType) $: updateRelationshipType(fromRelationship?.relationshipType)
$: tableChanged(fromTable, toTable)
function updateRelationshipType(fromType) { function updateRelationshipType(fromType) {
if (fromType === RelationshipTypes.MANY_TO_MANY) { if (fromType === RelationshipTypes.MANY_TO_MANY) {
@ -205,7 +215,6 @@
originalToName = toRelationship.name originalToName = toRelationship.name
originalFromName = fromRelationship.name originalFromName = fromRelationship.name
await save() await save()
await tables.fetch()
} }
async function deleteRelationship() { async function deleteRelationship() {
@ -215,10 +224,26 @@
await tables.fetch() await tables.fetch()
close() close()
} }
function tableChanged(fromTbl, toTbl) {
fromRelationship.name = toTbl?.name || ""
errors.fromCol = ""
toRelationship.name = fromTbl?.name || ""
errors.toCol = ""
if (toTbl || fromTbl) {
checkForErrors(
fromTable,
toTable,
through,
fromRelationship,
toRelationship
)
}
}
</script> </script>
<ModalContent <ModalContent
title="Create Relationship" title="Define Relationship"
confirmText="Save" confirmText="Save"
onConfirm={saveRelationship} onConfirm={saveRelationship}
disabled={!valid} disabled={!valid}
@ -234,6 +259,7 @@
<Select <Select
label="Select from table" label="Select from table"
options={tableOptions} options={tableOptions}
disabled={!!selectedFromTable}
on:change={() => ($touched.from = true)} on:change={() => ($touched.from = true)}
bind:error={errors.from} bind:error={errors.from}
bind:value={toRelationship.tableId} bind:value={toRelationship.tableId}

View File

@ -1,6 +1,5 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select, InlineAlert, notifications } from "@budibase/bbui"
import { notifications } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import api from "builderStore/api" import api from "builderStore/api"
@ -12,11 +11,13 @@
valid: true, valid: true,
schema: {}, schema: {},
} }
export let existingTableId
let csvString let csvString = undefined
let primaryDisplay let primaryDisplay = undefined
let schema = {} let schema = {}
let fields = [] let fields = []
let hasValidated = false
$: valid = !schema || fields.every(column => schema[column].success) $: valid = !schema || fields.every(column => schema[column].success)
$: dataImport = { $: dataImport = {
@ -25,6 +26,9 @@
csvString, csvString,
primaryDisplay, primaryDisplay,
} }
$: noFieldsError = existingTableId
? "No columns in CSV match existing table schema"
: "Could not find any columns to import"
function buildTableSchema(schema) { function buildTableSchema(schema) {
const tableSchema = {} const tableSchema = {}
@ -46,6 +50,7 @@
const response = await api.post("/api/tables/csv/validate", { const response = await api.post("/api/tables/csv/validate", {
csvString, csvString,
schema: schema || {}, schema: schema || {},
tableId: existingTableId,
}) })
const parseResult = await response.json() const parseResult = await response.json()
@ -63,6 +68,7 @@
notifications.error("CSV Invalid, please try another CSV file") notifications.error("CSV Invalid, please try another CSV file")
return [] return []
} }
hasValidated = true
} }
async function handleFile(evt) { async function handleFile(evt) {
@ -138,6 +144,7 @@
placeholder={null} placeholder={null}
getOptionLabel={option => option.label} getOptionLabel={option => option.label}
getOptionValue={option => option.value} getOptionValue={option => option.value}
disabled={!!existingTableId}
/> />
<span class="field-status" class:error={!schema[columnName].success}> <span class="field-status" class:error={!schema[columnName].success}>
{schema[columnName].success ? "Success" : "Failure"} {schema[columnName].success ? "Success" : "Failure"}
@ -149,9 +156,7 @@
</div> </div>
{/each} {/each}
</div> </div>
{/if} {#if !existingTableId}
{#if fields.length}
<div class="display-column"> <div class="display-column">
<Select <Select
label="Display Column" label="Display Column"
@ -161,6 +166,15 @@
/> />
</div> </div>
{/if} {/if}
{:else if hasValidated}
<div>
<InlineAlert
header="Invalid CSV"
bind:message={noFieldsError}
type="error"
/>
</div>
{/if}
<style> <style>
.dropzone { .dropzone {

View File

@ -1,7 +1,7 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { allScreens, store } from "builderStore" import { allScreens, store } from "builderStore"
import { tables } from "stores/backend" import { tables, datasources } from "stores/backend"
import { import {
ActionMenu, ActionMenu,
Icon, Icon,
@ -40,7 +40,10 @@
store.actions.screens.delete(templateScreens) store.actions.screens.delete(templateScreens)
await tables.fetch() await tables.fetch()
notifications.success("Table deleted") notifications.success("Table deleted")
if (wasSelectedTable._id === table._id) { if (table.type === "external") {
await datasources.fetch()
}
if (wasSelectedTable && wasSelectedTable._id === table._id) {
$goto("./table") $goto("./table")
} }
} }
@ -64,9 +67,7 @@
<Icon s hoverable name="MoreSmallList" /> <Icon s hoverable name="MoreSmallList" />
</div> </div>
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem> <MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>
{#if !external}
<MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem> <MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem>
{/if}
</ActionMenu> </ActionMenu>
<Modal bind:this={editorModal}> <Modal bind:this={editorModal}>

View File

@ -8,6 +8,7 @@
export let onOk = undefined export let onOk = undefined
export let onCancel = undefined export let onCancel = undefined
export let warning = true export let warning = true
export let disabled
let modal let modal
@ -26,6 +27,7 @@
confirmText={okText} confirmText={okText}
{cancelText} {cancelText}
{warning} {warning}
{disabled}
> >
<Body size="S"> <Body size="S">
{body} {body}

View File

@ -17,6 +17,7 @@
export let disabled = false export let disabled = false
export let options export let options
export let allowJS = true export let allowJS = true
export let appendBindingsAsOptions = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
@ -24,15 +25,30 @@
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue $: tempValue = readableValue
$: isJS = isJSBinding(value) $: isJS = isJSBinding(value)
$: allOptions = buildOptions(options, bindings, appendBindingsAsOptions)
const handleClose = () => { const handleClose = () => {
onChange(tempValue) onChange(tempValue)
bindingDrawer.hide() bindingDrawer.hide()
} }
const onChange = value => { const onChange = (value, optionPicked) => {
// Add HBS braces if picking binding
if (optionPicked && !options?.includes(value)) {
value = `{{ ${value} }}`
}
dispatch("change", readableToRuntimeBinding(bindings, value)) dispatch("change", readableToRuntimeBinding(bindings, value))
} }
const buildOptions = (options, bindings, appendBindingsAsOptions) => {
if (!appendBindingsAsOptions) {
return options
}
return []
.concat(options || [])
.concat(bindings?.map(binding => binding.readableBinding) || [])
}
</script> </script>
<div class="control"> <div class="control">
@ -40,12 +56,17 @@
{label} {label}
{disabled} {disabled}
value={isJS ? "(JavaScript function)" : readableValue} value={isJS ? "(JavaScript function)" : readableValue}
on:change={event => onChange(event.detail)} on:type={e => onChange(e.detail, false)}
on:pick={e => onChange(e.detail, true)}
{placeholder} {placeholder}
{options} options={allOptions}
/> />
{#if !disabled} {#if !disabled}
<div class="icon" on:click={bindingDrawer.show}> <div
class="icon"
on:click={bindingDrawer.show}
data-cy="text-binding-button"
>
<Icon size="S" name="FlashOn" /> <Icon size="S" name="FlashOn" />
</div> </div>
{/if} {/if}

View File

@ -1,9 +1,16 @@
<script> <script>
import { Icon, Modal, notifications, ModalContent } from "@budibase/bbui" import {
Icon,
Input,
Modal,
notifications,
ModalContent,
} from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import api from "builderStore/api" import api from "builderStore/api"
let revertModal let revertModal
let appName
$: appId = $store.appId $: appId = $store.appId
@ -33,10 +40,17 @@
<Icon name="Revert" hoverable on:click={revertModal.show} /> <Icon name="Revert" hoverable on:click={revertModal.show} />
<Modal bind:this={revertModal}> <Modal bind:this={revertModal}>
<ModalContent title="Revert Changes" confirmText="Revert" onConfirm={revert}> <ModalContent
title="Revert Changes"
confirmText="Revert"
onConfirm={revert}
disabled={appName !== $store.name}
>
<span <span
>The changes you have made will be deleted and the application reverted >The changes you have made will be deleted and the application reverted
back to its production state.</span back to its production state.</span
> >
<span>Please enter your app name to continue.</span>
<Input bind:value={appName} />
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@ -33,6 +33,15 @@
.instanceName("Content Placeholder") .instanceName("Content Placeholder")
.json() .json()
// Messages that can be sent from the iframe preview to the builder
// Budibase events are and initalisation events
const MessageTypes = {
IFRAME_LOADED: "iframe-loaded",
READY: "ready",
ERROR: "error",
BUDIBASE: "type",
}
// Construct iframe template // Construct iframe template
$: template = iframeTemplate.replace( $: template = iframeTemplate.replace(
/\{\{ CLIENT_LIB_PATH }}/, /\{\{ CLIENT_LIB_PATH }}/,
@ -59,6 +68,7 @@
theme: $store.theme, theme: $store.theme,
customTheme: $store.customTheme, customTheme: $store.customTheme,
previewDevice: $store.previewDevice, previewDevice: $store.previewDevice,
messagePassing: $store.clientFeatures.messagePassing,
} }
// Saving pages and screens to the DB causes them to have _revs. // Saving pages and screens to the DB causes them to have _revs.
@ -80,11 +90,14 @@
// Refresh the preview when required // Refresh the preview when required
$: refreshContent(strippedJson) $: refreshContent(strippedJson)
onMount(() => { function receiveMessage(message) {
const handlers = {
[MessageTypes.READY]: () => {
// Initialise the app when mounted // Initialise the app when mounted
iframe.contentWindow.addEventListener( if ($store.clientFeatures.messagePassing) {
"ready", if (!loading) return
() => { }
// Display preview immediately if the intelligent loading feature // Display preview immediately if the intelligent loading feature
// is not supported // is not supported
if (!$store.clientFeatures.intelligentLoading) { if (!$store.clientFeatures.intelligentLoading) {
@ -92,32 +105,58 @@
} }
refreshContent(strippedJson) refreshContent(strippedJson)
}, },
{ once: true } [MessageTypes.ERROR]: event => {
)
// Catch any app errors // Catch any app errors
iframe.contentWindow.addEventListener(
"error",
event => {
loading = false loading = false
error = event.detail || "An unknown error occurred" error = event.error || "An unknown error occurred"
},
}
const messageHandler = handlers[message.data.type] || handleBudibaseEvent
messageHandler(message)
}
onMount(() => {
window.addEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.addEventListener(
"ready",
() => {
receiveMessage({ data: { type: MessageTypes.READY } })
},
{ once: true }
)
iframe.contentWindow.addEventListener(
"error",
event => {
receiveMessage({
data: { type: MessageTypes.ERROR, error: event.detail },
})
}, },
{ once: true } { once: true }
) )
// Add listener for events sent by client library in preview // Add listener for events sent by client library in preview
iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent) iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent)
}
}) })
// Remove all iframe event listeners on component destroy // Remove all iframe event listeners on component destroy
onDestroy(() => { onDestroy(() => {
if (iframe.contentWindow) { if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener("bb-event", handleBudibaseEvent) window.removeEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.removeEventListener(
"bb-event",
handleBudibaseEvent
)
}
} }
}) })
const handleBudibaseEvent = event => { const handleBudibaseEvent = event => {
const { type, data } = event.detail const { type, data } = event.data || event.detail
if (type === "select-component" && data.id) { if (type === "select-component" && data.id) {
store.actions.components.select({ _id: data.id }) store.actions.components.select({ _id: data.id })
} else if (type === "update-prop") { } else if (type === "update-prop") {
@ -149,7 +188,7 @@
store.actions.components.paste(destination, data.mode) store.actions.components.paste(destination, data.mode)
} }
} else { } else {
console.warning(`Client sent unknown event type: ${type}`) console.warn(`Client sent unknown event type: ${type}`)
} }
} }

View File

@ -1,4 +1,13 @@
[ [
{
"name": "Blocks",
"icon": "Article",
"children": [
"tableblock",
"cardsblock",
"repeaterblock"
]
},
"section", "section",
"container", "container",
"dataprovider", "dataprovider",

View File

@ -84,17 +84,21 @@ export default `
if (window.loadBudibase) { if (window.loadBudibase) {
window.loadBudibase() window.loadBudibase()
document.documentElement.classList.add("loaded") document.documentElement.classList.add("loaded")
window.dispatchEvent(new Event("iframe-loaded")) window.parent.postMessage({ type: "iframe-loaded" })
} else { } else {
throw "The client library couldn't be loaded" throw "The client library couldn't be loaded"
} }
} catch (error) { } catch (error) {
window.dispatchEvent(new CustomEvent("error", { detail: error })) window.parent.postMessage({ type: "error", error })
} }
} }
window.addEventListener("message", receiveMessage) window.addEventListener("message", receiveMessage)
window.dispatchEvent(new Event("ready")) window.addEventListener("keydown", evt => {
window.parent.postMessage({ type: "keydown", key: event.key })
})
window.parent.postMessage({ type: "ready" })
</script> </script>
</head> </head>
<body/> <body/>

View File

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

View File

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

View File

@ -13,9 +13,10 @@
import { generate } from "shortid" import { generate } from "shortid"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { OperatorOptions, getValidOperatorsForType } from "constants/lucene" import { OperatorOptions, getValidOperatorsForType } from "constants/lucene"
import { selectedComponent, store } from "builderStore" import { selectedComponent } from "builderStore"
import { getComponentForSettingType } from "./componentSettings" import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte" import PropertyControl from "./PropertyControl.svelte"
import { getComponentSettings } from "builderStore/storeUtils"
export let conditions = [] export let conditions = []
export let bindings = [] export let bindings = []
@ -55,15 +56,11 @@
] ]
let dragDisabled = true let dragDisabled = true
$: definition = store.actions.components.getDefinition( $: settings = getComponentSettings($selectedComponent?._component)
$selectedComponent?._component $: settingOptions = settings.map(setting => ({
)
$: settings = (definition?.settings ?? []).map(setting => {
return {
label: setting.label, label: setting.label,
value: setting.key, value: setting.key,
} }))
})
$: conditions.forEach(link => { $: conditions.forEach(link => {
if (!link.id) { if (!link.id) {
link.id = generate() link.id = generate()
@ -71,9 +68,7 @@
}) })
const getSettingDefinition = key => { const getSettingDefinition = key => {
return definition?.settings?.find(setting => { return settings.find(setting => setting.key === key)
return setting.key === key
})
} }
const getComponentForSetting = key => { const getComponentForSetting = key => {
@ -175,7 +170,10 @@
bind:value={condition.action} bind:value={condition.action}
/> />
{#if condition.action === "update"} {#if condition.action === "update"}
<Select options={settings} bind:value={condition.setting} /> <Select
options={settingOptions}
bind:value={condition.setting}
/>
<div>TO</div> <div>TO</div>
{#if getSettingDefinition(condition.setting)} {#if getSettingDefinition(condition.setting)}
<PropertyControl <PropertyControl

View File

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

View File

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

View File

@ -1,15 +0,0 @@
<script>
import { Input } from "@budibase/bbui"
import { isJSBinding } from "@budibase/string-templates"
export let value
$: isJS = isJSBinding(value)
</script>
<Input
{...$$props}
value={isJS ? "(JavaScript function)" : value}
readonly={isJS}
on:change
/>

View File

@ -1,14 +1,11 @@
<script> <script>
import { Button, Icon, Drawer, Label } from "@budibase/bbui" import { Label } from "@budibase/bbui"
import { import {
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
import { capitalise } from "helpers"
export let label = "" export let label = ""
export let bindable = true
export let componentInstance = {} export let componentInstance = {}
export let control = null export let control = null
export let key = "" export let key = ""
@ -17,18 +14,19 @@
export let props = {} export let props = {}
export let onChange = () => {} export let onChange = () => {}
export let bindings = [] export let bindings = []
export let componentBindings = []
export let nested = false
let bindingDrawer $: allBindings = getAllBindings(bindings, componentBindings, nested)
let anchor $: safeValue = getSafeValue(value, props.defaultValue, allBindings)
let valid
$: safeValue = getSafeValue(value, props.defaultValue, bindings)
$: tempValue = safeValue $: tempValue = safeValue
$: replaceBindings = val => readableToRuntimeBinding(bindings, val) $: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
const handleClose = () => { const getAllBindings = (bindings, componentBindings, nested) => {
handleChange(tempValue) if (!nested) {
bindingDrawer.hide() return bindings
}
return [...(componentBindings || []), ...(bindings || [])]
} }
// Handle a value change of any type // Handle a value change of any type
@ -64,7 +62,7 @@
} }
</script> </script>
<div class="property-control" bind:this={anchor} data-cy={`setting-${key}`}> <div class="property-control" data-cy={`setting-${key}`}>
{#if type !== "boolean" && label} {#if type !== "boolean" && label}
<div class="label"> <div class="label">
<Label>{label}</Label> <Label>{label}</Label>
@ -78,37 +76,12 @@
updateOnChange={false} updateOnChange={false}
on:change={handleChange} on:change={handleChange}
onChange={handleChange} onChange={handleChange}
{bindings} bindings={allBindings}
name={key} name={key}
text={label} text={label}
{type} {type}
{...props} {...props}
/> />
{#if bindable && !key.startsWith("_") && type === "text"}
<div
class="icon"
data-cy={`${key}-binding-button`}
on:click={bindingDrawer.show}
>
<Icon size="S" name="FlashOn" />
</div>
<Drawer bind:this={bindingDrawer} title={capitalise(key)}>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" disabled={!valid} on:click={handleClose}>
Save
</Button>
<BindingPanel
slot="body"
bind:valid
value={safeValue}
on:change={e => (tempValue = e.detail)}
bindableProperties={bindings}
allowJS
/>
</Drawer>
{/if}
</div> </div>
</div> </div>
@ -120,41 +93,10 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
.label { .label {
text-transform: capitalize;
padding-bottom: var(--spectrum-global-dimension-size-65); padding-bottom: var(--spectrum-global-dimension-size-65);
} }
.control { .control {
position: relative; position: relative;
} }
.icon {
right: 1px;
top: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
</style> </style>

View File

@ -10,4 +10,10 @@
.filter(x => x != null) .filter(x => x != null)
</script> </script>
<DrawerBindableCombobox {value} {bindings} on:change options={urlOptions} /> <DrawerBindableCombobox
{value}
{bindings}
on:change
options={urlOptions}
appendBindingsAsOptions={false}
/>

View File

@ -15,10 +15,10 @@ import URLSelect from "./URLSelect.svelte"
import OptionsEditor from "./OptionsEditor/OptionsEditor.svelte" import OptionsEditor from "./OptionsEditor/OptionsEditor.svelte"
import FormFieldSelect from "./FormFieldSelect.svelte" import FormFieldSelect from "./FormFieldSelect.svelte"
import ValidationEditor from "./ValidationEditor/ValidationEditor.svelte" import ValidationEditor from "./ValidationEditor/ValidationEditor.svelte"
import Input from "./Input.svelte" import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
const componentMap = { const componentMap = {
text: Input, text: DrawerBindableCombobox,
select: Select, select: Select,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,
dataProvider: DataProviderSelect, dataProvider: DataProviderSelect,

View File

@ -54,7 +54,6 @@
<DetailSummary name="Screen" collapsible={false}> <DetailSummary name="Screen" collapsible={false}>
{#each screenSettings as def (`${componentInstance._id}-${def.key}`)} {#each screenSettings as def (`${componentInstance._id}-${def.key}`)}
<PropertyControl <PropertyControl
bindable={false}
control={def.control} control={def.control}
label={def.label} label={def.label}
key={def.key} key={def.key}

View File

@ -30,7 +30,6 @@
{#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)} {#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)}
<div style="grid-column: {prop.column || 'auto'}"> <div style="grid-column: {prop.column || 'auto'}">
<PropertyControl <PropertyControl
bindable={false}
label={`${prop.label}${hasPropChanged(style, prop) ? " *" : ""}`} label={`${prop.label}${hasPropChanged(style, prop) ? " *" : ""}`}
control={prop.control} control={prop.control}
key={prop.key} key={prop.key}

View File

@ -9,7 +9,6 @@ export const margin = {
label: "Top", label: "Top",
key: "margin-top", key: "margin-top",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -30,7 +29,6 @@ export const margin = {
label: "Right", label: "Right",
key: "margin-right", key: "margin-right",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -51,7 +49,6 @@ export const margin = {
label: "Bottom", label: "Bottom",
key: "margin-bottom", key: "margin-bottom",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -72,7 +69,6 @@ export const margin = {
label: "Left", label: "Left",
key: "margin-left", key: "margin-left",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -100,7 +96,6 @@ export const padding = {
label: "Top", label: "Top",
key: "padding-top", key: "padding-top",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -121,7 +116,6 @@ export const padding = {
label: "Right", label: "Right",
key: "padding-right", key: "padding-right",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -142,7 +136,6 @@ export const padding = {
label: "Bottom", label: "Bottom",
key: "padding-bottom", key: "padding-bottom",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },
@ -163,7 +156,6 @@ export const padding = {
label: "Left", label: "Left",
key: "padding-left", key: "padding-left",
control: Select, control: Select,
bindable: false,
placeholder: "None", placeholder: "None",
options: [ options: [
{ label: "4px", value: "4px" }, { label: "4px", value: "4px" },

View File

@ -9,7 +9,7 @@
Checkbox, Checkbox,
} from "@budibase/bbui" } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore" import { store, automationStore, hostingStore } from "builderStore"
import { admin } from "stores/portal" import { admin, auth } from "stores/portal"
import { string, mixed, object } from "yup" import { string, mixed, object } from "yup"
import api, { get, post } from "builderStore/api" import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
@ -139,6 +139,7 @@
} }
const userResp = await api.post(`/api/users/metadata/self`, user) const userResp = await api.post(`/api/users/metadata/self`, user)
await userResp.json() await userResp.json()
await auth.setInitInfo({})
$goto(`/builder/app/${appJson.instance._id}`) $goto(`/builder/app/${appJson.instance._id}`)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -146,6 +147,21 @@
submitting = false submitting = false
} }
} }
function getModalTitle() {
let title = "Create App"
if (template.fromFile) {
title = "Import App"
} else if (template.key) {
title = "Create app from template"
}
return title
}
async function onCancel() {
template = null
await auth.setInitInfo({})
}
</script> </script>
{#if showTemplateSelection} {#if showTemplateSelection}
@ -172,10 +188,10 @@
</ModalContent> </ModalContent>
{:else} {:else}
<ModalContent <ModalContent
title={template?.fromFile ? "Import app" : "Create app"} title={getModalTitle()}
confirmText={template?.fromFile ? "Import app" : "Create app"} confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp} onConfirm={createNewApp}
onCancel={inline ? () => (template = null) : null} onCancel={inline ? onCancel : null}
cancelText={inline ? "Back" : undefined} cancelText={inline ? "Back" : undefined}
showCloseIcon={!inline} showCloseIcon={!inline}
disabled={!valid} disabled={!valid}

View File

@ -37,6 +37,10 @@
<p class="detail">{template?.category?.toUpperCase()}</p> <p class="detail">{template?.category?.toUpperCase()}</p>
</div> </div>
{/each} {/each}
</div>
{:catch err}
<h1 style="color:red">{err}</h1>
{/await}
<div class="template start-from-scratch" on:click={() => onSelect(null)}> <div class="template start-from-scratch" on:click={() => onSelect(null)}>
<div <div
class="background-icon" class="background-icon"
@ -60,10 +64,6 @@
<Heading size="XS">Import an app</Heading> <Heading size="XS">Import an app</Heading>
<p class="detail">BLANK</p> <p class="detail">BLANK</p>
</div> </div>
</div>
{:catch err}
<h1 style="color:red">{err}</h1>
{/await}
</Layout> </Layout>
<style> <style>

View File

@ -138,3 +138,19 @@ export const RelationshipTypes = {
ONE_TO_MANY: "one-to-many", ONE_TO_MANY: "one-to-many",
MANY_TO_ONE: "many-to-one", MANY_TO_ONE: "many-to-one",
} }
export const ALLOWABLE_STRING_OPTIONS = [FIELDS.STRING, FIELDS.OPTIONS]
export const ALLOWABLE_STRING_TYPES = ALLOWABLE_STRING_OPTIONS.map(
opt => opt.type
)
export const ALLOWABLE_NUMBER_OPTIONS = [FIELDS.NUMBER, FIELDS.BOOLEAN]
export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
opt => opt.type
)
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat(
ALLOWABLE_STRING_TYPES
)

View File

@ -39,6 +39,8 @@ export const UNEDITABLE_USER_FIELDS = [
"lastName", "lastName",
] ]
export const UNSORTABLE_TYPES = ["formula", "attachment", "array", "link"]
export const LAYOUT_NAMES = { export const LAYOUT_NAMES = {
MASTER: { MASTER: {
PRIVATE: "layout_private_master", PRIVATE: "layout_private_master",

View File

@ -1,5 +1,5 @@
<script> <script>
import { isActive, redirect } from "@roxi/routify" import { isActive, redirect, params } from "@roxi/routify"
import { admin, auth } from "stores/portal" import { admin, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
@ -28,9 +28,13 @@
} }
if (user && user.tenantId) { if (user && user.tenantId) {
// no tenant in the url - send to account portal to fix this
if (!urlTenantId) { if (!urlTenantId) {
window.location.href = $admin.accountPortalUrl // redirect to correct tenantId subdomain
if (!window.location.host.includes("localhost")) {
let redirectUrl = window.location.href
redirectUrl = redirectUrl.replace("://", `://${user.tenantId}.`)
window.location.href = redirectUrl
}
return return
} }
@ -47,6 +51,10 @@
} }
onMount(async () => { onMount(async () => {
if ($params["?template"]) {
await auth.setInitInfo({ init_template: $params["?template"] })
}
await auth.checkAuth() await auth.checkAuth()
await admin.init() await admin.init()

View File

@ -8,17 +8,18 @@
Layout, Layout,
Modal, Modal,
InlineAlert, InlineAlert,
ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { datasources, integrations, queries, tables } from "stores/backend" import { datasources, integrations, queries, tables } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import CreateEditRelationship from "./CreateEditRelationship/CreateEditRelationship.svelte" import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
import DisplayColumnModal from "./modals/EditDisplayColumnsModal.svelte" import CreateExternalTableModal from "./modals/CreateExternalTableModal.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { capitalise } from "helpers" import { capitalise } from "helpers"
let relationshipModal let relationshipModal
let displayColumnModal let createExternalTableModal
let selectedFromRelationship, selectedToRelationship let selectedFromRelationship, selectedToRelationship
$: datasource = $datasources.list.find(ds => ds._id === $datasources.selected) $: datasource = $datasources.list.find(ds => ds._id === $datasources.selected)
@ -78,6 +79,10 @@
try { try {
// Create datasource // Create datasource
await datasources.save(datasource) await datasources.save(datasource)
if (datasource?.plus) {
await tables.fetch()
}
await datasources.fetch()
notifications.success(`Datasource ${name} updated successfully.`) notifications.success(`Datasource ${name} updated successfully.`)
} catch (err) { } catch (err) {
notifications.error(`Error saving datasource: ${err}`) notifications.error(`Error saving datasource: ${err}`)
@ -110,8 +115,8 @@
relationshipModal.show() relationshipModal.show()
} }
function openDisplayColumnModal() { function createNewTable() {
displayColumnModal.show() createExternalTableModal.show()
} }
</script> </script>
@ -126,8 +131,8 @@
/> />
</Modal> </Modal>
<Modal bind:this={displayColumnModal}> <Modal bind:this={createExternalTableModal}>
<DisplayColumnModal {datasource} {plusTables} save={saveDatasource} /> <CreateExternalTableModal {datasource} />
</Modal> </Modal>
{#if datasource && integration} {#if datasource && integration}
@ -163,15 +168,15 @@
<div class="query-header"> <div class="query-header">
<Heading size="S">Tables</Heading> <Heading size="S">Tables</Heading>
<div class="table-buttons"> <div class="table-buttons">
{#if plusTables && plusTables.length !== 0}
<Button primary on:click={openDisplayColumnModal}>
Update display columns
</Button>
{/if}
<div> <div>
<Button primary on:click={updateDatasourceSchema}> <ActionButton
size="S"
quiet
icon="DataRefresh"
on:click={updateDatasourceSchema}
>
Fetch tables from database Fetch tables from database
</Button> </ActionButton>
</div> </div>
</div> </div>
</div> </div>
@ -196,14 +201,23 @@
<p></p> <p></p>
</div> </div>
{/each} {/each}
<div class="add-table">
<Button cta on:click={createNewTable}>Create new table</Button>
</div>
</div> </div>
{#if plusTables?.length !== 0} {#if plusTables?.length !== 0}
<Divider /> <Divider />
<div class="query-header"> <div class="query-header">
<Heading size="S">Relationships</Heading> <Heading size="S">Relationships</Heading>
<Button primary on:click={() => openRelationshipModal()} <ActionButton
>Create relationship</Button icon="DataCorrelated"
primary
size="S"
quiet
on:click={openRelationshipModal}
> >
Define existing relationship
</ActionButton>
</div> </div>
<Body> <Body>
Tell budibase how your tables are related to get even more smart Tell budibase how your tables are related to get even more smart
@ -318,11 +332,14 @@
.table-buttons { .table-buttons {
display: grid; display: grid;
grid-gap: var(--spacing-l);
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.table-buttons div { .table-buttons div {
grid-column-end: -1; grid-column-end: -1;
} }
.add-table {
margin-top: var(--spacing-m);
}
</style> </style>

View File

@ -0,0 +1,45 @@
<script>
import { ModalContent, Body, Input } from "@budibase/bbui"
import { tables, datasources } from "stores/backend"
import { goto } from "@roxi/routify"
export let datasource
let name = ""
$: valid = name && name.length > 0 && !datasource?.entities[name]
$: error =
name && datasource?.entities[name] ? "Table name already in use." : null
function buildDefaultTable(tableName, datasourceId) {
return {
name: tableName,
type: "external",
primary: ["id"],
sourceId: datasourceId,
schema: {
id: {
autocolumn: true,
type: "number",
},
},
}
}
async function saveTable() {
const table = await tables.save(buildDefaultTable(name, datasource._id))
await datasources.fetch()
$goto(`../../table/${table._id}`)
}
</script>
<ModalContent
title="Create new table"
confirmText="Create"
onConfirm={saveTable}
disabled={!valid}
>
<Body
>Provide a name for your new table; you can add columns once it is created.</Body
>
<Input label="Table Name" bind:error bind:value={name} />
</ModalContent>

View File

@ -1,43 +0,0 @@
<script>
import { ModalContent, Select, Body } from "@budibase/bbui"
import { tables } from "stores/backend"
export let datasource
export let plusTables
export let save
async function saveDisplayColumns() {
// be explicit about copying over
for (let table of plusTables) {
datasource.entities[table.name].primaryDisplay = table.primaryDisplay
}
save()
await tables.fetch()
}
function getColumnOptions(table) {
if (!table || !table.schema) {
return []
}
return Object.entries(table.schema)
.filter(field => field[1].type !== "link")
.map(([fieldName]) => fieldName)
}
</script>
<ModalContent
title="Edit display columns"
confirmText="Save"
onConfirm={saveDisplayColumns}
>
<Body
>Select the columns that will be shown when displaying relationships.</Body
>
{#each plusTables as table}
<Select
label={table.name}
options={getColumnOptions(table)}
bind:value={table.primaryDisplay}
/>
{/each}
</ModalContent>

View File

@ -44,7 +44,7 @@
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
notifications.error("Invalid credentials") notifications.error(err.message ? err.message : "Invalid Credentials")
} }
} }

View File

@ -6,6 +6,7 @@
ActionButton, ActionButton,
ActionGroup, ActionGroup,
ButtonGroup, ButtonGroup,
Input,
Select, Select,
Modal, Modal,
Page, Page,
@ -36,6 +37,7 @@
let loaded = false let loaded = false
let searchTerm = "" let searchTerm = ""
let cloud = $admin.cloud let cloud = $admin.cloud
let appName = ""
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: enrichedApps = enrichApps($apps, $auth.user, sortBy)
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
@ -163,6 +165,7 @@
notifications.error(`Error deleting app: ${err}`) notifications.error(`Error deleting app: ${err}`)
} }
selectedApp = null selectedApp = null
appName = null
} }
const updateApp = async app => { const updateApp = async app => {
@ -184,9 +187,27 @@
} }
} }
function createAppFromTemplateUrl(templateKey) {
// validate the template key just to make sure
const templateParts = templateKey.split("/")
if (templateParts.length === 2 && templateParts[0] === "app") {
template = {
key: templateKey,
}
initiateAppCreation()
} else {
notifications.error("Your Template URL is invalid. Please try another.")
}
}
onMount(async () => { onMount(async () => {
await apps.load() await apps.load()
loaded = true loaded = true
// if the portal is loaded from an external URL with a template param
const initInfo = await auth.getInitInfo()
if (initInfo.init_template) {
createAppFromTemplateUrl(initInfo.init_template)
}
}) })
</script> </script>
@ -278,8 +299,13 @@
title="Confirm deletion" title="Confirm deletion"
okText="Delete app" okText="Delete app"
onOk={confirmDeleteApp} onOk={confirmDeleteApp}
onCancel={() => (appName = null)}
disabled={appName !== selectedApp?.name}
> >
Are you sure you want to delete the app <b>{selectedApp?.name}</b>? Are you sure you want to delete the app <b>{selectedApp?.name}</b>?
<p>Please enter the app name below to confirm.</p>
<Input bind:value={appName} data-cy="delete-app-confirmation" />
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog
bind:this={unpublishModal} bind:this={unpublishModal}

View File

@ -2,6 +2,7 @@ import { writable, get } from "svelte/store"
import { views, queries, datasources } from "./" import { views, queries, datasources } from "./"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import api from "builderStore/api" import api from "builderStore/api"
import { SWITCHABLE_TYPES } from "../../constants/backend"
export function createTablesStore() { export function createTablesStore() {
const store = writable({}) const store = writable({})
@ -11,6 +12,7 @@ export function createTablesStore() {
const tablesResponse = await api.get(`/api/tables`) const tablesResponse = await api.get(`/api/tables`)
const tables = await tablesResponse.json() const tables = await tablesResponse.json()
update(state => ({ ...state, list: tables })) update(state => ({ ...state, list: tables }))
return tables
} }
async function select(table) { async function select(table) {
@ -46,7 +48,11 @@ export function createTablesStore() {
const field = updatedTable.schema[key] const field = updatedTable.schema[key]
const oldField = oldTable?.schema[key] const oldField = oldTable?.schema[key]
// if the type has changed then revert back to the old field // if the type has changed then revert back to the old field
if (oldField != null && oldField?.type !== field.type) { if (
oldField != null &&
oldField?.type !== field.type &&
SWITCHABLE_TYPES.indexOf(oldField?.type) === -1
) {
updatedTable.schema[key] = oldField updatedTable.schema[key] = oldField
} }
// field has been renamed // field has been renamed
@ -62,6 +68,9 @@ export function createTablesStore() {
const response = await api.post(`/api/tables`, updatedTable) const response = await api.post(`/api/tables`, updatedTable)
const savedTable = await response.json() const savedTable = await response.json()
await fetch() await fetch()
if (table.type === "external") {
await datasources.fetch()
}
await select(savedTable) await select(savedTable)
return savedTable return savedTable
} }

View File

@ -57,11 +57,11 @@ export function createAuthStore() {
analytics.showChat({ analytics.showChat({
email: user.email, email: user.email,
created_at: (user.createdAt || Date.now()) / 1000, created_at: (user.createdAt || Date.now()) / 1000,
name: user.name, name: user.account?.name,
user_id: user._id, user_id: user._id,
tenant: user.tenantId, tenant: user.tenantId,
"Company size": user.size, "Company size": user.account?.size,
"Job role": user.profession, "Job role": user.account?.profession,
}) })
}) })
} }
@ -80,9 +80,30 @@ export function createAuthStore() {
} }
} }
async function setInitInfo(info) {
await api.post(`/api/global/auth/init`, info)
auth.update(store => {
store.initInfo = info
return store
})
return info
}
async function getInitInfo() {
const response = await api.get(`/api/global/auth/init`)
const json = response.json()
auth.update(store => {
store.initInfo = json
return store
})
return json
}
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
setOrganisation: setOrganisation, setOrganisation,
getInitInfo,
setInitInfo,
checkQueryString: async () => { checkQueryString: async () => {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
if (urlParams.has("tenantId")) { if (urlParams.has("tenantId")) {
@ -112,7 +133,7 @@ export function createAuthStore() {
if (response.status === 200) { if (response.status === 200) {
setUser(json.user) setUser(json.user)
} else { } else {
throw "Invalid credentials" throw new Error(json.message ? json.message : "Invalid credentials")
} }
return json return json
}, },
@ -122,6 +143,7 @@ export function createAuthStore() {
throw "Unable to create logout" throw "Unable to create logout"
} }
await response.json() await response.json()
await setInitInfo({})
setUser(null) setUser(null)
}, },
updateSelf: async fields => { updateSelf: async fields => {

View File

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

View File

@ -5,7 +5,8 @@
"deviceAwareness": true, "deviceAwareness": true,
"state": true, "state": true,
"customThemes": true, "customThemes": true,
"devicePreview": true "devicePreview": true,
"messagePassing": true
}, },
"layout": { "layout": {
"name": "Layout", "name": "Layout",
@ -2481,12 +2482,12 @@
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
"label": "Provider", "label": "Data provider",
"key": "dataProvider" "key": "dataProvider"
}, },
{ {
"type": "number", "type": "number",
"label": "Row Count", "label": "Row count",
"key": "rowCount", "key": "rowCount",
"defaultValue": 8 "defaultValue": 8
}, },
@ -2520,9 +2521,36 @@
}, },
{ {
"type": "boolean", "type": "boolean",
"label": "Auto Columns", "label": "Show auto columns",
"key": "showAutoColumns", "key": "showAutoColumns",
"defaultValue": false "defaultValue": false
},
{
"type": "boolean",
"label": "Link table rows",
"key": "linkRows"
},
{
"type": "boolean",
"label": "Open link screens in modal",
"key": "linkPeek"
},
{
"type": "url",
"label": "Link screen",
"key": "linkURL"
},
{
"section": true,
"name": "Advanced",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
}
]
} }
], ],
"context": { "context": {
@ -2592,10 +2620,539 @@
"key": "linkURL", "key": "linkURL",
"label": "Link URL" "label": "Link URL"
}, },
{
"type": "boolean",
"key": "linkPeek",
"label": "Open link in modal"
},
{ {
"type": "boolean", "type": "boolean",
"key": "horizontal", "key": "horizontal",
"label": "Horizontal" "label": "Horizontal"
},
{
"type": "boolean",
"label": "Show button",
"key": "showButton"
},
{
"type": "text",
"key": "buttonText",
"label": "Button text"
},
{
"type": "event",
"label": "Button action",
"key": "buttonOnClick"
}
]
},
"tableblock": {
"block": true,
"name": "Table block",
"icon": "Table",
"styles": ["size"],
"info": "Only the first 3 search columns will be used.",
"settings": [
{
"type": "text",
"label": "Title",
"key": "title"
},
{
"type": "dataSource",
"label": "Data",
"key": "dataSource"
},
{
"type": "multifield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": ["Ascending", "Descending"],
"defaultValue": "Descending"
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "spectrum--medium",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
]
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
},
{
"section": true,
"name": "Table",
"settings": [
{
"type": "number",
"label": "Row Count",
"key": "rowCount",
"defaultValue": 8
},
{
"type": "multifield",
"label": "Table Columns",
"key": "tableColumns",
"dependsOn": "dataSource",
"placeholder": "All columns"
},
{
"type": "boolean",
"label": "Quiet table variant",
"key": "quiet"
},
{
"type": "boolean",
"label": "Show auto columns",
"key": "showAutoColumns"
},
{
"type": "boolean",
"label": "Link table rows",
"key": "linkRows"
},
{
"type": "boolean",
"label": "Open link in modal",
"key": "linkPeek"
},
{
"type": "url",
"label": "Link screen",
"key": "linkURL"
}
]
},
{
"section": true,
"name": "Title button",
"settings": [
{
"type": "boolean",
"key": "showTitleButton",
"label": "Show link button",
"defaultValue": false
},
{
"type": "boolean",
"label": "Open link in modal",
"key": "titleButtonPeek"
},
{
"type": "text",
"key": "titleButtonText",
"label": "Button text"
},
{
"type": "url",
"label": "Button link",
"key": "titleButtonURL"
}
]
},
{
"section": true,
"name": "Advanced",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
}
]
}
]
},
"cardsblock": {
"block": true,
"name": "Cards block",
"icon": "Table",
"styles": ["size"],
"info": "Only the first 3 search columns will be used.",
"settings": [
{
"type": "text",
"label": "Title",
"key": "title"
},
{
"type": "dataSource",
"label": "Data",
"key": "dataSource"
},
{
"type": "multifield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": ["Ascending", "Descending"],
"defaultValue": "Descending"
},
{
"type": "number",
"label": "Limit",
"key": "limit",
"defaultValue": 8
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate"
},
{
"section": true,
"name": "Cards",
"settings": [
{
"type": "text",
"key": "cardTitle",
"label": "Title",
"nested": true
},
{
"type": "text",
"key": "cardSubtitle",
"label": "Subtitle",
"nested": true
},
{
"type": "text",
"key": "cardDescription",
"label": "Description",
"nested": true
},
{
"type": "text",
"key": "cardImageURL",
"label": "Image URL",
"nested": true
},
{
"type": "boolean",
"key": "linkCardTitle",
"label": "Link card title"
},
{
"type": "boolean",
"key": "cardPeek",
"label": "Open link in modal"
},
{
"type": "url",
"label": "Link screen",
"key": "cardURL",
"nested": true
},
{
"type": "boolean",
"key": "cardHorizontal",
"label": "Horizontal"
},
{
"type": "boolean",
"label": "Show button",
"key": "showCardButton"
},
{
"type": "text",
"key": "cardButtonText",
"label": "Button text",
"nested": true
},
{
"type": "event",
"label": "Button action",
"key": "cardButtonOnClick",
"nested": true
}
]
},
{
"section": true,
"name": "Title button",
"settings": [
{
"type": "boolean",
"key": "showTitleButton",
"label": "Show link button"
},
{
"type": "boolean",
"label": "Open link in modal",
"key": "titleButtonPeek"
},
{
"type": "text",
"key": "titleButtonText",
"label": "Button text"
},
{
"type": "url",
"label": "Button link",
"key": "titleButtonURL"
}
]
},
{
"section": true,
"name": "Advanced",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
}
]
}
],
"context": {
"type": "schema",
"suffix": "repeater"
}
},
"repeaterblock": {
"name": "Repeater block",
"icon": "ViewList",
"illegalChildren": ["section"],
"hasChildren": true,
"showSettingsBar": true,
"settings": [
{
"type": "dataSource",
"label": "Data",
"key": "dataSource"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": ["Ascending", "Descending"],
"defaultValue": "Descending"
},
{
"type": "number",
"label": "Limit",
"key": "limit",
"defaultValue": 10
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate"
},
{
"section": true,
"name": "Layout settings",
"settings": [
{
"type": "text",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found"
},
{
"type": "select",
"label": "Direction",
"key": "direction",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Column",
"value": "column",
"barIcon": "ViewRow",
"barTitle": "Column layout"
},
{
"label": "Row",
"value": "row",
"barIcon": "ViewColumn",
"barTitle": "Row layout"
}
],
"defaultValue": "column"
},
{
"type": "select",
"label": "Horiz. Align",
"key": "hAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Left",
"value": "left",
"barIcon": "AlignLeft",
"barTitle": "Align left"
},
{
"label": "Center",
"value": "center",
"barIcon": "AlignCenter",
"barTitle": "Align center"
},
{
"label": "Right",
"value": "right",
"barIcon": "AlignRight",
"barTitle": "Align right"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveLeftRight",
"barTitle": "Align stretched horizontally"
}
],
"defaultValue": "stretch"
},
{
"type": "select",
"label": "Vert. Align",
"key": "vAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Top",
"value": "top",
"barIcon": "AlignTop",
"barTitle": "Align top"
},
{
"label": "Middle",
"value": "middle",
"barIcon": "AlignMiddle",
"barTitle": "Align middle"
},
{
"label": "Bottom",
"value": "bottom",
"barIcon": "AlignBottom",
"barTitle": "Align bottom"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveUpDown",
"barTitle": "Align stretched vertically"
}
],
"defaultValue": "top"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
}
]
}
],
"context": [
{
"type": "static",
"suffix": "provider",
"values": [
{
"label": "Rows",
"key": "rows"
},
{
"label": "Rows Length",
"key": "rowsLength"
},
{
"label": "Schema",
"key": "schema"
},
{
"label": "Page Number",
"key": "pageNumber"
}
]
},
{
"type": "schema",
"suffix": "repeater"
} }
] ]
} }

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { notificationStore, dataSourceStore } from "stores" import { notificationStore, dataSourceStore } from "stores"
import API from "./api" import API from "./api"
import { fetchTableDefinition } from "./tables" import { fetchTableDefinition } from "./tables"
import { FieldTypes } from "../constants"
/** /**
* Fetches data about a certain row in a table. * Fetches data about a certain row in a table.
@ -107,6 +108,8 @@ export const deleteRows = async ({ tableId, rows }) => {
/** /**
* Enriches rows which contain certain field types so that they can * Enriches rows which contain certain field types so that they can
* be properly displayed. * be properly displayed.
* The ability to create these bindings has been removed, but they will still
* exist in client apps to support backwards compatibility.
*/ */
export const enrichRows = async (rows, tableId) => { export const enrichRows = async (rows, tableId) => {
if (!Array.isArray(rows)) { if (!Array.isArray(rows)) {
@ -129,7 +132,7 @@ export const enrichRows = async (rows, tableId) => {
const keys = Object.keys(schema) const keys = Object.keys(schema)
for (let key of keys) { for (let key of keys) {
const type = schema[key].type const type = schema[key].type
if (type === "link" && Array.isArray(row[key])) { if (type === FieldTypes.LINK && Array.isArray(row[key])) {
// Enrich row a string join of relationship fields // Enrich row a string join of relationship fields
row[`${key}_text`] = row[`${key}_text`] =
row[key] row[key]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Heading, Icon } from "@budibase/bbui" import { Heading, Icon } from "@budibase/bbui"
import { FieldTypes } from "../../constants"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
const { routeStore, styleable, linkable, builderStore } = getContext("sdk") const { routeStore, styleable, linkable, builderStore } = getContext("sdk")
@ -108,7 +109,7 @@
{#each validLinks as { text, url }} {#each validLinks as { text, url }}
{#if isInternal(url)} {#if isInternal(url)}
<a <a
class="link" class={FieldTypes.LINK}
href={url} href={url}
use:linkable use:linkable
on:click={close} on:click={close}
@ -117,7 +118,11 @@
{text} {text}
</a> </a>
{:else} {:else}
<a class="link" href={ensureExternal(url)} on:click={close}> <a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text} {text}
</a> </a>
{/if} {/if}

View File

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

View File

@ -1,18 +1,29 @@
<script> <script>
import "@spectrum-css/card/dist/index-vars.css" import "@spectrum-css/card/dist/index-vars.css"
import { getContext } from "svelte" import { getContext } from "svelte"
import { Button } from "@budibase/bbui"
export let title export let title
export let subtitle export let subtitle
export let description export let description
export let imageURL export let imageURL
export let linkURL export let linkURL
export let linkPeek
export let horizontal export let horizontal
export let showButton
export let buttonText
export let buttonOnClick
const { styleable, linkable } = getContext("sdk") const { styleable, routeStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
$: external = linkURL && !linkURL.startsWith("/") const handleLink = e => {
if (!linkURL) {
return
}
e.preventDefault()
routeStore.actions.navigate(linkURL, linkPeek)
}
</script> </script>
<div <div
@ -33,16 +44,10 @@
<div class="spectrum-Card-header"> <div class="spectrum-Card-header">
<div <div
class="spectrum-Card-title spectrum-Heading spectrum-Heading--sizeXS" class="spectrum-Card-title spectrum-Heading spectrum-Heading--sizeXS"
on:click={handleLink}
class:link={linkURL}
> >
{#if linkURL}
{#if external}
<a href={linkURL}>{title || "Card Title"}</a>
{:else}
<a use:linkable href={linkURL}>{title || "Card Title"}</a>
{/if}
{:else}
{title || "Card Title"} {title || "Card Title"}
{/if}
</div> </div>
</div> </div>
{#if subtitle} {#if subtitle}
@ -60,12 +65,17 @@
{description} {description}
</div> </div>
{/if} {/if}
{#if showButton}
<div class="spectrum-Card-footer button-container">
<Button on:click={buttonOnClick} secondary>{buttonText || ""}</Button>
</div>
{/if}
</div> </div>
</div> </div>
<style> <style>
.spectrum-Card { .spectrum-Card {
width: 240px; width: 300px;
border-color: var(--spectrum-global-color-gray-300) !important; border-color: var(--spectrum-global-color-gray-300) !important;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -76,11 +86,15 @@
flex-direction: row; flex-direction: row;
width: 420px; width: 420px;
} }
.spectrum-Card-title :global(a) { .spectrum-Card-container {
text-overflow: ellipsis; padding: var(--spectrum-global-dimension-size-50) 0;
overflow: hidden; }
white-space: nowrap; .spectrum-Card-title.link {
width: 100%; transition: color 130ms ease-in-out;
}
.spectrum-Card-title.link:hover {
cursor: pointer;
color: var(--spectrum-link-primary-m-text-color-hover);
} }
.spectrum-Card-subtitle { .spectrum-Card-subtitle {
text-overflow: ellipsis; text-overflow: ellipsis;
@ -91,14 +105,6 @@
word-wrap: anywhere; word-wrap: anywhere;
white-space: pre-wrap; white-space: pre-wrap;
} }
a {
transition: color 130ms ease-in-out;
color: var(--spectrum-alias-text-color);
}
a:hover {
color: var(--spectrum-link-primary-m-text-color-hover);
}
.horizontal .spectrum-Card-coverPhoto { .horizontal .spectrum-Card-coverPhoto {
flex: 0 0 160px; flex: 0 0 160px;
height: auto; height: auto;
@ -114,6 +120,19 @@
.spectrum-Card-footer { .spectrum-Card-footer {
border-top: none; border-top: none;
padding-top: 0; padding-top: 0;
padding-bottom: 0;
margin-top: -8px; margin-top: -8px;
margin-bottom: var(
--spectrum-card-body-padding-bottom,
var(--spectrum-global-dimension-size-300)
);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.button-container {
margin-bottom: var(--spectrum-global-dimension-size-300);
} }
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants"
const { API } = getContext("sdk") const { API } = getContext("sdk")
@ -68,7 +69,7 @@
{field} {field}
{disabled} {disabled}
{validation} {validation}
type="link" type={FieldTypes.LINK}
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi
bind:fieldSchema bind:fieldSchema

View File

@ -1,4 +1,5 @@
import flatpickr from "flatpickr" import flatpickr from "flatpickr"
import { FieldTypes } from "../../../constants"
/** /**
* Creates a validation function from a combination of schema-level constraints * Creates a validation function from a combination of schema-level constraints
@ -154,7 +155,7 @@ const parseType = (value, type) => {
} }
// Parse as string // Parse as string
if (type === "string") { if (type === FieldTypes.STRING) {
if (typeof value === "string" || Array.isArray(value)) { if (typeof value === "string" || Array.isArray(value)) {
return value return value
} }
@ -165,7 +166,7 @@ const parseType = (value, type) => {
} }
// Parse as number // Parse as number
if (type === "number") { if (type === FieldTypes.NUMBER) {
if (isNaN(value)) { if (isNaN(value)) {
return null return null
} }
@ -173,7 +174,7 @@ const parseType = (value, type) => {
} }
// Parse as date // Parse as date
if (type === "datetime") { if (type === FieldTypes.DATETIME) {
if (value instanceof Date) { if (value instanceof Date) {
return value.getTime() return value.getTime()
} }
@ -182,7 +183,7 @@ const parseType = (value, type) => {
} }
// Parse as boolean // Parse as boolean
if (type === "boolean") { if (type === FieldTypes.BOOLEAN) {
if (typeof value === "string") { if (typeof value === "string") {
return value.toLowerCase() === "true" return value.toLowerCase() === "true"
} }
@ -190,7 +191,7 @@ const parseType = (value, type) => {
} }
// Parse attachments, treating no elements as null // Parse attachments, treating no elements as null
if (type === "attachment") { if (type === FieldTypes.ATTACHMENT) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }
@ -198,14 +199,14 @@ const parseType = (value, type) => {
} }
// Parse links, treating no elements as null // Parse links, treating no elements as null
if (type === "link") { if (type === FieldTypes.LINK) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }
return value return value
} }
if (type === "array") { if (type === FieldTypes.ARRAY) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }

View File

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

View File

@ -2,6 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { Table } from "@budibase/bbui" import { Table } from "@budibase/bbui"
import SlotRenderer from "./SlotRenderer.svelte" import SlotRenderer from "./SlotRenderer.svelte"
import { UnsortableTypes } from "../../../constants"
export let dataProvider export let dataProvider
export let columns export let columns
@ -9,9 +10,17 @@
export let rowCount export let rowCount
export let quiet export let quiet
export let size export let size
export let linkRows
export let linkURL
export let linkColumn
export let linkPeek
const component = getContext("component") const component = getContext("component")
const { styleable } = getContext("sdk") const { styleable, getAction, ActionTypes, routeStore } = getContext("sdk")
const setSorting = getAction(
dataProvider?.id,
ActionTypes.SetDataProviderSorting
)
const customColumnKey = `custom-${Math.random()}` const customColumnKey = `custom-${Math.random()}`
const customRenderers = [ const customRenderers = [
{ {
@ -65,11 +74,35 @@
divider: true, divider: true,
} }
} }
fields.forEach(field => { fields.forEach(field => {
newSchema[field] = schema[field] newSchema[field] = schema[field]
if (schema[field] && UnsortableTypes.indexOf(schema[field].type) !== -1) {
newSchema[field].sortable = false
}
}) })
return newSchema return newSchema
} }
const onSort = e => {
setSorting({
column: e.detail.column,
order: e.detail.order,
})
}
const onClick = e => {
if (!linkRows || !linkURL) {
return
}
const col = linkColumn || "_id"
const id = e.detail?.[col]
if (!id) {
return
}
const split = linkURL.split("/:")
routeStore.actions.navigate(`${split[0]}/${id}`, linkPeek)
}
</script> </script>
<div use:styleable={$component.styles} class={size}> <div use:styleable={$component.styles} class={size}>
@ -84,6 +117,9 @@
allowEditRows={false} allowEditRows={false}
allowEditColumns={false} allowEditColumns={false}
showAutoColumns={true} showAutoColumns={true}
disableSorting
on:sort={onSort}
on:click={onClick}
> >
<slot /> <slot />
</Table> </Table>

View File

@ -8,6 +8,12 @@
import { Modal, ModalContent, ActionButton } from "@budibase/bbui" import { Modal, ModalContent, ActionButton } from "@budibase/bbui"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
const MessageTypes = {
NOTIFICATION: "notification",
CLOSE_SCREEN_MODAL: "close-screen-modal",
INVALIDATE_DATASOURCE: "invalidate-datasource",
}
let iframe let iframe
let listenersAttached = false let listenersAttached = false
@ -21,32 +27,33 @@
notificationStore.actions.send(message, type, icon) notificationStore.actions.send(message, type, icon)
} }
function receiveMessage(message) {
const handlers = {
[MessageTypes.NOTIFICATION]: () => {
proxyNotification(message.data)
},
[MessageTypes.CLOSE_SCREEN_MODAL]: peekStore.actions.hidePeek,
[MessageTypes.INVALIDATE_DATASOURCE]: () => {
invalidateDataSource(message.data)
},
}
const messageHandler = handlers[message.data.type]
if (messageHandler) {
messageHandler(message)
} else {
console.warning("Unknown event type", message?.data?.type)
}
}
const attachListeners = () => { const attachListeners = () => {
// Mirror datasource invalidation to keep the parent window up to date // Mirror datasource invalidation to keep the parent window up to date
iframe.contentWindow.addEventListener( window.addEventListener("message", receiveMessage)
"invalidate-datasource",
invalidateDataSource
)
// Listen for a close event to close the screen peek
iframe.contentWindow.addEventListener(
"close-screen-modal",
peekStore.actions.hidePeek
)
// Proxy notifications back to the parent window instead of iframe
iframe.contentWindow.addEventListener("notification", proxyNotification)
} }
const handleCancel = () => { const handleCancel = () => {
peekStore.actions.hidePeek() peekStore.actions.hidePeek()
iframe.contentWindow.removeEventListener( window.removeEventListener("message", receiveMessage)
"invalidate-datasource",
invalidateDataSource
)
iframe.contentWindow.removeEventListener(
"close-screen-modal",
peekStore.actions.hidePeek
)
iframe.contentWindow.removeEventListener("notification", proxyNotification)
} }
const handleFullscreen = () => { const handleFullscreen = () => {

View File

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

View File

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

View File

@ -2,10 +2,31 @@ export const TableNames = {
USERS: "ta_users", USERS: "ta_users",
} }
export const FieldTypes = {
STRING: "string",
LONGFORM: "longform",
OPTIONS: "options",
NUMBER: "number",
BOOLEAN: "boolean",
ARRAY: "array",
DATETIME: "datetime",
ATTACHMENT: "attachment",
LINK: "link",
FORMULA: "formula",
}
export const UnsortableTypes = [
FieldTypes.FORMULA,
FieldTypes.ATTACHMENT,
FieldTypes.ARRAY,
FieldTypes.LINK,
]
export const ActionTypes = { export const ActionTypes = {
ValidateForm: "ValidateForm", ValidateForm: "ValidateForm",
RefreshDatasource: "RefreshDatasource", RefreshDatasource: "RefreshDatasource",
SetDataProviderQuery: "SetDataProviderQuery", SetDataProviderQuery: "SetDataProviderQuery",
SetDataProviderSorting: "SetDataProviderSorting",
ClearForm: "ClearForm", ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep", ChangeFormStep: "ChangeFormStep",
} }

View File

@ -4,11 +4,7 @@ import { findComponentById, findComponentPathById } from "../utils/components"
import { pingEndUser } from "../api" import { pingEndUser } from "../api"
const dispatchEvent = (type, data = {}) => { const dispatchEvent = (type, data = {}) => {
window.dispatchEvent( window.parent.postMessage({ type, data })
new CustomEvent("bb-event", {
detail: { type, data },
})
)
} }
const createBuilderStore = () => { const createBuilderStore = () => {

View File

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

View File

@ -1,5 +1,6 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { fetchTableDefinition } from "../api" import { fetchTableDefinition } from "../api"
import { FieldTypes } from "../constants"
export const createDataSourceStore = () => { export const createDataSourceStore = () => {
const store = writable([]) const store = writable([])
@ -20,7 +21,7 @@ export const createDataSourceStore = () => {
// Only one side of the relationship is required as a trigger, as it will // Only one side of the relationship is required as a trigger, as it will
// automatically invalidate related table IDs // automatically invalidate related table IDs
else if (dataSource.type === "link") { else if (dataSource.type === FieldTypes.LINK) {
dataSourceId = dataSource.tableId || dataSource.rowTableId dataSourceId = dataSource.tableId || dataSource.rowTableId
} }
@ -59,11 +60,10 @@ export const createDataSourceStore = () => {
// Emit this as a window event, so parent screens which are iframing us in // Emit this as a window event, so parent screens which are iframing us in
// can also invalidate the same datasource // can also invalidate the same datasource
window.dispatchEvent( window.parent.postMessage({
new CustomEvent("invalidate-datasource", { type: "close-screen-modal",
detail: { dataSourceId }, detail: { dataSourceId },
}) })
)
let invalidations = [dataSourceId] let invalidations = [dataSourceId]
@ -73,7 +73,7 @@ export const createDataSourceStore = () => {
if (schema) { if (schema) {
Object.values(schema).forEach(fieldSchema => { Object.values(schema).forEach(fieldSchema => {
if ( if (
fieldSchema.type === "link" && fieldSchema.type === FieldTypes.LINK &&
fieldSchema.tableId && fieldSchema.tableId &&
!fieldSchema.autocolumn !fieldSchema.autocolumn
) { ) {

View File

@ -26,11 +26,14 @@ const createNotificationStore = () => {
// If peeking, pass notifications back to parent window // If peeking, pass notifications back to parent window
if (get(routeStore).queryParams?.peek) { if (get(routeStore).queryParams?.peek) {
window.dispatchEvent( window.parent.postMessage({
new CustomEvent("notification", { type: "notification",
detail: { message, type, icon }, detail: {
message,
type,
icon,
},
}) })
)
return return
} }

View File

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

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