Merge remote-tracking branch 'origin/develop' into feature/db-query-save-prompt

This commit is contained in:
Dean 2023-05-31 11:56:55 +01:00
commit 24d57e8fd2
333 changed files with 14451 additions and 3656 deletions

View File

@ -22,44 +22,67 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn lint
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn bootstrap
# Run build all the projects
- run: yarn build
# Check the types of the projects built via esbuild
- run: yarn check:types
test:
test-libraries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn bootstrap
- run: yarn build
- run: yarn test --ignore=@budibase/pro
- run: yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
name: codecov-umbrella
verbose: true
test-services:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn test --scope=@budibase/worker --scope=@budibase/server
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
@ -69,32 +92,34 @@ jobs:
test-pro:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn bootstrap
- run: yarn build --scope=@budibase/types --scope=@budibase/shared-core
- run: yarn test --scope=@budibase/pro
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
- run: yarn && yarn bootstrap && yarn build
- run: |
cache: "yarn"
- run: yarn
- run: yarn build
- name: Run tests
run: |
cd qa-core
yarn setup
yarn test:ci
@ -106,7 +131,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}

View File

@ -37,7 +37,7 @@ jobs:
with:
node-version: 14.x
- run: yarn
- run: yarn install --frozen-lockfile
- name: Update versions
run: |
version=$(cat lerna.json \
@ -51,7 +51,7 @@ jobs:
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn
- run: yarn build
- run: yarn build --configuration=production
- run: yarn build:sdk
- name: Publish budibase packages to NPM

View File

@ -42,7 +42,7 @@ jobs:
with:
node-version: 14.x
- run: yarn
- run: yarn install --frozen-lockfile
- name: Update versions
run: |
version=$(cat lerna.json \
@ -57,7 +57,7 @@ jobs:
echo "Syncing yarn workspace"
yarn
- run: yarn lint
- run: yarn build
- run: yarn build --configuration=production
- run: yarn build:sdk
- name: Publish budibase packages to NPM

2
.nvmrc
View File

@ -1 +1 @@
v14.20.1
v14.20.1

View File

@ -1 +1 @@
3.10.0
3.10.0

View File

@ -144,8 +144,6 @@ The following commands can be executed to manually get Budibase up and running (
`yarn` to install project dependencies
`yarn bootstrap` will install all budibase modules and symlink them together using lerna.
`yarn build` will build all budibase packages.
#### 4. Running
@ -243,7 +241,7 @@ An overview of the CI pipelines can be found [here](../.github/workflows/README.
Note that only budibase maintainers will be able to access the pro repo.
The `yarn bootstrap` command can be used to replace the NPM supplied dependency with the local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev.
By default, NX will make sure that dependencies are replaced with local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev.
### Troubleshooting

View File

@ -0,0 +1,77 @@
version: "3"
# optional ports are specified throughout for more advanced use cases.
services:
app-service:
build: ../packages/server
container_name: build-bbapps
environment:
SELF_HOSTED: 1
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
WORKER_URL: http://worker-service:4003
MINIO_URL: http://minio-service:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT}
PORT: 4002
API_ENCRYPTION_KEY: ${API_ENCRYPTION_KEY}
JWT_SECRET: ${JWT_SECRET}
LOG_LEVEL: info
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
ENABLE_ANALYTICS: "true"
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL}
BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD}
PLUGINS_DIR: ${PLUGINS_DIR}
depends_on:
- worker-service
- redis-service
# volumes:
# - /some/path/to/plugins:/plugins
worker-service:
build: ../packages/worker
container_name: build-bbworker
environment:
SELF_HOSTED: 1
PORT: 4003
CLUSTER_PORT: ${MAIN_PORT}
API_ENCRYPTION_KEY: ${API_ENCRYPTION_KEY}
JWT_SECRET: ${JWT_SECRET}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_URL: http://minio-service:9000
APPS_URL: http://app-service:4002
COUCH_DB_USERNAME: ${COUCH_DB_USER}
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on:
- redis-service
- minio-service
proxy-service-docker:
ports:
- "${MAIN_PORT}:10000"
container_name: build-bbproxy
image: budibase/proxy
environment:
- PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
- PROXY_RATE_LIMIT_API_PER_SECOND=20
- APPS_UPSTREAM_URL=http://app-service:4002
- WORKER_UPSTREAM_URL=http://worker-service:4003
- MINIO_UPSTREAM_URL=http://minio-service:9000
- COUCHDB_UPSTREAM_URL=http://couchdb-service:5984
- WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080
- RESOLVER=127.0.0.11
depends_on:
- minio-service
- worker-service
- app-service
- couchdb-service

View File

@ -55,7 +55,7 @@ http {
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";
@ -82,6 +82,12 @@ http {
set $couchdb ${COUCHDB_UPSTREAM_URL};
set $watchtower ${WATCHTOWER_UPSTREAM_URL};
location /health {
access_log off;
add_header 'Content-Type' 'application/json';
return 200 '{ "status": "OK" }';
}
location /app {
proxy_pass $apps;
}

View File

@ -1,22 +1,22 @@
FROM node:14-slim as build
FROM node:16-slim as build
# install node-gyp dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python
# add pin script
WORKDIR /
ADD scripts/pinVersions.js scripts/cleanup.sh ./
ADD scripts/cleanup.sh ./
RUN chmod +x /cleanup.sh
# build server
WORKDIR /app
ADD packages/server .
RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh
RUN yarn install --frozen-lockfile --production=true && /cleanup.sh
# build worker
WORKDIR /worker
ADD packages/worker .
RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh
RUN yarn install --frozen-lockfile --production=true && /cleanup.sh
FROM budibase/couchdb
ARG TARGETARCH
@ -31,9 +31,7 @@ COPY --from=build /worker /worker
# install base dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server && \
apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \
apt-get update
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server
# install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx
WORKDIR /nodejs

View File

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

10
nx.json
View File

@ -6,5 +6,15 @@
"cacheableOperations": ["build", "test"]
}
}
},
"targetDefaults": {
"dev:builder": {
"dependsOn": [
{
"projects": ["@budibase/string-templates"],
"target": "build"
}
]
}
}
}

