Merge branch 'develop' of github.com:Budibase/budibase into feature/table-fetching-frontend

This commit is contained in:
mike12345567 2023-06-05 14:21:35 +01:00
commit 805e417553
233 changed files with 5188 additions and 2599 deletions

View File

@ -37,14 +37,17 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn nx run-many -t=build --configuration=production
# Run build all the projects
- run: yarn build
# Check the types of the projects built via esbuild
- run: yarn check:types
test-libraries:
runs-on: ubuntu-latest
@ -52,7 +55,7 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -72,7 +75,7 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -82,7 +85,7 @@ jobs:
- run: yarn test --scope=@budibase/worker --scope=@budibase/server
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
@ -92,7 +95,7 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -107,7 +110,7 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
@ -131,7 +134,7 @@ jobs:
uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Check submodule
run: |

View File

@ -1,5 +1,7 @@
name: Budibase Prerelease
concurrency: release-prerelease
concurrency:
group: release-prerelease
cancel-in-progress: false
on:
push:

View File

@ -1,5 +1,7 @@
name: Budibase Release
concurrency: release
concurrency:
group: release
cancel-in-progress: false
on:
push:

View File

@ -1,5 +1,7 @@
name: Tag prerelease
concurrency: release-prerelease
concurrency:
group: tag-prerelease
cancel-in-progress: false
on:
push:

View File

@ -1,5 +1,7 @@
name: Tag release
concurrency: release-prerelease
concurrency:
group: tag-release
cancel-in-progress: false
on:
push:

View File

