1
0
Fork 0
mirror of synced 2024-06-15 00:44:41 +12:00

Merge branch 'develop' into labday/status-banner

This commit is contained in:
Rory Powell 2022-03-08 16:31:07 +00:00
commit c22fb79d94
201 changed files with 14306 additions and 1414 deletions

View file

@ -38,6 +38,17 @@ jobs:
fi
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Proxy service docker image
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker:proxy:prod
docker tag budibase/proxy:$release_tag budibase/proxy:$PROD_TAG
docker push budibase/proxy:$PROD_TAG
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
PROD_TAG: k8s
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:

View file

@ -23,12 +23,24 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- name: Get the latest budibase release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Proxy service docker image
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker:proxy:preprod
docker tag budibase/proxy:$release_tag budibase/proxy:$PREPROD_TAG
docker push budibase/proxy:$PREPROD_TAG
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
PREPROD_TAG: k8s-preprod
- name: Pull values.yaml from budibase-infra
run: |
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \

View file

@ -47,6 +47,13 @@ jobs:
yarn
yarn build
popd
- name: Build OpenAPI sepc
run: |
pushd packages/server
yarn
yarn specs
popd
- name: Setup Helm
uses: azure/setup-helm@v1
@ -77,3 +84,5 @@ jobs:
packages/cli/build/cli-win.exe
packages/cli/build/cli-linux
packages/cli/build/cli-macos
packages/server/specs/openapi.yaml
packages/server/specs/openapi.json

1
.gitignore vendored
View file

@ -96,4 +96,5 @@ hosting/proxy/.generated-nginx.prod.conf
*.sublime-workspace
bin/
hosting/.generated*
packages/builder/cypress.env.json

View file

@ -1,5 +1,4 @@
node_modules
public
dist
*.spec.js
packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte
@ -8,4 +7,4 @@ packages/server/coverage
packages/server/client
packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js

View file

@ -76,6 +76,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 300;
proxy_http_version 1.1;
@ -91,4 +92,4 @@ http {
gzip off;
gzip_comp_level 4;
}
}
}

View file