View File

@ -2,17 +2,23 @@
"name": "root",
"private": true,
"devDependencies": {
"@esbuild-plugins/node-resolve": "^0.2.2",
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.2.1",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
"babel-eslint": "^10.0.3",
"esbuild": "^0.17.18",
"eslint": "^7.28.0",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-svelte3": "^3.2.0",
"husky": "^8.0.3",
"js-yaml": "^4.1.0",
"kill-port": "^1.6.1",
"lerna": "^6.6.1",
"lerna": "7.0.0-alpha.0",
"madge": "^6.0.0",
"minimist": "^1.2.8",
"nx": "^16.2.1",
"prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2",
@ -23,10 +29,11 @@
},
"scripts": {
"preinstall": "node scripts/syncProPackage.js",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
"bootstrap": "./scripts/bootstrap.sh && lerna link && ./scripts/link-dependencies.sh",
"build": "lerna run --stream build",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types --skip-nx-cache",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk",
@ -41,10 +48,11 @@
"kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server",
"dev": "yarn run kill-all && lerna link && lerna run --stream --parallel dev:builder --concurrency 1 --stream",
"dev:noserver": "yarn run kill-builder && lerna link && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --concurrency 1 --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --concurrency 1 --scope @budibase/backend-core --scope @budibase/worker --scope @budibase/server",
"dev": "yarn run kill-all && lerna run --stream --parallel dev:builder --stream",
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"dev:docker": "yarn build && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream",
"lint:eslint": "eslint packages && eslint qa-core",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",
@ -53,16 +61,16 @@
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"",
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs",
"build:docker": "lerna run --stream build:docker && npm run build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:pre": "lerna run --stream build && lerna run --stream predocker",
"build:docker:proxy": "docker build hosting/proxy -t proxy-service",
"build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && npm run build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
"build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single": "npm run build:docker:pre && npm run build:docker:single:image",
"build:docker:single": "yarn build && lerna run --concurrency 1 predocker && yarn build:docker:single:image",
"build:docker:dependencies": "docker build -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest ./hosting",
"publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.2.1 --push ./hosting/couchdb",
"publish:docker:dependencies": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest -t budibase/dependencies:v3.2.1 --push ./hosting",
@ -101,5 +109,12 @@
"packages/worker",
"packages/pro/packages/pro"
]
}
},
"resolutions": {
"@budibase/backend-core": "0.0.0",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0"
},
"dependencies": {}
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "0.0.1",
"version": "0.0.0",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -22,7 +22,7 @@
"dependencies": {
"@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "0.0.1",
"@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",
@ -88,5 +88,19 @@
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}

View File

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

View File

@ -21,7 +21,7 @@ export enum ViewName {
AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
USER_BY_GROUP = "by_group_user",
USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger",
}

View File

