1
0
Fork 0
mirror of synced 2024-07-04 22:11:23 +12:00

Merge branch 'master' into BUDI-7655/migration-backend

This commit is contained in:
Adria Navarro 2023-12-11 09:31:56 +01:00 committed by GitHub
commit e5d27181f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 1077 additions and 505 deletions

View file

@ -1,7 +1,9 @@
Copyright 2019-2021, Budibase Ltd. Copyright 2019-2023, Budibase Ltd.
Each Budibase package has its own license, please check the license file in each package. Each Budibase package has its own license, please check the license file in each package.
You can consider Budibase to be GPLv3 licensed overall. You can consider Budibase to be GPLv3 licensed overall.
The apps that you build with Budibase do not package any GPLv3 licensed code, thus do not fall under those restrictions. The apps that you build with Budibase do not package any GPLv3 licensed code, thus do not fall under those restrictions.
Budibase ships with Structured Query Server, by The Neighbourhoodie Software GmbH. This license for this can be found at ./SQS_LICENSE

31
SQS_LICENSE Normal file
View file

@ -0,0 +1,31 @@
FORM OF CUSTOMER LICENCE
Budibase hereby grants the Customer a worldwide, royalty free, non-exclusive,
perpetual (for the lifetime of the intellectual property rights contained in the Product)
right and title to utilise the binary code of the The Neighbourhoodie Software GmbH
Structured Query Server software product (Product) for its own internal business
purposes (the Purpose) only (the Licence). The Product has the function of bringing a
CouchDB database (NoSQL database) into an SQL database form (SQLite) and thereby
making it usable for complex queries - which originally could only be displayed in an
SQL database. By indexing in SQLite and a server that is tailored to it, the Product
enables the use of CouchDB with SQL queries.
The Licence shall not permit sub-licensing, resale or transfer of the Product to third
parties, other than sub-licensing to the Customers direct contractors for the purposes
of utilizing the Product as contemplated above.
The Licence shall not permit the adaptation, modification, decompilation, reverse
engineering or similar activities with respect to the Product.
This licence is granted to the Customer only, although Customer and its Affiliates
employees, servants and agents shall be entitled to utilize the Product within the scope
of the Licence for the Customers Purpose only.
Reproduction is not permitted to users, except for reproductions that are necessary for
the use of the product under the licence described above. These conditions apply to the
product regardless of the form in which we make the product available and on which
devices it is installed and/or with which devices it is ultimately used. Depending on the
product variant or intended use, certain technical requirements in the IT infrastructure
must be satisfied as a prerequisite for use.
The law of the Northern Ireland applies exclusively to this licence, and the courts of
Northern Ireland shall have exclusive jurisdiction, save that we reserve a right to sue
you in the jurisdiction in which you are based. The application of the UN Sales
Convention (CISG) is excluded.
The invalidity of any part of this licence does not affect the validity of the remaining
regulations.

View file

@ -87,6 +87,7 @@ couchdb:
storageClass: "nfs-client" storageClass: "nfs-client"
adminPassword: admin adminPassword: admin
services:
objectStore: objectStore:
storageClass: "nfs-client" storageClass: "nfs-client"
redis: redis:

View file

@ -86,6 +86,7 @@ couchdb:
storageClass: "nfs-client" storageClass: "nfs-client"
adminPassword: admin adminPassword: admin
services:
objectStore: objectStore:
storageClass: "nfs-client" storageClass: "nfs-client"
redis: redis:

View file

@ -16,6 +16,7 @@ spec:
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: budibase-proxy app.kubernetes.io/name: budibase-proxy
minReadySeconds: 10
strategy: strategy:
type: RollingUpdate type: RollingUpdate
template: template:

View file

@ -30,10 +30,18 @@ elif [[ "${TARGETBUILD}" = "single" ]]; then
# mount, so we use that for all persistent data. # mount, so we use that for all persistent data.
sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini sed -i "s#DATA_DIR#/data#g" /opt/clouseau/clouseau.ini
sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini sed -i "s#DATA_DIR#/data#g" /opt/couchdb/etc/local.ini
elif [[ "${TARGETBUILD}" = "docker-compose" ]]; then
# We remove the database_dir and view_index_dir settings from the local.ini
# in docker-compose because it will default to /opt/couchdb/data which is what
# our docker-compose was using prior to us switching to using our own CouchDB
# image.
sed -i "s#^database_dir.*\$##g" /opt/couchdb/etc/local.ini
sed -i "s#^view_index_dir.*\$##g" /opt/couchdb/etc/local.ini
sed -i "s#^dir=.*\$#dir=/opt/couchdb/data#g" /opt/clouseau/clouseau.ini
elif [[ -n $KUBERNETES_SERVICE_HOST ]]; then elif [[ -n $KUBERNETES_SERVICE_HOST ]]; then
# In Kubernetes the directory /opt/couchdb/data has a persistent volume # In Kubernetes the directory /opt/couchdb/data has a persistent volume
# mount for storing database data. # mount for storing database data.
sed -i "s#DATA_DIR#/opt/couchdb/data#g" /opt/clouseau/clouseau.ini sed -i "s#^dir=.*\$#dir=/opt/couchdb/data#g" /opt/clouseau/clouseau.ini
# We remove the database_dir and view_index_dir settings from the local.ini # We remove the database_dir and view_index_dir settings from the local.ini
# in Kubernetes because it will default to /opt/couchdb/data which is what # in Kubernetes because it will default to /opt/couchdb/data which is what

View file

@ -57,7 +57,6 @@ services:
depends_on: depends_on:
- redis-service - redis-service
- minio-service - minio-service
- couch-init
minio-service: minio-service:
restart: unless-stopped restart: unless-stopped
@ -70,7 +69,7 @@ services:
MINIO_BROWSER: "off" MINIO_BROWSER: "off"
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] test: "timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1"
interval: 30s interval: 30s
timeout: 20s timeout: 20s
retries: 3 retries: 3
@ -98,26 +97,15 @@ services:
couchdb-service: couchdb-service:
restart: unless-stopped restart: unless-stopped
image: ibmcom/couchdb3 image: budibase/couchdb
pull_policy: always
environment: environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER} - COUCHDB_USER=${COUCH_DB_USER}
- TARGETBUILD=docker-compose
volumes: volumes:
- couchdb3_data:/opt/couchdb/data - couchdb3_data:/opt/couchdb/data
couch-init:
image: curlimages/curl
environment:
PUT_CALL: "curl -u ${COUCH_DB_USER}:${COUCH_DB_PASSWORD} -X PUT couchdb-service:5984"
depends_on:
- couchdb-service
command:
[
"sh",
"-c",
"sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;",
]
redis-service: redis-service:
restart: unless-stopped restart: unless-stopped
image: redis image: redis

View file

@ -42,7 +42,7 @@ http {
server { server {
listen 10000 default_server; listen 10000 default_server;
server_name _; server_name _;
client_max_body_size 1000m; client_max_body_size 50000m;
ignore_invalid_headers off; ignore_invalid_headers off;
proxy_buffering off; proxy_buffering off;

View file

@ -249,4 +249,30 @@ http {
gzip_comp_level 6; gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
} }
# From https://docs.datadoghq.com/integrations/nginx/?tab=kubernetes
server {
listen 81;
server_name localhost;
access_log off;
allow 127.0.0.1;
deny all;
location /nginx_status {
# Choose your status module
# freely available with open source NGINX
stub_status;
# for open source NGINX < version 1.7.5
# stub_status on;
# available only with NGINX Plus
# status;
# ensures the version information can be retrieved
server_tokens on;
}
}
} }

View file