@ -19,13 +19,7 @@ http {
tcp_nodelay on;
server_tokens off;
types_hash_max_size 2048;
{{#if compose}}
resolver 127.0.0.11 ipv6=off;
{{/if}}
{{#if k8s}}
resolver kube-dns.kube-system.svc.cluster.local valid=10s;
{{/if}}
resolver {{ resolver }} valid=10s ipv6=off;
# buffering
client_body_buffer_size 1K;
@ -55,7 +49,7 @@ http {
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io; font-src 'self' data https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com; frame-src 'self'; img-src http: https: data; manifest-src 'self'; media-src 'self'; worker-src 'none';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io ; font-src 'self' data https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com; frame-src 'self' https:; img-src http: https: data; manifest-src 'self'; media-src 'self'; worker-src 'none';" always;
# upstreams
set $apps {{ apps }};

View file

@ -1,145 +0,0 @@
user nginx;
error_log /var/log/nginx/error.log debug;
pid /var/run/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 33282;
events {
worker_connections 1024;
}
http {
limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s;
include /etc/nginx/mime.types;
default_type application/octet-stream;
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
types_hash_max_size 2048;
# buffering
client_body_buffer_size 1K;
client_header_buffer_size 1k;
client_max_body_size 1k;
ignore_invalid_headers off;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
map $http_upgrade $connection_upgrade {
default "upgrade";
}
server {
listen 10000 default_server;
listen [::]:10000 default_server;
server_name _;
client_max_body_size 1000m;
ignore_invalid_headers off;
proxy_buffering off;
port_in_redirect off;
# Security Headers
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me; frame-src 'self'; img-src http: https: data:; manifest-src 'self'; media-src 'self'; worker-src 'none';" always;
location /app {
proxy_pass http://app-service:4002;
rewrite ^/app/(.*)$ /$1 break;
}
location = / {
port_in_redirect off;
proxy_pass http://app-service:4002;
}
location = /v1/update {
proxy_pass http://watchtower-service:8080;
}
location /builder/ {
port_in_redirect off;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app-service:4002;
}
location ~ ^/(builder|app_) {
port_in_redirect off;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app-service:4002;
}
location ~ ^/api/(system|admin|global)/ {
proxy_pass http://worker-service:4003;
}
location /worker/ {
proxy_pass http://worker-service:4003;
rewrite ^/worker/(.*)$ /$1 break;
}
location /api/ {
# calls to the API are rate limited with bursting
limit_req zone=ratelimit burst=20 nodelay;
# 120s timeout on API requests
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app-service:4002;
}
location /db/ {
proxy_pass http://couchdb-service:5984;
rewrite ^/db/(.*)$ /$1 break;
}
location / {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 300;
proxy_http_version 1.1;
proxy_set_header Connection "";
chunked_transfer_encoding off;
proxy_pass http://minio-service:9000;
}
client_header_timeout 60;
client_body_timeout 60;
keepalive_timeout 60;
# gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
}
}

View file

@ -1,5 +1,5 @@
{
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

@ -42,13 +42,14 @@
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\"",
"lint:fix:ts": "lerna run lint:fix",
"lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"test:e2e": "lerna run cy:test",
"test:e2e:ci": "lerna run cy:ci",
"test:e2e": "lerna run cy:test --stream",
"test:e2e:ci": "lerna run cy:ci --stream",
"build:specs": "lerna run specs",
"build:docker": "lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:proxy": "docker build hosting/proxy -t proxy-service",
"build:docker:proxy:compose": "lerna run generate:proxy:compose && npm run build:docker:proxy",
"build:docker:proxy:preprod": "lerna run generate:proxy:preprod && npm run build:docker:proxy",
"build:docker:proxy:prod": "lerna run generate:proxy:prod && npm run build:docker:proxy",
"build:docker:proxy:compose": "node scripts/proxy/generateProxyConfig compose && npm run build:docker:proxy",
"build:docker:proxy:preprod": "node scripts/proxy/generateProxyConfig preprod && npm run build:docker:proxy",
"build:docker:proxy:prod": "node scripts/proxy/generateProxyConfig prod && npm run build:docker:proxy",
"build:docker:selfhost": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",

View file

@ -0,0 +1 @@
module.exports = require("./src/security/encryption")

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"description": "Budibase backend core libraries used in server and worker",
"main": "src/index.js",
"author": "Budibase",

View file

@ -32,11 +32,10 @@ const populateFromDB = async (userId, tenantId) => {
* @param {*} populateUser function to provide the user for re-caching. default to couch db
* @returns
*/
exports.getUser = async (
userId,
tenantId = null,
populateUser = populateFromDB
) => {
exports.getUser = async (userId, tenantId = null, populateUser = null) => {
if (!populateUser) {
populateUser = populateFromDB
}
if (!tenantId) {
try {
tenantId = getTenantId()

View file

@ -14,6 +14,7 @@ exports.DocumentTypes = {
APP_METADATA: `${PRE_APP}${exports.SEPARATOR}metadata`,
ROLE: "role",
MIGRATIONS: "migrations",
DEV_INFO: "devinfo",
}
exports.StaticDatabases = {

View file

@ -30,6 +30,7 @@ const UNICODE_MAX = "\ufff0"
exports.ViewNames = {
USER_BY_EMAIL: "by_email",
BY_API_KEY: "by_api_key",
}
exports.StaticDatabases = StaticDatabases
@ -67,6 +68,7 @@ function getDocParams(docType, docId = null, otherProps = {}) {
endkey: `${docType}${SEPARATOR}${docId}${UNICODE_MAX}`,
}
}
exports.getDocParams = getDocParams
/**
* Generates a new workspace ID.
@ -339,6 +341,14 @@ const getConfigParams = ({ type, workspace, user }, otherProps = {}) => {
}
}
/**
* Generates a new dev info document ID - this is scoped to a user.
* @returns {string} The new dev info ID which info for dev (like api key) can be stored under.
*/
const generateDevInfoID = userId => {
return `${DocumentTypes.DEV_INFO}${SEPARATOR}${userId}`
}
/**
* Returns the most granular configuration document from the DB based on the type, workspace and userID passed.
* @param {Object} db - db instance to query
@ -454,3 +464,4 @@ exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams
exports.getScopedFullConfig = getScopedFullConfig
exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc
exports.generateDevInfoID = generateDevInfoID

View file

@ -1,4 +1,5 @@
const { DocumentTypes, ViewNames } = require("./utils")
const { getGlobalDB } = require("../tenancy")
function DesignDoc() {
return {
@ -9,7 +10,8 @@ function DesignDoc() {
}
}
exports.createUserEmailView = async db => {
exports.createUserEmailView = async () => {
const db = getGlobalDB()
let designDoc
try {
designDoc = await db.get("_design/database")
@ -31,3 +33,51 @@ exports.createUserEmailView = async db => {
}
await db.put(designDoc)
}
exports.createApiKeyView = async () => {
const db = getGlobalDB()
let designDoc
try {
designDoc = await db.get("_design/database")
} catch (err) {
designDoc = DesignDoc()
}
const view = {
map: `function(doc) {
if (doc._id.startsWith("${DocumentTypes.DEV_INFO}") && doc.apiKey) {
emit(doc.apiKey, doc.userId)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewNames.BY_API_KEY]: view,
}
await db.put(designDoc)
}
exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = {
[ViewNames.USER_BY_EMAIL]: exports.createUserEmailView,
[ViewNames.BY_API_KEY]: exports.createApiKeyView,
}
// can pass DB in if working with something specific
if (!db) {
db = getGlobalDB()
}
try {
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
return response.length <= 1 ? response[0] : response
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await createFunc()
return exports.queryGlobalView(viewName, params)
} else {
throw err
}
}
}

View file

@ -4,6 +4,9 @@ const { getUser } = require("../cache/user")
const { getSession, updateSessionTTL } = require("../security/sessions")
const { buildMatcherRegex, matches } = require("./matchers")
const env = require("../environment")
const { SEPARATOR, ViewNames, queryGlobalView } = require("../../db")
const { getGlobalDB } = require("../tenancy")
const { decrypt } = require("../security/encryption")
function finalise(
ctx,
@ -16,6 +19,28 @@ function finalise(
ctx.version = version
}
async function checkApiKey(apiKey, populateUser) {
if (apiKey === env.INTERNAL_API_KEY) {
return { valid: true }
}
const decrypted = decrypt(apiKey)
const tenantId = decrypted.split(SEPARATOR)[0]
const db = getGlobalDB(tenantId)
// api key is encrypted in the database
const userId = await queryGlobalView(
ViewNames.BY_API_KEY,
{
key: apiKey,
},
db
)
if (userId) {
return { valid: true, user: await getUser(userId, tenantId, populateUser) }
} else {
throw "Invalid API key"
}
}
/**
* This middleware is tenancy aware, so that it does not depend on other middlewares being used.
* The tenancy modules should not be used here and it should be assumed that the tenancy context
@ -79,9 +104,19 @@ module.exports = (
const apiKey = ctx.request.headers[Headers.API_KEY]
const tenantId = ctx.request.headers[Headers.TENANT_ID]
// this is an internal request, no user made it
if (!authenticated && apiKey && apiKey === env.INTERNAL_API_KEY) {
authenticated = true
internal = true
if (!authenticated && apiKey) {
const populateUser = opts.populateUser ? opts.populateUser(ctx) : null
const { valid, user: foundUser } = await checkApiKey(
apiKey,
populateUser
)
if (valid && foundUser) {
authenticated = true
user = foundUser
} else if (valid) {
authenticated = true
internal = true
}
}
if (!user && tenantId) {
user = { tenantId }
@ -101,6 +136,7 @@ module.exports = (
// allow configuring for public access
if ((opts && opts.publicAllowed) || publicEndpoint) {
finalise(ctx, { authenticated: false, version, publicEndpoint })
return next()
} else {
ctx.throw(err.status || 403, err)
}

View file

@ -78,6 +78,7 @@ exports.ObjectStore = bucket => {
const config = {
s3ForcePathStyle: true,
signatureVersion: "v4",
apiVersion: "2006-03-01",
params: {
Bucket: sanitizeBucket(bucket),
},
@ -102,17 +103,21 @@ exports.makeSureBucketExists = async (client, bucketName) => {
.promise()
} catch (err) {
const promises = STATE.bucketCreationPromises
const doesntExist = err.statusCode === 404,
noAccess = err.statusCode === 403
if (promises[bucketName]) {
await promises[bucketName]
} else if (err.statusCode === 404) {
// bucket doesn't exist create it
promises[bucketName] = client
.createBucket({
Bucket: bucketName,
})
.promise()
await promises[bucketName]
delete promises[bucketName]
} else if (doesntExist || noAccess) {
if (doesntExist) {
// bucket doesn't exist create it
promises[bucketName] = client
.createBucket({
Bucket: bucketName,
})
.promise()
await promises[bucketName]
delete promises[bucketName]
}
// public buckets are quite hidden in the system, make sure
// no bucket is set accidentally
if (PUBLIC_BUCKETS.includes(bucketName)) {
@ -124,7 +129,7 @@ exports.makeSureBucketExists = async (client, bucketName) => {
.promise()
}
} else {
throw err
throw new Error("Unable to write to object store bucket.")
}
}
}

View file

@ -0,0 +1 @@
exports.lookupApiKey = async () => {}

View file

@ -0,0 +1,33 @@
const crypto = require("crypto")
const env = require("../environment")
const ALGO = "aes-256-ctr"
const SECRET = env.JWT_SECRET
const SEPARATOR = "-"
const ITERATIONS = 10000
const RANDOM_BYTES = 16
const STRETCH_LENGTH = 32
function stretchString(string, salt) {
return crypto.pbkdf2Sync(string, salt, ITERATIONS, STRETCH_LENGTH, "sha512")
}
exports.encrypt = input => {
const salt = crypto.randomBytes(RANDOM_BYTES)
const stretched = stretchString(SECRET, salt)
const cipher = crypto.createCipheriv(ALGO, stretched, salt)
const base = cipher.update(input)
const final = cipher.final()
const encrypted = Buffer.concat([base, final]).toString("hex")
return `${salt.toString("hex")}${SEPARATOR}${encrypted}`
}
exports.decrypt = input => {
const [salt, encrypted] = input.split(SEPARATOR)
const saltBuffer = Buffer.from(salt, "hex")
const stretched = stretchString(SECRET, saltBuffer)
const decipher = crypto.createDecipheriv(ALGO, stretched, saltBuffer)
const base = decipher.update(Buffer.from(encrypted, "hex"))
const final = decipher.final()
return Buffer.concat([base, final]).toString()
}

View file

@ -10,6 +10,7 @@ const PermissionLevels = {
// these are the global types, that govern the underlying default behaviour
const PermissionTypes = {
APP: "app",
TABLE: "table",
USER: "user",
AUTOMATION: "automation",

View file

@ -6,7 +6,7 @@ const {
} = require("./db/utils")
const jwt = require("jsonwebtoken")
const { options } = require("./middleware/passport/jwt")
const { createUserEmailView } = require("./db/views")
const { queryGlobalView } = require("./db/views")
const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants")
const {
getGlobalDB,
@ -139,25 +139,11 @@ exports.getGlobalUserByEmail = async email => {
if (email == null) {
throw "Must supply an email address to view"
}
const db = getGlobalDB()
try {
let users = (
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
key: email.toLowerCase(),
include_docs: true,
})
).rows
users = users.map(user => user.doc)
return users.length <= 1 ? users[0] : users
} catch (err) {
if (err != null && err.name === "not_found") {
await createUserEmailView(db)
return exports.getGlobalUserByEmail(email)
} else {
throw err
}
}
return queryGlobalView(ViewNames.USER_BY_EMAIL, {
key: email.toLowerCase(),
include_docs: true,
})
}
exports.saveUser = async (

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.2.1",
"@budibase/string-templates": "^1.0.72-alpha.0",
"@budibase/string-templates": "^1.0.81-alpha.0",
"@spectrum-css/actionbutton": "^1.0.1",
"@spectrum-css/actiongroup": "^1.0.1",
"@spectrum-css/avatar": "^3.0.2",

View file

@ -61,5 +61,6 @@
<style>
.spectrum-Toast {
pointer-events: all;
width: 100%;
}
</style>

View file

@ -6,7 +6,7 @@
import { fly } from "svelte/transition"
</script>
<Portal target=".modal-container">
<Portal target=".banner-container">
<div class="banner">
{#if $banner.message}
<div transition:fly={{ y: -30 }}>
@ -25,18 +25,7 @@
<style>
.banner {
position: fixed;
top: 20px;
left: 0;
right: 0;
margin: 0 auto;
padding: 0;
z-index: 9999;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
pointer-events: none;
gap: 10px;
width: 100%;
}
</style>

View file

@ -47,7 +47,9 @@
<use xlink:href="#spectrum-css-icon-Dash100" />
</svg>
</span>
<span class="spectrum-Checkbox-label">{text || ""}</span>
{#if text}
<span class="spectrum-Checkbox-label">{text}</span>
{/if}
</label>
<style>

View file

@ -54,34 +54,43 @@
<svelte:window on:keydown={handleKey} />
<!-- These svelte if statements need to be defined like this. -->
<!-- The modal transitions do not work if nested inside more than one "if" -->
{#if visible && inline}
<div use:focusFirstInput class="spectrum-Modal inline is-open">
<slot />
</div>
{:else if visible}
{#if inline}
{#if visible}
<div use:focusFirstInput class="spectrum-Modal inline is-open">
<slot />
</div>
{/if}
{:else}
<!--
We cannot conditionally render the portal as this leads to a missing
insertion point when using nested modals. Therefore we just conditionally
render the content of the portal.
It still breaks the modal animation, but its better than soft bricking the
screen.
-->
<Portal target=".modal-container">
<div
class="spectrum-Underlay is-open"
in:fade={{ duration: 200 }}
out:fade|local={{ duration: 200 }}
on:mousedown|self={cancel}
>
<div class="modal-wrapper" on:mousedown|self={cancel}>
<div class="modal-inner-wrapper" on:mousedown|self={cancel}>
<slot name="outside" />
<div
use:focusFirstInput
class="spectrum-Modal is-open"
in:fly={{ y: 30, duration: 200 }}
out:fly|local={{ y: 30, duration: 200 }}
>
<slot />
{#if visible}
<div
class="spectrum-Underlay is-open"
in:fade={{ duration: 200 }}
out:fade|local={{ duration: 200 }}
on:mousedown|self={cancel}
>
<div class="modal-wrapper" on:mousedown|self={cancel}>
<div class="modal-inner-wrapper" on:mousedown|self={cancel}>
<slot name="outside" />
<div
use:focusFirstInput
class="spectrum-Modal is-open"
in:fly={{ y: 30, duration: 200 }}
out:fly|local={{ y: 30, duration: 200 }}
>
<slot />
</div>
</div>
</div>
</div>
</div>
{/if}
</Portal>
{/if}

View file

@ -165,4 +165,8 @@
.secondary-action {
margin-right: auto;
}
.spectrum-Dialog-buttonGroup {
padding-left: 0;
}
</style>

View file

@ -8,9 +8,21 @@
export let allowEditRows = false
</script>
{#if allowSelectRows}
<Checkbox value={selected} />
{/if}
{#if allowEditRows}
<ActionButton size="S" on:click={onEdit}>Edit</ActionButton>
{/if}
<div>
{#if allowSelectRows}
<Checkbox value={selected} />
{/if}
{#if allowEditRows}
<ActionButton size="S" on:click={onEdit}>Edit</ActionButton>
{/if}
</div>
<style>
div {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
</style>

View file

@ -5,6 +5,7 @@
import SelectEditRenderer from "./SelectEditRenderer.svelte"
import { cloneDeep, deepGet } from "../helpers"
import ProgressCircle from "../ProgressCircle/ProgressCircle.svelte"
import Checkbox from "../Form/Checkbox.svelte"
/**
* The expected schema is our normal couch schemas for our tables.
@ -31,7 +32,6 @@
export let allowEditRows = true
export let allowEditColumns = true
export let selectedRows = []
export let editColumnTitle = "Edit"
export let customRenderers = []
export let disableSorting = false
export let autoSortColumns = true
@ -50,6 +50,8 @@
// Table state
let height = 0
let loaded = false
let checkboxStatus = false
$: schema = fixSchema(schema)
$: if (!loading) loaded = true
$: fields = getFields(schema, showAutoColumns, autoSortColumns)
@ -67,6 +69,16 @@
$: showEditColumn = allowEditRows || allowSelectRows
$: cellStyles = computeCellStyles(schema)
// Deselect the "select all" checkbox when the user navigates to a new page
$: {
let checkRowCount = rows.filter(o1 =>
selectedRows.some(o2 => o1._id === o2._id)
)
if (checkRowCount.length === 0) {
checkboxStatus = false
}
}
const fixSchema = schema => {
let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
@ -197,13 +209,32 @@
if (!allowSelectRows) {
return
}
if (selectedRows.includes(row)) {
selectedRows = selectedRows.filter(selectedRow => selectedRow !== row)
if (selectedRows.some(selectedRow => selectedRow._id === row._id)) {
selectedRows = selectedRows.filter(
selectedRow => selectedRow._id !== row._id
)
} else {
selectedRows = [...selectedRows, row]
}
}
const toggleSelectAll = e => {
const select = !!e.detail
if (select) {
// Add any rows which are not already in selected rows
rows.forEach(row => {
if (selectedRows.findIndex(x => x._id === row._id) === -1) {
selectedRows.push(row)
}
})
} else {
// Remove any rows from selected rows that are in the current data set
selectedRows = selectedRows.filter(el =>
rows.every(f => f._id !== el._id)
)
}
}
const computeCellStyles = schema => {
let styles = {}
Object.keys(schema || {}).forEach(field => {
@ -244,7 +275,14 @@
<div
class="spectrum-Table-headCell spectrum-Table-headCell--divider spectrum-Table-headCell--edit"
>
{editColumnTitle || ""}
{#if allowSelectRows}
<Checkbox
bind:value={checkboxStatus}
on:change={toggleSelectAll}
/>
{:else}
Edit
{/if}
</div>
{/if}
{#each fields as field}
@ -302,11 +340,16 @@
{#if showEditColumn}
<div
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
on:click={e => {
toggleSelectRow(row)
e.stopPropagation()
}}
>
<SelectEditRenderer
data={row}
selected={selectedRows.includes(row)}
onToggleSelection={() => toggleSelectRow(row)}
selected={selectedRows.findIndex(
selectedRow => selectedRow._id === row._id
) !== -1}
onEdit={e => editRow(e, row)}
{allowSelectRows}
{allowEditRows}

View file

@ -1,9 +1,10 @@
{
"baseUrl": "http://localhost:10001",
"baseUrl": "http://localhost:4100",
"video": false,
"projectId": "bmbemn",
"env": {
"PORT": "10001",
"PORT": "4100",
"WORKER_PORT": "4200",
"JWT_SECRET": "test",
"HOST_IP": ""
}

View file

@ -33,7 +33,7 @@ filterTests(['smoke', 'all'], () => {
cy.get(".spectrum-Button--cta").click()
})
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").click()
cy.get(".spectrum-Picker-label").eq(1).click()
cy.contains("dog").click()
cy.get(".spectrum-Textfield-input")
.first()

View file

@ -27,10 +27,13 @@ filterTests(["smoke", "all"], () => {
it("updates a column on the table", () => {
cy.get(".title").click()
cy.get(".spectrum-Table-editIcon > use").click()
cy.get("input").eq(1).type("updated", { force: true })
cy.get(".modal-inner-wrapper").within(() => {
cy.get("input").eq(0).type("updated", { force: true })
// Unset table display column
cy.get(".spectrum-Switch-input").eq(1).click()
cy.contains("Save Column").click()
})
cy.contains("nameupdated ").should("contain", "nameupdated")
})

View file

@ -1,43 +1,45 @@
import filterTests from "../../support/filterTests"
filterTests(['smoke', 'all'], () => {
context("REST Datasource Testing", () => {
before(() => {
cy.login()
cy.createTestApp()
})
const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries"
it("Should add REST data source with incorrect API", () => {
// Select REST data source
cy.selectExternalDatasource(datasource)
// Enter incorrect api & attempt to send query
cy.wait(500)
cy.get(".spectrum-Button").contains("Add query").click({ force: true })
cy.intercept('**/preview').as('queryError')
cy.get("input").clear().type("random text")
cy.get(".spectrum-Button").contains("Send").click({ force: true })
// Intercept Request after button click & apply assertions
cy.wait("@queryError")
cy.get("@queryError").its('response.body')
.should('have.property', 'message', 'Invalid URL: http://random text?')
cy.get("@queryError").its('response.body')
.should('have.property', 'status', 400)
})
it("should add and configure a REST datasource", () => {
// Select REST datasource and create query
cy.selectExternalDatasource(datasource)
cy.wait(500)
// createRestQuery confirms query creation
cy.createRestQuery("GET", restUrl)
// Confirm status code response within REST datasource
cy.get(".spectrum-FieldLabel")
.contains("Status")
.children()
.should('contain', 200)
})
filterTests(["smoke", "all"], () => {
context("REST Datasource Testing", () => {
before(() => {
cy.login()
cy.createTestApp()
})
const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries"
it("Should add REST data source with incorrect API", () => {
// Select REST data source
cy.selectExternalDatasource(datasource)
// Enter incorrect api & attempt to send query
cy.wait(500)
cy.get(".spectrum-Button").contains("Add query").click({ force: true })
cy.intercept("**/preview").as("queryError")
cy.get("input").clear().type("random text")
cy.get(".spectrum-Button").contains("Send").click({ force: true })
// Intercept Request after button click & apply assertions
cy.wait("@queryError")
cy.get("@queryError")
.its("response.body")
.should("have.property", "message", "Invalid URL: http://random text?")
cy.get("@queryError")
.its("response.body")
.should("have.property", "status", 400)
})
it("should add and configure a REST datasource", () => {
// Select REST datasource and create query
cy.selectExternalDatasource(datasource)
cy.wait(500)
// createRestQuery confirms query creation
cy.createRestQuery("GET", restUrl, "/breweries")
// Confirm status code response within REST datasource
cy.get(".spectrum-FieldLabel")
.contains("Status")
.children()
.should("contain", 200)
})
})
})

View file

@ -1,115 +1,139 @@
import filterTests from "../support/filterTests"
filterTests(['smoke', 'all'], () => {
filterTests(["smoke", "all"], () => {
context("Query Level Transformers", () => {
before(() => {
cy.login()
cy.deleteApp("Cypress Tests")
cy.createApp("Cypress Tests")
})
it("should write a transformer function", () => {
// Add REST datasource - contains API for breweries
const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries"
cy.selectExternalDatasource(datasource)
cy.createRestQuery("GET", restUrl)
cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click()
// Get Transformer Function from file
cy.readFile("cypress/support/queryLevelTransformerFunction.js").then((transformerFunction) => {
// Add REST datasource - contains API for breweries
const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries"
cy.selectExternalDatasource(datasource)
cy.createRestQuery("GET", restUrl, "/breweries")
cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click()
// Get Transformer Function from file
cy.readFile("cypress/support/queryLevelTransformerFunction.js").then(
transformerFunction => {
cy.get(".CodeMirror textarea")
// Highlight current text and overwrite with file contents
.type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true })
.type(transformerFunction, { parseSpecialCharSequences: false })
})
// Send Query
cy.intercept('**/queries/preview').as('query')
cy.get(".spectrum-Button").contains("Send").click({ force: true })
cy.wait("@query")
// Assert against Status Code, body, & body rows
cy.get("@query").its('response.statusCode')
.should('eq', 200)
cy.get("@query").its('response.body').should('not.be.empty')
cy.get("@query").its('response.body.rows').should('not.be.empty')
})
// Highlight current text and overwrite with file contents
.type(Cypress.platform === "darwin" ? "{cmd}a" : "{ctrl}a", {
force: true,
})
.type(transformerFunction, { parseSpecialCharSequences: false })
}
)
// Send Query
cy.intercept("**/queries/preview").as("query")
cy.get(".spectrum-Button").contains("Send").click({ force: true })
cy.wait("@query")
// Assert against Status Code, body, & body rows
cy.get("@query").its("response.statusCode").should("eq", 200)
cy.get("@query").its("response.body").should("not.be.empty")
cy.get("@query").its("response.body.rows").should("not.be.empty")
})
it("should add data to the previous query", () => {
// Add REST datasource - contains API for breweries
const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries"
cy.selectExternalDatasource(datasource)
cy.createRestQuery("GET", restUrl)
cy.createRestQuery("GET", restUrl, "/breweries")
cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click()
// Get Transformer Function with Data from file
cy.readFile("cypress/support/queryLevelTransformerFunctionWithData.js").then((transformerFunction) => {
cy.readFile(
"cypress/support/queryLevelTransformerFunctionWithData.js"
).then(transformerFunction => {
//console.log(transformerFunction[1])
cy.get(".CodeMirror textarea")
// Highlight current text and overwrite with file contents
.type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true })
.type(transformerFunction, { parseSpecialCharSequences: false })
// Highlight current text and overwrite with file contents
.type(Cypress.platform === "darwin" ? "{cmd}a" : "{ctrl}a", {
force: true,
})
.type(transformerFunction, { parseSpecialCharSequences: false })
})
// Send Query
cy.intercept('**/queries/preview').as('query')
cy.intercept("**/queries/preview").as("query")
cy.get(".spectrum-Button").contains("Send").click({ force: true })
cy.wait("@query")
// Assert against Status Code, body, & body rows
cy.get("@query").its('response.statusCode')
.should('eq', 200)
cy.get("@query").its('response.body').should('not.be.empty')
cy.get("@query").its('response.body.rows').should('not.be.empty')
cy.get("@query").its("response.statusCode").should("eq", 200)
cy.get("@query").its("response.body").should("not.be.empty")
cy.get("@query").its("response.body.rows").should("not.be.empty")
})
it("should run an invalid query within the transformer section", () => {
// Add REST datasource - contains API for breweries
const datasource = "REST"
const restUrl = "https://api.openbrewerydb.org/breweries"
cy.selectExternalDatasource(datasource)
cy.createRestQuery("GET", restUrl)
cy.createRestQuery("GET", restUrl, "/breweries")
cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click()
// Clear the code box and add "test"
cy.get(".CodeMirror textarea")
.type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true })
.type("test")
.type(Cypress.platform === "darwin" ? "{cmd}a" : "{ctrl}a", {
force: true,
})
.type("test")
// Run Query and intercept
cy.intercept('**/preview').as('queryError')
cy.intercept("**/preview").as("queryError")
cy.get(".spectrum-Button").contains("Send").click({ force: true })
cy.wait("@queryError")
cy.wait(500)
// Assert against message and status for the query error
cy.get("@queryError").its('response.body').should('have.property', 'message', "test is not defined")
cy.get("@queryError").its('response.body').should('have.property', 'status', 400)
cy.get("@queryError")
.its("response.body")
.should("have.property", "message", "test is not defined")
cy.get("@queryError")
.its("response.body")
.should("have.property", "status", 400)
})
xit("should run an invalid query via POST request", () => {
// POST request with transformer as null
cy.request({method: 'POST',
url: `${Cypress.config().baseUrl}/api/queries/`,
body: {fields : {"headers":{},"queryString":null,"path":null},
parameters : [],
schema : {},
name : "test",
queryVerb : "read",
transformer : null,
datasourceId: "test"},
// Expected 400 error - Transformer must be a string
failOnStatusCode: false}).then((response) => {
cy.request({
method: "POST",
url: `${Cypress.config().baseUrl}/api/queries/`,
body: {
fields: { headers: {}, queryString: null, path: null },
parameters: [],
schema: {},
name: "test",
queryVerb: "read",
transformer: null,
datasourceId: "test",
},
// Expected 400 error - Transformer must be a string
failOnStatusCode: false,
}).then(response => {
expect(response.status).to.equal(400)
expect(response.body.message).to.include('Invalid body - "transformer" must be a string')
expect(response.body.message).to.include(
'Invalid body - "transformer" must be a string'
)
})
})
xit("should run an empty query", () => {
// POST request with Transformer as an empty string
cy.request({method: 'POST',
url: `${Cypress.config().baseUrl}/api/queries/preview`,
body: {fields : {"headers":{},"queryString":null,"path":null},
queryVerb : "read",
transformer : "",
datasourceId: "test"},
// Expected 400 error - Transformer is not allowed to be empty
failOnStatusCode: false}).then((response) => {
cy.request({
method: "POST",
url: `${Cypress.config().baseUrl}/api/queries/preview`,
body: {
fields: { headers: {}, queryString: null, path: null },
queryVerb: "read",
transformer: "",
datasourceId: "test",
},
// Expected 400 error - Transformer is not allowed to be empty
failOnStatusCode: false,
}).then(response => {
expect(response.status).to.equal(400)
expect(response.body.message).to.include('Invalid body - "transformer" is not allowed to be empty')
expect(response.body.message).to.include(
'Invalid body - "transformer" is not allowed to be empty'
)
})
})
})

View file

@ -4,17 +4,17 @@ const path = require("path")
const tmpdir = path.join(require("os").tmpdir(), ".budibase")
// normal development system
const WORKER_PORT = "10002"
const MAIN_PORT = cypressConfig.env.PORT
const SERVER_PORT = cypressConfig.env.PORT
const WORKER_PORT = cypressConfig.env.WORKER_PORT
process.env.BUDIBASE_API_KEY = "6BE826CB-6B30-4AEC-8777-2E90464633DE"
process.env.NODE_ENV = "cypress"
process.env.ENABLE_ANALYTICS = "false"
process.env.PORT = MAIN_PORT
process.env.JWT_SECRET = cypressConfig.env.JWT_SECRET
process.env.COUCH_URL = `leveldb://${tmpdir}/.data/`
process.env.SELF_HOSTED = 1
process.env.WORKER_URL = "http://localhost:10002/"
process.env.APPS_URL = `http://localhost:${MAIN_PORT}/`
process.env.WORKER_URL = `http://localhost:${WORKER_PORT}/`
process.env.APPS_URL = `http://localhost:${SERVER_PORT}/`
process.env.MINIO_URL = `http://localhost:4004`
process.env.MINIO_ACCESS_KEY = "budibase"
process.env.MINIO_SECRET_KEY = "budibase"
@ -33,11 +33,14 @@ exports.run = (
// require("dotenv").config({ path: resolve(dir, ".env") })
// don't make this a variable or top level require
// it will cause environment module to be loaded prematurely
require(serverLoc)
// override the port with the worker port temporarily
process.env.PORT = WORKER_PORT
require(workerLoc)
// reload main port for rest of system
process.env.PORT = MAIN_PORT
// override the port with the server port
process.env.PORT = SERVER_PORT
require(serverLoc)
}
if (require.main === module) {

View file

@ -39,7 +39,7 @@ Cypress.Commands.add("createApp", name => {
cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click()
cy.wait(5000)
cy.wait(10000)
})
cy.createTable("Cypress Tests", true)
})
@ -116,10 +116,10 @@ Cypress.Commands.add("createTestTableWithData", () => {
Cypress.Commands.add("createTable", (tableName, initialTable) => {
if (!initialTable) {
cy.navigateToDataSection()
cy.get(".add-button").click()
cy.get(`[data-cy="new-table"]`).click()
}
cy.wait(7000)
cy.get(".spectrum-Modal")
cy.wait(5000)
cy.get(".spectrum-Dialog-grid")
.contains("Budibase DB")
.click({ force: true })
.then(() => {
@ -172,17 +172,19 @@ Cypress.Commands.add("addRow", values => {
Cypress.Commands.add("addRowMultiValue", values => {
cy.contains("Create row").click()
cy.get(".spectrum-Form-itemField")
.click()
.then(() => {
cy.get(".spectrum-Popover").within(() => {
for (let i = 0; i < values.length; i++) {
cy.get(".spectrum-Menu-item").eq(i).click()
}
cy.get(".spectrum-Modal").within(() => {
cy.get(".spectrum-Form-itemField")
.click()
.then(() => {
cy.get(".spectrum-Popover").within(() => {
for (let i = 0; i < values.length; i++) {
cy.get(".spectrum-Menu-item").eq(i).click()
}
})
cy.get(".spectrum-Dialog-grid").click("top")
cy.get(".spectrum-ButtonGroup").contains("Create").click()
})
cy.get(".spectrum-Dialog-grid").click("top")
cy.get(".spectrum-ButtonGroup").contains("Create").click()
})
})
})
Cypress.Commands.add("createUser", email => {
@ -435,7 +437,7 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => {
}
})
Cypress.Commands.add("createRestQuery", (method, restUrl) => {
Cypress.Commands.add("createRestQuery", (method, restUrl, queryPrettyName) => {
// addExternalDatasource should be called prior to this
// Configures REST datasource & sends query
cy.wait(1000)
@ -450,5 +452,5 @@ Cypress.Commands.add("createRestQuery", (method, restUrl) => {
cy.get(".spectrum-Button").contains("Save").click({ force: true })
cy.get(".hierarchy-items-container")
.should("contain", method)
.and("contain", restUrl)
.and("contain", queryPrettyName)
})

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -11,12 +11,13 @@
"rollup": "rollup -c -w",
"cy:setup": "ts-node ./cypress/ts/setup.ts",
"cy:setup:ci": "node ./cypress/setup.js",
"cy:run": "xvfb-run cypress run --headed --browser chrome",
"cy:open": "cypress open",
"cy:run:ci": "cypress run --record",
"cy:test": "start-server-and-test cy:setup http://localhost:10001/builder cy:run",
"cy:ci": "start-server-and-test cy:setup:ci http://localhost:10001/builder cy:run",
"cy:debug": "start-server-and-test cy:setup http://localhost:10001/builder cy:open"
"cy:run": "cypress run",
"cy:run:ci": "xvfb-run cypress run --headed --browser chrome",
"cy:test": "start-server-and-test cy:setup http://localhost:4100/builder cy:run",
"cy:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci",
"cy:debug": "start-server-and-test cy:setup http://localhost:4100/builder cy:open",
"cy:debug:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:open"
},
"jest": {
"globals": {
@ -64,10 +65,10 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.0.76-alpha.2",
"@budibase/client": "^1.0.76-alpha.2",
"@budibase/frontend-core": "^1.0.76-alpha.2",
"@budibase/string-templates": "^1.0.76-alpha.2",
"@budibase/bbui": "^1.0.81-alpha.0",
"@budibase/client": "^1.0.81-alpha.0",
"@budibase/frontend-core": "^1.0.81-alpha.0",
"@budibase/string-templates": "^1.0.81-alpha.0",
"@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",

View file

@ -8,8 +8,10 @@
const queryHandler = { parse, stringify }
</script>
<NotificationDisplay />
<div class="banner-container" />
<BannerDisplay />
<NotificationDisplay />
<Router {routes} config={{ queryHandler }} />
<div class="modal-container" />
<HelpIcon />

View file

@ -32,12 +32,14 @@ export const getBindableProperties = (asset, componentId) => {
const urlBindings = getUrlBindings(asset)
const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings()
const selectedRowsBindings = getSelectedRowsBindings(asset)
return [
...contextBindings,
...urlBindings,
...stateBindings,
...userBindings,
...deviceBindings,
...selectedRowsBindings,
]
}
@ -315,6 +317,44 @@ const getDeviceBindings = () => {
return bindings
}
/**
* Gets all selected rows bindings for tables in the current asset.
*/
const getSelectedRowsBindings = asset => {
let bindings = []
if (get(store).clientFeatures?.rowSelection) {
// Add bindings for table components
let tables = findAllMatchingComponents(asset?.props, component =>
component._component.endsWith("table")
)
const safeState = makePropSafe("rowSelection")
bindings = bindings.concat(
tables.map(table => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(table._id)}.${makePropSafe(
"selectedRows"
)}`,
readableBinding: `${table._instanceName}.Selected rows`,
}))
)
// Add bindings for table blocks
let tableBlocks = findAllMatchingComponents(asset?.props, component =>
component._component.endsWith("tableblock")
)
bindings = bindings.concat(
tableBlocks.map(block => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(
block._id + "-table"
)}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`,
}))
)
}
return bindings
}
/**
* Gets all state bindings that are globally available.
*/
@ -597,14 +637,9 @@ const buildFormSchema = component => {
* in the app.
*/
export const getAllStateVariables = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(store).layouts || [])
allAssets = allAssets.concat(get(store).screens || [])
// Find all button action settings in all components
let eventSettings = []
allAssets.forEach(asset => {
getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = getComponentSettings(component._component)
settings
@ -635,6 +670,15 @@ export const getAllStateVariables = () => {
return Array.from(bindingSet)
}
export const getAllAssets = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(store).layouts || [])
allAssets = allAssets.concat(get(store).screens || [])
return allAssets
}
/**
* Recurses the input object to remove any instances of bindings.
*/

View file

@ -57,6 +57,7 @@ const automationActions = store => ({
return state
})
},
save: async automation => {
const response = await API.updateAutomation(automation)
store.update(state => {
@ -130,6 +131,12 @@ const automationActions = store => ({
name: block.name,
})
},
toggleFieldControl: value => {
store.update(state => {
state.selectedBlock.rowControl = value
return state
})
},
deleteAutomationBlock: block => {
store.update(state => {
const idx =

View file

@ -41,6 +41,7 @@ const INITIAL_FRONTEND_STATE = {
intelligentLoading: false,
deviceAwareness: false,
state: false,
rowSelection: false,
customThemes: false,
devicePreview: false,
messagePassing: false,

View file

@ -3,14 +3,8 @@
import Flowchart from "./FlowChart/FlowChart.svelte"
$: automation = $automationStore.selectedAutomation?.automation
function onSelect(block) {
automationStore.update(state => {
state.selectedBlock = block
return state
})
}
</script>
{#if automation}
<Flowchart {automation} {onSelect} />
<Flowchart {automation} />
{/if}

View file

@ -14,7 +14,7 @@
} from "@budibase/bbui"
export let automation
export let onSelect
let testDataModal
let blocks
let confirmDeleteDialog
@ -45,7 +45,7 @@
<div class="title">
<div class="subtitle">
<Heading size="S">{automation.name}</Heading>
<div style="display:flex;">
<div style="display:flex; align-items: center;">
<div class="iconPadding">
<div class="icon">
<Icon
@ -72,7 +72,7 @@
animate:flip={{ duration: 500 }}
in:fly|local={{ x: 500, duration: 1500 }}
>
<FlowItem {testDataModal} {onSelect} {block} />
<FlowItem {testDataModal} {block} />
</div>
{/each}
</div>

View file

@ -10,6 +10,7 @@
Button,
StatusLight,
ActionButton,
Select,
notifications,
} from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
@ -18,7 +19,6 @@
import ActionModal from "./ActionModal.svelte"
import { externalActions } from "./ExternalActions"
export let onSelect
export let block
export let testDataModal
let selected
@ -28,6 +28,10 @@
let setupToggled
let blockComplete
$: rowControl = $automationStore.selectedAutomation.automation.rowControl
$: showBindingPicker =
block.stepId === "CREATE_ROW" || block.stepId === "UPDATE_ROW"
$: testResult = $automationStore.selectedAutomation.testResults?.steps.filter(
step => (block.id ? step.id === block.id : step.stepId === block.stepId)
)
@ -44,12 +48,6 @@
$automationStore.selectedAutomation?.automation?.definition?.steps.length +
1
// Logic for hiding / showing the add button.first we check if it has a child
// then we check to see whether its inputs have been commpleted
$: disableAddButton = isTrigger
? $automationStore.selectedAutomation?.automation?.definition?.steps
.length > 0
: !isTrigger && steps.length - blockIdx > 1
$: hasCompletedInputs = Object.keys(
block.schema?.inputs?.properties || {}
).every(x => block?.inputs[x])
@ -64,6 +62,26 @@
notifications.error("Error saving notification")
}
}
function toggleFieldControl(evt) {
onSelect(block)
let rowControl
if (evt.detail === "Use values") {
rowControl = false
} else {
rowControl = true
}
automationStore.actions.toggleFieldControl(rowControl)
automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
}
async function onSelect(block) {
await automationStore.update(state => {
state.selectedBlock = block
return state
})
}
</script>
<div
@ -126,15 +144,33 @@
<Layout noPadding gap="S">
<div class="splitHeader">
<ActionButton
on:click={() => (setupToggled = !setupToggled)}
on:click={() => {
onSelect(block)
setupToggled = !setupToggled
}}
quiet
icon={setupToggled ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Setup</Detail>
</ActionButton>
{#if !isTrigger}
<div on:click={() => deleteStep()}>
<Icon name="DeleteOutline" />
<div class="block-options">
{#if showBindingPicker}
<div>
<Select
on:change={toggleFieldControl}
quiet
defaultValue="Use values"
autoWidth
value={rowControl ? "Use bindings" : "Use values"}
options={["Use values", "Use bindings"]}
placeholder={null}
/>
</div>
{/if}
<div class="delete-padding" on:click={() => deleteStep()}>
<Icon name="DeleteOutline" />
</div>
</div>
{/if}
</div>
@ -180,6 +216,13 @@
{/if}
<style>
.delete-padding {
padding-left: 30px;
}
.block-options {
display: flex;
align-items: center;
}
.center-items {
display: flex;
align-items: center;

View file

@ -227,6 +227,7 @@
/>
{:else if value.customType === "row"}
<RowSelector
{block}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}

View file

@ -1,26 +1,31 @@
<script>
import { tables } from "stores/backend"
import {
Select,
Toggle,
DatePicker,
Multiselect,
TextArea,
} from "@budibase/bbui"
import { Select } from "@budibase/bbui"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import { automationStore } from "builderStore"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
const dispatch = createEventDispatcher()
export let value
export let bindings
export let block
let table
let schemaFields
let placeholders = {
number: 10,
boolean: "true",
datetime: "2022-02-16T12:00:00.000Z ",
options: "1",
array: "1 2 3 4",
link: "ro_ta_123_456",
longform: "long form text",
}
$: rowControl = block.rowControl
$: {
table = $tables.list.find(table => table._id === value?.tableId)
schemaFields = Object.entries(table?.schema ?? {})
@ -37,18 +42,48 @@
dispatch("change", value)
}
const onChange = (e, field) => {
value[field] = e.detail
const coerce = (value, type) => {
if (type === "boolean") {
if (typeof value === "boolean") {
return value
}
return value === "true"
}
if (type === "number") {
if (typeof value === "number") {
return value
}
return Number(value)
}
if (type === "options") {
return [value]
}
if (type === "array") {
if (Array.isArray(value)) {
return value
}
return value.split(",").map(x => x.trim())
}
if (type === "link") {
if (Array.isArray(value)) {
return value
}
return [value]
}
return value
}
const onChange = (e, field, type) => {
value[field] = coerce(e.detail, type)
dispatch("change", value)
}
// Ensure any nullish tableId values get set to empty string so
// that the select works
$: if (value?.tableId == null) value = { tableId: "" }
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
</script>
<Select
@ -62,55 +97,46 @@
<div class="schema-fields">
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn}
{#if schemaHasOptions(schema) && schema.type !== "array"}
<Select
on:change={e => onChange(e, field)}
label={field}
value={value[field]}
options={schema.constraints.inclusion}
/>
{:else if schema.type === "datetime"}
<DatePicker
label={field}
value={value[field]}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "boolean"}
<Toggle
text={field}
value={value[field]}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "array"}
<Multiselect
bind:value={value[field]}
label={field}
options={schema.constraints.inclusion}
/>
{:else if schema.type === "longform"}
<TextArea label={field} bind:value={value[field]} />
{:else if schema.type === "link"}
<LinkedRowSelector bind:linkedRows={value[field]} {schema} />
{:else if schema.type === "string" || schema.type === "number"}
{#if schema.type !== "attachment"}
{#if $automationStore.selectedAutomation.automation.testData}
<ModalBindableInput
value={value[field]}
panel={AutomationBindingPanel}
label={field}
type={value.customType}
on:change={e => onChange(e, field)}
{bindings}
/>
{#if !rowControl}
<RowSelectorTypes
{field}
{schema}
{bindings}
{value}
{onChange}
/>
{:else}
<DrawerBindableInput
placeholder={placeholders[schema.type]}
panel={AutomationBindingPanel}
value={Array.isArray(value[field])
? value[field].join(" ")
: value[field]}
on:change={e => onChange(e, field, schema.type)}
label={field}
type="string"
{bindings}
fillWidth={true}
allowJS={true}
/>
{/if}
{:else if !rowControl}
<RowSelectorTypes {field} {schema} {bindings} {value} {onChange} />
{:else}
<DrawerBindableInput
placeholder={placeholders[schema.type]}
panel={AutomationBindingPanel}
value={value[field]}
on:change={e => onChange(e, field)}
value={Array.isArray(value[field])
? value[field].join(" ")
: value[field]}
on:change={e => onChange(e, field, schema.type)}
label={field}
type="string"
{bindings}
fillWidth={true}
allowJS={false}
allowJS={true}
/>
{/if}
{/if}

View file

@ -0,0 +1,64 @@
<script>
import {
Select,
Toggle,
DatePicker,
Multiselect,
TextArea,
} from "@budibase/bbui"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
export let onChange
export let field
export let schema
export let value
export let bindings
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
</script>
{#if schemaHasOptions(schema) && schema.type !== "array"}
<Select
on:change={e => onChange(e, field)}
label={field}
value={value[field]}
options={schema.constraints.inclusion}
/>
{:else if schema.type === "datetime"}
<DatePicker
label={field}
value={value[field]}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "boolean"}
<Toggle
text={field}
value={value[field]}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "array"}
<Multiselect
bind:value={value[field]}
label={field}
options={schema.constraints.inclusion}
/>
{:else if schema.type === "longform"}
<TextArea label={field} bind:value={value[field]} />
{:else if schema.type === "link"}
<LinkedRowSelector bind:linkedRows={value[field]} {schema} />
{:else if schema.type === "string" || schema.type === "number"}
<DrawerBindableInput
panel={AutomationBindingPanel}
value={value[field]}
on:change={e => onChange(e, field)}
label={field}
type="string"
{bindings}
fillWidth={true}
allowJS={true}
/>
{/if}

View file

@ -1,5 +1,5 @@
<script>
import { Input, Icon, notifications } from "@budibase/bbui"
import CopyInput from "components/common/inputs/CopyInput.svelte"
export let value
@ -10,55 +10,6 @@
return `${window.location.origin}/${uri}`
}
function copyToClipboard() {
const dummy = document.createElement("textarea")
document.body.appendChild(dummy)
dummy.value = fullWebhookURL(value)
dummy.select()
document.execCommand("copy")
document.body.removeChild(dummy)
notifications.success(`URL copied to clipboard`)
}
</script>
<div>
<Input readonly value={fullWebhookURL(value)} />
<div class="icon" on:click={() => copyToClipboard()}>
<Icon size="S" name="Copy" />
</div>
</div>
<style>
div {
position: relative;
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
</style>
<CopyInput {value} copyValue={fullWebhookURL(value)} />

View file

@ -57,7 +57,8 @@
{data}
{loading}
{type}
allowEditing={!view?.calculation}
allowEditing={false}
rowCount={10}
bind:hideAutocolumns
>
<ViewFilterButton {view} />

View file

@ -8,7 +8,11 @@
import EditQueryPopover from "./popovers/EditQueryPopover.svelte"
import NavItem from "components/common/NavItem.svelte"
import TableNavigator from "components/backend/TableNavigator/TableNavigator.svelte"
import { customQueryIconText, customQueryIconColor } from "helpers/data/utils"
import {
customQueryIconText,
customQueryIconColor,
customQueryText,
} from "helpers/data/utils"
import ICONS from "./icons"
import { notifications } from "@budibase/bbui"
@ -137,7 +141,7 @@
icon="SQLQuery"
iconText={customQueryIconText(datasource, query)}
iconColor={customQueryIconColor(datasource, query)}
text={query.name}
text={customQueryText(datasource, query)}
opened={$queries.selected === query._id}
selected={$queries.selected === query._id}
on:click={() => onClickQuery(query)}

View file

@ -20,7 +20,9 @@
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource updated successfully.`)
} catch (err) {
notifications.error("Error saving datasource")
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}

View file

@ -0,0 +1,58 @@
<script>
import { Input, Icon, notifications } from "@budibase/bbui"
export let label = null
export let value
export let copyValue
const copyToClipboard = val => {
const dummy = document.createElement("textarea")
document.body.appendChild(dummy)
dummy.value = val
dummy.select()
document.execCommand("copy")
document.body.removeChild(dummy)
notifications.success(`URL copied to clipboard`)
}
</script>
<div>
<Input readonly {value} {label} />
<div class="icon" on:click={() => copyToClipboard(value || copyValue)}>
<Icon size="S" name="Copy" />
</div>
</div>
<style>
div {
position: relative;
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
</style>

View file

@ -134,8 +134,9 @@
// Remove all iframe event listeners on component destroy
onDestroy(() => {
window.removeEventListener("message", receiveMessage)
if (iframe.contentWindow) {
window.removeEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.removeEventListener(

View file

@ -7,6 +7,7 @@
import RoleSelect from "./PropertyControls/RoleSelect.svelte"
import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte"
import { getComponentForSettingType } from "./PropertyControls/componentSettings"
import { Utils } from "@budibase/frontend-core"
export let componentDefinition
export let componentInstance
@ -40,13 +41,13 @@
]
}
const updateProp = async (key, value) => {
const updateProp = Utils.sequential(async (key, value) => {
try {
await store.actions.components.updateProp(key, value)
} catch (error) {
notifications.error("Error updating component prop")
}
}
})
const canRenderControl = setting => {
const control = getComponentForSettingType(setting?.type)

View file

@ -0,0 +1,71 @@
<script>
import { Label, Select, Body } from "@budibase/bbui"
import { tables } from "stores/backend"
import { onMount } from "svelte"
export let parameters
$: tableOptions = $tables.list || []
const FORMATS = [
{
label: "CSV",
value: "csv",
},
{
label: "JSON",
value: "json",
},
]
onMount(() => {
if (!parameters.type) {
parameters.type = "csv"
}
})
</script>
<div class="root">
<Body size="S">
Choose the table that you would like to export your row selection from.
<br />
Please ensure you have enabled row selection in the table settings
</Body>
<div class="params">
<Label small>Table</Label>
<Select
bind:value={parameters.tableId}
options={tableOptions}
getOptionLabel={option => option.name}
getOptionValue={option => option._id}
/>
<Label small>Type</Label>
<Select bind:value={parameters.type} options={FORMATS} />
</div>
</div>
<style>
.root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.root :global(p) {
line-height: 1.5;
}
.params {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 100px 1fr;
align-items: center;
}
</style>

View file

@ -12,3 +12,4 @@ export { default as UpdateState } from "./UpdateState.svelte"
export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
export { default as DuplicateRow } from "./DuplicateRow.svelte"
export { default as S3Upload } from "./S3Upload.svelte"
export { default as ExportData } from "./ExportData.svelte"

View file

@ -80,6 +80,10 @@
"value": "publicUrl"
}
]
},
{
"name": "Export Data",
"component": "ExportData"
}
]
}

View file

@ -0,0 +1,41 @@
<script>
import { ModalContent, Body, notifications } from "@budibase/bbui"
import { auth } from "stores/portal"
import { onMount } from "svelte"
import CopyInput from "components/common/inputs/CopyInput.svelte"
let apiKey = null
async function generateAPIKey() {
try {
apiKey = await auth.generateAPIKey()
notifications.success("New API key generated")
} catch (err) {
notifications.error("Unable to generate new API key")
}
// need to return false to keep modal open
return false
}
onMount(async () => {
try {
apiKey = await auth.fetchAPIKey()
} catch (err) {
notifications.error("Unable to fetch API key")
}
})
</script>
<ModalContent
title="Developer information"
showConfirmButton={false}
showSecondaryButton={true}
secondaryButtonText="Re-generate key"
secondaryAction={generateAPIKey}
>
<Body size="S">
You can find information about your developer account here, such as the API
key used to access the Budibase API.
</Body>
<CopyInput bind:value={apiKey} label="API key" />
</ModalContent>

View file

@ -30,8 +30,8 @@ export function breakQueryString(qs) {
const params = qs.split("&")
let paramObj = {}
for (let param of params) {
const [key, value] = param.split("=")
paramObj[key] = value
const split = param.split("=")
paramObj[split[0]] = split.slice(1).join("=")
}
return paramObj
}
@ -109,6 +109,36 @@ export function customQueryIconColor(datasource, query) {
}
}
export function customQueryText(datasource, query) {
if (!query.name || datasource.source !== IntegrationTypes.REST) {
return query.name
}
// Remove protocol
let name = query.name
if (name.includes("//")) {
name = name.split("//")[1]
}
// If no path, return the full name
if (!name.includes("/")) {
return name
}
// Remove trailing slash
if (name.endsWith("/")) {
name = name.slice(0, -1)
}
// Only use path
const split = name.split("/")
if (split[1]) {
return `/${split.slice(1).join("/")}`
} else {
return split[0]
}
}
export function flipHeaderState(headersActivity) {
if (!headersActivity) {
return {}

View file

@ -45,6 +45,14 @@
store.actions.screens.select(id)
}
}
// If we didn't find a valid asset, just update the preview type
if (!id) {
store.update(state => {
state.currentFrontEndType = assetType
return state
})
}
}
</script>

View file

@ -18,11 +18,13 @@
import { onMount } from "svelte"
import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte"
import Logo from "assets/bb-emblem.svg"
let loaded = false
let userInfoModal
let changePasswordModal
let apiKeyModal
let mobileMenuVisible = false
$: menu = buildMenu($auth.isAdmin)
@ -162,6 +164,11 @@
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
Update user information
</MenuItem>
{#if $auth.isBuilder}
<MenuItem icon="Key" on:click={() => apiKeyModal.show()}>
View API key
</MenuItem>
{/if}
<MenuItem
icon="LockClosed"
on:click={() => changePasswordModal.show()}
@ -186,6 +193,9 @@
<Modal bind:this={changePasswordModal}>
<ChangePasswordModal />
</Modal>
<Modal bind:this={apiKeyModal}>
<UpdateAPIKeyModal />
</Modal>
{/if}
<style>

View file

@ -60,7 +60,7 @@ export function createQueriesStore() {
})
return savedQuery
},
import: async (data, datasourceId) => {
import: async ({ data, datasourceId }) => {
return await API.importQueries({
datasourceId,
data,

View file

@ -172,6 +172,13 @@ export function createAuthStore() {
resetCode,
})
},
generateAPIKey: async () => {
return API.generateAPIKey()
},
fetchAPIKey: async () => {
const info = await API.fetchDeveloperInfo()
return info?.apiKey
},
}
return {

View file

@ -3,3 +3,4 @@ docker-compose.yaml
nginx.conf
build/
docker-error.log
envoy.yaml

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"bin": {

View file

@ -6,7 +6,8 @@
"state": true,
"customThemes": true,
"devicePreview": true,
"messagePassing": true
"messagePassing": true,
"rowSelection": true
},
"layout": {
"name": "Layout",
@ -2714,6 +2715,13 @@
"key": "showAutoColumns",
"defaultValue": false
},
{
"type": "boolean",
"label": "Allow row selection",
"key": "allowSelectRows",
"defaultValue": false
},
{
"type": "boolean",
"label": "Link table rows",
@ -2973,6 +2981,11 @@
"label": "Show auto columns",
"key": "showAutoColumns"
},
{
"type": "boolean",
"label": "Allow row selection",
"key": "allowSelectRows"
},
{
"type": "boolean",
"label": "Link table rows",

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,25 +19,12 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "^1.0.76-alpha.2",
"@budibase/frontend-core": "^1.0.76-alpha.2",
"@budibase/string-templates": "^1.0.76-alpha.2",
"@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",
"@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/tag": "^3.1.4",
"@spectrum-css/typography": "^3.0.2",
"@spectrum-css/vars": "^3.0.1",
"apexcharts": "^3.22.1",
"dayjs": "^1.10.5",
"@budibase/bbui": "^1.0.81-alpha.0",
"@budibase/frontend-core": "^1.0.81-alpha.0",
"@budibase/string-templates": "^1.0.81-alpha.0",
"regexparam": "^1.3.0",
"rollup-plugin-polyfill-node": "^0.8.0",
"shortid": "^2.2.15",
"svelte": "^3.38.2",
"svelte-apexcharts": "^1.0.2",
"svelte-flatpickr": "^3.1.0",
"svelte-spa-router": "^3.0.5"
},
"devDependencies": {

View file

@ -21,6 +21,7 @@
import UserBindingsProvider from "components/context/UserBindingsProvider.svelte"
import DeviceBindingsProvider from "components/context/DeviceBindingsProvider.svelte"
import StateBindingsProvider from "components/context/StateBindingsProvider.svelte"
import RowSelectionProvider from "components/context/RowSelectionProvider.svelte"
import SettingsBar from "components/preview/SettingsBar.svelte"
import SelectionIndicator from "components/preview/SelectionIndicator.svelte"
import HoverIndicator from "components/preview/HoverIndicator.svelte"
@ -90,59 +91,61 @@
<UserBindingsProvider>
<DeviceBindingsProvider>
<StateBindingsProvider>
<!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder}
<SettingsBar />
{/if}
{/key}
<RowSelectionProvider>
<!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder}
<SettingsBar />
{/if}
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice === "tablet"}
class:mobile-preview={$builderStore.previewDevice === "mobile"}
>
<!-- Actual app -->
<div id="app-root">
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice === "tablet"}
class:mobile-preview={$builderStore.previewDevice === "mobile"}
>
<!-- Actual app -->
<div id="app-root">
<CustomThemeWrapper>
{#key `${$screenStore.activeLayout._id}-${$builderStore.previewType}`}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!--
<!--
Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with.
-->
<div id="flatpickr-root" />
<div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
</div>
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
</div>
<!-- Selection indicators should be bounded by device -->
<!--
<!-- Selection indicators should be bounded by device -->
<!--
We don't want to key these by componentID as they control their own
re-mounting to avoid flashes.
-->
{#if $builderStore.inBuilder}
<SelectionIndicator />
<HoverIndicator />
<DNDHandler />
{/if}
</div>
{#if $builderStore.inBuilder}
<SelectionIndicator />
<HoverIndicator />
<DNDHandler />
{/if}
</div>
</RowSelectionProvider>
</StateBindingsProvider>
</DeviceBindingsProvider>
</UserBindingsProvider>

View file

@ -18,6 +18,7 @@
export let quiet
export let compact
export let size
export let allowSelectRows
export let linkRows
export let linkURL
export let linkColumn
@ -157,6 +158,7 @@
>
<BlockComponent
type="table"
context="table"
props={{
dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
columns: tableColumns,
@ -164,6 +166,7 @@
rowCount,
quiet,
compact,
allowSelectRows,
size,
linkRows,
linkURL,

View file

@ -81,7 +81,10 @@
loading = false
return res
} catch (error) {
notificationStore.actions.error(`Error uploading file: ${error}`)
notificationStore.actions.error(
`Error uploading file: ${error?.message || error}`
)
loading = false
}
}

View file

@ -17,7 +17,7 @@ export const getOptions = (
dataProvider?.rows?.forEach(row => {
const value = row?.[valueColumn]
if (value) {
if (value != null) {
const label = row[labelColumn] || value
optionsSet[value] = { value, label }
}
@ -30,7 +30,7 @@ export const getOptions = (
let optionsSet = {}
dataProvider?.rows?.forEach(row => {
const value = row?.[valueColumn]
if (value) {
if (value != null) {
const label = row[labelColumn] || value
optionsSet[value] = { value, label }
}

View file

@ -3,6 +3,7 @@
import { Table } from "@budibase/bbui"
import SlotRenderer from "./SlotRenderer.svelte"
import { UnsortableTypes } from "../../../constants"
import { onDestroy } from "svelte"
export let dataProvider
export let columns
@ -14,10 +15,12 @@
export let linkURL
export let linkColumn
export let linkPeek
export let allowSelectRows
export let compact
const component = getContext("component")
const { styleable, getAction, ActionTypes, routeStore } = getContext("sdk")
const { styleable, getAction, ActionTypes, routeStore, rowSelectionStore } =
getContext("sdk")
const customColumnKey = `custom-${Math.random()}`
const customRenderers = [
{
@ -25,7 +28,7 @@
component: SlotRenderer,
},
]
let selectedRows = []
$: hasChildren = $component.children
$: loading = dataProvider?.loading ?? false
$: data = dataProvider?.rows || []
@ -36,6 +39,13 @@
dataProvider?.id,
ActionTypes.SetDataProviderSorting
)
$: {
rowSelectionStore.actions.updateSelection(
$component.id,
selectedRows.length ? selectedRows[0].tableId : "",
selectedRows.map(row => row._id)
)
}
const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection
@ -117,6 +127,10 @@
const split = linkURL.split("/:")
routeStore.actions.navigate(`${split[0]}/${id}`, linkPeek)
}
onDestroy(() => {
rowSelectionStore.actions.updateSelection($component.id, [])
})
</script>
<div use:styleable={$component.styles} class={size}>
@ -128,7 +142,8 @@
{quiet}
{compact}
{customRenderers}
allowSelectRows={false}
allowSelectRows={!!allowSelectRows}
bind:selectedRows
allowEditRows={false}
allowEditColumns={false}
showAutoColumns={true}
@ -139,10 +154,19 @@
>
<slot />
</Table>
{#if allowSelectRows && selectedRows.length}
<div class="row-count">
{selectedRows.length} row{selectedRows.length === 1 ? "" : "s"} selected
</div>
{/if}
</div>
<style>
div {
background-color: var(--spectrum-alias-background-color-secondary);
}
.row-count {
margin-top: var(--spacing-l);
}
</style>

View file

@ -0,0 +1,8 @@
<script>
import Provider from "./Provider.svelte"
import { rowSelectionStore } from "stores"
</script>
<Provider key="rowSelection" data={$rowSelectionStore}>
<slot />
</Provider>

View file

@ -6,6 +6,7 @@ import {
screenStore,
builderStore,
uploadStore,
rowSelectionStore,
} from "stores"
import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable"
@ -19,6 +20,7 @@ export default {
authStore,
notificationStore,
routeStore,
rowSelectionStore,
screenStore,
builderStore,
uploadStore,

View file

@ -10,7 +10,7 @@ export { peekStore } from "./peek"
export { stateStore } from "./state"
export { themeStore } from "./theme"
export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js"
// Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context"

View file

@ -0,0 +1,31 @@
import { get, writable } from "svelte/store"
const createRowSelectionStore = () => {
const store = writable({})
function updateSelection(componentId, tableId, selectedRows) {
store.update(state => {
state[componentId] = { tableId: tableId, selectedRows: selectedRows }
return state
})
}
function getSelection(tableId) {
const selection = get(store)
const componentId = Object.keys(selection).find(
componentId => selection[componentId].tableId === tableId
)
return componentId ? selection[componentId] : {}
}
return {
subscribe: store.subscribe,
set: store.set,
actions: {
updateSelection,
getSelection,
},
}
}
export const rowSelectionStore = createRowSelectionStore()

View file

@ -1,4 +1,5 @@
import { get } from "svelte/store"
import download from "downloadjs"
import {
routeStore,
builderStore,
@ -8,6 +9,7 @@ import {
notificationStore,
dataSourceStore,
uploadStore,
rowSelectionStore,
} from "stores"
import { API } from "api"
import { ActionTypes } from "constants"
@ -127,12 +129,16 @@ const queryExecutionHandler = async action => {
// Trigger a notification and invalidate the datasource as long as this
// was not a readable query
if (!query.readable) {
API.notifications.error.success("Query executed successfully")
notificationStore.actions.success("Query executed successfully")
await dataSourceStore.actions.invalidateDataSource(query.datasourceId)
}
return { result }
} catch (error) {
notificationStore.actions.error(
"An error occurred while executing the query"
)
// Abort next actions
return false
}
@ -235,6 +241,26 @@ const s3UploadHandler = async action => {
}
}
const exportDataHandler = async action => {
let selection = rowSelectionStore.actions.getSelection(
action.parameters.tableId
)
if (selection.selectedRows && selection.selectedRows.length > 0) {
try {
const data = await API.exportRows({
tableId: selection.tableId,
rows: selection.selectedRows,
})
download(JSON.stringify(data), `export.${action.parameters.type}`)
} catch (error) {
notificationStore.actions.error("There was an error exporting the data")
}
} else {
notificationStore.actions.error("Please select at least one row")
}
}
const handlerMap = {
["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler,
@ -250,6 +276,7 @@ const handlerMap = {
["Change Form Step"]: changeFormStepHandler,
["Update State"]: updateStateHandler,
["Upload File to S3"]: s3UploadHandler,
["Export Data"]: exportDataHandler,
}
const confirmTextMap = {

2689
packages/client/stats.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -84,28 +84,28 @@
integrity sha512-+oKLUI2a0QmQP9EzySeq/G4FpUkkdaDNbuEbqCj2IkPMc/2v/nwzsPhh1fj2UIghGAiiUwXfPpzax1e8fyhQUg==
"@spectrum-css/divider@^1.0.3":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@spectrum-css/divider/-/divider-1.0.9.tgz#00246bd453981c4696149d26f5bcfeefd29b4b53"
integrity sha512-kmSMSXbm56FR0/OAGwT6tlsHuy1OpOve2DBggjND+AVWk6i3TpoTjvbVppy/f8fuLfbMDS5D3MPD27wTEj8wDA==
version "1.0.17"
resolved "https://registry.yarnpkg.com/@spectrum-css/divider/-/divider-1.0.17.tgz#cae86fdcb5eb6dae95798ae19ec962e5735fc27f"
integrity sha512-wuijKLQ+hwZC/aN8x7fVY18KPoFrxnhohheoDB99OTH44nt5+jEggzcPtwMHiUyb5iWMZx045OrG2DdJHiGl1A==
dependencies:
"@spectrum-css/vars" "^4.3.0"
"@spectrum-css/vars" "^7.0.0"
"@spectrum-css/link@^3.1.3":
version "3.1.9"
resolved "https://registry.yarnpkg.com/@spectrum-css/link/-/link-3.1.9.tgz#fe40db561c98bf2987489541ef39dcc71416908f"
integrity sha512-/DpmLIbQGDBNZl+Fnf5VDQ34uC6E6Bz393CAYkzYFyadtvzVEy+PGCgUkT3Tgrwu833IW9fZOh7rkKjw1o/Zng==
version "3.1.15"
resolved "https://registry.yarnpkg.com/@spectrum-css/link/-/link-3.1.15.tgz#08bcb78e3fe3e816968ba96399597f54e2a7d62d"
integrity sha512-LKyI/zr8HXY/PGHCyQxT1Uv1zUzvg7Kuy8E1Itzp+yeCs82hg91aUYkXdQzeWm2eXB/w9cBfjr4NoCl9RWb5bQ==
"@spectrum-css/page@^3.0.1":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@spectrum-css/page/-/page-3.0.8.tgz#001efa9e4c10095df9b2b37cf7d7d6eb60140190"
integrity sha512-naEGOyDv9zeK05oa8mZKdwenPILmHG9OTLyKcE8RwuYQDvb0EHcMGC54DOKtGJ5SMNMGCMdC4RwmYUYYKAhkNA==
version "3.0.9"
resolved "https://registry.yarnpkg.com/@spectrum-css/page/-/page-3.0.9.tgz#f8a705dee90af958e2ee20307218e4f82a018c36"
integrity sha512-zxbzJHDHgbc6fq6DpgWPtQa73kCl/3bukMOV2l784jyEWfXx62nuhFYxFVUq8olvyHw2MNZEXF//P7y+W5axVw==
dependencies:
"@spectrum-css/vars" "^4.3.0"
"@spectrum-css/vars" "^4.3.1"
"@spectrum-css/tag@^3.1.4":
version "3.1.4"
resolved "https://registry.yarnpkg.com/@spectrum-css/tag/-/tag-3.1.4.tgz#334384dd789ddf0562679cae62ef763883480ac5"
integrity sha512-9dYBMhCEkjy+p75XJIfCA2/zU4JAqsJrL7fkYIDXakS6/BzeVtIvAW/6JaIHtLIA9lrj0Sn4m+ZjceKnZNIv1w==
version "3.3.3"
resolved "https://registry.yarnpkg.com/@spectrum-css/tag/-/tag-3.3.3.tgz#826bf03525d10f1ae034681095337973bd43f4af"
integrity sha512-sWcopo4Pgl5VMOF0TuP6on3KmnrcGcaYfBt1/LDAin8+pUoqv2NgLv5BkO7maaPsd9pCLU4K9Y8NPXbujDOefQ==
"@spectrum-css/typography@^3.0.2":
version "3.0.2"
@ -117,10 +117,15 @@
resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-3.0.2.tgz#ea9062c3c98dfc6ba59e5df14a03025ad8969999"
integrity sha512-vzS9KqYXot4J3AEER/u618MXWAS+IoMvYMNrOoscKiLLKYQWenaueakUWulFonToPd/9vIpqtdbwxznqrK5qDw==
"@spectrum-css/vars@^4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-4.3.0.tgz#03ddf67d3aa8a9a4cb0edbbd259465c9ced7e70d"
integrity sha512-ZQ2XAhgu4G9yBeXQNDAz07Z8oZNnMt5o9vzf/mpBA7Teb/JI+8qXp2wt8D245SzmtNlFkG/bzRYvQc0scgZeCQ==
"@spectrum-css/vars@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-4.3.1.tgz#d333fa41909f691c8750b5c15ad9ba029df2248e"
integrity sha512-rX6Iasu9BsFMVgEN0vGRPm9dmSxva+IK/uqQAa9HM0lliwqUiFrJxrFXHHpiAgNuux/U4srEJwbSpGzfF+CegQ==
"@spectrum-css/vars@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-7.0.0.tgz#61a10028f3eeb69d8e488cfeb6251ec75107076d"
integrity sha512-0c7i7B/OPrLjrSIQVAxhnDDmDs9tA46gMuuiiaqpRNqEjfS1pRv5eY06qxNMXO5TWL75bHxaiiTQy8NdGA/v6A==
"@trysound/sax@0.2.0":
version "0.2.0"
@ -169,9 +174,9 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
color-convert "^2.0.1"
apexcharts@^3.19.2, apexcharts@^3.22.1:
version "3.30.0"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.30.0.tgz#09b008d0a58bb303904bed33b09b260e8fa5e283"
integrity sha512-NHhFjkd4sqoQqHi+ECN/duVCRvqVZMdXX/UBzCs1xriq8NbNLvs+nIM8OXH1Siv+W50FrK1uTDZrW2cLsKWhBQ==
version "3.33.1"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.33.1.tgz#7159f45e7d726a548e5135a327c03e7894d0bf13"
integrity sha512-5aVzrgJefd8EH4w7oRmuOhA3+cxJxQg27cYg3ANVGvPCOB4AY3mVVNtFHRFaIq7bv8ws4GRaA9MWfzoWQw3MPQ==
dependencies:
svg.draggable.js "^2.2.2"
svg.easing.js "^2.0.0"
@ -459,6 +464,11 @@ domutils@^2.6.0:
domelementtype "^2.2.0"
domhandler "^4.2.0"
downloadjs@1.4.7:
version "1.4.7"
resolved "https://registry.yarnpkg.com/downloadjs/-/downloadjs-1.4.7.tgz#f69f96f940e0d0553dac291139865a3cd0101e3c"
integrity sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw=
electron-to-chromium@^1.3.896:
version "1.3.900"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.900.tgz#5be2c5818a2a012c511b4b43e87b6ab7a296d4f5"
@ -1355,9 +1365,9 @@ svelte-apexcharts@^1.0.2:
apexcharts "^3.19.2"
svelte-flatpickr@^3.1.0:
version "3.2.4"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-3.2.4.tgz#1824e26a5dc151d14906cfc7dfd100aefd1b072d"
integrity sha512-EE2wbFfpZ3iCBOXRRW52w436Jv5lqFoJkd/1vB8XmkfASJgF9HrrZ6Er11NWSmmpaV1nPywwDYFXdWHCB+Wi9Q==
version "3.2.6"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-3.2.6.tgz#595a97b2f25a669e61fe743f90a10dce783bbd49"
integrity sha512-0ePUyE9OjInYFqQwRKOxnFSu4dQX9+/rzFMynq2fKYXx406ZUThzSx72gebtjr0DoAQbsH2///BBZa5qk4qZXg==
dependencies:
flatpickr "^4.5.2"
@ -1369,9 +1379,9 @@ svelte-spa-router@^3.0.5:
regexparam "2.0.0"
svelte@^3.38.2:
version "3.44.1"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.44.1.tgz#5cc772a8340f4519a4ecd1ac1a842325466b1a63"
integrity sha512-4DrCEJoBvdR689efHNSxIQn2pnFwB7E7j2yLEJtHE/P8hxwZWIphCtJ8are7bjl/iVMlcEf5uh5pJ68IwR09vQ==
version "3.46.4"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.46.4.tgz#0c46bc4a3e20a2617a1b7dc43a722f9d6c084a38"
integrity sha512-qKJzw6DpA33CIa+C/rGp4AUdSfii0DOTCzj/2YpSKKayw5WGSS624Et9L1nU1k2OVRS9vaENQXp2CVZNU+xvIg==
svg.draggable.js@^2.2.2:
version "2.2.2"

View file

@ -1,12 +1,12 @@
{
"name": "@budibase/frontend-core",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase",
"license": "MPL-2.0",
"svelte": "src/index.js",
"dependencies": {
"@budibase/bbui": "^1.0.76-alpha.2",
"@budibase/bbui": "^1.0.81-alpha.0",
"lodash": "^4.17.21",
"svelte": "^3.46.2"
}

View file

@ -1,61 +1,66 @@
export const buildAttachmentEndpoints = API => ({
/**
* Uploads an attachment to the server.
* @param data the attachment to upload
* @param tableId the table ID to upload to
*/
uploadAttachment: async ({ data, tableId }) => {
return await API.post({
url: `/api/attachments/${tableId}/upload`,
body: data,
json: false,
})
},
/**
* Uploads an attachment to the server as a builder user from the builder.
* @param data the data to upload
*/
uploadBuilderAttachment: async data => {
return await API.post({
url: "/api/attachments/process",
body: data,
json: false,
})
},
export const buildAttachmentEndpoints = API => {
/**
* Generates a signed URL to upload a file to an external datasource.
* @param datasourceId the ID of the datasource to upload to
* @param bucket the name of the bucket to upload to
* @param key the name of the file to upload to
*/
getSignedDatasourceURL: async ({ datasourceId, bucket, key }) => {
const getSignedDatasourceURL = async ({ datasourceId, bucket, key }) => {
return await API.post({
url: `/api/attachments/${datasourceId}/url`,
body: { bucket, key },
})
},
}
/**
* Uploads a file to an external datasource.
* @param datasourceId the ID of the datasource to upload to
* @param bucket the name of the bucket to upload to
* @param key the name of the file to upload to
* @param data the file to upload
*/
externalUpload: async ({ datasourceId, bucket, key, data }) => {
const { signedUrl, publicUrl } = await API.getSignedDatasourceURL({
datasourceId,
bucket,
key,
})
await API.put({
url: signedUrl,
body: data,
json: false,
external: true,
})
return { publicUrl }
},
})
return {
getSignedDatasourceURL,
/**
* Uploads an attachment to the server.
* @param data the attachment to upload
* @param tableId the table ID to upload to
*/
uploadAttachment: async ({ data, tableId }) => {
return await API.post({
url: `/api/attachments/${tableId}/upload`,
body: data,
json: false,
})
},
/**
* Uploads an attachment to the server as a builder user from the builder.
* @param data the data to upload
*/
uploadBuilderAttachment: async data => {
return await API.post({
url: "/api/attachments/process",
body: data,
json: false,
})
},
/**
* Uploads a file to an external datasource.
* @param datasourceId the ID of the datasource to upload to
* @param bucket the name of the bucket to upload to
* @param key the name of the file to upload to
* @param data the file to upload
*/
externalUpload: async ({ datasourceId, bucket, key, data }) => {
console.log(API)
const { signedUrl, publicUrl } = await getSignedDatasourceURL({
datasourceId,
bucket,
key,
})
await API.put({
url: signedUrl,
body: data,
json: false,
external: true,
})
return { publicUrl }
},
}
}

View file

@ -20,6 +20,7 @@ import { buildScreenEndpoints } from "./screens"
import { buildTableEndpoints } from "./tables"
import { buildTemplateEndpoints } from "./templates"
import { buildUserEndpoints } from "./user"
import { buildSelfEndpoints } from "./self"
import { buildViewEndpoints } from "./views"
const defaultAPIClientConfig = {
@ -231,5 +232,6 @@ export const createAPIClient = config => {
...buildTemplateEndpoints(API),
...buildUserEndpoints(API),
...buildViewEndpoints(API),
...buildSelfEndpoints(API),
}
}

View file

@ -39,9 +39,11 @@ export const buildOtherEndpoints = API => ({
* Gets the version of the installed Budibase environment.
*/
getBudibaseVersion: async () => {
return await API.get({
url: "/api/dev/version",
}).version
return (
await API.get({
url: "/api/dev/version",
})
).version
},
/**

View file

@ -60,4 +60,18 @@ export const buildRowEndpoints = API => ({
},
})
},
/**
* Exports rows.
* @param tableId the table ID to export the rows from
* @param rows the array of rows to export
*/
exportRows: async ({ tableId, rows }) => {
return await API.post({
url: `/api/${tableId}/rows/exportRows`,
body: {
rows,
},
})
},
})

View file

@ -0,0 +1,54 @@
export const buildSelfEndpoints = API => ({
/**
* Using the logged in user, this will generate a new API key,
* assuming the user is a builder.
* @return {Promise<object>} returns the API response, including an API key.
*/
generateAPIKey: async () => {
const response = await API.post({
url: "/api/global/self/api_key",
})
return response?.apiKey
},
/**
* retrieves the API key for the logged in user.
* @return {Promise<object>} An object containing the user developer information.
*/
fetchDeveloperInfo: async () => {
return API.get({
url: "/api/global/self/api_key",
})
},
/**
* Fetches the currently logged-in user object.
* Used in client apps.
*/
fetchSelf: async () => {
return await API.get({
url: "/api/self",
})
},
/**
* Fetches the currently logged-in user object.
* Used in the builder.
*/
fetchBuilderSelf: async () => {
return await API.get({
url: "/api/global/self",
})
},
/**
* Updates the current logged-in user.
* @param user the new user object to save
*/
updateSelf: async user => {
return await API.post({
url: "/api/global/self",
body: user,
})
},
})

View file

@ -1,24 +1,4 @@
export const buildUserEndpoints = API => ({
/**
* Fetches the currently logged-in user object.
* Used in client apps.
*/
fetchSelf: async () => {
return await API.get({
url: "/api/self",
})
},
/**
* Fetches the currently logged-in user object.
* Used in the builder.
*/
fetchBuilderSelf: async () => {
return await API.get({
url: "/api/global/users/self",
})
},
/**
* Gets a list of users in the current tenant.
*/
@ -61,17 +41,6 @@ export const buildUserEndpoints = API => ({
})
},
/**
* Updates the current logged-in user.
* @param user the new user object to save
*/
updateSelf: async user => {
return await API.post({
url: "/api/global/users/self",
body: user,
})
},
/**
* Creates or updates a user in the current tenant.
* @param user the new user to create

View file

@ -1,7 +1,5 @@
export { createAPIClient } from "./api"
export { createLocalStorageStore } from "./stores/localStorage"
export { fetchData } from "./fetch/fetchData"
export * as Constants from "./constants"
export * as LuceneUtils from "./utils/lucene"
export * as JSONUtils from "./utils/json"
export * as CookieUtils from "./utils/cookies"
export * from "./stores"
export * from "./utils"

View file

@ -0,0 +1 @@
export { createLocalStorageStore } from "./localStorage"

View file

@ -0,0 +1,4 @@
export * as LuceneUtils from "./lucene"
export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies"
export * as Utils from "./utils"

View file

@ -0,0 +1,17 @@
/**
* Utility to wrap an async function and ensure all invocations happen
* sequentially.
* @param fn the async function to run
* @return {Promise} a sequential version of the function
*/
export const sequential = fn => {
let promise
return async (...params) => {
if (promise) {
await promise
}
promise = fn(...params)
await promise
promise = null
}
}

View file

@ -3,8 +3,7 @@ myapps/
.env
builder/*
client/*
public/
db/dev.db/
dist
coverage/
watchtower-hook.json
watchtower-hook.json

View file

@ -0,0 +1,17 @@
module MySQLMock {
const mysql: any = {}
const client = {
connect: jest.fn(),
end: jest.fn(),
query: jest.fn(async () => {
return [[]]
}),
}
mysql.createConnection = jest.fn(async () => {
return client
})
module.exports = mysql
}

View file

@ -53,6 +53,7 @@ module FetchMock {
{
doc: {
_id: "test",
tableId: opts.body.split("tableId:")[1].split('"')[0],
},
},
],

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/server",
"email": "hi@budibase.com",
"version": "1.0.76-alpha.2",
"version": "1.0.81-alpha.0",
"description": "Budibase Web Server",
"main": "src/index.ts",
"repository": {
@ -21,10 +21,8 @@
"dev:stack:down": "node scripts/dev/manage.js down",
"dev:stack:nuke": "node scripts/dev/manage.js nuke",
"dev:builder": "yarn run dev:stack:up && nodemon",
"generate:proxy:compose": "node scripts/proxy/generateProxyConfig compose",
"generate:proxy:preprod": "node scripts/proxy/generateProxyConfig preprod",
"generate:proxy:prod": "node scripts/proxy/generateProxyConfig prod",
"format": "prettier --config ../../.prettierrc.json 'src/**/*.ts' --write",
"specs": "node specs/generate.js && openapi-typescript specs/openapi.yaml --output src/definitions/openapi.ts",
"lint": "eslint --fix src/",
"lint:fix": "yarn run format && yarn run lint",
"initialise": "node scripts/initialise.js",
@ -73,9 +71,9 @@
"license": "GPL-3.0",
"dependencies": {
"@apidevtools/swagger-parser": "^10.0.3",
"@budibase/backend-core": "^1.0.76-alpha.2",
"@budibase/client": "^1.0.76-alpha.2",
"@budibase/string-templates": "^1.0.76-alpha.2",
"@budibase/backend-core": "^1.0.81-alpha.0",
"@budibase/client": "^1.0.81-alpha.0",
"@budibase/string-templates": "^1.0.81-alpha.0",
"@bull-board/api": "^3.7.0",
"@bull-board/koa": "^3.7.0",
"@elastic/elasticsearch": "7.10.0",
@ -110,6 +108,7 @@
"koa-send": "5.0.0",
"koa-session": "5.12.0",
"koa-static": "5.0.0",
"koa2-ratelimit": "^1.1.0",
"lodash": "4.17.21",
"memorystream": "^0.3.1",
"mongodb": "3.6.3",
@ -125,6 +124,7 @@
"pouchdb-all-dbs": "1.0.2",
"pouchdb-find": "^7.2.2",
"pouchdb-replication-stream": "1.2.9",
"redis": "4",
"server-destroy": "1.0.1",
"svelte": "^3.38.2",
"swagger-parser": "^10.0.3",
@ -150,6 +150,7 @@
"@types/koa-router": "^7.4.2",
"@types/node": "^15.12.4",
"@types/oracledb": "^5.2.1",
"@types/redis": "^4.0.11",
"@typescript-eslint/parser": "4.28.0",
"apidoc": "^0.50.2",
"babel-jest": "^27.0.2",
@ -157,12 +158,15 @@
"docker-compose": "^0.23.6",
"eslint": "^6.8.0",
"jest": "^27.0.5",
"jest-openapi": "^0.14.2",
"nodemon": "^2.0.4",
"openapi-types": "^9.3.1",
"openapi-typescript": "^5.2.0",
"path-to-regexp": "^6.2.0",
"prettier": "^2.3.1",
"rimraf": "^3.0.2",
"supertest": "^4.0.2",
"swagger-jsdoc": "^6.1.0",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"typescript": "^4.3.5",

View file

@ -1,5 +1,10 @@
USE master;
IF NOT EXISTS(SELECT 1 FROM sys.schemas WHERE name = 'Chains')
BEGIN
EXEC sys.sp_executesql N'CREATE SCHEMA Chains;'
END
IF OBJECT_ID ('dbo.products', 'U') IS NOT NULL
DROP TABLE products;
GO
@ -61,3 +66,15 @@ VALUES ('Bob', '30'),
('Bobert', '99'),
('Jan', '22'),
('Megan', '11');
IF OBJECT_ID ('Chains.sizes', 'U') IS NOT NULL
DROP TABLE Chains.sizes;
GO
CREATE TABLE Chains.sizes
(
sizeid int IDENTITY(1, 1),
name varchar(30),
CONSTRAINT pk_size PRIMARY KEY NONCLUSTERED (sizeid)
);

View file

@ -8,9 +8,9 @@
To install oracle express edition simply run `docker-compose up`
- A single instance pluggable database (PDB) will be created named `xepdb`
- A single instance pluggable database (PDB) will be created named `xepdb1`
- The default password is configured in the compose file as `oracle`
- The `system`, `sys` and `pdbadmin` users all share this password
- The `system` and `pdbadmin` users share this password
## Instant Client

View file

@ -1,11 +1,14 @@
SELECT 'CREATE DATABASE main'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'main')\gexec
CREATE SCHEMA test;
CREATE TYPE person_job AS ENUM ('qa', 'programmer', 'designer');
CREATE TABLE Persons (
PersonID SERIAL PRIMARY KEY,
LastName varchar(255),
FirstName varchar(255),
Address varchar(255),
City varchar(255) DEFAULT 'Belfast'
City varchar(255) DEFAULT 'Belfast',
Type person_job
);
CREATE TABLE Tasks (
TaskID SERIAL PRIMARY KEY,
@ -35,8 +38,12 @@ CREATE TABLE Products_Tasks (
REFERENCES Tasks(TaskID),
PRIMARY KEY (ProductID, TaskID)
);
INSERT INTO Persons (FirstName, LastName, Address, City) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast');
INSERT INTO Persons (FirstName, LastName, Address, City) Values ('John', 'Smith', '64 Updown Road', 'Dublin');
CREATE TABLE test.table1 (
id SERIAL PRIMARY KEY,
Name varchar(255)
);
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa');
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer');
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (1, 2, 'assembling', TRUE);
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (2, 1, 'processing', FALSE);
INSERT INTO Products (ProductName) VALUES ('Computers');
@ -46,3 +53,4 @@ INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (1, 1);
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (2, 1);
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (3, 1);
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (1, 2);
INSERT INTO test.table1 (Name) VALUES ('Test');

View file

@ -2,14 +2,13 @@
* This script just makes it easy to re-create *
* a cypress like environment for testing the backend *
******************************************************/
const path = require("path")
import path from "path"
const tmpdir = path.join(require("os").tmpdir(), ".budibase")
const MAIN_PORT = "10001"
const WORKER_PORT = "10002"
const SERVER_PORT = "4100"
const WORKER_PORT = "4200"
// @ts-ignore
process.env.PORT = MAIN_PORT
process.env.BUDIBASE_API_KEY = "6BE826CB-6B30-4AEC-8777-2E90464633DE"
process.env.NODE_ENV = "cypress"
process.env.ENABLE_ANALYTICS = "false"
@ -27,7 +26,11 @@ process.env.ALLOW_DEV_AUTOMATIONS = "1"
// don't make this a variable or top level require
// it will cause environment module to be loaded prematurely
const server = require("../src/app")
// override the port with the worker port temporarily
process.env.PORT = WORKER_PORT
const worker = require("../../worker/src/index")
process.env.PORT = MAIN_PORT
// override the port with the server port
process.env.PORT = SERVER_PORT
const server = require("../src/app")

View file

@ -0,0 +1,94 @@
const swaggerJsdoc = require("swagger-jsdoc")
const { join } = require("path")
const { writeFileSync } = require("fs")
const { examples, schemas } = require("./resources")
const parameters = require("./parameters")
const security = require("./security")
const VARIABLES = {}
const options = {
definition: {
openapi: "3.0.0",
info: {
title: "Budibase API",
description: "The public API for Budibase apps and its services.",
version: "1.0.0",
},
servers: [
{
url: "https://budibase.app/api/public/v1",
description: "Budibase Cloud API",
},
{
url: "{protocol}://{hostname}/api/public/v1",
description: "Budibase self hosted API",
variables: {
protocol: {
default: "http",
description:
"Whether HTTP or HTTPS should be used to communicate with your Budibase instance.",
},
hostname: {
default: "localhost:10000",
description: "The URL of your Budibase instance.",
},
},
},
],
components: {
parameters: {
...parameters,
},
examples: {
...examples,
},
securitySchemes: {
...security,
},
schemas: {
...schemas,
},
},
security: [
{
ApiKeyAuth: [],
},
],
},
format: ".json",
apis: [join(__dirname, "..", "src", "api", "routes", "public", "*.ts")],
}
function writeFile(output, filename) {
try {
const path = join(__dirname, filename)
let spec = output
if (filename.endsWith("json")) {
spec = JSON.stringify(output, null, 2)
}
// input the static variables
for (let [key, replacement] of Object.entries(VARIABLES)) {
spec = spec.replace(new RegExp(`{${key}}`, "g"), replacement)
}
writeFileSync(path, spec)
console.log(`Wrote spec to ${path}`)
return path
} catch (err) {
console.error(err)
}
}
function run() {
const outputJSON = swaggerJsdoc(options)
options.format = ".yaml"
const outputYAML = swaggerJsdoc(options)
writeFile(outputJSON, "openapi.json")
return writeFile(outputYAML, "openapi.yaml")
}
if (require.main === module) {
run()
}
module.exports = run

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