@ -1,5 +1,5 @@
{
"version": "2.6.19-alpha.11",
"version": "2.6.19-alpha.52",
"npmClient": "yarn",
"packages": [
"packages/backend-core",

View File

@ -4,7 +4,6 @@
"devDependencies": {
"@esbuild-plugins/node-resolve": "^0.2.2",
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/esbuild": "16.2.1",
"@nx/js": "16.2.1",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
@ -34,6 +33,7 @@
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types --skip-nx-cache",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk",
@ -52,7 +52,7 @@
"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:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"dev:docker": "yarn build && docker-compose -f hosting/docker-compose.dev.yaml -f hosting/docker-compose.build.yaml up --build --scale proxy-service=0 ",
"dev:docker": "yarn build && 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",
"lint:eslint": "eslint packages && eslint qa-core",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",
@ -110,5 +110,11 @@
"packages/pro/packages/pro"
]
},
"resolutions": {
"@budibase/backend-core": "0.0.0",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0"
},
"dependencies": {}
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "0.0.1",
"version": "0.0.0",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -22,7 +22,7 @@
"dependencies": {
"@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "0.0.1",
"@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",
@ -33,7 +33,7 @@
"correlation-id": "4.0.0",
"dotenv": "16.0.1",
"emitter-listener": "1.1.2",
"ioredis": "4.28.0",
"ioredis": "5.3.2",
"joi": "17.6.0",
"jsonwebtoken": "9.0.0",
"koa-passport": "4.1.4",
@ -62,7 +62,6 @@
"@swc/jest": "^0.2.24",
"@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3",
"@types/ioredis": "4.28.0",
"@types/jest": "29.5.0",
"@types/koa": "2.13.4",
"@types/lodash": "4.14.180",
@ -74,7 +73,7 @@
"@types/tar-fs": "2.0.1",
"@types/uuid": "8.3.4",
"chance": "1.1.8",
"ioredis-mock": "5.8.0",
"ioredis-mock": "8.7.0",
"jest": "29.5.0",
"jest-environment-node": "29.5.0",
"jest-serial-runner": "^1.2.1",

View File

@ -72,16 +72,12 @@ describe("writethrough", () => {
writethrough.put({ ...current, value: 4 }),
])
// with a lock, this will work
const newRev = responses.map(x => x.rev).find(x => x !== current._rev)
expect(newRev).toBeDefined()
expect(responses.map(x => x.rev)).toEqual(
expect.arrayContaining([current._rev, current._rev, newRev])
)
expectFunctionWasCalledTimesWith(
mocks.alerts.logWarn,
2,
"Ignoring redlock conflict in write-through cache"
)
const output = await db.get(current._id)
expect(output.value).toBe(4)

View File

@ -16,6 +16,7 @@ export enum Header {
LICENSE_KEY = "x-budibase-license-key",
API_VER = "x-budibase-api-version",
APP_ID = "x-budibase-app-id",
SESSION_ID = "x-budibase-session-id",
TYPE = "x-budibase-type",
PREVIEW_ROLE = "x-budibase-role",
TENANT_ID = "x-budibase-tenant-id",

View File

@ -97,7 +97,6 @@ const environment = {
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
MOCK_REDIS: process.env.MOCK_REDIS,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
AWS_REGION: process.env.AWS_REGION,
@ -129,6 +128,7 @@ const environment = {
PLUGIN_BUCKET_NAME:
process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS,
USE_COUCH: process.env.USE_COUCH || true,
MOCK_REDIS: process.env.MOCK_REDIS,
DEFAULT_LICENSE: process.env.DEFAULT_LICENSE,
SERVICE: process.env.SERVICE || "budibase",
LOG_LEVEL: process.env.LOG_LEVEL || "info",

View File

@ -21,6 +21,7 @@ export * as context from "./context"
export * as cache from "./cache"
export * as objectStore from "./objectStore"
export * as redis from "./redis"
export { Client as RedisClient } from "./redis"
export * as locks from "./redis/redlockImpl"
export * as utils from "./utils"
export * as errors from "./errors"

View File

@ -96,6 +96,7 @@ if (!env.DISABLE_PINO_LOGGER) {
const mergingObject: any = {
err: error,
pid: process.pid,
...contextObject,
}

View File

@ -6,7 +6,8 @@ let userClient: Client,
appClient: Client,
cacheClient: Client,
writethroughClient: Client,
lockClient: Client
lockClient: Client,
socketClient: Client
async function init() {
userClient = await new Client(utils.Databases.USER_CACHE).init()
@ -14,9 +15,10 @@ async function init() {
appClient = await new Client(utils.Databases.APP_METADATA).init()
cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init()
lockClient = await new Client(utils.Databases.LOCKS).init()
writethroughClient = await new Client(
utils.Databases.WRITE_THROUGH,
utils.SelectableDatabase.WRITE_THROUGH
writethroughClient = await new Client(utils.Databases.WRITE_THROUGH).init()
socketClient = await new Client(
utils.Databases.SOCKET_IO,
utils.SelectableDatabase.SOCKET_IO
).init()
}
@ -27,6 +29,7 @@ export async function shutdown() {
if (cacheClient) await cacheClient.finish()
if (writethroughClient) await writethroughClient.finish()
if (lockClient) await lockClient.finish()
if (socketClient) await socketClient.finish()
}
process.on("exit", async () => {
@ -74,3 +77,10 @@ export async function getLockClient() {
}
return lockClient
}
export async function getSocketClient() {
if (!socketClient) {
await init()
}
return socketClient
}

View File

@ -1,6 +1,15 @@
import env from "../environment"
// ioredis mock is all in memory
const Redis = env.MOCK_REDIS ? require("ioredis-mock") : require("ioredis")
import Redis from "ioredis"
// mock-redis doesn't have any typing
let MockRedis: any | undefined
if (env.MOCK_REDIS) {
try {
// ioredis mock is all in memory
MockRedis = require("ioredis-mock")
} catch (err) {
console.log("Mock redis unavailable")
}
}
import {
addDbPrefix,
removeDbPrefix,
@ -18,7 +27,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
// for testing just generate the client once
let CLOSED = false
let CLIENTS: { [key: number]: any } = {}
0
let CONNECTED = false
// mock redis always connected
@ -55,6 +64,7 @@ function connectionError(
* will return the ioredis client which will be ready to use.
*/
function init(selectDb = DEFAULT_SELECT_DB) {
const RedisCore = env.MOCK_REDIS && MockRedis ? MockRedis : Redis
let timeout: NodeJS.Timeout
CLOSED = false
let client = pickClient(selectDb)
@ -64,7 +74,7 @@ function init(selectDb = DEFAULT_SELECT_DB) {
}
// testing uses a single in memory client
if (env.MOCK_REDIS) {
CLIENTS[selectDb] = new Redis(getRedisOptions())
CLIENTS[selectDb] = new RedisCore(getRedisOptions())
}
// start the timer - only allowed 5 seconds to connect
timeout = setTimeout(() => {
@ -84,11 +94,11 @@ function init(selectDb = DEFAULT_SELECT_DB) {
const { redisProtocolUrl, opts, host, port } = getRedisOptions()
if (CLUSTERED) {
client = new Redis.Cluster([{ host, port }], opts)
client = new RedisCore.Cluster([{ host, port }], opts)
} else if (redisProtocolUrl) {
client = new Redis(redisProtocolUrl)
client = new RedisCore(redisProtocolUrl)
} else {
client = new Redis(opts)
client = new RedisCore(opts)
}
// attach handlers
client.on("end", (err: Error) => {
@ -183,6 +193,9 @@ class RedisWrapper {
CLOSED = false
init(this._select)
await waitForConnection(this._select)
if (this._select && !env.isTest()) {
this.getClient().select(this._select)
}
return this
}
@ -209,6 +222,11 @@ class RedisWrapper {
return this.getClient().keys(addDbPrefix(db, pattern))
}
async exists(key: string) {
const db = this._db
return await this.getClient().exists(addDbPrefix(db, key))
}
async get(key: string) {
const db = this._db
let response = await this.getClient().get(addDbPrefix(db, key))

View File

@ -4,10 +4,10 @@ import { LockOptions, LockType } from "@budibase/types"
import * as context from "../context"
import env from "../environment"
const getClient = async (
async function getClient(
type: LockType,
opts?: Redlock.Options
): Promise<Redlock> => {
): Promise<Redlock> {
if (type === LockType.CUSTOM) {
return newRedlock(opts)
}
@ -18,6 +18,9 @@ const getClient = async (
case LockType.TRY_ONCE: {
return newRedlock(OPTIONS.TRY_ONCE)
}
case LockType.TRY_TWICE: {
return newRedlock(OPTIONS.TRY_TWICE)
}
case LockType.DEFAULT: {
return newRedlock(OPTIONS.DEFAULT)
}
@ -35,6 +38,9 @@ const OPTIONS = {
// immediately throws an error if the lock is already held
retryCount: 0,
},
TRY_TWICE: {
retryCount: 1,
},
TEST: {
// higher retry count in unit tests
// due to high contention.
@ -62,7 +68,7 @@ const OPTIONS = {
},
}
const newRedlock = async (opts: Redlock.Options = {}) => {
export async function newRedlock(opts: Redlock.Options = {}) {
let options = { ...OPTIONS.DEFAULT, ...opts }
const redisWrapper = await getLockClient()
const client = redisWrapper.getClient()
@ -81,22 +87,26 @@ type RedlockExecution<T> =
| SuccessfulRedlockExecution<T>
| UnsuccessfulRedlockExecution
export const doWithLock = async <T>(
function getLockName(opts: LockOptions) {
// determine lock name
// by default use the tenantId for uniqueness, unless using a system lock
const prefix = opts.systemLock ? "system" : context.getTenantId()
let name: string = `lock:${prefix}_${opts.name}`
// add additional unique name if required
if (opts.resource) {
name = name + `_${opts.resource}`
}
return name
}
export async function doWithLock<T>(
opts: LockOptions,
task: () => Promise<T>
): Promise<RedlockExecution<T>> => {
): Promise<RedlockExecution<T>> {
const redlock = await getClient(opts.type, opts.customOptions)
let lock
try {
// determine lock name
// by default use the tenantId for uniqueness, unless using a system lock
const prefix = opts.systemLock ? "system" : context.getTenantId()
let name: string = `lock:${prefix}_${opts.name}`
// add additional unique name if required
if (opts.resource) {
name = name + `_${opts.resource}`
}
const name = getLockName(opts)
// create the lock
lock = await redlock.lock(name, opts.ttl)
@ -112,7 +122,6 @@ export const doWithLock = async <T>(
if (opts.type === LockType.TRY_ONCE) {
// don't throw for try-once locks, they will always error
// due to retry count (0) exceeded
console.warn(e)
return { executed: false }
} else {
console.error(e)

View File

@ -27,6 +27,7 @@ export enum Databases {
GENERIC_CACHE = "data_cache",
WRITE_THROUGH = "writeThrough",
LOCKS = "locks",
SOCKET_IO = "socket_io",
}
/**
@ -40,7 +41,7 @@ export enum Databases {
*/
export enum SelectableDatabase {
DEFAULT = 0,
WRITE_THROUGH = 1,
SOCKET_IO = 1,
UNUSED_1 = 2,
UNUSED_2 = 3,
UNUSED_3 = 4,
@ -94,7 +95,7 @@ export function getRedisOptions() {
opts.port = port
opts.password = password
}
return { opts, host, port, redisProtocolUrl }
return { opts, host, port: parseInt(port), redisProtocolUrl }
}
export function addDbPrefix(db: string, key: string) {

View File

@ -90,6 +90,10 @@ export const useScimIntegration = () => {
return useFeature(Feature.SCIM)
}
export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "0.0.1",
"version": "0.0.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,8 +38,8 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",

View File

@ -56,6 +56,8 @@ export default function positionDropdown(element, opts) {
styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") {
styles.left = anchorBounds.right + offset
} else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset
} else {
styles.left = anchorBounds.left
}

View File

@ -13,10 +13,12 @@
export let url = ""
export let disabled = false
export let initials = "JD"
export let color = null
const DefaultColor = "#3aab87"
$: color = getColor(initials)
$: avatarColor = color || getColor(initials)
$: style = getStyle(size, avatarColor)
const getColor = initials => {
if (!initials?.length) {
@ -26,6 +28,12 @@
const hue = ((code % 26) / 26) * 360
return `hsl(${hue}, 50%, 50%)`
}
const getStyle = (sizeKey, color) => {
const size = `var(${sizes.get(sizeKey)})`
const fontSize = `calc(${size} / 2)`
return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};`
}
</script>
{#if url}
@ -37,13 +45,7 @@
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
/>
{:else}
<div
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
<div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
{initials || ""}
</div>
{/if}

View File

@ -3,11 +3,13 @@
import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte"
import { setContext } from "svelte"
export let title
export let fillWidth
export let left = "314px"
export let width = "calc(100% - 626px)"
export let headless = false
let visible = false
@ -25,6 +27,11 @@
visible = false
}
setContext("drawer-actions", {
hide,
show,
})
const easeInOutQuad = x => {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2
}
@ -47,27 +54,34 @@
<section
class:fillWidth
class="drawer"
class:headless
transition:slide|local
style={`width: ${width}; left: ${left};`}
>
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" />
</div>
</header>
{#if !headless}
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" />
</div>
</header>
{/if}
<slot name="body" />
</section>
</Portal>
{/if}
<style>
.drawer.headless :global(.drawer-contents) {
height: calc(40vh + 75px);
}
.buttons {
display: flex;
gap: var(--spacing-m);

View File

@ -165,7 +165,7 @@
{/if}
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
<Icon name="Delete" />
</div>
{/if}
</div>
@ -209,7 +209,7 @@
{/if}
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
<Icon name="Delete" />
</div>
{/if}
</div>

View File

@ -12,6 +12,7 @@
export let emphasized = false
export let onTop = false
export let size = "M"
export let beforeSwitch = null
let thisSelected = undefined
@ -28,9 +29,18 @@
thisSelected = selected
dispatch("select", thisSelected)
} else if ($tab.title !== thisSelected) {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
if (typeof beforeSwitch == "function") {
const proceed = beforeSwitch($tab.title)
if (proceed) {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
}
} else {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
}
}
if ($tab.title !== thisSelected) {
tab.update(state => {

View File

@ -31,4 +31,12 @@
.spectrum-Tooltip-tip {
border-top-color: var(--spectrum-global-color-gray-500);
}
.spectrum-Tooltip {
max-width: 280px;
}
.spectrum-Tooltip-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "0.0.1",
"version": "0.0.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -58,11 +58,18 @@
}
},
"dependencies": {
"@budibase/bbui": "0.0.1",
"@budibase/frontend-core": "0.0.1",
"@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/types": "0.0.1",
"@budibase/bbui": "0.0.0",
"@budibase/frontend-core": "0.0.0",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0",
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.1.8",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.11.2",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",

View File

@ -77,7 +77,7 @@ export const getAuthBindings = () => {
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`,
key: "accessToken",
display: { name: "OAuthToken" },
display: { name: "OAuthToken", type: "text" },
},
]
@ -434,6 +434,9 @@ export const getUserBindings = () => {
providerId: "user",
category: "Current User",
icon: "User",
display: {
name: key,
},
})
return acc
}, [])
@ -550,7 +553,7 @@ const getUrlBindings = asset => {
readableBinding: `URL.${param}`,
category: "URL",
icon: "RailTop",
display: { type: "string" },
display: { type: "string", name: param },
}))
const queryParamsBinding = {
type: "context",
@ -558,7 +561,7 @@ const getUrlBindings = asset => {
readableBinding: "Query params",
category: "URL",
icon: "RailTop",
display: { type: "object" },
display: { type: "object", name: "Query params" },
}
return urlParamBindings.concat([queryParamsBinding])
}
@ -589,7 +592,6 @@ export const getEventContextBindings = (
actionId
) => {
let bindings = []
// Check if any context bindings are provided by the component for this
// setting
const component = findComponent(asset.props, componentId)
@ -605,6 +607,9 @@ export const getEventContextBindings = (
)}`,
category: component._instanceName,
icon: def.icon,
display: {
name: contextEntry.label,
},
})
})
}
@ -628,6 +633,9 @@ export const getEventContextBindings = (
runtimeBinding: `actions.${idx}.${contextValue.value}`,
category: "Actions",
icon: "JourneyAction",
display: {
name: contextValue.label,
},
})
})
}

View File

@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users"
import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core"
@ -12,6 +13,7 @@ export const store = getFrontendStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
export const userStore = getUserStore()
// Setup history for screens
export const screenHistoryStore = createHistoryStore({

View File

@ -37,8 +37,10 @@ import {
} from "builderStore/dataBinding"
import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket"
const INITIAL_FRONTEND_STATE = {
initialised: false,
apps: [],
name: "",
url: "",
@ -69,7 +71,9 @@ const INITIAL_FRONTEND_STATE = {
customTheme: {},
previewDevice: "desktop",
highlightedSettingKey: null,
propertyFocus: null,
builderSidePanel: false,
hasLock: true,
// URL params
selectedScreenId: null,
@ -86,6 +90,7 @@ const INITIAL_FRONTEND_STATE = {
export const getFrontendStore = () => {
const store = writable({ ...INITIAL_FRONTEND_STATE })
let websocket
// This is a fake implementation of a "patch" API endpoint to try and prevent
// 409s. All screen doc mutations (aside from creation) use this function,
@ -110,10 +115,11 @@ export const getFrontendStore = () => {
store.actions = {
reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
},
initialise: async pkg => {
const { layouts, screens, application, clientLibPath } = pkg
const { layouts, screens, application, clientLibPath, hasLock } = pkg
websocket = createBuilderWebsocket(application.appId)
await store.actions.components.refreshDefinitions(application.appId)
// Reset store state
@ -137,6 +143,8 @@ export const getFrontendStore = () => {
upgradableVersion: application.upgradableVersion,
navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [],
hasLock,
initialised: true,
}))
screenHistoryStore.reset()
automationHistoryStore.reset()
@ -1319,6 +1327,12 @@ export const getFrontendStore = () => {
highlightedSettingKey: key,
}))
},
propertyFocus: key => {
store.update(state => ({
...state,
propertyFocus: key,
}))
},
},
dnd: {
start: component => {

View File

@ -0,0 +1,42 @@
import { writable, get } from "svelte/store"
export const getUserStore = () => {
const store = writable([])
const init = users => {
store.set(users)
}
const updateUser = user => {
const $users = get(store)
if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user])
} else {
store.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
const removeUser = sessionId => {
store.update(state => {
return state.filter(x => x.sessionId !== sessionId)
})
}
const reset = () => {
store.set([])
}
return {
...store,
actions: {
init,
updateUser,
removeUser,
reset,
},
}
}

View File

@ -1,3 +1,4 @@
import { ActionStepID } from "constants/backend/automations"
import { TableNames } from "../constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) {
}
return base
}
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -0,0 +1,37 @@
import { createWebsocket } from "@budibase/frontend-core"
import { userStore } from "builderStore"
import { datasources, tables } from "stores/backend"
import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core"
export const createBuilderWebsocket = appId => {
const socket = createWebsocket("/socket/builder")
// Built-in events
socket.on("connect", () => {
socket.emit(BuilderSocketEvent.SelectApp, appId, response => {
userStore.actions.init(response.users)
})
})
socket.on("connect_error", err => {
console.log("Failed to connect to builder websocket:", err.message)
})
socket.on("disconnect", () => {
userStore.actions.reset()
})
// User events
socket.onOther(SocketEvent.UserUpdate, userStore.actions.updateUser)
socket.onOther(SocketEvent.UserDisconnect, userStore.actions.removeUser)
// Table events
socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => {
tables.replaceTable(id, table)
})
// Datasource events
socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource)
})
return socket
}

View File

@ -6,24 +6,48 @@
Body,
Icon,
notifications,
Tags,
Tag,
} from "@budibase/bbui"
import { automationStore } from "builderStore"
import { admin } from "stores/portal"
import { automationStore, selectedAutomation } from "builderStore"
import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils"
export let blockIdx
export let lastStep
const disabled = {
SEND_EMAIL_SMTP: {
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
}
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction
let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
$: collectBlockExists = checkForCollectStep($selectedAutomation)
const disabled = () => {
return {
SEND_EMAIL_SMTP: {
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
COLLECT: {
disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists,
message: collectDisabledMessage(),
},
}
}
const collectDisabledMessage = () => {
if (collectBlockExists) {
return "Only one Collect step allowed"
}
if (!lastStep) {
return "Only available as the last step"
}
}
const external = actions.reduce((acc, elm) => {
const [k, v] = elm
if (!v.internal && !v.custom) {
@ -38,6 +62,15 @@
acc[k] = v
}
delete acc.LOOP
// Filter out Collect block if not App Action or Webhook
if (
!collectBlockAllowedSteps.includes(
$selectedAutomation.definition.trigger.stepId
)
) {
delete acc.COLLECT
}
return acc
}, {})
@ -48,7 +81,6 @@
}
return acc
}, {})
console.log(plugins)
const selectAction = action => {
actionVal = action
@ -72,7 +104,7 @@
<ModalContent
title="Add automation step"
confirmText="Save"
size="M"
size="L"
disabled={!selectedAction}
onConfirm={addBlockToAutomation}
>
@ -107,7 +139,7 @@
<Detail size="S">Actions</Detail>
<div class="item-list">
{#each Object.entries(internal) as [idx, action]}
{@const isDisabled = disabled[idx] && disabled[idx].disabled}
{@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
<div
class="item"
class:disabled={isDisabled}
@ -117,8 +149,14 @@
<div class="item-body">
<Icon name={action.icon} />
<Body size="XS">{action.name}</Body>
{#if isDisabled}
<Icon name="Help" tooltip={disabled[idx].message} />
{#if isDisabled && !syncAutomationsEnabled}
<div class="tag-color">
<Tags>
<Tag icon="LockClosed">Business</Tag>
</Tags>
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{/if}
</div>
</div>
@ -152,6 +190,7 @@
display: flex;
margin-left: var(--spacing-m);
gap: var(--spacing-m);
align-items: center;
}
.item-list {
display: grid;
@ -181,4 +220,8 @@
.disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600);
}
.tag-color :global(.spectrum-Tags-item) {
background: var(--spectrum-global-color-gray-200);
}
</style>

View File

@ -17,7 +17,11 @@
import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
import { ActionStepID, TriggerStepID } from "constants/backend/automations"
import {
ActionStepID,
TriggerStepID,
Features,
} from "constants/backend/automations"
import { permissions } from "stores/backend"
export let block
@ -31,6 +35,9 @@
let showLooping = false
let role
$: collectBlockExists = $selectedAutomation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = $selectedAutomation?._id
$: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW ||
@ -184,7 +191,7 @@
{#if !isTrigger}
<div>
<div class="block-options">
{#if !loopBlock}
{#if block?.features?.[Features.LOOPING] || !block.features}
<ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping
</ActionButton>
@ -224,21 +231,28 @@
</Layout>
</div>
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
</div>
<div class="separator" />
<Icon on:click={() => actionModal.show()} hoverable name="AddCircle" size="S" />
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
{#if !collectBlockExists || !lastStep}
<div class="separator" />
<Icon
on:click={() => actionModal.show()}
hoverable
name="AddCircle"
size="S"
/>
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" />
{/if}
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
<style>
.delete-padding {
padding-left: 30px;

View File

@ -11,8 +11,8 @@
ActionButton,
Drawer,
Modal,
Detail,
notifications,
Icon,
} from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore"
@ -27,9 +27,18 @@
import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import {
bindingsToCompletions,
jsAutocomplete,
EditorModes,
} from "components/common/CodeEditor"
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core"
import { getSchemaForTable } from "builderStore/dataBinding"
import {
getSchemaForTable,
getEnvironmentBindings,
} from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte"
@ -43,7 +52,6 @@
let webhookModal
let drawer
let fillWidth = true
let codeBindingOpen = false
let inputData
$: filters = lookForFilters(schemaProperties) || []
@ -210,6 +218,19 @@
}
const outputs = Object.entries(schema)
let bindingIcon = ""
let bindindingRank = 0
if (idx === 0) {
bindingIcon = automation.trigger.icon
} else if (isLoopBlock) {
bindingIcon = "Reuse"
bindindingRank = idx + 1
} else {
bindingIcon = allSteps[idx].icon
bindindingRank = idx - loopBlockCount
}
bindings = bindings.concat(
outputs.map(([name, value]) => {
let runtimeName = isLoopBlock
@ -218,17 +239,24 @@
? `steps[${idx - loopBlockCount}].${name}`
: `steps.${idx - loopBlockCount}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName
const categoryName =
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`
return {
label: runtime,
readableBinding: runtime,
runtimeBinding: runtime,
type: value.type,
description: value.description,
category:
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`,
path: runtime,
icon: bindingIcon,
category: categoryName,
display: {
type: value.type,
name: name,
rank: bindindingRank,
},
}
})
)
@ -237,15 +265,12 @@
// Environment bindings
if ($licensing.environmentVariablesEnabled) {
bindings = bindings.concat(
$environment.variables.map(variable => {
getEnvironmentBindings().map(binding => {
return {
label: `env.${variable.name}`,
path: `env.${variable.name}`,
icon: "Key",
category: "Environment",
...binding,
display: {
type: "string",
name: variable.name,
...binding.display,
rank: 98,
},
}
})
@ -437,25 +462,27 @@
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "code"}
<CodeEditorModal>
<ActionButton
on:click={() => (codeBindingOpen = !codeBindingOpen)}
quiet
icon={codeBindingOpen ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Bindings</Detail>
</ActionButton>
{#if codeBindingOpen}
<pre>{JSON.stringify(bindings, null, 2)}</pre>
{/if}
<Editor
mode="javascript"
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail.value }, key)
inputData[key] = e.detail.value
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
value={inputData[key]}
completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/>
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
@ -505,6 +532,11 @@
{/if}
<style>
.messaging {
display: flex;
align-items: center;
margin-top: var(--spacing-xl);
}
.fields {
display: flex;
flex-direction: column;

View File

@ -16,11 +16,11 @@
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
const userSchemaOverrides = {
firstName: { name: "First name", disabled: true },
lastName: { name: "Last name", disabled: true },
email: { name: "Email", disabled: true },
roleId: { name: "Role", disabled: true },
status: { name: "Status", disabled: true },
firstName: { displayName: "First name", disabled: true },
lastName: { displayName: "Last name", disabled: true },
email: { displayName: "Email", disabled: true },
roleId: { displayName: "Role", disabled: true },
status: { displayName: "Status", disabled: true },
}
$: id = $tables.selected?._id
@ -36,7 +36,8 @@
allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
on:updatetable={e => tables.updateTable(e.detail)}
showAvatars={false}
on:updatetable={e => tables.replaceTable(id, e.detail)}
>
<svelte:fragment slot="controls">
{#if isInternal}

View File

@ -3,6 +3,7 @@
export let query = {}
export let data = []
export let editRows = false
let loading = false
let error = false
@ -12,7 +13,14 @@
{#if error}
<div class="errors">{error}</div>
{/if}
<Table schema={query.schema} {data} {loading} {type} rowCount={5} />
<Table
schema={query.schema}
{data}
{loading}
{type}
rowCount={5}
allowEditing={editRows}
/>
<style>
.errors {

View File

@ -81,6 +81,7 @@
<Label>{label}</Label>
<Editor
editorHeight="250"
editorWidth="320"
mode="json"
on:change={({ detail }) => (value = detail.value)}
value={stringVal}

View File

@ -22,8 +22,8 @@
export let rowCount
export let disableSorting = false
export let customPlaceholder = false
export let allowClickRows
export let allowEditing = true
export let allowClickRows
const dispatch = createEventDispatcher()

View File

@ -113,17 +113,26 @@
})
download(data, `export.${exportFormat}`)
} else if (filters || sorting) {
const data = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query: luceneFilter,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
download(data, `export.${exportFormat}`)
let response
try {
response = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query: luceneFilter,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
} catch (e) {
console.error("Failed to export", e)
notifications.error("Export Failed")
}
if (response) {
download(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else {
await exportView()
}

View File

@ -1,255 +0,0 @@
<script>
import {
ModalContent,
Modal,
Body,
Layout,
Detail,
Heading,
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import ICONS from "../icons"
import { API } from "api"
import { IntegrationTypes, DatasourceTypes } from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
import DatasourceCard from "../_components/DatasourceCard.svelte"
export let modal
let integrations = {}
let integration = {}
let internalTableModal
let externalDatasourceModal
let importModal
$: showImportButton = false
$: customIntegrations = Object.entries(integrations).filter(
entry => entry[1].custom
)
$: sortedIntegrations = sortIntegrations(integrations)
checkShowImport()
onMount(() => {
fetchIntegrations()
})
function selectIntegration(integrationType) {
const selected = integrations[integrationType]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
auth: selected.auth,
features: selected.features || [],
}
if (selected.friendlyName) {
integration.name = selected.friendlyName
}
checkShowImport()
}
function checkShowImport() {
showImportButton = integration.type === "REST"
}
function showImportModal() {
importModal.show()
}
async function chooseNextModal() {
if (integration.type === IntegrationTypes.INTERNAL) {
externalDatasourceModal.hide()
internalTableModal.show()
} else if (integration.type === IntegrationTypes.REST) {
try {
// Skip modal for rest, create straight away
const resp = await createRestDatasource(integration)
$goto(`./datasource/${resp._id}`)
} catch (error) {
notifications.error("Error creating datasource")
}
} else {
externalDatasourceModal.show()
}
}
async function fetchIntegrations() {
let newIntegrations = {
[IntegrationTypes.INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
}
try {
const integrationList = await API.getIntegrations()
newIntegrations = {
...newIntegrations,
...integrationList,
}
} catch (error) {
notifications.error("Error fetching integrations")
}
integrations = newIntegrations
}
function sortIntegrations(integrations) {
let integrationsArray = Object.entries(integrations)
function getTypeOrder(schema) {
if (schema.type === DatasourceTypes.API) {
return 1
}
if (schema.type === DatasourceTypes.RELATIONAL) {
return 2
}
return schema.type?.charCodeAt(0)
}
integrationsArray.sort((a, b) => {
let typeOrderA = getTypeOrder(a[1])
let typeOrderB = getTypeOrder(b[1])
if (typeOrderA === typeOrderB) {
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
}
return typeOrderA < typeOrderB ? -1 : 1
})
return integrationsArray
}
</script>
<Modal bind:this={internalTableModal}>
<CreateTableModal />
</Modal>
<Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} {modal} />
{:else}
<DatasourceConfigModal {integration} {modal} />
{/if}
</Modal>
<Modal bind:this={importModal}>
{#if integration.type === "REST"}
<ImportRestQueriesModal
navigateDatasource={true}
createDatasource={true}
onCancel={() => modal.show()}
/>
{/if}
</Modal>
<Modal bind:this={modal}>
<ModalContent
disabled={!Object.keys(integration).length}
title="Add datasource"
confirmText="Continue"
showSecondaryButton={showImportButton}
secondaryButtonText="Import"
secondaryAction={() => showImportModal()}
showCancelButton={false}
size="M"
onConfirm={() => {
chooseNextModal()
}}
>
<Layout noPadding gap="XS">
<Body size="S">Get started with Budibase DB</Body>
<div
class:selected={integration.type === IntegrationTypes.INTERNAL}
on:click={() => selectIntegration(IntegrationTypes.INTERNAL)}
class="item hoverable"
>
<div class="item-body with-type">
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
<div class="text">
<Heading size="XXS">Budibase DB</Heading>
<Detail size="S" class="type">Non-relational</Detail>
</div>
</div>
</div>
</Layout>
<Layout noPadding gap="XS">
<Body size="S">Connect to an external datasource</Body>
<div class="item-list">
{#each sortedIntegrations.filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]}
<DatasourceCard
on:selected={evt => selectIntegration(evt.detail)}
{schema}
bind:integrationType
{integration}
/>
{/each}
</div>
</Layout>
{#if customIntegrations.length > 0}
<Layout noPadding gap="XS">
<Body size="S">Custom datasource</Body>
<div class="item-list">
{#each customIntegrations as [integrationType, schema]}
<DatasourceCard
on:selected={evt => selectIntegration(evt.detail)}
{schema}
bind:integrationType
{integration}
/>
{/each}
</div>
</Layout>
{/if}
</ModalContent>
</Modal>
<style>
.item-list {
display: grid;
grid-template-columns: repeat(2, minmax(150px, 1fr));
grid-gap: var(--spectrum-alias-grid-baseline);
}
.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;
}
.text :global(.spectrum-Detail) {
color: var(--spectrum-global-color-gray-700);
}
</style>

View File

@ -18,7 +18,6 @@
import { DatasourceFeature } from "@budibase/types"
export let integration
export let modal
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)

View File

@ -8,7 +8,6 @@
import { onMount } from "svelte"
export let integration
export let modal
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
@ -21,7 +20,6 @@
<ModalContent
title={`Connect to ${IntegrationNames[datasource.type]}`}
onCancel={() => modal.show()}
cancelText="Back"
size="L"
>

View File

@ -4,6 +4,7 @@
import { API } from "api"
import { parseFile } from "./utils"
let fileInput
let error = null
let fileName = null
let fileType = null
@ -16,6 +17,7 @@
export let schema = {}
export let allValid = true
export let displayColumn = null
export let promptUpload = false
const typeOptions = [
{
@ -99,10 +101,19 @@
schema[name].type = e.detail
schema[name].constraints = FIELDS[e.detail.toUpperCase()].constraints
}
const openFileUpload = (promptUpload, fileInput) => {
if (promptUpload && fileInput) {
fileInput.click()
}
}
$: openFileUpload(promptUpload, fileInput)
</script>
<div class="dropzone">
<input
bind:this={fileInput}
disabled={loading}
id="file-upload"
accept="text/csv,application/json"

View File

@ -28,6 +28,7 @@
? selectedSource._id
: BUDIBASE_INTERNAL_DB_ID
export let promptUpload = false
export let name
export let beforeSave = async () => {}
export let afterSave = async table => {
@ -136,7 +137,13 @@
<Label grey extraSmall
>Create a Table from a CSV or JSON file (Optional)</Label
>
<TableDataImport bind:rows bind:schema bind:allValid bind:displayColumn />
<TableDataImport
{promptUpload}
bind:rows
bind:schema
bind:allValid
bind:displayColumn
/>
</Layout>
</div>
</ModalContent>

View File

@ -1,143 +0,0 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
ProgressCircle,
Layout,
Body,
Icon,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
export let buttonSize = "M"
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
let appLockModal
let processing = false
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
const getExpiryDuration = app => {
if (!app?.lockedBy?.lockedAt) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
processing = true
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
processing = false
}
</script>
{#if lockedBy}
<div class="lock-status">
<Icon
name="LockClosed"
hoverable
size={buttonSize}
on:click={e => {
e.stopPropagation()
appLockModal.show()
}}
/>
</div>
{/if}
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work being lost from overlapping changes
between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
cta
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</Layout>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
max-width: 175px;
}
</style>

View File

@ -0,0 +1,289 @@
<script>
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import {
autocompletion,
closeBrackets,
completionKeymap,
closeBracketsKeymap,
} from "@codemirror/autocomplete"
import {
EditorView,
lineNumbers,
keymap,
highlightSpecialChars,
drawSelection,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightWhitespace,
placeholder as placeholderFn,
MatchDecorator,
ViewPlugin,
Decoration,
} from "@codemirror/view"
import {
bracketMatching,
foldKeymap,
foldGutter,
syntaxHighlighting,
} from "@codemirror/language"
import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
import {
defaultKeymap,
historyKeymap,
history,
indentWithTab,
} from "@codemirror/commands"
import { Compartment } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./"
import { themeStore } from "builderStore"
export let label
export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
return {
start: selection_range.from,
end: selection_range.to,
}
}
export const insertAtPos = opts => {
// Updating the value inside.
// Retain focus
editor.dispatch({
changes: {
from: opts.start || editor.state.doc.length,
to: opts.end || editor.state.doc.length,
insert: opts.value,
},
selection: opts.cursor
? {
anchor: opts.start + opts.value.length,
}
: undefined,
})
}
// For handlebars only.
const bindStyle = new MatchDecorator({
regexp: /{{[."#\-\w\s\][]*}}/g,
decoration: () => {
return Decoration.mark({
tag: "span",
attributes: {
class: "binding-wrap",
},
})
},
})
let plugin = ViewPlugin.define(
view => ({
decorations: bindStyle.createDeco(view),
update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
const dispatch = createEventDispatcher()
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
const buildKeymap = () => {
const baseMap = [
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
]
return baseMap
}
const buildBaseExtensions = () => {
return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(),
autocompletion({
override: [...completions],
closeOnBlur: true,
icons: false,
optionClass: () => "autocomplete-option",
}),
EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
]
}
const buildExtensions = base => {
const complete = [...base]
if (mode.name == "javascript") {
complete.push(javascript())
complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
complete.push(
EditorView.inputHandler.of((view, from, to, insert) => {
if (insert === "$") {
let { text } = view.state.doc.lineAt(from)
const left = from ? text.substring(0, from) : ""
const right = to ? text.substring(to) : ""
const wrap = !left.includes('$("') || !right.includes('")')
const tr = view.state.update(
{
changes: [{ from, insert: wrap ? '$("")' : "$" }],
selection: {
anchor: from + (wrap ? 3 : 1),
},
},
{
scrollIntoView: true,
userEvent: "input.type",
}
)
view.dispatch(tr)
return true
}
return false
})
)
}
if (placeholder) {
complete.push(placeholderFn(placeholder))
}
return complete
}
let textarea
let editor
let mounted = false
let isEditorInitialised = false
const initEditor = () => {
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value,
extensions: buildExtensions(baseExtensions),
parent: textarea,
})
}
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
initEditor()
}
// Theme change
$: if (mounted && isEditorInitialised && $themeStore?.theme) {
if (currentTheme != $themeStore?.theme) {
currentTheme = $themeStore?.theme
isDark = !currentTheme.includes("light")
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
})
}
}
onMount(async () => {
mounted = true
return () => {
if (editor) {
editor.destroy()
}
}
})
</script>
{#if label}
<div>
<Label small>{label}</Label>
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`}>
<div tabindex="-1" bind:this={textarea} />
</div>
<style>
.code-editor.handlebars :global(.cm-content) {
font-family: var(--font-sans);
}
.code-editor :global(.cm-tooltip.cm-completionInfo) {
padding: var(--spacing-m);
}
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
border-radius: var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
}
.code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 4px 6px;
}
</style>

View File

@ -0,0 +1,387 @@
import { EditorView } from "@codemirror/view"
import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html"
import { groupBy } from "lodash"
export const EditorModes = {
JS: {
name: "javascript",
json: false,
match: /\$$/,
},
Handlebars: {
name: "handlebars",
base: "text/html",
match: /{{[\s]*[\w\s]*/,
},
Text: {
name: "text/html",
},
}
export const SECTIONS = {
HB_HELPER: {
name: "Helper",
type: "helper",
icon: "Code",
},
}
export const getDefaultTheme = opts => {
const { height, resize, dark } = opts
return EditorView.theme(
{
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--spectrum-alias-text-color)",
},
"&": {
height: height ? `${height}` : "",
lineHeight: "1.3",
border:
"var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)",
borderRadius: "var(--border-radius-s)",
backgroundColor:
"var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )",
resize: resize ? `${resize}` : "",
overflow: "hidden",
color: "var(--spectrum-alias-text-color)",
},
"& .cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily:
"var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))",
maxHeight: "16em",
},
"& .cm-placeholder": {
color: "var(--spectrum-alias-text-color)",
fontStyle: "italic",
},
"&.cm-focused": {
outline: "none",
borderColor: "var(--spectrum-alias-border-color-mouse-focus)",
},
// AUTO COMPLETE
"& .cm-completionDetail": {
fontStyle: "unset",
textTransform: "uppercase",
fontSize: "10px",
backgroundColor: "var(--spectrum-global-color-gray-100)",
color: "var(--spectrum-global-color-gray-600)",
},
"& .cm-completionLabel": {
marginLeft:
"calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))",
},
"& .info-bubble": {
fontSize: "var(--font-size-s)",
display: "grid",
gridGap: "var(--spacing-s)",
gridTemplateColumns: "1fr",
color: "var(--spectrum-global-color-gray-800)",
},
"& .cm-tooltip": {
marginLeft: "var(--spacing-s)",
border: "1px solid var(--spectrum-global-color-gray-300)",
borderRadius:
"var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )",
backgroundColor: "var(--spectrum-global-color-gray-50)",
},
// Section header
"& .info-section": {
display: "flex",
padding: "var(--spacing-s)",
gap: "var(--spacing-m)",
borderBottom: "1px solid var(--spectrum-global-color-gray-200)",
color: "var(--spectrum-global-color-gray-800)",
fontWeight: "bold",
},
"& .info-section .spectrum-Icon": {
color: "var(--spectrum-global-color-gray-600)",
},
// Autocomplete Option
"& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "var(--spectrum-alias-font-size-default)",
padding: "var(--spacing-s)",
color: "var(--spectrum-global-color-gray-800)",
},
"& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": {
backgroundColor: "var(--spectrum-global-color-gray-200)",
},
"& .binding-wrap": {
color: "var(--spectrum-global-color-blue-700)",
fontFamily: "monospace",
},
},
{ dark }
)
}
export const buildHelperInfoNode = (completion, helper) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = helper.example
? `<div class="binding__example">${helper.example}</div>`
: ""
const descriptionMarkup = sanitizeHtml(helper.description, {
allowedTags: [],
allowedAttributes: {},
})
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
const toSpectrumIcon = name => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="false"
aria-label="${name}-section-icon"
>
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
</svg>`
}
export const buildSectionHeader = (type, sectionName, icon, rank) => {
const ele = document.createElement("div")
ele.classList.add("info-section")
ele.classList.add(type)
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
return {
name: sectionName,
header: () => ele,
rank,
}
}
export const helpersToCompletion = (helpers, mode) => {
const { type, name: sectionName, icon } = SECTIONS.HB_HELPER
const helperSection = buildSectionHeader(type, sectionName, icon, 99)
return Object.keys(helpers).reduce((acc, key) => {
let helper = helpers[key]
acc.push({
label: key,
info: completion => {
return buildHelperInfoNode(completion, helper)
},
type: "helper",
section: helperSection,
detail: "FUNCTION",
apply: (view, completion, from, to) => {
insertBinding(view, from, to, key, mode)
},
})
return acc
}, [])
}
export const getHelperCompletions = mode => {
const manifest = getManifest()
return Object.keys(manifest).reduce((acc, key) => {
acc = acc || []
return [...acc, ...helpersToCompletion(manifest[key], mode)]
}, [])
}
const bindingFilter = (options, query) => {
return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase()
const label_parsed = completion.label.toLowerCase()
const query_parsed = query.toLowerCase()
return (
section_parsed.includes(query_parsed) ||
label_parsed.includes(query_parsed)
)
})
}
export const hbAutocomplete = baseCompletions => {
async function coreCompletion(context) {
let bindingStart = context.matchBefore(EditorModes.Handlebars.match)
let options = baseCompletions || []
if (!bindingStart) {
return null
}
// Accommodate spaces
const match = bindingStart.text.match(/{{[\s]*/)
const query = bindingStart.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
return {
from: bindingStart.from + match[0].length,
filter: false,
options: filtered,
}
}
return coreCompletion
}
export const jsAutocomplete = baseCompletions => {
async function coreCompletion(context) {
let jsBinding = context.matchBefore(/\$\("[\s\w]*/)
let options = baseCompletions || []
if (jsBinding) {
// Accommodate spaces
const match = jsBinding.text.match(/\$\("[\s]*/)
const query = jsBinding.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
return {
from: jsBinding.from + match[0].length,
filter: false,
options: filtered,
}
}
return null
}
return coreCompletion
}
export const buildBindingInfoNode = (completion, binding) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = binding.readableBinding
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
: ""
const descriptionNodeHtml = binding.description
? `<div class="binding__description">${binding.description}</div>`
: ""
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
// Readdress these methods. They shouldn't be used
export const hbInsert = (value, from, to, text) => {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
const right = to ? value.substring(to) : ""
if (!left.includes("{{") || !right.includes("}}")) {
parsedInsert = `{{ ${text} }}`
} else {
parsedInsert = ` ${text} `
}
return parsedInsert
}
export function jsInsert(value, from, to, text, { helper } = {}) {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
const right = to ? value.substring(to) : ""
if (helper) {
parsedInsert = `helpers.${text}()`
} else if (!left.includes('$("') || !right.includes('")')) {
parsedInsert = `$("${text}")`
} else {
parsedInsert = text
}
return parsedInsert
}
// Autocomplete apply behaviour
export const insertBinding = (view, from, to, text, mode) => {
let parsedInsert
if (mode.name == "javascript") {
parsedInsert = jsInsert(view.state.doc?.toString(), from, to, text)
} else if (mode.name == "handlebars") {
parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text)
} else {
console.log("Unsupported")
return
}
let bindingClosePattern = mode.name == "javascript" ? /[\s]*"\)/ : /[\s]*}}/
let sliced = view.state.doc?.toString().slice(to)
const rightBrace = sliced.match(bindingClosePattern)
let cursorPos = from + parsedInsert.length
if (rightBrace) {
cursorPos = from + parsedInsert.length + rightBrace[0].length
}
view.dispatch({
changes: {
from,
to,
insert: parsedInsert,
},
selection: {
anchor: cursorPos,
},
})
}
export const bindingsToCompletions = (bindings, mode) => {
const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => {
acc[ele.category] = acc[ele.category] || {}
if (ele.icon) {
acc[ele.category]["icon"] = acc[ele.category]["icon"] || ele.icon
}
if (typeof ele.display?.rank == "number") {
acc[ele.category]["rank"] = acc[ele.category]["rank"] || ele.display.rank
}
return acc
}, {})
const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => {
const { icon, rank } = categoryMeta[catKey] || {}
const bindindSectionHeader = buildSectionHeader(
bindingByCategory.type,
catKey,
icon || "",
typeof rank == "number" ? rank : 1
)
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label: binding.display?.name || "NO NAME",
info: completion => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindindSectionHeader,
apply: (view, completion, from, to) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
}, [])
return completions
}

View File

@ -28,7 +28,6 @@
.dash-card {
background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s);
overflow: hidden;
min-height: 170px;
}
.dash-card-header {

View File

@ -8,6 +8,7 @@
faLock,
faFileArrowUp,
faChevronLeft,
faCircleInfo,
} from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -20,7 +21,8 @@
faDiscord,
faEnvelope,
faFileArrowUp,
faChevronLeft
faChevronLeft,
faCircleInfo
)
dom.watch()
</script>

View File

@ -83,7 +83,7 @@
.help {
z-index: 2;
position: absolute;
bottom: var(--spacing-xl);
bottom: 24px;
right: 24px;
}

View File

@ -1,17 +1,13 @@
<script>
import groupBy from "lodash/fp/groupBy"
import {
Search,
TextArea,
DrawerContent,
Tabs,
Tab,
Body,
Layout,
Button,
ActionButton,
Heading,
Icon,
Popover,
} from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte"
import {
@ -23,11 +19,21 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { handlebarsCompletions } from "constants/completions"
import { addHBSBinding, addJSBinding } from "./utils"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { store } from "builderStore"
import { convertToJS } from "@budibase/string-templates"
import { admin } from "stores/portal"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
hbAutocomplete,
EditorModes,
bindingsToCompletions,
hbInsert,
jsInsert,
} from "../CodeEditor"
import { getContext } from "svelte"
import BindingPicker from "./BindingPicker.svelte"
const dispatch = createEventDispatcher()
@ -41,54 +47,21 @@
export let allowJS = false
export let allowHelpers = true
let helpers = handlebarsCompletions()
const drawerActions = getContext("drawer-actions")
const bindingDrawerActions = getContext("binding-drawer-actions")
let getCaretPosition
let search = ""
let insertAtPos
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
let mode = initialValueJS ? "JavaScript" : "Handlebars"
let mode = initialValueJS ? "JavaScript" : "Text"
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let selectedCategory = null
let popover
let popoverAnchor
let hoverTarget
let sidebar = true
let targetMode = null
$: usingJS = mode === "JavaScript"
$: searchRgx = new RegExp(search, "ig")
$: categories = Object.entries(groupBy("category", bindings))
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
$: categoryNames = getCategoryNames(categories)
$: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`)
$: editorMode = mode == "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
$: bindingCompletions = bindingsToCompletions(bindings, editorMode)
const updateValue = val => {
valid = isValid(readableToRuntimeBinding(bindings, val))
@ -97,43 +70,30 @@
}
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
// Adds a JS/HBS helper to the expression
const addHelper = (helper, js) => {
let tempVal
const onSelectHelper = (helper, js) => {
const pos = getCaretPosition()
const { start, end } = pos
if (js) {
const decoded = decodeJSBinding(jsValue)
tempVal = jsValue = encodeJSBinding(
addJSBinding(decoded, pos, helper.text, { helper: true })
)
let js = decodeJSBinding(jsValue)
const insertVal = jsInsert(js, start, end, helper.text, { helper: true })
insertAtPos({ start, end, value: insertVal })
} else {
tempVal = hbsValue = addHBSBinding(hbsValue, pos, helper.text)
const insertVal = hbInsert(hbsValue, start, end, helper.text)
insertAtPos({ start, end, value: insertVal })
}
updateValue(tempVal)
}
// Adds a data binding to the expression
const addBinding = (binding, { forceJS } = {}) => {
const onSelectBinding = (binding, { forceJS } = {}) => {
const { start, end } = getCaretPosition()
if (usingJS || forceJS) {
let js = decodeJSBinding(jsValue)
js = addJSBinding(js, getCaretPosition(), binding.readableBinding)
jsValue = encodeJSBinding(js)
updateValue(jsValue)
const insertVal = jsInsert(js, start, end, binding.readableBinding)
insertAtPos({ start, end, value: insertVal })
} else {
hbsValue = addHBSBinding(
hbsValue,
getCaretPosition(),
binding.readableBinding
)
updateValue(hbsValue)
const insertVal = hbInsert(hbsValue, start, end, binding.readableBinding)
insertAtPos({ start, end, value: insertVal })
}
}
@ -152,24 +112,25 @@
updateValue(jsValue)
}
const switchMode = () => {
if (targetMode == "Text") {
jsValue = null
updateValue(jsValue)
} else {
hbsValue = null
updateValue(hbsValue)
}
mode = targetMode + ""
targetMode = null
}
const convert = () => {
const runtime = readableToRuntimeBinding(bindings, hbsValue)
const runtimeJs = encodeJSBinding(convertToJS(runtime))
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
hbsValue = null
mode = "JavaScript"
addBinding("", { forceJS: true })
}
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
onSelectBinding("", { forceJS: true })
}
onMount(() => {
@ -177,332 +138,301 @@
})
</script>
<span class="detailPopover">
<Popover
align="right-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<span class="binding-drawer">
<DrawerContent>
<div class="main">
<Tabs
selected={mode}
on:select={onChangeMode}
beforeSwitch={selectedMode => {
if (selectedMode == mode) {
return true
}
<DrawerContent>
<svelte:fragment slot="sidebar">
<Layout noPadding gap="S">
{#if selectedCategory}
<div>
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
//Get the current mode value
const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
{#if !selectedCategory}
<div class="heading">Search</div>
<Search placeholder="Search" bind:value={search} />
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
if (editorValue) {
targetMode = selectedMode
return false
}
return true
}}
>
<Tab title="Text">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<div class="overlay-wrap">
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
{`Switch to ${targetMode}?`}
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep text
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard text
</Button>
</div>
</div>
</div>
{/if}
<CodeEditor
value={hbsValue}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
completions={[
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
placeholder=""
height="100%"
/>
</div>
<div class="binding-footer">
<div class="messaging">
{#if !valid}
<div class="syntax-error">
Current Handlebars syntax is invalid, please check the
guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</div>
{:else}
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing &#123;&#123; or use the
menu on the right
</div>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, usingJS)}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(helper, usingJS),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</Layout>
</svelte:fragment>
<div class="main">
<Tabs selected={mode} on:select={onChangeMode}>
<Tab title="Handlebars">
<div class="main-content">
<TextArea
bind:getCaretPosition
value={hbsValue}
on:change={onChangeHBSValue}
placeholder="Add text, or click the objects on the left to add them to the textbox."
/>
{#if !valid}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
{#if $admin.isDev && allowJS}
<div class="convert">
<Button secondary on:click={convert}>Convert to JS</Button>
</div>
<div class="actions">
{#if $admin.isDev && allowJS}
<ActionButton
secondary
on:click={() => {
convert()
targetMode = null
}}
>
Convert To JS
</ActionButton>
{/if}
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{/if}
</div>
</Tab>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content">
<Layout noPadding gap="XS">
<CodeMirrorEditor
bind:getCaretPosition
height={200}
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
hints={codeMirrorHints}
/>
<Body size="S">
JavaScript expressions are executed as functions, so ensure that
your expression returns a value.
</Body>
</Layout>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
</Tabs>
</div>
</DrawerContent>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<div class="overlay-wrap">
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
{`Switch to ${targetMode}?`}
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep javascript
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard javascript
</Button>
</div>
</div>
</div>
{/if}
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
completions={[
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
height="100%"
/>
</div>
<div class="binding-footer">
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing $ or use the menu on
the right
</div>
</div>
</div>
<div class="actions">
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
<div class="drawer-actions">
<Button
secondary
quiet
on:click={() => {
store.actions.settings.propertyFocus(null)
drawerActions.hide()
}}
>
Cancel
</Button>
<Button
cta
disabled={!valid}
on:click={() => {
bindingDrawerActions.save()
}}
>
Save
</Button>
</div>
</Tabs>
</div>
</DrawerContent>
</span>
<style>
ul.helpers li * {
pointer-events: none;
.binding-drawer :global(.container > .main) {
overflow: hidden;
height: 100%;
padding: 0px;
}
ul.category-list li {
.binding-drawer :global(.container > .main > .main) {
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.binding-drawer :global(.spectrum-Tabs-content) {
flex: 1;
overflow: hidden;
}
.binding-drawer :global(.spectrum-Tabs-content > div),
.binding-drawer :global(.spectrum-Tabs-content > div > div),
.binding-drawer :global(.spectrum-Tabs-content .main-content) {
height: 100%;
}
.binding-drawer .main-content {
grid-template-rows: unset;
}
.messaging {
display: flex;
align-items: center;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
min-width: 0;
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
.messaging-wrap {
overflow: hidden;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
.messaging-wrap > div {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.main :global(textarea) {
min-height: 202px !important;
}
.main {
margin: calc(-1 * var(--spacing-xl));
}
.main-content {
padding: var(--spacing-s) var(--spacing-xl);
}
.heading,
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
.main :global(.spectrum-Tabs div.drawer-actions) {
display: flex;
gap: var(--spacing-m);
align-items: center;
margin-left: auto;
}
ul {
list-style: none;
padding: 0;
margin: 0;
.main :global(.spectrum-Tabs-content),
.main :global(.spectrum-Tabs-content .main-content) {
margin-top: 0px;
padding: 0px;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
.helper {
.main :global(.spectrum-Tabs) {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xs);
}
.helper__name {
font-weight: bold;
}
.helper__description,
.helper__description :global(*) {
color: var(--spectrum-global-color-gray-700);
}
.helper__example {
white-space: normal;
margin: 0.5rem 0 0 0;
font-weight: 700;
}
.helper__description :global(p) {
margin: 0;
}
.syntax-error {
padding-top: var(--spacing-m);
color: var(--red);
font-size: 12px;
}
@ -511,7 +441,66 @@
text-decoration: underline;
}
.convert {
padding-top: var(--spacing-m);
.binding-footer {
width: 100%;
display: flex;
justify-content: space-between;
}
.main-content {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 380px;
}
.main-content.binding-panel {
grid-template-columns: 1fr 320px;
}
.binding-picker {
border-left: 2px solid var(--border-light);
border-left: var(--border-light);
overflow: scroll;
height: 100%;
}
.editor {
padding: var(--spacing-xl);
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.overlay-wrap {
position: relative;
flex: 1;
}
.mode-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border-radius: var(--border-radius-s);
}
.prompt-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-l);
}
.prompt-body .switch-actions {
display: flex;
gap: var(--spacing-l);
}
.binding-drawer :global(.code-editor),
.binding-drawer :global(.code-editor > div) {
height: 100%;
}
</style>

View File

@ -0,0 +1,393 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
import { handlebarsCompletions } from "constants/completions"
export let addHelper
export let addBinding
export let bindings
export let mode
export let allowHelpers
let search = ""
let popover
let popoverAnchor
let hoverTarget
let helpers = handlebarsCompletions()
let selectedCategory
$: searchRgx = new RegExp(search, "ig")
// Icons
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
</script>
<span class="detailPopover">
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<Layout noPadding gap="S">
{#if selectedCategory}
<div class="sub-section-back">
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="search">
<span class="search-input">
<Input
placeholder={"Search for bindings"}
autocomplete="off"
bind:value={search}
/>
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="search-input-icon"
on:click={() => {
if (!search) {
return
}
search = null
}}
class:searching={search}
>
<Icon name={search ? "Close" : "Search"} />
</span>
</div>
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="sub-section">
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="sub-section">
<div class="cat-heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, mode.name == "javascript")}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(
helper,
mode.name == "javascript"
),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</Layout>
<style>
.search :global(input) {
border: none;
border-radius: 0px;
background: none;
padding: 0px;
}
.search {
padding: var(--spacing-m) var(--spacing-l);
display: flex;
align-items: center;
border-top: 0px;
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
position: sticky;
top: 0;
background-color: var(--background);
z-index: 2;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
ul.category-list {
padding: 0px var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.sub-section {
padding: var(--spacing-l);
padding-top: 0px;
}
.sub-section-back {
padding: var(--spacing-l);
padding-top: var(--spacing-xl);
padding-bottom: 0px;
}
.cat-heading {
margin-bottom: var(--spacing-l);
}
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
:global(.drawer-actions) {
display: flex;
gap: var(--spacing-m);
}
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
</style>

View File

@ -5,7 +5,7 @@
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
@ -34,6 +34,10 @@
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: handleClose,
})
const onChange = (value, optionPicked) => {
// Add HBS braces if picking binding
if (optionPicked && !options?.includes(value)) {
@ -63,7 +67,6 @@
on:pick={e => onChange(e.detail, true)}
on:blur={() => dispatch("blur")}
{placeholder}
options={allOptions}
{error}
/>
{#if !disabled}
@ -77,6 +80,7 @@
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
Save
</Button>

View File

@ -4,8 +4,11 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { store } from "builderStore"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
@ -20,6 +23,7 @@
export let allowHelpers = true
export let updateOnChange = true
export let drawerLeft
export let key
const dispatch = createEventDispatcher()
let bindingDrawer
@ -32,10 +36,15 @@
const saveBinding = () => {
onChange(tempValue)
store.actions.settings.propertyFocus(null)
onBlur()
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: saveBinding,
})
const onChange = value => {
currentVal = readableToRuntimeBinding(bindings, value)
dispatch("change", currentVal)
@ -58,12 +67,24 @@
{updateOnChange}
/>
{#if !disabled}
<div class="icon" on:click={bindingDrawer.show}>
<div
class="icon"
on:click={() => {
store.actions.settings.propertyFocus(key)
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
<Drawer {fillWidth} bind:this={bindingDrawer} {title} left={drawerLeft}>
<Drawer
{fillWidth}
bind:this={bindingDrawer}
{title}
left={drawerLeft}
headless
>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>

View File

@ -113,109 +113,113 @@
})
</script>
<div class="action-top-nav">
<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()}
/>
</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>
{#if $store.hasLock}
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</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}
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
{#if !isPublished}
<ActionButton
quiet
icon="UserGroup"
icon="GlobeStrike"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<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>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
<div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button>
<DeployModal onOk={completePublish} />
{#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
</div>
<style>

View File

@ -9,6 +9,8 @@
import { store } from "builderStore"
import { API } from "api"
export let disabled = false
let revertModal
let appName
@ -34,6 +36,7 @@
size="M"
tooltip="Revert changes"
on:click={revertModal.show}
{disabled}
/>
<Modal bind:this={revertModal}>

View File

@ -58,6 +58,7 @@
justify-content: flex-start;
align-items: stretch;
transition: width 130ms ease-out;
overflow: hidden;
}
.panel.borderLeft {
border-left: var(--border-light);

View File

@ -17,14 +17,14 @@ import URLSelect from "./controls/URLSelect.svelte"
import OptionsEditor from "./controls/OptionsEditor/OptionsEditor.svelte"
import FormFieldSelect from "./controls/FormFieldSelect.svelte"
import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
const componentMap = {
text: DrawerBindableCombobox,
text: DrawerBindableInput,
select: Select,
radio: RadioGroup,
dataSource: DataSourceSelect,

View File

@ -126,8 +126,7 @@
}
const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings)
let allBindings = []
if (!actions) {
return []
}
@ -145,14 +144,35 @@
.forEach(action => {
// Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => {
const hasKey = bindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding
})
if (!hasKey) {
allBindings.push(stateBinding)
bindings.push(stateBinding)
}
})
// Get which indexes are asynchronous automations as we want to filter them out from the bindings
const asynchronousAutomationIndexes = actions
.map((action, index) => {
if (
action[EVENT_TYPE_KEY] === "Trigger Automation" &&
!action.parameters?.synchronous
) {
return index
}
})
.filter(index => index !== undefined)
// Based on the above, filter out the asynchronous automations from the bindings
if (asynchronousAutomationIndexes) {
allBindings = eventContextBindings
.filter((binding, index) => {
return !asynchronousAutomationIndexes.includes(index)
})
.concat(bindings)
} else {
allBindings = eventContextBindings.concat(bindings)
}
return allBindings
}
</script>

View File

@ -1,8 +1,8 @@
<script>
import { Select, Label, Input, Checkbox } from "@budibase/bbui"
import { Select, Label, Input, Checkbox, Icon } from "@budibase/bbui"
import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte"
import { TriggerStepID } from "constants/backend/automations"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
export let parameters = {}
export let bindings = []
@ -16,6 +16,14 @@
? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW
$: {
if (automationStatus === AUTOMATION_STATUS.NEW) {
parameters.synchronous = false
}
parameters.synchronous = automations.find(
automation => automation._id === parameters.automationId
)?.synchronous
}
$: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
.map(automation => {
@ -23,10 +31,15 @@
automation.definition.trigger.inputs.fields || {}
).map(([name, type]) => ({ name, type }))
let hasCollectBlock = automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
return {
name: automation.name,
_id: automation._id,
schema,
synchronous: hasCollectBlock,
}
})
$: hasAutomations = automations && automations.length > 0
@ -35,6 +48,8 @@
)
$: 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]) => {
@ -57,6 +72,14 @@
parameters.fields = {}
parameters.automationId = automations[0]?._id
}
const onChange = value => {
let automationId = value.detail
parameters.synchronous = automations.find(
automation => automation._id === automationId
)?.synchronous
parameters.automationId = automationId
}
</script>
<div class="root">
@ -85,6 +108,7 @@
{#if automationStatus === AUTOMATION_STATUS.EXISTING}
<Select
on:change={onChange}
bind:value={parameters.automationId}
placeholder="Choose automation"
options={automations}
@ -98,6 +122,29 @@
/>
{/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
>
</div>
</div>
<Label small />
<div class="timeout-width">
<Input
label="Timeout in seconds (120 max)"
type="number"
{error}
bind:value={parameters.timeout}
/>
</div>
{/if}
<Label small />
<Checkbox
text="Do not display default notification"
@ -133,6 +180,9 @@
max-width: 800px;
margin: 0 auto;
}
.timeout-width {
width: 30%;
}
.params {
display: grid;
@ -142,6 +192,11 @@
align-items: center;
}
.synchronous-info {
display: flex;
gap: var(--spacing-s);
}
.fields {
margin-top: var(--spacing-l);
display: grid;

View File

@ -57,7 +57,13 @@
{
"name": "Trigger Automation",
"type": "application",
"component": "TriggerAutomation"
"component": "TriggerAutomation",
"context": [
{
"label": "Automation Result",
"value": "result"
}
]
},
{
"name": "Update Field Value",

View File

@ -21,6 +21,7 @@
export let componentBindings = []
export let nested = false
export let highlighted = false
export let propertyFocus = false
export let info = null
$: nullishValue = value == null || value === ""
@ -72,6 +73,10 @@
if (highlighted) {
store.actions.settings.highlight(null)
}
// To fix focus 'affect' when property is target of a drawer other actions in the builder.
if (propertyFocus) {
store.actions.settings.propertyFocus(null)
}
})
</script>
@ -79,6 +84,7 @@
class="property-control"
class:wide={!label || labelHidden}
class:highlighted={highlighted && nullishValue}
class:property-focus={propertyFocus}
>
{#if label && !labelHidden}
<div class="label">
@ -125,6 +131,14 @@
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-static-red-600);
}
.property-control.property-focus :global(input) {
border-color: var(
--spectrum-textfield-m-border-color-down,
var(--spectrum-alias-border-color-mouse-focus)
);
}
.label {
margin-top: 16px;
transform: translateY(-50%);

View File

@ -1,5 +1,5 @@
<script>
import { goto } from "@roxi/routify"
import { goto, beforeUrlChange } from "@roxi/routify"
import {
Icon,
Select,
@ -12,6 +12,8 @@
Heading,
Tabs,
Tab,
Modal,
ModalContent,
} from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
@ -29,6 +31,12 @@
export let query
const resumeNavigation = () => {
if (typeof navigateTo == "string") {
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo)
}
}
const transformerDocs = "https://docs.budibase.com/docs/transformers"
let fields = query?.schema ? schemaToFields(query.schema) : []
@ -36,6 +44,31 @@
let data = []
let saveId
let currentTab = "JSON"
let saveModal
let override = false
let navigateTo = null
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
}
// initialise a new empty schema
if (query && !query.schema) {
query.schema = {}
}
let queryStr = JSON.stringify(query)
$beforeUrlChange(event => {
const updated = JSON.stringify(query)
if (updated !== queryStr && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
saveModal.show()
return false
} else return true
})
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields)
@ -60,11 +93,6 @@
}
}
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
}
function resetDependentFields() {
if (query.fields.extra) {
query.fields.extra = {}
@ -101,22 +129,48 @@
}
}
// return the query.
async function saveQuery() {
try {
const { _id } = await queries.save(query.datasourceId, query)
saveId = _id
notifications.success(`Query saved successfully`)
const response = await queries.save(query.datasourceId, query)
saveId = response._id
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${_id}`)
if (response?._rev) {
queryStr = JSON.stringify(query)
}
return response
} catch (error) {
notifications.error("Error saving query")
}
}
</script>
<Modal
bind:this={saveModal}
on:hide={() => {
navigateTo = null
}}
>
<ModalContent
title="You have unsaved changes"
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
await saveQuery()
override = true
resumeNavigation()
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing and changes to your query</Body>
</ModalContent>
</Modal>
<div class="wrapper">
<Layout gap="S" noPadding>
<Heading size="M">Query {integrationInfo?.friendlyName}</Heading>
@ -125,7 +179,13 @@
<div class="config">
<div class="config-field">
<Label>Query Name</Label>
<Input bind:value={query.name} />
<Input
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
query.name = newValue.trim()
}}
/>
</div>
{#if queryConfig}
<div class="config-field">
@ -149,18 +209,20 @@
/>
{/if}
{#key query.parameters}
<BindingBuilder
queryBindings={query.parameters}
bindable={false}
on:change={e => {
query.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
<div class="binding-wrap">
<BindingBuilder
queryBindings={query.parameters}
bindable={false}
on:change={e => {
query.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
</div>
{/key}
{/if}
</div>
@ -203,7 +265,18 @@
<div class="viewer-controls">
<Heading size="S">Results</Heading>
<ButtonGroup gap="XS">
<Button cta disabled={queryInvalid} on:click={saveQuery}>
<Button
cta
disabled={queryInvalid}
on:click={async () => {
await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${query._id}`)
}
}}
>
Save Query
</Button>
<Button secondary on:click={previewQuery}>Run Query</Button>
@ -274,4 +347,9 @@
min-width: 150px;
align-items: center;
}
.binding-wrap :global(div.container) {
padding-left: 0px;
padding-right: 0px;
}
</style>

View File

@ -71,6 +71,9 @@
tourStep.onComplete()
}
popover.hide()
if (tourStep.endRoute) {
$goto(tourStep.endRoute)
}
}
}

View File

@ -76,6 +76,7 @@ const getTours = () => {
title: "Publish",
layout: OnboardingPublish,
route: "/builder/app/:application/design",
endRoute: "/builder/app/:application/data",
query: ".toprightnav #builder-app-publish-button",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_APP_PUBLISH)

View File

@ -1,13 +1,14 @@
<script>
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui"
import AppLockModal from "../common/AppLockModal.svelte"
import { Heading, Body, Button, Icon } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates"
import { goto } from "@roxi/routify"
import { UserAvatar } from "@budibase/frontend-core"
export let app
export let lockedAction
$: editing = app?.lockedBy != null
const handleDefaultClick = () => {
if (window.innerWidth < 640) {
goToOverview()
@ -17,12 +18,6 @@
}
const goToBuilder = () => {
if (app.lockedOther) {
notifications.error(
`App locked by ${app.lockedBy.email}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../app/${app.devId}`)
}
@ -44,7 +39,10 @@
</div>
<div class="updated">
{#if app.updatedAt}
{#if editing}
Currently editing
<UserAvatar user={app.lockedBy} />
{:else if app.updatedAt}
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: new Date().getTime() - new Date(app.updatedAt).getTime(),
})}
@ -59,12 +57,12 @@
</div>
<div class="app-row-actions">
<AppLockModal {app} buttonSize="M" />
<Button size="S" secondary on:click={lockedAction || goToOverview}
>Manage</Button
>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
>
<Button size="S" secondary on:click={lockedAction || goToOverview}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
Edit
</Button>
</div>
</div>
@ -87,6 +85,9 @@
.updated {
color: var(--spectrum-global-color-gray-700);
display: flex;
align-items: center;
gap: 8px;
}
.title,

View File

@ -1,12 +1,6 @@
<script>
import { writable, get as svelteGet } from "svelte/store"
import {
notifications,
Input,
ModalContent,
Dropzone,
Toggle,
} from "@budibase/bbui"
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore } from "builderStore"
import { API } from "api"
import { apps, admin, auth } from "stores/portal"
@ -22,7 +16,6 @@
let creating = false
let defaultAppName
let includeSampleDB = true
const values = writable({ name: "", url: null })
const validation = createValidationStore()
@ -117,8 +110,6 @@
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file)
} else {
data.append("sampleData", includeSampleDB)
}
// Create App
@ -213,15 +204,6 @@
</div>
{/if}
</span>
{#if !template && !template?.fromFile}
<span>
<Toggle
text="Include sample data"
bind:value={includeSampleDB}
disabled={creating}
/>
</span>
{/if}
</ModalContent>
<style>

View File

@ -20,9 +20,14 @@ export const ActionStepID = {
FILTER: "FILTER",
QUERY_ROWS: "QUERY_ROWS",
LOOP: "LOOP",
COLLECT: "COLLECT",
// these used to be lowercase step IDs, maintain for backwards compat
discord: "discord",
slack: "slack",
zapier: "zapier",
integromat: "integromat",
}
export const Features = {
LOOPING: "LOOPING",
}

View File

@ -0,0 +1,28 @@
<script>
import { UserAvatar } from "@budibase/frontend-core"
export let users = []
$: uniqueUsers = unique(users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script>
<div class="avatars">
{#each uniqueUsers as user}
<UserAvatar {user} tooltipDirection="bottom" />
{/each}
</div>
<style>
.avatars {
display: flex;
gap: 4px;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { store, automationStore } from "builderStore"
import { store, automationStore, userStore } from "builderStore"
import { roles, flags } from "stores/backend"
import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
@ -13,7 +13,6 @@
Modal,
notifications,
} from "@budibase/bbui"
import AppActions from "components/deploy/AppActions.svelte"
import { API } from "api"
import { isActive, goto, layout, redirect } from "@roxi/routify"
@ -23,6 +22,7 @@
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import UserAvatars from "./_components/UserAvatars.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application
@ -30,7 +30,9 @@
let promise = getPackage()
let hasSynced = false
let commandPaletteModal
let loaded = false
$: loaded && initTour()
$: selected = capitalise(
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
)
@ -43,6 +45,7 @@
await automationStore.actions.fetch()
await roles.fetch()
await flags.fetch()
loaded = true
return pkg
} catch (error) {
notifications.error(`Error initialising app: ${error?.message}`)
@ -67,13 +70,18 @@
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && $store.hasLock) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
const initTour = async () => {
// Skip tour if we don't have the lock
if (!$store.hasLock) {
return
}
// Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) {
@ -110,7 +118,6 @@
// check if user has beta access
// const betaResponse = await API.checkBetaAccess($auth?.user?.email)
// betaAccess = betaResponse.access
initTour()
} catch (error) {
notifications.error("Failed to sync with production database")
}
@ -119,10 +126,11 @@
})
onDestroy(() => {
store.update(state => {
state.appId = null
return state
})
// Run async on a slight delay to let other cleanup logic run without
// being confused by the store wiping
setTimeout(() => {
store.actions.reset()
}, 10)
})
</script>
@ -134,74 +142,89 @@
<div class="root">
<div class="top-nav">
<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>
{#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">
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab
quiet
selected={$isActive(path)}
on:click={topItemNavigate(path)}
title={capitalise(title)}
id={`builder-${title}-tab`}
/>
</TourWrap>
{/each}
</Tabs>
</div>
<div class="toprightnav">
<AppActions {application} />
</div>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
{#if $store.hasLock}
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab
quiet
selected={$isActive(path)}
on:click={topItemNavigate(path)}
title={capitalise(title)}
id={`builder-${title}-tab`}
/>
</TourWrap>
{/each}
</Tabs>
{:else}
<div class="secondary-editor">
<Icon name="LockClosed" />
Another user is currently editing your screens and automations
</div>
{/if}
</div>
<div class="toprightnav">
<UserAvatars users={$userStore} />
<AppActions {application} />
</div>
{/if}
</div>
{#await promise}
<!-- This should probably be some kind of loading state? -->
<div class="loading" />
{:then _}
<slot />
<div class="body">
<slot />
</div>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
@ -237,6 +260,7 @@
box-sizing: border-box;
align-items: stretch;
border-bottom: var(--border-light);
z-index: 2;
}
.topleftnav {
@ -270,4 +294,18 @@
align-items: center;
gap: var(--spacing-l);
}
.secondary-editor {
align-self: center;
display: flex;
flex-direction: row;
gap: 8px;
}
.body {
flex: 1 1 auto;
z-index: 1;
display: flex;
flex-direction: column;
}
</style>

View File

@ -8,6 +8,15 @@
import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
// Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({

View File

@ -2,13 +2,11 @@
import { Button } from "@budibase/bbui"
</script>
<div class="beta-background" />
<div class="beta">
Enjoying the Grid?
<Button
size="M"
cta
on:click={() => window.open("https://t.maze.co/156382627", "_blank")}
on:click={() => window.open("https://t.maze.co/165900794", "_blank")}
>
Give Feedback
</Button>
@ -17,30 +15,16 @@
<style>
.beta {
position: absolute;
bottom: 32px;
right: 32px;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
z-index: 10;
}
.beta :global(.spectrum-Button) {
background: var(--spectrum-global-color-magenta-400);
border-color: var(--spectrum-global-color-magenta-400);
}
.beta-background {
z-index: 0;
pointer-events: none;
position: absolute;
bottom: -230px;
right: -105px;
width: 1400px;
height: 320px;
transform: rotate(-22deg);
background: linear-gradient(
to top,
var(--cell-background) 20%,
transparent
);
}
</style>

View File

@ -0,0 +1,51 @@
<script>
import { Body, Label } from "@budibase/bbui"
export let title
export let description
export let disabled
</script>
<div on:click class:disabled class="option">
<div class="header">
<div class="icon">
<slot />
</div>
<Body>{title}</Body>
</div>
<Label>{description}</Label>
</div>
<style>
.option {
background-color: var(--background);
border: 1px solid var(--grey-4);
padding: 10px 16px 14px;
border-radius: 4px;
cursor: pointer;
}
.option :global(label) {
cursor: pointer;
}
.option:hover {
background-color: var(--background-alt);
}
.header {
display: flex;
margin-bottom: 8px;
align-items: center;
}
.icon {
display: flex;
margin-right: 8px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@ -1,25 +1,26 @@
<script>
import { Button, Layout } from "@budibase/bbui"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
import Panel from "components/design/Panel.svelte"
let modal
import { isActive, goto } from "@roxi/routify"
import BetaButton from "./_components/BetaButton.svelte"
</script>
<!-- routify:options index=1 -->
<div class="data">
<Panel title="Sources" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Button cta on:click={modal.show}>Add source</Button>
<CreateDatasourceModal bind:modal />
<DatasourceNavigator />
</Layout>
</Panel>
{#if !$isActive("./new")}
<Panel title="Sources" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Button cta on:click={() => $goto("./new")}>Add source</Button>
<DatasourceNavigator />
</Layout>
</Panel>
{/if}
<div class="content">
<slot />
</div>
<BetaButton />
</div>
<style>
@ -40,5 +41,6 @@
justify-content: flex-start;
align-items: stretch;
flex: 1 1 auto;
z-index: 1;
}
</style>

View File

@ -1,22 +1,17 @@
<script>
import { redirect } from "@roxi/routify"
import { onMount } from "svelte"
import { admin } from "stores/portal"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
import { datasources } from "stores/backend"
let modal
$: setupComplete =
$: hasData =
$datasources.list.find(x => (x._id = "bb_internal"))?.entities?.length >
1 || $datasources.list.length > 1
onMount(() => {
if (!setupComplete && !$admin.isDev) {
modal.show()
if (!hasData) {
$redirect("./new")
} else {
$redirect("./table")
}
})
</script>
<CreateDatasourceModal bind:modal />

View File

@ -0,0 +1,257 @@
<script>
import { API } from "api"
import { tables, datasources } from "stores/backend"
import { Icon, Modal, notifications, Heading, Body } from "@budibase/bbui"
import { params, goto } from "@roxi/routify"
import {
IntegrationTypes,
DatasourceTypes,
DEFAULT_BB_DATASOURCE_ID,
} from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import DatasourceOption from "./_components/DatasourceOption.svelte"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons/index.js"
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
let internalTableModal
let externalDatasourceModal
let integrations = []
let integration = null
let disabled = false
let promptUpload = false
$: hasData = $datasources.list.length > 1 || $tables.list.length > 1
$: hasDefaultData =
$datasources.list.findIndex(
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
) !== -1
const createSampleData = async () => {
disabled = true
try {
await API.addSampleData($params.application)
await tables.fetch()
await datasources.fetch()
$goto("./table")
} catch (e) {
disabled = false
notifications.error("Error creating datasource")
}
}
const handleIntegrationSelect = integrationType => {
const selected = integrations.find(([type]) => type === integrationType)[1]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
auth: selected.auth,
features: selected.features || [],
}
if (selected.friendlyName) {
integration.name = selected.friendlyName
}
if (integration.type === IntegrationTypes.REST) {
disabled = true
// Skip modal for rest, create straight away
createRestDatasource(integration)
.then(response => {
$goto(`./datasource/${response._id}`)
})
.catch(() => {
disabled = false
notifications.error("Error creating datasource")
})
} else {
externalDatasourceModal.show()
}
}
const handleInternalTable = () => {
promptUpload = false
internalTableModal.show()
}
const handleDataImport = () => {
promptUpload = true
internalTableModal.show()
}
const handleInternalTableSave = table => {
notifications.success(`Table created successfully.`)
$goto(`./table/${table._id}`)
}
function sortIntegrations(integrations) {
let integrationsArray = Object.entries(integrations)
function getTypeOrder(schema) {
if (schema.type === DatasourceTypes.API) {
return 1
}
if (schema.type === DatasourceTypes.RELATIONAL) {
return 2
}
return schema.type?.charCodeAt(0)
}
integrationsArray.sort((a, b) => {
let typeOrderA = getTypeOrder(a[1])
let typeOrderB = getTypeOrder(b[1])
if (typeOrderA === typeOrderB) {
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
}
return typeOrderA < typeOrderB ? -1 : 1
})
return integrationsArray
}
const fetchIntegrations = async () => {
const unsortedIntegrations = await API.getIntegrations()
integrations = sortIntegrations(unsortedIntegrations)
}
$: fetchIntegrations()
</script>
<Modal bind:this={internalTableModal}>
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
</Modal>
<Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} />
{:else}
<DatasourceConfigModal {integration} />
{/if}
</Modal>
<div class="page">
<div class="closeButton">
{#if hasData}
<Icon hoverable name="Close" on:click={$goto("./table")} />
{/if}
</div>
<div class="heading">
<Heading weight="light">Add new data source</Heading>
</div>
<div class="subHeading">
<Body>Get started with our Budibase DB</Body>
<div
role="tooltip"
title="Budibase DB is built with CouchDB"
class="tooltip"
>
<FontAwesomeIcon name="fa-solid fa-circle-info" />
</div>
</div>
<div class="options">
<DatasourceOption
on:click={handleInternalTable}
title="Create new table"
description="Non-relational"
{disabled}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
<DatasourceOption
on:click={createSampleData}
title="Use sample data"
description="Non-relational"
disabled={disabled || hasDefaultData}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
<DatasourceOption
on:click={handleDataImport}
title="Upload data"
description="Non-relational"
{disabled}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
</div>
<div class="subHeading">
<Body>Or connect to an external datasource</Body>
</div>
<div class="options">
{#each integrations as [key, value]}
<DatasourceOption
on:click={() => handleIntegrationSelect(key)}
title={value.friendlyName}
description={value.type}
{disabled}
>
<IntegrationIcon integrationType={key} schema={value} />
</DatasourceOption>
{/each}
</div>
</div>
<style>
.page {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.closeButton {
height: 38px;
display: flex;
justify-content: right;
width: 100%;
}
.heading {
margin-bottom: 12px;
}
.subHeading {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.tooltip {
margin-left: 6px;
}
.options {
width: 100%;
display: grid;
column-gap: 24px;
row-gap: 24px;
grid-template-columns: repeat(auto-fit, 235px);
justify-content: center;
margin-bottom: 48px;
max-width: 1050px;
}
</style>

View File

@ -3,8 +3,10 @@
import QueryViewer from "components/integration/QueryViewer.svelte"
import RestQueryViewer from "components/integration/RestQueryViewer.svelte"
import { IntegrationTypes } from "constants/backend"
import { cloneDeep } from "lodash/fp"
$: query = $queries.selected
$: editableQuery = cloneDeep(query)
$: datasource = $datasources.list.find(ds => ds._id === query?.datasourceId)
$: isRestQuery = datasource?.source === IntegrationTypes.REST
</script>
@ -13,6 +15,6 @@
{#if isRestQuery}
<RestQueryViewer queryId={$queries.selectedQueryId} />
{:else}
<QueryViewer {query} />
<QueryViewer query={editableQuery} />
{/if}
{/if}

View File

@ -101,7 +101,12 @@
}
// Ignore events when typing
const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") {
const inCodeEditor =
document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
return
}
// Key events are always for the selected component

View File

@ -140,6 +140,7 @@
nested={setting.nested}
onChange={val => updateSetting(setting, val)}
highlighted={$store.highlightedSettingKey === setting.key}
propertyFocus={$store.propertyFocus === setting.key}
info={setting.info}
props={{
// Generic settings

View File

@ -1,2 +1,14 @@
<script>
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
</script>
<!-- routify:options index=2 -->
<slot />

View File

@ -5,7 +5,6 @@
Divider,
ActionMenu,
MenuItem,
Avatar,
Page,
Icon,
Body,
@ -22,6 +21,8 @@
import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core"
let loaded = false
let userInfoModal
@ -96,11 +97,7 @@
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right">
<div slot="control" class="avatar">
<Avatar
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" />
</div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
@ -125,7 +122,7 @@
</div>
<Layout noPadding gap="XS">
<Heading size="M">
Hey {$auth.user.firstName || $auth.user.email}
Hey {helpers.getUserLabel($auth.user)}
</Heading>
<Body>
Welcome to the {$organisation.company} portal. Below you'll find the

View File

@ -1,11 +1,12 @@
<script>
import { auth } from "stores/portal"
import { ActionMenu, Avatar, MenuItem, Icon, Modal } from "@budibase/bbui"
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import ProfileModal from "components/settings/ProfileModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import ThemeModal from "components/settings/ThemeModal.svelte"
import APIKeyModal from "components/settings/APIKeyModal.svelte"
import { UserAvatar } from "@budibase/frontend-core"
let themeModal
let profileModal
@ -23,7 +24,7 @@
<ActionMenu align="right">
<div slot="control" class="user-dropdown">
<Avatar size="M" initials={$auth.initials} url={$auth.user.pictureUrl} />
<UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" />
</div>
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}>

View File

@ -1,47 +1,9 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let row
let showTooltip
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
</script>
{#if row?.user?.email}
<div
class="container"
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row.user)} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping text={row.user.email} direction="bottom" />
</div>
{/if}
<UserAvatar user={row.user} />
{/if}
<style>
.container {
position: relative;
}
.tooltip {
z-index: 1;
position: absolute;
top: 75%;
left: 120%;
transform: translateX(-100%) translateY(-50%);
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 130px;
pointer-events: none;
}
</style>

View File

@ -355,7 +355,6 @@
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
overflow: hidden;
}
.empty-wrapper {

View File

@ -1,13 +0,0 @@
<script>
import PanelHeader from "./PanelHeader.svelte"
export let onBack = () => {}
</script>
<div>
<PanelHeader
title="Give it some data"
subtitle="Not ready to add yours? Get started with sample data!"
{onBack}
/>
<slot />
</div>

View File

@ -1,120 +0,0 @@
<script>
import { Button, FancyForm, FancyInput, FancyCheckbox } from "@budibase/bbui"
import GoogleButton from "components/backend/DatasourceNavigator/_components/GoogleButton.svelte"
import { capitalise } from "helpers/helpers"
import PanelHeader from "./PanelHeader.svelte"
import { helpers } from "@budibase/shared-core"
export let title = ""
export let onBack = null
export let onNext = () => {}
export let fields = {}
export let type = ""
let errors = {}
const formatName = name => {
if (name === "ca") {
return "CA"
}
if (name === "ssl") {
return "SSL"
}
if (name === "rejectUnauthorized") {
return "Reject Unauthorized"
}
return capitalise(name)
}
const getDefaultValues = fields => {
const newValues = {}
Object.entries(fields).forEach(([name, { default: defaultValue }]) => {
if (defaultValue) {
newValues[name] = defaultValue
}
})
return newValues
}
const values = getDefaultValues(fields)
const validateRequired = value => {
if (value.length < 1) {
return "Required field"
}
}
const getIsValid = (fields, errors, values) => {
for (const [name, { required }] of Object.entries(fields)) {
if (required && !values[name]) {
return false
}
}
return Object.values(errors).every(error => !error)
}
$: isValid = getIsValid(fields, errors, values)
$: isGoogle = helpers.isGoogleSheets(type)
const handleNext = async () => {
const parsedValues = {}
Object.entries(values).forEach(([name, value]) => {
if (fields[name].type === "number") {
parsedValues[name] = parseInt(value, 10)
} else {
parsedValues[name] = value
}
})
if (isGoogle) {
parsedValues.isGoogle = isGoogle
}
return await onNext(parsedValues)
}
</script>
<div>
<PanelHeader
{title}
subtitle="Fill in the required fields to fetch your tables"
{onBack}
/>
<div class="form">
<FancyForm>
{#each Object.entries(fields) as [name, { type, default: defaultValue, required }]}
{#if type !== "boolean"}
<FancyInput
bind:value={values[name]}
bind:error={errors[name]}
validate={required ? validateRequired : () => {}}
label={formatName(name)}
{type}
/>
{/if}
{/each}
{#each Object.entries(fields) as [name, { type, default: defaultValue, required }]}
{#if type === "boolean"}
<FancyCheckbox bind:value={values[name]} text={formatName(name)} />
{/if}
{/each}
</FancyForm>
</div>
{#if isGoogle}
<GoogleButton disabled={!isValid} preAuthStep={handleNext} samePage />
{:else}
<Button cta disabled={!isValid} on:click={handleNext}>Connect</Button>
{/if}
</div>
<style>
.form {
margin-bottom: 36px;
}
</style>

View File

@ -1,6 +1,5 @@
<script>
export let name = ""
export let showData = false
const rows = [
{
@ -49,7 +48,7 @@
<h1>{name}</h1>
</div>
<div class="nav">Home</div>
<table class={`table ${showData ? "tableVisible" : ""}`}>
<table>
<thead>
<tr>
<th>FIRST NAME</th>
@ -71,7 +70,7 @@
{/each}
</tbody>
</table>
<div class={`sidePanel ${showData ? "sidePanelVisible" : ""}`}>
<div class="sidePanel">
<h2>{rows[0].firstName}</h2>
<div class="field">
<label for="exampleLastName">lastName</label>
@ -199,14 +198,6 @@
text-align: left;
}
.table {
opacity: 0;
}
.tableVisible {
opacity: 1;
}
.sidePanel {
position: absolute;
width: 300px;
@ -216,9 +207,6 @@
top: 0;
right: -364px;
padding: 42px 32px;
}
.sidePanelVisible {
right: 0;
}

View File

@ -3,6 +3,7 @@
import PanelHeader from "./PanelHeader.svelte"
import { APP_URL_REGEX } from "constants"
export let disabled
export let name = ""
export let url = ""
export let onNext = () => {}
@ -71,7 +72,9 @@
{:else}
<p></p>
{/if}
<Button size="L" cta disabled={!isValid} on:click={onNext}>Lets go!</Button>
<Button size="L" cta disabled={!isValid || disabled} on:click={onNext}
>Lets go!</Button
>
</div>
<style>

View File

@ -1,102 +1,50 @@
<script>
import { goto } from "@roxi/routify"
import NamePanel from "./_components/NamePanel.svelte"
import DataPanel from "./_components/DataPanel.svelte"
import DatasourceConfigPanel from "./_components/DatasourceConfigPanel.svelte"
import ExampleApp from "./_components/ExampleApp.svelte"
import { FancyButton, notifications, Modal, Body } from "@budibase/bbui"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import { notifications } from "@budibase/bbui"
import { SplitPage } from "@budibase/frontend-core"
import { API } from "api"
import { store, automationStore } from "builderStore"
import { saveDatasource } from "builderStore/datasource"
import { integrations } from "stores/backend"
import { auth, admin, organisation } from "stores/portal"
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import { auth, admin } from "stores/portal"
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
import { Roles } from "constants/backend"
import Spinner from "components/common/Spinner.svelte"
import { helpers } from "@budibase/shared-core"
import { validateDatasourceConfig } from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
let name = "My first app"
let url = "my-first-app"
let stage = "name"
let appId = null
let plusIntegrations = {}
let integrationsLoading = true
let creationLoading = false
let uploadModal
let googleComplete = false
let loading = false
$: getIntegrations()
const createApp = async () => {
loading = true
const createApp = async useSampleData => {
creationLoading = true
// Create form data to create app
// This is form based and not JSON
try {
let data = new FormData()
data.append("name", name.trim())
data.append("url", url.trim())
data.append("useTemplate", false)
let data = new FormData()
data.append("name", name.trim())
data.append("url", url.trim())
data.append("useTemplate", false)
if (useSampleData) {
data.append("sampleData", true)
}
const createdApp = await API.createApp(data)
const createdApp = await API.createApp(data)
// Select Correct Application/DB in prep for creating user
const pkg = await API.fetchAppPackage(createdApp.instance._id)
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
// Update checklist - in case first app
await admin.init()
// Select Correct Application/DB in prep for creating user
const pkg = await API.fetchAppPackage(createdApp.instance._id)
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
// Update checklist - in case first app
await admin.init()
// Create user
await auth.setInitInfo({})
// Create user
await auth.setInitInfo({})
let defaultScreenTemplate = createFromScratchScreen.create()
defaultScreenTemplate.routing.route = "/home"
defaultScreenTemplate.routing.roldId = Roles.BASIC
await store.actions.screens.save(defaultScreenTemplate)
let defaultScreenTemplate = createFromScratchScreen.create()
defaultScreenTemplate.routing.route = "/home"
defaultScreenTemplate.routing.roldId = Roles.BASIC
await store.actions.screens.save(defaultScreenTemplate)
appId = createdApp.instance._id
return createdApp
} catch (e) {
creationLoading = false
throw e
}
}
const getIntegrations = async () => {
try {
await integrations.init()
const newPlusIntegrations = {}
Object.entries($integrations).forEach(([integrationType, schema]) => {
// google sheets not available in self-host
if (
helpers.isGoogleSheets(integrationType) &&
!$organisation.googleDatasourceConfigured
) {
return
}
if (schema?.plus) {
newPlusIntegrations[integrationType] = schema
}
})
plusIntegrations = newPlusIntegrations
} catch (e) {
notifications.error("There was a problem communicating with the server.")
} finally {
integrationsLoading = false
}
appId = createdApp.instance._id
return createdApp
}
const goToApp = () => {
@ -104,152 +52,23 @@
notifications.success(`App created successfully`)
}
const handleCreateApp = async ({
datasourceConfig,
useSampleData,
isGoogle,
}) => {
let app
const handleCreateApp = async () => {
try {
if (
datasourceConfig &&
plusIntegrations[stage].features[DatasourceFeature.CONNECTION_CHECKING]
) {
const resp = await validateDatasourceConfig({
config: datasourceConfig,
type: stage,
})
if (!resp.connected) {
notifications.error(
`Unable to connect - ${resp.error ?? "Error validating datasource"}`
)
return false
}
}
await createApp()
app = await createApp(useSampleData)
let datasource
if (datasourceConfig) {
datasource = await saveDatasource({
plus: true,
auth: undefined,
name: plusIntegrations[stage].friendlyName,
schema: plusIntegrations[stage].datasource,
config: datasourceConfig,
type: stage,
})
}
store.set()
if (isGoogle) {
googleComplete = true
return { datasource, appId: app.appId }
} else {
goToApp()
}
goToApp()
} catch (e) {
console.log(e)
creationLoading = false
loading = false
notifications.error("There was a problem creating your app")
// Reset the store so that we don't send up stale headers
store.actions.reset()
// If we successfully created an app, delete it again so that we
// can try again once the error has been corrected.
// This also ensures onboarding can't be skipped by entering invalid
// data credentials.
if (app?.appId) {
await API.deleteApp(app.appId)
}
}
}
</script>
<Modal bind:this={uploadModal}>
<CreateTableModal
name="Your Data"
beforeSave={createApp}
afterSave={goToApp}
/>
</Modal>
<div class="full-width">
<SplitPage>
{#if stage === "name"}
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
{:else if googleComplete}
<div class="centered">
<Body
>Please login to your Google account in the new tab which as opened to
continue.</Body
>
</div>
{:else if integrationsLoading || creationLoading}
<div class="centered">
<Spinner />
</div>
{:else if stage === "data"}
<DataPanel onBack={() => (stage = "name")}>
<div class="dataButton">
<FancyButton
on:click={() => handleCreateApp({ useSampleData: true })}
>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<img
alt="Budibase Logo"
class="budibaseLogo"
src={"https://i.imgur.com/Xhdt1YP.png"}
/>
</div>
Budibase Sample data
</div>
</FancyButton>
</div>
<div class="dataButton">
<FancyButton on:click={uploadModal.show}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
</div>
Upload data (CSV or JSON)
</div>
</FancyButton>
</div>
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
<div class="dataButton">
<FancyButton on:click={() => (stage = integrationType)}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<IntegrationIcon {integrationType} {schema} />
</div>
{schema.friendlyName}
</div>
</FancyButton>
</div>
{/each}
</DataPanel>
{:else if stage in plusIntegrations}
<DatasourceConfigPanel
title={plusIntegrations[stage].friendlyName}
fields={plusIntegrations[stage].datasource}
type={stage}
onBack={() => (stage = "data")}
onNext={data => {
const isGoogle = data.isGoogle
delete data.isGoogle
return handleCreateApp({ datasourceConfig: data, isGoogle })
}}
/>
{:else}
<p>There was an problem. Please refresh the page and try again.</p>
{/if}
<NamePanel bind:name bind:url disabled={loading} onNext={handleCreateApp} />
<div slot="right">
<ExampleApp {name} showData={stage !== "name"} />
<ExampleApp {name} />
</div>
</SplitPage>
</div>
@ -258,35 +77,4 @@
.full-width {
width: 100%;
}
.centered {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.dataButton {
margin-bottom: 12px;
}
.dataButtonContent {
display: flex;
align-items: center;
}
.budibaseLogo {
height: 20px;
}
.dataButtonIcon {
width: 22px;
display: flex;
justify-content: center;
margin-right: 16px;
}
.dataButtonContent :global(svg) {
font-size: 18px;
color: white;
}
</style>

View File

@ -20,11 +20,10 @@
Breadcrumb,
Header,
} from "components/portal/page"
import { apps, auth, overview } from "stores/portal"
import { apps, overview } from "stores/portal"
import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -53,8 +52,6 @@
$: appId = $overview.selectedAppId
$: initialiseApp(appId)
$: isPublished = app?.status === AppStatus.DEPLOYED
$: appLocked = !!app?.lockedBy
$: lockedByYou = $auth.user.email === app?.lockedBy?.email
const initialiseApp = async appId => {
loaded = false
@ -80,13 +77,6 @@
}
const editApp = () => {
if (appLocked && !lockedByYou) {
const identifier = app?.lockedBy?.firstName || app?.lockedBy?.email
notifications.warning(
`App locked by ${identifier}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`)
}
@ -135,7 +125,6 @@
/>
</div>
<div slot="buttons">
<AppLockModal {app} />
<span class="desktop">
<Button
size="M"
@ -148,14 +137,7 @@
</Button>
</span>
<span class="desktop">
<Button
size="M"
cta
disabled={appLocked && !lockedByYou}
on:click={editApp}
>
Edit
</Button>
<Button size="M" cta on:click={editApp}>Edit</Button>
</span>
<ActionMenu align="right">
<span slot="control" class="app-overview-actions-icon">
@ -167,13 +149,7 @@
</MenuItem>
</span>
<span class="mobile">
<MenuItem
icon="Edit"
disabled={appLocked && !lockedByYou}
on:click={editApp}
>
Edit
</MenuItem>
<MenuItem icon="Edit" on:click={editApp}>Edit</MenuItem>
</span>
<MenuItem
on:click={() => exportApp({ published: false })}

View File

@ -1,14 +1,11 @@
<script>
import getUserInitials from "helpers/userInitials.js"
import { Avatar } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let value
$: initials = getUserInitials(value)
</script>
<div title={value.email} class="cell">
<Avatar size="M" {initials} />
<div class="cell">
<UserAvatar user={value} />
</div>
<style>

View File

@ -7,7 +7,6 @@
Icon,
Heading,
Link,
Avatar,
Layout,
Body,
notifications,
@ -15,7 +14,7 @@
import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups, overview } from "stores/portal"
import { fetchData } from "@budibase/frontend-core"
import { fetchData, UserAvatar } from "@budibase/frontend-core"
import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -52,18 +51,30 @@
return groups.actions.getGroupAppIds(group).includes(prodAppId)
})
const updateDeploymentString = () => {
return deployments?.length
? processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)
: ""
}
// App is updating in the layout asynchronously
$: if ($store.appId?.length) {
fetchDeployments().then(resp => {
deployments = resp
})
}
$: deploymentString = updateDeploymentString(deployments)
async function fetchAppEditor(editorId) {
appEditor = await users.get(editorId)
}
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
const confirmUnpublishApp = async () => {
try {
await API.unpublishApp(app.prodId)
@ -116,19 +127,11 @@
</div>
<div class="status-text">
{#if deployments?.length}
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)}
{#if isPublished}
- <Link on:click={unpublishModal.show}>Unpublish</Link>
{/if}
{#if isPublished}
{deploymentString}
- <Link on:click={unpublishModal.show}>Unpublish</Link>
{/if}
{#if !deployments?.length}
-
{/if}
@ -140,7 +143,7 @@
<div class="last-edited-content">
<div class="updated-by">
{#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} />
<UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText}
</div>
@ -201,7 +204,7 @@
<div class="users">
<div class="list">
{#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} />
<UserAvatar {user} />
{/each}
</div>
<div class="text">

View File

@ -58,7 +58,7 @@
}
onMount(async () => {
await Promise.all(fetchConfig(), fetchAPIKey())
await Promise.all([fetchConfig(), fetchAPIKey()])
})
const copyToClipboard = async value => {

View File

@ -2,7 +2,6 @@
import { goto, url } from "@roxi/routify"
import {
ActionMenu,
Avatar,
Button,
Layout,
Heading,
@ -25,13 +24,14 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.svelte"
import GroupIcon from "../groups/_components/GroupIcon.svelte"
import { Constants } from "@budibase/frontend-core"
import { Constants, UserAvatar } from "@budibase/frontend-core"
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte"
import { helpers } from "@budibase/shared-core"
export let userId
@ -91,7 +91,7 @@
$: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global
$: nameLabel = getNameLabel(user)
$: initials = getInitials(nameLabel)
$: initials = helpers.getUserInitials(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => {
@ -150,17 +150,6 @@
return label
}
const getInitials = nameLabel => {
if (!nameLabel) {
return "?"
}
return nameLabel
.split(" ")
.slice(0, 2)
.map(x => x[0])
.join("")
}
async function updateUserFirstName(evt) {
try {
await users.save({ ...user, firstName: evt.target.value })
@ -238,7 +227,7 @@
<div class="title">
<div class="user-info">
<Avatar size="XXL" {initials} />
<UserAvatar size="XXL" {user} showTooltip={false} />
<div class="subtitle">
<Heading size="M">{nameLabel}</Heading>
{#if nameLabel !== user?.email}

View File

@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"
import { writable, derived, get } from "svelte/store"
import { queries, tables } from "./"
import { API } from "api"
@ -91,6 +91,39 @@ export function createDatasourcesStore() {
})
}
// Handles external updates of datasources
const replaceDatasource = (datasourceId, datasource) => {
if (!datasourceId) {
return
}
// Handle deletion
if (!datasource) {
store.update(state => ({
...state,
list: state.list.filter(x => x._id !== datasourceId),
}))
return
}
// Add new datasource
const index = get(store).list.findIndex(x => x._id === datasource._id)
if (index === -1) {
store.update(state => ({
...state,
list: [...state.list, datasource],
}))
}
// Update existing datasource
else if (datasource) {
store.update(state => {
state.list[index] = datasource
return state
})
}
}
return {
subscribe: derivedStore.subscribe,
fetch,
@ -100,6 +133,7 @@ export function createDatasourcesStore() {
save,
delete: deleteDatasource,
removeSchemaError,
replaceDatasource,
}
}

View File

@ -22,18 +22,6 @@ export function createTablesStore() {
}))
}
const fetchTable = async tableId => {
const table = await API.fetchTableDefinition(tableId)
store.update(state => {
const indexToUpdate = state.list.findIndex(t => t._id === table._id)
state.list[indexToUpdate] = table
return {
...state,
}
})
}
const select = tableId => {
store.update(state => ({
...state,
@ -74,20 +62,21 @@ export function createTablesStore() {
}
const savedTable = await API.saveTable(updatedTable)
await fetch()
if (table.type === "external") {
await datasources.fetch()
}
await select(savedTable._id)
replaceTable(savedTable._id, savedTable)
await datasources.fetch()
select(savedTable._id)
return savedTable
}
const deleteTable = async table => {
if (!table?._id || !table?._rev) {
return
}
await API.deleteTable({
tableId: table?._id,
tableRev: table?._rev,
tableId: table._id,
tableRev: table._rev,
})
await fetch()
replaceTable(table._id, null)
}
const saveField = async ({
@ -135,35 +124,56 @@ export function createTablesStore() {
await save(draft)
}
const updateTable = table => {
const index = get(store).list.findIndex(x => x._id === table._id)
if (index === -1) {
// Handles external updates of tables
const replaceTable = (tableId, table) => {
if (!tableId) {
return
}
// This function has to merge state as there discrepancies with the table
// API endpoints. The table list endpoint and get table endpoint use the
// "type" property to mean different things.
store.update(state => {
state.list[index] = {
...table,
type: state.list[index].type,
}
return state
})
// Handle deletion
if (!table) {
store.update(state => ({
...state,
list: state.list.filter(x => x._id !== tableId),
}))
return
}
// Add new table
const index = get(store).list.findIndex(x => x._id === table._id)
if (index === -1) {
store.update(state => ({
...state,
list: [...state.list, table],
}))
}
// Update existing table
else if (table) {
// This function has to merge state as there discrepancies with the table
// API endpoints. The table list endpoint and get table endpoint use the
// "type" property to mean different things.
store.update(state => {
state.list[index] = {
...table,
type: state.list[index].type,
}
return state
})
}
}
return {
...store,
subscribe: derivedStore.subscribe,
fetch,
fetchTable,
init: fetch,
select,
save,
delete: deleteTable,
saveField,
deleteField,
updateTable,
replaceTable,
}
}

View File

@ -1,4 +1,4 @@
import { writable, get, derived } from "svelte/store"
import { writable, derived } from "svelte/store"
import { tables } from "./"
import { API } from "api"
@ -27,21 +27,31 @@ export function createViewsStore() {
const deleteView = async view => {
await API.deleteView(view)
await tables.fetch()
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
delete table.views[view.name]
}
return { ...state }
})
}
const save = async view => {
const savedView = await API.saveView(view)
const viewMeta = {
name: view.name,
...savedView,
}
const viewTable = get(tables).list.find(table => table._id === view.tableId)
if (view.originalName) delete viewTable.views[view.originalName]
viewTable.views[view.name] = viewMeta
await tables.save(viewTable)
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
}
return {

View File

@ -116,6 +116,9 @@ export const createLicensingStore = () => {
const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS
)
const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
store.update(state => {
return {
...state,
@ -130,6 +133,7 @@ export const createLicensingStore = () => {
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
syncAutomationsEnabled,
}
})
},

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "0.0.1",
"version": "0.0.0",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "dist/src/index.js",
"bin": {
@ -29,9 +29,9 @@
"outputPath": "build"
},
"dependencies": {
"@budibase/backend-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/types": "0.0.1",
"@budibase/backend-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0",
"axios": "0.21.2",
"chalk": "4.1.0",
"cli-progress": "3.11.2",

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