@ -1,5 +1,5 @@
{ {
"version": "2.13.31", "version": "2.13.35",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View file

@ -19,6 +19,7 @@ import {
GoogleInnerConfig, GoogleInnerConfig,
OIDCInnerConfig, OIDCInnerConfig,
PlatformLogoutOpts, PlatformLogoutOpts,
SessionCookie,
SSOProviderType, SSOProviderType,
} from "@budibase/types" } from "@budibase/types"
import * as events from "../events" import * as events from "../events"
@ -44,7 +45,6 @@ export const buildAuthMiddleware = authenticated
export const buildTenancyMiddleware = tenancy export const buildTenancyMiddleware = tenancy
export const buildCsrfMiddleware = csrf export const buildCsrfMiddleware = csrf
export const passport = _passport export const passport = _passport
export const jwt = require("jsonwebtoken")
// Strategies // Strategies
_passport.use(new LocalStrategy(local.options, local.authenticate)) _passport.use(new LocalStrategy(local.options, local.authenticate))
@ -191,10 +191,10 @@ export async function platformLogout(opts: PlatformLogoutOpts) {
if (!ctx) throw new Error("Koa context must be supplied to logout.") if (!ctx) throw new Error("Koa context must be supplied to logout.")
const currentSession = getCookie(ctx, Cookie.Auth) const currentSession = getCookie<SessionCookie>(ctx, Cookie.Auth)
let sessions = await getSessionsForUser(userId) let sessions = await getSessionsForUser(userId)
if (keepActiveSession) { if (currentSession && keepActiveSession) {
sessions = sessions.filter( sessions = sessions.filter(
session => session.sessionId !== currentSession.sessionId session => session.sessionId !== currentSession.sessionId
) )

View file

@ -13,7 +13,7 @@ import { getGlobalDB, doInTenant } from "../context"
import { decrypt } from "../security/encryption" import { decrypt } from "../security/encryption"
import * as identity from "../context/identity" import * as identity from "../context/identity"
import env from "../environment" import env from "../environment"
import { Ctx, EndpointMatcher } from "@budibase/types" import { Ctx, EndpointMatcher, SessionCookie } from "@budibase/types"
import { InvalidAPIKeyError, ErrorCode } from "../errors" import { InvalidAPIKeyError, ErrorCode } from "../errors"
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
@ -98,7 +98,9 @@ export default function (
// check the actual user is authenticated first, try header or cookie // check the actual user is authenticated first, try header or cookie
let headerToken = ctx.request.headers[Header.TOKEN] let headerToken = ctx.request.headers[Header.TOKEN]
const authCookie = getCookie(ctx, Cookie.Auth) || openJwt(headerToken) const authCookie =
getCookie<SessionCookie>(ctx, Cookie.Auth) ||
openJwt<SessionCookie>(headerToken)
let apiKey = ctx.request.headers[Header.API_KEY] let apiKey = ctx.request.headers[Header.API_KEY]
if (!apiKey && ctx.request.headers[Header.AUTHORIZATION]) { if (!apiKey && ctx.request.headers[Header.AUTHORIZATION]) {

View file

@ -3,7 +3,7 @@ import { Cookie } from "../../../constants"
import * as configs from "../../../configs" import * as configs from "../../../configs"
import * as cache from "../../../cache" import * as cache from "../../../cache"
import * as utils from "../../../utils" import * as utils from "../../../utils"
import { UserCtx, SSOProfile } from "@budibase/types" import { UserCtx, SSOProfile, DatasourceAuthCookie } from "@budibase/types"
import { ssoSaveUserNoOp } from "../sso/sso" import { ssoSaveUserNoOp } from "../sso/sso"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
@ -58,7 +58,14 @@ export async function postAuth(
const platformUrl = await configs.getPlatformUrl({ tenantAware: false }) const platformUrl = await configs.getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback` let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
const authStateCookie = utils.getCookie(ctx, Cookie.DatasourceAuth) const authStateCookie = utils.getCookie<{ appId: string }>(
ctx,
Cookie.DatasourceAuth
)
if (!authStateCookie) {
throw new Error("Unable to fetch datasource auth cookie")
}
return passport.authenticate( return passport.authenticate(
new GoogleStrategy( new GoogleStrategy(

View file

@ -305,20 +305,33 @@ export async function retrieveDirectory(bucketName: string, path: string) {
let writePath = join(budibaseTempDir(), v4()) let writePath = join(budibaseTempDir(), v4())
fs.mkdirSync(writePath) fs.mkdirSync(writePath)
const objects = await listAllObjects(bucketName, path) const objects = await listAllObjects(bucketName, path)
let fullObjects = await Promise.all( let streams = await Promise.all(
objects.map(obj => retrieve(bucketName, obj.Key!)) objects.map(obj => getReadStream(bucketName, obj.Key!))
) )
let count = 0 let count = 0
const writePromises: Promise<Error>[] = []
for (let obj of objects) { for (let obj of objects) {
const filename = obj.Key! const filename = obj.Key!
const data = fullObjects[count++] const stream = streams[count++]
const possiblePath = filename.split("/") const possiblePath = filename.split("/")
if (possiblePath.length > 1) { const dirs = possiblePath.slice(0, possiblePath.length - 1)
const dirs = possiblePath.slice(0, possiblePath.length - 1) const possibleDir = join(writePath, ...dirs)
fs.mkdirSync(join(writePath, ...dirs), { recursive: true }) if (possiblePath.length > 1 && !fs.existsSync(possibleDir)) {
fs.mkdirSync(possibleDir, { recursive: true })
} }
fs.writeFileSync(join(writePath, ...possiblePath), data) const writeStream = fs.createWriteStream(join(writePath, ...possiblePath), {
mode: 0o644,
})
stream.pipe(writeStream)
writePromises.push(
new Promise((resolve, reject) => {
stream.on("finish", resolve)
stream.on("error", reject)
writeStream.on("error", reject)
})
)
} }
await Promise.all(writePromises)
return writePath return writePath
} }

View file

@ -73,6 +73,9 @@ export async function encryptFile(
const outputFileName = `${filename}.enc` const outputFileName = `${filename}.enc`
const filePath = join(dir, filename) const filePath = join(dir, filename)
if (fs.lstatSync(filePath).isDirectory()) {
throw new Error("Unable to encrypt directory")
}
const inputFile = fs.createReadStream(filePath) const inputFile = fs.createReadStream(filePath)
const outputFile = fs.createWriteStream(join(dir, outputFileName)) const outputFile = fs.createWriteStream(join(dir, outputFileName))
@ -110,6 +113,9 @@ export async function decryptFile(
outputPath: string, outputPath: string,
secret: string secret: string
) { ) {
if (fs.lstatSync(inputPath).isDirectory()) {
throw new Error("Unable to encrypt directory")
}
const { salt, iv } = await getSaltAndIV(inputPath) const { salt, iv } = await getSaltAndIV(inputPath)
const inputFile = fs.createReadStream(inputPath, { const inputFile = fs.createReadStream(inputPath, {
start: SALT_LENGTH + IV_LENGTH, start: SALT_LENGTH + IV_LENGTH,

View file

@ -11,8 +11,7 @@ import {
TenantResolutionStrategy, TenantResolutionStrategy,
} from "@budibase/types" } from "@budibase/types"
import type { SetOption } from "cookies" import type { SetOption } from "cookies"
import jwt, { Secret } from "jsonwebtoken"
const jwt = require("jsonwebtoken")
const APP_PREFIX = DocumentType.APP + SEPARATOR const APP_PREFIX = DocumentType.APP + SEPARATOR
const PROD_APP_PREFIX = "/app/" const PROD_APP_PREFIX = "/app/"
@ -60,10 +59,7 @@ export function isServingApp(ctx: Ctx) {
return true return true
} }
// prod app // prod app
if (ctx.path.startsWith(PROD_APP_PREFIX)) { return ctx.path.startsWith(PROD_APP_PREFIX)
return true
}
return false
} }
export function isServingBuilder(ctx: Ctx): boolean { export function isServingBuilder(ctx: Ctx): boolean {
@ -138,16 +134,16 @@ function parseAppIdFromUrl(url?: string) {
* opens the contents of the specified encrypted JWT. * opens the contents of the specified encrypted JWT.
* @return the contents of the token. * @return the contents of the token.
*/ */
export function openJwt(token: string) { export function openJwt<T>(token?: string): T | undefined {
if (!token) { if (!token) {
return token return undefined
} }
try { try {
return jwt.verify(token, env.JWT_SECRET) return jwt.verify(token, env.JWT_SECRET as Secret) as T
} catch (e) { } catch (e) {
if (env.JWT_SECRET_FALLBACK) { if (env.JWT_SECRET_FALLBACK) {
// fallback to enable rotation // fallback to enable rotation
return jwt.verify(token, env.JWT_SECRET_FALLBACK) return jwt.verify(token, env.JWT_SECRET_FALLBACK) as T
} else { } else {
throw e throw e
} }
@ -159,13 +155,9 @@ export function isValidInternalAPIKey(apiKey: string) {
return true return true
} }
// fallback to enable rotation // fallback to enable rotation
if ( return !!(
env.INTERNAL_API_KEY_FALLBACK && env.INTERNAL_API_KEY_FALLBACK && env.INTERNAL_API_KEY_FALLBACK === apiKey
env.INTERNAL_API_KEY_FALLBACK === apiKey )
) {
return true
}
return false
} }
/** /**
@ -173,14 +165,14 @@ export function isValidInternalAPIKey(apiKey: string) {
* @param ctx The request which is to be manipulated. * @param ctx The request which is to be manipulated.
* @param name The name of the cookie to get. * @param name The name of the cookie to get.
*/ */
export function getCookie(ctx: Ctx, name: string) { export function getCookie<T>(ctx: Ctx, name: string) {
const cookie = ctx.cookies.get(name) const cookie = ctx.cookies.get(name)
if (!cookie) { if (!cookie) {
return cookie return undefined
} }
return openJwt(cookie) return openJwt<T>(cookie)
} }
/** /**
@ -197,7 +189,7 @@ export function setCookie(
opts = { sign: true } opts = { sign: true }
) { ) {
if (value && opts && opts.sign) { if (value && opts && opts.sign) {
value = jwt.sign(value, env.JWT_SECRET) value = jwt.sign(value, env.JWT_SECRET as Secret)
} }
const config: SetOption = { const config: SetOption = {

View file

@ -1,20 +1,17 @@
<script> <script>
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import { createEventDispatcher } from "svelte"
export let name export let name
export let show = false export let initiallyShow = false
export let collapsible = true export let collapsible = true
const dispatch = createEventDispatcher() let show = initiallyShow
const onHeaderClick = () => { const onHeaderClick = () => {
if (!collapsible) { if (!collapsible) {
return return
} }
show = !show show = !show
if (show) {
dispatch("open")
}
} }
</script> </script>

View file

@ -53,7 +53,7 @@
$: { $: {
if (selectedImage?.url) { if (selectedImage?.url) {
selectedUrl = selectedImage?.url selectedUrl = selectedImage?.url
} else if (selectedImage) { } else if (selectedImage && isImage) {
try { try {
let reader = new FileReader() let reader = new FileReader()
reader.readAsDataURL(selectedImage) reader.readAsDataURL(selectedImage)

View file

@ -8,6 +8,7 @@ import { derived, get } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history" import { createHistoryStore } from "builderStore/store/history"
import { cloneDeep } from "lodash/fp"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
@ -69,7 +70,14 @@ export const selectedComponent = derived(
if (!$selectedScreen || !$store.selectedComponentId) { if (!$selectedScreen || !$store.selectedComponentId) {
return null return null
} }
return findComponent($selectedScreen?.props, $store.selectedComponentId) const selected = findComponent(
$selectedScreen?.props,
$store.selectedComponentId
)
const clone = selected ? cloneDeep(selected) : selected
store.actions.components.migrateSettings(clone)
return clone
} }
) )

View file

@ -85,6 +85,7 @@ const INITIAL_FRONTEND_STATE = {
selectedScreenId: null, selectedScreenId: null,
selectedComponentId: null, selectedComponentId: null,
selectedLayoutId: null, selectedLayoutId: null,
hoverComponentId: null,
// Client state // Client state
selectedComponentInstance: null, selectedComponentInstance: null,
@ -112,7 +113,7 @@ export const getFrontendStore = () => {
} }
let clone = cloneDeep(screen) let clone = cloneDeep(screen)
const result = patchFn(clone) const result = patchFn(clone)
// An explicit false result means skip this change
if (result === false) { if (result === false) {
return return
} }
@ -601,6 +602,36 @@ export const getFrontendStore = () => {
// Finally try an external table // Finally try an external table
return validTables.find(table => table.sourceType === DB_TYPE_EXTERNAL) return validTables.find(table => table.sourceType === DB_TYPE_EXTERNAL)
}, },
migrateSettings: enrichedComponent => {
const componentPrefix = "@budibase/standard-components"
let migrated = false
if (enrichedComponent?._component == `${componentPrefix}/formblock`) {
// Use default config if the 'buttons' prop has never been initialised
if (!("buttons" in enrichedComponent)) {
enrichedComponent["buttons"] =
Utils.buildDynamicButtonConfig(enrichedComponent)
migrated = true
} else if (enrichedComponent["buttons"] == null) {
// Ignore legacy config if 'buttons' has been reset by 'resetOn'
const { _id, actionType, dataSource } = enrichedComponent
enrichedComponent["buttons"] = Utils.buildDynamicButtonConfig({
_id,
actionType,
dataSource,
})
migrated = true
}
// Ensure existing Formblocks position their buttons at the top.
if (!("buttonPosition" in enrichedComponent)) {
enrichedComponent["buttonPosition"] = "top"
migrated = true
}
}
return migrated
},
enrichEmptySettings: (component, opts) => { enrichEmptySettings: (component, opts) => {
if (!component?._component) { if (!component?._component) {
return return
@ -672,7 +703,6 @@ export const getFrontendStore = () => {
component[setting.key] = setting.defaultValue component[setting.key] = setting.defaultValue
} }
} }
// Validate non-empty settings // Validate non-empty settings
else { else {
if (setting.type === "dataProvider") { if (setting.type === "dataProvider") {
@ -722,6 +752,9 @@ export const getFrontendStore = () => {
useDefaultValues: true, useDefaultValues: true,
}) })
// Migrate nested component settings
store.actions.components.migrateSettings(instance)
// Add any extra properties the component needs // Add any extra properties the component needs
let extras = {} let extras = {}
if (definition.hasChildren) { if (definition.hasChildren) {
@ -845,7 +878,16 @@ export const getFrontendStore = () => {
if (!component) { if (!component) {
return false return false
} }
return patchFn(component, screen)
// Mutates the fetched component with updates
const patchResult = patchFn(component, screen)
// Mutates the component with any required settings updates
const migrated = store.actions.components.migrateSettings(component)
// Returning an explicit false signifies that we should skip this
// update. If we migrated something, ensure we never skip.
return migrated ? null : patchResult
} }
await store.actions.screens.patch(patchScreen, screenId) await store.actions.screens.patch(patchScreen, screenId)
}, },
@ -1247,9 +1289,13 @@ export const getFrontendStore = () => {
const settings = getComponentSettings(component._component) const settings = getComponentSettings(component._component)
const updatedSetting = settings.find(setting => setting.key === name) const updatedSetting = settings.find(setting => setting.key === name)
const resetFields = settings.filter( // Can be a single string or array of strings
setting => name === setting.resetOn const resetFields = settings.filter(setting => {
) return (
name === setting.resetOn ||
(Array.isArray(setting.resetOn) && setting.resetOn.includes(name))
)
})
resetFields?.forEach(setting => { resetFields?.forEach(setting => {
component[setting.key] = null component[setting.key] = null
}) })
@ -1271,6 +1317,7 @@ export const getFrontendStore = () => {
}) })
} }
component[name] = value component[name] = value
return true
} }
}, },
requestEjectBlock: componentId => { requestEjectBlock: componentId => {
@ -1278,7 +1325,6 @@ export const getFrontendStore = () => {
}, },
handleEjectBlock: async (componentId, ejectedDefinition) => { handleEjectBlock: async (componentId, ejectedDefinition) => {
let nextSelectedComponentId let nextSelectedComponentId
await store.actions.screens.patch(screen => { await store.actions.screens.patch(screen => {
const block = findComponent(screen.props, componentId) const block = findComponent(screen.props, componentId)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)

View file

@ -57,16 +57,11 @@
}} }}
class="buttons" class="buttons"
> >
<Icon hoverable size="M" name="Play" /> <Icon size="M" name="Play" />
<div>Run test</div> <div>Run test</div>
</div> </div>
<div class="buttons"> <div class="buttons">
<Icon <Icon disabled={!$automationStore.testResults} size="M" name="Multiple" />
disabled={!$automationStore.testResults}
hoverable
size="M"
name="Multiple"
/>
<div <div
class:disabled={!$automationStore.testResults} class:disabled={!$automationStore.testResults}
on:click={() => { on:click={() => {

View file

@ -97,6 +97,7 @@
class:typing={typing && !automationNameError} class:typing={typing && !automationNameError}
class:typing-error={automationNameError} class:typing-error={automationNameError}
class="blockSection" class="blockSection"
on:click={() => dispatch("toggle")}
> >
<div class="splitHeader"> <div class="splitHeader">
<div class="center-items"> <div class="center-items">
@ -138,7 +139,20 @@
on:input={e => { on:input={e => {
automationName = e.target.value.trim() automationName = e.target.value.trim()
}} }}
on:click={startTyping} on:click={e => {
e.stopPropagation()
startTyping()
}}
on:keydown={async e => {
if (e.key === "Enter") {
typing = false
if (automationNameError) {
automationName = stepNames[block.id] || block?.name
} else {
await saveName()
}
}
}}
on:blur={async () => { on:blur={async () => {
typing = false typing = false
if (automationNameError) { if (automationNameError) {
@ -168,7 +182,11 @@
</StatusLight> </StatusLight>
</div> </div>
<Icon <Icon
on:click={() => dispatch("toggle")} e.stopPropagation()
on:click={e => {
e.stopPropagation()
dispatch("toggle")
}}
hoverable hoverable
name={open ? "ChevronUp" : "ChevronDown"} name={open ? "ChevronUp" : "ChevronDown"}
/> />
@ -195,7 +213,10 @@
{/if} {/if}
{#if !showTestStatus} {#if !showTestStatus}
<Icon <Icon
on:click={() => dispatch("toggle")} on:click={e => {
e.stopPropagation()
dispatch("toggle")
}}
hoverable hoverable
name={open ? "ChevronUp" : "ChevronDown"} name={open ? "ChevronUp" : "ChevronDown"}
/> />

View file

@ -1,11 +1,9 @@
<script> <script>
import { import {
ModalContent, ModalContent,
Tabs,
Tab,
TextArea, TextArea,
Label,
notifications, notifications,
ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { automationStore, selectedAutomation } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
@ -55,50 +53,69 @@
notifications.error(error) notifications.error(error)
} }
} }
const toggle = () => {
selectedValues = !selectedValues
selectedJSON = !selectedJSON
}
let selectedValues = true
let selectedJSON = false
</script> </script>
<ModalContent <ModalContent
title="Add test data" title="Add test data"
confirmText="Test" confirmText="Run test"
size="M" size="L"
showConfirmButton={true} showConfirmButton={true}
disabled={isError} disabled={isError}
onConfirm={testAutomation} onConfirm={testAutomation}
cancelText="Cancel" cancelText="Cancel"
> >
<Tabs selected="Form" quiet> <div class="size">
<Tab icon="Form" title="Form"> <div class="options">
<div class="tab-content-padding"> <ActionButton quiet selected={selectedValues} on:click={toggle}
<AutomationBlockSetup >Use values</ActionButton
{testData} >
{schemaProperties} <ActionButton quiet selected={selectedJSON} on:click={toggle}
isTestModal >Use JSON</ActionButton
block={trigger} >
/> </div>
</div></Tab </div>
>
<Tab icon="FileJson" title="JSON"> {#if selectedValues}
<div class="tab-content-padding"> <div class="tab-content-padding">
<Label>JSON</Label> <AutomationBlockSetup
<div class="text-area-container"> {testData}
<TextArea {schemaProperties}
value={JSON.stringify($selectedAutomation.testData, null, 2)} isTestModal
error={failedParse} block={trigger}
on:change={e => parseTestJSON(e)} />
/> </div>
</div> {/if}
</div> {#if selectedJSON}
</Tab> <div class="text-area-container">
</Tabs> <TextArea
value={JSON.stringify($selectedAutomation.testData, null, 2)}
error={failedParse}
on:change={e => parseTestJSON(e)}
/>
</div>
{/if}
</ModalContent> </ModalContent>
<style> <style>
.text-area-container :global(textarea) { .text-area-container :global(textarea) {
min-height: 200px; min-height: 300px;
height: 200px; height: 300px;
} }
.tab-content-padding { .tab-content-padding {
padding: 0 var(--spacing-xl); padding: 0 var(--spacing-s);
}
.options {
display: flex;
align-items: center;
gap: 8px;
} }
</style> </style>

View file

@ -9,7 +9,7 @@
<div class="title"> <div class="title">
<div class="title-text"> <div class="title-text">
<Icon name="MultipleCheck" /> <Icon name="MultipleCheck" />
<div style="padding-left: var(--spacing-l)">Test Details</div> <div style="padding-left: var(--spacing-l); ">Test Details</div>
</div> </div>
<div style="padding-right: var(--spacing-xl)"> <div style="padding-right: var(--spacing-xl)">
<Icon <Icon
@ -40,6 +40,7 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding-top: var(--spacing-s);
} }
.title :global(h1) { .title :global(h1) {

View file

@ -1,20 +1,44 @@
<script> <script>
import AutomationList from "./AutomationList.svelte" import AutomationList from "./AutomationList.svelte"
import CreateAutomationModal from "./CreateAutomationModal.svelte" import CreateAutomationModal from "./CreateAutomationModal.svelte"
import { Modal, Button, Layout } from "@budibase/bbui" import { Modal, Icon } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
export let modal export let modal
export let webhookModal export let webhookModal
</script> </script>
<Panel title="Automations" borderRight> <Panel title="Automations" borderRight noHeaderBorder titleCSS={false}>
<Layout paddingX="L" paddingY="XL" gap="S"> <span class="panel-title-content" slot="panel-title-content">
<Button cta on:click={modal.show}>Add automation</Button> <div class="header">
</Layout> <div>Automations</div>
<div on:click={modal.show} class="add-automation-button">
<Icon name="Add" />
</div>
</div>
</span>
<AutomationList /> <AutomationList />
</Panel> </Panel>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} /> <CreateAutomationModal {webhookModal} />
</Modal> </Modal>
<style>
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-m);
}
.add-automation-button {
margin-left: 130px;
color: var(--grey-7);
cursor: pointer;
}
.add-automation-button:hover {
color: var(--ink);
}
</style>

View file

@ -149,7 +149,6 @@
} }
const initialiseField = (field, savingColumn) => { const initialiseField = (field, savingColumn) => {
isCreating = !field isCreating = !field
if (field && !savingColumn) { if (field && !savingColumn) {
editableColumn = cloneDeep(field) editableColumn = cloneDeep(field)
originalName = editableColumn.name ? editableColumn.name + "" : null originalName = editableColumn.name ? editableColumn.name + "" : null
@ -171,7 +170,8 @@
relationshipPart2 = part2 relationshipPart2 = part2
} }
} }
} else if (!savingColumn) { }
if (!savingColumn && !originalName) {
let highestNumber = 0 let highestNumber = 0
Object.keys(table.schema).forEach(columnName => { Object.keys(table.schema).forEach(columnName => {
const columnNumber = extractColumnNumber(columnName) const columnNumber = extractColumnNumber(columnName)
@ -307,12 +307,6 @@
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column") gridDispatch("close-edit-column")
if (saveColumn.type === LINK_TYPE) {
// Fetching the new tables
tables.fetch()
// Fetching the new relationships
datasources.fetch()
}
if (originalName) { if (originalName) {
notifications.success("Column updated successfully") notifications.success("Column updated successfully")
} else { } else {
@ -339,11 +333,6 @@
confirmDeleteDialog.hide() confirmDeleteDialog.hide()
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column") gridDispatch("close-edit-column")
if (editableColumn.type === LINK_TYPE) {
// Updating the relationships
datasources.fetch()
}
} }
} catch (error) { } catch (error) {
notifications.error(`Error deleting column: ${error.message}`) notifications.error(`Error deleting column: ${error.message}`)
@ -540,8 +529,16 @@
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#if mounted} {#if mounted}
<Input <Input
value={editableColumn.name}
autofocus autofocus
bind:value={editableColumn.name} on:input={e => {
if (
!uneditable &&
!(linkEditDisabled && editableColumn.type === LINK_TYPE)
) {
editableColumn.name = e.target.value
}
}}
disabled={uneditable || disabled={uneditable ||
(linkEditDisabled && editableColumn.type === LINK_TYPE)} (linkEditDisabled && editableColumn.type === LINK_TYPE)}
error={errors?.name} error={errors?.name}

View file

@ -23,6 +23,7 @@
export let showTooltip = false export let showTooltip = false
export let selectedBy = null export let selectedBy = null
export let compact = false export let compact = false
export let hovering = false
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -61,6 +62,7 @@
<div <div
class="nav-item" class="nav-item"
class:hovering
class:border class:border
class:selected class:selected
class:withActions class:withActions
@ -71,6 +73,8 @@
on:dragstart on:dragstart
on:dragover on:dragover
on:drop on:drop
on:mouseenter
on:mouseleave
on:click={onClick} on:click={onClick}
ondragover="return false" ondragover="return false"
ondragenter="return false" ondragenter="return false"
@ -152,15 +156,17 @@
--avatars-background: var(--spectrum-global-color-gray-200); --avatars-background: var(--spectrum-global-color-gray-200);
} }
.nav-item.selected { .nav-item.selected {
background-color: var(--spectrum-global-color-gray-300); background-color: var(--spectrum-global-color-gray-300) !important;
--avatars-background: var(--spectrum-global-color-gray-300); --avatars-background: var(--spectrum-global-color-gray-300);
color: var(--ink); color: var(--ink);
} }
.nav-item:hover { .nav-item:hover,
background-color: var(--spectrum-global-color-gray-300); .hovering {
background-color: var(--spectrum-global-color-gray-200);
--avatars-background: var(--spectrum-global-color-gray-300); --avatars-background: var(--spectrum-global-color-gray-300);
} }
.nav-item:hover .actions { .nav-item:hover .actions,
.hovering .actions {
visibility: visible; visibility: visible;
} }
.nav-item-content { .nav-item-content {

View file

@ -16,7 +16,8 @@
export let wide = false export let wide = false
export let extraWide = false export let extraWide = false
export let closeButtonIcon = "Close" export let closeButtonIcon = "Close"
export let noHeaderBorder = false
export let titleCSS = true
$: customHeaderContent = $$slots["panel-header-content"] $: customHeaderContent = $$slots["panel-header-content"]
$: customTitleContent = $$slots["panel-title-content"] $: customTitleContent = $$slots["panel-title-content"]
</script> </script>
@ -32,6 +33,7 @@
class="header" class="header"
class:custom={customHeaderContent} class:custom={customHeaderContent}
class:borderBottom={borderBottomHeader} class:borderBottom={borderBottomHeader}
class:noHeaderBorder
> >
{#if showBackButton} {#if showBackButton}
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} /> <Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
@ -41,7 +43,7 @@
<Icon name={icon} /> <Icon name={icon} />
</AbsTooltip> </AbsTooltip>
{/if} {/if}
<div class="title"> <div class:title={titleCSS}>
{#if customTitleContent} {#if customTitleContent}
<slot name="panel-title-content" /> <slot name="panel-title-content" />
{:else} {:else}
@ -106,6 +108,10 @@
padding: 0 var(--spacing-l); padding: 0 var(--spacing-l);
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.noHeaderBorder {
border-bottom: none !important;
}
.header.borderBottom { .header.borderBottom {
border-bottom: var(--border-light); border-bottom: var(--border-light);
} }

View file

@ -49,7 +49,15 @@
<div class="field-label">{item.label || item.field}</div> <div class="field-label">{item.label || item.field}</div>
</div> </div>
<div class="list-item-right"> <div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin /> <Toggle
on:click={e => {
e.stopPropagation()
}}
on:change={onToggle(item)}
text=""
value={item.active}
thin
/>
</div> </div>
</div> </div>

View file

@ -13,7 +13,7 @@
export let app export let app
export let published export let published
let includeInternalTablesRows = true let includeInternalTablesRows = true
let encypt = true let encrypt = true
let password = null let password = null
const validation = createValidationStore() const validation = createValidationStore()
@ -27,9 +27,9 @@
$: stepConfig = { $: stepConfig = {
[Step.CONFIG]: { [Step.CONFIG]: {
title: published ? "Export published app" : "Export latest app", title: published ? "Export published app" : "Export latest app",
confirmText: encypt ? "Continue" : exportButtonText, confirmText: encrypt ? "Continue" : exportButtonText,
onConfirm: () => { onConfirm: () => {
if (!encypt) { if (!encrypt) {
exportApp() exportApp()
} else { } else {
currentStep = Step.SET_PASSWORD currentStep = Step.SET_PASSWORD
@ -46,7 +46,7 @@
if (!$validation.valid) { if (!$validation.valid) {
return keepOpen return keepOpen
} }
exportApp(password) await exportApp(password)
}, },
isValid: $validation.valid, isValid: $validation.valid,
}, },
@ -109,13 +109,13 @@
text="Export rows from internal tables" text="Export rows from internal tables"
bind:value={includeInternalTablesRows} bind:value={includeInternalTablesRows}
/> />
<Toggle text="Encrypt my export" bind:value={encypt} /> <Toggle text="Encrypt my export" bind:value={encrypt} />
</Body> </Body>
{#if !encypt} <InlineAlert
<InlineAlert header={encrypt
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys." ? "Please note Budibase does not encrypt attachments during the export process to ensure efficient export of large attachments."
/> : "Do not share your Budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys."}
{/if} />
{/if} {/if}
{#if currentStep === Step.SET_PASSWORD} {#if currentStep === Step.SET_PASSWORD}
<Input <Input

View file

@ -110,7 +110,7 @@
} }
.setup { .setup {
padding-top: var(--spectrum-global-dimension-size-200); padding-top: 9px;
border-left: var(--border-light); border-left: var(--border-light);
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -32,6 +32,7 @@
const generalSettings = settings.filter( const generalSettings = settings.filter(
setting => !setting.section && setting.tag === tag setting => !setting.section && setting.tag === tag
) )
const customSections = settings.filter( const customSections = settings.filter(
setting => setting.section && setting.tag === tag setting => setting.section && setting.tag === tag
) )
@ -151,7 +152,7 @@
{#if section.visible} {#if section.visible}
<DetailSummary <DetailSummary
name={showSectionTitle ? section.name : ""} name={showSectionTitle ? section.name : ""}
show={section.collapsed !== true} initiallyShow={section.collapsed !== true}
> >
{#if section.info} {#if section.info}
<div class="section-info"> <div class="section-info">

View file

@ -36,12 +36,14 @@
// Determine selected component ID // Determine selected component ID
$: selectedComponentId = $store.selectedComponentId $: selectedComponentId = $store.selectedComponentId
$: hoverComponentId = $store.hoverComponentId
$: previewData = { $: previewData = {
appId: $store.appId, appId: $store.appId,
layout, layout,
screen, screen,
selectedComponentId, selectedComponentId,
hoverComponentId,
theme: $store.theme, theme: $store.theme,
customTheme: $store.customTheme, customTheme: $store.customTheme,
previewDevice: $store.previewDevice, previewDevice: $store.previewDevice,
@ -117,6 +119,8 @@
error = event.error || "An unknown error occurred" error = event.error || "An unknown error occurred"
} else if (type === "select-component" && data.id) { } else if (type === "select-component" && data.id) {
$store.selectedComponentId = data.id $store.selectedComponentId = data.id
} else if (type === "hover-component" && data.id) {
$store.hoverComponentId = data.id
} else if (type === "update-prop") { } else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value) await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "update-styles") { } else if (type === "update-styles") {

View file

@ -89,6 +89,17 @@
} }
return findComponentPath($selectedComponent, component._id)?.length > 0 return findComponentPath($selectedComponent, component._id)?.length > 0
} }
const handleMouseover = componentId => {
if ($store.hoverComponentId !== componentId) {
$store.hoverComponentId = componentId
}
}
const handleMouseout = componentId => {
if ($store.hoverComponentId === componentId) {
$store.hoverComponentId = null
}
}
</script> </script>
<ul> <ul>
@ -109,6 +120,9 @@
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => toggleNodeOpen(component._id)} on:iconClick={() => toggleNodeOpen(component._id)}
on:drop={onDrop} on:drop={onDrop}
hovering={$store.hoverComponentId === component._id}
on:mouseenter={() => handleMouseover(component._id)}
on:mouseleave={() => handleMouseout(component._id)}
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
iconTooltip={getComponentName(component)} iconTooltip={getComponentName(component)}

View file

@ -32,6 +32,17 @@
const handleScroll = e => { const handleScroll = e => {
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
const handleMouseover = componentId => {
if ($store.hoverComponentId !== componentId) {
$store.hoverComponentId = componentId
}
}
const handleMouseout = componentId => {
if ($store.hoverComponentId === componentId) {
$store.hoverComponentId = null
}
}
</script> </script>
<div class="components"> <div class="components">
@ -57,6 +68,12 @@
on:click={() => { on:click={() => {
$store.selectedComponentId = `${$store.selectedScreenId}-screen` $store.selectedComponentId = `${$store.selectedScreenId}-screen`
}} }}
hovering={$store.hoverComponentId ===
`${$store.selectedScreenId}-screen`}
on:mouseenter={() =>
handleMouseover(`${$store.selectedScreenId}-screen`)}
on:mouseleave={() =>
handleMouseout(`${$store.selectedScreenId}-screen`)}
id={`component-screen`} id={`component-screen`}
selectedBy={$userSelectedResourceMap[ selectedBy={$userSelectedResourceMap[
`${$store.selectedScreenId}-screen` `${$store.selectedScreenId}-screen`
@ -78,6 +95,12 @@
on:click={() => { on:click={() => {
$store.selectedComponentId = `${$store.selectedScreenId}-navigation` $store.selectedComponentId = `${$store.selectedScreenId}-navigation`
}} }}
hovering={$store.hoverComponentId ===
`${$store.selectedScreenId}-navigation`}
on:mouseenter={() =>
handleMouseover(`${$store.selectedScreenId}-navigation`)}
on:mouseleave={() =>
handleMouseout(`${$store.selectedScreenId}-navigation`)}
id={`component-nav`} id={`component-nav`}
selectedBy={$userSelectedResourceMap[ selectedBy={$userSelectedResourceMap[
`${$store.selectedScreenId}-navigation` `${$store.selectedScreenId}-navigation`

View file

@ -81,13 +81,21 @@ export function createTablesStore() {
replaceTable(savedTable._id, savedTable) replaceTable(savedTable._id, savedTable)
select(savedTable._id) select(savedTable._id)
// make sure tables up to date (related) // make sure tables up to date (related)
let tableIdsToFetch = [] let newTableIds = []
for (let column of Object.values(updatedTable?.schema || {})) { for (let column of Object.values(updatedTable?.schema || {})) {
if (column.type === FIELDS.LINK.type) { if (column.type === FIELDS.LINK.type) {
tableIdsToFetch.push(column.tableId) newTableIds.push(column.tableId)
} }
} }
tableIdsToFetch = [...new Set(tableIdsToFetch)]
let oldTableIds = []
for (let column of Object.values(oldTable?.schema || {})) {
if (column.type === FIELDS.LINK.type) {
oldTableIds.push(column.tableId)
}
}
const tableIdsToFetch = [...new Set([...newTableIds, ...oldTableIds])]
// too many tables to fetch, just get all // too many tables to fetch, just get all
if (tableIdsToFetch.length > 3) { if (tableIdsToFetch.length > 3) {
await fetch() await fetch()

View file

@ -15,7 +15,8 @@
"pkg": "pkg . --out-path build --no-bytecode --public --public-packages \"*\" -C GZip", "pkg": "pkg . --out-path build --no-bytecode --public --public-packages \"*\" -C GZip",
"build": "yarn prebuild && yarn rename && yarn tsc && yarn pkg && yarn postbuild", "build": "yarn prebuild && yarn rename && yarn tsc && yarn pkg && yarn postbuild",
"check:types": "tsc -p tsconfig.json --noEmit --paths null", "check:types": "tsc -p tsconfig.json --noEmit --paths null",
"postbuild": "rm -rf prebuilds 2> /dev/null" "postbuild": "rm -rf prebuilds 2> /dev/null",
"start": "ts-node ./src/index.ts"
}, },
"pkg": { "pkg": {
"targets": [ "targets": [

View file

@ -6112,54 +6112,32 @@
} }
] ]
}, },
{
"tag": "style",
"type": "select",
"label": "Button position",
"key": "buttonPosition",
"options": [
{
"label": "Bottom",
"value": "bottom"
},
{
"label": "Top",
"value": "top"
}
],
"defaultValue": "bottom"
},
{ {
"section": true, "section": true,
"name": "Buttons", "name": "Buttons",
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
},
"settings": [ "settings": [
{ {
"type": "text", "type": "buttonConfiguration",
"key": "saveButtonLabel", "key": "buttons",
"label": "Save button",
"nested": true, "nested": true,
"defaultValue": "Save" "resetOn": ["actionType", "dataSource"]
},
{
"type": "text",
"key": "deleteButtonLabel",
"label": "Delete button",
"nested": true,
"defaultValue": "Delete",
"dependsOn": {
"setting": "actionType",
"value": "Update"
}
},
{
"type": "url",
"label": "Navigate after button press",
"key": "actionUrl",
"placeholder": "Choose a screen",
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
},
{
"type": "boolean",
"label": "Hide notifications",
"key": "notificationOverride",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
} }
] ]
}, },

View file

@ -5,6 +5,7 @@
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js" import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { Utils } from "@budibase/frontend-core"
export let title export let title
export let dataSource export let dataSource
@ -33,6 +34,7 @@
export let notificationOverride export let notificationOverride
const { fetchDatasourceSchema, API } = getContext("sdk") const { fetchDatasourceSchema, API } = getContext("sdk")
const component = getContext("component")
const stateKey = `ID_${generate()}` const stateKey = `ID_${generate()}`
let formId let formId
@ -259,16 +261,25 @@
name="Details form block" name="Details form block"
type="formblock" type="formblock"
bind:id={detailsFormBlockId} bind:id={detailsFormBlockId}
context="form-edit"
props={{ props={{
dataSource, dataSource,
saveButtonLabel: sidePanelSaveLabel || "Save", //always show buttonPosition: "top",
deleteButtonLabel: deleteLabel, buttons: Utils.buildDynamicButtonConfig({
_id: $component.id + "-form-edit",
showDeleteButton: deleteLabel !== "",
showSaveButton: true,
saveButtonLabel: sidePanelSaveLabel || "Save",
deleteButtonLabel: deleteLabel,
notificationOverride,
actionType: "Update",
dataSource,
}),
actionType: "Update", actionType: "Update",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`, rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
fields: sidePanelFields || normalFields, fields: sidePanelFields || normalFields,
title: editTitle, title: editTitle,
labelPosition: "left", labelPosition: "left",
notificationOverride,
}} }}
/> />
</BlockComponent> </BlockComponent>
@ -284,16 +295,23 @@
<BlockComponent <BlockComponent
name="New row form block" name="New row form block"
type="formblock" type="formblock"
context="form-new"
props={{ props={{
dataSource, dataSource,
showSaveButton: true, buttonPosition: "top",
showDeleteButton: false, buttons: Utils.buildDynamicButtonConfig({
saveButtonLabel: sidePanelSaveLabel || "Save", //always show _id: $component.id + "-form-new",
showDeleteButton: false,
showSaveButton: true,
saveButtonLabel: "Save",
notificationOverride,
actionType: "Create",
dataSource,
}),
actionType: "Create", actionType: "Create",
fields: sidePanelFields || normalFields, fields: sidePanelFields || normalFields,
title: "Create Row", title: "Create Row",
labelPosition: "left", labelPosition: "left",
notificationOverride,
}} }}
/> />
</BlockComponent> </BlockComponent>

View file

@ -4,28 +4,31 @@
import Block from "components/Block.svelte" import Block from "components/Block.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import InnerFormBlock from "./InnerFormBlock.svelte" import InnerFormBlock from "./InnerFormBlock.svelte"
import { Utils } from "@budibase/frontend-core"
export let actionType export let actionType
export let dataSource export let dataSource
export let size export let size
export let disabled export let disabled
export let fields export let fields
export let buttons
export let buttonPosition
export let title export let title
export let description export let description
export let showDeleteButton
export let showSaveButton
export let saveButtonLabel
export let deleteButtonLabel
export let rowId export let rowId
export let actionUrl export let actionUrl
export let noRowsMessage export let noRowsMessage
export let notificationOverride export let notificationOverride
// Accommodate old config to ensure delete button does not reappear // Legacy
$: deleteLabel = showDeleteButton === false ? "" : deleteButtonLabel?.trim() export let showDeleteButton
$: saveLabel = showSaveButton === false ? "" : saveButtonLabel?.trim() export let showSaveButton
export let saveButtonLabel
export let deleteButtonLabel
const { fetchDatasourceSchema } = getContext("sdk") const { fetchDatasourceSchema } = getContext("sdk")
const component = getContext("component")
const convertOldFieldFormat = fields => { const convertOldFieldFormat = fields => {
if (!fields) { if (!fields) {
@ -98,11 +101,23 @@
fields: fieldsOrDefault, fields: fieldsOrDefault,
title, title,
description, description,
saveButtonLabel: saveLabel,
deleteButtonLabel: deleteLabel,
schema, schema,
repeaterId, repeaterId,
notificationOverride, notificationOverride,
buttons:
buttons ||
Utils.buildDynamicButtonConfig({
_id: $component.id,
showDeleteButton,
showSaveButton,
saveButtonLabel,
deleteButtonLabel,
notificationOverride,
actionType,
actionUrl,
dataSource,
}),
buttonPosition: buttons ? buttonPosition : "top",
} }
const fetchSchema = async () => { const fetchSchema = async () => {
schema = (await fetchDatasourceSchema(dataSource)) || {} schema = (await fetchDatasourceSchema(dataSource)) || {}

View file

@ -1,22 +1,18 @@
<script> <script>
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import Placeholder from "components/app/Placeholder.svelte" import Placeholder from "components/app/Placeholder.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { getContext } from "svelte" import { getContext } from "svelte"
export let dataSource export let dataSource
export let actionUrl
export let actionType export let actionType
export let size export let size
export let disabled export let disabled
export let fields export let fields
export let title export let title
export let description export let description
export let saveButtonLabel export let buttons
export let deleteButtonLabel export let buttonPosition = "bottom"
export let schema export let schema
export let repeaterId
export let notificationOverride
const FieldTypeToComponentMap = { const FieldTypeToComponentMap = {
string: "stringfield", string: "stringfield",
@ -37,74 +33,7 @@
let formId let formId
$: onSave = [ $: renderHeader = buttons || title
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: dataSource?.resourceId,
notificationOverride,
},
},
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Close Side Panel",
},
// Clear a create form once submitted
...(actionType !== "Create"
? []
: [
{
"##eventHandlerType": "Clear Form",
parameters: {
componentId: formId,
},
},
]),
{
"##eventHandlerType": "Navigate To",
parameters: {
url: actionUrl,
},
},
]
$: onDelete = [
{
"##eventHandlerType": "Delete Row",
parameters: {
confirm: true,
tableId: dataSource?.resourceId,
rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`,
revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`,
notificationOverride,
},
},
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Close Side Panel",
},
{
"##eventHandlerType": "Navigate To",
parameters: {
url: actionUrl,
},
},
]
$: renderDeleteButton = deleteButtonLabel && actionType === "Update"
$: renderSaveButton = saveButtonLabel && actionType !== "View"
$: renderButtons = renderDeleteButton || renderSaveButton
$: renderHeader = renderButtons || title
const getComponentForField = field => { const getComponentForField = field => {
const fieldSchemaName = field.field || field.name const fieldSchemaName = field.field || field.name
@ -184,42 +113,14 @@
props={{ text: title || "" }} props={{ text: title || "" }}
order={0} order={0}
/> />
{#if renderButtons} {#if buttonPosition == "top"}
<BlockComponent <BlockComponent
type="container" type="buttongroup"
props={{ props={{
direction: "row", buttons,
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
}} }}
order={1} order={0}
> />
{#if renderDeleteButton}
<BlockComponent
type="button"
props={{
text: deleteButtonLabel,
onClick: onDelete,
quiet: true,
type: "secondary",
}}
order={0}
/>
{/if}
{#if renderSaveButton}
<BlockComponent
type="button"
props={{
text: saveButtonLabel,
onClick: onSave,
type: "cta",
}}
order={1}
/>
{/if}
</BlockComponent>
{/if} {/if}
</BlockComponent> </BlockComponent>
</BlockComponent> </BlockComponent>
@ -245,6 +146,20 @@
</BlockComponent> </BlockComponent>
{/key} {/key}
</BlockComponent> </BlockComponent>
{#if buttonPosition === "bottom"}
<BlockComponent
type="buttongroup"
props={{
buttons,
}}
styles={{
normal: {
"margin-top": "16",
},
}}
order={1}
/>
{/if}
</BlockComponent> </BlockComponent>
{:else} {:else}
<Placeholder <Placeholder

View file

@ -3,8 +3,7 @@
import IndicatorSet from "./IndicatorSet.svelte" import IndicatorSet from "./IndicatorSet.svelte"
import { builderStore, dndIsDragging } from "stores" import { builderStore, dndIsDragging } from "stores"
let componentId $: componentId = $builderStore.hoverComponentId
$: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920 $: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920
const onMouseOver = e => { const onMouseOver = e => {
@ -24,12 +23,12 @@
} }
if (newId !== componentId) { if (newId !== componentId) {
componentId = newId builderStore.actions.hoverComponent(newId)
} }
} }
const onMouseLeave = () => { const onMouseLeave = () => {
componentId = null builderStore.actions.hoverComponent(null)
} }
onMount(() => { onMount(() => {

View file

@ -32,6 +32,7 @@ const loadBudibase = async () => {
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"], layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"], screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"], selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],
hoverComponentId: window["##BUDIBASE_HOVER_COMPONENT_ID##"],
previewId: window["##BUDIBASE_PREVIEW_ID##"], previewId: window["##BUDIBASE_PREVIEW_ID##"],
theme: window["##BUDIBASE_PREVIEW_THEME##"], theme: window["##BUDIBASE_PREVIEW_THEME##"],
customTheme: window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"], customTheme: window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"],

View file

@ -8,6 +8,7 @@ const createBuilderStore = () => {
inBuilder: false, inBuilder: false,
screen: null, screen: null,
selectedComponentId: null, selectedComponentId: null,
hoverComponentId: null,
editMode: false, editMode: false,
previewId: null, previewId: null,
theme: null, theme: null,
@ -23,6 +24,16 @@ const createBuilderStore = () => {
} }
const store = writable(initialState) const store = writable(initialState)
const actions = { const actions = {
hoverComponent: id => {
if (id === get(store).hoverComponentId) {
return
}
store.update(state => ({
...state,
hoverComponentId: id,
}))
eventStore.actions.dispatchEvent("hover-component", { id })
},
selectComponent: id => { selectComponent: id => {
if (id === get(store).selectedComponentId) { if (id === get(store).selectedComponentId) {
return return

View file

@ -1,3 +1,6 @@
import { makePropSafe as safe } from "@budibase/string-templates"
import { Helpers } from "@budibase/bbui"
/** /**
* Utility to wrap an async function and ensure all invocations happen * Utility to wrap an async function and ensure all invocations happen
* sequentially. * sequentially.
@ -106,3 +109,135 @@ export const domDebounce = callback => {
} }
} }
} }
/**
* Build the default FormBlock button configs per actionType
* Parse any legacy button config and mirror its the outcome
*
* @param {any} props
* */
export const buildDynamicButtonConfig = props => {
const {
_id,
actionType,
dataSource,
notificationOverride,
actionUrl,
showDeleteButton,
deleteButtonLabel,
showSaveButton,
saveButtonLabel,
} = props || {}
if (!_id) {
console.log("MISSING ID")
return
}
const formId = `${_id}-form`
const repeaterId = `${_id}-repeater`
const resourceId = dataSource?.resourceId
// Accommodate old config to ensure delete button does not reappear
const deleteText = showDeleteButton === false ? "" : deleteButtonLabel?.trim()
const saveText = showSaveButton === false ? "" : saveButtonLabel?.trim()
const onSave = [
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: resourceId,
notificationOverride,
},
},
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Close Side Panel",
},
// Clear a create form once submitted
...(actionType !== "Create"
? []
: [
{
"##eventHandlerType": "Clear Form",
parameters: {
componentId: formId,
},
},
]),
...(actionUrl
? [
{
"##eventHandlerType": "Navigate To",
parameters: {
url: actionUrl,
},
},
]
: []),
]
const onDelete = [
{
"##eventHandlerType": "Delete Row",
parameters: {
confirm: true,
tableId: resourceId,
rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`,
revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`,
notificationOverride,
},
},
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Close Side Panel",
},
...(actionUrl
? [
{
"##eventHandlerType": "Navigate To",
parameters: {
url: actionUrl,
},
},
]
: []),
]
const defaultButtons = []
if (["Update", "Create"].includes(actionType) && showSaveButton !== false) {
defaultButtons.push({
text: saveText || "Save",
_id: Helpers.uuid(),
_component: "@budibase/standard-components/button",
onClick: onSave,
type: "cta",
})
}
if (actionType == "Update" && showDeleteButton !== false) {
defaultButtons.push({
text: deleteText || "Delete",
_id: Helpers.uuid(),
_component: "@budibase/standard-components/button",
onClick: onDelete,
quiet: true,
type: "secondary",
})
}
return defaultButtons
}

View file

@ -9,7 +9,7 @@ import { quotas } from "@budibase/pro"
import { events, context, utils, constants } from "@budibase/backend-core" import { events, context, utils, constants } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { QueryEvent } from "../../../threads/definitions" import { QueryEvent } from "../../../threads/definitions"
import { ConfigType, Query, UserCtx } from "@budibase/types" import { ConfigType, Query, UserCtx, SessionCookie } from "@budibase/types"
import { ValidQueryNameRegex } from "@budibase/shared-core" import { ValidQueryNameRegex } from "@budibase/shared-core"
const Runner = new Thread(ThreadType.QUERY, { const Runner = new Thread(ThreadType.QUERY, {
@ -113,7 +113,7 @@ function getOAuthConfigCookieId(ctx: UserCtx) {
} }
function getAuthConfig(ctx: UserCtx) { function getAuthConfig(ctx: UserCtx) {
const authCookie = utils.getCookie(ctx, constants.Cookie.Auth) const authCookie = utils.getCookie<SessionCookie>(ctx, constants.Cookie.Auth)
let authConfigCtx: any = {} let authConfigCtx: any = {}
authConfigCtx["configId"] = getOAuthConfigCookieId(ctx) authConfigCtx["configId"] = getOAuthConfigCookieId(ctx)
authConfigCtx["sessionId"] = authCookie ? authCookie.sessionId : null authConfigCtx["sessionId"] = authCookie ? authCookie.sessionId : null

View file

@ -2,7 +2,7 @@ import * as linkRows from "../../../db/linkedRows"
import { generateRowID, InternalTables } from "../../../db/utils" import { generateRowID, InternalTables } from "../../../db/utils"
import * as userController from "../user" import * as userController from "../user"
import { import {
cleanupAttachments, AttachmentCleanup,
inputProcessing, inputProcessing,
outputProcessing, outputProcessing,
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
@ -79,7 +79,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
table, table,
})) as Row })) as Row
// check if any attachments removed // check if any attachments removed
await cleanupAttachments(table, { oldRow, row }) await AttachmentCleanup.rowUpdate(table, { row, oldRow })
if (isUserTable) { if (isUserTable) {
// the row has been updated, need to put it into the ctx // the row has been updated, need to put it into the ctx
@ -119,7 +119,7 @@ export async function save(ctx: UserCtx) {
throw { validation: validateResult.errors } throw { validation: validateResult.errors }
} }
// make sure link rows are up to date // make sure link rows are up-to-date
row = (await linkRows.updateLinks({ row = (await linkRows.updateLinks({
eventType: linkRows.EventType.ROW_SAVE, eventType: linkRows.EventType.ROW_SAVE,
row, row,
@ -165,7 +165,7 @@ export async function destroy(ctx: UserCtx) {
tableId, tableId,
}) })
// remove any attachments that were on the row from object storage // remove any attachments that were on the row from object storage
await cleanupAttachments(table, { row }) await AttachmentCleanup.rowDelete(table, [row])
// remove any static formula // remove any static formula
await updateRelatedFormula(table, row) await updateRelatedFormula(table, row)
@ -216,7 +216,7 @@ export async function bulkDestroy(ctx: UserCtx) {
await db.bulkDocs(processedRows.map(row => ({ ...row, _deleted: true }))) await db.bulkDocs(processedRows.map(row => ({ ...row, _deleted: true })))
} }
// remove any attachments that were on the rows from object storage // remove any attachments that were on the rows from object storage
await cleanupAttachments(table, { rows: processedRows }) await AttachmentCleanup.rowDelete(table, processedRows)
await updateRelatedFormula(table, processedRows) await updateRelatedFormula(table, processedRows)
await Promise.all(updates) await Promise.all(updates)
return { response: { ok: true }, rows: processedRows } return { response: { ok: true }, rows: processedRows }

View file

@ -63,6 +63,7 @@
// Extract data from message // Extract data from message
const { const {
selectedComponentId, selectedComponentId,
hoverComponentId,
layout, layout,
screen, screen,
appId, appId,
@ -81,6 +82,7 @@
window["##BUDIBASE_PREVIEW_LAYOUT##"] = layout window["##BUDIBASE_PREVIEW_LAYOUT##"] = layout
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
window["##BUDIBASE_HOVER_COMPONENT_ID##"] = hoverComponentId
window["##BUDIBASE_PREVIEW_ID##"] = Math.random() window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
window["##BUDIBASE_PREVIEW_THEME##"] = theme window["##BUDIBASE_PREVIEW_THEME##"] = theme
window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"] = customTheme window["##BUDIBASE_PREVIEW_CUSTOM_THEME##"] = customTheme
@ -108,4 +110,4 @@
</script> </script>
</head> </head>
<body></body> <body></body>
</html> </html>

View file

@ -11,7 +11,7 @@ import {
} from "../../../constants" } from "../../../constants"
import { import {
inputProcessing, inputProcessing,
cleanupAttachments, AttachmentCleanup,
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import { getViews, saveView } from "../view/utils" import { getViews, saveView } from "../view/utils"
import viewTemplate from "../view/viewBuilder" import viewTemplate from "../view/viewBuilder"
@ -82,7 +82,10 @@ export async function checkForColumnUpdates(
}) })
// cleanup any attachments from object storage for deleted attachment columns // cleanup any attachments from object storage for deleted attachment columns
await cleanupAttachments(updatedTable, { oldTable, rows: rawRows }) await AttachmentCleanup.tableUpdate(updatedTable, rawRows, {
oldTable,
rename: columnRename,
})
// Update views // Update views
await checkForViewUpdates(updatedTable, deletedColumns, columnRename) await checkForViewUpdates(updatedTable, deletedColumns, columnRename)
} }

View file

@ -59,6 +59,7 @@ const environment = {
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD, BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
PLUGINS_DIR: process.env.PLUGINS_DIR || "/plugins", PLUGINS_DIR: process.env.PLUGINS_DIR || "/plugins",
OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB,
// flags // flags
ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS, ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS,
DISABLE_THREADING: process.env.DISABLE_THREADING, DISABLE_THREADING: process.env.DISABLE_THREADING,

View file

@ -17,7 +17,7 @@ import {
import { import {
getSqlQuery, getSqlQuery,
buildExternalTableId, buildExternalTableId,
convertSqlType, generateColumnDefinition,
finaliseExternalTables, finaliseExternalTables,
SqlClient, SqlClient,
checkExternalTables, checkExternalTables,
@ -429,15 +429,12 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
const hasDefault = def.COLUMN_DEFAULT const hasDefault = def.COLUMN_DEFAULT
const isAuto = !!autoColumns.find(col => col === name) const isAuto = !!autoColumns.find(col => col === name)
const required = !!requiredColumns.find(col => col === name) const required = !!requiredColumns.find(col => col === name)
schema[name] = { schema[name] = generateColumnDefinition({
autocolumn: isAuto, autocolumn: isAuto,
name: name, name,
constraints: { presence: required && !isAuto && !hasDefault,
presence: required && !isAuto && !hasDefault,
},
...convertSqlType(def.DATA_TYPE),
externalType: def.DATA_TYPE, externalType: def.DATA_TYPE,
} })
} }
tables[tableName] = { tables[tableName] = {
_id: buildExternalTableId(datasourceId, tableName), _id: buildExternalTableId(datasourceId, tableName),

View file

@ -12,12 +12,13 @@ import {
SourceName, SourceName,
Schema, Schema,
TableSourceType, TableSourceType,
FieldType,
} from "@budibase/types" } from "@budibase/types"
import { import {
getSqlQuery, getSqlQuery,
SqlClient, SqlClient,
buildExternalTableId, buildExternalTableId,
convertSqlType, generateColumnDefinition,
finaliseExternalTables, finaliseExternalTables,
checkExternalTables, checkExternalTables,
} from "./utils" } from "./utils"
@ -305,16 +306,17 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
(column.Extra === "auto_increment" || (column.Extra === "auto_increment" ||
column.Extra.toLowerCase().includes("generated")) column.Extra.toLowerCase().includes("generated"))
const required = column.Null !== "YES" const required = column.Null !== "YES"
const constraints = { schema[columnName] = generateColumnDefinition({
presence: required && !isAuto && !hasDefault,
}
schema[columnName] = {
name: columnName, name: columnName,
autocolumn: isAuto, autocolumn: isAuto,
constraints, presence: required && !isAuto && !hasDefault,
...convertSqlType(column.Type),
externalType: column.Type, externalType: column.Type,
} options: column.Type.startsWith("enum")
? column.Type.substring(5, column.Type.length - 1)
.split(",")
.map(str => str.replace(/^'(.*)'$/, "$1"))
: undefined,
})
} }
if (!tables[tableName]) { if (!tables[tableName]) {
tables[tableName] = { tables[tableName] = {

View file

@ -15,7 +15,7 @@ import {
import { import {
buildExternalTableId, buildExternalTableId,
checkExternalTables, checkExternalTables,
convertSqlType, generateColumnDefinition,
finaliseExternalTables, finaliseExternalTables,
getSqlQuery, getSqlQuery,
SqlClient, SqlClient,
@ -250,14 +250,6 @@ class OracleIntegration extends Sql implements DatasourcePlus {
) )
} }
private internalConvertType(column: OracleColumn) {
if (this.isBooleanType(column)) {
return { type: FieldTypes.BOOLEAN }
}
return convertSqlType(column.type)
}
/** /**
* Fetches the tables from the oracle table and assigns them to the datasource. * Fetches the tables from the oracle table and assigns them to the datasource.
* @param datasourceId - datasourceId to fetch * @param datasourceId - datasourceId to fetch
@ -302,13 +294,15 @@ class OracleIntegration extends Sql implements DatasourcePlus {
const columnName = oracleColumn.name const columnName = oracleColumn.name
let fieldSchema = table.schema[columnName] let fieldSchema = table.schema[columnName]
if (!fieldSchema) { if (!fieldSchema) {
fieldSchema = { fieldSchema = generateColumnDefinition({
autocolumn: OracleIntegration.isAutoColumn(oracleColumn), autocolumn: OracleIntegration.isAutoColumn(oracleColumn),
name: columnName, name: columnName,
constraints: { presence: false,
presence: false, externalType: oracleColumn.type,
}, })
...this.internalConvertType(oracleColumn),
if (this.isBooleanType(oracleColumn)) {
fieldSchema.type = FieldTypes.BOOLEAN
} }
table.schema[columnName] = fieldSchema table.schema[columnName] = fieldSchema

View file

@ -16,7 +16,7 @@ import {
import { import {
getSqlQuery, getSqlQuery,
buildExternalTableId, buildExternalTableId,
convertSqlType, generateColumnDefinition,
finaliseExternalTables, finaliseExternalTables,
SqlClient, SqlClient,
checkExternalTables, checkExternalTables,
@ -162,6 +162,14 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
WHERE pg_namespace.nspname = '${this.config.schema}'; WHERE pg_namespace.nspname = '${this.config.schema}';
` `
ENUM_VALUES = () => `
SELECT t.typname,
e.enumlabel
FROM pg_type t
JOIN pg_enum e on t.oid = e.enumtypid
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace;
`
constructor(config: PostgresConfig) { constructor(config: PostgresConfig) {
super(SqlClient.POSTGRES) super(SqlClient.POSTGRES)
this.config = config this.config = config
@ -303,6 +311,18 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
const tables: { [key: string]: Table } = {} const tables: { [key: string]: Table } = {}
// Fetch enum values
const enumsResponse = await this.client.query(this.ENUM_VALUES())
const enumValues = enumsResponse.rows?.reduce((acc, row) => {
if (!acc[row.typname]) {
return {
[row.typname]: [row.enumlabel],
}
}
acc[row.typname].push(row.enumlabel)
return acc
}, {})
for (let column of columnsResponse.rows) { for (let column of columnsResponse.rows) {
const tableName: string = column.table_name const tableName: string = column.table_name
const columnName: string = column.column_name const columnName: string = column.column_name
@ -333,16 +353,13 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
column.is_generated && column.is_generated !== "NEVER" column.is_generated && column.is_generated !== "NEVER"
const isAuto: boolean = hasNextVal || identity || isGenerated const isAuto: boolean = hasNextVal || identity || isGenerated
const required = column.is_nullable === "NO" const required = column.is_nullable === "NO"
const constraints = { tables[tableName].schema[columnName] = generateColumnDefinition({
presence: required && !hasDefault && !isGenerated,
}
tables[tableName].schema[columnName] = {
autocolumn: isAuto, autocolumn: isAuto,
name: columnName, name: columnName,
constraints, presence: required && !hasDefault && !isGenerated,
...convertSqlType(column.data_type),
externalType: column.data_type, externalType: column.data_type,
} options: enumValues?.[column.udt_name],
})
} }
let finalizedTables = finaliseExternalTables(tables, entities) let finalizedTables = finaliseExternalTables(tables, entities)

View file

@ -67,6 +67,10 @@ const SQL_BOOLEAN_TYPE_MAP = {
tinyint: FieldType.BOOLEAN, tinyint: FieldType.BOOLEAN,
} }
const SQL_OPTIONS_TYPE_MAP = {
"user-defined": FieldType.OPTIONS,
}
const SQL_MISC_TYPE_MAP = { const SQL_MISC_TYPE_MAP = {
json: FieldType.JSON, json: FieldType.JSON,
bigint: FieldType.BIGINT, bigint: FieldType.BIGINT,
@ -78,6 +82,7 @@ const SQL_TYPE_MAP = {
...SQL_STRING_TYPE_MAP, ...SQL_STRING_TYPE_MAP,
...SQL_BOOLEAN_TYPE_MAP, ...SQL_BOOLEAN_TYPE_MAP,
...SQL_MISC_TYPE_MAP, ...SQL_MISC_TYPE_MAP,
...SQL_OPTIONS_TYPE_MAP,
} }
export enum SqlClient { export enum SqlClient {
@ -178,25 +183,49 @@ export function breakRowIdField(_id: string | { _id: string }): any[] {
} }
} }
export function convertSqlType(type: string) { export function generateColumnDefinition(config: {
externalType: string
autocolumn: boolean
name: string
presence: boolean
options?: string[]
}) {
let { externalType, autocolumn, name, presence, options } = config
let foundType = FieldType.STRING let foundType = FieldType.STRING
const lcType = type.toLowerCase() const lowerCaseType = externalType.toLowerCase()
let matchingTypes = [] let matchingTypes = []
for (let [external, internal] of Object.entries(SQL_TYPE_MAP)) { for (let [external, internal] of Object.entries(SQL_TYPE_MAP)) {
if (lcType.includes(external)) { if (lowerCaseType.includes(external)) {
matchingTypes.push({ external, internal }) matchingTypes.push({ external, internal })
} }
} }
//Set the foundType based the longest match // Set the foundType based the longest match
if (matchingTypes.length > 0) { if (matchingTypes.length > 0) {
foundType = matchingTypes.reduce((acc, val) => { foundType = matchingTypes.reduce((acc, val) => {
return acc.external.length >= val.external.length ? acc : val return acc.external.length >= val.external.length ? acc : val
}).internal }).internal
} }
const schema: any = { type: foundType }
const constraints: {
presence: boolean
inclusion?: string[]
} = {
presence,
}
if (foundType === FieldType.OPTIONS) {
constraints.inclusion = options
}
const schema: any = {
type: foundType,
externalType,
autocolumn,
name,
constraints,
}
if (foundType === FieldType.DATETIME) { if (foundType === FieldType.DATETIME) {
schema.dateOnly = SQL_DATE_ONLY_TYPES.includes(lcType) schema.dateOnly = SQL_DATE_ONLY_TYPES.includes(lowerCaseType)
schema.timeOnly = SQL_TIME_ONLY_TYPES.includes(lcType) schema.timeOnly = SQL_TIME_ONLY_TYPES.includes(lowerCaseType)
} }
return schema return schema
} }

View file

@ -1,5 +1,5 @@
import env from "./environment" import env from "./environment"
import Koa, { ExtendableContext } from "koa" import Koa from "koa"
import koaBody from "koa-body" import koaBody from "koa-body"
import http from "http" import http from "http"
import * as api from "./api" import * as api from "./api"
@ -27,6 +27,9 @@ export default function createKoaApp() {
// @ts-ignore // @ts-ignore
enableTypes: ["json", "form", "text"], enableTypes: ["json", "form", "text"],
parsedMethods: ["POST", "PUT", "PATCH", "DELETE"], parsedMethods: ["POST", "PUT", "PATCH", "DELETE"],
formidable: {
maxFileSize: parseInt(env.MAX_IMPORT_SIZE_MB || "100") * 1024 * 1024,
},
}) })
) )

View file

@ -1,3 +1,4 @@
export const DB_EXPORT_FILE = "db.txt" export const DB_EXPORT_FILE = "db.txt"
export const GLOBAL_DB_EXPORT_FILE = "global.txt" export const GLOBAL_DB_EXPORT_FILE = "global.txt"
export const STATIC_APP_FILES = ["manifest.json", "budibase-client.js"] export const STATIC_APP_FILES = ["manifest.json", "budibase-client.js"]
export const ATTACHMENT_DIRECTORY = "attachments"

View file

@ -8,13 +8,15 @@ import {
TABLE_ROW_PREFIX, TABLE_ROW_PREFIX,
USER_METDATA_PREFIX, USER_METDATA_PREFIX,
} from "../../../db/utils" } from "../../../db/utils"
import { DB_EXPORT_FILE, STATIC_APP_FILES } from "./constants" import {
DB_EXPORT_FILE,
STATIC_APP_FILES,
ATTACHMENT_DIRECTORY,
} from "./constants"
import fs from "fs" import fs from "fs"
import { join } from "path" import { join } from "path"
import env from "../../../environment" import env from "../../../environment"
import { v4 as uuid } from "uuid"
const uuid = require("uuid/v4")
import tar from "tar" import tar from "tar"
const MemoryStream = require("memorystream") const MemoryStream = require("memorystream")
@ -30,12 +32,11 @@ export interface ExportOpts extends DBDumpOpts {
encryptPassword?: string encryptPassword?: string
} }
function tarFilesToTmp(tmpDir: string, files: string[]) { async function tarFilesToTmp(tmpDir: string, files: string[]) {
const fileName = `${uuid()}.tar.gz` const fileName = `${uuid()}.tar.gz`
const exportFile = join(budibaseTempDir(), fileName) const exportFile = join(budibaseTempDir(), fileName)
tar.create( await tar.create(
{ {
sync: true,
gzip: true, gzip: true,
file: exportFile, file: exportFile,
noDirRecurse: false, noDirRecurse: false,
@ -150,19 +151,21 @@ export async function exportApp(appId: string, config?: ExportOpts) {
for (let file of fs.readdirSync(tmpPath)) { for (let file of fs.readdirSync(tmpPath)) {
const path = join(tmpPath, file) const path = join(tmpPath, file)
await encryption.encryptFile( // skip the attachments - too big to encrypt
{ dir: tmpPath, filename: file }, if (file !== ATTACHMENT_DIRECTORY) {
config.encryptPassword await encryption.encryptFile(
) { dir: tmpPath, filename: file },
config.encryptPassword
fs.rmSync(path) )
fs.rmSync(path)
}
} }
} }
// if tar requested, return where the tarball is // if tar requested, return where the tarball is
if (config?.tar) { if (config?.tar) {
// now the tmpPath contains both the DB export and attachments, tar this // now the tmpPath contains both the DB export and attachments, tar this
const tarPath = tarFilesToTmp(tmpPath, fs.readdirSync(tmpPath)) const tarPath = await tarFilesToTmp(tmpPath, fs.readdirSync(tmpPath))
// cleanup the tmp export files as tarball returned // cleanup the tmp export files as tarball returned
fs.rmSync(tmpPath, { recursive: true, force: true }) fs.rmSync(tmpPath, { recursive: true, force: true })

View file

@ -6,17 +6,20 @@ import {
AutomationTriggerStepId, AutomationTriggerStepId,
RowAttachment, RowAttachment,
} from "@budibase/types" } from "@budibase/types"
import { getAutomationParams, TABLE_ROW_PREFIX } from "../../../db/utils" import { getAutomationParams } from "../../../db/utils"
import { budibaseTempDir } from "../../../utilities/budibaseDir" import { budibaseTempDir } from "../../../utilities/budibaseDir"
import { DB_EXPORT_FILE, GLOBAL_DB_EXPORT_FILE } from "./constants" import {
DB_EXPORT_FILE,
GLOBAL_DB_EXPORT_FILE,
ATTACHMENT_DIRECTORY,
} from "./constants"
import { downloadTemplate } from "../../../utilities/fileSystem" import { downloadTemplate } from "../../../utilities/fileSystem"
import { ObjectStoreBuckets } from "../../../constants" import { ObjectStoreBuckets } from "../../../constants"
import { join } from "path" import { join } from "path"
import fs from "fs" import fs from "fs"
import sdk from "../../" import sdk from "../../"
import { v4 as uuid } from "uuid"
const uuid = require("uuid/v4") import tar from "tar"
const tar = require("tar")
type TemplateType = { type TemplateType = {
file?: { file?: {
@ -114,12 +117,11 @@ async function getTemplateStream(template: TemplateType) {
} }
} }
export function untarFile(file: { path: string }) { export async function untarFile(file: { path: string }) {
const tmpPath = join(budibaseTempDir(), uuid()) const tmpPath = join(budibaseTempDir(), uuid())
fs.mkdirSync(tmpPath) fs.mkdirSync(tmpPath)
// extract the tarball // extract the tarball
tar.extract({ await tar.extract({
sync: true,
cwd: tmpPath, cwd: tmpPath,
file: file.path, file: file.path,
}) })
@ -130,9 +132,11 @@ async function decryptFiles(path: string, password: string) {
try { try {
for (let file of fs.readdirSync(path)) { for (let file of fs.readdirSync(path)) {
const inputPath = join(path, file) const inputPath = join(path, file)
const outputPath = inputPath.replace(/\.enc$/, "") if (!inputPath.endsWith(ATTACHMENT_DIRECTORY)) {
await encryption.decryptFile(inputPath, outputPath, password) const outputPath = inputPath.replace(/\.enc$/, "")
fs.rmSync(inputPath) await encryption.decryptFile(inputPath, outputPath, password)
fs.rmSync(inputPath)
}
} }
} catch (err: any) { } catch (err: any) {
if (err.message === "incorrect header check") { if (err.message === "incorrect header check") {
@ -162,7 +166,7 @@ export async function importApp(
const isDirectory = const isDirectory =
template.file && fs.lstatSync(template.file.path).isDirectory() template.file && fs.lstatSync(template.file.path).isDirectory()
if (template.file && (isTar || isDirectory)) { if (template.file && (isTar || isDirectory)) {
const tmpPath = isTar ? untarFile(template.file) : template.file.path const tmpPath = isTar ? await untarFile(template.file) : template.file.path
if (isTar && template.file.password) { if (isTar && template.file.password) {
await decryptFiles(tmpPath, template.file.password) await decryptFiles(tmpPath, template.file.password)
} }

View file

@ -19,11 +19,10 @@ import { context } from "@budibase/backend-core"
import { getTable } from "../getters" import { getTable } from "../getters"
import { checkAutoColumns } from "./utils" import { checkAutoColumns } from "./utils"
import * as viewsSdk from "../../views" import * as viewsSdk from "../../views"
import sdk from "../../../index"
import { getRowParams } from "../../../../db/utils" import { getRowParams } from "../../../../db/utils"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import env from "../../../../environment" import env from "../../../../environment"
import { cleanupAttachments } from "../../../../utilities/rowProcessor" import { AttachmentCleanup } from "../../../../utilities/rowProcessor"
export async function save( export async function save(
table: Table, table: Table,
@ -164,9 +163,10 @@ export async function destroy(table: Table) {
await runStaticFormulaChecks(table, { await runStaticFormulaChecks(table, {
deletion: true, deletion: true,
}) })
await cleanupAttachments(table, { await AttachmentCleanup.tableDelete(
rows: rowsData.rows.map((row: any) => row.doc), table,
}) rowsData.rows.map((row: any) => row.doc)
)
return { table } return { table }
} }

View file

@ -56,6 +56,7 @@ import {
import API from "./api" import API from "./api"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import jwt, { Secret } from "jsonwebtoken"
mocks.licenses.init(pro) mocks.licenses.init(pro)
@ -391,7 +392,7 @@ class TestConfiguration {
sessionId: "sessionid", sessionId: "sessionid",
tenantId: this.getTenantId(), tenantId: this.getTenantId(),
} }
const authToken = auth.jwt.sign(authObj, coreEnv.JWT_SECRET) const authToken = jwt.sign(authObj, coreEnv.JWT_SECRET as Secret)
// returning necessary request headers // returning necessary request headers
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
@ -412,7 +413,7 @@ class TestConfiguration {
sessionId: "sessionid", sessionId: "sessionid",
tenantId, tenantId,
} }
const authToken = auth.jwt.sign(authObj, coreEnv.JWT_SECRET) const authToken = jwt.sign(authObj, coreEnv.JWT_SECRET as Secret)
const headers: any = { const headers: any = {
Accept: "application/json", Accept: "application/json",

View file

@ -0,0 +1,99 @@
import { FieldTypes, ObjectStoreBuckets } from "../../constants"
import { context, db as dbCore, objectStore } from "@budibase/backend-core"
import { RenameColumn, Row, RowAttachment, Table } from "@budibase/types"
export class AttachmentCleanup {
static async coreCleanup(fileListFn: () => string[]): Promise<void> {
const appId = context.getAppId()
if (!dbCore.isProdAppID(appId)) {
const prodAppId = dbCore.getProdAppID(appId!)
// if prod exists, then don't allow deleting
const exists = await dbCore.dbExists(prodAppId)
if (exists) {
return
}
}
const files = fileListFn()
if (files.length > 0) {
await objectStore.deleteFiles(ObjectStoreBuckets.APPS, files)
}
}
private static async tableChange(
table: Table,
rows: Row[],
opts: { oldTable?: Table; rename?: RenameColumn; deleting?: boolean }
) {
return AttachmentCleanup.coreCleanup(() => {
let files: string[] = []
const tableSchema = opts.oldTable?.schema || table.schema
for (let [key, schema] of Object.entries(tableSchema)) {
if (schema.type !== FieldTypes.ATTACHMENT) {
continue
}
const columnRemoved = opts.oldTable && !table.schema[key]
const renaming = opts.rename?.old === key
// old table had this column, new table doesn't - delete it
if ((columnRemoved && !renaming) || opts.deleting) {
rows.forEach(row => {
files = files.concat(
row[key].map((attachment: any) => attachment.key)
)
})
}
}
return files
})
}
static async tableDelete(table: Table, rows: Row[]) {
return AttachmentCleanup.tableChange(table, rows, { deleting: true })
}
static async tableUpdate(
table: Table,
rows: Row[],
opts: { oldTable?: Table; rename?: RenameColumn }
) {
return AttachmentCleanup.tableChange(table, rows, opts)
}
static async rowDelete(table: Table, rows: Row[]) {
return AttachmentCleanup.coreCleanup(() => {
let files: string[] = []
for (let [key, schema] of Object.entries(table.schema)) {
if (schema.type !== FieldTypes.ATTACHMENT) {
continue
}
rows.forEach(row => {
files = files.concat(
row[key].map((attachment: any) => attachment.key)
)
})
}
return files
})
}
static rowUpdate(table: Table, opts: { row: Row; oldRow: Row }) {
return AttachmentCleanup.coreCleanup(() => {
let files: string[] = []
for (let [key, schema] of Object.entries(table.schema)) {
if (schema.type !== FieldTypes.ATTACHMENT) {
continue
}
const oldKeys =
opts.oldRow[key]?.map(
(attachment: RowAttachment) => attachment.key
) || []
const newKeys =
opts.row[key]?.map((attachment: RowAttachment) => attachment.key) ||
[]
files = files.concat(
oldKeys.filter((key: string) => newKeys.indexOf(key) === -1)
)
}
return files
})
}
}

View file

@ -1,16 +1,7 @@
import * as linkRows from "../../db/linkedRows" import * as linkRows from "../../db/linkedRows"
import { import { FieldTypes, AutoFieldSubTypes } from "../../constants"
FieldTypes,
AutoFieldSubTypes,
ObjectStoreBuckets,
} from "../../constants"
import { processFormulas, fixAutoColumnSubType } from "./utils" import { processFormulas, fixAutoColumnSubType } from "./utils"
import { import { objectStore, utils } from "@budibase/backend-core"
context,
db as dbCore,
objectStore,
utils,
} from "@budibase/backend-core"
import { InternalTables } from "../../db/utils" import { InternalTables } from "../../db/utils"
import { TYPE_TRANSFORM_MAP } from "./map" import { TYPE_TRANSFORM_MAP } from "./map"
import { FieldSubtype, Row, RowAttachment, Table } from "@budibase/types" import { FieldSubtype, Row, RowAttachment, Table } from "@budibase/types"
@ -22,6 +13,7 @@ import {
import { isExternalTableID } from "../../integrations/utils" import { isExternalTableID } from "../../integrations/utils"
export * from "./utils" export * from "./utils"
export * from "./attachments"
type AutoColumnProcessingOpts = { type AutoColumnProcessingOpts = {
reprocessing?: boolean reprocessing?: boolean
@ -30,27 +22,6 @@ type AutoColumnProcessingOpts = {
const BASE_AUTO_ID = 1 const BASE_AUTO_ID = 1
/**
* Given the old state of the row and the new one after an update, this will
* find the keys that have been removed in the updated row.
*/
function getRemovedAttachmentKeys(
oldRow: Row,
row: Row,
attachmentKey: string
) {
if (!oldRow[attachmentKey]) {
return []
}
const oldKeys = oldRow[attachmentKey].map((attachment: any) => attachment.key)
// no attachments in new row, all removed
if (!row[attachmentKey]) {
return oldKeys
}
const newKeys = row[attachmentKey].map((attachment: any) => attachment.key)
return oldKeys.filter((key: string) => newKeys.indexOf(key) === -1)
}
/** /**
* This will update any auto columns that are found on the row/table with the correct information based on * This will update any auto columns that are found on the row/table with the correct information based on
* time now and the current logged in user making the request. * time now and the current logged in user making the request.
@ -249,7 +220,9 @@ export async function outputProcessing<T extends Row[] | Row>(
continue continue
} }
row[property].forEach((attachment: RowAttachment) => { row[property].forEach((attachment: RowAttachment) => {
attachment.url ??= objectStore.getAppFileUrl(attachment.key) if (!attachment.url) {
attachment.url = objectStore.getAppFileUrl(attachment.key)
}
}) })
} }
} else if ( } else if (
@ -286,59 +259,3 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
return (wasArray ? enriched : enriched[0]) as T return (wasArray ? enriched : enriched[0]) as T
} }
/**
* Clean up any attachments that were attached to a row.
* @param table The table from which a row is being removed.
* @param row optional - the row being removed.
* @param rows optional - if multiple rows being deleted can do this in bulk.
* @param oldRow optional - if updating a row this will determine the difference.
* @param oldTable optional - if updating a table, can supply the old table to look for
* deleted attachment columns.
* @return When all attachments have been removed this will return.
*/
export async function cleanupAttachments(
table: Table,
{
row,
rows,
oldRow,
oldTable,
}: { row?: Row; rows?: Row[]; oldRow?: Row; oldTable?: Table }
): Promise<any> {
const appId = context.getAppId()
if (!dbCore.isProdAppID(appId)) {
const prodAppId = dbCore.getProdAppID(appId!)
// if prod exists, then don't allow deleting
const exists = await dbCore.dbExists(prodAppId)
if (exists) {
return
}
}
let files: string[] = []
function addFiles(row: Row, key: string) {
if (row[key]) {
files = files.concat(row[key].map((attachment: any) => attachment.key))
}
}
const schemaToUse = oldTable ? oldTable.schema : table.schema
for (let [key, schema] of Object.entries(schemaToUse)) {
if (schema.type !== FieldTypes.ATTACHMENT) {
continue
}
// old table had this column, new table doesn't - delete it
if (rows && oldTable && !table.schema[key]) {
rows.forEach(row => addFiles(row, key))
} else if (oldRow && row) {
// if updating, need to manage the differences
files = files.concat(getRemovedAttachmentKeys(oldRow, row, key))
} else if (row) {
addFiles(row, key)
} else if (rows) {
rows.forEach(row => addFiles(row, key))
}
}
if (files.length > 0) {
await objectStore.deleteFiles(ObjectStoreBuckets.APPS, files)
}
}

View file

@ -0,0 +1,110 @@
import { AttachmentCleanup } from "../attachments"
import { FieldType, Table, Row, TableSourceType } from "@budibase/types"
import { DEFAULT_BB_DATASOURCE_ID } from "../../../constants"
import { objectStore } from "@budibase/backend-core"
const BUCKET = "prod-budi-app-assets"
const FILE_NAME = "file/thing.jpg"
jest.mock("@budibase/backend-core", () => {
const actual = jest.requireActual("@budibase/backend-core")
return {
...actual,
objectStore: {
deleteFiles: jest.fn(),
ObjectStoreBuckets: actual.objectStore.ObjectStoreBuckets,
},
db: {
isProdAppID: () => jest.fn(() => false),
dbExists: () => jest.fn(() => false),
},
}
})
const mockedDeleteFiles = objectStore.deleteFiles as jest.MockedFunction<
typeof objectStore.deleteFiles
>
function table(): Table {
return {
name: "table",
sourceId: DEFAULT_BB_DATASOURCE_ID,
sourceType: TableSourceType.INTERNAL,
type: "table",
schema: {
attach: {
name: "attach",
type: FieldType.ATTACHMENT,
constraints: {},
},
},
}
}
function row(fileKey: string = FILE_NAME): Row {
return {
attach: [
{
size: 1,
extension: "jpg",
key: fileKey,
},
],
}
}
describe("attachment cleanup", () => {
beforeEach(() => {
mockedDeleteFiles.mockClear()
})
it("should be able to cleanup a table update", async () => {
const originalTable = table()
delete originalTable.schema["attach"]
await AttachmentCleanup.tableUpdate(originalTable, [row()], {
oldTable: table(),
})
expect(mockedDeleteFiles).toBeCalledWith(BUCKET, [FILE_NAME])
})
it("should be able to cleanup a table deletion", async () => {
await AttachmentCleanup.tableDelete(table(), [row()])
expect(mockedDeleteFiles).toBeCalledWith(BUCKET, [FILE_NAME])
})
it("should handle table column renaming", async () => {
const updatedTable = table()
updatedTable.schema.attach2 = updatedTable.schema.attach
delete updatedTable.schema.attach
await AttachmentCleanup.tableUpdate(updatedTable, [row()], {
oldTable: table(),
rename: { old: "attach", updated: "attach2" },
})
expect(mockedDeleteFiles).not.toBeCalled()
})
it("shouldn't cleanup if no table changes", async () => {
await AttachmentCleanup.tableUpdate(table(), [row()], { oldTable: table() })
expect(mockedDeleteFiles).not.toBeCalled()
})
it("should handle row updates", async () => {
const updatedRow = row()
delete updatedRow.attach
await AttachmentCleanup.rowUpdate(table(), {
row: updatedRow,
oldRow: row(),
})
expect(mockedDeleteFiles).toBeCalledWith(BUCKET, [FILE_NAME])
})
it("should handle row deletion", async () => {
await AttachmentCleanup.rowDelete(table(), [row()])
expect(mockedDeleteFiles).toBeCalledWith(BUCKET, [FILE_NAME])
})
it("shouldn't cleanup attachments if row not updated", async () => {
await AttachmentCleanup.rowUpdate(table(), { row: row(), oldRow: row() })
expect(mockedDeleteFiles).not.toBeCalled()
})
})

View file

@ -3,6 +3,7 @@ import {
FieldType, FieldType,
FieldTypeSubtypes, FieldTypeSubtypes,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
RowAttachment,
Table, Table,
TableSourceType, TableSourceType,
} from "@budibase/types" } from "@budibase/types"
@ -70,6 +71,49 @@ describe("rowProcessor - outputProcessing", () => {
) )
}) })
it("should handle attachments correctly", async () => {
const table: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
attach: {
type: FieldType.ATTACHMENT,
name: "attach",
constraints: {},
},
},
}
const row: { attach: RowAttachment[] } = {
attach: [
{
size: 10,
name: "test",
extension: "jpg",
key: "test.jpg",
},
],
}
const output = await outputProcessing(table, row, { squash: false })
expect(output.attach[0].url).toBe(
"/files/signed/prod-budi-app-assets/test.jpg"
)
row.attach[0].url = ""
const output2 = await outputProcessing(table, row, { squash: false })
expect(output2.attach[0].url).toBe(
"/files/signed/prod-budi-app-assets/test.jpg"
)
row.attach[0].url = "aaaa"
const output3 = await outputProcessing(table, row, { squash: false })
expect(output3.attach[0].url).toBe("aaaa")
})
it("process output even when the field is not empty", async () => { it("process output even when the field is not empty", async () => {
const table: Table = { const table: Table = {
_id: generator.guid(), _id: generator.guid(),

View file

@ -0,0 +1,9 @@
export interface DatasourceAuthCookie {
appId: string
provider: string
}
export interface SessionCookie {
sessionId: string
userId: string
}

View file

@ -9,3 +9,4 @@ export * from "./app"
export * from "./global" export * from "./global"
export * from "./pagination" export * from "./pagination"
export * from "./searchFilter" export * from "./searchFilter"
export * from "./cookies"

View file

@ -15,6 +15,7 @@ import {
PasswordResetRequest, PasswordResetRequest,
PasswordResetUpdateRequest, PasswordResetUpdateRequest,
GoogleInnerConfig, GoogleInnerConfig,
DatasourceAuthCookie,
} from "@budibase/types" } from "@budibase/types"
import env from "../../../environment" import env from "../../../environment"
@ -148,7 +149,13 @@ export const datasourcePreAuth = async (ctx: any, next: any) => {
} }
export const datasourceAuth = async (ctx: any, next: any) => { export const datasourceAuth = async (ctx: any, next: any) => {
const authStateCookie = getCookie(ctx, Cookie.DatasourceAuth) const authStateCookie = getCookie<DatasourceAuthCookie>(
ctx,
Cookie.DatasourceAuth
)
if (!authStateCookie) {
throw new Error("Unable to retrieve datasource authentication cookie")
}
const provider = authStateCookie.provider const provider = authStateCookie.provider
const { middleware } = require(`@budibase/backend-core`) const { middleware } = require(`@budibase/backend-core`)
const handler = middleware.datasource[provider] const handler = middleware.datasource[provider]

View file

@ -35,6 +35,7 @@ import {
ConfigType, ConfigType,
} from "@budibase/types" } from "@budibase/types"
import API from "./api" import API from "./api"
import jwt, { Secret } from "jsonwebtoken"
class TestConfiguration { class TestConfiguration {
server: any server: any
@ -209,7 +210,7 @@ class TestConfiguration {
sessionId: "sessionid", sessionId: "sessionid",
tenantId: user.tenantId, tenantId: user.tenantId,
} }
const authCookie = auth.jwt.sign(authToken, coreEnv.JWT_SECRET) const authCookie = jwt.sign(authToken, coreEnv.JWT_SECRET as Secret)
return { return {
Accept: "application/json", Accept: "application/json",
...this.cookieHeader([`${constants.Cookie.Auth}=${authCookie}`]), ...this.cookieHeader([`${constants.Cookie.Auth}=${authCookie}`]),
@ -327,7 +328,7 @@ class TestConfiguration {
// CONFIGS - OIDC // CONFIGS - OIDC
getOIDConfigCookie(configId: string) { getOIDConfigCookie(configId: string) {
const token = auth.jwt.sign(configId, coreEnv.JWT_SECRET) const token = jwt.sign(configId, coreEnv.JWT_SECRET as Secret)
return this.cookieHeader([[`${constants.Cookie.OIDC_CONFIG}=${token}`]]) return this.cookieHeader([[`${constants.Cookie.OIDC_CONFIG}=${token}`]])
} }