Merge branch 'develop' of github.com:Budibase/budibase into form-step-updates

This commit is contained in:
mike12345567 2023-06-26 12:45:34 +01:00
commit 2cafc9f80c
221 changed files with 6828 additions and 9234 deletions

View File

@ -1,5 +1,9 @@
name: Budibase CI
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
on:
# Trigger the workflow on push or pull request,
# but only for the master branch
@ -23,6 +27,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -135,15 +142,39 @@ jobs:
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Check submodule
- name: Check pro commit
id: get_pro_commits
run: |
cd packages/pro
git fetch
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"
echo "Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md"
exit 1
pro_commit=$(git rev-parse HEAD)
branch=${{ github.base_ref || github.ref_name }}
echo "Running on branch `$branch` (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})"
if [[ "$branch" == "master" ]]; then
base_commit=$(git rev-parse origin/master)
else
echo "All good, the submodule had been merged!"
base_commit=$(git rev-parse origin/develop)
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

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

View File

@ -37,6 +37,14 @@ COPY --from=build /worker /worker
RUN apt-get update && \
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
WORKDIR /nodejs
RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh && \

View File

@ -1,22 +1,10 @@
{
"version": "2.7.33",
"version": "2.7.34-alpha.6",
"npmClient": "yarn",
"packages": [
"packages/backend-core",
"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"
"packages/*"
],
"useWorkspaces": true,
"useNx": true,
"command": {
"publish": {
"ignoreChanges": [

View File

@ -2,23 +2,22 @@
"name": "root",
"private": true,
"devDependencies": {
"@esbuild-plugins/node-resolve": "^0.2.2",
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.2.1",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
"babel-eslint": "^10.0.3",
"esbuild": "^0.17.18",
"esbuild-node-externals": "^1.7.0",
"eslint": "^7.28.0",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-svelte3": "^3.2.0",
"husky": "^8.0.3",
"js-yaml": "^4.1.0",
"kill-port": "^1.6.1",
"lerna": "7.0.0-alpha.0",
"lerna": "7.0.2",
"madge": "^6.0.0",
"minimist": "^1.2.8",
"nx": "^16.2.1",
"prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2",
@ -48,9 +47,9 @@
"kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server",
"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:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server",
"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: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: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",
@ -67,6 +66,7 @@
"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: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: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 .",
@ -95,19 +95,7 @@
},
"workspaces": {
"packages": [
"packages/backend-core",
"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"
"packages/*"
]
},
"resolutions": {

View File

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

View File

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

View File

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

View File

@ -1,12 +1,17 @@
import crypto from "crypto"
import fs from "fs"
import zlib from "zlib"
import env from "../environment"
import { join } from "path"
const ALGO = "aes-256-ctr"
const SEPARATOR = "-"
const ITERATIONS = 10000
const RANDOM_BYTES = 16
const STRETCH_LENGTH = 32
const SALT_LENGTH = 16
const IV_LENGTH = 16
export enum SecretOption {
API = "api",
ENCRYPTION = "encryption",
@ -31,15 +36,15 @@ export function getSecret(secretOption: SecretOption): string {
return secret
}
function stretchString(string: string, salt: Buffer) {
return crypto.pbkdf2Sync(string, salt, ITERATIONS, STRETCH_LENGTH, "sha512")
function stretchString(secret: string, salt: Buffer) {
return crypto.pbkdf2Sync(secret, salt, ITERATIONS, STRETCH_LENGTH, "sha512")
}
export function encrypt(
input: string,
secretOption: SecretOption = SecretOption.API
) {
const salt = crypto.randomBytes(RANDOM_BYTES)
const salt = crypto.randomBytes(SALT_LENGTH)
const stretched = stretchString(getSecret(secretOption), salt)
const cipher = crypto.createCipheriv(ALGO, stretched, salt)
const base = cipher.update(input)
@ -60,3 +65,115 @@ export function decrypt(
const final = decipher.final()
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
* to check if the role inherits any others.
* @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.
*/
export async function getRole(roleId?: string): Promise<RoleDoc | undefined> {
export async function getRole(
roleId?: string,
opts?: { defaultPublic?: boolean }
): Promise<RoleDoc | undefined> {
if (!roleId) {
return undefined
}
@ -161,6 +165,9 @@ export async function getRole(roleId?: string): Promise<RoleDoc | undefined> {
// finalise the ID
role._id = getExternalRoleID(role._id)
} catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
}
// only throw an error if there is no role at all
if (Object.keys(role).length === 0) {
throw err

View File

@ -8,6 +8,8 @@
export let disabled = false
export let error = null
export let validate = null
export let indeterminate = false
export let compact = false
const dispatch = createEventDispatcher()
@ -21,11 +23,19 @@
}
</script>
<FancyField {error} {value} {validate} {disabled} clickable on:click={onChange}>
<FancyField
{error}
{value}
{validate}
{disabled}
{compact}
clickable
on:click={onChange}
>
<span>
<Checkbox {disabled} {value} />
<Checkbox {disabled} {value} {indeterminate} />
</span>
<div class="text">
<div class="text" class:compact>
{#if text}
{text}
{/if}
@ -47,6 +57,10 @@
line-clamp: 2;
-webkit-box-orient: vertical;
}
.text.compact {
font-size: 13px;
line-height: 15px;
}
.text > :global(*) {
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 ref
export let autoHeight
export let compact = false
const formContext = getContext("fancy-form")
const id = Math.random()
@ -42,6 +43,7 @@
class:disabled
class:focused
class:clickable
class:compact
class:auto-height={autoHeight}
>
<div class="content" on:click>
@ -61,7 +63,6 @@
<style>
.fancy-field {
max-width: 400px;
background: var(--spectrum-global-color-gray-75);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
@ -69,6 +70,12 @@
transition: border-color 130ms ease-out, background 130ms ease-out,
background 130ms ease-out;
color: var(--spectrum-global-color-gray-800);
--padding: 16px;
--height: 64px;
}
.fancy-field.compact {
--padding: 8px;
--height: 36px;
}
.fancy-field:hover {
border-color: var(--spectrum-global-color-gray-400);
@ -91,8 +98,8 @@
}
.content {
position: relative;
height: 64px;
padding: 0 16px;
height: var(--height);
padding: 0 var(--padding);
}
.fancy-field.auto-height .content {
height: auto;
@ -103,7 +110,7 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 16px;
gap: var(--padding);
}
.field {
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 FancyForm } from "./FancyForm.svelte"
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
export { default as FancyCheckboxGroup } from "./FancyCheckboxGroup.svelte"
export { default as ErrorMessage } from "./ErrorMessage.svelte"

View File

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

View File

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

View File

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

View File

@ -23,10 +23,11 @@ function prepareData(config) {
return datasource
}
export async function saveDatasource(config, skipFetch = false) {
export async function saveDatasource(config, { skipFetch, tablesFilter } = {}) {
const datasource = prepareData(config)
// Create datasource
const resp = await datasources.save(datasource, !skipFetch && datasource.plus)
const fetchSchema = !skipFetch && datasource.plus
const resp = await datasources.save(datasource, { fetchSchema, tablesFilter })
// update the tables incase datasource plus
await tables.fetch()
@ -41,6 +42,13 @@ export async function createRestDatasource(integration) {
export async function validateDatasourceConfig(config) {
const datasource = prepareData(config)
const resp = await API.validateDatasource(datasource)
return resp
return await API.validateDatasource(datasource)
}
export async function getDatasourceInfo(config) {
let datasource = config
if (!config._id) {
datasource = prepareData(config)
}
return await API.fetchInfoForDatasource(datasource)
}

View File

@ -74,6 +74,7 @@ const INITIAL_FRONTEND_STATE = {
propertyFocus: null,
builderSidePanel: false,
hasLock: true,
showPreview: false,
// URL params
selectedScreenId: null,

View File

@ -13,6 +13,8 @@
Modal,
notifications,
Icon,
Checkbox,
DatePicker,
} from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore"
@ -306,6 +308,11 @@
drawer.hide()
}
function canShowField(key, value) {
const dependsOn = value?.dependsOn
return !dependsOn || !!inputData[dependsOn]
}
onMount(async () => {
try {
await environment.loadVariables()
@ -317,210 +324,233 @@
<div class="fields">
{#each deprecatedSchemaProperties as [key, value]}
<div class="block-field">
{#if key !== "fields"}
<Label
tooltip={value.title === "Binding / Value"
? "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 canShowField(key, value)}
<div class="block-field">
{#if key !== "fields" && value.type !== "boolean"}
<Label
tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string"
: null}>{value.title || (key === "row" ? "Table" : key)}</Label
>
{/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}
{#if value.type === "string" && value.enum && canShowField(key)}
<Select
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
value={inputData[key]}
placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/>
{:else}
<div class="test">
{: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.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
fillWidth={true}
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type={value.customType}
type="email"
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={false}
updateOnChange={false}
placeholder={value.customType === "queryLimit" ? queryLimit : ""}
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}
</div>
</div>
{/if}
{/each}
</div>
<Modal bind:this={webhookModal} width="30%">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
notifications,
Modal,
Table,
Toggle,
FancyCheckboxGroup,
} from "@budibase/bbui"
import { datasources, integrations, tables } from "stores/backend"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
@ -16,7 +16,7 @@
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify"
import ValuesList from "components/common/ValuesList.svelte"
import { getDatasourceInfo } from "builderStore/datasource"
export let datasource
export let save
@ -34,7 +34,7 @@
let selectedFromRelationship, selectedToRelationship
let confirmDialog
let specificTables = null
let requireSpecificTables = false
let tableList
$: integration = datasource && $integrations[datasource.source]
$: plusTables = datasource?.plus
@ -153,30 +153,28 @@
warning={false}
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>
If you have fetched tables from this database before, this action may
overwrite any changes you made after your initial fetch.
</Body>
<br />
<div class="table-checkboxes">
<FancyCheckboxGroup options={tableList} bind:selected={specificTables} />
</div>
</ConfirmDialog>
<Divider />
<div class="query-header">
<Heading size="S">Tables</Heading>
<div class="table-buttons">
<Button secondary on:click={() => confirmDialog.show()}>
<Button
secondary
on:click={async () => {
const info = await getDatasourceInfo(datasource)
tableList = info.tableNames
confirmDialog.show()
}}
>
Fetch tables
</Button>
<Button cta icon="Add" on:click={createNewTable}>New table</Button>
@ -246,4 +244,8 @@
display: flex;
gap: var(--spacing-m);
}
.table-checkboxes {
width: 100%;
}
</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) {
const integrationList = get(integrations)
if (!integrationList) {
return
}
if (integrationList[integrationType]?.iconUrl) {
return { url: integrationList[integrationType].iconUrl }
} else if (schema?.custom || !ICONS[integrationType]) {

View File

@ -1,12 +1,19 @@
<script>
import { goto } from "@roxi/routify"
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui"
import {
ModalContent,
notifications,
Body,
Layout,
FancyCheckboxGroup,
} 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,
getDatasourceInfo,
} from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
@ -15,11 +22,24 @@
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
let isValid = false
let fetchTableStep = false
let selectedTables = []
let tableList = []
$: name =
IntegrationNames[datasource.type] || datasource.name || datasource.type
IntegrationNames[datasource?.type] || datasource?.name || datasource?.type
$: datasourcePlus = datasource?.plus
$: title = fetchTableStep ? "Fetch your tables" : `Connect to ${name}`
$: confirmText = fetchTableStep
? "Continue"
: datasourcePlus
? "Connect"
: "Save and continue to query"
async function validateConfig() {
if (!integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
return true
}
const displayError = message =>
notifications.error(message ?? "Error validating datasource")
@ -47,35 +67,75 @@
if (!datasource.name) {
datasource.name = name
}
const resp = await save(datasource)
const opts = {}
if (datasourcePlus && selectedTables) {
opts.tablesFilter = selectedTables
}
const resp = await save(datasource, opts)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource created successfully.`)
notifications.success("Datasource created successfully.")
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}
async function nextStep() {
let connected = true
if (datasourcePlus) {
connected = await validateConfig()
}
if (!connected) {
return false
}
if (datasourcePlus && !fetchTableStep) {
notifications.success("Connected to datasource successfully.")
const info = await getDatasourceInfo(datasource)
tableList = info.tableNames
fetchTableStep = true
return false
} else {
await saveDatasource()
return true
}
}
</script>
<ModalContent
title={`Connect to ${name}`}
onConfirm={() => saveDatasource()}
confirmText={datasource.plus ? "Connect" : "Save and continue to query"}
cancelText="Back"
showSecondaryButton={datasource.plus}
{title}
onConfirm={() => nextStep()}
{confirmText}
cancelText={fetchTableStep ? "Cancel" : "Back"}
showSecondaryButton={datasourcePlus}
size="L"
disabled={!isValid}
>
<Layout noPadding>
<Body size="XS"
>Connect your database to Budibase using the config below.
<Body size="XS">
{#if !fetchTableStep}
Connect your database to Budibase using the config below
{:else}
Choose what tables you want to sync with Budibase
{/if}
</Body>
</Layout>
<IntegrationConfigForm
schema={datasource.schema}
bind:datasource
creating={true}
on:valid={e => (isValid = e.detail)}
/>
{#if !fetchTableStep}
<IntegrationConfigForm
schema={datasource?.schema}
bind:datasource
creating={true}
on:valid={e => (isValid = e.detail)}
/>
{:else}
<div class="table-checkboxes">
<FancyCheckboxGroup options={tableList} bind:selected={selectedTables} />
</div>
{/if}
</ModalContent>
<style>
.table-checkboxes {
width: 100%;
}
</style>

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

@ -69,7 +69,7 @@
name: "App",
description: "",
icon: "Play",
action: () => window.open(`/${$store.appId}`),
action: () => store.update(state => ({ ...state, showPreview: true })),
},
{
type: "Preview",

View File

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

View File

@ -62,7 +62,10 @@
}
const previewApp = () => {
window.open(`/${application}`)
store.update(state => ({
...state,
showPreview: true,
}))
}
const viewApp = () => {

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 ContinueIf } from "./ContinueIf.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 PromptUser } from "./PromptUser.svelte"
export { default as OpenSidePanel } from "./OpenSidePanel.svelte"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@
import TemplateCard from "components/common/TemplateCard.svelte"
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
import { Roles } from "constants/backend"
import { lowercase } from "helpers"
export let template
@ -19,6 +20,7 @@
const values = writable({ name: "", url: null })
const validation = createValidationStore()
const encryptionValidation = createValidationStore()
$: {
const { url } = $values
@ -27,8 +29,11 @@
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
encryptionValidation.check({ ...$values })
}
$: encryptedFile = $values.file?.name?.endsWith(".enc.tar.gz")
onMount(async () => {
const lastChar = $auth.user?.firstName
? $auth.user?.firstName[$auth.user?.firstName.length - 1]
@ -87,6 +92,9 @@
appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template })
encryptionValidation.addValidatorType("encryptionPassword", "text", true)
// init validation
const { url } = $values
validation.check({
@ -110,6 +118,9 @@
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file)
if ($values.encryptionPassword?.trim()) {
data.append("encryptionPassword", $values.encryptionPassword.trim())
}
}
// Create App
@ -143,67 +154,119 @@
$goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) {
creating = false
console.error(error)
notifications.error("Error creating app")
throw error
}
}
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>
<ModalContent
title={"Create your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp}
disabled={!$validation.valid}
title={stepConfig[currentStep].title}
confirmText={stepConfig[currentStep].confirmText}
onConfirm={stepConfig[currentStep].onConfirm}
disabled={!stepConfig[currentStep].isValid}
>
{#if template && !template?.fromFile}
<TemplateCard
name={template.name}
imageSrc={template.image}
backgroundColour={template.background}
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 currentStep === Step.CONFIG}
{#if template && !template?.fromFile}
<TemplateCard
name={template.name}
imageSrc={template.image}
backgroundColour={template.background}
overlayEnabled={false}
icon={template.icon}
/>
{/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>
<style>

View File

@ -1,27 +1,128 @@
<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 published
let excludeRows = false
let includeInternalTablesRows = true
let encypt = true
$: title = published ? "Export published app" : "Export latest app"
$: confirmText = published ? "Export published" : "Export latest"
let password = null
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 appName = encodeURIComponent(app.name)
window.location = `/api/backups/export?appId=${id}&appname=${appName}&excludeRows=${excludeRows}`
const url = `/api/backups/export?appId=${id}`
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>
<ModalContent {title} {confirmText} onConfirm={exportApp}>
<InlineAlert
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys."
/>
<Body
>Apps can be exported with or without data that is within internal tables -
select this below.</Body
>
<Toggle text="Exclude Rows" bind:value={excludeRows} />
<ModalContent
title={stepConfig[currentStep].title}
confirmText={stepConfig[currentStep].confirmText}
onConfirm={stepConfig[currentStep].onConfirm}
disabled={!stepConfig[currentStep].isValid}
>
{#if currentStep === Step.CONFIG}
<Body>
<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>

View File

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

View File

@ -5,6 +5,7 @@ import { notifications } from "@budibase/bbui"
export const createValidationStore = () => {
const DEFAULT = {
values: {},
errors: {},
touched: {},
valid: false,
@ -20,7 +21,7 @@ export const createValidationStore = () => {
validator[propertyName] = propertyValidator
}
const addValidatorType = (propertyName, type, required) => {
const addValidatorType = (propertyName, type, required, options) => {
if (!type || !propertyName) {
return
}
@ -33,6 +34,9 @@ export const createValidationStore = () => {
case "email":
propertyValidator = string().email().nullable()
break
case "password":
propertyValidator = string().nullable()
break
default:
propertyValidator = string().nullable()
}
@ -41,9 +45,65 @@ export const createValidationStore = () => {
propertyValidator = propertyValidator.required()
}
if (options?.minLength) {
propertyValidator = propertyValidator.min(options.minLength)
}
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 obj = object().shape(validator)
// clear the previous errors
@ -87,5 +147,6 @@ export const createValidationStore = () => {
check,
addValidator,
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

@ -24,6 +24,7 @@
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import UserAvatars from "./_components/UserAvatars.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
import PreviewOverlay from "./_components/PreviewOverlay.svelte"
export let application
@ -140,7 +141,7 @@
<BuilderSidePanel />
{/if}
<div class="root">
<div class="root" class:blur={$store.showPreview}>
<div class="top-nav">
{#if $store.initialised}
<div class="topleftnav">
@ -230,6 +231,10 @@
{/await}
</div>
{#if $store.showPreview}
<PreviewOverlay />
{/if}
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal}>
<CommandPalette />
@ -248,6 +253,10 @@
width: 100%;
display: flex;
flex-direction: column;
transition: filter 260ms ease-out;
}
.root.blur {
filter: blur(8px);
}
.top-nav {

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,114 @@
<script>
import {
Modal,
notifications,
Body,
Layout,
ModalContent,
} from "@budibase/bbui"
import CreateEditVariableModal from "components/portal/environment/CreateEditVariableModal.svelte"
import ConfigInput from "./ConfigInput.svelte"
import { createConfigStore } from "./stores/config"
import { createValidationStore } from "./stores/validation"
import { createValidatedConfigStore } from "./stores/validatedConfig"
import { datasources } from "stores/backend"
import { get } from "svelte/store"
import { environment } from "stores/portal"
export let integration
export let config
export let onDatasourceCreated = () => {}
$: configStore = createConfigStore(integration, config)
$: validationStore = createValidationStore(integration)
$: validatedConfigStore = createValidatedConfigStore(
configStore,
validationStore,
integration
)
const handleConfirm = async () => {
validationStore.markAllFieldsActive()
const config = get(configStore)
try {
if (await validationStore.validate(config)) {
const datasource = await datasources.create({
integration,
fields: config,
})
await onDatasourceCreated(datasource)
} else {
notifications.send("Invalid fields", {
type: "error",
icon: "Alert",
autoDismiss: true,
})
}
} catch (e) {
// Do nothing on errors, alerts are handled by `datasources.create`
}
// Prevent modal closing
return false
}
const handleBlur = key => {
validationStore.markFieldActive(key)
validationStore.validate(get(configStore))
}
const handleChange = (key, newValue) => {
configStore.updateFieldValue(key, newValue)
validationStore.validate(get(configStore))
}
let createVariableModal
let selectedConfigKey
const showModal = key => {
selectedConfigKey = key
createVariableModal.show()
}
async function save(data) {
try {
await environment.createVariable(data)
configStore.updateFieldValue(selectedConfigKey, `{{ 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={$validationStore.allFieldsActive && $validationStore.invalid}
size="L"
>
<Layout noPadding>
<Body size="XS">
Connect your database to Budibase using the config below.
</Body>
</Layout>
{#each $validatedConfigStore as { type, key, value, error, name }}
<ConfigInput
{type}
{value}
{error}
{name}
showModal={() => showModal(key)}
on:blur={() => handleBlur(key)}
on:change={e => handleChange(key, e.detail)}
/>
{/each}
</ModalContent>
<Modal bind:this={createVariableModal}>
<CreateEditVariableModal {save} />
</Modal>

View File

@ -0,0 +1,26 @@
import { writable } from "svelte/store"
export const createConfigStore = (integration, config) => {
const configStore = writable(config)
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
})
}
return {
subscribe: configStore.subscribe,
updateFieldValue,
}
}

View File

@ -0,0 +1,40 @@
import { capitalise } from "helpers"
import { derived } from "svelte/store"
export const createValidatedConfigStore = (
configStore,
validationStore,
integration
) => {
return derived(
[configStore, validationStore],
([$configStore, $validationStore]) => {
return Object.entries(integration.datasource).map(([key, properties]) => {
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]
}
return {
key,
value: getValue(),
error: $validationStore.errors[key],
name: capitalise(properties.display || key),
type: properties.type,
}
})
}
)
}

View File

@ -0,0 +1,95 @@
import { capitalise } from "helpers"
import { object, string, number } from "yup"
import { derived, writable, get } from "svelte/store"
import { notifications } from "@budibase/bbui"
const propertyValidator = type => {
if (type === "number") {
return number().nullable()
}
if (type === "email") {
return string().email().nullable()
}
return string().nullable()
}
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
}
export const createValidationStore = integration => {
const allValidators = getValidatorFields(integration)
const selectedValidatorsStore = writable({})
const errorsStore = writable({})
const markAllFieldsActive = () => {
selectedValidatorsStore.set(allValidators)
}
const markFieldActive = key => {
selectedValidatorsStore.update($validatorsStore => ({
...$validatorsStore,
[key]: allValidators[key],
}))
}
const validate = async config => {
try {
await object()
.shape(get(selectedValidatorsStore))
.validate(config, { 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 combined = derived(
[errorsStore, selectedValidatorsStore],
([$errorsStore, $selectedValidatorsStore]) => {
return {
errors: $errorsStore,
invalid: Object.keys($errorsStore).length > 0,
allFieldsActive:
Object.keys($selectedValidatorsStore).length ===
Object.keys(allValidators).length,
}
}
)
return {
subscribe: combined.subscribe,
markAllFieldsActive,
markFieldActive,
validate,
}
}

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 { auth } from "stores/portal"
export let preAuthStep
export let datasource
export let disabled
export let samePage
@ -15,18 +13,8 @@
class:disabled
{disabled}
on:click={async () => {
let ds = datasource
let appId = $store.appId
if (!ds) {
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}`
const url = `/api/global/auth/${tenantId}/datasource/google?appId=${appId}`
if (samePage) {
window.location = url
} 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="Cancel"
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,76 @@
<script>
import { Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { IntegrationTypes } from "constants/backend"
import GoogleAuthPrompt from "./GoogleAuthPrompt.svelte"
import TableImportSelection from "./TableImportSelection/index.svelte"
import DatasourceConfigEditor from "./DatasourceConfigEditor/index.svelte"
import { datasources } from "stores/backend"
import { createOnGoogleAuthStore } from "./stores/onGoogleAuth.js"
import { createDatasourceCreationStore } from "./stores/datasourceCreation.js"
import { configFromIntegration } from "stores/selectors"
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()
})
</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}
onDatasourceCreated={store.setDatasource}
/>
{: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

@ -22,6 +22,7 @@
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { API } from "api"
import { DatasourceFeature } from "@budibase/types"
import Spinner from "components/common/Spinner.svelte"
const querySchema = {
name: {},
@ -33,6 +34,7 @@
let isValid = true
let integration, baseDatasource, datasource
let queryList
let loading = false
$: baseDatasource = $datasources.selected
$: queryList = $queries.list.filter(
@ -65,9 +67,11 @@
}
const saveDatasource = async () => {
loading = true
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
loading = false
return false
}
}
@ -82,6 +86,8 @@
baseDatasource = cloneDeep(datasource)
} catch (err) {
notifications.error(`Error saving datasource: ${err}`)
} finally {
loading = false
}
}
@ -119,8 +125,17 @@
<Divider />
<div class="config-header">
<Heading size="S">Configuration</Heading>
<Button disabled={!changed || !isValid} cta on:click={saveDatasource}>
Save
<Button
disabled={!changed || !isValid || loading}
cta
on:click={saveDatasource}
>
<div class="save-button-content">
{#if loading}
<Spinner size="10">Save</Spinner>
{/if}
Save
</div>
</Button>
</div>
<IntegrationConfigForm
@ -216,4 +231,10 @@
flex-direction: column;
gap: var(--spacing-m);
}
.save-button-content {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style>

View File

@ -32,7 +32,7 @@
</header>
<Body size="M">
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>
</Layout>
<Divider />

View File

@ -1,18 +1,16 @@
<script>
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 {
IntegrationTypes,
DatasourceTypes,
DEFAULT_BB_DATASOURCE_ID,
} from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
tables,
datasources,
sortedIntegrations as integrations,
} from "stores/backend"
import { hasData } from "stores/selectors"
import { Icon, notifications, Heading, Body } from "@budibase/bbui"
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 IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons/index.js"
@ -20,19 +18,14 @@
let internalTableModal
let externalDatasourceModal
let integrations = []
let integration = null
let disabled = false
let promptUpload = false
$: hasData = $datasources.list.length > 1 || $tables.list.length > 1
$: hasDefaultData =
$datasources.list.findIndex(
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
) !== -1
let sampleDataLoading = false
let externalDatasourceLoading = false
$: disabled = sampleDataLoading || externalDatasourceLoading
const createSampleData = async () => {
disabled = true
sampleDataLoading = true
try {
await API.addSampleData($params.application)
@ -40,118 +33,22 @@
await datasources.fetch()
$goto("./table")
} catch (e) {
disabled = false
sampleDataLoading = false
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>
<Modal bind:this={internalTableModal}>
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
</Modal>
<CreateInternalTableModal bind:this={internalTableModal} />
<Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} />
{:else}
<DatasourceConfigModal {integration} />
{/if}
</Modal>
<CreateExternalDatasourceModal
bind:loading={externalDatasourceLoading}
bind:this={externalDatasourceModal}
/>
<div class="page">
<div class="closeButton">
{#if hasData}
{#if hasData($datasources, $tables)}
<Icon hoverable name="Close" on:click={$goto("./table")} />
{/if}
</div>
@ -172,7 +69,7 @@
<div class="options">
<DatasourceOption
on:click={handleInternalTable}
on:click={internalTableModal.show}
title="Create new table"
description="Non-relational"
{disabled}
@ -183,12 +80,12 @@
on:click={createSampleData}
title="Use sample data"
description="Non-relational"
disabled={disabled || hasDefaultData}
disabled={disabled || $datasources.hasDefaultData}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
<DatasourceOption
on:click={handleDataImport}
on:click={() => internalTableModal.show({ promptUpload: true })}
title="Upload data"
description="Non-relational"
{disabled}
@ -202,14 +99,17 @@
</div>
<div class="options">
{#each integrations as [key, value]}
{#each $integrations as integration}
<DatasourceOption
on:click={() => handleIntegrationSelect(key)}
title={value.friendlyName}
description={value.type}
on:click={() => externalDatasourceModal.show(integration)}
title={integration.friendlyName}
description={integration.type}
{disabled}
>
<IntegrationIcon integrationType={key} schema={value} />
<IntegrationIcon
integrationType={integration.name}
schema={integration}
/>
</DatasourceOption>
{/each}
</div>

View File

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

View File

@ -1,6 +1,6 @@
<script>
import { notifications } from "@budibase/bbui"
import { apps, templates, licensing, groups } from "stores/portal"
import { admin, apps, templates, licensing, groups } from "stores/portal"
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
@ -9,14 +9,18 @@
onMount(async () => {
try {
// Always load latest
await Promise.all([
licensing.init(),
templates.load(),
groups.actions.init(),
])
const promises = [licensing.init()]
if ($templates?.length === 0) {
if (!$admin.offlineMode) {
promises.push(templates.load())
}
promises.push(groups.actions.init())
// Always load latest
await Promise.all(promises)
if (!$admin.offlineMode && $templates?.length === 0) {
notifications.error("There was a problem loading quick start templates")
}

View File

@ -247,7 +247,7 @@
>
Create new app
</Button>
{#if $apps?.length > 0}
{#if $apps?.length > 0 && !$admin.offlineMode}
<Button
size="M"
secondary

View File

@ -0,0 +1,235 @@
<script>
import GoogleLogo from "./_logos/Google.svelte"
import { isEqual, cloneDeep } from "lodash/fp"
import {
Button,
Heading,
Divider,
Label,
notifications,
Layout,
Input,
Body,
Toggle,
Icon,
Helpers,
Link,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import { organisation, admin } from "stores/portal"
const ConfigTypes = {
Google: "google",
}
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
$: googleCallbackReadonly = $admin.cloud || !googleCallbackUrl
// Indicate to user that callback is based on platform url
// If there is an existing value, indicate that it may be removed to return to default behaviour
$: googleCallbackTooltip = $admin.cloud
? null
: googleCallbackReadonly
? "Visit the organisation page to update the platform URL"
: "Leave blank to use the default callback URL"
$: googleSheetsCallbackUrl = `${$organisation.platformUrl}/api/global/auth/datasource/google/callback`
$: GoogleConfigFields = {
Google: [
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: $organisation.googleCallbackUrl,
copyButton: true,
},
{
name: "sheetsURL",
label: "Sheets URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: googleSheetsCallbackUrl,
copyButton: true,
},
],
}
let google
const providers = { google }
// control the state of the save button depending on whether form has changed
let originalGoogleDoc
let googleSaveButtonDisabled
$: {
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
}
$: googleComplete = !!(
providers.google?.config?.clientID && providers.google?.config?.clientSecret
)
async function saveConfig(config) {
// Delete unsupported fields
delete config.createdAt
delete config.updatedAt
return API.saveConfig(config)
}
async function saveGoogle() {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
return
}
const google = providers.google
try {
const res = await saveConfig(google)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
const copyToClipboard = async value => {
await Helpers.copyToClipboard(value)
notifications.success("Copied")
}
onMount(async () => {
try {
await organisation.init()
} catch (error) {
notifications.error("Error getting org config")
}
// Fetch Google config
let googleDoc
try {
googleDoc = await API.getConfig(ConfigTypes.Google)
} catch (error) {
notifications.error("Error fetching Google OAuth config")
}
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: false },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
// Default activated to true for older configs
if (googleDoc.config.activated === undefined) {
googleDoc.config.activated = true
}
originalGoogleDoc = cloneDeep(googleDoc)
providers.google = googleDoc
}
googleCallbackUrl = providers?.google?.config?.callbackURL
})
</script>
{#if providers.google}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div class="provider-title">
<GoogleLogo />
<span>Google</span>
</div>
</Heading>
<Body size="S">
To allow users to authenticate using their Google accounts, fill out the
fields below. Read the <Link
size="M"
href={"https://docs.budibase.com/docs/sso-with-google"}
>documentation</Link
> for more information.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each GoogleConfigFields.Google as field}
<div class="form-row">
<Label size="L" tooltip={field.tooltip}>{field.label}</Label>
<div class="inputContainer">
<div class="input">
<Input
bind:value={providers.google.config[field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div>
{#if field.copyButton}
<div
class="copy"
on:click={() => copyToClipboard(field.placeholder)}
>
<Icon size="S" name="Copy" />
</div>
{/if}
</div>
</div>
{/each}
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle text="" bind:value={providers.google.config.activated} />
</div>
</Layout>
<div>
<Button
disabled={googleSaveButtonDisabled}
cta
on:click={() => saveGoogle()}
>
Save
</Button>
</div>
{/if}
<style>
.form-row {
display: grid;
grid-template-columns: 120px 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.provider-title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--spacing-m);
}
.provider-title span {
flex: 1 1 auto;
}
.inputContainer {
display: flex;
flex-direction: row;
}
.input {
flex: 1;
}
.copy {
display: flex;
align-items: center;
margin-left: 10px;
}
</style>

View File

@ -1,5 +1,4 @@
<script>
import GoogleLogo from "./_logos/Google.svelte"
import OidcLogo from "./_logos/OIDC.svelte"
import MicrosoftLogo from "assets/microsoft-logo.png"
import Auth0Logo from "assets/auth0-logo.png"
@ -28,9 +27,9 @@
import { API } from "api"
import { organisation, admin, licensing } from "stores/portal"
import Scim from "./scim.svelte"
import Google from "./google.svelte"
const ConfigTypes = {
Google: "google",
OIDC: "oidc",
}
@ -38,43 +37,6 @@
$: enforcedSSO = $organisation.isSSOEnforced
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
$: googleCallbackReadonly = $admin.cloud || !googleCallbackUrl
// Indicate to user that callback is based on platform url
// If there is an existing value, indicate that it may be removed to return to default behaviour
$: googleCallbackTooltip = $admin.cloud
? null
: googleCallbackReadonly
? "Visit the organisation page to update the platform URL"
: "Leave blank to use the default callback URL"
$: googleSheetsCallbackUrl = `${$organisation.platformUrl}/api/global/auth/datasource/google/callback`
$: GoogleConfigFields = {
Google: [
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: $organisation.googleCallbackUrl,
copyButton: true,
},
{
name: "sheetsURL",
label: "Sheets URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: googleSheetsCallbackUrl,
copyButton: true,
},
],
}
$: OIDCConfigFields = {
Oidc: [
{ name: "configUrl", label: "Config URL" },
@ -133,15 +95,9 @@
const providers = { google, oidc }
// control the state of the save button depending on whether form has changed
let originalGoogleDoc
let originalOidcDoc
let googleSaveButtonDisabled
let oidcSaveButtonDisabled
$: {
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
// delete the callback url which is never saved to the oidc
// config doc, to ensure an accurate comparison
delete providers.oidc?.config.configs[0].callbackURL
@ -151,10 +107,6 @@
: (oidcSaveButtonDisabled = false)
}
$: googleComplete = !!(
providers.google?.config?.clientID && providers.google?.config?.clientSecret
)
$: oidcComplete = !!(
providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID &&
@ -230,30 +182,6 @@
originalOidcDoc = cloneDeep(providers.oidc)
}
async function saveGoogle() {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
return
}
const google = providers.google
try {
const res = await saveConfig(google)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
let defaultScopes = ["profile", "email", "offline_access"]
const refreshScopes = idx => {
@ -281,29 +209,6 @@
notifications.error("Error getting org config")
}
// Fetch Google config
let googleDoc
try {
googleDoc = await API.getConfig(ConfigTypes.Google)
} catch (error) {
notifications.error("Error fetching Google OAuth config")
}
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: false },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
// Default activated to true for older configs
if (googleDoc.config.activated === undefined) {
googleDoc.config.activated = true
}
originalGoogleDoc = cloneDeep(googleDoc)
providers.google = googleDoc
}
googleCallbackUrl = providers?.google?.config?.callbackURL
// Get the list of user uploaded logos and push it to the dropdown options.
// This needs to be done before the config call so they're available when
// the dropdown renders.
@ -395,62 +300,7 @@
> before enabling this feature.
</Body>
</Layout>
{#if providers.google}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div class="provider-title">
<GoogleLogo />
<span>Google</span>
</div>
</Heading>
<Body size="S">
To allow users to authenticate using their Google accounts, fill out the
fields below. Read the <Link
size="M"
href={"https://docs.budibase.com/docs/sso-with-google"}
>documentation</Link
> for more information.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each GoogleConfigFields.Google as field}
<div class="form-row">
<Label size="L" tooltip={field.tooltip}>{field.label}</Label>
<div class="inputContainer">
<div class="input">
<Input
bind:value={providers.google.config[field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div>
{#if field.copyButton}
<div
class="copy"
on:click={() => copyToClipboard(field.placeholder)}
>
<Icon size="S" name="Copy" />
</div>
{/if}
</div>
</div>
{/each}
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle text="" bind:value={providers.google.config.activated} />
</div>
</Layout>
<div>
<Button
disabled={googleSaveButtonDisabled}
cta
on:click={() => saveGoogle()}
>
Save
</Button>
</div>
{/if}
<Google />
{#if providers.oidc}
<Divider />
<Layout gap="XS" noPadding>

View File

@ -0,0 +1,14 @@
<script>
export let value
</script>
<div style="display: flex; ">
{#if value === "Unavailable"}
Email already in use. Please use a different email.
{:else}
{value}
{/if}
</div>
<style>
</style>

View File

@ -1,6 +1,7 @@
<script>
import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte"
import InviteResponseRenderer from "./InviteResponseRenderer.svelte"
export let inviteUsersResponse
@ -50,7 +51,7 @@
}
</script>
<ModalContent size="M" showCancelButton={false} {title} confirmText="Done">
<ModalContent size="L" showCancelButton={false} {title} confirmText="Done">
{#if hasSuccess}
<Body size="XS">
Your users should now receive an email invite to get access to their
@ -67,6 +68,9 @@
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[
{ column: "reason", component: InviteResponseRenderer },
]}
/>
{/if}
</ModalContent>

View File

@ -373,7 +373,7 @@
<OnboardingTypeModal {chooseCreationType} />
</Modal>
<Modal bind:this={passwordModal}>
<Modal bind:this={passwordModal} disableCancel={true}>
<PasswordModal
createUsersResponse={bulkSaveResponse}
userData={userData.users}

View File

@ -1,6 +1,21 @@
import { writable, derived, get } from "svelte/store"
import { IntegrationTypes, DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
import { queries, tables } from "./"
import { API } from "api"
import { DatasourceFeature } from "@budibase/types"
import { notifications } from "@budibase/bbui"
export class ImportTableError extends Error {
constructor(message) {
super(message)
const [title, description] = message.split(" - ")
this.name = "TableSelectionError"
// Capitalize the first character of both the title and description
this.title = title[0].toUpperCase() + title.substr(1)
this.description = description[0].toUpperCase() + description.substr(1)
}
}
export function createDatasourcesStore() {
const store = writable({
@ -8,9 +23,13 @@ export function createDatasourcesStore() {
selectedDatasourceId: null,
schemaError: null,
})
const derivedStore = derived(store, $store => ({
...$store,
selected: $store.list?.find(ds => ds._id === $store.selectedDatasourceId),
hasDefaultData: $store.list.some(
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
),
}))
const fetch = async () => {
@ -50,23 +69,62 @@ export function createDatasourcesStore() {
}
const updateSchema = async (datasource, tablesFilter) => {
const response = await API.buildDatasourceSchema({
datasourceId: datasource?._id,
tablesFilter,
})
return updateDatasource(response)
try {
const response = await API.buildDatasourceSchema({
datasourceId: datasource?._id,
tablesFilter,
})
updateDatasource(response)
} catch (e) {
// buildDatasourceSchema call returns user presentable errors with two parts divided with a " - ".
if (e.message.split(" - ").length === 2) {
throw new ImportTableError(e.message)
} else {
throw e
}
}
}
const save = async (body, fetchSchema = false) => {
let response
if (body._id) {
response = await API.updateDatasource(body)
} else {
response = await API.createDatasource({
datasource: body,
fetchSchema,
const sourceCount = source => {
return get(store).list.filter(datasource => datasource.source === source)
.length
}
const create = async ({ integration, fields }) => {
try {
const datasource = {
type: "datasource",
source: integration.name,
config: fields,
name: `${integration.friendlyName}-${
sourceCount(integration.name) + 1
}`,
plus: integration.plus && integration.name !== IntegrationTypes.REST,
}
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const { connected } = await API.validateDatasource(datasource)
if (!connected) throw new Error("Unable to connect")
}
const response = await API.createDatasource({
datasource,
fetchSchema:
integration.plus &&
integration.name !== IntegrationTypes.GOOGLE_SHEETS,
})
notifications.success("Datasource created successfully.")
return updateDatasource(response)
} catch (e) {
notifications.error(`Error creating datasource: ${e.message}`)
throw e
}
}
const save = async body => {
const response = await API.updateDatasource(body)
return updateDatasource(response)
}
@ -128,16 +186,23 @@ export function createDatasourcesStore() {
}
}
const getTableNames = async datasource => {
const info = await API.fetchInfoForDatasource(datasource)
return info.tableNames || []
}
return {
subscribe: derivedStore.subscribe,
fetch,
init: fetch,
select,
updateSchema,
create,
save,
delete: deleteDatasource,
removeSchemaError,
replaceDatasource,
getTableNames,
}
}

View File

@ -3,7 +3,8 @@ export { tables } from "./tables"
export { views } from "./views"
export { permissions } from "./permissions"
export { roles } from "./roles"
export { datasources } from "./datasources"
export { datasources, ImportTableError } from "./datasources"
export { integrations } from "./integrations"
export { sortedIntegrations } from "./sortedIntegrations"
export { queries } from "./queries"
export { flags } from "./flags"

View File

@ -2,14 +2,16 @@ import { writable } from "svelte/store"
import { API } from "api"
const createIntegrationsStore = () => {
const store = writable(null)
const store = writable({})
const init = async () => {
const integrations = await API.getIntegrations()
store.set(integrations)
}
return {
...store,
init: async () => {
const integrations = await API.getIntegrations()
store.set(integrations)
},
init,
}
}

View File

@ -0,0 +1,39 @@
import { integrations } from "./integrations"
import { derived } from "svelte/store"
import { DatasourceTypes } from "constants/backend"
const getIntegrationOrder = type => {
if (type === DatasourceTypes.API) return 1
if (type === DatasourceTypes.RELATIONAL) return 2
if (type === DatasourceTypes.NON_RELATIONAL) return 3
// Sort all others arbitrarily by the first character of their name.
// Character codes can technically be as low as 0, so make sure the number is at least 4
return type.charCodeAt(0) + 4
}
export const createSortedIntegrationsStore = () => {
return derived(integrations, $integrations => {
const integrationsAsArray = Object.entries($integrations).map(
([name, integration]) => ({
name,
...integration,
})
)
return integrationsAsArray.sort((integrationA, integrationB) => {
const integrationASortOrder = getIntegrationOrder(integrationA.type)
const integrationBSortOrder = getIntegrationOrder(integrationB.type)
if (integrationASortOrder === integrationBSortOrder) {
return integrationA.friendlyName.localeCompare(
integrationB.friendlyName
)
}
return integrationASortOrder < integrationBSortOrder ? -1 : 1
})
})
}
export const sortedIntegrations = createSortedIntegrationsStore()

View File

@ -0,0 +1,127 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { createSortedIntegrationsStore } from "./sortedIntegrations"
import { DatasourceTypes } from "constants/backend"
import { derived } from "svelte/store"
import { integrations } from "stores/backend/integrations"
vi.mock("svelte/store", () => ({
derived: vi.fn(() => {}),
}))
vi.mock("stores/backend/integrations", () => ({ integrations: vi.fn() }))
const inputA = {
nonRelationalA: {
friendlyName: "non-relational A",
type: DatasourceTypes.NON_RELATIONAL,
},
relationalB: {
friendlyName: "relational B",
type: DatasourceTypes.RELATIONAL,
},
relationalA: {
friendlyName: "relational A",
type: DatasourceTypes.RELATIONAL,
},
api: {
friendlyName: "api",
type: DatasourceTypes.API,
},
relationalC: {
friendlyName: "relational C",
type: DatasourceTypes.RELATIONAL,
},
nonRelationalB: {
friendlyName: "non-relational B",
type: DatasourceTypes.NON_RELATIONAL,
},
otherC: {
friendlyName: "other C",
type: "random",
},
otherB: {
friendlyName: "other B",
type: "arbitrary",
},
otherA: {
friendlyName: "other A",
type: "arbitrary",
},
}
const inputB = Object.fromEntries(Object.entries(inputA).reverse())
const expectedOutput = [
{
name: "api",
friendlyName: "api",
type: DatasourceTypes.API,
},
{
name: "relationalA",
friendlyName: "relational A",
type: DatasourceTypes.RELATIONAL,
},
{
name: "relationalB",
friendlyName: "relational B",
type: DatasourceTypes.RELATIONAL,
},
{
name: "relationalC",
friendlyName: "relational C",
type: DatasourceTypes.RELATIONAL,
},
{
name: "nonRelationalA",
friendlyName: "non-relational A",
type: DatasourceTypes.NON_RELATIONAL,
},
{
name: "nonRelationalB",
friendlyName: "non-relational B",
type: DatasourceTypes.NON_RELATIONAL,
},
{
name: "otherA",
friendlyName: "other A",
type: "arbitrary",
},
{
name: "otherB",
friendlyName: "other B",
type: "arbitrary",
},
{
name: "otherC",
friendlyName: "other C",
type: "random",
},
]
describe("sorted integrations store", () => {
beforeEach(ctx => {
vi.clearAllMocks()
ctx.returnedStore = createSortedIntegrationsStore()
ctx.derivedCallback = derived.mock.calls[0][1]
})
it("calls derived with the correct parameters", () => {
expect(derived).toHaveBeenCalledTimes(1)
expect(derived).toHaveBeenCalledWith(integrations, expect.toBeFunc())
})
describe("derived callback", () => {
it("When no integrations are loaded", ctx => {
expect(ctx.derivedCallback({})).toEqual([])
})
it("When integrations are present", ctx => {
expect(ctx.derivedCallback(inputA)).toEqual(expectedOutput)
expect(ctx.derivedCallback(inputB)).toEqual(expectedOutput)
})
})
})

View File

@ -46,6 +46,7 @@ export function createAdminStore() {
store.accountPortalUrl = environment.accountPortalUrl
store.isDev = environment.isDev
store.baseUrl = environment.baseUrl
store.offlineMode = environment.offlineMode
return store
})
}

View File

@ -0,0 +1,35 @@
import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
import { DatasourceFeature } from "@budibase/types"
export const integrationForDatasource = (integrations, datasource) => ({
name: datasource.source,
...integrations[datasource.source],
})
export const hasData = (datasources, tables) =>
datasources.list.length > 1 || tables.list.length > 1
export const hasDefaultData = datasources =>
datasources.list.some(
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
)
export const configFromIntegration = integration => {
const config = {}
Object.entries(integration?.datasource || {}).forEach(([key, properties]) => {
if (properties.type === "fieldGroup") {
Object.keys(properties.fields).forEach(fieldKey => {
config[fieldKey] = null
})
} else {
config[key] = properties.default ?? null
}
})
return config
}
export const shouldIntegrationFetchTableNames = integration => {
return integration.features?.[DatasourceFeature.FETCH_TABLE_NAMES]
}

View File

@ -0,0 +1,56 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
import { integrationForDatasource, hasData, hasDefaultData } from "./selectors"
describe("selectors", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("integrationForDatasource", () => {
it("returns the integration corresponding to the given datasource", () => {
expect(
integrationForDatasource(
{ integrationOne: { some: "data" } },
{ source: "integrationOne" }
)
).toEqual({ some: "data", name: "integrationOne" })
})
})
describe("hasData", () => {
describe("when the user has created a datasource in addition to the premade Budibase DB source", () => {
it("returns true", () => {
expect(hasData({ list: [1, 1] }, { list: [] })).toBe(true)
})
})
describe("when the user has created a table in addition to the premade users table", () => {
it("returns true", () => {
expect(hasData({ list: [] }, { list: [1, 1] })).toBe(true)
})
})
describe("when the user doesn't have data", () => {
it("returns false", () => {
expect(hasData({ list: [] }, { list: [] })).toBe(false)
})
})
})
describe("hasDefaultData", () => {
describe("when the user has default data", () => {
it("returns true", () => {
expect(
hasDefaultData({ list: [{ _id: DEFAULT_BB_DATASOURCE_ID }] })
).toBe(true)
})
})
describe("when the user doesn't have default data", () => {
it("returns false", () => {
expect(hasDefaultData({ list: [{ _id: "some other id" }] })).toBe(false)
})
})
})
})

View File

@ -12,9 +12,31 @@ const ignoredWarnings = [
"a11y-click-events-have-key-events",
]
const copyFonts = dest =>
viteStaticCopy({
targets: [
{
src: "../../node_modules/@fontsource/source-sans-pro",
dest,
},
{
src: "../../node_modules/remixicon/fonts/*",
dest,
},
],
})
export default defineConfig(({ mode }) => {
const isProduction = mode === "production"
const env = loadEnv(mode, process.cwd())
// Plugins to only run in dev
const devOnlyPlugins = [
// Copy fonts to an additional path so that svelte's automatic
// prefixing of the base URL path can still resolve assets
copyFonts("builder/fonts"),
]
return {
test: {
setupFiles: ["./vitest.setup.js"],
@ -60,18 +82,8 @@ export default defineConfig(({ mode }) => {
),
"process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN),
}),
viteStaticCopy({
targets: [
{
src: "../../node_modules/@fontsource/source-sans-pro",
dest: "fonts",
},
{
src: "../../node_modules/remixicon/fonts/*",
dest: "fonts",
},
],
}),
copyFonts("fonts"),
...(isProduction ? [] : devOnlyPlugins),
],
optimizeDeps: {
exclude: ["@roxi/routify"],

View File

@ -49,7 +49,7 @@
"pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5",
"tar": "6.1.11",
"tar": "6.1.15",
"yaml": "^2.1.1"
},
"devDependencies": {

View File

@ -2221,7 +2221,8 @@
"ValidateForm",
"ClearForm",
"ChangeFormStep",
"UpdateFieldValue"
"UpdateFieldValue",
"ScrollTo"
],
"styles": ["size"],
"size": {
@ -3543,7 +3544,8 @@
{
"type": "field/sortable",
"label": "Sort column",
"key": "sortColumn"
"key": "sortColumn",
"placeholder": "None"
},
{
"type": "select",
@ -4322,7 +4324,8 @@
{
"type": "field/sortable",
"label": "Sort by",
"key": "sortColumn"
"key": "sortColumn",
"placeholder": "None"
},
{
"type": "select",
@ -4566,7 +4569,8 @@
{
"type": "field/sortable",
"label": "Sort column",
"key": "sortColumn"
"key": "sortColumn",
"placeholder": "None"
},
{
"type": "select",
@ -4734,7 +4738,8 @@
{
"type": "field/sortable",
"label": "Sort column",
"key": "sortColumn"
"key": "sortColumn",
"placeholder": "None"
},
{
"type": "select",
@ -5225,5 +5230,91 @@
"type": "schema",
"suffix": "repeater"
}
},
"gridblock": {
"name": "Grid block",
"icon": "Table",
"styles": ["size"],
"size": {
"width": 600,
"height": 400
},
"info": "Grid Blocks are only compatible with internal or SQL tables",
"settings": [
{
"type": "table",
"label": "Table",
"key": "table",
"required": true
},
{
"type": "columns/basic",
"label": "Columns",
"key": "columns",
"dependsOn": "table"
},
{
"type": "filter",
"label": "Filtering",
"key": "initialFilter"
},
{
"type": "field/sortable",
"label": "Sort column",
"key": "initialSortColumn",
"placeholder": "Default"
},
{
"type": "select",
"label": "Sort order",
"key": "initialSortOrder",
"options": ["Ascending", "Descending"],
"defaultValue": "Ascending"
},
{
"type": "select",
"label": "Row height",
"key": "initialRowHeight",
"placeholder": "Default",
"options": [
{
"label": "Small",
"value": 36
},
{
"label": "Medium",
"value": 64
},
{
"label": "Large",
"value": 92
}
]
},
{
"type": "boolean",
"label": "Add rows",
"key": "allowAddRows",
"defaultValue": true
},
{
"type": "boolean",
"label": "Edit rows",
"key": "allowEditRows",
"defaultValue": true
},
{
"type": "boolean",
"label": "Delete rows",
"key": "allowDeleteRows",
"defaultValue": true
},
{
"type": "boolean",
"label": "High contrast",
"key": "stripeRows",
"defaultValue": false
}
]
}
}

View File

@ -1,7 +1,6 @@
import { createAPIClient } from "@budibase/frontend-core"
import { notificationStore } from "../stores/notification.js"
import { authStore } from "../stores/auth.js"
import { devToolsStore } from "../stores/devTools.js"
import { notificationStore, devToolsEnabled, devToolsStore } from "../stores/"
import { get } from "svelte/store"
export const API = createAPIClient({
@ -25,9 +24,10 @@ export const API = createAPIClient({
}
// Add role header
const devToolsState = get(devToolsStore)
if (devToolsState.enabled && devToolsState.role) {
headers["x-budibase-role"] = devToolsState.role
const $devToolsStore = get(devToolsStore)
const $devToolsEnabled = get(devToolsEnabled)
if ($devToolsEnabled && $devToolsStore.role) {
headers["x-budibase-role"] = $devToolsStore.role
}
},
@ -35,7 +35,8 @@ export const API = createAPIClient({
// We could also log these to sentry.
// Or we could check error.status and redirect to login on a 403 etc.
onError: error => {
const { status, method, url, message, handled } = error || {}
const { status, method, url, message, handled, suppressErrors } =
error || {}
const ignoreErrorUrls = [
"bbtel",
"/api/global/self",
@ -49,7 +50,7 @@ export const API = createAPIClient({
}
// Notify all errors
if (message) {
if (message && !suppressErrors) {
// Don't notify if the URL contains the word analytics as it may be
// blocked by browser extensions
let ignore = false

View File

@ -17,6 +17,7 @@
appStore,
devToolsStore,
environmentStore,
devToolsEnabled,
} from "stores"
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
@ -47,10 +48,7 @@
let permissionError = false
// Determine if we should show devtools or not
$: showDevTools =
!$builderStore.inBuilder &&
$devToolsStore.enabled &&
!$routeStore.queryParams?.peek
$: showDevTools = $devToolsEnabled && !$routeStore.queryParams?.peek
// Handle no matching route
$: {
@ -107,6 +105,7 @@
lang="en"
dir="ltr"
class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}"
class:builder={$builderStore.inBuilder}
>
<DeviceBindingsProvider>
<UserBindingsProvider>
@ -223,12 +222,14 @@
overflow: hidden;
height: 100%;
width: 100%;
background: transparent;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
#spectrum-root.builder {
background: transparent;
}
#clip-root {
max-width: 100%;

View File

@ -0,0 +1,71 @@
<script>
// NOTE: this is not a block - it's just named as such to avoid confusing users,
// because it functions similarly to one
import { getContext } from "svelte"
import { Grid } from "@budibase/frontend-core"
export let table
export let allowAddRows = true
export let allowEditRows = true
export let allowDeleteRows = true
export let stripeRows = false
export let initialFilter = null
export let initialSortColumn = null
export let initialSortOrder = null
export let initialRowHeight = null
export let columns = null
const component = getContext("component")
const { styleable, API, builderStore } = getContext("sdk")
$: columnWhitelist = columns?.map(col => col.name)
$: schemaOverrides = getSchemaOverrides(columns)
const getSchemaOverrides = columns => {
let overrides = {}
columns?.forEach(column => {
overrides[column.name] = {
displayName: column.displayName || column.name,
}
})
return overrides
}
</script>
<div
use:styleable={$component.styles}
class:in-builder={$builderStore.inBuilder}
>
<Grid
tableId={table?.tableId}
{API}
{allowAddRows}
{allowEditRows}
{allowDeleteRows}
{stripeRows}
{initialFilter}
{initialSortColumn}
{initialSortOrder}
{initialRowHeight}
{columnWhitelist}
{schemaOverrides}
showControls={false}
allowExpandRows={false}
allowSchemaChanges={false}
/>
</div>
<style>
div {
display: flex;
flex-direction: column;
align-items: stretch;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
overflow: hidden;
min-height: 410px;
}
div.in-builder :global(*) {
pointer-events: none;
}
</style>

View File

@ -408,12 +408,20 @@
}
}
const handleScrollToField = ({ field }) => {
const fieldId = get(getField(field)).fieldState.fieldId
const label = document.querySelector(`label[for="${fieldId}"]`)
document.getElementById(fieldId).focus({ preventScroll: true })
label.scrollIntoView({ behavior: "smooth" })
}
// Action context to pass to children
const actions = [
{ type: ActionTypes.ValidateForm, callback: formApi.validate },
{ type: ActionTypes.ClearForm, callback: formApi.reset },
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
{ type: ActionTypes.UpdateFieldValue, callback: handleUpdateFieldValue },
{ type: ActionTypes.ScrollTo, callback: handleScrollToField },
]
</script>

View File

@ -36,6 +36,7 @@ export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte"
export { default as sidepanel } from "./SidePanel.svelte"
export { default as gridblock } from "./GridBlock.svelte"
export * from "./charts"
export * from "./forms"
export * from "./table"

View File

@ -1,5 +1,5 @@
<script>
import { Heading, Button, Select } from "@budibase/bbui"
import { Heading, Select, ActionButton } from "@budibase/bbui"
import { devToolsStore } from "../../stores"
import { getContext } from "svelte"
@ -30,7 +30,7 @@
</script>
<div class="dev-preview-header" class:mobile={$context.device.mobile}>
<Heading size="XS">Budibase App Preview</Heading>
<Heading size="XS">Preview</Heading>
<Select
quiet
options={previewOptions}
@ -40,36 +40,57 @@
on:change={e => devToolsStore.actions.changeRole(e.detail)}
/>
{#if !$context.device.mobile}
<Button
<ActionButton
quiet
overBackground
icon="Code"
on:click={() => devToolsStore.actions.setVisible(!$devToolsStore.visible)}
>
{$devToolsStore.visible ? "Close" : "Open"} DevTools
</Button>
</ActionButton>
{/if}
<ActionButton
quiet
icon="Close"
on:click={() => window.parent.closePreview?.()}
>
Close preview
</ActionButton>
</div>
<style>
.dev-preview-header {
flex: 0 0 50px;
height: 50px;
flex: 0 0 60px;
display: grid;
align-items: center;
background-color: var(--spectrum-global-color-blue-400);
background-color: black;
padding: 0 var(--spacing-xl);
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto auto auto;
grid-gap: var(--spacing-xl);
}
.dev-preview-header.mobile {
flex: 0 0 50px;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
}
.dev-preview-header :global(.spectrum-Heading),
.dev-preview-header :global(.spectrum-Picker-menuIcon),
.dev-preview-header :global(.spectrum-Picker-label) {
color: white !important;
.dev-preview-header :global(.spectrum-Icon),
.dev-preview-header :global(.spectrum-Picker-label),
.dev-preview-header :global(.spectrum-ActionButton) {
font-weight: 600;
color: white;
}
.dev-preview-header :global(.spectrum-Picker) {
padding-left: 8px;
padding-right: 8px;
transition: background 130ms ease-out;
border-radius: 4px;
}
.dev-preview-header :global(.spectrum-ActionButton:hover),
.dev-preview-header :global(.spectrum-Picker:hover),
.dev-preview-header :global(.spectrum-Picker.is-open) {
background: rgba(255, 255, 255, 0.1);
}
.dev-preview-header :global(.spectrum-ActionButton:active) {
background: rgba(255, 255, 255, 0.2);
}
@media print {
.dev-preview-header {

View File

@ -6,7 +6,7 @@
<div class="notifications">
{#if $notificationStore}
{#each $notificationStore as { type, icon, message, id, dismissable } (id)}
{#each $notificationStore as { type, icon, message, id, dismissable, count } (id)}
<div
in:fly={{
duration: 300,
@ -17,7 +17,7 @@
>
<Notification
{type}
{message}
message={count > 1 ? `(${count}) ${message}` : message}
{icon}
{dismissable}
on:dismiss={() => notificationStore.actions.dismiss(id)}

View File

@ -29,6 +29,7 @@ export const ActionTypes = {
SetDataProviderSorting: "SetDataProviderSorting",
ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep",
ScrollTo: "ScrollTo",
}
export const DNDPlaceholderID = "dnd-placeholder"

View File

@ -2,7 +2,6 @@ import ClientApp from "./components/ClientApp.svelte"
import {
builderStore,
appStore,
devToolsStore,
blockStore,
componentStore,
environmentStore,
@ -51,11 +50,6 @@ const loadBudibase = async () => {
await environmentStore.actions.fetchEnvironment()
}
// Enable dev tools or not. We need to be using a dev app and not inside
// the builder preview to enable them.
const enableDevTools = !get(builderStore).inBuilder && get(appStore).isDevApp
devToolsStore.actions.setEnabled(enableDevTools)
// Register handler for runtime events from the builder
window.handleBuilderRuntimeEvent = (type, data) => {
if (!window["##BUDIBASE_IN_BUILDER##"]) {

View File

@ -2,13 +2,14 @@ import { derived } from "svelte/store"
import { Constants } from "@budibase/frontend-core"
import { devToolsStore } from "../devTools.js"
import { authStore } from "../auth.js"
import { devToolsEnabled } from "./devToolsEnabled.js"
// Derive the current role of the logged-in user
export const currentRole = derived(
[devToolsStore, authStore],
([$devToolsStore, $authStore]) => {
[devToolsEnabled, devToolsStore, authStore],
([$devToolsEnabled, $devToolsStore, $authStore]) => {
return (
($devToolsStore.enabled && $devToolsStore.role) ||
($devToolsEnabled && $devToolsStore.role) ||
$authStore?.roleId ||
Constants.Roles.PUBLIC
)

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