Merge remote-tracking branch 'origin/develop' into merge-master

This commit is contained in:
Dean 2023-06-27 09:31:35 +01:00
commit eaecd3ab68
293 changed files with 7894 additions and 11086 deletions

View File

@ -1,5 +1,9 @@
name: Budibase CI name: Budibase CI
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on: on:
# Trigger the workflow on push or pull request, # Trigger the workflow on push or pull request,
# but only for the master branch # but only for the master branch
@ -23,6 +27,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@ -135,15 +142,39 @@ jobs:
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Check submodule - name: Check pro commit
id: get_pro_commits
run: | run: |
cd packages/pro cd packages/pro
git fetch pro_commit=$(git rev-parse HEAD)
if ! git merge-base --is-ancestor $(git log -n 1 --pretty=format:%H) origin/develop; then
echo "Current commit has not been merged to develop" branch=${{ github.base_ref || github.ref_name }}
echo "Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md" echo "Running on branch `$branch` (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})"
exit 1
if [[ "$branch" == "master" ]]; then
base_commit=$(git rev-parse origin/master)
else else
echo "All good, the submodule had been merged!" base_commit=$(git rev-parse origin/develop)
fi fi
echo "pro_commit=$pro_commit"
echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT"
echo "base_commit=$base_commit"
echo "base_commit=$base_commit" >> "$GITHUB_OUTPUT"
- name: Check submodule merged to develop
uses: actions/github-script@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const submoduleCommit = '${{ steps.get_pro_commits.outputs.pro_commit }}';
const baseCommit = '${{ steps.get_pro_commits.outputs.base_commit }}';
if (submoduleCommit !== baseCommit) {
console.error('Submodule commit does not match the latest commit on the develop branch.');
console.error('Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md')
process.exit(1);
} else {
console.log('All good, the submodule had been merged and setup correctly!')
}

View File

@ -32,10 +32,11 @@ jobs:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: yarn - run: cd scripts && yarn
- name: Tag prerelease - name: Tag prerelease
run: | run: |
cd scripts
# setup the username and email. # setup the username and email.
git config --global user.name "Budibase Staging Release Bot" git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>" git config --global user.email "<>"
./scripts/versionCommit.sh prerelease ./versionCommit.sh prerelease

View File

@ -42,12 +42,13 @@ jobs:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: yarn - run: cd scripts && yarn
- name: Tag release - name: Tag release
run: | run: |
cd scripts
# setup the username and email. # setup the username and email.
git config --global user.name "Budibase Staging Release Bot" git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>" git config --global user.email "<>"
BUMP_TYPE_INPUT=${{ github.event.inputs.versioning }} BUMP_TYPE_INPUT=${{ github.event.inputs.versioning }}
BUMP_TYPE=${BUMP_TYPE_INPUT:-"patch"} BUMP_TYPE=${BUMP_TYPE_INPUT:-"patch"}
./scripts/versionCommit.sh $BUMP_TYPE ./versionCommit.sh $BUMP_TYPE

View File