@ -104,6 +104,22 @@ async function newContext(updates: ContextMap, task: any) {
return Context.run(context, task)
}
export async function doInAutomationContext(params: {
appId: string
automationId: string
task: any
}): Promise<any> {
const tenantId = getTenantIDFromAppID(params.appId)
return newContext(
{
tenantId,
appId: params.appId,
automationId: params.automationId,
},
params.task
)
}
export async function doInContext(appId: string, task: any): Promise<any> {
const tenantId = getTenantIDFromAppID(appId)
return newContext(
@ -187,6 +203,11 @@ export function getTenantId(): string {
return tenantId
}
export function getAutomationId(): string | undefined {
const context = Context.get()
return context?.automationId
}
export function getAppId(): string | undefined {
const context = Context.get()
const foundId = context?.appId

View File

@ -7,4 +7,5 @@ export type ContextMap = {
identity?: IdentityContext
environmentVariables?: Record<string, string>
isScim?: boolean
automationId?: string
}

View File

@ -12,7 +12,7 @@ import {
isDocument,
} from "@budibase/types"
import { getCouchInfo } from "./connections"
import { directCouchCall } from "./utils"
import { directCouchUrlCall } from "./utils"
import { getPouchDB } from "./pouchDB"
import { WriteStream, ReadStream } from "fs"
import { newid } from "../../docIds/newid"
@ -46,6 +46,8 @@ export class DatabaseImpl implements Database {
private readonly instanceNano?: Nano.ServerScope
private readonly pouchOpts: DatabaseOpts
private readonly couchInfo = getCouchInfo()
constructor(dbName?: string, opts?: DatabaseOpts, connection?: string) {
if (dbName == null) {
throw new Error("Database name cannot be undefined.")
@ -53,8 +55,8 @@ export class DatabaseImpl implements Database {
this.name = dbName
this.pouchOpts = opts || {}
if (connection) {
const couchInfo = getCouchInfo(connection)
this.instanceNano = buildNano(couchInfo)
this.couchInfo = getCouchInfo(connection)
this.instanceNano = buildNano(this.couchInfo)
}
if (!DatabaseImpl.nano) {
DatabaseImpl.init()
@ -67,7 +69,11 @@ export class DatabaseImpl implements Database {
}
async exists() {
let response = await directCouchCall(`/${this.name}`, "HEAD")
const response = await directCouchUrlCall({
url: `${this.couchInfo.url}/${this.name}`,
method: "HEAD",
cookie: this.couchInfo.cookie,
})
return response.status === 200
}

View File

@ -4,21 +4,21 @@ export const getCouchInfo = (connection?: string) => {
const urlInfo = getUrlInfo(connection)
let username
let password
if (env.COUCH_DB_USERNAME) {
// set from env
username = env.COUCH_DB_USERNAME
} else if (urlInfo.auth.username) {
if (urlInfo.auth?.username) {
// set from url
username = urlInfo.auth.username
} else if (env.COUCH_DB_USERNAME) {
// set from env
username = env.COUCH_DB_USERNAME
} else if (!env.isTest()) {
throw new Error("CouchDB username not set")
}
if (env.COUCH_DB_PASSWORD) {
// set from env
password = env.COUCH_DB_PASSWORD
} else if (urlInfo.auth.password) {
if (urlInfo.auth?.password) {
// set from url
password = urlInfo.auth.password
} else if (env.COUCH_DB_PASSWORD) {
// set from env
password = env.COUCH_DB_PASSWORD
} else if (!env.isTest()) {
throw new Error("CouchDB password not set")
}

View File

@ -9,6 +9,20 @@ export async function directCouchCall(
) {
let { url, cookie } = getCouchInfo()
const couchUrl = `${url}/${path}`
return await directCouchUrlCall({ url: couchUrl, cookie, method, body })
}
export async function directCouchUrlCall({
url,
cookie,
method,
body,
}: {
url: string
cookie: string
method: string
body?: any
}) {
const params: any = {
method: method,
headers: {
@ -19,7 +33,7 @@ export async function directCouchCall(
params.body = JSON.stringify(body)
params.headers["Content-Type"] = "application/json"
}
return await fetch(checkSlashesInUrl(encodeURI(couchUrl)), params)
return await fetch(checkSlashesInUrl(encodeURI(url)), params)
}
export async function directCouchQuery(

View File

@ -69,10 +69,10 @@ function findVersion() {
try {
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
const content = readFileSync(packageJsonFile!, "utf-8")
const version = JSON.parse(content).version
return version
return JSON.parse(content).version
} catch {
throw new Error("Cannot find a valid version in its package.json")
// throwing an error here is confusing/causes backend-core to be hard to import
return undefined
}
}
@ -95,7 +95,7 @@ const environment = {
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
REDIS_PASSWORD: process.env.REDIS_PASSWORD || "budibase",
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
MOCK_REDIS: process.env.MOCK_REDIS,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,

View File

@ -39,6 +39,7 @@ if (!env.DISABLE_PINO_LOGGER) {
objects?: any[]
tenantId?: string
appId?: string
automationId?: string
identityId?: string
identityType?: IdentityType
correlationId?: string
@ -86,18 +87,45 @@ if (!env.DISABLE_PINO_LOGGER) {
contextObject = {
tenantId: getTenantId(),
appId: getAppId(),
automationId: getAutomationId(),
identityId: identity?._id,
identityType: identity?.type,
correlationId: correlation.getId(),
}
}
const mergingObject = {
objects: objects.length ? objects : undefined,
const mergingObject: any = {
err: error,
pid: process.pid,
...contextObject,
}
if (objects.length) {
// init generic data object for params supplied that don't have a
// '_logKey' field. This prints an object using argument index as the key
// e.g. { 0: {}, 1: {} }
const data: any = {}
let dataIndex = 0
for (let i = 0; i < objects.length; i++) {
const object = objects[i]
// the object has specified a log key
// use this instead of generic key
const logKey = object._logKey
if (logKey) {
delete object._logKey
mergingObject[logKey] = object
} else {
data[dataIndex] = object
dataIndex++
}
}
if (Object.keys(data).length) {
mergingObject.data = data
}
}
return [mergingObject, message]
}
@ -159,6 +187,16 @@ if (!env.DISABLE_PINO_LOGGER) {
return appId
}
const getAutomationId = () => {
let appId
try {
appId = context.getAutomationId()
} catch (e) {
// do nothing
}
return appId
}
const getIdentity = () => {
let identity
try {

View File

@ -128,6 +128,7 @@ class InMemoryQueue {
on() {
// do nothing
return this
}
async waitForCompletion() {

View File

@ -1,5 +1,6 @@
import { Job, JobId, Queue } from "bull"
import { JobQueue } from "./constants"
import * as context from "../context"
export type StalledFn = (job: Job) => Promise<void>
@ -31,77 +32,164 @@ function handleStalled(queue: Queue, removeStalledCb?: StalledFn) {
})
}
function logging(queue: Queue, jobQueue: JobQueue) {
let eventType: string
switch (jobQueue) {
case JobQueue.AUTOMATION:
eventType = "automation-event"
break
case JobQueue.APP_BACKUP:
eventType = "app-backup-event"
break
case JobQueue.AUDIT_LOG:
eventType = "audit-log-event"
break
case JobQueue.SYSTEM_EVENT_QUEUE:
eventType = "system-event"
break
function getLogParams(
eventType: QueueEventType,
event: BullEvent,
opts: {
job?: Job
jobId?: JobId
error?: Error
} = {},
extra: any = {}
) {
const message = `[BULL] ${eventType}=${event}`
const err = opts.error
const bullLog = {
_logKey: "bull",
eventType,
event,
job: opts.job,
jobId: opts.jobId || opts.job?.id,
...extra,
}
let automationLog
if (opts.job?.data?.automation) {
automationLog = {
_logKey: "automation",
trigger: opts.job
? opts.job.data.automation.definition.trigger.event
: undefined,
}
}
return [message, err, bullLog, automationLog]
}
enum BullEvent {
ERROR = "error",
WAITING = "waiting",
ACTIVE = "active",
STALLED = "stalled",
PROGRESS = "progress",
COMPLETED = "completed",
FAILED = "failed",
PAUSED = "paused",
RESUMED = "resumed",
CLEANED = "cleaned",
DRAINED = "drained",
REMOVED = "removed",
}
enum QueueEventType {
AUTOMATION_EVENT = "automation-event",
APP_BACKUP_EVENT = "app-backup-event",
AUDIT_LOG_EVENT = "audit-log-event",
SYSTEM_EVENT = "system-event",
}
const EventTypeMap: { [key in JobQueue]: QueueEventType } = {
[JobQueue.AUTOMATION]: QueueEventType.AUTOMATION_EVENT,
[JobQueue.APP_BACKUP]: QueueEventType.APP_BACKUP_EVENT,
[JobQueue.AUDIT_LOG]: QueueEventType.AUDIT_LOG_EVENT,
[JobQueue.SYSTEM_EVENT_QUEUE]: QueueEventType.SYSTEM_EVENT,
}
function logging(queue: Queue, jobQueue: JobQueue) {
const eventType = EventTypeMap[jobQueue]
function doInJobContext(job: Job, task: any) {
// if this is an automation job try to get the app id
const appId = job.data.event?.appId
if (appId) {
return context.doInContext(appId, task)
} else {
task()
}
}
queue
.on(BullEvent.STALLED, async (job: Job) => {
// A job has been marked as stalled. This is useful for debugging job
// workers that crash or pause the event loop.
await doInJobContext(job, () => {
console.error(...getLogParams(eventType, BullEvent.STALLED, { job }))
})
})
.on(BullEvent.ERROR, (error: any) => {
// An error occurred.
console.error(...getLogParams(eventType, BullEvent.ERROR, { error }))
})
if (process.env.NODE_DEBUG?.includes("bull")) {
queue
.on("error", (error: any) => {
// An error occurred.
console.error(`${eventType}=error error=${JSON.stringify(error)}`)
})
.on("waiting", (jobId: JobId) => {
.on(BullEvent.WAITING, (jobId: JobId) => {
// A Job is waiting to be processed as soon as a worker is idling.
console.log(`${eventType}=waiting jobId=${jobId}`)
console.info(...getLogParams(eventType, BullEvent.WAITING, { jobId }))
})
.on("active", (job: Job, jobPromise: any) => {
.on(BullEvent.ACTIVE, async (job: Job, jobPromise: any) => {
// A job has started. You can use `jobPromise.cancel()`` to abort it.
console.log(`${eventType}=active jobId=${job.id}`)
await doInJobContext(job, () => {
console.info(...getLogParams(eventType, BullEvent.ACTIVE, { job }))
})
})
.on("stalled", (job: Job) => {
// A job has been marked as stalled. This is useful for debugging job
// workers that crash or pause the event loop.
console.error(
`${eventType}=stalled jobId=${job.id} job=${JSON.stringify(job)}`
)
.on(BullEvent.PROGRESS, async (job: Job, progress: any) => {
// A job's progress was updated
await doInJobContext(job, () => {
console.info(
...getLogParams(
eventType,
BullEvent.PROGRESS,
{ job },
{ progress }
)
)
})
})
.on("progress", (job: Job, progress: any) => {
// A job's progress was updated!
console.log(
`${eventType}=progress jobId=${job.id} progress=${progress}`
)
})
.on("completed", (job: Job, result) => {
.on(BullEvent.COMPLETED, async (job: Job, result) => {
// A job successfully completed with a `result`.
console.log(`${eventType}=completed jobId=${job.id} result=${result}`)
await doInJobContext(job, () => {
console.info(
...getLogParams(eventType, BullEvent.COMPLETED, { job }, { result })
)
})
})
.on("failed", (job, err: any) => {
.on(BullEvent.FAILED, async (job: Job, error: any) => {
// A job failed with reason `err`!
console.log(`${eventType}=failed jobId=${job.id} error=${err}`)
await doInJobContext(job, () => {
console.error(
...getLogParams(eventType, BullEvent.FAILED, { job, error })
)
})
})
.on("paused", () => {
.on(BullEvent.PAUSED, () => {
// The queue has been paused.
console.log(`${eventType}=paused`)
console.info(...getLogParams(eventType, BullEvent.PAUSED))
})
.on("resumed", (job: Job) => {
.on(BullEvent.RESUMED, () => {
// The queue has been resumed.
console.log(`${eventType}=paused jobId=${job.id}`)
console.info(...getLogParams(eventType, BullEvent.RESUMED))
})
.on("cleaned", (jobs: Job[], type: string) => {
.on(BullEvent.CLEANED, (jobs: Job[], type: string) => {
// Old jobs have been cleaned from the queue. `jobs` is an array of cleaned
// jobs, and `type` is the type of jobs cleaned.
console.log(`${eventType}=cleaned length=${jobs.length} type=${type}`)
console.info(
...getLogParams(
eventType,
BullEvent.CLEANED,
{},
{ length: jobs.length, type }
)
)
})
.on("drained", () => {
.on(BullEvent.DRAINED, () => {
// Emitted every time the queue has processed all the waiting jobs (even if there can be some delayed jobs not yet processed)
console.log(`${eventType}=drained`)
console.info(...getLogParams(eventType, BullEvent.DRAINED))
})
.on("removed", (job: Job) => {
.on(BullEvent.REMOVED, (job: Job) => {
// A job successfully removed.
console.log(`${eventType}=removed jobId=${job.id}`)
console.info(...getLogParams(eventType, BullEvent.REMOVED, { job }))
})
}
}

View File

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

View File

@ -27,6 +27,7 @@ export enum Databases {
GENERIC_CACHE = "data_cache",
WRITE_THROUGH = "writeThrough",
LOCKS = "locks",
SOCKET_IO = "socket_io",
}
/**

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "0.0.1",
"version": "0.0.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,8 +38,8 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",
@ -84,11 +84,25 @@
"@spectrum-css/vars": "3.0.1",
"dayjs": "^1.10.4",
"easymde": "^2.16.1",
"svelte-flatpickr": "^3.3.2",
"svelte-flatpickr": "3.2.3",
"svelte-portal": "^1.0.0"
},
"resolutions": {
"loader-utils": "1.4.1"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/string-templates"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}

View File

@ -102,7 +102,9 @@
margin-left: 0;
transition: color ease-out 130ms;
}
.is-selected:not(.spectrum-ActionButton--emphasized):not(.spectrum-ActionButton--quiet) {
.is-selected:not(.spectrum-ActionButton--emphasized):not(
.spectrum-ActionButton--quiet
) {
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-500);
}

View File

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

View File

@ -2,6 +2,7 @@
import "@spectrum-css/button/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte"
export let type
export let disabled = false
export let size = "M"
export let cta = false
@ -21,6 +22,7 @@
<button
{id}
{type}
class:spectrum-Button--cta={cta}
class:spectrum-Button--primary={primary}
class:spectrum-Button--secondary={secondary}
@ -73,6 +75,7 @@
button {
position: relative;
}
.spectrum-Button-label {
white-space: nowrap;
overflow: hidden;

View File

@ -0,0 +1,19 @@
<script>
import { slide } from "svelte/transition"
export let error = null
</script>
<div transition:slide|local={{ duration: 130 }} class="error-message">
{error}
</div>
<style>
.error-message {
background: var(--spectrum-global-color-red-400);
color: white;
font-size: 14px;
padding: 6px 16px;
font-weight: 500;
}
</style>

View File

@ -1,7 +1,7 @@
<script>
import Icon from "../Icon/Icon.svelte"
import { getContext, onMount } from "svelte"
import { slide } from "svelte/transition"
import ErrorMessage from "./ErrorMessage.svelte"
export let disabled = false
export let error = null
@ -55,9 +55,7 @@
{/if}
</div>
{#if error}
<div transition:slide|local={{ duration: 130 }} class="error-message">
{error}
</div>
<ErrorMessage {error} />
{/if}
</div>
@ -110,13 +108,6 @@
.field {
flex: 1 1 auto;
}
.error-message {
background: var(--spectrum-global-color-red-400);
color: white;
font-size: 14px;
padding: 6px 16px;
font-weight: 500;
}
.error-icon {
flex: 0 0 auto;
}

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "0.0.1",
"version": "0.0.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -9,7 +9,7 @@
"dev:builder": "routify -c dev:vite",
"dev:vite": "vite --host 0.0.0.0",
"rollup": "rollup -c -w",
"test": "vitest"
"test": "vitest run"
},
"jest": {
"globals": {
@ -58,10 +58,11 @@
}
},
"dependencies": {
"@budibase/bbui": "0.0.1",
"@budibase/frontend-core": "0.0.1",
"@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1",
"@budibase/bbui": "0.0.0",
"@budibase/frontend-core": "0.0.0",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
@ -116,5 +117,31 @@
"vite": "^3.0.8",
"vitest": "^0.29.2"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/string-templates",
"@budibase/shared-core"
],
"target": "build"
}
]
},
"test": {
"dependsOn": [
{
"projects": [
"@budibase/shared-core",
"@budibase/string-templates"
],
"target": "build"
}
]
}
}
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"
}

View File

@ -2,6 +2,7 @@ import { datasources, tables } from "../stores/backend"
import { IntegrationNames } from "../constants/backend"
import { get } from "svelte/store"
import cloneDeep from "lodash/cloneDeepWith"
import { API } from "api"
function prepareData(config) {
let datasource = {}
@ -37,3 +38,9 @@ export async function createRestDatasource(integration) {
const config = cloneDeep(integration)
return saveDatasource(config)
}
export async function validateDatasourceConfig(config) {
const datasource = prepareData(config)
const resp = await API.validateDatasource(datasource)
return resp
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import { createWebsocket } from "@budibase/frontend-core"
import { userStore } from "builderStore"
import { datasources, tables } from "stores/backend"
export const createBuilderWebsocket = () => {
const socket = createWebsocket("/socket/builder")
// Connection events
socket.on("connect", () => {
socket.emit("get-users", null, response => {
userStore.actions.init(response.users)
})
})
socket.on("connect_error", err => {
console.log("Failed to connect to builder websocket:", err.message)
})
// User events
socket.on("user-update", userStore.actions.updateUser)
socket.on("user-disconnect", userStore.actions.removeUser)
// Table events
socket.on("table-change", ({ id, table }) => {
tables.replaceTable(id, table)
})
// Datasource events
socket.on("datasource-change", ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource)
})
return {
...socket,
disconnect: () => {
socket?.disconnect()
userStore.actions.reset()
},
}
}

View File

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

View File

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

View File

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

View File

@ -111,7 +111,7 @@
{rowCount}
{disableSorting}
{customPlaceholder}
{allowEditing}
allowEditRows={allowEditing}
showAutoColumns={!hideAutocolumns}
{allowClickRows}
on:clickrelationship={e => selectRelationship(e.detail)}

View File

@ -58,6 +58,7 @@
{loading}
{type}
rowCount={10}
allowEditing={false}
bind:hideAutocolumns
>
<ViewFilterButton {view} />

View File

@ -3,6 +3,7 @@
import ImportModal from "../modals/ImportModal.svelte"
export let tableId
export let tableType
export let disabled
let modal
@ -12,5 +13,5 @@
Import
</ActionButton>
<Modal bind:this={modal}>
<ImportModal {tableId} on:importrows />
<ImportModal {tableId} {tableType} on:importrows />
</Modal>

View File

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

View File

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

View File

@ -13,15 +13,18 @@
const dispatch = createEventDispatcher()
export let tableId
export let tableType
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
async function importData() {
try {
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
} catch (error) {
@ -45,6 +48,13 @@
</Body>
<Layout gap="XS" noPadding>
<Label grey extraSmall>CSV or JSON file to import</Label>
<TableDataImport {tableId} bind:rows bind:allValid bind:displayColumn />
<TableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
</Layout>
</ModalContent>

View File

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

View File

@ -4,55 +4,66 @@
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith"
import { saveDatasource as save } from "builderStore/datasource"
import { onMount } from "svelte"
import {
saveDatasource as save,
validateDatasourceConfig,
} from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
export let integration
export let modal
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
let skipFetch = false
let isValid = false
$: name =
IntegrationNames[datasource.type] || datasource.name || datasource.type
async function validateConfig() {
const displayError = message =>
notifications.error(message ?? "Error validating datasource")
let connected = false
try {
const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) {
displayError(`Unable to connect - ${resp.error}`)
}
connected = resp.connected
} catch (err) {
displayError(err?.message)
}
return connected
}
async function saveDatasource() {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try {
if (!datasource.name) {
datasource.name = name
}
const resp = await save(datasource, skipFetch)
const resp = await save(datasource)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource updated successfully.`)
notifications.success(`Datasource created successfully.`)
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}
onMount(() => {
skipFetch = false
})
</script>
<ModalContent
title={`Connect to ${name}`}
onConfirm={() => saveDatasource()}
onCancel={() => modal.show()}
confirmText={datasource.plus
? "Save and fetch tables"
: "Save and continue to query"}
confirmText={datasource.plus ? "Connect" : "Save and continue to query"}
cancelText="Back"
showSecondaryButton={datasource.plus}
secondaryButtonText={datasource.plus ? "Skip table fetch" : undefined}
secondaryAction={() => {
skipFetch = true
saveDatasource()
return true
}}
size="L"
disabled={!isValid}
>

View File

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

View File

@ -1,5 +1,5 @@
<script>
import { Select } from "@budibase/bbui"
import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { FIELDS } from "constants/backend"
import { API } from "api"
import { parseFile } from "./utils"
@ -9,14 +9,17 @@
let fileType = null
let loading = false
let updateExistingRows = false
let validation = {}
let validateHash = ""
let schema = null
let invalidColumns = []
export let tableId = null
export let tableType
export let rows = []
export let allValid = false
export let identifierFields = []
const typeOptions = [
{
@ -159,6 +162,22 @@
</div>
{/each}
</div>
{#if tableType === "internal"}
<br />
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{#if updateExistingRows}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
/>
{/if}
{/if}
{#if invalidColumns.length > 0}
<p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM">
The following columns are present in the data you wish to import, but do

View File

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

View File

@ -6,7 +6,8 @@
import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify"
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
const alphabetical = (a, b) =>
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId

View File

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

View File

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

View File

@ -146,15 +146,18 @@
/* Override default active line highlight colour in dark theme */
div
:global(.CodeMirror-focused.cm-s-tomorrow-night-eighties
.CodeMirror-activeline-background) {
:global(
.CodeMirror-focused.cm-s-tomorrow-night-eighties
.CodeMirror-activeline-background
) {
background: rgba(255, 255, 255, 0.075);
}
/* Remove active line styling when not focused */
div
:global(.CodeMirror:not(.CodeMirror-focused)
.CodeMirror-activeline-background) {
:global(
.CodeMirror:not(.CodeMirror-focused) .CodeMirror-activeline-background
) {
background: unset;
}

View File

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

View File

@ -17,6 +17,7 @@
export let highlighted = false
export let rightAlignIcon = false
export let id
export let showTooltip = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@ -84,7 +85,7 @@
<Icon color={iconColor} size="S" name={icon} />
</div>
{/if}
<div class="text">{text}</div>
<div class="text" title={showTooltip ? text : null}>{text}</div>
{#if withActions}
<div class="actions">
<slot />

View File

@ -113,109 +113,113 @@
})
</script>
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
<RevertModal />
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
{#if $store.hasLock}
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
{#if !isPublished}
<ActionButton
quiet
icon="UserGroup"
icon="GlobeStrike"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
<div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button>
<DeployModal onOk={completePublish} />
{#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
</div>
<style>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,31 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
let modal
export let onConfirm
export function show() {
modal.show()
}
export function hide() {
modal.hide()
}
</script>
<Modal bind:this={modal} on:hide={modal}>
<ModalContent
title="Your account is currently de-activated"
size="S"
showCancelButton={true}
showCloseIcon={false}
confirmText={"View plans"}
{onConfirm}
>
<Body size="S"
>Due to the free plan user limit being exceeded, your account has been
de-activated. Upgrade your plan to re-activate your account.</Body
>
</ModalContent>
</Modal>

View File

@ -3,7 +3,6 @@ import { temporalStore } from "builderStore"
import { admin, auth, licensing } from "stores/portal"
import { get } from "svelte/store"
import { BANNER_TYPES } from "@budibase/bbui"
import { capitalise } from "helpers"
const oneDayInSeconds = 86400
@ -146,23 +145,19 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => {
const userLicensing = get(licensing)
return {
key: EXPIRY_KEY,
type: BANNER_TYPES.WARNING,
type: BANNER_TYPES.NEGATIVE,
onChange: () => {
defaultCacheFn(EXPIRY_KEY)
},
criteria: () => {
return userLicensing.warnUserLimit
return userLicensing.errUserLimit
},
message: `${capitalise(
userLicensing.license.plan.type
)} plan changes - Users will be limited to ${
userLicensing.userLimit
} users in ${userLicensing.userLimitDays}`,
message: "Your Budibase account is de-activated. Upgrade your plan",
...{
extraButtonText: "Find out more",
extraButtonText: "View plans",
extraButtonAction: () => {
defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER)
window.location.href = "/builder/portal/users/users"
window.location.href = "https://budibase.com/pricing/"
},
},
showCloseButton: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,13 +28,16 @@
let inviting = false
let searchFocus = false
// Initially filter entities without app access
// Show all when false
let filterByAppAccess = true
let appInvites = []
let filteredInvites = []
let filteredUsers = []
let filteredGroups = []
let selectedGroup
let userOnboardResponse = null
let userLimitReachedModal
$: queryIsEmail = emailValidator(query) === true
@ -52,15 +55,32 @@
}
const filterInvites = async query => {
appInvites = await getInvites()
if (!query || query == "") {
filteredInvites = appInvites
if (!prodAppId) {
return
}
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
appInvites = await getInvites()
//On Focus behaviour
if (!filterByAppAccess && !query) {
filteredInvites =
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
return
}
filteredInvites = appInvites.filter(invite => {
const inviteInfo = invite.info?.apps
if (!query && inviteInfo && prodAppId) {
return Object.keys(inviteInfo).includes(prodAppId)
}
return invite.email.includes(query)
})
}
$: filterInvites(query)
$: filterByAppAccess, prodAppId, filterInvites(query)
$: if (searchFocus === true) {
filterByAppAccess = false
}
const usersFetch = fetchData({
API,
@ -79,9 +99,9 @@
}
await usersFetch.update({
query: {
appId: query ? null : prodAppId,
appId: query || !filterByAppAccess ? null : prodAppId,
email: query,
paginated: query ? null : false,
paginated: query || !filterByAppAccess ? null : false,
},
})
await usersFetch.refresh()
@ -107,7 +127,12 @@
}
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
$: debouncedUpdateFetch(
query,
$store.builderSidePanel,
loaded,
filterByAppAccess
)
const updateAppUser = async (user, role) => {
if (!prodAppId) {
@ -182,9 +207,10 @@
}
const searchGroups = (userGroups, query) => {
let filterGroups = query?.length
? userGroups
: getAppGroups(userGroups, prodAppId)
let filterGroups =
query?.length || !filterByAppAccess
? userGroups
: getAppGroups(userGroups, prodAppId)
return filterGroups
.filter(group => {
if (!query?.length) {
@ -214,7 +240,7 @@
}
// Adds the 'role' attribute and sets it to the current app.
$: enrichedGroups = getEnrichedGroups($groups)
$: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess)
$: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers]
@ -226,7 +252,7 @@
specific roles for the app.
*/
const buildGroupUsers = (userGroups, filteredUsers) => {
if (query) {
if (query || !filterByAppAccess) {
return []
}
// Must exclude users who have explicit privileges
@ -321,12 +347,12 @@
[prodAppId]: role,
},
})
await filterInvites()
await filterInvites(query)
}
const onUninviteAppUser = async invite => {
await uninviteAppUser(invite)
await filterInvites()
await filterInvites(query)
}
// Purge only the app from the invite or recind the invite if only 1 app remains?
@ -351,7 +377,6 @@
onMount(() => {
rendered = true
searchFocus = true
})
function handleKeyDown(evt) {
@ -417,7 +442,6 @@
autocomplete="off"
disabled={inviting}
value={query}
autofocus
on:input={e => {
query = e.target.value.trim()
}}
@ -428,16 +452,20 @@
<span
class="search-input-icon"
class:searching={query}
class:searching={query || !filterByAppAccess}
on:click={() => {
if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) {
return
}
query = null
userOnboardResponse = null
filterByAppAccess = true
}}
>
<Icon name={query ? "Close" : "Search"} />
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
</span>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,20 @@
<script>
import { Button, Layout } from "@budibase/bbui"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
import Panel from "components/design/Panel.svelte"
let modal
import { isActive, goto } from "@roxi/routify"
</script>
<!-- routify:options index=1 -->
<div class="data">
<Panel title="Sources" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Button cta on:click={modal.show}>Add source</Button>
<CreateDatasourceModal bind:modal />
<DatasourceNavigator />
</Layout>
</Panel>
{#if !$isActive("./new")}
<Panel title="Sources" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Button cta on:click={() => $goto("./new")}>Add source</Button>
<DatasourceNavigator />
</Layout>
</Panel>
{/if}
<div class="content">
<slot />

View File

@ -20,6 +20,8 @@
import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { API } from "api"
import { DatasourceFeature } from "@budibase/types"
const querySchema = {
name: {},
@ -45,7 +47,30 @@
}
}
async function validateConfig() {
const displayError = message =>
notifications.error(message ?? "Error validating datasource")
let connected = false
try {
const resp = await API.validateDatasource(datasource)
if (!resp.connected) {
displayError(`Unable to connect - ${resp.error}`)
}
connected = resp.connected
} catch (err) {
displayError(err?.message)
}
return connected
}
const saveDatasource = async () => {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try {
// Create datasource
await datasources.save(datasource)

View File

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

View File

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

View File

@ -59,6 +59,7 @@
text={screen.routing.route}
on:click={() => store.actions.screens.select(screen._id)}
rightAlignIcon
showTooltip
>
<ScreenDropdownMenu screenId={screen._id} />
<RoleIndicator slot="right" roleId={screen.routing.roleId} />

View File

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

View File

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

View File

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

View File

@ -1,15 +1,10 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { Tooltip } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let row
let showTooltip
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
</script>
{#if row?.user?.email}
@ -19,7 +14,7 @@
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row.user)} />
<UserAvatar user={row.user} />
</div>
{#if showTooltip}
<div class="tooltip">

View File

@ -14,6 +14,7 @@
import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
import { store, automationStore } from "builderStore"
import { API } from "api"
@ -28,6 +29,7 @@
let template
let creationModal
let appLimitModal
let accountLockedModal
let creatingApp = false
let searchTerm = ""
let creatingFromTemplate = false
@ -48,6 +50,11 @@
: true)
)
$: automationErrors = getAutomationErrors(enrichedApps)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const usersLimitLockAction = $licensing?.errUserLimit
? () => accountLockedModal.show()
: null
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
@ -189,6 +196,9 @@
creatingFromTemplate = true
createAppFromTemplateUrl(initInfo.init_template)
}
if (usersLimitLockAction) {
usersLimitLockAction()
}
} catch (error) {
notifications.error("Error getting init info")
}
@ -230,20 +240,30 @@
<Layout noPadding gap="L">
<div class="title">
<div class="buttons">
<Button size="M" cta on:click={initiateAppCreation}>
<Button
size="M"
cta
on:click={usersLimitLockAction || initiateAppCreation}
>
Create new app
</Button>
{#if $apps?.length > 0}
<Button
size="M"
secondary
on:click={$goto("/builder/portal/apps/templates")}
on:click={usersLimitLockAction ||
$goto("/builder/portal/apps/templates")}
>
View templates
</Button>
{/if}
{#if !$apps?.length}
<Button size="L" quiet secondary on:click={initiateAppImport}>
<Button
size="L"
quiet
secondary
on:click={usersLimitLockAction || initiateAppImport}
>
Import app
</Button>
{/if}
@ -267,7 +287,7 @@
<div class="app-table">
{#each filteredApps as app (app.appId)}
<AppRow {app} />
<AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
@ -294,6 +314,11 @@
</Modal>
<AppLimitModal bind:this={appLimitModal} />
<AccountLockedModal
bind:this={accountLockedModal}
onConfirm={() =>
isOwner ? $licensing.goToUpgradePage() : $licensing.goToPricingPage()}
/>
<style>
.title {
@ -330,7 +355,6 @@
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
overflow: hidden;
}
.empty-wrapper {

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import PanelHeader from "./PanelHeader.svelte"
import { APP_URL_REGEX } from "constants"
export let disabled
export let name = ""
export let url = ""
export let onNext = () => {}
@ -31,6 +32,18 @@
return "Invalid URL"
}
}
$: urlManuallySet = false
const updateUrl = event => {
const appName = event.detail
if (urlManuallySet) {
return
}
const parsedUrl = appName.toLowerCase().replace(/\s+/g, "-")
url = encodeURI(parsedUrl)
}
</script>
<div>
@ -43,11 +56,13 @@
bind:value={name}
bind:error={nameError}
validate={validateName}
on:change={updateUrl}
label="Name"
/>
<FancyInput
bind:value={url}
bind:error={urlError}
on:change={() => (urlManuallySet = true)}
validate={validateUrl}
label="URL"
/>
@ -57,7 +72,9 @@
{:else}
<p></p>
{/if}
<Button size="L" cta disabled={!isValid} on:click={onNext}>Lets go!</Button>
<Button size="L" cta disabled={!isValid || disabled} on:click={onNext}
>Lets go!</Button
>
</div>
<style>

View File

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

View File

@ -24,7 +24,6 @@
import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -80,13 +79,6 @@
}
const editApp = () => {
if (appLocked && !lockedByYou) {
const identifier = app?.lockedBy?.firstName || app?.lockedBy?.email
notifications.warning(
`App locked by ${identifier}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`)
}
@ -135,7 +127,6 @@
/>
</div>
<div slot="buttons">
<AppLockModal {app} />
<span class="desktop">
<Button
size="M"

View File

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

View File

@ -7,7 +7,6 @@
Icon,
Heading,
Link,
Avatar,
Layout,
Body,
notifications,
@ -15,7 +14,7 @@
import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups, overview } from "stores/portal"
import { fetchData } from "@budibase/frontend-core"
import { fetchData, UserAvatar } from "@budibase/frontend-core"
import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -56,14 +55,6 @@
appEditor = await users.get(editorId)
}
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
const confirmUnpublishApp = async () => {
try {
await API.unpublishApp(app.prodId)
@ -140,7 +131,7 @@
<div class="last-edited-content">
<div class="updated-by">
{#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} />
<UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText}
</div>
@ -201,7 +192,7 @@
<div class="users">
<div class="list">
{#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} />
<UserAvatar {user} />
{/each}
</div>
<div class="text">

View File

@ -115,27 +115,6 @@
align-items: center;
}
input[type="file"] {
display: none;
}
.sso-link-icon {
padding-top: 4px;
margin-left: 3px;
}
.sso-link {
margin-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
}
.enforce-sso-title {
margin-right: 10px;
}
.enforce-sso-heading-container {
display: flex;
flex-direction: row;
align-items: start;
}
.provider-title {
display: flex;
flex-direction: row;
@ -143,9 +122,6 @@
align-items: center;
gap: var(--spacing-m);
}
.provider-title span {
flex: 1 1 auto;
}
.inputContainer {
display: flex;
flex-direction: row;

View File

@ -1,7 +1,7 @@
<script>
import EditUserPicker from "./EditUserPicker.svelte"
import { Heading, Pagination, Table } from "@budibase/bbui"
import { Heading, Pagination, Table, Search } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { goto } from "@roxi/routify"
import { API } from "api"
@ -12,7 +12,9 @@
export let groupId
const fetchGroupUsers = fetchData({
let emailSearch
let fetchGroupUsers
$: fetchGroupUsers = fetchData({
API,
datasource: {
type: "groupUser",
@ -20,6 +22,7 @@
options: {
query: {
groupId,
emailSearch,
},
},
})
@ -59,24 +62,31 @@
</script>
<div class="header">
<Heading size="S">Users</Heading>
{#if !scimEnabled}
<EditUserPicker {groupId} onUsersUpdated={fetchGroupUsers.getInitialData} />
{:else}
<ScimBanner />
{/if}
</div>
<div class="controls-right">
<Search bind:value={emailSearch} placeholder="Search email" />
</div>
</div>
<Table
schema={userSchema}
data={$fetchGroupUsers?.rows}
loading={$fetchGroupUsers.loading}
allowEditRows={false}
customPlaceholder
customRenderers={customUserTableRenderers}
on:click={e => $goto(`../users/${e.detail._id}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">This user group doesn't have any users</Heading>
<Heading size="S"
>{emailSearch
? `No users found matching the email "${emailSearch}"`
: "This user group doesn't have any users"}</Heading
>
</div>
</Table>
@ -98,7 +108,7 @@
.header {
display: flex;
flex-direction: row;
justify-content: flex-start;
justify-content: space-between;
align-items: center;
gap: var(--spacing-l);
}
@ -109,4 +119,15 @@
width: 100%;
text-align: center;
}
.controls-right {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.controls-right :global(.spectrum-Search) {
width: 200px;
}
</style>

View File

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

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