Merge branch 'develop' of github.com:Budibase/budibase into test/qa-18-api-automation-testing-permissionsuser-settings

This commit is contained in:
Michael Drury 2023-07-03 10:31:25 +01:00
commit 223e0e555a
340 changed files with 8530 additions and 11453 deletions

View File

@ -10,4 +10,4 @@ packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/builder/cypress/reports
packages/sdk/sdk
packages/sdk/sdk

View File

@ -16,8 +16,7 @@
"dist",
"public",
"*.spec.js",
"bundle.js",
"packages/pro"
"bundle.js"
],
"plugins": ["svelte3"],
"extends": ["eslint:recommended"],
@ -30,9 +29,7 @@
"files": ["**/*.ts"],
"parser": "@typescript-eslint/parser",
"plugins": [],
"extends": [
"eslint:recommended"
],
"extends": ["eslint:recommended"],
"rules": {
"no-unused-vars": "off",
"no-inner-declarations": "off",

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
@ -22,7 +26,16 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -34,10 +47,16 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -52,10 +71,16 @@ jobs:
test-libraries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -72,10 +97,16 @@ jobs:
test-services:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -91,11 +122,14 @@ jobs:
test-pro:
runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name
steps:
- uses: actions/checkout@v3
- name: Checkout repo and submodules
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:
@ -107,10 +141,16 @@ jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only
uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -129,21 +169,46 @@ jobs:
check-pro-submodule:
runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name
steps:
- name: Checkout code
- name: Checkout repo and submodules
uses: actions/checkout@v3
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

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

View File

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

View File

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

View File

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

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,21 +1,10 @@
{
"version": "2.7.20-alpha.2",
"version": "2.7.37-alpha.3",
"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/*"
],
"useNx": true,
"command": {
"publish": {
"ignoreChanges": [

View File

@ -2,28 +2,26 @@
"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.1",
"lerna": "7.0.2",
"madge": "^6.0.0",
"minimist": "^1.2.8",
"nx": "^16.3.2",
"prettier": "^2.3.1",
"prettier": "2.8.8",
"prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0",
"semver": "^7.5.0",
"svelte": "^3.38.2",
"typescript": "4.7.3"
},
@ -48,9 +46,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 +65,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 +94,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

@ -5,6 +5,7 @@ import {
GoogleInnerConfig,
OIDCConfig,
OIDCInnerConfig,
OIDCLogosConfig,
SCIMConfig,
SCIMInnerConfig,
SettingsConfig,
@ -191,6 +192,10 @@ export function getDefaultGoogleConfig(): GoogleInnerConfig | undefined {
// OIDC
export async function getOIDCLogosDoc(): Promise<OIDCLogosConfig | undefined> {
return getConfig<OIDCLogosConfig>(ConfigType.OIDC_LOGOS)
}
async function getOIDCConfigDoc(): Promise<OIDCConfig | undefined> {
return getConfig<OIDCConfig>(ConfigType.OIDC)
}

View File

@ -57,6 +57,9 @@ class Replication {
appReplicateOpts() {
return {
filter: (doc: any) => {
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
return false
}
return doc._id !== DocumentType.APP_METADATA
},
}

View File

@ -81,8 +81,19 @@ export function generateAppUserID(prodAppId: string, userId: string) {
* Generates a new role ID.
* @returns {string} The new role ID which the role doc can be stored under.
*/
export function generateRoleID(id?: any) {
return `${DocumentType.ROLE}${SEPARATOR}${id || newid()}`
export function generateRoleID(name: string) {
const prefix = `${DocumentType.ROLE}${SEPARATOR}`
if (name.startsWith(prefix)) {
return name
}
return `${prefix}${name}`
}
/**
* Utility function to be more verbose.
*/
export function prefixRoleID(name: string) {
return generateRoleID(name)
}
/**

View File

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

View File

@ -1,5 +1,5 @@
import { BuiltinPermissionID, PermissionLevel } from "./permissions"
import { generateRoleID, getRoleParams, DocumentType, SEPARATOR } from "../db"
import { prefixRoleID, getRoleParams, DocumentType, SEPARATOR } from "../db"
import { getAppDB } from "../context"
import { doWithDB } from "../db"
import { Screen, Role as RoleDoc } from "@budibase/types"
@ -25,18 +25,28 @@ const EXTERNAL_BUILTIN_ROLE_IDS = [
BUILTIN_IDS.PUBLIC,
]
export const RoleIDVersion = {
// original version, with a UUID based ID
UUID: undefined,
// new version - with name based ID
NAME: "name",
}
export class Role implements RoleDoc {
_id: string
_rev?: string
name: string
permissionId: string
inherits?: string
version?: string
permissions = {}
constructor(id: string, name: string, permissionId: string) {
this._id = id
this.name = name
this.permissionId = permissionId
// version for managing the ID - removing the role_ when responding
this.version = RoleIDVersion.NAME
}
addInheritance(inherits: string) {
@ -157,13 +167,16 @@ export async function getRole(
role = cloneDeep(
Object.values(BUILTIN_ROLES).find(role => role._id === roleId)
)
} else {
// make sure has the prefix (if it has it then it won't be added)
roleId = prefixRoleID(roleId)
}
try {
const db = getAppDB()
const dbRole = await db.get(getDBRoleID(roleId))
role = Object.assign(role, dbRole)
// finalise the ID
role._id = getExternalRoleID(role._id)
role._id = getExternalRoleID(role._id, role.version)
} catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
@ -261,6 +274,9 @@ export async function getAllRoles(appId?: string) {
})
)
roles = body.rows.map((row: any) => row.doc)
roles.forEach(
role => (role._id = getExternalRoleID(role._id!, role.version))
)
}
const builtinRoles = getBuiltinRoles()
@ -268,14 +284,15 @@ export async function getAllRoles(appId?: string) {
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
const builtinRole = builtinRoles[builtinRoleId]
const dbBuiltin = roles.filter(
dbRole => getExternalRoleID(dbRole._id) === builtinRoleId
dbRole =>
getExternalRoleID(dbRole._id!, dbRole.version) === builtinRoleId
)[0]
if (dbBuiltin == null) {
roles.push(builtinRole || builtinRoles.BASIC)
} else {
// remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id!, dbBuiltin.version)
roles.push(Object.assign(builtinRole, dbBuiltin))
}
}
@ -381,19 +398,22 @@ export class AccessController {
/**
* Adds the "role_" for builtin role IDs which are to be written to the DB (for permissions).
*/
export function getDBRoleID(roleId?: string) {
if (roleId?.startsWith(DocumentType.ROLE)) {
return roleId
export function getDBRoleID(roleName: string) {
if (roleName?.startsWith(DocumentType.ROLE)) {
return roleName
}
return generateRoleID(roleId)
return prefixRoleID(roleName)
}
/**
* Remove the "role_" from builtin role IDs that have been written to the DB (for permissions).
*/
export function getExternalRoleID(roleId?: string) {
export function getExternalRoleID(roleId: string, version?: string) {
// for built-in roles we want to remove the DB role ID element (role_)
if (roleId?.startsWith(DocumentType.ROLE) && isBuiltin(roleId)) {
if (
(roleId.startsWith(DocumentType.ROLE) && isBuiltin(roleId)) ||
version === RoleIDVersion.NAME
) {
return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1]
}
return roleId

View File

@ -8,11 +8,10 @@
export let showSelectAll = true
export let selectAllText = "Select all"
let selectedBooleans = reset()
let selectedBooleans = options.map(x => selected.indexOf(x) > -1)
const dispatch = createEventDispatcher()
$: updateSelected(selectedBooleans)
$: dispatch("change", selected)
$: allSelected = selected?.length === options.length
$: noneSelected = !selected?.length
@ -28,6 +27,7 @@
}
}
selected = array
dispatch("change", selected)
}
function toggleSelectAll() {
@ -36,6 +36,7 @@
} else {
selectedBooleans = reset()
}
dispatch("change", selected)
}
</script>

View File

@ -71,6 +71,7 @@
timeOnly,
enableTime,
time24hr,
disabled,
}
const handleChange = event => {

View File

@ -99,9 +99,15 @@
bind:this={button}
>
{#if fieldIcon}
<span class="option-extra icon">
<Icon size="S" name={fieldIcon} />
</span>
{#if !useOptionIconImage}
<span class="option-extra icon">
<Icon size="S" name={fieldIcon} />
</span>
{:else}
<span class="option-extra icon field-icon">
<img src={fieldIcon} alt="icon" width="15" height="15" />
</span>
{/if}
{/if}
{#if fieldColour}
<span class="option-extra">
@ -311,4 +317,8 @@
max-width: 170px;
font-size: 12px;
}
.option-extra.icon.field-icon {
display: flex;
}
</style>

View File

@ -87,7 +87,7 @@
border-color: var(--spectrum-global-color-gray-400);
}
/* Toolbar button color */
:global(.EasyMDEContainer .editor-toolbar button i) {
:global(.EasyMDEContainer .editor-toolbar button) {
color: var(--spectrum-global-color-gray-800);
}
/* Separator between toolbar buttons*/

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

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

@ -61,6 +61,9 @@ const INITIAL_FRONTEND_STATE = {
showNotificationAction: false,
sidePanel: false,
},
features: {
componentValidation: false,
},
errors: [],
hasAppPackage: false,
libraries: null,
@ -117,10 +120,13 @@ export const getFrontendStore = () => {
reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
websocket = null
},
initialise: async pkg => {
const { layouts, screens, application, clientLibPath, hasLock } = pkg
websocket = createBuilderWebsocket(application.appId)
if (!websocket) {
websocket = createBuilderWebsocket(application.appId)
}
await store.actions.components.refreshDefinitions(application.appId)
// Reset store state
@ -145,6 +151,11 @@ export const getFrontendStore = () => {
navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [],
hasLock,
features: {
...INITIAL_FRONTEND_STATE.features,
...application.features,
},
icon: application.icon || {},
initialised: true,
}))
screenHistoryStore.reset()
@ -225,6 +236,7 @@ export const getFrontendStore = () => {
legalDirectChildren = []
) => {
const type = component._component
if (illegalChildren.includes(type)) {
return type
}
@ -238,10 +250,13 @@ export const getFrontendStore = () => {
return
}
if (type === "@budibase/standard-components/sidepanel") {
illegalChildren = []
}
const definition = store.actions.components.getDefinition(
component._component
)
// Reset whitelist for direct children
legalDirectChildren = []
if (definition?.legalDirectChildren?.length) {
@ -280,9 +295,12 @@ export const getFrontendStore = () => {
}
},
save: async screen => {
// Validate screen structure
// Temporarily disabled to accommodate migration issues
// store.actions.screens.validate(screen)
const state = get(store)
// Validate screen structure if the app supports it
if (state.features?.componentValidation) {
store.actions.screens.validate(screen)
}
// Check screen definition for any component settings which need updated
store.actions.screens.enrichEmptySettings(screen)
@ -293,7 +311,6 @@ export const getFrontendStore = () => {
const routesResponse = await API.fetchAppRoutes()
// If plugins changed we need to fetch the latest app metadata
const state = get(store)
let usedPlugins = state.usedPlugins
if (savedScreen.pluginAdded) {
const { application } = await API.fetchAppPackage(state.appId)

View File

@ -78,9 +78,6 @@
}
async function removeLooping() {
let loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
try {
await automationStore.actions.deleteAutomationBlock(loopBlock)
} catch (error) {
@ -89,10 +86,6 @@
}
async function deleteStep() {
let loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
try {
if (loopBlock) {
await automationStore.actions.deleteAutomationBlock(loopBlock)
@ -168,8 +161,8 @@
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
.properties
)}
block={loopBlock}
{webhookModal}
block={loopBlock}
/>
</Layout>
</div>
@ -191,7 +184,7 @@
{#if !isTrigger}
<div>
<div class="block-options">
{#if block?.features?.[Features.LOOPING] || !block.features}
{#if !loopBlock && (block?.features?.[Features.LOOPING] || !block.features)}
<ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping
</ActionButton>

View File

@ -32,7 +32,12 @@
<div slot="control" class="icon">
<Icon s hoverable name="MoreSmallList" />
</div>
<MenuItem icon="Duplicate" on:click={duplicateAutomation}>Duplicate</MenuItem>
<MenuItem
icon="Duplicate"
on:click={duplicateAutomation}
disabled={automation.definition.trigger.name === "Webhook"}
>Duplicate</MenuItem
>
<MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>

View File

@ -309,7 +309,7 @@
}
function canShowField(key, value) {
const dependsOn = value.dependsOn
const dependsOn = value?.dependsOn
return !dependsOn || !!inputData[dependsOn]
}
@ -333,7 +333,7 @@
: null}>{value.title || (key === "row" ? "Table" : key)}</Label
>
{/if}
{#if value.type === "string" && value.enum && canShowField(key)}
{#if value.type === "string" && value.enum && canShowField(key, value)}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}

View File

@ -1,5 +1,5 @@
<script>
import { tables } from "stores/backend"
import { datasources, tables } from "stores/backend"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core"
@ -26,19 +26,33 @@
$: id = $tables.selected?._id
$: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external"
const handleGridTableUpdate = async e => {
tables.replaceTable(id, e.detail)
// We need to refresh datasources when an external table changes.
// Type "external" may exist - sometimes type is "table" and sometimes it
// is "external" - it has different meanings in different endpoints.
// If we check both these then we hopefully catch all external tables.
if (e.detail?.type === "external" || e.detail?.sql) {
await datasources.fetch()
}
}
</script>
<div class="wrapper">
<Grid
{API}
tableId={id}
tableType={$tables.selected?.type}
allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
on:updatetable={e => tables.replaceTable(id, e.detail)}
on:updatetable={handleGridTableUpdate}
>
<svelte:fragment slot="filter">
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="controls">
{#if isInternal}
<GridCreateViewButton />
@ -53,7 +67,6 @@
<GridImportButton />
{/if}
<GridExportButton />
<GridFilterButton />
<GridAddColumnModal />
<GridEditColumnModal />
{#if isUsersTable}

View File

@ -1,6 +1,6 @@
<script>
import { ActionButton, Modal, notifications } from "@budibase/bbui"
import CreateEditRelationship from "../../Datasources/CreateEditRelationship.svelte"
import { ActionButton, notifications } from "@budibase/bbui"
import CreateEditRelationshipModal from "../../Datasources/CreateEditRelationshipModal.svelte"
import { datasources } from "../../../../stores/backend"
import { createEventDispatcher } from "svelte"
@ -8,9 +8,7 @@
const dispatch = createEventDispatcher()
$: datasource = findDatasource(table?._id)
$: plusTables = datasource?.plus
? Object.values(datasource?.entities || {})
: []
$: tables = datasource?.plus ? Object.values(datasource?.entities || {}) : []
let modal
@ -24,31 +22,32 @@
})
}
async function saveRelationship() {
try {
// Create datasource
await datasources.save(datasource)
notifications.success(`Relationship information saved.`)
dispatch("updatecolumns")
} catch (err) {
notifications.error(`Error saving relationship info: ${err}`)
}
const afterSave = ({ action }) => {
notifications.success(`Relationship ${action} successfully`)
dispatch("updatecolumns")
}
const onError = err => {
notifications.error(`Error saving relationship info: ${err}`)
}
</script>
{#if datasource}
<div>
<ActionButton icon="DataCorrelated" primary quiet on:click={modal.show}>
<ActionButton
icon="DataCorrelated"
primary
quiet
on:click={() => modal.show({ fromTable: table })}
>
Define relationship
</ActionButton>
</div>
<Modal bind:this={modal}>
<CreateEditRelationship
{datasource}
save={saveRelationship}
close={modal.hide}
{plusTables}
selectedFromTable={table}
/>
</Modal>
<CreateEditRelationshipModal
bind:this={modal}
{datasource}
{tables}
{afterSave}
{onError}
/>
{/if}

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

@ -12,15 +12,14 @@
let selectedRole = BASE_ROLE
let errors = []
let builtInRoles = ["Admin", "Power", "Basic", "Public"]
let validRegex = /^[a-zA-Z0-9_]*$/
// Don't allow editing of public role
$: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
$: selectedRoleId = selectedRole._id
$: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId)
$: isCreating = selectedRoleId == null || selectedRoleId === ""
$: hasUniqueRoleName = !otherRoles
?.map(role => role.name)
?.includes(selectedRole.name)
$: roleNameError = getRoleNameError(selectedRole.name)
$: valid =
selectedRole.name &&
@ -85,7 +84,7 @@
await roles.save(selectedRole)
notifications.success("Role saved successfully")
} catch (error) {
notifications.error("Error saving role")
notifications.error(`Error saving role - ${error.message}`)
return false
}
}
@ -97,7 +96,20 @@
changeRole()
notifications.success("Role deleted successfully")
} catch (error) {
notifications.error("Error deleting role")
notifications.error(`Error deleting role - ${error.message}`)
return false
}
}
const getRoleNameError = name => {
const hasUniqueRoleName = !otherRoles
?.map(role => role.name)
?.includes(name)
const invalidRoleName = !validRegex.test(name)
if (!hasUniqueRoleName) {
return "Select a unique role name."
} else if (invalidRoleName) {
return "Please enter a role name consisting of only alphanumeric symbols and underscores"
}
}
@ -108,7 +120,7 @@
title="Edit Roles"
confirmText={isCreating ? "Create" : "Save"}
onConfirm={saveRole}
disabled={!valid || !hasUniqueRoleName}
disabled={!valid || roleNameError}
>
{#if errors.length}
<ErrorsBox {errors} />
@ -128,8 +140,8 @@
<Input
label="Name"
bind:value={selectedRole.name}
disabled={shouldDisableRoleInput}
error={!hasUniqueRoleName ? "Select a unique role name." : null}
disabled={!!selectedRoleId}
error={roleNameError}
/>
<Select
label="Inherits Role"

View File

@ -92,13 +92,20 @@
},
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
async function exportView() {
try {
const data = await API.exportView({
viewName: view,
format: exportFormat,
})
download(data, `export.${exportFormat === "csv" ? "csv" : "json"}`)
downloadWithBlob(
data,
`export.${exportFormat === "csv" ? "csv" : "json"}`
)
} catch (error) {
notifications.error(`Unable to export ${exportFormat.toUpperCase()} data`)
}
@ -111,7 +118,7 @@
rows: selectedRows.map(row => row._id),
format: exportFormat,
})
download(data, `export.${exportFormat}`)
downloadWithBlob(data, `export.${exportFormat}`)
} else if (filters || sorting) {
let response
try {
@ -130,7 +137,7 @@
notifications.error("Export Failed")
}
if (response) {
download(response, `export.${exportFormat}`)
downloadWithBlob(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else {

View File

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

View File

@ -12,8 +12,10 @@
customQueryText,
} from "helpers/data/utils"
import IntegrationIcon from "./IntegrationIcon.svelte"
import { TableNames } from "constants"
let openDataSources = []
$: enrichedDataSources = enrichDatasources(
$datasources,
$params,
@ -71,6 +73,13 @@
$goto(`./datasource/${datasource._id}`)
}
const selectTable = tableId => {
tables.select(tableId)
if (!$isActive("./table/:tableId")) {
$goto(`./table/${tableId}`)
}
}
function closeNode(datasource) {
openDataSources = openDataSources.filter(id => datasource._id !== id)
}
@ -151,9 +160,16 @@
{#if $database?._id}
<div class="hierarchy-items-container">
<NavItem
icon="UserGroup"
text="Users"
selected={$isActive("./table/:tableId") &&
$tables.selected?._id === TableNames.USERS}
on:click={() => selectTable(TableNames.USERS)}
/>
{#each enrichedDataSources as datasource, idx}
<NavItem
border={idx > 0}
border
text={datasource.name}
opened={datasource.open}
selected={$isActive("./datasource") && datasource.selected}
@ -174,7 +190,7 @@
</NavItem>
{#if datasource.open}
<TableNavigator sourceId={datasource._id} />
<TableNavigator sourceId={datasource._id} {selectTable} />
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query}
<NavItem
indentLevel={1}

View File

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

View File

@ -1,251 +0,0 @@
<script>
import {
Heading,
Body,
Divider,
InlineAlert,
Button,
notifications,
Modal,
Table,
FancyCheckboxGroup,
} from "@budibase/bbui"
import { datasources, integrations, tables } from "stores/backend"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
import CreateExternalTableModal from "./CreateExternalTableModal.svelte"
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify"
import { getDatasourceInfo } from "builderStore/datasource"
export let datasource
export let save
let tableSchema = {
name: {},
primary: { displayName: "Primary Key" },
}
let relationshipSchema = {
tables: {},
columns: {},
}
let relationshipModal
let createExternalTableModal
let selectedFromRelationship, selectedToRelationship
let confirmDialog
let specificTables = null
let tableList
$: integration = datasource && $integrations[datasource.source]
$: plusTables = datasource?.plus
? Object.values(datasource?.entities || {})
: []
$: relationships = getRelationships(plusTables)
$: schemaError = $datasources.schemaError
$: relationshipInfo = relationshipTableData(relationships)
function getRelationships(tables) {
if (!tables || !Array.isArray(tables)) {
return {}
}
let pairs = {}
for (let table of tables) {
for (let column of Object.values(table.schema)) {
if (column.type !== "link") {
continue
}
// these relationships have an id to pair them to each other
// one has a main for the from side
const key = column.main ? "from" : "to"
pairs[column._id] = {
...pairs[column._id],
[key]: column,
}
}
}
return pairs
}
function buildRelationshipDisplayString(fromCol, toCol) {
function getTableName(tableId) {
if (!tableId || typeof tableId !== "string") {
return null
}
return plusTables.find(table => table._id === tableId)?.name || "Unknown"
}
if (!toCol || !fromCol) {
return "Cannot build name"
}
const fromTableName = getTableName(toCol.tableId)
const toTableName = getTableName(fromCol.tableId)
const throughTableName = getTableName(fromCol.through)
let displayString
if (throughTableName) {
displayString = `${fromTableName} ↔ ${toTableName}`
} else {
displayString = `${fromTableName} → ${toTableName}`
}
return displayString
}
async function updateDatasourceSchema() {
try {
await datasources.updateSchema(datasource, specificTables)
notifications.success(`Datasource ${name} tables updated successfully.`)
await tables.fetch()
} catch (error) {
notifications.error(
`Error updating datasource schema ${
error?.message ? `: ${error.message}` : ""
}`
)
}
}
function onClickTable(table) {
$goto(`../../table/${table._id}`)
}
function openRelationshipModal(fromRelationship, toRelationship) {
selectedFromRelationship = fromRelationship || {}
selectedToRelationship = toRelationship || {}
relationshipModal.show()
}
function createNewTable() {
createExternalTableModal.show()
}
function relationshipTableData(relations) {
return Object.values(relations).map(relationship => ({
tables: buildRelationshipDisplayString(
relationship.from,
relationship.to
),
columns: `${relationship.from?.name} to ${relationship.to?.name}`,
from: relationship.from,
to: relationship.to,
}))
}
</script>
<Modal bind:this={relationshipModal}>
<CreateEditRelationship
{datasource}
{save}
close={relationshipModal.hide}
{plusTables}
fromRelationship={selectedFromRelationship}
toRelationship={selectedToRelationship}
/>
</Modal>
<Modal bind:this={createExternalTableModal}>
<CreateExternalTableModal {datasource} />
</Modal>
<ConfirmDialog
bind:this={confirmDialog}
okText="Fetch tables"
onOk={updateDatasourceSchema}
onCancel={() => confirmDialog.hide()}
warning={false}
title="Confirm table fetch"
>
<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={async () => {
const info = await getDatasourceInfo(datasource)
tableList = info.tableNames
confirmDialog.show()
}}
>
Fetch tables
</Button>
<Button cta icon="Add" on:click={createNewTable}>New table</Button>
</div>
</div>
<Body>
This datasource can determine tables automatically. Budibase can fetch your
tables directly from the database and you can use them without having to write
any queries at all.
</Body>
{#if schemaError}
<InlineAlert
type="error"
header="Error fetching tables"
message={schemaError}
onConfirm={datasources.removeSchemaError}
/>
{/if}
{#if plusTables && Object.values(plusTables).length > 0}
<Table
on:click={({ detail }) => onClickTable(detail)}
schema={tableSchema}
data={Object.values(plusTables)}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "primary", component: ArrayRenderer }]}
/>
{:else}
<Body size="S"><i>No tables found.</i></Body>
{/if}
{#if integration.relationships !== false}
<Divider />
<div class="query-header">
<Heading size="S">Relationships</Heading>
<Button primary on:click={() => openRelationshipModal()}>
Define relationship
</Button>
</div>
<Body>
Tell budibase how your tables are related to get even more smart features.
</Body>
{#if relationshipInfo && relationshipInfo.length > 0}
<Table
on:click={({ detail }) => openRelationshipModal(detail.from, detail.to)}
schema={relationshipSchema}
data={relationshipInfo}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{:else}
<Body size="S"><i>No relationships configured.</i></Body>
{/if}
{/if}
<style>
.query-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 0 0 var(--spacing-s) 0;
}
.table-buttons {
display: flex;
gap: var(--spacing-m);
}
.table-checkboxes {
width: 100%;
}
</style>

View File

@ -1,86 +0,0 @@
<script>
import { Body, notifications } from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import ICONS from "../icons"
export let integration = {}
let integrations = []
const INTERNAL = "BUDIBASE"
async function fetchIntegrations() {
let otherIntegrations
try {
otherIntegrations = await API.getIntegrations()
} catch (error) {
otherIntegrations = {}
notifications.error("Error getting integrations")
}
integrations = {
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
...otherIntegrations,
}
}
function selectIntegration(integrationType) {
const selected = integrations[integrationType]
// build the schema
const schema = {}
for (let key of Object.keys(selected.datasource)) {
schema[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
...schema,
}
}
onMount(() => {
fetchIntegrations()
})
</script>
<section>
<div class="integration-list">
{#each Object.entries(integrations) as [integrationType, schema]}
<div
class="integration hoverable"
class:selected={integration.type === integrationType}
on:click={() => selectIntegration(integrationType)}
>
<svelte:component
this={ICONS[integrationType]}
height="50"
width="50"
/>
<Body size="XS">{schema.name || integrationType}</Body>
</div>
{/each}
</div>
</section>
<style>
.integration-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-gap: var(--spectrum-alias-grid-baseline);
}
.integration {
display: grid;
background: var(--background-alt);
place-items: center;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
transition: 0.3s all;
border-radius: var(--spectrum-alias-item-rounded-border-radius-s);
}
.integration:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
</style>

View File

@ -1,123 +0,0 @@
<script>
import {
Divider,
Heading,
ActionButton,
Badge,
Body,
Layout,
} from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import RestAuthenticationBuilder from "./auth/RestAuthenticationBuilder.svelte"
import ViewDynamicVariables from "./variables/ViewDynamicVariables.svelte"
import {
getRestBindings,
getEnvironmentBindings,
readableToRuntimeBinding,
runtimeToReadableMap,
} from "builderStore/dataBinding"
import { cloneDeep } from "lodash/fp"
import { licensing } from "stores/portal"
export let datasource
export let queries
let addHeader
let parsedHeaders = runtimeToReadableMap(
getRestBindings(),
cloneDeep(datasource?.config?.defaultHeaders)
)
const onDefaultHeaderUpdate = headers => {
const flatHeaders = cloneDeep(headers).reduce((acc, entry) => {
acc[entry.name] = readableToRuntimeBinding(getRestBindings(), entry.value)
return acc
}, {})
datasource.config.defaultHeaders = flatHeaders
}
</script>
<Divider />
<div class="section-header">
<div class="badge">
<Heading size="S">Headers</Heading>
<Badge quiet grey>Optional</Badge>
</div>
</div>
<Body size="S">
Headers enable you to provide additional information about the request, such
as format.
</Body>
<KeyValueBuilder
bind:this={addHeader}
bind:object={parsedHeaders}
on:change={evt => onDefaultHeaderUpdate(evt.detail)}
noAddButton
bindings={getRestBindings()}
/>
<div>
<ActionButton icon="Add" on:click={() => addHeader.addEntry()}>
Add header
</ActionButton>
</div>
<Divider />
<div class="section-header">
<div class="badge">
<Heading size="S">Authentication</Heading>
<Badge quiet grey>Optional</Badge>
</div>
</div>
<Body size="S">
Create an authentication config that can be shared with queries.
</Body>
<RestAuthenticationBuilder bind:configs={datasource.config.authConfigs} />
<Divider />
<div class="section-header">
<div class="badge">
<Heading size="S">Variables</Heading>
<Badge quiet grey>Optional</Badge>
</div>
</div>
<Body size="S"
>Variables enable you to store and re-use values in queries, with the choice
of a static value such as a token using static variables, or a value from a
query response using dynamic variables.</Body
>
<Heading size="XS">Static</Heading>
<Layout noPadding gap="XS">
<KeyValueBuilder
name="Variable"
keyPlaceholder="Name"
headings
bind:object={datasource.config.staticVariables}
on:change
bindings={$licensing.environmentVariablesEnabled
? getEnvironmentBindings()
: []}
/>
</Layout>
<div />
<Heading size="XS">Dynamic</Heading>
<Body size="S">
Dynamic variables are evaluated when a dependant query is executed. The value
is cached for a period of time and will be refreshed if a query fails.
</Body>
<ViewDynamicVariables {queries} {datasource} />
<style>
.section-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.badge {
display: flex;
gap: var(--spacing-m);
}
</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

@ -1,141 +0,0 @@
<script>
import { goto } from "@roxi/routify"
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"
export let integration
// 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
$: 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")
let connected = false
try {
const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) {
displayError(`Unable to connect - ${resp.error}`)
}
connected = resp.connected
} catch (err) {
displayError(err?.message)
}
return connected
}
async function saveDatasource() {
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try {
if (!datasource.name) {
datasource.name = name
}
const opts = {}
if (datasourcePlus && selectedTables) {
opts.tablesFilter = selectedTables
}
const resp = await save(datasource, opts)
$goto(`./datasource/${resp._id}`)
notifications.success("Datasource created successfully.")
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}
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}
onConfirm={() => nextStep()}
{confirmText}
cancelText={fetchTableStep ? "Cancel" : "Back"}
showSecondaryButton={datasourcePlus}
size="L"
disabled={!isValid}
>
<Layout noPadding>
<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>
{#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,207 +0,0 @@
<script>
import {
Body,
FancyCheckboxGroup,
InlineAlert,
Layout,
Link,
ModalContent,
notifications,
} from "@budibase/bbui"
import { IntegrationNames, IntegrationTypes } from "constants/backend"
import GoogleButton from "../_components/GoogleButton.svelte"
import { organisation } from "stores/portal"
import { onDestroy, onMount } from "svelte"
import {
getDatasourceInfo,
saveDatasource,
validateDatasourceConfig,
} from "builderStore/datasource"
import cloneDeep from "lodash/cloneDeepWith"
import IntegrationConfigForm from "../TableIntegrationMenu/IntegrationConfigForm.svelte"
import { goto } from "@roxi/routify"
import { DatasourceFeature } from "@budibase/types"
import { API } from "api"
export let integration
export let continueSetupId = false
let datasource = cloneDeep(integration)
datasource.config.continueSetupId = continueSetupId
let { schema } = datasource
$: isGoogleConfigured = !!$organisation.googleDatasourceConfigured
onMount(async () => {
await organisation.init()
})
const integrationName = IntegrationNames[IntegrationTypes.GOOGLE_SHEETS]
export const GoogleDatasouceConfigStep = {
AUTH: "auth",
SET_URL: "set_url",
SET_SHEETS: "set_sheets",
}
let step = continueSetupId
? GoogleDatasouceConfigStep.SET_URL
: GoogleDatasouceConfigStep.AUTH
let isValid = false
let allSheets
let selectedSheets
let setSheetsErrorTitle, setSheetsErrorMessage
$: modalConfig = {
[GoogleDatasouceConfigStep.AUTH]: {
title: `Connect to ${integrationName}`,
},
[GoogleDatasouceConfigStep.SET_URL]: {
title: `Connect your spreadsheet`,
confirmButtonText: "Connect",
onConfirm: async () => {
const checkConnection =
integration.features[DatasourceFeature.CONNECTION_CHECKING]
if (checkConnection) {
const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) {
notifications.error(`Unable to connect - ${resp.error}`)
return false
}
}
try {
datasource = await saveDatasource(datasource, {
tablesFilter: selectedSheets,
skipFetch: true,
})
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
if (!integration.features[DatasourceFeature.FETCH_TABLE_NAMES]) {
notifications.success(`Datasource created successfully.`)
return
}
const info = await getDatasourceInfo(datasource)
allSheets = info.tableNames
step = GoogleDatasouceConfigStep.SET_SHEETS
notifications.success(
checkConnection
? "Connection Successful"
: `Datasource created successfully.`
)
// prevent the modal from closing
return false
},
},
[GoogleDatasouceConfigStep.SET_SHEETS]: {
title: `Choose your sheets`,
confirmButtonText: selectedSheets?.length
? "Fetch sheets"
: "Continue without fetching",
onConfirm: async () => {
try {
if (selectedSheets.length) {
await API.buildDatasourceSchema({
datasourceId: datasource._id,
tablesFilter: selectedSheets,
})
}
return
} catch (err) {
const message = err?.message ?? "Error fetching the sheets"
// Handling message with format: Error title - error description
const indexSeparator = message.indexOf(" - ")
if (indexSeparator >= 0) {
setSheetsErrorTitle = message.substr(0, indexSeparator)
setSheetsErrorMessage =
message[indexSeparator + 3].toUpperCase() +
message.substr(indexSeparator + 4)
} else {
setSheetsErrorTitle = null
setSheetsErrorMessage = message
}
// prevent the modal from closing
return false
}
},
},
}
// This will handle the user closing the modal pressing outside the modal
onDestroy(() => {
if (step === GoogleDatasouceConfigStep.SET_SHEETS) {
$goto(`./datasource/${datasource._id}`)
}
})
</script>
<ModalContent
title={modalConfig[step].title}
cancelText="Cancel"
size="L"
confirmText={modalConfig[step].confirmButtonText}
showConfirmButton={!!modalConfig[step].onConfirm}
onConfirm={modalConfig[step].onConfirm}
disabled={!isValid}
>
{#if step === GoogleDatasouceConfigStep.AUTH}
<!-- 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 {integrationName} 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}
{/if}
{#if step === GoogleDatasouceConfigStep.SET_URL}
<Layout noPadding no>
<Body size="S">Add the URL of the sheet you want to connect.</Body>
<IntegrationConfigForm
{schema}
bind:datasource
creating={true}
on:valid={e => (isValid = e.detail)}
/>
</Layout>
{/if}
{#if step === GoogleDatasouceConfigStep.SET_SHEETS}
<Layout noPadding no>
<Body size="S">Select which spreadsheets you want to connect.</Body>
<FancyCheckboxGroup
options={allSheets}
bind:selected={selectedSheets}
selectAllText="Select all sheets"
/>
{#if setSheetsErrorTitle || setSheetsErrorMessage}
<InlineAlert
type="error"
header={setSheetsErrorTitle}
message={setSheetsErrorMessage}
/>
{/if}
</Layout>
{/if}
</ModalContent>

View File

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

View File

@ -0,0 +1,45 @@
<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"
import SelectField from "./fields/Select.svelte"
export let type
export let value
export let error
export let name
export let config
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 if (type === "select") {
return SelectField
} else {
return StringField
}
}
$: component = selectComponent(type)
</script>
<svelte:component
this={component}
{type}
{value}
{error}
{name}
{config}
{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,37 @@
<script>
import { createEventDispatcher } from "svelte"
import { Layout, Accordion } from "@budibase/bbui"
import ConfigInput from "../ConfigInput.svelte"
export let value
export let name
export let config
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={config?.openByDefault ||
Object.values(value).some(properties => !!properties.value)}
header={name}
>
<Layout gap="S">
{#each value as field}
<ConfigInput
{...field}
on:change={e => handleChange(field.key, e.detail)}
/>
{/each}
</Layout>
</Accordion>

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

View File

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

View File

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

View File

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

View File

@ -59,7 +59,6 @@
$: valid = getErrorCount(errors) === 0 && allRequiredAttributesSet()
$: isManyToMany = relationshipType === RelationshipTypes.MANY_TO_MANY
$: isManyToOne = relationshipType === RelationshipTypes.MANY_TO_ONE
$: toRelationship.relationshipType = fromRelationship?.relationshipType
function getTable(id) {
return plusTables.find(table => table._id === id)
@ -180,6 +179,16 @@
return getErrorCount(errors) === 0
}
function otherRelationshipType(type) {
if (type === RelationshipTypes.MANY_TO_ONE) {
return RelationshipTypes.ONE_TO_MANY
} else if (type === RelationshipTypes.ONE_TO_MANY) {
return RelationshipTypes.MANY_TO_ONE
} else if (type === RelationshipTypes.MANY_TO_MANY) {
return RelationshipTypes.MANY_TO_MANY
}
}
function buildRelationships() {
const id = Helpers.uuid()
//Map temporary variables
@ -200,6 +209,7 @@
...toRelationship,
tableId: fromId,
name: fromColumn,
relationshipType: otherRelationshipType(relationshipType),
through: throughId,
type: "link",
_id: id,

View File

@ -0,0 +1,66 @@
<script>
import { Modal } from "@budibase/bbui"
import { get } from "svelte/store"
import CreateEditRelationship from "./CreateEditRelationship.svelte"
import { integrations, datasources } from "stores/backend"
import { integrationForDatasource } from "stores/selectors"
export let datasource
export let tables
export let beforeSave = async () => {}
export let afterSave = async () => {}
export let onError = async () => {}
let relationshipModal
let fromRelationship = {}
let toRelationship = {}
let fromTable = null
export function show({
fromRelationship: selectedFromRelationship = {},
toRelationship: selectedToRelationship = {},
fromTable: selectedFromTable = null,
}) {
fromRelationship = selectedFromRelationship
toRelationship = selectedToRelationship
fromTable = selectedFromTable
relationshipModal.show()
}
export function hide() {
relationshipModal.hide()
}
// action is one of 'created', 'updated' or 'deleted'
async function saveRelationship(action) {
try {
await beforeSave({ action, datasource })
const integration = integrationForDatasource(
get(integrations),
datasource
)
await datasources.update({ datasource, integration })
await afterSave({ datasource, action })
} catch (err) {
await onError({ err, datasource, action })
}
}
</script>
<Modal bind:this={relationshipModal}>
<CreateEditRelationship
save={saveRelationship}
close={relationshipModal.hide}
selectedFromTable={fromTable}
{datasource}
plusTables={tables}
{fromRelationship}
{toRelationship}
/>
</Modal>
<style>
</style>

View File

@ -0,0 +1,73 @@
<script>
import {
Body,
FancyCheckboxGroup,
InlineAlert,
Layout,
ModalContent,
} from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
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.loading || $store.hasSelected
? `Fetch ${tableType}`
: "Continue without fetching"
$: description = isSheets
? "Select which spreadsheets you want to connect."
: "Choose what tables you want to sync with Budibase"
$: selectAllText = isSheets ? "Select all sheets" : "Select all"
</script>
<ModalContent
{title}
cancelText="Skip"
size="L"
{confirmText}
onConfirm={() => store.importSelectedTables(onComplete)}
disabled={$store.loading}
>
{#if $store.loading}
<div class="loading">
<Spinner size="20" />
</div>
{: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>
<style>
.loading {
display: flex;
justify-content: center;
}
</style>

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.filter(t => datasource.entities[t]))
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

@ -1,21 +0,0 @@
<script>
import { Menu, Icon, MenuSection, MenuItem } from "@budibase/bbui"
export let heading
export let tables
export let selected = false
export let select
</script>
<Menu>
<MenuSection {heading}>
{#each tables as table}
<MenuItem noClose icon="Table" on:click={() => select(table)}>
{table.name}
{#if selected}
<Icon size="S" name="Checkmark" />
{/if}
</MenuItem>
{/each}
</MenuSection>
</Menu>

View File

@ -10,17 +10,13 @@
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId
export let selectTable
$: sortedTables = $tables.list
.filter(table => table.sourceId === sourceId)
.filter(
table => table.sourceId === sourceId && table._id !== TableNames.USERS
)
.sort(alphabetical)
const selectTable = tableId => {
tables.select(tableId)
if (!$isActive("./table/:tableId")) {
$goto(`./table/${tableId}`)
}
}
</script>
{#if $database?._id}

View File

@ -93,6 +93,7 @@
try {
await beforeSave()
table = await tables.save(newTable)
await datasources.fetch()
await afterSave(table)
} catch (e) {
notifications.error(e)

View File

@ -65,6 +65,7 @@
const updatedTable = cloneDeep(table)
updatedTable.name = updatedName
await tables.save(updatedTable)
await datasources.fetch()
notifications.success("Table renamed successfully")
}

View File

@ -55,7 +55,7 @@
name: "Automations",
description: "",
icon: "Compass",
action: () => $goto("./automate"),
action: () => $goto("./automation"),
},
{
type: "Publish",
@ -127,7 +127,7 @@
type: "Automation",
name: automation.name,
icon: "ShareAndroid",
action: () => $goto(`./automate/${automation._id}`),
action: () => $goto(`./automation/${automation._id}`),
})),
...Constants.Themes.map(theme => ({
type: "Change Builder Theme",

View File

@ -9,6 +9,20 @@
faFileArrowUp,
faChevronLeft,
faCircleInfo,
faBold,
faItalic,
faHeading,
faQuoteLeft,
faListUl,
faListOl,
faLink,
faImage,
faEye,
faColumns,
faArrowsAlt,
faQuestionCircle,
faCircleCheck,
faGear,
} from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -22,7 +36,25 @@
faEnvelope,
faFileArrowUp,
faChevronLeft,
faCircleInfo
faCircleInfo,
// -- Required for easyMDE use in the builder.
faBold,
faItalic,
faHeading,
faQuoteLeft,
faListUl,
faListOl,
faLink,
faImage,
faEye,
faColumns,
faArrowsAlt,
faQuestionCircle,
// --
faCircleCheck,
faGear
)
dom.watch()
</script>

View File

@ -469,10 +469,12 @@
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
overflow: hidden;
}
.overlay-wrap {
position: relative;
flex: 1;
overflow: hidden;
}
.mode-overlay {
position: absolute;

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<script>
import { Select, Label, Input, Checkbox, Icon } from "@budibase/bbui"
import { Select, Label, Input, Checkbox, Icon, Body } from "@budibase/bbui"
import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
export let parameters = {}
@ -11,11 +11,9 @@
NEW: "new",
EXISTING: "existing",
}
let automationStatus = parameters.automationId
? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW
$: {
if (automationStatus === AUTOMATION_STATUS.NEW) {
parameters.synchronous = false
@ -23,6 +21,7 @@
parameters.synchronous = automations.find(
automation => automation._id === parameters.automationId
)?.synchronous
parameters
}
$: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
@ -42,35 +41,16 @@
synchronous: hasCollectBlock,
}
})
$: hasAutomations = automations && automations.length > 0
$: selectedAutomation = automations?.find(
a => a._id === parameters?.automationId
)
$: selectedSchema = selectedAutomation?.schema
$: error = parameters.timeout > 120 ? "Timeout must be less than 120s" : null
const onFieldsChanged = e => {
parameters.fields = Object.entries(e.detail || {}).reduce(
(acc, [key, value]) => {
acc[key.trim()] = value
return acc
},
{}
)
}
const setNew = () => {
automationStatus = AUTOMATION_STATUS.NEW
parameters.automationId = undefined
parameters.fields = {}
}
const setExisting = () => {
automationStatus = AUTOMATION_STATUS.EXISTING
parameters.newAutomationName = ""
parameters.fields = {}
parameters.automationId = automations[0]?._id
const onFieldsChanged = field => {
parameters.fields = { ...parameters.fields, ...field }
}
const onChange = value => {
@ -83,30 +63,11 @@
</script>
<div class="root">
<div class="radios">
<div class="radio-container" on:click={setNew}>
<input
type="radio"
value={AUTOMATION_STATUS.NEW}
bind:group={automationStatus}
/>
<Label small>Create a new automation</Label>
<div class="fields">
<div class:title-padding={parameters.synchronous}>
<Label small>Automation</Label>
</div>
<div class="radio-container" on:click={hasAutomations ? setExisting : null}>
<input
type="radio"
value={AUTOMATION_STATUS.EXISTING}
bind:group={automationStatus}
disabled={!hasAutomations}
/>
<Label small grey={!hasAutomations}>Use an existing automation</Label>
</div>
</div>
<div class="params">
<Label small>Automation</Label>
{#if automationStatus === AUTOMATION_STATUS.EXISTING}
<div style="width: 100%">
<Select
on:change={onChange}
bind:value={parameters.automationId}
@ -115,42 +76,46 @@
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
{:else}
<Input
bind:value={parameters.newAutomationName}
placeholder="Enter automation name"
/>
{/if}
{#if parameters.synchronous}
<Label small />
<div class="synchronous-info">
<Icon name="Info" />
<div>
<i
>This automation will run synchronously as it contains a Collect
step</i
>
{#if parameters.synchronous}
<div class="synchronous-info">
<Icon size="XS" name="Info" />
<Body size="XS">This automation will run synchronously</Body>
</div>
</div>
<Label small />
{/if}
</div>
</div>
{#if parameters.synchronous}
<div class="fields">
<Label small>Timeout</Label>
<div class="timeout-width">
<Input
label="Timeout in seconds (120 max)"
type="number"
{error}
bind:value={parameters.timeout}
/>
<Input type="number" {error} bind:value={parameters.timeout} />
</div>
</div>
{/if}
<div class="fields">
{#if selectedSchema && selectedSchema.length}
{#each selectedSchema as field, idx}
{#if idx === 0}
<Label small>Fields</Label>
{:else}
<Label small />
{/if}
<Input disabled value={field.name} />
<DrawerBindableInput
value={parameters.fields && parameters.fields[field.name]}
{bindings}
on:change={event => onFieldsChanged({ [field.name]: event.detail })}
/>
{/each}
{/if}
</div>
<div class="param-margin">
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
@ -161,18 +126,6 @@
/>
{/if}
</div>
<div class="fields">
{#key parameters.automationId}
<SaveFields
schemaFields={selectedSchema}
parameterFields={parameters.fields}
fieldLabel="Field"
on:change={onFieldsChanged}
{bindings}
/>
{/key}
</div>
</div>
<style>
@ -184,17 +137,24 @@
width: 30%;
}
.param-margin {
margin-top: var(--spacing-l);
}
.title-padding {
padding-bottom: 20px;
}
.params {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
display: flex;
flex-wrap: nowrap;
gap: 25px;
}
.synchronous-info {
display: flex;
gap: var(--spacing-s);
margin-top: var(--spacing-s);
}
.fields {
@ -202,29 +162,7 @@
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr auto 1fr auto;
grid-template-columns: 15% auto auto;
align-items: center;
}
.radios,
.radio-container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.radios {
gap: var(--spacing-m);
margin-bottom: var(--spacing-l);
}
.radio-container {
gap: var(--spacing-m);
}
.radio-container :global(label) {
margin: 0;
}
input[type="radio"]:checked {
background: var(--blue);
}
</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

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

View File

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

View File

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

View File

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

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

@ -16,7 +16,7 @@
let password = null
const validation = createValidationStore()
validation.addValidatorType("password", "password", true)
validation.addValidatorType("password", "password", true, { minLength: 8 })
$: validation.observe("password", password)
const Step = { CONFIG: "config", SET_PASSWORD: "set_password" }

View File

@ -14,6 +14,10 @@
import EditableIcon from "../common/EditableIcon.svelte"
export let app
export let onUpdateComplete
$: appIdParts = app.appId ? app.appId?.split("_") : []
$: appId = appIdParts.slice(-1)[0]
const values = writable({
name: app.name,
@ -34,8 +38,20 @@
const setupValidation = async () => {
const applications = svelteGet(apps)
appValidation.name(validation, { apps: applications, currentApp: app })
appValidation.url(validation, { apps: applications, currentApp: app })
appValidation.name(validation, {
apps: applications,
currentApp: {
...app,
appId,
},
})
appValidation.url(validation, {
apps: applications,
currentApp: {
...app,
appId,
},
})
// init validation
const { url } = $values
validation.check({
@ -46,7 +62,7 @@
async function updateApp() {
try {
await apps.update(app.instance._id, {
await apps.update(app.appId, {
name: $values.name?.trim(),
url: $values.url?.trim(),
icon: {
@ -54,6 +70,9 @@
color: $values.iconColor,
},
})
if (typeof onUpdateComplete == "function") {
onUpdateComplete()
}
} catch (error) {
console.error(error)
notifications.error("Error updating app")

View File

@ -52,7 +52,13 @@ export const url = (validation, { apps, currentApp } = { apps: [] }) => {
}
return !apps
.map(app => app.url)
.some(appUrl => appUrl?.toLowerCase() === value.toLowerCase())
.some(appUrl => {
const url =
appUrl?.[0] === "/"
? appUrl.substring(1, appUrl.length)
: appUrl
return url?.toLowerCase() === value.toLowerCase()
})
}
)
.test("valid-url", "Not a valid URL", value => {

View File

@ -21,7 +21,7 @@ export const createValidationStore = () => {
validator[propertyName] = propertyValidator
}
const addValidatorType = (propertyName, type, required) => {
const addValidatorType = (propertyName, type, required, options) => {
if (!type || !propertyName) {
return
}
@ -45,11 +45,8 @@ export const createValidationStore = () => {
propertyValidator = propertyValidator.required()
}
// We want to do this after the possible required validation, to prioritise the required error
switch (type) {
case "password":
propertyValidator = propertyValidator.min(8)
break
if (options?.minLength) {
propertyValidator = propertyValidator.min(options.minLength)
}
validator[propertyName] = propertyValidator
@ -58,7 +55,7 @@ export const createValidationStore = () => {
const observe = async (propertyName, value) => {
const values = get(validation).values
let fieldIsValid
if (!values.hasOwnProperty(propertyName)) {
if (!Object.prototype.hasOwnProperty.call(values, propertyName)) {
// Initial setup
values[propertyName] = value
return

View File

@ -40,6 +40,8 @@
let userOnboardResponse = null
let userLimitReachedModal
let inviteFailureResponse = ""
$: queryIsEmail = emailValidator(query) === true
$: prodAppId = apps.getProdAppID($store.appId)
$: promptInvite = showInvite(
@ -308,19 +310,6 @@
let userInviteResponse
try {
userInviteResponse = await users.onboard(payload)
const newUser = userInviteResponse?.successful.find(
user => user.email === newUserEmail
)
if (newUser) {
notifications.success(
userInviteResponse.created
? "User created successfully"
: "User invite successful"
)
} else {
throw new Error("User invite failed")
}
} catch (error) {
console.error(error.message)
notifications.error("Error inviting user")
@ -331,12 +320,31 @@
const onInviteUser = async () => {
userOnboardResponse = await inviteUser()
const originalQuery = query + ""
query = null
const userInviteSuccess = userOnboardResponse?.successful
if (userInviteSuccess && userInviteSuccess[0].email === query) {
query = null
query = userInviteSuccess[0].email
const newUser = userOnboardResponse?.successful.find(
user => user.email === originalQuery
)
if (newUser) {
query = originalQuery
notifications.success(
userOnboardResponse.created
? "User created successfully"
: "User invite successful"
)
} else {
const failedUser = userOnboardResponse?.unsuccessful.find(
user => user.email === originalQuery
)
inviteFailureResponse =
failedUser?.reason === "Unavailable"
? "Email already in use. Please use a different email."
: failedUser?.reason
notifications.error(inviteFailureResponse)
}
userOnboardResponse = null
}
const onUpdateUserInvite = async (invite, role) => {

View File

@ -4,8 +4,6 @@
import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import {
ActionMenu,
MenuItem,
Icon,
Tabs,
Tab,
@ -142,56 +140,17 @@
{/if}
<div class="root" class:blur={$store.showPreview}>
<div class="top-nav">
<div class="top-nav" class:has-lock={$store.hasLock}>
{#if $store.initialised}
<div class="topleftnav">
<ActionMenu>
<div slot="control">
<Icon size="M" hoverable name="ShowMenu" />
</div>
<MenuItem on:click={() => $goto("../../portal/apps")}>
Exit to portal
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
<span class="back-to-apps">
<Icon
size="S"
hoverable
name="BackAndroid"
on:click={() => $goto("../../portal/apps")}
/>
</span>
{#if $store.hasLock}
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
@ -209,13 +168,23 @@
{:else}
<div class="secondary-editor">
<Icon name="LockClosed" />
Another user is currently editing your screens and automations
<div
class="secondary-editor-body"
title="Another user is currently editing your screens and automations"
>
Another user is currently editing your screens and automations
</div>
</div>
{/if}
</div>
<div class="topcenternav">
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="toprightnav">
<UserAvatars users={$userStore} />
<AppActions {application} />
<span class:nav-lock={!$store.hasLock}>
<UserAvatars users={$userStore} />
</span>
<AppActions {application} {loaded} />
</div>
{/if}
</div>
@ -241,6 +210,13 @@
</Modal>
<style>
.back-to-apps {
display: contents;
}
.back-to-apps :global(.icon) {
margin-left: 12px;
margin-right: 12px;
}
.loading {
min-height: 100%;
height: 100%;
@ -272,27 +248,34 @@
z-index: 2;
}
.topleftnav {
.top-nav.has-lock {
padding-right: 0px;
}
.topcenternav {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
}
.topleftnav :global(.spectrum-Heading) {
.topcenternav :global(.spectrum-Heading) {
flex: 1 1 auto;
width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
padding: 0px var(--spacing-m);
}
.topcenternav {
.topleftnav {
display: flex;
position: relative;
margin-bottom: -2px;
overflow: hidden;
}
.topcenternav :global(.spectrum-Tabs-itemLabel) {
.topleftnav :global(.spectrum-Tabs-itemLabel) {
font-weight: 600;
}
@ -301,7 +284,10 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-l);
}
.toprightnav :global(.avatars) {
margin-right: var(--spacing-l);
}
.secondary-editor {
@ -309,6 +295,16 @@
display: flex;
flex-direction: row;
gap: 8px;
min-width: 0;
overflow: hidden;
margin-left: var(--spacing-xl);
}
.secondary-editor-body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0px;
}
.body {

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

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

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