@ -126,6 +126,16 @@ http {
proxy_pass http://app-service; proxy_pass http://app-service;
} }
location /embed {
rewrite /embed/(.*) /app/$1 break;
proxy_pass http://app-service;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header x-budibase-embed "true";
add_header x-budibase-embed "true";
add_header Content-Security-Policy "frame-ancestors *";
}
location /builder { location /builder {
proxy_read_timeout 120s; proxy_read_timeout 120s;
proxy_connect_timeout 120s; proxy_connect_timeout 120s;

View File

@ -92,6 +92,16 @@ http {
proxy_pass $apps; proxy_pass $apps;
} }
location /embed {
rewrite /embed/(.*) /app/$1 break;
proxy_pass $apps;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header x-budibase-embed "true";
add_header x-budibase-embed "true";
add_header Content-Security-Policy "frame-ancestors *";
}
location = / { location = / {
proxy_pass $apps; proxy_pass $apps;
} }

View File

@ -2,7 +2,9 @@ const fs = require("fs")
const { execSync } = require("child_process") const { execSync } = require("child_process")
const path = require("path") const path = require("path")
const IMAGES = { const IS_SINGLE_IMAGE = process.env.SINGLE_IMAGE
let IMAGES = {
worker: "budibase/worker", worker: "budibase/worker",
apps: "budibase/apps", apps: "budibase/apps",
proxy: "budibase/proxy", proxy: "budibase/proxy",
@ -10,7 +12,13 @@ const IMAGES = {
couch: "ibmcom/couchdb3", couch: "ibmcom/couchdb3",
curl: "curlimages/curl", curl: "curlimages/curl",
redis: "redis", redis: "redis",
watchtower: "containrrr/watchtower" watchtower: "containrrr/watchtower",
}
if (IS_SINGLE_IMAGE) {
IMAGES = {
budibase: "budibase/budibase"
}
} }
const FILES = { const FILES = {
@ -39,11 +47,10 @@ for (let image in IMAGES) {
} }
// copy config files // copy config files
copyFile(FILES.COMPOSE) if (!IS_SINGLE_IMAGE) {
copyFile(FILES.COMPOSE)
}
copyFile(FILES.ENV) copyFile(FILES.ENV)
// compress // compress
execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`) execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`)
// clean up
fs.rmdirSync(OUTPUT_DIR, { recursive: true })

View File

@ -37,6 +37,14 @@ COPY --from=build /worker /worker
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server
# Install postgres client for pg_dump utils
RUN apt install software-properties-common apt-transport-https gpg -y \
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
&& apt update -y \
&& apt install postgresql-client-15 -y \
&& apt remove software-properties-common apt-transport-https gpg -y
# install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx # install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx
WORKDIR /nodejs WORKDIR /nodejs
RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh && \ RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh && \

View File

@ -1,22 +1,8 @@
{ {
"version": "2.7.35", "version": "2.7.35",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": ["packages/*"],
"packages/backend-core", "useNx": true,
"packages/bbui",
"packages/builder",
"packages/cli",
"packages/client",
"packages/frontend-core",
"packages/sdk",
"packages/server",
"packages/shared-core",
"packages/string-templates",
"packages/types",
"packages/worker",
"packages/pro/packages/pro"
],
"useWorkspaces": true,
"command": { "command": {
"publish": { "publish": {
"ignoreChanges": [ "ignoreChanges": [
@ -31,4 +17,4 @@
"loadEnvFiles": false "loadEnvFiles": false
} }
} }
} }

View File

@ -2,28 +2,26 @@
"name": "root", "name": "root",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-resolve": "^0.2.2",
"@esbuild-plugins/tsconfig-paths": "^0.1.2", "@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.2.1", "@nx/js": "16.2.1",
"@rollup/plugin-json": "^4.0.2", "@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "5.45.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"esbuild": "^0.17.18", "esbuild": "^0.17.18",
"esbuild-node-externals": "^1.7.0",
"eslint": "^7.28.0", "eslint": "^7.28.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-svelte3": "^3.2.0", "eslint-plugin-svelte3": "^3.2.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kill-port": "^1.6.1", "kill-port": "^1.6.1",
"lerna": "7.0.0-alpha.0", "lerna": "7.0.2",
"madge": "^6.0.0", "madge": "^6.0.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"nx": "^16.2.1",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0", "rollup-plugin-replace": "^2.2.0",
"semver": "^7.5.0",
"svelte": "^3.38.2", "svelte": "^3.38.2",
"typescript": "4.7.3" "typescript": "4.7.3"
}, },
@ -48,9 +46,9 @@
"kill-builder": "kill-port 3000", "kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002", "kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server", "kill-all": "yarn run kill-builder && yarn run kill-server",
"dev": "yarn run kill-all && lerna run --stream --parallel dev:builder --stream", "dev": "yarn run kill-all && lerna run --stream --parallel dev:builder --stream",
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server", "dev:server": "yarn run kill-server && yarn build --projects=@budibase/client && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0", "dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream", "test": "lerna run --stream test --stream",
@ -67,6 +65,7 @@
"build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -", "build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
"build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .", "build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .", "build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .",
@ -95,19 +94,7 @@
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/backend-core", "packages/*"
"packages/bbui",
"packages/builder",
"packages/cli",
"packages/client",
"packages/frontend-core",
"packages/sdk",
"packages/server",
"packages/shared-core",
"packages/string-templates",
"packages/types",
"packages/worker",
"packages/pro/packages/pro"
] ]
}, },
"resolutions": { "resolutions": {

View File

@ -31,4 +31,6 @@ const config: Config.InitialOptions = {
coverageReporters: ["lcov", "json", "clover"], coverageReporters: ["lcov", "json", "clover"],
} }
process.env.DISABLE_PINO_LOGGER = "1"
export default config export default config

View File

@ -27,7 +27,7 @@
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",
"aws-sdk": "2.1030.0", "aws-sdk": "2.1030.0",
"bcrypt": "5.0.1", "bcrypt": "5.1.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.10.1", "bull": "4.10.1",
"correlation-id": "4.0.0", "correlation-id": "4.0.0",

View File

@ -14,10 +14,15 @@ async function servedBuilder(timezone: string) {
await publishEvent(Event.SERVED_BUILDER, properties) await publishEvent(Event.SERVED_BUILDER, properties)
} }
async function servedApp(app: App, timezone: string) { async function servedApp(
app: App,
timezone: string,
embed?: boolean | undefined
) {
const properties: AppServedEvent = { const properties: AppServedEvent = {
appVersion: app.version, appVersion: app.version,
timezone, timezone,
embed: embed === true,
} }
await publishEvent(Event.SERVED_APP, properties) await publishEvent(Event.SERVED_APP, properties)
} }

View File

@ -1,10 +1,11 @@
import * as google from "../sso/google" import * as google from "../sso/google"
import { Cookie } from "../../../constants" import { Cookie } from "../../../constants"
import { clearCookie, getCookie } from "../../../utils"
import { doWithDB } from "../../../db"
import * as configs from "../../../configs" import * as configs from "../../../configs"
import { BBContext, Database, SSOProfile } from "@budibase/types" import * as cache from "../../../cache"
import * as utils from "../../../utils"
import { UserCtx, SSOProfile } 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
type Passport = { type Passport = {
@ -22,7 +23,7 @@ async function fetchGoogleCreds() {
export async function preAuth( export async function preAuth(
passport: Passport, passport: Passport,
ctx: BBContext, ctx: UserCtx,
next: Function next: Function
) { ) {
// get the relevant config // get the relevant config
@ -36,8 +37,8 @@ export async function preAuth(
ssoSaveUserNoOp ssoSaveUserNoOp
) )
if (!ctx.query.appId || !ctx.query.datasourceId) { if (!ctx.query.appId) {
ctx.throw(400, "appId and datasourceId query params not present.") ctx.throw(400, "appId query param not present.")
} }
return passport.authenticate(strategy, { return passport.authenticate(strategy, {
@ -49,7 +50,7 @@ export async function preAuth(
export async function postAuth( export async function postAuth(
passport: Passport, passport: Passport,
ctx: BBContext, ctx: UserCtx,
next: Function next: Function
) { ) {
// get the relevant config // get the relevant config
@ -57,7 +58,7 @@ 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 = getCookie(ctx, Cookie.DatasourceAuth) const authStateCookie = utils.getCookie(ctx, Cookie.DatasourceAuth)
return passport.authenticate( return passport.authenticate(
new GoogleStrategy( new GoogleStrategy(
@ -69,33 +70,26 @@ export async function postAuth(
( (
accessToken: string, accessToken: string,
refreshToken: string, refreshToken: string,
profile: SSOProfile, _profile: SSOProfile,
done: Function done: Function
) => { ) => {
clearCookie(ctx, Cookie.DatasourceAuth) utils.clearCookie(ctx, Cookie.DatasourceAuth)
done(null, { accessToken, refreshToken }) done(null, { accessToken, refreshToken })
} }
), ),
{ successRedirect: "/", failureRedirect: "/error" }, { successRedirect: "/", failureRedirect: "/error" },
async (err: any, tokens: string[]) => { async (err: any, tokens: string[]) => {
const baseUrl = `/builder/app/${authStateCookie.appId}/data` const baseUrl = `/builder/app/${authStateCookie.appId}/data`
// update the DB for the datasource with all the user info
await doWithDB(authStateCookie.appId, async (db: Database) => { const id = utils.newid()
let datasource await cache.store(
try { `datasource:creation:${authStateCookie.appId}:google:${id}`,
datasource = await db.get(authStateCookie.datasourceId) {
} catch (err: any) { tokens,
if (err.status === 404) {
ctx.redirect(baseUrl)
}
} }
if (!datasource.config) { )
datasource.config = {}
} ctx.redirect(`${baseUrl}/new?continue_google_setup=${id}`)
datasource.config.auth = { type: "google", ...tokens }
await db.put(datasource)
ctx.redirect(`${baseUrl}/datasource/${authStateCookie.datasourceId}`)
})
} }
)(ctx, next) )(ctx, next)
} }

View File

@ -1,12 +1,17 @@
import crypto from "crypto" import crypto from "crypto"
import fs from "fs"
import zlib from "zlib"
import env from "../environment" import env from "../environment"
import { join } from "path"
const ALGO = "aes-256-ctr" const ALGO = "aes-256-ctr"
const SEPARATOR = "-" const SEPARATOR = "-"
const ITERATIONS = 10000 const ITERATIONS = 10000
const RANDOM_BYTES = 16
const STRETCH_LENGTH = 32 const STRETCH_LENGTH = 32
const SALT_LENGTH = 16
const IV_LENGTH = 16
export enum SecretOption { export enum SecretOption {
API = "api", API = "api",
ENCRYPTION = "encryption", ENCRYPTION = "encryption",
@ -31,15 +36,15 @@ export function getSecret(secretOption: SecretOption): string {
return secret return secret
} }
function stretchString(string: string, salt: Buffer) { function stretchString(secret: string, salt: Buffer) {
return crypto.pbkdf2Sync(string, salt, ITERATIONS, STRETCH_LENGTH, "sha512") return crypto.pbkdf2Sync(secret, salt, ITERATIONS, STRETCH_LENGTH, "sha512")
} }
export function encrypt( export function encrypt(
input: string, input: string,
secretOption: SecretOption = SecretOption.API secretOption: SecretOption = SecretOption.API
) { ) {
const salt = crypto.randomBytes(RANDOM_BYTES) const salt = crypto.randomBytes(SALT_LENGTH)
const stretched = stretchString(getSecret(secretOption), salt) const stretched = stretchString(getSecret(secretOption), salt)
const cipher = crypto.createCipheriv(ALGO, stretched, salt) const cipher = crypto.createCipheriv(ALGO, stretched, salt)
const base = cipher.update(input) const base = cipher.update(input)
@ -60,3 +65,115 @@ export function decrypt(
const final = decipher.final() const final = decipher.final()
return Buffer.concat([base, final]).toString() return Buffer.concat([base, final]).toString()
} }
export async function encryptFile(
{ dir, filename }: { dir: string; filename: string },
secret: string
) {
const outputFileName = `${filename}.enc`
const filePath = join(dir, filename)
const inputFile = fs.createReadStream(filePath)
const outputFile = fs.createWriteStream(join(dir, outputFileName))
const salt = crypto.randomBytes(SALT_LENGTH)
const iv = crypto.randomBytes(IV_LENGTH)
const stretched = stretchString(secret, salt)
const cipher = crypto.createCipheriv(ALGO, stretched, iv)
outputFile.write(salt)
outputFile.write(iv)
inputFile.pipe(zlib.createGzip()).pipe(cipher).pipe(outputFile)
return new Promise<{ filename: string; dir: string }>(r => {
outputFile.on("finish", () => {
r({
filename: outputFileName,
dir,
})
})
})
}
async function getSaltAndIV(path: string) {
const fileStream = fs.createReadStream(path)
const salt = await readBytes(fileStream, SALT_LENGTH)
const iv = await readBytes(fileStream, IV_LENGTH)
fileStream.close()
return { salt, iv }
}
export async function decryptFile(
inputPath: string,
outputPath: string,
secret: string
) {
const { salt, iv } = await getSaltAndIV(inputPath)
const inputFile = fs.createReadStream(inputPath, {
start: SALT_LENGTH + IV_LENGTH,
})
const outputFile = fs.createWriteStream(outputPath)
const stretched = stretchString(secret, salt)
const decipher = crypto.createDecipheriv(ALGO, stretched, iv)
const unzip = zlib.createGunzip()
inputFile.pipe(decipher).pipe(unzip).pipe(outputFile)
return new Promise<void>((res, rej) => {
outputFile.on("finish", () => {
outputFile.close()
res()
})
inputFile.on("error", e => {
outputFile.close()
rej(e)
})
decipher.on("error", e => {
outputFile.close()
rej(e)
})
unzip.on("error", e => {
outputFile.close()
rej(e)
})
outputFile.on("error", e => {
outputFile.close()
rej(e)
})
})
}
function readBytes(stream: fs.ReadStream, length: number) {
return new Promise<Buffer>((resolve, reject) => {
let bytesRead = 0
const data: Buffer[] = []
stream.on("readable", () => {
let chunk
while ((chunk = stream.read(length - bytesRead)) !== null) {
data.push(chunk)
bytesRead += chunk.length
}
resolve(Buffer.concat(data))
})
stream.on("end", () => {
reject(new Error("Insufficient data in the stream."))
})
stream.on("error", error => {
reject(error)
})
})
}

View File

@ -140,9 +140,13 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
* Gets the role object, this is mainly useful for two purposes, to check if the level exists and * Gets the role object, this is mainly useful for two purposes, to check if the level exists and
* to check if the role inherits any others. * to check if the role inherits any others.
* @param {string|null} roleId The level ID to lookup. * @param {string|null} roleId The level ID to lookup.
* @param {object|null} opts options for the function, like whether to halt errors, instead return public.
* @returns {Promise<Role|object|null>} The role object, which may contain an "inherits" property. * @returns {Promise<Role|object|null>} The role object, which may contain an "inherits" property.
*/ */
export async function getRole(roleId?: string): Promise<RoleDoc | undefined> { export async function getRole(
roleId?: string,
opts?: { defaultPublic?: boolean }
): Promise<RoleDoc | undefined> {
if (!roleId) { if (!roleId) {
return undefined return undefined
} }
@ -161,6 +165,9 @@ export async function getRole(roleId?: string): Promise<RoleDoc | undefined> {
// finalise the ID // finalise the ID
role._id = getExternalRoleID(role._id) role._id = getExternalRoleID(role._id)
} catch (err) { } catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
}
// only throw an error if there is no role at all // only throw an error if there is no role at all
if (Object.keys(role).length === 0) { if (Object.keys(role).length === 0) {
throw err throw err

View File

@ -8,6 +8,8 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let validate = null export let validate = null
export let indeterminate = false
export let compact = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -21,11 +23,19 @@
} }
</script> </script>
<FancyField {error} {value} {validate} {disabled} clickable on:click={onChange}> <FancyField
{error}
{value}
{validate}
{disabled}
{compact}
clickable
on:click={onChange}
>
<span> <span>
<Checkbox {disabled} {value} /> <Checkbox {disabled} {value} {indeterminate} />
</span> </span>
<div class="text"> <div class="text" class:compact>
{#if text} {#if text}
{text} {text}
{/if} {/if}
@ -47,6 +57,10 @@
line-clamp: 2; line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.text.compact {
font-size: 13px;
line-height: 15px;
}
.text > :global(*) { .text > :global(*) {
font-size: inherit !important; font-size: inherit !important;
} }

View File

@ -0,0 +1,69 @@
<script>
import FancyCheckbox from "./FancyCheckbox.svelte"
import FancyForm from "./FancyForm.svelte"
import { createEventDispatcher } from "svelte"
export let options = []
export let selected = []
export let showSelectAll = true
export let selectAllText = "Select all"
let selectedBooleans = reset()
const dispatch = createEventDispatcher()
$: updateSelected(selectedBooleans)
$: allSelected = selected?.length === options.length
$: noneSelected = !selected?.length
function reset() {
return Array(options.length).fill(true)
}
function updateSelected(selectedArr) {
const array = []
for (let [i, isSelected] of Object.entries(selectedArr)) {
if (isSelected) {
array.push(options[i])
}
}
selected = array
dispatch("change", selected)
}
function toggleSelectAll() {
if (allSelected === true) {
selectedBooleans = []
} else {
selectedBooleans = reset()
}
dispatch("change", selected)
}
</script>
{#if options && Array.isArray(options)}
<div class="checkbox-group" class:has-select-all={showSelectAll}>
<FancyForm on:change>
{#if showSelectAll}
<FancyCheckbox
bind:value={allSelected}
on:change={toggleSelectAll}
text={selectAllText}
indeterminate={!allSelected && !noneSelected}
compact
/>
{/if}
{#each options as option, i}
<FancyCheckbox bind:value={selectedBooleans[i]} text={option} compact />
{/each}
</FancyForm>
</div>
{/if}
<style>
.checkbox-group.has-select-all :global(.fancy-field:first-of-type) {
background: var(--spectrum-global-color-gray-100);
}
.checkbox-group.has-select-all :global(.fancy-field:first-of-type:hover) {
background: var(--spectrum-global-color-gray-200);
}
</style>

View File

@ -11,6 +11,7 @@
export let value export let value
export let ref export let ref
export let autoHeight export let autoHeight
export let compact = false
const formContext = getContext("fancy-form") const formContext = getContext("fancy-form")
const id = Math.random() const id = Math.random()
@ -42,6 +43,7 @@
class:disabled class:disabled
class:focused class:focused
class:clickable class:clickable
class:compact
class:auto-height={autoHeight} class:auto-height={autoHeight}
> >
<div class="content" on:click> <div class="content" on:click>
@ -61,7 +63,6 @@
<style> <style>
.fancy-field { .fancy-field {
max-width: 400px;
background: var(--spectrum-global-color-gray-75); background: var(--spectrum-global-color-gray-75);
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px; border-radius: 4px;
@ -69,6 +70,12 @@
transition: border-color 130ms ease-out, background 130ms ease-out, transition: border-color 130ms ease-out, background 130ms ease-out,
background 130ms ease-out; background 130ms ease-out;
color: var(--spectrum-global-color-gray-800); color: var(--spectrum-global-color-gray-800);
--padding: 16px;
--height: 64px;
}
.fancy-field.compact {
--padding: 8px;
--height: 36px;
} }
.fancy-field:hover { .fancy-field:hover {
border-color: var(--spectrum-global-color-gray-400); border-color: var(--spectrum-global-color-gray-400);
@ -91,8 +98,8 @@
} }
.content { .content {
position: relative; position: relative;
height: 64px; height: var(--height);
padding: 0 16px; padding: 0 var(--padding);
} }
.fancy-field.auto-height .content { .fancy-field.auto-height .content {
height: auto; height: auto;
@ -103,7 +110,7 @@
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 16px; gap: var(--padding);
} }
.field { .field {
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -4,4 +4,5 @@ export { default as FancySelect } from "./FancySelect.svelte"
export { default as FancyButton } from "./FancyButton.svelte" export { default as FancyButton } from "./FancyButton.svelte"
export { default as FancyForm } from "./FancyForm.svelte" export { default as FancyForm } from "./FancyForm.svelte"
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte" export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
export { default as FancyCheckboxGroup } from "./FancyCheckboxGroup.svelte"
export { default as ErrorMessage } from "./ErrorMessage.svelte" export { default as ErrorMessage } from "./ErrorMessage.svelte"

View File

@ -9,6 +9,7 @@
export let text = null export let text = null
export let disabled = false export let disabled = false
export let size export let size
export let indeterminate = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = event => { const onChange = event => {
@ -22,6 +23,7 @@
class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}" class="spectrum-Checkbox spectrum-Checkbox--emphasized {sizeClass}"
class:is-invalid={!!error} class:is-invalid={!!error}
class:checked={value} class:checked={value}
class:is-indeterminate={indeterminate}
> >
<input <input
checked={value} checked={value}

View File

@ -8,6 +8,7 @@
export let fixed = false export let fixed = false
export let inline = false export let inline = false
export let disableCancel = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let visible = fixed || inline let visible = fixed || inline
@ -38,7 +39,7 @@
} }
export function cancel() { export function cancel() {
if (!visible) { if (!visible || disableCancel) {
return return
} }
dispatch("cancel") dispatch("cancel")

View File

@ -9,7 +9,8 @@
"dev:builder": "routify -c dev:vite", "dev:builder": "routify -c dev:vite",
"dev:vite": "vite --host 0.0.0.0", "dev:vite": "vite --host 0.0.0.0",
"rollup": "rollup -c -w", "rollup": "rollup -c -w",
"test": "vitest run" "test": "vitest run",
"test:watch": "vitest"
}, },
"jest": { "jest": {
"globals": { "globals": {

View File

@ -1,46 +0,0 @@
import { datasources, tables } from "../stores/backend"
import { IntegrationNames } from "../constants/backend"
import { get } from "svelte/store"
import cloneDeep from "lodash/cloneDeepWith"
import { API } from "api"
function prepareData(config) {
let datasource = {}
let existingTypeCount = get(datasources).list.filter(
ds => ds.source === config.type
).length
let baseName = IntegrationNames[config.type] || config.name
let name =
existingTypeCount === 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
datasource.type = "datasource"
datasource.source = config.type
datasource.config = config.config
datasource.name = name
datasource.plus = config.plus
return datasource
}
export async function saveDatasource(config, skipFetch = false) {
const datasource = prepareData(config)
// Create datasource
const resp = await datasources.save(datasource, !skipFetch && datasource.plus)
// update the tables incase datasource plus
await tables.fetch()
await datasources.select(resp._id)
return resp
}
export async function createRestDatasource(integration) {
const config = cloneDeep(integration)
return saveDatasource(config)
}
export async function validateDatasourceConfig(config) {
const datasource = prepareData(config)
const resp = await API.validateDatasource(datasource)
return resp
}

View File

@ -74,6 +74,7 @@ const INITIAL_FRONTEND_STATE = {
propertyFocus: null, propertyFocus: null,
builderSidePanel: false, builderSidePanel: false,
hasLock: true, hasLock: true,
showPreview: false,
// URL params // URL params
selectedScreenId: null, selectedScreenId: null,
@ -116,10 +117,13 @@ export const getFrontendStore = () => {
reset: () => { reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE }) store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect() websocket?.disconnect()
websocket = null
}, },
initialise: async pkg => { initialise: async pkg => {
const { layouts, screens, application, clientLibPath, hasLock } = pkg const { layouts, screens, application, clientLibPath, hasLock } = pkg
websocket = createBuilderWebsocket(application.appId) if (!websocket) {
websocket = createBuilderWebsocket(application.appId)
}
await store.actions.components.refreshDefinitions(application.appId) await store.actions.components.refreshDefinitions(application.appId)
// Reset store state // Reset store state

View File

@ -13,6 +13,8 @@
Modal, Modal,
notifications, notifications,
Icon, Icon,
Checkbox,
DatePicker,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
@ -306,6 +308,11 @@
drawer.hide() drawer.hide()
} }
function canShowField(key, value) {
const dependsOn = value?.dependsOn
return !dependsOn || !!inputData[dependsOn]
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -317,210 +324,233 @@
<div class="fields"> <div class="fields">
{#each deprecatedSchemaProperties as [key, value]} {#each deprecatedSchemaProperties as [key, value]}
<div class="block-field"> {#if canShowField(key, value)}
{#if key !== "fields"} <div class="block-field">
<Label {#if key !== "fields" && value.type !== "boolean"}
tooltip={value.title === "Binding / Value" <Label
? "If using the String input type, please use a comma or newline separated string" tooltip={value.title === "Binding / Value"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label ? "If using the String input type, please use a comma or newline separated string"
> : null}>{value.title || (key === "row" ? "Table" : key)}</Label
{/if} >
{#if value.type === "string" && value.enum}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/>
{:else if value.type === "json"}
<Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
delete inputData.value1
delete inputData.value2
delete inputData.value3
delete inputData.value4
delete inputData.value5
/***********************/
onChange(e, key)
}}
/>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<FilterDrawer
slot="body"
{filters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)}
/>
</Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
fillWidth
updateOnChange={false}
/>
{:else}
<DrawerBindableInput
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type="email"
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={false}
updateOnChange={false}
drawerLeft="260px"
/>
{/if} {/if}
{:else if value.customType === "query"} {#if value.type === "string" && value.enum && canShowField(key, value)}
<QuerySelector <Select
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"}
<CronBuilder on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "queryParams"}
<QueryParamSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "table"}
<TableSelector
{isTrigger}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
{block}
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"}
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "code"}
<CodeEditorModal>
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/>
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} value={inputData[key]}
updateOnChange={false} placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/> />
{:else} {:else if value.type === "json"}
<div class="test"> <Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
delete inputData.value1
delete inputData.value2
delete inputData.value3
delete inputData.value4
delete inputData.value5
/***********************/
onChange(e, key)
}}
/>
{:else if value.type === "boolean"}
<div style="margin-top: 10px">
<Checkbox
text={value.title}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
</div>
{:else if value.type === "date"}
<DatePicker
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} {fillWidth} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<FilterDrawer
slot="body"
{filters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}
panel={AutomationBindingPanel}
fillWidth
on:change={e => (tempFilters = e.detail)}
/>
</Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
fillWidth
updateOnChange={false}
/>
{:else}
<DrawerBindableInput <DrawerBindableInput
fillWidth={true} fillWidth
title={value.title} title={value.title}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type="email"
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
allowJS={false}
updateOnChange={false} updateOnChange={false}
placeholder={value.customType === "queryLimit" ? queryLimit : ""}
drawerLeft="260px" drawerLeft="260px"
/> />
</div> {/if}
{:else if value.customType === "query"}
<QuerySelector
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"}
<CronBuilder
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "queryParams"}
<QueryParamSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "table"}
<TableSelector
{isTrigger}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
{block}
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"}
<FieldSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
{isTestModal}
/>
{:else if value.customType === "triggerSchema"}
<SchemaSetup
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "code"}
<CodeEditorModal>
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/>
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal}
<ModalBindableInput
title={value.title}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
/>
{:else}
<div class="test">
<DrawerBindableInput
fillWidth={true}
title={value.title}
panel={AutomationBindingPanel}
type={value.customType}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
placeholder={value.customType === "queryLimit"
? queryLimit
: ""}
drawerLeft="260px"
/>
</div>
{/if}
{/if} {/if}
{/if} </div>
</div> {/if}
{/each} {/each}
</div> </div>
<Modal bind:this={webhookModal} width="30%"> <Modal bind:this={webhookModal} width="30%">

View File

@ -44,13 +44,15 @@
<Grid <Grid
{API} {API}
tableId={id} tableId={id}
tableType={$tables.selected?.type}
allowAddRows={!isUsersTable} allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable} allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null} schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false} showAvatars={false}
on:updatetable={handleGridTableUpdate} on:updatetable={handleGridTableUpdate}
> >
<svelte:fragment slot="filter">
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
{#if isInternal} {#if isInternal}
<GridCreateViewButton /> <GridCreateViewButton />
@ -65,7 +67,6 @@
<GridImportButton /> <GridImportButton />
{/if} {/if}
<GridExportButton /> <GridExportButton />
<GridFilterButton />
<GridAddColumnModal /> <GridAddColumnModal />
<GridEditColumnModal /> <GridEditColumnModal />
{#if isUsersTable} {#if isUsersTable}

View File

@ -1,8 +1,10 @@
<script> <script>
import { get } from "svelte/store"
import { ActionButton, Modal, notifications } from "@budibase/bbui" import { ActionButton, Modal, notifications } from "@budibase/bbui"
import CreateEditRelationship from "../../Datasources/CreateEditRelationship.svelte" import CreateEditRelationship from "../../Datasources/CreateEditRelationship.svelte"
import { datasources } from "../../../../stores/backend" import { datasources, integrations } from "../../../../stores/backend"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { integrationForDatasource } from "stores/selectors"
export let table export let table
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -27,7 +29,10 @@
async function saveRelationship() { async function saveRelationship() {
try { try {
// Create datasource // Create datasource
await datasources.save(datasource) await datasources.update({
datasource,
integration: integrationForDatasource(get(integrations), datasource),
})
notifications.success(`Relationship information saved.`) notifications.success(`Relationship information saved.`)
dispatch("updatecolumns") dispatch("updatecolumns")
} catch (err) { } catch (err) {

View File

@ -14,6 +14,12 @@
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: text = getText(filters)
const getText = filters => {
const count = filters?.length
return count ? `Filter (${count})` : "Filter"
}
</script> </script>
<ActionButton <ActionButton
@ -23,7 +29,7 @@
on:click={modal.show} on:click={modal.show}
selected={tempValue?.length > 0} selected={tempValue?.length > 0}
> >
Filter {text}
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent <ModalContent

View File

@ -4,6 +4,9 @@
const { columns, tableId, filter, table } = getContext("grid") const { columns, tableId, filter, table } = getContext("grid")
// Wipe filter whenever table ID changes to avoid using stale filters
$: $tableId, filter.set([])
const onFilter = e => { const onFilter = e => {
filter.set(e.detail || []) filter.set(e.detail || [])
} }

View File

@ -4,12 +4,12 @@
export let disabled = false export let disabled = false
const { rows, tableId, tableType } = getContext("grid") const { rows, tableId, table } = getContext("grid")
</script> </script>
<ImportButton <ImportButton
{disabled} {disabled}
tableId={$tableId} tableId={$tableId}
{tableType} tableType={$table?.type}
on:importrows={rows.actions.refreshData} on:importrows={rows.actions.refreshData}
/> />

View File

@ -14,6 +14,7 @@
export let tableId export let tableId
export let tableType export let tableType
let rows = [] let rows = []
let allValid = false let allValid = false
let displayColumn = null let displayColumn = null

View File

@ -1,219 +0,0 @@
<script>
import {
Label,
Input,
Layout,
Toggle,
Button,
TextArea,
Modal,
EnvDropdown,
Accordion,
notifications,
} from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { capitalise } from "helpers"
import { IntegrationTypes } from "constants/backend"
import { createValidationStore } from "helpers/validation/yup"
import { createEventDispatcher, onMount } from "svelte"
import { environment, licensing, auth } from "stores/portal"
import CreateEditVariableModal from "components/portal/environment/CreateEditVariableModal.svelte"
export let datasource
export let schema
export let creating
let createVariableModal
let selectedKey
const validation = createValidationStore()
const dispatch = createEventDispatcher()
function filter([key, value]) {
if (!value) {
return false
}
return !(
(datasource.source === IntegrationTypes.REST &&
key === "defaultHeaders") ||
value.deprecated
)
}
$: config = datasource?.config
$: configKeys = Object.entries(schema || {})
.filter(el => filter(el))
.map(([key]) => key)
// setup the validation for each required field
$: configKeys.forEach(key => {
if (schema[key].required) {
validation.addValidatorType(key, schema[key].type, schema[key].required)
}
})
// run the validation whenever the config changes
$: validation.check(config)
// dispatch the validation result
$: dispatch(
"valid",
Object.values($validation.errors).filter(val => val != null).length === 0
)
let addButton
function getDisplayName(key, fieldKey) {
let name
if (fieldKey && schema[key]["fields"][fieldKey]?.display) {
name = schema[key]["fields"][fieldKey].display
} else if (fieldKey) {
name = fieldKey
} else if (schema[key]?.display) {
name = schema[key].display
} else {
name = key
}
return capitalise(name)
}
function getDisplayError(error, configKey) {
return error?.replace(
new RegExp(`${configKey}`, "i"),
getDisplayName(configKey)
)
}
function getFieldGroupKeys(fieldGroup) {
return Object.entries(schema[fieldGroup].fields || {})
.filter(el => filter(el))
.map(([key]) => key)
}
async function save(data) {
try {
await environment.createVariable(data)
config[selectedKey] = `{{ env.${data.name} }}`
createVariableModal.hide()
} catch (err) {
notifications.error(`Failed to create variable: ${err.message}`)
}
}
function showModal(configKey) {
selectedKey = configKey
createVariableModal.show()
}
async function handleUpgradePanel() {
await environment.upgradePanelOpened()
$licensing.goToUpgradePage()
}
onMount(async () => {
try {
await environment.loadVariables()
if ($auth.user) {
await licensing.init()
}
} catch (err) {
console.error(err)
}
})
</script>
<form>
<Layout noPadding gap="S">
{#if !creating}
<div class="form-row">
<Label>Name</Label>
<Input on:change bind:value={datasource.name} />
</div>
{/if}
{#each configKeys as configKey}
{#if schema[configKey].type === "object"}
<div class="form-row ssl">
<Label>{getDisplayName(configKey)}</Label>
<Button secondary thin outline on:click={addButton.addEntry()}
>Add</Button
>
</div>
<KeyValueBuilder
bind:this={addButton}
defaults={schema[configKey].default}
bind:object={config[configKey]}
on:change
noAddButton={true}
/>
{:else if schema[configKey].type === "boolean"}
<div class="form-row">
<Label>{getDisplayName(configKey)}</Label>
<Toggle text="" bind:value={config[configKey]} />
</div>
{:else if schema[configKey].type === "longForm"}
<div class="form-row">
<Label>{getDisplayName(configKey)}</Label>
<TextArea
type={schema[configKey].type}
on:change
bind:value={config[configKey]}
error={getDisplayError($validation.errors[configKey], configKey)}
/>
</div>
{:else if schema[configKey].type === "fieldGroup"}
<Accordion
itemName={configKey}
initialOpen={getFieldGroupKeys(configKey).some(
fieldKey => !!config[fieldKey]
)}
header={getDisplayName(configKey)}
>
<Layout gap="S">
{#each getFieldGroupKeys(configKey) as fieldKey}
<div class="form-row">
<Label>{getDisplayName(configKey, fieldKey)}</Label>
<Input
type={schema[configKey]["fields"][fieldKey]?.type}
on:change
bind:value={config[fieldKey]}
/>
</div>
{/each}
</Layout>
</Accordion>
{:else}
<div class="form-row">
<Label>{getDisplayName(configKey)}</Label>
<EnvDropdown
showModal={() => showModal(configKey)}
variables={$environment.variables}
type={configKey === "port" ? "string" : schema[configKey].type}
on:change
bind:value={config[configKey]}
error={getDisplayError($validation.errors[configKey], configKey)}
environmentVariablesEnabled={$licensing.environmentVariablesEnabled}
{handleUpgradePanel}
/>
</div>
{/if}
{/each}
</Layout>
</form>
<Modal bind:this={createVariableModal}>
<CreateEditVariableModal {save} />
</Modal>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.form-row.ssl {
display: grid;
grid-template-columns: 20% 20%;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -8,7 +8,7 @@
notifications, notifications,
Modal, Modal,
Table, Table,
Toggle, FancyCheckboxGroup,
} from "@budibase/bbui" } from "@budibase/bbui"
import { datasources, integrations, tables } from "stores/backend" import { datasources, integrations, tables } from "stores/backend"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte" import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
@ -16,7 +16,6 @@
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte" import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ValuesList from "components/common/ValuesList.svelte"
export let datasource export let datasource
export let save export let save
@ -34,7 +33,7 @@
let selectedFromRelationship, selectedToRelationship let selectedFromRelationship, selectedToRelationship
let confirmDialog let confirmDialog
let specificTables = null let specificTables = null
let requireSpecificTables = false let tableList
$: integration = datasource && $integrations[datasource.source] $: integration = datasource && $integrations[datasource.source]
$: plusTables = datasource?.plus $: plusTables = datasource?.plus
@ -153,30 +152,27 @@
warning={false} warning={false}
title="Confirm table fetch" title="Confirm table fetch"
> >
<Toggle
bind:value={requireSpecificTables}
on:change={e => {
requireSpecificTables = e.detail
specificTables = null
}}
thin
text="Fetch listed tables only (one per line)"
/>
{#if requireSpecificTables}
<ValuesList label="" bind:values={specificTables} />
{/if}
<br />
<Body> <Body>
If you have fetched tables from this database before, this action may If you have fetched tables from this database before, this action may
overwrite any changes you made after your initial fetch. overwrite any changes you made after your initial fetch.
</Body> </Body>
<br />
<div class="table-checkboxes">
<FancyCheckboxGroup options={tableList} bind:selected={specificTables} />
</div>
</ConfirmDialog> </ConfirmDialog>
<Divider /> <Divider />
<div class="query-header"> <div class="query-header">
<Heading size="S">Tables</Heading> <Heading size="S">Tables</Heading>
<div class="table-buttons"> <div class="table-buttons">
<Button secondary on:click={() => confirmDialog.show()}> <Button
secondary
on:click={async () => {
tableList = await datasources.getTableNames(datasource)
confirmDialog.show()
}}
>
Fetch tables Fetch tables
</Button> </Button>
<Button cta icon="Add" on:click={createNewTable}>New table</Button> <Button cta icon="Add" on:click={createNewTable}>New table</Button>
@ -246,4 +242,8 @@
display: flex; display: flex;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.table-checkboxes {
width: 100%;
}
</style> </style>

View File

@ -45,6 +45,9 @@
<Heading size="S">Headers</Heading> <Heading size="S">Headers</Heading>
<Badge quiet grey>Optional</Badge> <Badge quiet grey>Optional</Badge>
</div> </div>
<div class="headerRight">
<slot name="headerRight" />
</div>
</div> </div>
<Body size="S"> <Body size="S">
Headers enable you to provide additional information about the request, such Headers enable you to provide additional information about the request, such
@ -69,6 +72,9 @@
<Heading size="S">Authentication</Heading> <Heading size="S">Authentication</Heading>
<Badge quiet grey>Optional</Badge> <Badge quiet grey>Optional</Badge>
</div> </div>
<div class="headerRight">
<slot name="headerRight" />
</div>
</div> </div>
<Body size="S"> <Body size="S">
Create an authentication config that can be shared with queries. Create an authentication config that can be shared with queries.
@ -81,6 +87,9 @@
<Heading size="S">Variables</Heading> <Heading size="S">Variables</Heading>
<Badge quiet grey>Optional</Badge> <Badge quiet grey>Optional</Badge>
</div> </div>
<div class="headerRight">
<slot name="headerRight" />
</div>
</div> </div>
<Body size="S" <Body size="S"
>Variables enable you to store and re-use values in queries, with the choice >Variables enable you to store and re-use values in queries, with the choice
@ -110,6 +119,7 @@
<style> <style>
.section-header { .section-header {
width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@ -120,4 +130,8 @@
display: flex; display: flex;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.headerRight {
margin-left: auto;
}
</style> </style>

View File

@ -1,60 +0,0 @@
<script>
import { createEventDispatcher } from "svelte"
import { Heading, Detail } from "@budibase/bbui"
import IntegrationIcon from "../IntegrationIcon.svelte"
export let integration
export let integrationType
export let schema
let dispatcher = createEventDispatcher()
</script>
<div
class:selected={integration.type === integrationType}
on:click={() => dispatcher("selected", integrationType)}
class="item hoverable"
>
<div class="item-body" class:with-type={!!schema.type}>
<IntegrationIcon {integrationType} {schema} size="25" />
<div class="text">
<Heading size="XXS">{schema.friendlyName}</Heading>
{#if schema.type}
<Detail size="S">{schema.type || ""}</Detail>
{/if}
</div>
</div>
</div>
<style>
.item {
cursor: pointer;
display: grid;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s)
var(--spectrum-alias-item-padding-m);
background: var(--spectrum-alias-background-color-secondary);
transition: background 0.13s ease-out;
border-radius: 5px;
box-sizing: border-box;
border-width: 2px;
}
.item:hover,
.item.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.item-body {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
.item-body.with-type {
align-items: flex-start;
}
.item-body.with-type :global(svg) {
margin-top: 4px;
}
</style>

View File

@ -1,145 +0,0 @@
<script>
export let width = 100
export let height = 100
</script>
<svg
{width}
{height}
viewBox="0 0 46 46"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"
>
<!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
<title>btn_google_dark_normal_ios</title>
<desc>Created with Sketch.</desc>
<defs>
<filter
x="-50%"
y="-50%"
width="200%"
height="200%"
filterUnits="objectBoundingBox"
id="filter-1"
>
<feOffset dx="0" dy="1" in="SourceAlpha" result="shadowOffsetOuter1" />
<feGaussianBlur
stdDeviation="0.5"
in="shadowOffsetOuter1"
result="shadowBlurOuter1"
/>
<feColorMatrix
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.168 0"
in="shadowBlurOuter1"
type="matrix"
result="shadowMatrixOuter1"
/>
<feOffset dx="0" dy="0" in="SourceAlpha" result="shadowOffsetOuter2" />
<feGaussianBlur
stdDeviation="0.5"
in="shadowOffsetOuter2"
result="shadowBlurOuter2"
/>
<feColorMatrix
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.084 0"
in="shadowBlurOuter2"
type="matrix"
result="shadowMatrixOuter2"
/>
<feMerge>
<feMergeNode in="shadowMatrixOuter1" />
<feMergeNode in="shadowMatrixOuter2" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<rect id="path-2" x="0" y="0" width="40" height="40" rx="2" />
<rect id="path-3" x="5" y="5" width="38" height="38" rx="1" />
</defs>
<g
id="Google-Button"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
sketch:type="MSPage"
>
<g
id="9-PATCH"
sketch:type="MSArtboardGroup"
transform="translate(-608.000000, -219.000000)"
/>
<g
id="btn_google_dark_normal"
sketch:type="MSArtboardGroup"
transform="translate(-1.000000, -1.000000)"
>
<g
id="button"
sketch:type="MSLayerGroup"
transform="translate(4.000000, 4.000000)"
filter="url(#filter-1)"
>
<g id="button-bg">
<use
fill="#4285F4"
fill-rule="evenodd"
sketch:type="MSShapeGroup"
xlink:href="#path-2"
/>
<use fill="none" xlink:href="#path-2" />
<use fill="none" xlink:href="#path-2" />
<use fill="none" xlink:href="#path-2" />
</g>
</g>
<g id="button-bg-copy">
<use
fill="#FFFFFF"
fill-rule="evenodd"
sketch:type="MSShapeGroup"
xlink:href="#path-3"
/>
<use fill="none" xlink:href="#path-3" />
<use fill="none" xlink:href="#path-3" />
<use fill="none" xlink:href="#path-3" />
</g>
<g
id="logo_googleg_48dp"
sketch:type="MSLayerGroup"
transform="translate(15.000000, 15.000000)"
>
<path
d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z"
id="Shape"
fill="#4285F4"
sketch:type="MSShapeGroup"
/>
<path
d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z"
id="Shape"
fill="#34A853"
sketch:type="MSShapeGroup"
/>
<path
d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z"
id="Shape"
fill="#FBBC05"
sketch:type="MSShapeGroup"
/>
<path
d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z"
id="Shape"
fill="#EA4335"
sketch:type="MSShapeGroup"
/>
<path
d="M0,0 L18,0 L18,18 L0,18 L0,0 Z"
id="Shape"
sketch:type="MSShapeGroup"
/>
</g>
<g id="handles_square" sketch:type="MSLayerGroup" />
</g>
</g>
</svg>

View File

@ -44,6 +44,9 @@ export default ICONS
export function getIcon(integrationType, schema) { export function getIcon(integrationType, schema) {
const integrationList = get(integrations) const integrationList = get(integrations)
if (!integrationList) {
return
}
if (integrationList[integrationType]?.iconUrl) { if (integrationList[integrationType]?.iconUrl) {
return { url: integrationList[integrationType].iconUrl } return { url: integrationList[integrationType].iconUrl }
} else if (schema?.custom || !ICONS[integrationType]) { } else if (schema?.custom || !ICONS[integrationType]) {

View File

@ -1,81 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith"
import {
saveDatasource as save,
validateDatasourceConfig,
} from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
export let integration
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
let isValid = false
$: name =
IntegrationNames[datasource.type] || datasource.name || datasource.type
async function validateConfig() {
const displayError = message =>
notifications.error(message ?? "Error validating datasource")
let connected = false
try {
const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) {
displayError(`Unable to connect - ${resp.error}`)
}
connected = resp.connected
} catch (err) {
displayError(err?.message)
}
return connected
}
async function saveDatasource() {
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try {
if (!datasource.name) {
datasource.name = name
}
const resp = await save(datasource)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource created successfully.`)
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}
</script>
<ModalContent
title={`Connect to ${name}`}
onConfirm={() => saveDatasource()}
confirmText={datasource.plus ? "Connect" : "Save and continue to query"}
cancelText="Back"
showSecondaryButton={datasource.plus}
size="L"
disabled={!isValid}
>
<Layout noPadding>
<Body size="XS"
>Connect your database to Budibase using the config below.
</Body>
</Layout>
<IntegrationConfigForm
schema={datasource.schema}
bind:datasource
creating={true}
on:valid={e => (isValid = e.detail)}
/>
</ModalContent>

View File

@ -1,43 +0,0 @@
<script>
import { ModalContent, Body, Layout, Link } from "@budibase/bbui"
import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith"
import GoogleButton from "../_components/GoogleButton.svelte"
import { saveDatasource as save } from "builderStore/datasource"
import { organisation } from "stores/portal"
import { onMount } from "svelte"
export let integration
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
$: isGoogleConfigured = !!$organisation.googleDatasourceConfigured
onMount(async () => {
await organisation.init()
})
</script>
<ModalContent
title={`Connect to ${IntegrationNames[datasource.type]}`}
cancelText="Back"
size="L"
>
<!-- check true and false directly, don't render until flag is set -->
{#if isGoogleConfigured === true}
<Layout noPadding>
<Body size="S"
>Authenticate with your google account to use the {IntegrationNames[
datasource.type
]} integration.</Body
>
</Layout>
<GoogleButton preAuthStep={() => save(datasource, true)} />
{:else if isGoogleConfigured === false}
<Body size="S"
>Google authentication is not enabled, please complete Google SSO
configuration.</Body
>
<Link href="/builder/portal/settings/auth">Configure Google SSO</Link>
{/if}
</ModalContent>

View File

@ -1,7 +1,9 @@
<script> <script>
import { datasources } from "stores/backend" import { get } from "svelte/store"
import { datasources, integrations } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { Input, ModalContent, Modal } from "@budibase/bbui" import { Input, ModalContent, Modal } from "@budibase/bbui"
import { integrationForDatasource } from "stores/selectors"
let error = "" let error = ""
let modal let modal
@ -32,7 +34,10 @@
...datasource, ...datasource,
name, name,
} }
await datasources.save(updatedDatasource) await datasources.update({
datasource: updatedDatasource,
integration: integrationForDatasource(get(integrations), datasource),
})
notifications.success(`Datasource ${name} updated successfully.`) notifications.success(`Datasource ${name} updated successfully.`)
hide() hide()
} }

View File

@ -0,0 +1,40 @@
<script>
import ObjectField from "./fields/Object.svelte"
import BooleanField from "./fields/Boolean.svelte"
import LongFormField from "./fields/LongForm.svelte"
import FieldGroupField from "./fields/FieldGroup.svelte"
import StringField from "./fields/String.svelte"
export let type
export let value
export let error
export let name
export let showModal = () => {}
const selectComponent = type => {
if (type === "object") {
return ObjectField
} else if (type === "boolean") {
return BooleanField
} else if (type === "longForm") {
return LongFormField
} else if (type === "fieldGroup") {
return FieldGroupField
} else {
return StringField
}
}
$: component = selectComponent(type)
</script>
<svelte:component
this={component}
{type}
{value}
{error}
{name}
{showModal}
on:blur
on:change
/>

View File

@ -0,0 +1,20 @@
<script>
import { Label, Toggle } from "@budibase/bbui"
export let value
export let name
</script>
<div class="form-row">
<Label>{name}</Label>
<Toggle on:blur on:change text="" {value} />
</div>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -0,0 +1,47 @@
<script>
import { createEventDispatcher } from "svelte"
import { Label, Input, Layout, Accordion } from "@budibase/bbui"
export let value
export let name
let dispatch = createEventDispatcher()
const handleChange = (updatedFieldKey, updatedFieldValue) => {
const updatedValue = value.map(field => {
return {
key: field.key,
value: field.key === updatedFieldKey ? updatedFieldValue : field.value,
}
})
dispatch("change", updatedValue)
}
</script>
<Accordion
initialOpen={Object.values(value).some(properties => !!properties.value)}
header={name}
>
<Layout gap="S">
{#each value as field}
<div class="form-row">
<Label>{field.name}</Label>
<Input
type={field.type}
on:change={e => handleChange(field.key, e.detail)}
value={field.value}
/>
</div>
{/each}
</Layout>
</Accordion>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -0,0 +1,22 @@
<script>
import { Label, TextArea } from "@budibase/bbui"
export let type
export let name
export let value
export let error
</script>
<div class="form-row">
<Label>{name}</Label>
<TextArea on:blur on:change {type} {value} {error} />
</div>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -0,0 +1,37 @@
<script>
import { Label, Button } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let name
export let value
let addButton
</script>
<div class="form-row ssl">
<Label>{name}</Label>
<Button secondary thin outline on:click={addButton.addEntry()}>Add</Button>
</div>
<KeyValueBuilder
on:change
on:blur
bind:this={addButton}
defaults={value}
noAddButton={true}
/>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.form-row.ssl {
display: grid;
grid-template-columns: 20% 20%;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -0,0 +1,39 @@
<script>
import { Label, EnvDropdown } from "@budibase/bbui"
import { environment, licensing } from "stores/portal"
export let type
export let name
export let value
export let error
export let showModal = () => {}
async function handleUpgradePanel() {
await environment.upgradePanelOpened()
$licensing.goToUpgradePage()
}
</script>
<div class="form-row">
<Label>{name}</Label>
<EnvDropdown
on:change
on:blur
type={type === "port" ? "string" : type}
{value}
{error}
variables={$environment.variables}
environmentVariablesEnabled={$licensing.environmentVariablesEnabled}
{showModal}
{handleUpgradePanel}
/>
</div>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -0,0 +1,100 @@
<script>
import {
Modal,
notifications,
Body,
Layout,
ModalContent,
} from "@budibase/bbui"
import CreateEditVariableModal from "components/portal/environment/CreateEditVariableModal.svelte"
import ConfigInput from "./ConfigInput.svelte"
import { createValidatedConfigStore } from "./stores/validatedConfig"
import { createValidatedNameStore } from "./stores/validatedName"
import { get } from "svelte/store"
import { environment } from "stores/portal"
export let integration
export let config
export let onSubmit = () => {}
export let showNameField = false
export let nameFieldValue = ""
$: configStore = createValidatedConfigStore(integration, config)
$: nameStore = createValidatedNameStore(nameFieldValue, showNameField)
const handleConfirm = async () => {
configStore.markAllFieldsActive()
nameStore.markActive()
if ((await configStore.validate()) && (await nameStore.validate())) {
return await onSubmit({
config: get(configStore).config,
name: get(nameStore).name,
})
}
return false
}
let createVariableModal
let configValueSetterCallback = () => {}
const showModal = setter => {
configValueSetterCallback = setter
createVariableModal.show()
}
async function saveVariable(data) {
try {
await environment.createVariable(data)
configValueSetterCallback(`{{ env.${data.name} }}`)
createVariableModal.hide()
} catch (err) {
notifications.error(`Failed to create variable: ${err.message}`)
}
}
</script>
<ModalContent
title={`Connect to ${integration.friendlyName}`}
onConfirm={handleConfirm}
confirmText={integration.plus ? "Connect" : "Save and continue to query"}
cancelText="Back"
disabled={$configStore.preventSubmit || $nameStore.preventSubmit}
size="L"
>
<Layout noPadding>
<Body size="XS">
Connect your database to Budibase using the config below.
</Body>
</Layout>
{#if showNameField}
<ConfigInput
type="string"
value={$nameStore.name}
error={$nameStore.error}
name="Name"
showModal={() => showModal(nameStore.updateValue)}
on:blur={nameStore.markActive}
on:change={e => nameStore.updateValue(e.detail)}
/>
{/if}
{#each $configStore.validatedConfig as { type, key, value, error, name }}
<ConfigInput
{type}
{value}
{error}
{name}
showModal={() =>
showModal(newValue => configStore.updateFieldValue(key, newValue))}
on:blur={() => configStore.markFieldActive(key)}
on:change={e => configStore.updateFieldValue(key, e.detail)}
/>
{/each}
</ModalContent>
<Modal bind:this={createVariableModal}>
<CreateEditVariableModal save={saveVariable} />
</Modal>

View File

@ -0,0 +1,129 @@
import { derived, writable, get } from "svelte/store"
import { getValidatorFields } from "./validation"
import { capitalise } from "helpers"
import { notifications } from "@budibase/bbui"
import { object } from "yup"
export const createValidatedConfigStore = (integration, config) => {
const configStore = writable(config)
const allValidators = getValidatorFields(integration)
const selectedValidatorsStore = writable({})
const errorsStore = writable({})
const validate = async () => {
try {
await object()
.shape(get(selectedValidatorsStore))
.validate(get(configStore), { abortEarly: false })
errorsStore.set({})
return true
} catch (error) {
// Yup error
if (error.inner) {
const errors = {}
error.inner.forEach(innerError => {
errors[innerError.path] = capitalise(innerError.message)
})
errorsStore.set(errors)
} else {
// Non-yup error
notifications.error("Unexpected validation error")
}
return false
}
}
const updateFieldValue = (key, value) => {
configStore.update($configStore => {
const newStore = { ...$configStore }
if (integration.datasource[key].type === "fieldGroup") {
value.forEach(field => {
newStore[field.key] = field.value
})
} else {
newStore[key] = value
}
return newStore
})
validate()
}
const markAllFieldsActive = () => {
selectedValidatorsStore.set(allValidators)
validate()
}
const markFieldActive = key => {
selectedValidatorsStore.update($validatorsStore => ({
...$validatorsStore,
[key]: allValidators[key],
}))
validate()
}
const combined = derived(
[configStore, errorsStore, selectedValidatorsStore],
([$configStore, $errorsStore, $selectedValidatorsStore]) => {
const validatedConfig = []
Object.entries(integration.datasource).forEach(([key, properties]) => {
if (integration.name === "REST" && key !== "rejectUnauthorized") {
return
}
const getValue = () => {
if (properties.type === "fieldGroup") {
return Object.entries(properties.fields).map(
([fieldKey, fieldProperties]) => {
return {
key: fieldKey,
name: capitalise(fieldProperties.display || fieldKey),
type: fieldProperties.type,
value: $configStore[fieldKey],
}
}
)
}
return $configStore[key]
}
validatedConfig.push({
key,
value: getValue(),
error: $errorsStore[key],
name: capitalise(properties.display || key),
type: properties.type,
})
})
const allFieldsActive =
Object.keys($selectedValidatorsStore).length ===
Object.keys(allValidators).length
const hasErrors = Object.keys($errorsStore).length > 0
return {
validatedConfig,
config: $configStore,
errors: $errorsStore,
preventSubmit: allFieldsActive && hasErrors,
}
}
)
return {
subscribe: combined.subscribe,
updateFieldValue,
markAllFieldsActive,
markFieldActive,
validate,
}
}

View File

@ -0,0 +1,53 @@
import { derived, get, writable } from "svelte/store"
import { capitalise } from "helpers"
import { string } from "yup"
export const createValidatedNameStore = (name, isVisible) => {
const nameStore = writable(name)
const isActiveStore = writable(false)
const errorStore = writable(null)
const validate = async () => {
if (!isVisible || !get(isActiveStore)) {
return true
}
try {
await string().required().validate(get(nameStore), { abortEarly: false })
errorStore.set(null)
return true
} catch (error) {
errorStore.set(capitalise(error.message))
return false
}
}
const updateValue = value => {
nameStore.set(value)
validate()
}
const markActive = () => {
isActiveStore.set(true)
validate()
}
const combined = derived(
[nameStore, errorStore, isActiveStore],
([$nameStore, $errorStore, $isActiveStore]) => ({
name: $nameStore,
error: $errorStore,
preventSubmit: $errorStore !== null && $isActiveStore,
})
)
return {
subscribe: combined.subscribe,
updateValue,
markActive,
validate,
}
}

View File

@ -0,0 +1,27 @@
import { string, number } from "yup"
const propertyValidator = type => {
if (type === "number") {
return number().nullable()
}
if (type === "email") {
return string().email().nullable()
}
return string().nullable()
}
export const getValidatorFields = integration => {
const validatorFields = {}
Object.entries(integration?.datasource || {}).forEach(([key, properties]) => {
if (properties.required) {
validatorFields[key] = propertyValidator(properties.type).required()
} else {
validatorFields[key] = propertyValidator(properties.type).notRequired()
}
})
return validatorFields
}

View File

@ -55,7 +55,7 @@
name: "Automations", name: "Automations",
description: "", description: "",
icon: "Compass", icon: "Compass",
action: () => $goto("./automate"), action: () => $goto("./automation"),
}, },
{ {
type: "Publish", type: "Publish",
@ -69,7 +69,7 @@
name: "App", name: "App",
description: "", description: "",
icon: "Play", icon: "Play",
action: () => window.open(`/${$store.appId}`), action: () => store.update(state => ({ ...state, showPreview: true })),
}, },
{ {
type: "Preview", type: "Preview",
@ -127,7 +127,7 @@
type: "Automation", type: "Automation",
name: automation.name, name: automation.name,
icon: "ShareAndroid", icon: "ShareAndroid",
action: () => $goto(`./automate/${automation._id}`), action: () => $goto(`./automation/${automation._id}`),
})), })),
...Constants.Themes.map(theme => ({ ...Constants.Themes.map(theme => ({
type: "Change Builder Theme", type: "Change Builder Theme",

View File

@ -21,6 +21,8 @@
faColumns, faColumns,
faArrowsAlt, faArrowsAlt,
faQuestionCircle, faQuestionCircle,
faCircleCheck,
faGear,
} from "@fortawesome/free-solid-svg-icons" } from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons" import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -48,8 +50,11 @@
faEye, faEye,
faColumns, faColumns,
faArrowsAlt, faArrowsAlt,
faQuestionCircle faQuestionCircle,
// -- // --
faCircleCheck,
faGear
) )
dom.watch() dom.watch()
</script> </script>

View File

@ -21,7 +21,6 @@
export let allowHelpers = true export let allowHelpers = true
export let updateOnChange = true export let updateOnChange = true
export let drawerLeft export let drawerLeft
export let key
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer

View File

@ -3,34 +3,45 @@
notifications, notifications,
Popover, Popover,
Layout, Layout,
Heading,
Body, Body,
Button, Button,
ActionButton, ActionButton,
Icon,
Link,
Modal,
StatusLight,
} from "@budibase/bbui" } from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { checkIncomingDeploymentStatus } from "components/deploy/utils" import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import DeployModal from "components/deploy/DeployModal.svelte"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { store } from "builderStore" import { store } from "builderStore"
import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
import { goto } from "@roxi/routify"
export let application export let application
export let loaded
let publishPopover
let publishPopoverAnchor
let unpublishModal let unpublishModal
let updateAppModal
let revertModal
let versionModal
$: filteredApps = $apps.filter( let appActionPopover
app => app.devId === application && app.status === "published" let appActionPopoverOpen = false
) let appActionPopoverAnchor
let publishing = false
$: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: deployments = [] $: deployments = []
@ -38,7 +49,29 @@
.filter(deployment => deployment.status === "SUCCESS") .filter(deployment => deployment.status === "SUCCESS")
.sort((a, b) => a.updatedAt > b.updatedAt) .sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished = selectedApp && latestDeployments?.length > 0 $: isPublished =
selectedApp?.status === "published" && latestDeployments?.length > 0
$: updateAvailable =
$store.upgradableVersion &&
$store.version &&
$store.upgradableVersion !== $store.version
$: canPublish = !publishing && loaded
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($store.devId)
await store.actions.initialise(applicationPkg)
}
const updateDeploymentString = () => {
return deployments?.length
? processStringSync("Published {{ duration time 'millisecond' }} ago", {
time:
new Date().getTime() - new Date(deployments[0].updatedAt).getTime(),
})
: ""
}
const reviewPendingDeployments = (deployments, newDeployments) => { const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments.length > 0) { if (deployments.length > 0) {
@ -62,7 +95,10 @@
} }
const previewApp = () => { const previewApp = () => {
window.open(`/${application}`) store.update(state => ({
...state,
showPreview: true,
}))
} }
const viewApp = () => { const viewApp = () => {
@ -77,11 +113,36 @@
} }
} }
async function publishApp() {
try {
publishing = true
await API.publishAppChanges($store.appId)
notifications.send("App published", {
type: "success",
icon: "GlobeCheck",
})
await completePublish()
} catch (error) {
console.error(error)
analytics.captureException(error)
notifications.error("Error publishing app")
}
publishing = false
}
const unpublishApp = () => { const unpublishApp = () => {
publishPopover.hide() appActionPopover.hide()
unpublishModal.show() unpublishModal.show()
} }
const revertApp = () => {
appActionPopover.hide()
revertModal.show()
}
const confirmUnpublishApp = async () => { const confirmUnpublishApp = async () => {
if (!application || !isPublished) { if (!application || !isPublished) {
//confirm the app has loaded. //confirm the app has loaded.
@ -90,7 +151,10 @@
try { try {
await API.unpublishApp(selectedApp.prodId) await API.unpublishApp(selectedApp.prodId)
await apps.load() await apps.load()
notifications.success("App unpublished successfully") notifications.send("App unpublished", {
type: "success",
icon: "GlobeStrike",
})
} catch (err) { } catch (err) {
notifications.error("Error unpublishing app") notifications.error("Error unpublishing app")
} }
@ -114,97 +178,161 @@
</script> </script>
{#if $store.hasLock} {#if $store.hasLock}
<div class="action-top-nav"> <div class="action-top-nav" class:has-lock={$store.hasLock}>
<div class="action-buttons"> <div class="action-buttons">
<div class="version"> <!-- svelte-ignore a11y-click-events-have-key-events -->
<VersionModal /> {#if updateAvailable}
</div> <div class="app-action-button version" on:click={versionModal.show}>
<RevertModal /> <div class="app-action">
<ActionButton quiet>
{#if isPublished} <StatusLight notice />
<div class="publish-popover"> Update
<div bind:this={publishPopoverAnchor}> </ActionButton>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div> </div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div> </div>
{/if} {/if}
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<TourWrap <TourWrap
tourStepKey={$store.onboarding tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT ? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT} : TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
> >
<span id="builder-app-users-button"> <div class="app-action-button users">
<ActionButton <div class="app-action" id="builder-app-users-button">
quiet <ActionButton
icon="UserGroup" quiet
size="M" icon="UserGroup"
on:click={() => { on:click={() => {
store.update(state => { store.update(state => {
state.builderSidePanel = true state.builderSidePanel = true
return state return state
}) })
}} }}
> >
Users Users
</ActionButton> </ActionButton>
</span> </div>
</div>
</TourWrap> </TourWrap>
<div class="app-action-button preview">
<div class="app-action">
<ActionButton quiet icon="PlayCircle" on:click={previewApp}>
Preview
</ActionButton>
</div>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="app-action-button publish app-action-popover"
on:click={() => {
if (!appActionPopoverOpen) {
appActionPopover.show()
} else {
appActionPopover.hide()
}
}}
>
<div bind:this={appActionPopoverAnchor}>
<div class="app-action">
<Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} />
<TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}>
<span class="publish-open" id="builder-app-publish-button">
Publish
<Icon
name={appActionPopoverOpen ? "ChevronUp" : "ChevronDown"}
size="M"
/>
</span>
</TourWrap>
</div>
</div>
<Popover
bind:this={appActionPopover}
align="right"
disabled={!isPublished}
anchor={appActionPopoverAnchor}
offset={35}
on:close={() => {
appActionPopoverOpen = false
}}
on:open={() => {
appActionPopoverOpen = true
}}
>
<div class="app-action-popover-content">
<Layout noPadding gap="M">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Body size="M">
<span
class="app-link"
on:click={() => {
if (isPublished) {
viewApp()
} else {
appActionPopover.hide()
updateAppModal.show()
}
}}
>
{selectedApp ? `${selectedApp?.url}` : ""}
{#if isPublished}
<Icon size="S" name="LinkOut" />
{:else}
<Icon size="S" name="Edit" />
{/if}
</span>
</Body>
<Body size="S">
<span class="publish-popover-status">
{#if isPublished}
<span class="status-text">
{updateDeploymentString(deployments)}
</span>
<span class="unpublish-link">
<Link quiet on:click={unpublishApp}>Unpublish</Link>
</span>
<span class="revert-link">
<Link quiet secondary on:click={revertApp}>Revert</Link>
</span>
{:else}
<span class="status-text unpublished">Not published</span>
{/if}
</span>
</Body>
<div class="action-buttons">
{#if $store.hasLock}
{#if isPublished}
<ActionButton
quiet
icon="Code"
on:click={() => {
$goto("./settings/embed")
appActionPopover.hide()
}}
>
Embed
</ActionButton>
{/if}
<Button
cta
on:click={publishApp}
id={"builder-app-publish-button"}
disabled={!canPublish}
>
Publish
</Button>
{/if}
</div>
</Layout>
</div>
</Popover>
</div>
</div> </div>
</div> </div>
<!-- Modals -->
<ConfirmDialog <ConfirmDialog
bind:this={unpublishModal} bind:this={unpublishModal}
title="Confirm unpublish" title="Confirm unpublish"
@ -213,45 +341,117 @@
> >
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<Modal bind:this={updateAppModal} padding={false} width="600px">
<UpdateAppModal
app={selectedApp}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<RevertModal bind:this={revertModal} />
<VersionModal hideIcon bind:this={versionModal} />
{:else}
<div class="app-action-button preview-locked">
<div class="app-action">
<ActionButton quiet icon="PlayCircle" on:click={previewApp}>
Preview
</ActionButton>
</div>
</div>
{/if} {/if}
<div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button>
{#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
</div>
<style> <style>
/* .banner-btn { .app-action-popover-content {
display: flex;
align-items: center;
gap: var(--spacing-s);
} */
.popover-content {
padding: var(--spacing-xl); padding: var(--spacing-xl);
width: 360px;
} }
.buttons {
display: flex; .app-action-popover-content :global(.icon svg.spectrum-Icon) {
flex-direction: row; height: 0.8em;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-l);
} }
.action-buttons { .action-buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
/* gap: var(--spacing-s); */ height: 100%;
}
.version {
margin-right: var(--spacing-s);
} }
.action-top-nav { .action-top-nav {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
height: 100%;
}
.app-link {
display: flex;
align-items: center;
gap: var(--spacing-s);
cursor: pointer;
}
.app-action-popover-content .status-text {
color: var(--spectrum-global-color-green-500);
border-right: 1px solid var(--spectrum-global-color-gray-500);
padding-right: var(--spacing-m);
}
.app-action-popover-content .status-text.unpublished {
color: var(--spectrum-global-color-gray-600);
border-right: 0px;
padding-right: 0px;
}
.app-action-popover-content .action-buttons {
gap: var(--spacing-m);
}
.app-action-popover-content
.publish-popover-status
.unpublish-link
:global(.spectrum-Link) {
color: var(--spectrum-global-color-red-400);
}
.publish-popover-status {
display: flex;
gap: var(--spacing-m);
}
.app-action-popover .publish-open {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.app-action-button {
height: 100%;
display: flex;
align-items: center;
padding-right: var(--spacing-m);
}
.app-action-button.publish:hover {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
.app-action-button.publish {
border-left: var(--border-light);
padding: 0px var(--spacing-l);
}
.app-action-button.version :global(.spectrum-ActionButton-label) {
display: flex;
gap: var(--spectrum-actionbutton-icon-gap);
}
.app-action-button.preview-locked {
padding-right: 0px;
}
.app-action {
display: flex;
align-items: center;
gap: var(--spacing-s);
} }
</style> </style>

View File

@ -0,0 +1,45 @@
<script>
import { Input, notifications } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { store } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { apps } from "stores/portal"
import { API } from "api"
export const show = () => {
deletionModal.show()
}
export const hide = () => {
deletionModal.hide()
}
let deletionModal
let deletionConfirmationAppName
const deleteApp = async () => {
try {
await API.deleteApp($store.appId)
apps.load()
notifications.success("App deleted successfully")
$goto("/builder")
} catch (err) {
notifications.error("Error deleting app")
}
}
</script>
<ConfirmDialog
bind:this={deletionModal}
title="Delete app"
okText="Delete"
onOk={deleteApp}
onCancel={() => (deletionConfirmationAppName = null)}
disabled={deletionConfirmationAppName !== $store.name}
>
Are you sure you want to delete <b>{$store.name}</b>?
<br />
Please enter the app name below to confirm.
<br /><br />
<Input bind:value={deletionConfirmationAppName} placeholder={$store.name} />
</ConfirmDialog>

View File

@ -1,15 +1,9 @@
<script> <script>
import { import { Input, Modal, notifications, ModalContent } from "@budibase/bbui"
Input,
Modal,
notifications,
ModalContent,
ActionButton,
} from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { API } from "api" import { API } from "api"
export let disabled = false export let onComplete = () => {}
let revertModal let revertModal
let appName let appName
@ -24,20 +18,20 @@
const applicationPkg = await API.fetchAppPackage(appId) const applicationPkg = await API.fetchAppPackage(appId)
await store.actions.initialise(applicationPkg) await store.actions.initialise(applicationPkg)
notifications.info("Changes reverted successfully") notifications.info("Changes reverted successfully")
onComplete()
} catch (error) { } catch (error) {
notifications.error(`Error reverting changes: ${error}`) notifications.error(`Error reverting changes: ${error}`)
} }
} }
</script>
<ActionButton export const hide = () => {
quiet revertModal.hide()
icon="Revert" }
size="M"
tooltip="Revert changes" export const show = () => {
on:click={revertModal.show} revertModal.show()
{disabled} }
/> </script>
<Modal bind:this={revertModal}> <Modal bind:this={revertModal}>
<ModalContent <ModalContent

View File

@ -18,6 +18,7 @@
updateModal.hide() updateModal.hide()
} }
export let onComplete = () => {}
export let hideIcon = false export let hideIcon = false
let updateModal let updateModal
@ -47,6 +48,7 @@
notifications.success( notifications.success(
`App updated successfully to version ${$store.upgradableVersion}` `App updated successfully to version ${$store.upgradableVersion}`
) )
onComplete()
} catch (err) { } catch (err) {
notifications.error(`Error updating app: ${err}`) notifications.error(`Error updating app: ${err}`)
} }
@ -70,9 +72,7 @@
</script> </script>
{#if !hideIcon && updateAvailable} {#if !hideIcon && updateAvailable}
<StatusLight hoverable on:click={updateModal.show} notice> <StatusLight hoverable on:click={updateModal.show} notice>Update</StatusLight>
Update available
</StatusLight>
{/if} {/if}
<Modal bind:this={updateModal}> <Modal bind:this={updateModal}>
<ModalContent <ModalContent

View File

@ -0,0 +1,50 @@
<script>
import { currentAsset, store } from "builderStore"
import { onMount } from "svelte"
import { Label, Combobox, Select } from "@budibase/bbui"
import {
getActionProviderComponents,
buildFormSchema,
} from "builderStore/dataBinding"
import { findComponent } from "builderStore/componentUtils"
export let parameters
onMount(() => {
if (!parameters.type) {
parameters.type = "top"
}
})
$: formComponent = findComponent($currentAsset.props, parameters.componentId)
$: formSchema = buildFormSchema(formComponent)
$: fieldOptions = Object.keys(formSchema || {})
$: actionProviders = getActionProviderComponents(
$currentAsset,
$store.selectedComponentId,
"ScrollTo"
)
</script>
<div class="root">
<Label small>Form</Label>
<Select
bind:value={parameters.componentId}
options={actionProviders}
getOptionLabel={x => x._instanceName}
getOptionValue={x => x._id}
/>
<Label small>Field</Label>
<Combobox bind:value={parameters.field} options={fieldOptions} />
</div>
<style>
.root {
display: grid;
align-items: center;
gap: var(--spacing-m);
grid-template-columns: auto;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -16,6 +16,7 @@ export { default as S3Upload } from "./S3Upload.svelte"
export { default as ExportData } from "./ExportData.svelte" export { default as ExportData } from "./ExportData.svelte"
export { default as ContinueIf } from "./ContinueIf.svelte" export { default as ContinueIf } from "./ContinueIf.svelte"
export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte" export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte"
export { default as ScrollTo } from "./ScrollTo.svelte"
export { default as ShowNotification } from "./ShowNotification.svelte" export { default as ShowNotification } from "./ShowNotification.svelte"
export { default as PromptUser } from "./PromptUser.svelte" export { default as PromptUser } from "./PromptUser.svelte"
export { default as OpenSidePanel } from "./OpenSidePanel.svelte" export { default as OpenSidePanel } from "./OpenSidePanel.svelte"

View File

@ -70,6 +70,11 @@
"type": "form", "type": "form",
"component": "UpdateFieldValue" "component": "UpdateFieldValue"
}, },
{
"name": "Scroll To Field",
"type": "form",
"component": "ScrollTo"
},
{ {
"name": "Validate Form", "name": "Validate Form",
"type": "form", "type": "form",

View File

@ -2,9 +2,4 @@
import ColumnEditor from "./ColumnEditor.svelte" import ColumnEditor from "./ColumnEditor.svelte"
</script> </script>
<ColumnEditor <ColumnEditor {...$$props} on:change allowCellEditing={false} />
{...$$props}
on:change
allowCellEditing={false}
subject="Dynamic Filter"
/>

View File

@ -142,10 +142,10 @@
<div class="column"> <div class="column">
<div class="wide"> <div class="wide">
<Body size="S"> <Body size="S">
By default, all table columns will automatically be shown. By default, all columns will automatically be shown.
<br /> <br />
You can manually control which columns are included in your table, You can manually control which columns are included by adding them
and their appearance, by adding them below. below.
</Body> </Body>
</div> </div>
</div> </div>

View File

@ -13,7 +13,6 @@
export let componentInstance export let componentInstance
export let value = [] export let value = []
export let allowCellEditing = true export let allowCellEditing = true
export let subject = "Table"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -75,11 +74,10 @@
} }
</script> </script>
<ActionButton on:click={open}>Configure columns</ActionButton> <div class="column-editor">
<Drawer bind:this={drawer} title="{subject} Columns"> <ActionButton on:click={open}>Configure columns</ActionButton>
<svelte:fragment slot="description"> </div>
Configure the columns in your {subject.toLowerCase()}. <Drawer bind:this={drawer} title="Columns">
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button> <Button cta slot="buttons" on:click={save}>Save</Button>
<ColumnDrawer <ColumnDrawer
slot="body" slot="body"
@ -89,3 +87,9 @@
{allowCellEditing} {allowCellEditing}
/> />
</Drawer> </Drawer>
<style>
.column-editor :global(.spectrum-ActionButton) {
width: 100%;
}
</style>

View File

@ -1,7 +1,7 @@
<script> <script>
import { Button, ActionButton, Drawer } from "@budibase/bbui" import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnDrawer.svelte" import ColumnDrawer from "./ColumnEditor/ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { import {
getDatasourceForProvider, getDatasourceForProvider,

View File

@ -20,15 +20,26 @@
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) $: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource)?.schema $: schema = getSchemaForDatasource($currentAsset, datasource)?.schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: text = getText(value)
async function saveFilter() { async function saveFilter() {
dispatch("change", tempValue) dispatch("change", tempValue)
notifications.success("Filters saved") notifications.success("Filters saved")
drawer.hide() drawer.hide()
} }
const getText = filters => {
if (!filters?.length) {
return "No filters set"
} else {
return `${filters.length} filter${filters.length === 1 ? "" : "s"} set`
}
}
</script> </script>
<ActionButton on:click={drawer.show}>Define filters</ActionButton> <div class="filter-editor">
<ActionButton on:click={drawer.show}>{text}</ActionButton>
</div>
<Drawer bind:this={drawer} title="Filtering"> <Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button> <Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<FilterDrawer <FilterDrawer
@ -40,3 +51,9 @@
on:change={e => (tempValue = e.detail)} on:change={e => (tempValue = e.detail)}
/> />
</Drawer> </Drawer>
<style>
.filter-editor :global(.spectrum-ActionButton) {
width: 100%;
}
</style>

View File

@ -176,7 +176,10 @@
notifications.success(`Request saved successfully`) notifications.success(`Request saved successfully`)
if (dynamicVariables) { if (dynamicVariables) {
datasource.config.dynamicVariables = rebuildVariables(saveId) datasource.config.dynamicVariables = rebuildVariables(saveId)
datasource = await datasources.save(datasource) datasource = await datasources.update({
integration: integrationInfo,
datasource,
})
} }
prettifyQueryRequestBody( prettifyQueryRequestBody(
query, query,

View File

@ -11,7 +11,7 @@ export const TOUR_STEP_KEYS = {
BUILDER_DATA_SECTION: "builder-data-section", BUILDER_DATA_SECTION: "builder-data-section",
BUILDER_DESIGN_SECTION: "builder-design-section", BUILDER_DESIGN_SECTION: "builder-design-section",
BUILDER_USER_MANAGEMENT: "builder-user-management", BUILDER_USER_MANAGEMENT: "builder-user-management",
BUILDER_AUTOMATE_SECTION: "builder-automate-section", BUILDER_AUTOMATION_SECTION: "builder-automation-section",
FEATURE_USER_MANAGEMENT: "feature-user-management", FEATURE_USER_MANAGEMENT: "feature-user-management",
} }
@ -34,7 +34,7 @@ const getTours = () => {
title: "Data", title: "Data",
route: "/builder/app/:application/data", route: "/builder/app/:application/data",
layout: OnboardingData, layout: OnboardingData,
query: ".topcenternav .spectrum-Tabs-item#builder-data-tab", query: ".topleftnav .spectrum-Tabs-item#builder-data-tab",
onLoad: async () => { onLoad: async () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_DATA_SECTION) tourEvent(TOUR_STEP_KEYS.BUILDER_DATA_SECTION)
}, },
@ -45,20 +45,20 @@ const getTours = () => {
title: "Design", title: "Design",
route: "/builder/app/:application/design", route: "/builder/app/:application/design",
layout: OnboardingDesign, layout: OnboardingDesign,
query: ".topcenternav .spectrum-Tabs-item#builder-design-tab", query: ".topleftnav .spectrum-Tabs-item#builder-design-tab",
onLoad: () => { onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_DESIGN_SECTION) tourEvent(TOUR_STEP_KEYS.BUILDER_DESIGN_SECTION)
}, },
align: "left", align: "left",
}, },
{ {
id: TOUR_STEP_KEYS.BUILDER_AUTOMATE_SECTION, id: TOUR_STEP_KEYS.BUILDER_AUTOMATION_SECTION,
title: "Automations", title: "Automations",
route: "/builder/app/:application/automate", route: "/builder/app/:application/automation",
query: ".topcenternav .spectrum-Tabs-item#builder-automate-tab", query: ".topleftnav .spectrum-Tabs-item#builder-automation-tab",
body: "Once you have your app screens made, you can set up automations to fit in with your current workflow", body: "Once you have your app screens made, you can set up automations to fit in with your current workflow",
onLoad: () => { onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_AUTOMATE_SECTION) tourEvent(TOUR_STEP_KEYS.BUILDER_AUTOMATION_SECTION)
}, },
align: "left", align: "left",
}, },

View File

@ -4,18 +4,27 @@
export let active = false export let active = false
</script> </script>
<a on:click href={url} class:active> {#if url}
{text || ""} <a on:click href={url} class:active>
</a> {text || ""}
</a>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span on:click class:active>
{text || ""}
</span>
{/if}
<style> <style>
a { a,
span {
padding: var(--spacing-s) var(--spacing-m); padding: var(--spacing-s) var(--spacing-m);
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
border-radius: 4px; border-radius: 4px;
transition: background 130ms ease-out; transition: background 130ms ease-out;
} }
.active, .active,
span:hover,
a:hover { a:hover {
background-color: var(--spectrum-global-color-gray-200); background-color: var(--spectrum-global-color-gray-200);
cursor: pointer; cursor: pointer;

View File

@ -22,7 +22,7 @@
} }
const goToOverview = () => { const goToOverview = () => {
$goto(`../overview/${app.devId}`) $goto(`../../app/${app.devId}/settings`)
} }
</script> </script>

View File

@ -11,6 +11,7 @@
import TemplateCard from "components/common/TemplateCard.svelte" import TemplateCard from "components/common/TemplateCard.svelte"
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen" import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
import { lowercase } from "helpers"
export let template export let template
@ -19,6 +20,7 @@
const values = writable({ name: "", url: null }) const values = writable({ name: "", url: null })
const validation = createValidationStore() const validation = createValidationStore()
const encryptionValidation = createValidationStore()
$: { $: {
const { url } = $values const { url } = $values
@ -27,8 +29,11 @@
...$values, ...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url, url: url?.[0] === "/" ? url.substring(1, url.length) : url,
}) })
encryptionValidation.check({ ...$values })
} }
$: encryptedFile = $values.file?.name?.endsWith(".enc.tar.gz")
onMount(async () => { onMount(async () => {
const lastChar = $auth.user?.firstName const lastChar = $auth.user?.firstName
? $auth.user?.firstName[$auth.user?.firstName.length - 1] ? $auth.user?.firstName[$auth.user?.firstName.length - 1]
@ -87,6 +92,9 @@
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications }) appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template }) appValidation.file(validation, { template })
encryptionValidation.addValidatorType("encryptionPassword", "text", true)
// init validation // init validation
const { url } = $values const { url } = $values
validation.check({ validation.check({
@ -110,6 +118,9 @@
data.append("templateName", template.name) data.append("templateName", template.name)
data.append("templateKey", template.key) data.append("templateKey", template.key)
data.append("templateFile", $values.file) data.append("templateFile", $values.file)
if ($values.encryptionPassword?.trim()) {
data.append("encryptionPassword", $values.encryptionPassword.trim())
}
} }
// Create App // Create App
@ -143,67 +154,119 @@
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) { } catch (error) {
creating = false creating = false
console.error(error) throw error
notifications.error("Error creating app")
} }
} }
const Step = { CONFIG: "config", SET_PASSWORD: "set_password" }
let currentStep = Step.CONFIG
$: stepConfig = {
[Step.CONFIG]: {
title: "Create your app",
confirmText: template?.fromFile ? "Import app" : "Create app",
onConfirm: async () => {
if (encryptedFile) {
currentStep = Step.SET_PASSWORD
return false
} else {
try {
await createNewApp()
} catch (error) {
notifications.error("Error creating app")
}
}
},
isValid: $validation.valid,
},
[Step.SET_PASSWORD]: {
title: "Provide the export password",
confirmText: "Import app",
onConfirm: async () => {
try {
await createNewApp()
} catch (e) {
let message = "Error creating app"
if (e.message) {
message += `: ${lowercase(e.message)}`
}
notifications.error(message)
return false
}
},
isValid: $encryptionValidation.valid,
},
}
</script> </script>
<ModalContent <ModalContent
title={"Create your app"} title={stepConfig[currentStep].title}
confirmText={template?.fromFile ? "Import app" : "Create app"} confirmText={stepConfig[currentStep].confirmText}
onConfirm={createNewApp} onConfirm={stepConfig[currentStep].onConfirm}
disabled={!$validation.valid} disabled={!stepConfig[currentStep].isValid}
> >
{#if template && !template?.fromFile} {#if currentStep === Step.CONFIG}
<TemplateCard {#if template && !template?.fromFile}
name={template.name} <TemplateCard
imageSrc={template.image} name={template.name}
backgroundColour={template.background} imageSrc={template.image}
overlayEnabled={false} backgroundColour={template.background}
icon={template.icon} overlayEnabled={false}
/> icon={template.icon}
{/if} />
{#if template?.fromFile}
<Dropzone
error={$validation.touched.file && $validation.errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$validation.touched.file = true
}}
/>
{/if}
<Input
autofocus={true}
bind:value={$values.name}
disabled={creating}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
placeholder={defaultAppName}
/>
<span>
<Input
bind:value={$values.url}
disabled={creating}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.url
? $values.url
: `/${resolveAppUrl(template, $values.name)}`}
/>
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if} {/if}
</span> {#if template?.fromFile}
<Dropzone
error={$validation.touched.file && $validation.errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$validation.touched.file = true
}}
/>
{/if}
<Input
autofocus={true}
bind:value={$values.name}
disabled={creating}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
placeholder={defaultAppName}
/>
<span>
<Input
bind:value={$values.url}
disabled={creating}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.url
? $values.url
: `/${resolveAppUrl(template, $values.name)}`}
/>
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if}
</span>
{/if}
{#if currentStep === Step.SET_PASSWORD}
<Input
autofocus={true}
label="Imported file password"
type="password"
bind:value={$values.encryptionPassword}
disabled={creating}
on:blur={() => ($encryptionValidation.touched.encryptionPassword = true)}
error={$encryptionValidation.touched.encryptionPassword &&
$encryptionValidation.errors.encryptionPassword}
/>
{/if}
</ModalContent> </ModalContent>
<style> <style>

View File

@ -1,27 +1,128 @@
<script> <script>
import { ModalContent, Toggle, Body, InlineAlert } from "@budibase/bbui" import {
ModalContent,
Toggle,
Body,
InlineAlert,
Input,
notifications,
} from "@budibase/bbui"
import { createValidationStore } from "helpers/validation/yup"
export let app export let app
export let published export let published
let excludeRows = false let includeInternalTablesRows = true
let encypt = true
$: title = published ? "Export published app" : "Export latest app" let password = null
$: confirmText = published ? "Export published" : "Export latest" const validation = createValidationStore()
validation.addValidatorType("password", "password", true, { minLength: 8 })
$: validation.observe("password", password)
const exportApp = () => { const Step = { CONFIG: "config", SET_PASSWORD: "set_password" }
let currentStep = Step.CONFIG
$: exportButtonText = published ? "Export published" : "Export latest"
$: stepConfig = {
[Step.CONFIG]: {
title: published ? "Export published app" : "Export latest app",
confirmText: encypt ? "Continue" : exportButtonText,
onConfirm: () => {
if (!encypt) {
exportApp()
} else {
currentStep = Step.SET_PASSWORD
return false
}
},
isValid: true,
},
[Step.SET_PASSWORD]: {
title: "Add password to encrypt your export",
confirmText: exportButtonText,
onConfirm: async () => {
await validation.check({ password })
if (!$validation.valid) {
return false
}
exportApp(password)
},
isValid: $validation.valid,
},
}
const exportApp = async () => {
const id = published ? app.prodId : app.devId const id = published ? app.prodId : app.devId
const appName = encodeURIComponent(app.name) const url = `/api/backups/export?appId=${id}`
window.location = `/api/backups/export?appId=${id}&appname=${appName}&excludeRows=${excludeRows}` await downloadFile(url, {
excludeRows: !includeInternalTablesRows,
encryptPassword: password,
})
}
async function downloadFile(url, body) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (response.ok) {
const contentDisposition = response.headers.get("Content-Disposition")
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
)
const filename = matches[1].replace(/['"]/g, "")
const url = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
} else {
notifications.error("Error exporting the app.")
}
} catch (error) {
notifications.error(error.message || "Error downloading the exported app")
}
} }
</script> </script>
<ModalContent {title} {confirmText} onConfirm={exportApp}> <ModalContent
<InlineAlert title={stepConfig[currentStep].title}
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys." confirmText={stepConfig[currentStep].confirmText}
/> onConfirm={stepConfig[currentStep].onConfirm}
<Body disabled={!stepConfig[currentStep].isValid}
>Apps can be exported with or without data that is within internal tables - >
select this below.</Body {#if currentStep === Step.CONFIG}
> <Body>
<Toggle text="Exclude Rows" bind:value={excludeRows} /> <Toggle
text="Export rows from internal tables"
bind:value={includeInternalTablesRows}
/>
<Toggle text="Encrypt my export" bind:value={encypt} />
</Body>
{#if !encypt}
<InlineAlert
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys."
/>
{/if}
{/if}
{#if currentStep === Step.SET_PASSWORD}
<Input
type="password"
label="Password"
placeholder="Type here..."
bind:value={password}
error={$validation.errors.password}
/>
{/if}
</ModalContent> </ModalContent>

View File

@ -14,6 +14,7 @@
import EditableIcon from "../common/EditableIcon.svelte" import EditableIcon from "../common/EditableIcon.svelte"
export let app export let app
export let onUpdateComplete
const values = writable({ const values = writable({
name: app.name, name: app.name,
@ -54,6 +55,9 @@
color: $values.iconColor, color: $values.iconColor,
}, },
}) })
if (typeof onUpdateComplete == "function") {
onUpdateComplete()
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error("Error updating app") notifications.error("Error updating app")

View File

@ -6,7 +6,6 @@ export function createValidationStore(initialValue, ...validators) {
let touched = false let touched = false
const value = writable(initialValue || "") const value = writable(initialValue || "")
const error = derived(value, $v => validate($v, validators))
const touchedStore = derived(value, () => { const touchedStore = derived(value, () => {
if (!touched) { if (!touched) {
touched = true touched = true
@ -14,6 +13,10 @@ export function createValidationStore(initialValue, ...validators) {
} }
return touched return touched
}) })
const error = derived(
[value, touchedStore],
([$v, $t]) => $t && validate($v, validators)
)
return [value, error, touchedStore] return [value, error, touchedStore]
} }

View File

@ -5,6 +5,7 @@ import { notifications } from "@budibase/bbui"
export const createValidationStore = () => { export const createValidationStore = () => {
const DEFAULT = { const DEFAULT = {
values: {},
errors: {}, errors: {},
touched: {}, touched: {},
valid: false, valid: false,
@ -20,7 +21,7 @@ export const createValidationStore = () => {
validator[propertyName] = propertyValidator validator[propertyName] = propertyValidator
} }
const addValidatorType = (propertyName, type, required) => { const addValidatorType = (propertyName, type, required, options) => {
if (!type || !propertyName) { if (!type || !propertyName) {
return return
} }
@ -33,6 +34,9 @@ export const createValidationStore = () => {
case "email": case "email":
propertyValidator = string().email().nullable() propertyValidator = string().email().nullable()
break break
case "password":
propertyValidator = string().nullable()
break
default: default:
propertyValidator = string().nullable() propertyValidator = string().nullable()
} }
@ -41,9 +45,65 @@ export const createValidationStore = () => {
propertyValidator = propertyValidator.required() propertyValidator = propertyValidator.required()
} }
if (options?.minLength) {
propertyValidator = propertyValidator.min(options.minLength)
}
validator[propertyName] = propertyValidator validator[propertyName] = propertyValidator
} }
const observe = async (propertyName, value) => {
const values = get(validation).values
let fieldIsValid
if (!Object.prototype.hasOwnProperty.call(values, propertyName)) {
// Initial setup
values[propertyName] = value
return
}
if (value === values[propertyName]) {
return
}
const obj = object().shape(validator)
try {
validation.update(store => {
store.errors[propertyName] = null
return store
})
await obj.validateAt(propertyName, { [propertyName]: value })
fieldIsValid = true
} catch (error) {
const [fieldError] = error.errors
if (fieldError) {
validation.update(store => {
store.errors[propertyName] = capitalise(fieldError)
store.valid = false
return store
})
}
}
if (fieldIsValid) {
// Validate the rest of the fields
try {
await obj.validate(
{ ...values, [propertyName]: value },
{ abortEarly: false }
)
validation.update(store => {
store.valid = true
return store
})
} catch {
validation.update(store => {
store.valid = false
return store
})
}
}
}
const check = async values => { const check = async values => {
const obj = object().shape(validator) const obj = object().shape(validator)
// clear the previous errors // clear the previous errors
@ -87,5 +147,6 @@ export const createValidationStore = () => {
check, check,
addValidator, addValidator,
addValidatorType, addValidatorType,
observe,
} }
} }

View File

@ -0,0 +1,91 @@
<script>
import { onMount } from "svelte"
import { fade, fly } from "svelte/transition"
import { store, selectedScreen } from "builderStore"
import { ProgressCircle } from "@budibase/bbui"
$: route = $selectedScreen?.routing.route || "/"
$: src = `/${$store.appId}#${route}`
const close = () => {
store.update(state => ({
...state,
showPreview: false,
}))
}
onMount(() => {
window.closePreview = () => {
store.update(state => ({
...state,
showPreview: false,
}))
}
})
</script>
<div
class="preview-overlay"
transition:fade={{ duration: 260 }}
on:click|self={close}
>
<div
class="container spectrum {$store.theme}"
transition:fly={{ duration: 260, y: 130 }}
>
<div class="header placeholder" />
<div class="loading placeholder">
<ProgressCircle />
</div>
<iframe title="Budibase App Preview" {src} />
</div>
</div>
<style>
.preview-overlay {
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: 999;
position: absolute;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: stretch;
padding: 48px;
}
.container {
flex: 1 1 auto;
background: var(--spectrum-global-color-gray-75);
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 0 80px 0 rgba(0, 0, 0, 0.5);
}
iframe {
position: absolute;
height: 100%;
width: 100%;
border: none;
outline: none;
z-index: 1;
}
.header {
height: 60px;
width: 100%;
background: black;
top: 0;
position: absolute;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translateY(-50%) translateX(-50%);
}
.placeholder {
z-index: 0;
}
</style>

View File

@ -4,8 +4,6 @@
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
ActionMenu,
MenuItem,
Icon, Icon,
Tabs, Tabs,
Tab, Tab,
@ -24,6 +22,7 @@
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import UserAvatars from "./_components/UserAvatars.svelte" import UserAvatars from "./_components/UserAvatars.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
import PreviewOverlay from "./_components/PreviewOverlay.svelte"
export let application export let application
@ -140,57 +139,18 @@
<BuilderSidePanel /> <BuilderSidePanel />
{/if} {/if}
<div class="root"> <div class="root" class:blur={$store.showPreview}>
<div class="top-nav"> <div class="top-nav" class:has-lock={$store.hasLock}>
{#if $store.initialised} {#if $store.initialised}
<div class="topleftnav"> <div class="topleftnav">
<ActionMenu> <span class="back-to-apps">
<div slot="control"> <Icon
<Icon size="M" hoverable name="ShowMenu" /> size="S"
</div> hoverable
<MenuItem on:click={() => $goto("../../portal/apps")}> name="BackAndroid"
Exit to portal on:click={() => $goto("../../portal/apps")}
</MenuItem> />
<MenuItem </span>
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
{#if $store.hasLock} {#if $store.hasLock}
<Tabs {selected} size="M"> <Tabs {selected} size="M">
{#each $layout.children as { path, title }} {#each $layout.children as { path, title }}
@ -208,13 +168,23 @@
{:else} {:else}
<div class="secondary-editor"> <div class="secondary-editor">
<Icon name="LockClosed" /> <Icon name="LockClosed" />
Another user is currently editing your screens and automations <div
class="secondary-editor-body"
title="Another user is currently editing your screens and automations"
>
Another user is currently editing your screens and automations
</div>
</div> </div>
{/if} {/if}
</div> </div>
<div class="topcenternav">
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="toprightnav"> <div class="toprightnav">
<UserAvatars users={$userStore} /> <span class:nav-lock={!$store.hasLock}>
<AppActions {application} /> <UserAvatars users={$userStore} />
</span>
<AppActions {application} {loaded} />
</div> </div>
{/if} {/if}
</div> </div>
@ -230,12 +200,23 @@
{/await} {/await}
</div> </div>
{#if $store.showPreview}
<PreviewOverlay />
{/if}
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal}> <Modal bind:this={commandPaletteModal}>
<CommandPalette /> <CommandPalette />
</Modal> </Modal>
<style> <style>
.back-to-apps {
display: contents;
}
.back-to-apps :global(.icon) {
margin-left: 12px;
margin-right: 12px;
}
.loading { .loading {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
@ -248,6 +229,10 @@
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: filter 260ms ease-out;
}
.root.blur {
filter: blur(8px);
} }
.top-nav { .top-nav {
@ -263,27 +248,34 @@
z-index: 2; z-index: 2;
} }
.topleftnav { .top-nav.has-lock {
padding-right: 0px;
}
.topcenternav {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.topleftnav :global(.spectrum-Heading) {
.topcenternav :global(.spectrum-Heading) {
flex: 1 1 auto; flex: 1 1 auto;
width: 0;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 0px var(--spacing-m);
} }
.topcenternav { .topleftnav {
display: flex; display: flex;
position: relative; position: relative;
margin-bottom: -2px; margin-bottom: -2px;
overflow: hidden;
} }
.topcenternav :global(.spectrum-Tabs-itemLabel) {
.topleftnav :global(.spectrum-Tabs-itemLabel) {
font-weight: 600; font-weight: 600;
} }
@ -292,7 +284,10 @@
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: var(--spacing-l); }
.toprightnav :global(.avatars) {
margin-right: var(--spacing-l);
} }
.secondary-editor { .secondary-editor {
@ -300,6 +295,16 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
min-width: 0;
overflow: hidden;
margin-left: var(--spacing-xl);
}
.secondary-editor-body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0px;
} }
.body { .body {

View File

@ -0,0 +1,31 @@
<script>
import { ModalContent, Body, Layout, Link } from "@budibase/bbui"
import { organisation } from "stores/portal"
import GoogleButton from "./GoogleButton.svelte"
$: isGoogleConfigured = !!$organisation.googleDatasourceConfigured
</script>
<ModalContent
showConfirmButton={false}
title={`Connect to Google Sheets`}
cancelText="Cancel"
size="L"
>
<!-- check true and false directly, don't render until flag is set -->
{#if isGoogleConfigured === true}
<Layout noPadding>
<Body size="S"
>Authenticate with your Google account to use the Google Sheets
integration.</Body
>
</Layout>
<GoogleButton samePage />
{:else if isGoogleConfigured === false}
<Body size="S"
>Google authentication is not enabled, please complete Google SSO
configuration.</Body
>
<Link href="/builder/portal/settings/auth">Configure Google SSO</Link>
{/if}
</ModalContent>

View File

@ -3,8 +3,6 @@
import { store } from "builderStore" import { store } from "builderStore"
import { auth } from "stores/portal" import { auth } from "stores/portal"
export let preAuthStep
export let datasource
export let disabled export let disabled
export let samePage export let samePage
@ -15,18 +13,8 @@
class:disabled class:disabled
{disabled} {disabled}
on:click={async () => { on:click={async () => {
let ds = datasource
let appId = $store.appId let appId = $store.appId
if (!ds) { const url = `/api/global/auth/${tenantId}/datasource/google?appId=${appId}`
const resp = await preAuthStep()
if (resp.datasource && resp.appId) {
ds = resp.datasource
appId = resp.appId
} else {
ds = resp
}
}
const url = `/api/global/auth/${tenantId}/datasource/google?datasourceId=${ds._id}&appId=${appId}`
if (samePage) { if (samePage) {
window.location = url window.location = url
} else { } else {

View File

@ -0,0 +1,62 @@
<script>
import {
Body,
FancyCheckboxGroup,
InlineAlert,
Layout,
ModalContent,
} from "@budibase/bbui"
import { IntegrationTypes } from "constants/backend"
import { createTableSelectionStore } from "./tableSelectionStore"
export let integration
export let datasource
export let onComplete = () => {}
$: store = createTableSelectionStore(integration, datasource)
$: isSheets = integration.name === IntegrationTypes.GOOGLE_SHEETS
$: tableType = isSheets ? "sheets" : "tables"
$: title = `Choose your ${tableType}`
$: confirmText = $store.hasSelected
? `Fetch ${tableType}`
: "Continue without fetching"
$: description = isSheets
? "Select which spreadsheets you want to connect."
: "Choose what tables you want to sync with Budibase"
$: selectAllText = isSheets ? "Select all sheets" : "Select all"
</script>
<ModalContent
{title}
cancelText="Skip"
size="L"
{confirmText}
onConfirm={() => store.importSelectedTables(onComplete)}
disabled={$store.loading}
>
{#if $store.loading}
<p>loading...</p>
{:else}
<Layout noPadding no>
<Body size="S">{description}</Body>
<FancyCheckboxGroup
options={$store.tableNames}
selected={$store.selectedTableNames}
on:change={e => store.setSelectedTableNames(e.detail)}
{selectAllText}
/>
{#if $store.error}
<InlineAlert
type="error"
header={$store.error.title}
message={$store.error.description}
/>
{/if}
</Layout>
{/if}
</ModalContent>

View File

@ -0,0 +1,64 @@
import { derived, writable, get } from "svelte/store"
import { notifications } from "@budibase/bbui"
import { datasources, ImportTableError } from "stores/backend"
export const createTableSelectionStore = (integration, datasource) => {
const tableNamesStore = writable([])
const selectedTableNamesStore = writable([])
const errorStore = writable(null)
const loadingStore = writable(true)
datasources.getTableNames(datasource).then(tableNames => {
tableNamesStore.set(tableNames)
selectedTableNamesStore.set(tableNames)
loadingStore.set(false)
})
const setSelectedTableNames = selectedTableNames => {
selectedTableNamesStore.set(selectedTableNames)
}
const importSelectedTables = async onComplete => {
errorStore.set(null)
try {
await datasources.updateSchema(datasource, get(selectedTableNamesStore))
notifications.success(`Tables fetched successfully.`)
await onComplete()
} catch (err) {
if (err instanceof ImportTableError) {
errorStore.set(err)
} else {
notifications.error("Error fetching tables.")
}
}
// Prevent modal closing
return false
}
const combined = derived(
[tableNamesStore, selectedTableNamesStore, errorStore, loadingStore],
([
$tableNamesStore,
$selectedTableNamesStore,
$errorStore,
$loadingStore,
]) => {
return {
tableNames: $tableNamesStore,
selectedTableNames: $selectedTableNamesStore,
error: $errorStore,
loading: $loadingStore,
hasSelected: $selectedTableNamesStore.length > 0,
}
}
)
return {
subscribe: combined.subscribe,
setSelectedTableNames,
importSelectedTables,
}
}

View File

@ -0,0 +1,95 @@
<script>
import { Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { IntegrationTypes } from "constants/backend"
import GoogleAuthPrompt from "./GoogleAuthPrompt.svelte"
import { get } from "svelte/store"
import TableImportSelection from "./TableImportSelection/index.svelte"
import DatasourceConfigEditor from "components/backend/Datasources/ConfigEditor/index.svelte"
import { datasources } from "stores/backend"
import { createOnGoogleAuthStore } from "./stores/onGoogleAuth.js"
import { createDatasourceCreationStore } from "./stores/datasourceCreation.js"
import { configFromIntegration } from "stores/selectors"
import { notifications } from "@budibase/bbui"
export let loading = false
const store = createDatasourceCreationStore()
const onGoogleAuth = createOnGoogleAuthStore()
let modal
const handleStoreChanges = (store, modal, goto) => {
store.stage === null ? modal?.hide() : modal?.show()
if (store.finished) {
goto(`./datasource/${store.datasource._id}`)
}
}
$: handleStoreChanges($store, modal, $goto)
export function show(integration) {
if (integration.name === IntegrationTypes.REST) {
// A REST integration is created immediately, we don't need to display a config modal.
loading = true
datasources
.create({ integration, fields: configFromIntegration(integration) })
.then(datasource => {
store.setIntegration(integration)
store.setDatasource(datasource)
})
.finally(() => (loading = false))
} else if (integration.name === IntegrationTypes.GOOGLE_SHEETS) {
// This prompt redirects users to the Google OAuth flow, they'll be returned to this modal afterwards
// with query params populated that trigger the `onGoogleAuth` store.
store.googleAuthStage()
} else {
// All other integrations can generate config from data in the integration object.
store.setIntegration(integration)
store.setConfig(configFromIntegration(integration))
store.editConfigStage()
}
}
// Triggers opening the config editor whenever Google OAuth returns the user to the page
$: $onGoogleAuth((integration, config) => {
store.setIntegration(integration)
store.setConfig(config)
store.editConfigStage()
})
const createDatasource = async config => {
try {
const datasource = await datasources.create({
integration: get(store).integration,
config,
})
store.setDatasource(datasource)
notifications.success("Datasource created successfully")
} catch (e) {
notifications.error(`Error creating datasource: ${e.message}`)
}
// Prevent modal closing
return false
}
</script>
<Modal on:hide={store.cancel} bind:this={modal}>
{#if $store.stage === "googleAuth"}
<GoogleAuthPrompt />
{:else if $store.stage === "editConfig"}
<DatasourceConfigEditor
integration={$store.integration}
config={$store.config}
onSubmit={({ config }) => createDatasource(config)}
/>
{:else if $store.stage === "selectTables"}
<TableImportSelection
integration={$store.integration}
datasource={$store.datasource}
onComplete={store.markAsFinished}
/>
{/if}
</Modal>

View File

@ -0,0 +1,92 @@
import { get, writable } from "svelte/store"
import { shouldIntegrationFetchTableNames } from "stores/selectors"
export const defaultStore = {
finished: false,
stage: null,
integration: null,
config: null,
datasource: null,
}
export const createDatasourceCreationStore = () => {
const store = writable(defaultStore)
store.cancel = () => {
const $store = get(store)
// If the datasource has already been created, mark the store as finished.
if ($store.stage === "selectTables") {
store.markAsFinished()
} else {
store.set(defaultStore)
}
}
// Used only by Google Sheets
store.googleAuthStage = () => {
store.update($store => ({
...$store,
stage: "googleAuth",
}))
}
store.setIntegration = integration => {
store.update($store => ({
...$store,
integration,
}))
}
store.setConfig = config => {
store.update($store => ({
...$store,
config,
}))
}
// Used for every flow but REST
store.editConfigStage = () => {
store.update($store => ({
...$store,
stage: "editConfig",
}))
}
store.setDatasource = datasource => {
const $store = get(store)
store.set({ ...$store, datasource })
if (shouldIntegrationFetchTableNames($store.integration)) {
store.selectTablesStage()
} else {
store.markAsFinished()
}
}
// Only used for datasource plus
store.selectTablesStage = () => {
store.update($store => ({
...$store,
stage: "selectTables",
}))
}
store.markAsFinished = () => {
store.update($store => ({
...$store,
finished: true,
}))
}
return {
subscribe: store.subscribe,
cancel: store.cancel,
googleAuthStage: store.googleAuthStage,
setIntegration: store.setIntegration,
setConfig: store.setConfig,
editConfigStage: store.editConfigStage,
setDatasource: store.setDatasource,
selectTablesStage: store.selectTablesStage,
markAsFinished: store.markAsFinished,
}
}

View File

@ -0,0 +1,116 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import {
defaultStore,
createDatasourceCreationStore,
} from "./datasourceCreation"
import { get } from "svelte/store"
// eslint-disable-next-line no-unused-vars
import { shouldIntegrationFetchTableNames } from "stores/selectors"
vi.mock("stores/selectors", () => ({
shouldIntegrationFetchTableNames: vi.fn(),
}))
describe("datasource creation store", () => {
beforeEach(ctx => {
vi.clearAllMocks()
// eslint-disable-next-line no-import-assign
ctx.store = createDatasourceCreationStore()
ctx.integration = { data: "integration" }
ctx.config = { data: "config" }
ctx.datasource = { data: "datasource" }
})
describe("store creation", () => {
it("returns the default values", ctx => {
expect(get(ctx.store)).toEqual(defaultStore)
})
})
describe("cancel", () => {
describe("when at the `selectTables` stage", () => {
beforeEach(ctx => {
ctx.store.selectTablesStage()
ctx.store.cancel()
})
it("marks the store as finished", ctx => {
expect(get(ctx.store)).toEqual({
...defaultStore,
stage: "selectTables",
finished: true,
})
})
})
describe("When at any previous stage", () => {
beforeEach(ctx => {
ctx.store.cancel()
})
it("resets to the default values", ctx => {
expect(get(ctx.store)).toEqual(defaultStore)
})
})
})
describe("googleAuthStage", () => {
beforeEach(ctx => {
ctx.store.googleAuthStage()
})
it("sets the stage", ctx => {
expect(get(ctx.store)).toEqual({ ...defaultStore, stage: "googleAuth" })
})
})
describe("setIntegration", () => {
beforeEach(ctx => {
ctx.store.setIntegration(ctx.integration)
})
it("sets the integration", ctx => {
expect(get(ctx.store)).toEqual({
...defaultStore,
integration: ctx.integration,
})
})
})
describe("setConfig", () => {
beforeEach(ctx => {
ctx.store.setConfig(ctx.config)
})
it("sets the config", ctx => {
expect(get(ctx.store)).toEqual({
...defaultStore,
config: ctx.config,
})
})
})
describe("editConfigStage", () => {
beforeEach(ctx => {
ctx.store.editConfigStage()
})
it("sets the stage", ctx => {
expect(get(ctx.store)).toEqual({ ...defaultStore, stage: "editConfig" })
})
})
describe("markAsFinished", () => {
beforeEach(ctx => {
ctx.store.markAsFinished()
})
it("marks the store as finished", ctx => {
expect(get(ctx.store)).toEqual({
...defaultStore,
finished: true,
})
})
})
})

View File

@ -0,0 +1,24 @@
import { derived } from "svelte/store"
import { params } from "@roxi/routify"
import { integrations } from "stores/backend"
import { IntegrationTypes } from "constants/backend"
export const createOnGoogleAuthStore = () => {
return derived([params, integrations], ([$params, $integrations]) => {
const id = $params["?continue_google_setup"]
return callback => {
if ($integrations && id) {
history.replaceState({}, null, window.location.pathname)
const integration = {
name: IntegrationTypes.GOOGLE_SHEETS,
...$integrations[IntegrationTypes.GOOGLE_SHEETS],
}
const fields = { continueSetupId: id, sheetId: "" }
callback(integration, fields)
}
}
})
}

View File

@ -0,0 +1,70 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { createOnGoogleAuthStore } from "./onGoogleAuth"
import { writable, get } from "svelte/store"
// eslint-disable-next-line no-unused-vars
import { params } from "@roxi/routify"
// eslint-disable-next-line no-unused-vars
import { integrations } from "stores/backend"
import { IntegrationTypes } from "constants/backend"
vi.mock("@roxi/routify", () => ({
params: vi.fn(),
}))
vi.mock("stores/backend", () => ({
integrations: vi.fn(),
}))
vi.stubGlobal("history", { replaceState: vi.fn() })
vi.stubGlobal("window", { location: { pathname: "/current-path" } })
describe("google auth store", () => {
beforeEach(ctx => {
vi.clearAllMocks()
// eslint-disable-next-line no-import-assign
integrations = writable({
[IntegrationTypes.GOOGLE_SHEETS]: { data: "integration" },
})
ctx.callback = vi.fn()
})
describe("with id present", () => {
beforeEach(ctx => {
// eslint-disable-next-line no-import-assign
params = writable({ "?continue_google_setup": "googleId" })
get(createOnGoogleAuthStore())(ctx.callback)
})
it("invokes the provided callback with an integration and fields", ctx => {
expect(ctx.callback).toHaveBeenCalledTimes(1)
expect(ctx.callback).toHaveBeenCalledWith(
{
name: IntegrationTypes.GOOGLE_SHEETS,
data: "integration",
},
{ continueSetupId: "googleId", sheetId: "" }
)
})
it("clears the query param", () => {
expect(history.replaceState).toHaveBeenCalledTimes(1)
expect(history.replaceState).toHaveBeenCalledWith(
{},
null,
`/current-path`
)
})
})
describe("without id present", () => {
beforeEach(ctx => {
// eslint-disable-next-line no-import-assign
params = writable({})
get(createOnGoogleAuthStore())(ctx.callback)
})
it("doesn't invoke the provided callback", ctx => {
expect(ctx.callback).toHaveBeenCalledTimes(0)
})
})
})

View File

@ -0,0 +1,22 @@
<script>
import { Modal, notifications } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
let modal
let promptUpload = false
export function show({ promptUpload: newPromptUpload = false }) {
promptUpload = newPromptUpload
modal.show()
}
const handleInternalTableSave = table => {
notifications.success(`Table created successfully.`)
$goto(`./table/${table._id}`)
}
</script>
<Modal bind:this={modal}>
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
</Modal>

View File

@ -0,0 +1,41 @@
<script>
import { Modal, notifications } from "@budibase/bbui"
import { integrationForDatasource } from "stores/selectors"
import { datasources, integrations } from "stores/backend"
import DatasourceConfigEditor from "components/backend/Datasources/ConfigEditor/index.svelte"
import EditDatasourceConfigButton from "./EditDatasourceConfigButton.svelte"
export let datasource
$: integration = integrationForDatasource($integrations, datasource)
let modal
async function saveDatasource({ config, name }) {
try {
await datasources.update({
integration,
datasource: { ...datasource, config, name },
})
notifications.success(
`Datasource ${datasource.name} updated successfully`
)
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}
</script>
<EditDatasourceConfigButton on:click={modal.show} {datasource} />
<Modal bind:this={modal}>
<DatasourceConfigEditor
{integration}
config={datasource.config}
showNameField
nameFieldValue={datasource.name}
onSubmit={saveDatasource}
/>
</Modal>

View File

@ -0,0 +1,117 @@
<script>
import { Body } from "@budibase/bbui"
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
import { IntegrationTypes } from "constants/backend"
export let datasource
const getSubtitle = datasource => {
if (datasource.source === IntegrationTypes.REST) {
return datasource.name
}
if (
datasource.source === IntegrationTypes.POSTGRES ||
datasource.source === IntegrationTypes.MYSQL ||
datasource.source === IntegrationTypes.ORACLE ||
datasource.source === IntegrationTypes.REDIS
) {
return `${datasource.config.host}:${datasource.config.port}`
}
if (datasource.source === IntegrationTypes.SQL_SERVER) {
return `${datasource.config.server}:${datasource.config.port}`
}
if (datasource.source === IntegrationTypes.SNOWFLAKE) {
return `${datasource.config.warehouse}:${datasource.config.database}:${datasource.config.schema}`
}
if (datasource.source === IntegrationTypes.ARANGODB) {
return `${datasource.config.url}:${datasource.config.databaseName}`
}
if (datasource.source === IntegrationTypes.COUCHDB) {
return datasource.config.database
}
if (
datasource.source === IntegrationTypes.DYNAMODB ||
datasource.source === IntegrationTypes.S3
) {
return `${datasource.config.endpoint}:${datasource.config.region}`
}
if (datasource.source === IntegrationTypes.ELASTICSEARCH) {
return datasource.config.url
}
if (datasource.source === IntegrationTypes.FIRESTORE) {
return datasource.config.projectId
}
if (datasource.source === IntegrationTypes.MONGODB) {
return datasource.config.db
}
if (datasource.source === IntegrationTypes.AIRTABLE) {
return datasource.config.base
}
if (datasource.source === IntegrationTypes.GOOGLE_SHEETS) {
return datasource.config.spreadsheetId
}
}
$: subtitle = getSubtitle(datasource)
</script>
<div class="button" on:click>
<div class="left">
{#if datasource.source !== IntegrationTypes.REST}
<div class="connected">
<FontAwesomeIcon name="fa-solid fa-circle-check" />
<Body size="S">Connected</Body>
</div>
{/if}
<div class="truncate">
<Body>{getSubtitle(datasource)}</Body>
</div>
</div>
<div class="right">
<FontAwesomeIcon name="fa-solid fa-gear" />
</div>
</div>
<style>
.button {
display: flex;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 5px;
width: 100%;
background-color: #00000047;
color: white;
overflow: hidden;
padding: 12px 16px;
box-sizing: border-box;
}
.left {
flex: 1;
overflow: hidden;
}
.right {
display: flex;
align-items: center;
margin-left: 16px;
}
.right :global(svg) {
color: var(--spectrum-global-color-gray-600);
}
.button:hover {
cursor: pointer;
filter: brightness(1.2);
}
.connected {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.connected :global(svg) {
margin-right: 6px;
color: #009562;
}
.connected :global(p) {
color: #009562;
}
.truncate :global(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -11,7 +11,7 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { datasources, integrations, queries, tables } from "stores/backend" import { datasources, integrations, queries, tables } from "stores/backend"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" import EditDatasourceConfig from "./_components/EditDatasourceConfig.svelte"
import RestExtraConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/rest/RestExtraConfigForm.svelte" import RestExtraConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/rest/RestExtraConfigForm.svelte"
import PlusConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte" import PlusConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
@ -20,8 +20,7 @@
import { isEqual } from "lodash" import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { API } from "api" import Spinner from "components/common/Spinner.svelte"
import { DatasourceFeature } from "@budibase/types"
const querySchema = { const querySchema = {
name: {}, name: {},
@ -33,6 +32,7 @@
let isValid = true let isValid = true
let integration, baseDatasource, datasource let integration, baseDatasource, datasource
let queryList let queryList
let loading = false
$: baseDatasource = $datasources.selected $: baseDatasource = $datasources.selected
$: queryList = $queries.list.filter( $: queryList = $queries.list.filter(
@ -47,33 +47,10 @@
} }
} }
async function validateConfig() {
const displayError = message =>
notifications.error(message ?? "Error validating datasource")
let connected = false
try {
const resp = await API.validateDatasource(datasource)
if (!resp.connected) {
displayError(`Unable to connect - ${resp.error}`)
}
connected = resp.connected
} catch (err) {
displayError(err?.message)
}
return connected
}
const saveDatasource = async () => { const saveDatasource = async () => {
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try { try {
// Create datasource // Create datasource
await datasources.save(datasource) await datasources.update({ datasource, integration })
if (datasource?.plus) { if (datasource?.plus) {
await tables.fetch() await tables.fetch()
} }
@ -114,21 +91,8 @@
/> />
<Heading size="M">{$datasources.selected?.name}</Heading> <Heading size="M">{$datasources.selected?.name}</Heading>
</header> </header>
<Body size="M">{integration.description}</Body>
</Layout> </Layout>
<Divider /> <EditDatasourceConfig {datasource} />
<div class="config-header">
<Heading size="S">Configuration</Heading>
<Button disabled={!changed || !isValid} cta on:click={saveDatasource}>
Save
</Button>
</div>
<IntegrationConfigForm
on:change={hasChanged}
schema={integration.datasource}
bind:datasource
on:valid={e => (isValid = e.detail)}
/>
{#if datasource.plus} {#if datasource.plus}
<PlusConfigForm bind:datasource save={saveDatasource} /> <PlusConfigForm bind:datasource save={saveDatasource} />
{/if} {/if}
@ -175,7 +139,21 @@
queries={queryList} queries={queryList}
bind:datasource bind:datasource
on:change={hasChanged} on:change={hasChanged}
/> >
<Button
slot="headerRight"
disabled={!changed || !isValid || loading}
cta
on:click={saveDatasource}
>
<div class="save-button-content">
{#if loading}
<Spinner size="10">Save</Spinner>
{/if}
Save
</div>
</Button>
</RestExtraConfigForm>
{/if} {/if}
</Layout> </Layout>
</section> </section>
@ -193,12 +171,6 @@
align-items: center; align-items: center;
} }
.config-header {
display: flex;
justify-content: space-between;
margin: 0 0 var(--spacing-xs) 0;
}
.query-header { .query-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -216,4 +188,10 @@
flex-direction: column; flex-direction: column;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.save-button-content {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style> </style>

View File

@ -32,7 +32,7 @@
</header> </header>
<Body size="M"> <Body size="M">
Budibase internal tables are part of your app, so the data will be Budibase internal tables are part of your app, so the data will be
stored in your apps context. stored in your app's context.
</Body> </Body>
</Layout> </Layout>
<Divider /> <Divider />

View File

@ -1,18 +1,16 @@
<script> <script>
import { API } from "api" import { API } from "api"
import { tables, datasources } from "stores/backend"
import { Icon, Modal, notifications, Heading, Body } from "@budibase/bbui"
import { params, goto } from "@roxi/routify"
import { import {
IntegrationTypes, tables,
DatasourceTypes, datasources,
DEFAULT_BB_DATASOURCE_ID, sortedIntegrations as integrations,
} from "constants/backend" } from "stores/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte" import { hasData } from "stores/selectors"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte" import { Icon, notifications, Heading, Body } from "@budibase/bbui"
import { createRestDatasource } from "builderStore/datasource" import { params, goto } from "@roxi/routify"
import CreateExternalDatasourceModal from "./_components/CreateExternalDatasourceModal/index.svelte"
import CreateInternalTableModal from "./_components/CreateInternalTableModal.svelte"
import DatasourceOption from "./_components/DatasourceOption.svelte" import DatasourceOption from "./_components/DatasourceOption.svelte"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte" import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons/index.js" import ICONS from "components/backend/DatasourceNavigator/icons/index.js"
@ -20,19 +18,14 @@
let internalTableModal let internalTableModal
let externalDatasourceModal let externalDatasourceModal
let integrations = []
let integration = null
let disabled = false
let promptUpload = false
$: hasData = $datasources.list.length > 1 || $tables.list.length > 1 let sampleDataLoading = false
$: hasDefaultData = let externalDatasourceLoading = false
$datasources.list.findIndex(
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID $: disabled = sampleDataLoading || externalDatasourceLoading
) !== -1
const createSampleData = async () => { const createSampleData = async () => {
disabled = true sampleDataLoading = true
try { try {
await API.addSampleData($params.application) await API.addSampleData($params.application)
@ -40,118 +33,22 @@
await datasources.fetch() await datasources.fetch()
$goto("./table") $goto("./table")
} catch (e) { } catch (e) {
disabled = false sampleDataLoading = false
notifications.error("Error creating datasource") notifications.error("Error creating datasource")
} }
} }
const handleIntegrationSelect = integrationType => {
const selected = integrations.find(([type]) => type === integrationType)[1]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
auth: selected.auth,
features: selected.features || [],
}
if (selected.friendlyName) {
integration.name = selected.friendlyName
}
if (integration.type === IntegrationTypes.REST) {
disabled = true
// Skip modal for rest, create straight away
createRestDatasource(integration)
.then(response => {
$goto(`./datasource/${response._id}`)
})
.catch(() => {
disabled = false
notifications.error("Error creating datasource")
})
} else {
externalDatasourceModal.show()
}
}
const handleInternalTable = () => {
promptUpload = false
internalTableModal.show()
}
const handleDataImport = () => {
promptUpload = true
internalTableModal.show()
}
const handleInternalTableSave = table => {
notifications.success(`Table created successfully.`)
$goto(`./table/${table._id}`)
}
function sortIntegrations(integrations) {
let integrationsArray = Object.entries(integrations)
function getTypeOrder(schema) {
if (schema.type === DatasourceTypes.API) {
return 1
}
if (schema.type === DatasourceTypes.RELATIONAL) {
return 2
}
return schema.type?.charCodeAt(0)
}
integrationsArray.sort((a, b) => {
let typeOrderA = getTypeOrder(a[1])
let typeOrderB = getTypeOrder(b[1])
if (typeOrderA === typeOrderB) {
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
}
return typeOrderA < typeOrderB ? -1 : 1
})
return integrationsArray
}
const fetchIntegrations = async () => {
const unsortedIntegrations = await API.getIntegrations()
integrations = sortIntegrations(unsortedIntegrations)
}
$: fetchIntegrations()
</script> </script>
<Modal bind:this={internalTableModal}> <CreateInternalTableModal bind:this={internalTableModal} />
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
</Modal>
<Modal bind:this={externalDatasourceModal}> <CreateExternalDatasourceModal
{#if integration?.auth?.type === "google"} bind:loading={externalDatasourceLoading}
<GoogleDatasourceConfigModal {integration} /> bind:this={externalDatasourceModal}
{:else} />
<DatasourceConfigModal {integration} />
{/if}
</Modal>
<div class="page"> <div class="page">
<div class="closeButton"> <div class="closeButton">
{#if hasData} {#if hasData($datasources, $tables)}
<Icon hoverable name="Close" on:click={$goto("./table")} /> <Icon hoverable name="Close" on:click={$goto("./table")} />
{/if} {/if}
</div> </div>
@ -172,7 +69,7 @@
<div class="options"> <div class="options">
<DatasourceOption <DatasourceOption
on:click={handleInternalTable} on:click={internalTableModal.show}
title="Create new table" title="Create new table"
description="Non-relational" description="Non-relational"
{disabled} {disabled}
@ -183,12 +80,12 @@
on:click={createSampleData} on:click={createSampleData}
title="Use sample data" title="Use sample data"
description="Non-relational" description="Non-relational"
disabled={disabled || hasDefaultData} disabled={disabled || $datasources.hasDefaultData}
> >
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" /> <svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption> </DatasourceOption>
<DatasourceOption <DatasourceOption
on:click={handleDataImport} on:click={() => internalTableModal.show({ promptUpload: true })}
title="Upload data" title="Upload data"
description="Non-relational" description="Non-relational"
{disabled} {disabled}
@ -202,14 +99,17 @@
</div> </div>
<div class="options"> <div class="options">
{#each integrations as [key, value]} {#each $integrations as integration}
<DatasourceOption <DatasourceOption
on:click={() => handleIntegrationSelect(key)} on:click={() => externalDatasourceModal.show(integration)}
title={value.friendlyName} title={integration.friendlyName}
description={value.type} description={integration.type}
{disabled} {disabled}
> >
<IntegrationIcon integrationType={key} schema={value} /> <IntegrationIcon
integrationType={integration.name}
schema={integration}
/>
</DatasourceOption> </DatasourceOption>
{/each} {/each}
</div> </div>

View File

@ -3,6 +3,7 @@
"name": "Blocks", "name": "Blocks",
"icon": "Article", "icon": "Article",
"children": [ "children": [
"gridblock",
"tableblock", "tableblock",
"cardsblock", "cardsblock",
"repeaterblock", "repeaterblock",

View File

@ -0,0 +1,78 @@
<script>
import { Content, SideNav, SideNavItem } from "components/portal/page"
import { Page, Layout } from "@budibase/bbui"
import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte"
let deleteModal
</script>
<!-- routify:options index=4 -->
<div class="settings">
<Page>
<Layout noPadding gap="L">
<Content showMobileNav>
<SideNav slot="side-nav">
<SideNavItem
text="Automation History"
url={$url("./automation-history")}
active={$isActive("./automation-history")}
/>
<SideNavItem
text="Backups"
url={$url("./backups")}
active={$isActive("./backups")}
/>
<SideNavItem
text="Embed"
url={$url("./embed")}
active={$isActive("./embed")}
/>
<SideNavItem
text="Export"
url={$url("./export")}
active={$isActive("./export")}
/>
<SideNavItem
text="Name and URL"
url={$url("./name-and-url")}
active={$isActive("./name-and-url")}
/>
<SideNavItem
text="Version"
url={$url("./version")}
active={$isActive("./version")}
/>
<div class="delete-action">
<SideNavItem
text="Delete app"
on:click={() => {
deleteModal.show()
}}
/>
</div>
</SideNav>
<slot />
</Content>
</Layout>
</Page>
</div>
<DeleteModal bind:this={deleteModal} />
<style>
.delete-action :global(span) {
color: var(--spectrum-global-color-red-400);
}
.delete-action {
display: contents;
}
.settings {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
height: 0;
}
</style>

View File

@ -47,7 +47,7 @@
<Button <Button
secondary secondary
on:click={() => { on:click={() => {
$goto(`../../../../app/${appId}/automate/${history.automationId}`) $goto(`/builder/app/${appId}/automation/${history.automationId}`)
}} }}
> >
Edit automation Edit automation

View File

@ -12,11 +12,11 @@
import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte" import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte"
import StatusRenderer from "./_components/StatusRenderer.svelte" import StatusRenderer from "./_components/StatusRenderer.svelte"
import HistoryDetailsPanel from "./_components/HistoryDetailsPanel.svelte" import HistoryDetailsPanel from "./_components/HistoryDetailsPanel.svelte"
import { automationStore } from "builderStore" import { automationStore, store } from "builderStore"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte" import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import { auth, licensing, admin, overview } from "stores/portal" import { auth, licensing, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal" import Portal from "svelte-portal"
@ -35,7 +35,6 @@
let loaded = false let loaded = false
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
$: app = $overview.selectedApp
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
@ -191,7 +190,8 @@
/> />
</div> </div>
</div> </div>
{#if (licensePlan?.type !== Constants.PlanType.ENTERPRISE && $auth.user.accountPortalAccess) || !$admin.cloud}
{#if (!$licensing.isEnterprisePlan && $auth.user.accountPortalAccess) || !$admin.cloud}
<Button secondary on:click={$licensing.goToUpgradePage()}> <Button secondary on:click={$licensing.goToUpgradePage()}>
Get more history Get more history
</Button> </Button>
@ -227,7 +227,7 @@
{#if selectedHistory} {#if selectedHistory}
<Portal target="#side-panel"> <Portal target="#side-panel">
<HistoryDetailsPanel <HistoryDetailsPanel
appId={app.devId} appId={$store.appId}
bind:history={selectedHistory} bind:history={selectedHistory}
close={sidePanel.close} close={sidePanel.close}
/> />

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