Merge branch 'develop' into test/qa-20-Add-relationship-tests-to-datasources

This commit is contained in:
Pedro Silva 2023-05-30 09:50:20 +01:00
commit 8ee3f083e8
209 changed files with 4780 additions and 2066 deletions

View File

@ -44,9 +44,12 @@ jobs:
node-version: 14.x node-version: 14.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn
# Run build all the projects
- run: yarn build - run: yarn build
# Check the types of the projects built via esbuild
- run: yarn check:types
test: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -59,8 +62,27 @@ jobs:
node-version: 14.x node-version: 14.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn
- run: yarn build --scope=@budibase/types --scope=@budibase/shared-core --scope=@budibase/string-templates - run: yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
- run: yarn test --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 - uses: codecov/codecov-action@v3
with: with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos

View File

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

View File

@ -42,7 +42,7 @@ jobs:
with: with:
node-version: 14.x node-version: 14.x
- run: yarn - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions
run: | run: |
version=$(cat lerna.json \ version=$(cat lerna.json \
@ -57,7 +57,7 @@ jobs:
echo "Syncing yarn workspace" echo "Syncing yarn workspace"
yarn yarn
- run: yarn lint - run: yarn lint
- run: yarn build - run: yarn build --configuration=production
- run: yarn build:sdk - run: yarn build:sdk
- name: Publish budibase packages to NPM - 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` to install project dependencies
`yarn bootstrap` will install all budibase modules and symlink them together using lerna.
`yarn build` will build all budibase packages. `yarn build` will build all budibase packages.
#### 4. Running #### 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. 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 ### 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

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

View File

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

10
nx.json
View File

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

View File

@ -2,17 +2,23 @@
"name": "root", "name": "root",
"private": true, "private": true,
"devDependencies": { "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", "@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "5.45.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"esbuild": "^0.17.18",
"eslint": "^7.28.0", "eslint": "^7.28.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-svelte3": "^3.2.0", "eslint-plugin-svelte3": "^3.2.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kill-port": "^1.6.1", "kill-port": "^1.6.1",
"lerna": "^6.6.1", "lerna": "7.0.0-alpha.0",
"madge": "^6.0.0", "madge": "^6.0.0",
"minimist": "^1.2.8",
"nx": "^16.2.1",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
@ -23,10 +29,11 @@
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js", "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", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"bootstrap": "./scripts/bootstrap.sh && lerna link && ./scripts/link-dependencies.sh", "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "lerna run --stream build", "build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", "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:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'", "backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk", "build:sdk": "lerna run --stream build:sdk",
@ -41,10 +48,11 @@
"kill-builder": "kill-port 3000", "kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002", "kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server", "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": "yarn run kill-all && lerna run --stream --parallel dev:builder --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: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 --concurrency 1 --scope @budibase/backend-core --scope @budibase/worker --scope @budibase/server", "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: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", "test": "lerna run --stream test --stream",
"lint:eslint": "eslint packages && eslint qa-core", "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}\"", "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: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", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs", "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:pre": "lerna run --stream build && lerna run --stream predocker",
"build:docker:proxy": "docker build hosting/proxy -t proxy-service", "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: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:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", "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: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: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", "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: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", "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/worker",
"packages/pro/packages/pro" "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", "name": "@budibase/backend-core",
"version": "0.0.1", "version": "0.0.0",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -22,7 +22,7 @@
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.2", "@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "0.0.1", "@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",
@ -88,5 +88,19 @@
"tsconfig-paths": "4.0.0", "tsconfig-paths": "4.0.0",
"typescript": "4.7.3" "typescript": "4.7.3"
}, },
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc" "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

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

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.0.1", "version": "0.0.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,8 +38,8 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1", "@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/shared-core": "0.0.1", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.0",
"@spectrum-css/accordion": "3.0.24", "@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1", "@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1", "@spectrum-css/actiongroup": "1.0.1",
@ -90,5 +90,19 @@
"resolutions": { "resolutions": {
"loader-utils": "1.4.1" "loader-utils": "1.4.1"
}, },
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/string-templates"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc" "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

@ -102,7 +102,9 @@
margin-left: 0; margin-left: 0;
transition: color ease-out 130ms; 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); background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-500); border-color: var(--spectrum-global-color-gray-500);
} }

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import { datasources, tables } from "../stores/backend"
import { IntegrationNames } from "../constants/backend" import { IntegrationNames } from "../constants/backend"
import { get } from "svelte/store" import { get } from "svelte/store"
import cloneDeep from "lodash/cloneDeepWith" import cloneDeep from "lodash/cloneDeepWith"
import { API } from "api"
function prepareData(config) { function prepareData(config) {
let datasource = {} let datasource = {}
@ -37,3 +38,9 @@ export async function createRestDatasource(integration) {
const config = cloneDeep(integration) const config = cloneDeep(integration)
return saveDatasource(config) 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 { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal" import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users"
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
@ -12,6 +13,7 @@ export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
export const userStore = getUserStore()
// Setup history for screens // Setup history for screens
export const screenHistoryStore = createHistoryStore({ export const screenHistoryStore = createHistoryStore({

View File

@ -37,8 +37,10 @@ import {
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields" import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket"
const INITIAL_FRONTEND_STATE = { const INITIAL_FRONTEND_STATE = {
initialised: false,
apps: [], apps: [],
name: "", name: "",
url: "", url: "",
@ -70,6 +72,7 @@ const INITIAL_FRONTEND_STATE = {
previewDevice: "desktop", previewDevice: "desktop",
highlightedSettingKey: null, highlightedSettingKey: null,
builderSidePanel: false, builderSidePanel: false,
hasLock: true,
// URL params // URL params
selectedScreenId: null, selectedScreenId: null,
@ -86,6 +89,7 @@ const INITIAL_FRONTEND_STATE = {
export const getFrontendStore = () => { export const getFrontendStore = () => {
const store = writable({ ...INITIAL_FRONTEND_STATE }) const store = writable({ ...INITIAL_FRONTEND_STATE })
let websocket
// This is a fake implementation of a "patch" API endpoint to try and prevent // 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, // 409s. All screen doc mutations (aside from creation) use this function,
@ -110,10 +114,11 @@ export const getFrontendStore = () => {
store.actions = { store.actions = {
reset: () => { reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE }) store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
}, },
initialise: async pkg => { 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) await store.actions.components.refreshDefinitions(application.appId)
// Reset store state // Reset store state
@ -137,6 +142,8 @@ export const getFrontendStore = () => {
upgradableVersion: application.upgradableVersion, upgradableVersion: application.upgradableVersion,
navigation: application.navigation || {}, navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [], usedPlugins: application.usedPlugins || [],
hasLock,
initialised: true,
})) }))
screenHistoryStore.reset() screenHistoryStore.reset()
automationHistoryStore.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 { TableNames } from "../constants"
import { import {
AUTO_COLUMN_DISPLAY_NAMES, AUTO_COLUMN_DISPLAY_NAMES,
@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) {
} }
return base 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)
})
// Table 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, Body,
Icon, Icon,
notifications, notifications,
Tags,
Tag,
} from "@budibase/bbui" } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import { admin } from "stores/portal" import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils"
export let blockIdx export let blockIdx
export let lastStep
const disabled = { let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
SEND_EMAIL_SMTP: { let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
}
let selectedAction let selectedAction
let actionVal let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION) 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 external = actions.reduce((acc, elm) => {
const [k, v] = elm const [k, v] = elm
if (!v.internal && !v.custom) { if (!v.internal && !v.custom) {
@ -38,6 +62,15 @@
acc[k] = v acc[k] = v
} }
delete acc.LOOP 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 return acc
}, {}) }, {})
@ -48,7 +81,6 @@
} }
return acc return acc
}, {}) }, {})
console.log(plugins)
const selectAction = action => { const selectAction = action => {
actionVal = action actionVal = action
@ -72,7 +104,7 @@
<ModalContent <ModalContent
title="Add automation step" title="Add automation step"
confirmText="Save" confirmText="Save"
size="M" size="L"
disabled={!selectedAction} disabled={!selectedAction}
onConfirm={addBlockToAutomation} onConfirm={addBlockToAutomation}
> >
@ -107,7 +139,7 @@
<Detail size="S">Actions</Detail> <Detail size="S">Actions</Detail>
<div class="item-list"> <div class="item-list">
{#each Object.entries(internal) as [idx, action]} {#each Object.entries(internal) as [idx, action]}
{@const isDisabled = disabled[idx] && disabled[idx].disabled} {@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
<div <div
class="item" class="item"
class:disabled={isDisabled} class:disabled={isDisabled}
@ -117,8 +149,14 @@
<div class="item-body"> <div class="item-body">
<Icon name={action.icon} /> <Icon name={action.icon} />
<Body size="XS">{action.name}</Body> <Body size="XS">{action.name}</Body>
{#if isDisabled} {#if isDisabled && !syncAutomationsEnabled}
<Icon name="Help" tooltip={disabled[idx].message} /> <div class="tag-color">
<Tags>
<Tag icon="LockClosed">Business</Tag>
</Tags>
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{/if} {/if}
</div> </div>
</div> </div>
@ -152,6 +190,7 @@
display: flex; display: flex;
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
gap: var(--spacing-m); gap: var(--spacing-m);
align-items: center;
} }
.item-list { .item-list {
display: grid; display: grid;
@ -181,4 +220,8 @@
.disabled :global(.spectrum-Body) { .disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
} }
.tag-color :global(.spectrum-Tags-item) {
background: var(--spectrum-global-color-gray-200);
}
</style> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,6 +53,7 @@
config, config,
schema: selected.datasource, schema: selected.datasource,
auth: selected.auth, auth: selected.auth,
features: selected.features || [],
} }
if (selected.friendlyName) { if (selected.friendlyName) {
integration.name = selected.friendlyName integration.name = selected.friendlyName

View File

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

View File

@ -1,5 +1,5 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import { API } from "api" import { API } from "api"
import { parseFile } from "./utils" import { parseFile } from "./utils"
@ -9,14 +9,17 @@
let fileType = null let fileType = null
let loading = false let loading = false
let updateExistingRows = false
let validation = {} let validation = {}
let validateHash = "" let validateHash = ""
let schema = null let schema = null
let invalidColumns = [] let invalidColumns = []
export let tableId = null export let tableId = null
export let tableType
export let rows = [] export let rows = []
export let allValid = false export let allValid = false
export let identifierFields = []
const typeOptions = [ const typeOptions = [
{ {
@ -159,6 +162,22 @@
</div> </div>
{/each} {/each}
</div> </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} {#if invalidColumns.length > 0}
<p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM"> <p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM">
The following columns are present in the data you wish to import, but do The following columns are present in the data you wish to import, but do

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 */ /* Override default active line highlight colour in dark theme */
div div
:global(.CodeMirror-focused.cm-s-tomorrow-night-eighties :global(
.CodeMirror-activeline-background) { .CodeMirror-focused.cm-s-tomorrow-night-eighties
.CodeMirror-activeline-background
) {
background: rgba(255, 255, 255, 0.075); background: rgba(255, 255, 255, 0.075);
} }
/* Remove active line styling when not focused */ /* Remove active line styling when not focused */
div div
:global(.CodeMirror:not(.CodeMirror-focused) :global(
.CodeMirror-activeline-background) { .CodeMirror:not(.CodeMirror-focused) .CodeMirror-activeline-background
) {
background: unset; background: unset;
} }

View File

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

View File

@ -126,8 +126,7 @@
} }
const getAllBindings = (bindings, eventContextBindings, actions) => { const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings) let allBindings = []
if (!actions) { if (!actions) {
return [] return []
} }
@ -145,14 +144,35 @@
.forEach(action => { .forEach(action => {
// Check we have a binding for this action, and generate one if not // Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key) const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => { const hasKey = bindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding return binding.runtimeBinding === stateBinding.runtimeBinding
}) })
if (!hasKey) { 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 return allBindings
} }
</script> </script>

View File

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

View File

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

View File

@ -1,13 +1,16 @@
<script> <script>
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui" import { Heading, Body, Button, Icon } from "@budibase/bbui"
import AppLockModal from "../common/AppLockModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { helpers } from "@budibase/shared-core"
import { UserAvatar } from "@budibase/frontend-core"
export let app export let app
export let lockedAction export let lockedAction
$: editing = app?.lockedBy != null
$: initials = helpers.getUserInitials(app?.lockedBy)
const handleDefaultClick = () => { const handleDefaultClick = () => {
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
goToOverview() goToOverview()
@ -17,12 +20,6 @@
} }
const goToBuilder = () => { 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}`) $goto(`../../app/${app.devId}`)
} }
@ -44,7 +41,10 @@
</div> </div>
<div class="updated"> <div class="updated">
{#if app.updatedAt} {#if editing}
Currently editing
<UserAvatar user={app.lockedBy} />
{:else if app.updatedAt}
{processStringSync("Updated {{ duration time 'millisecond' }} ago", { {processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: new Date().getTime() - new Date(app.updatedAt).getTime(), time: new Date().getTime() - new Date(app.updatedAt).getTime(),
})} })}
@ -59,12 +59,12 @@
</div> </div>
<div class="app-row-actions"> <div class="app-row-actions">
<AppLockModal {app} buttonSize="M" /> <Button size="S" secondary on:click={lockedAction || goToOverview}>
<Button size="S" secondary on:click={lockedAction || goToOverview} Manage
>Manage</Button </Button>
> <Button size="S" primary on:click={lockedAction || goToBuilder}>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button Edit
> </Button>
</div> </div>
</div> </div>
@ -87,6 +87,9 @@
.updated { .updated {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
display: flex;
align-items: center;
gap: 8px;
} }
.title, .title,

View File

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

View File

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

View File

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

View File

@ -8,6 +8,15 @@
import { onDestroy, onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify" 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 // Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({

View File

@ -20,6 +20,8 @@
import { isEqual } from "lodash" import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { API } from "api"
import { DatasourceFeature } from "@budibase/types"
const querySchema = { const querySchema = {
name: {}, 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 () => { const saveDatasource = async () => {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try { try {
// Create datasource // Create datasource
await datasources.save(datasource) await datasources.save(datasource)

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 --> <!-- routify:options index=2 -->
<slot /> <slot />

View File

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

View File

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

View File

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

View File

@ -31,6 +31,18 @@
return "Invalid URL" 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> </script>
<div> <div>
@ -43,11 +55,13 @@
bind:value={name} bind:value={name}
bind:error={nameError} bind:error={nameError}
validate={validateName} validate={validateName}
on:change={updateUrl}
label="Name" label="Name"
/> />
<FancyInput <FancyInput
bind:value={url} bind:value={url}
bind:error={urlError} bind:error={urlError}
on:change={() => (urlManuallySet = true)}
validate={validateUrl} validate={validateUrl}
label="URL" label="URL"
/> />

View File

@ -18,6 +18,8 @@
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { validateDatasourceConfig } from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
let name = "My first app" let name = "My first app"
let url = "my-first-app" let url = "my-first-app"
@ -108,7 +110,24 @@
isGoogle, isGoogle,
}) => { }) => {
let app let app
try { try {
if (
datasourceConfig &&
plusIntegrations[stage].features[DatasourceFeature.CONNECTION_CHECKING]
) {
const resp = await validateDatasourceConfig({
config: datasourceConfig,
type: stage,
})
if (!resp.connected) {
notifications.error(
`Unable to connect - ${resp.error ?? "Error validating datasource"}`
)
return false
}
}
app = await createApp(useSampleData) app = await createApp(useSampleData)
let datasource let datasource

View File

@ -24,7 +24,6 @@
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore" import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte" import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api" import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -80,13 +79,6 @@
} }
const editApp = () => { 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}`) $goto(`../../../app/${app.devId}`)
} }
@ -135,7 +127,6 @@
/> />
</div> </div>
<div slot="buttons"> <div slot="buttons">
<AppLockModal {app} />
<span class="desktop"> <span class="desktop">
<Button <Button
size="M" size="M"

View File

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

View File

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

View File

@ -115,27 +115,6 @@
align-items: center; 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 { .provider-title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -143,9 +122,6 @@
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.provider-title span {
flex: 1 1 auto;
}
.inputContainer { .inputContainer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { API } from "api" import { API } from "api"
import { licensing } from "./licensing" import { licensing } from "./licensing"
import { ConfigType } from "../../../../types/src/documents" import { ConfigType } from "@budibase/types"
export const createFeatureStore = () => { export const createFeatureStore = () => {
const internalStore = writable({ const internalStore = writable({

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "0.0.1", "version": "0.0.0",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"bin": { "bin": {
@ -29,9 +29,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "0.0.1", "@budibase/backend-core": "0.0.0",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.1", "@budibase/types": "0.0.0",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",
@ -63,5 +63,19 @@
"renamer": "^4.0.0", "renamer": "^4.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "4.7.3" "typescript": "4.7.3"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/backend-core"
],
"target": "build"
}
]
}
}
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "0.0.1", "version": "0.0.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,11 +19,11 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "0.0.1", "@budibase/bbui": "0.0.0",
"@budibase/frontend-core": "0.0.1", "@budibase/frontend-core": "0.0.0",
"@budibase/shared-core": "0.0.1", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.1", "@budibase/types": "0.0.0",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",
@ -65,5 +65,20 @@
"resolutions": { "resolutions": {
"loader-utils": "1.4.1" "loader-utils": "1.4.1"
}, },
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/string-templates",
"@budibase/shared-core"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc" "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

@ -54,8 +54,9 @@
color: white; color: white;
} }
div div
:global(.apexcharts-theme-dark :global(
.apexcharts-tooltip-series-group.apexcharts-active) { .apexcharts-theme-dark .apexcharts-tooltip-series-group.apexcharts-active
) {
padding-bottom: 0; padding-bottom: 0;
} }
</style> </style>

View File

@ -72,9 +72,11 @@
:global(.spectrum-Form-itemField .spectrum-Textfield--multiline) { :global(.spectrum-Form-itemField .spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px); min-height: calc(var(--height) - 24px);
} }
:global(.spectrum-Form--labelsAbove :global(
.spectrum-Form-itemField .spectrum-Form--labelsAbove
.spectrum-Textfield--multiline) { .spectrum-Form-itemField
.spectrum-Textfield--multiline
) {
min-height: calc(var(--height) - 24px); min-height: calc(var(--height) - 24px);
} }
</style> </style>

View File

@ -122,13 +122,23 @@ const deleteRowHandler = async action => {
} }
const triggerAutomationHandler = async action => { const triggerAutomationHandler = async action => {
const { fields, notificationOverride } = action.parameters const { fields, notificationOverride, timeout } = action.parameters
if (fields) { if (fields) {
try { try {
await API.triggerAutomation({ const result = await API.triggerAutomation({
automationId: action.parameters.automationId, automationId: action.parameters.automationId,
fields, fields,
timeout,
}) })
// Value will exist if automation is synchronous, so return it.
if (result.value) {
if (!notificationOverride) {
notificationStore.actions.success("Automation ran successfully")
}
return { result }
}
if (!notificationOverride) { if (!notificationOverride) {
notificationStore.actions.success("Automation triggered") notificationStore.actions.success("Automation triggered")
} }
@ -138,7 +148,6 @@ const triggerAutomationHandler = async action => {
} }
} }
} }
const navigationHandler = action => { const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters const { url, peek, externalNewTab } = action.parameters
routeStore.actions.navigate(url, peek, externalNewTab) routeStore.actions.navigate(url, peek, externalNewTab)

View File

@ -1,13 +1,13 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "0.0.1", "version": "0.0.0",
"description": "Budibase frontend core libraries used in builder and client", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"dependencies": { "dependencies": {
"@budibase/bbui": "0.0.1", "@budibase/bbui": "0.0.0",
"@budibase/shared-core": "0.0.1", "@budibase/shared-core": "0.0.0",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",

View File

@ -4,10 +4,10 @@ export const buildAutomationEndpoints = API => ({
* @param automationId the ID of the automation to trigger * @param automationId the ID of the automation to trigger
* @param fields the fields to trigger the automation with * @param fields the fields to trigger the automation with
*/ */
triggerAutomation: async ({ automationId, fields }) => { triggerAutomation: async ({ automationId, fields, timeout }) => {
return await API.post({ return await API.post({
url: `/api/automations/${automationId}/trigger`, url: `/api/automations/${automationId}/trigger`,
body: { fields }, body: { fields, timeout },
}) })
}, },

View File

@ -58,4 +58,15 @@ export const buildDatasourceEndpoints = API => ({
url: `/api/datasources/${datasourceId}/${datasourceRev}`, url: `/api/datasources/${datasourceId}/${datasourceRev}`,
}) })
}, },
/**
* Validate a datasource configuration
* @param datasource the datasource configuration to validate
*/
validateDatasource: async datasource => {
return await API.post({
url: `/api/datasources/verify`,
body: { datasource },
})
},
}) })

View File

@ -62,13 +62,15 @@ export const buildTableEndpoints = API => ({
/** /**
* Imports data into an existing table * Imports data into an existing table
* @param tableId the table ID to import to * @param tableId the table ID to import to
* @param data the data import object * @param rows the data import object
* @param identifierFields column names to be used as keys for overwriting existing rows
*/ */
importTableData: async ({ tableId, rows }) => { importTableData: async ({ tableId, rows, identifierFields }) => {
return await API.post({ return await API.post({
url: `/api/tables/${tableId}/import`, url: `/api/tables/${tableId}/import`,
body: { body: {
rows, rows,
identifierFields,
}, },
}) })
}, },

View File

@ -0,0 +1,58 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core"
export let user
export let size
export let tooltipDirection = "top"
export let showTooltip = true
$: tooltipStyle = getTooltipStyle(tooltipDirection)
const getTooltipStyle = direction => {
if (!direction) {
return ""
}
if (direction === "top") {
return "transform: translateX(-50%) translateY(-100%);"
} else if (direction === "bottom") {
return "transform: translateX(-50%) translateY(100%);"
}
}
</script>
{#if user}
<div class="user-avatar">
<Avatar
{size}
initials={helpers.getUserInitials(user)}
color={helpers.getUserColor(user)}
/>
{#if showTooltip}
<div class="tooltip" style={tooltipStyle}>
<Tooltip
direction={tooltipDirection}
textWrapping
text={user.email}
size="S"
/>
</div>
{/if}
</div>
{/if}
<style>
.user-avatar {
position: relative;
}
.tooltip {
display: none;
position: absolute;
top: 0;
left: 50%;
white-space: nowrap;
}
.user-avatar:hover .tooltip {
display: block;
}
</style>

View File

@ -37,6 +37,9 @@
.boolean-cell { .boolean-cell {
padding: 2px var(--cell-padding); padding: 2px var(--cell-padding);
pointer-events: none; pointer-events: none;
flex: 1 1 auto;
display: flex;
justify-content: center;
} }
.boolean-cell.editable { .boolean-cell.editable {
pointer-events: all; pointer-events: all;

View File

@ -11,6 +11,7 @@
export let selected export let selected
export let rowFocused export let rowFocused
export let rowIdx export let rowIdx
export let topRow = false
export let focused export let focused
export let selectedUser export let selectedUser
export let column export let column
@ -68,6 +69,7 @@
{highlighted} {highlighted}
{selected} {selected}
{rowIdx} {rowIdx}
{topRow}
{focused} {focused}
{selectedUser} {selectedUser}
{readonly} {readonly}

View File

@ -6,6 +6,7 @@
export let selectedUser = null export let selectedUser = null
export let error = null export let error = null
export let rowIdx export let rowIdx
export let topRow = false
export let defaultHeight = false export let defaultHeight = false
export let center = false export let center = false
export let readonly = false export let readonly = false
@ -15,7 +16,7 @@
const getStyle = (width, selectedUser) => { const getStyle = (width, selectedUser) => {
let style = `flex: 0 0 ${width}px;` let style = `flex: 0 0 ${width}px;`
if (selectedUser) { if (selectedUser) {
style += `--cell-color:${selectedUser.color};` style += `--user-color:${selectedUser.color};`
} }
return style return style
} }
@ -31,13 +32,14 @@
class:readonly class:readonly
class:default-height={defaultHeight} class:default-height={defaultHeight}
class:selected-other={selectedUser != null} class:selected-other={selectedUser != null}
class:alt={rowIdx % 2 === 1}
class:top={topRow}
on:focus on:focus
on:mousedown on:mousedown
on:mouseup on:mouseup
on:click on:click
on:contextmenu on:contextmenu
{style} {style}
data-row={rowIdx}
> >
{#if error} {#if error}
<div class="label"> <div class="label">
@ -70,6 +72,9 @@
width: 0; width: 0;
--cell-color: transparent; --cell-color: transparent;
} }
.cell.alt {
--cell-background: var(--cell-background-alt);
}
.cell.default-height { .cell.default-height {
height: var(--default-row-height); height: var(--default-row-height);
} }
@ -94,14 +99,15 @@
} }
/* Cell border for cells with labels */ /* Cell border for cells with labels */
.cell.error:after, .cell.error:after {
.cell.selected-other:not(.focused):after {
border-radius: 0 2px 2px 2px; border-radius: 0 2px 2px 2px;
} }
.cell[data-row="0"].error:after, .cell.top.error:after {
.cell[data-row="0"].selected-other:not(.focused):after {
border-radius: 2px 2px 2px 0; border-radius: 2px 2px 2px 0;
} }
.cell.selected-other:not(.focused):after {
border-radius: 2px;
}
/* Cell z-index */ /* Cell z-index */
.cell.error, .cell.error,
@ -111,14 +117,8 @@
.cell.focused { .cell.focused {
z-index: 2; z-index: 2;
} }
.cell.focused { .cell.selected-other:hover {
--cell-color: var(--spectrum-global-color-blue-400); z-index: 2;
}
.cell.error {
--cell-color: var(--spectrum-global-color-red-500);
}
.cell.readonly {
--cell-color: var(--spectrum-global-color-gray-600);
} }
.cell:not(.focused) { .cell:not(.focused) {
user-select: none; user-select: none;
@ -126,6 +126,21 @@
.cell:hover { .cell:hover {
cursor: default; cursor: default;
} }
/* Cell color overrides */
.cell.selected-other {
--cell-color: var(--user-color);
}
.cell.focused {
--cell-color: var(--spectrum-global-color-blue-400);
}
.cell.error {
--cell-color: var(--spectrum-global-color-red-500);
}
.cell.focused.readonly {
--cell-color: var(--spectrum-global-color-gray-600);
}
.cell.highlighted:not(.focused), .cell.highlighted:not(.focused),
.cell.focused.readonly { .cell.focused.readonly {
--cell-background: var(--cell-background-hover); --cell-background: var(--cell-background-hover);
@ -141,7 +156,7 @@
left: 0; left: 0;
padding: 1px 4px 3px 4px; padding: 1px 4px 3px 4px;
margin: 0 0 -2px 0; margin: 0 0 -2px 0;
background: var(--user-color); background: var(--cell-color);
border-radius: 2px; border-radius: 2px;
display: block; display: block;
color: white; color: white;
@ -152,14 +167,19 @@
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
} }
.cell[data-row="0"] .label { .cell.top .label {
bottom: auto; bottom: auto;
top: 100%; top: 100%;
border-radius: 0 2px 2px 2px;
padding: 2px 4px 2px 4px; padding: 2px 4px 2px 4px;
margin: -2px 0 0 0; margin: -2px 0 0 0;
} }
.error .label { .error .label {
background: var(--spectrum-global-color-red-500); background: var(--spectrum-global-color-red-500);
} }
.selected-other:not(.error) .label {
display: none;
}
.selected-other:not(.error):hover .label {
display: block;
}
</style> </style>

View File

@ -21,16 +21,7 @@
svelteDispatch("select") svelteDispatch("select")
const id = row?._id const id = row?._id
if (id) { if (id) {
selectedRows.update(state => { selectedRows.actions.toggleRow(id)
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
} }
} }
@ -47,6 +38,7 @@
highlighted={rowFocused || rowHovered} highlighted={rowFocused || rowHovered}
selected={rowSelected} selected={rowSelected}
{defaultHeight} {defaultHeight}
rowIdx={row?.__idx}
> >
<div class="gutter"> <div class="gutter">
{#if $$slots.default} {#if $$slots.default}

View File

@ -196,7 +196,11 @@
<MenuItem disabled={!canMoveRight} icon="ChevronRight" on:click={moveRight}> <MenuItem disabled={!canMoveRight} icon="ChevronRight" on:click={moveRight}>
Move right Move right
</MenuItem> </MenuItem>
<MenuItem icon="VisibilityOff" on:click={hideColumn}>Hide column</MenuItem> <MenuItem
disabled={idx === "sticky"}
icon="VisibilityOff"
on:click={hideColumn}>Hide column</MenuItem
>
</Menu> </Menu>
</Popover> </Popover>

View File

@ -52,7 +52,7 @@
{:else} {:else}
<div class="text-cell" class:number={type === "number"}> <div class="text-cell" class:number={type === "number"}>
<div class="value"> <div class="value">
{value || ""} {value ?? ""}
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -1,24 +0,0 @@
<script>
export let user
</script>
<div class="user" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
<style>
div {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
div:hover {
cursor: pointer;
}
</style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { setContext } from "svelte" import { setContext, onMount } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { clickOutside, ProgressCircle } from "@budibase/bbui" import { clickOutside, ProgressCircle } from "@budibase/bbui"
@ -24,6 +24,7 @@
import RowHeightButton from "../controls/RowHeightButton.svelte" import RowHeightButton from "../controls/RowHeightButton.svelte"
import ColumnWidthButton from "../controls/ColumnWidthButton.svelte" import ColumnWidthButton from "../controls/ColumnWidthButton.svelte"
import NewRow from "./NewRow.svelte" import NewRow from "./NewRow.svelte"
import { createGridWebsocket } from "../lib/websocket"
import { import {
MaxCellRenderHeight, MaxCellRenderHeight,
MaxCellRenderWidthOverflow, MaxCellRenderWidthOverflow,
@ -33,6 +34,7 @@
export let API = null export let API = null
export let tableId = null export let tableId = null
export let tableType = null
export let schemaOverrides = null export let schemaOverrides = null
export let allowAddRows = true export let allowAddRows = true
export let allowAddColumns = true export let allowAddColumns = true
@ -40,6 +42,9 @@
export let allowExpandRows = true export let allowExpandRows = true
export let allowEditRows = true export let allowEditRows = true
export let allowDeleteRows = true export let allowDeleteRows = true
export let stripeRows = false
export let collaboration = true
export let showAvatars = true
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
const rand = Math.random() const rand = Math.random()
@ -54,6 +59,7 @@
allowExpandRows, allowExpandRows,
allowEditRows, allowEditRows,
allowDeleteRows, allowDeleteRows,
stripeRows,
}) })
// Build up context // Build up context
@ -62,6 +68,7 @@
rand, rand,
config, config,
tableId: tableIdStore, tableId: tableIdStore,
tableType,
schemaOverrides: schemaOverridesStore, schemaOverrides: schemaOverridesStore,
} }
context = { ...context, ...createEventManagers() } context = { ...context, ...createEventManagers() }
@ -88,6 +95,7 @@
allowExpandRows, allowExpandRows,
allowEditRows, allowEditRows,
allowDeleteRows, allowDeleteRows,
stripeRows,
}) })
// Set context for children to consume // Set context for children to consume
@ -97,7 +105,11 @@
export const getContext = () => context export const getContext = () => context
// Initialise websocket for multi-user // Initialise websocket for multi-user
// onMount(() => createWebsocket(context)) onMount(() => {
if (collaboration) {
return createGridWebsocket(context)
}
})
</script> </script>
<div <div
@ -105,6 +117,7 @@
id="grid-{rand}" id="grid-{rand}"
class:is-resizing={$isResizing} class:is-resizing={$isResizing}
class:is-reordering={$isReordering} class:is-reordering={$isReordering}
class:stripe={$config.stripeRows}
style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};" style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};"
> >
<div class="controls"> <div class="controls">
@ -118,7 +131,9 @@
<RowHeightButton /> <RowHeightButton />
</div> </div>
<div class="controls-right"> <div class="controls-right">
<UserAvatars /> {#if showAvatars}
<UserAvatars />
{/if}
</div> </div>
</div> </div>
{#if $loaded} {#if $loaded}
@ -167,6 +182,7 @@
/* Variables */ /* Variables */
--cell-background: var(--spectrum-global-color-gray-50); --cell-background: var(--spectrum-global-color-gray-50);
--cell-background-hover: var(--spectrum-global-color-gray-100); --cell-background-hover: var(--spectrum-global-color-gray-100);
--cell-background-alt: var(--cell-background);
--cell-padding: 8px; --cell-padding: 8px;
--cell-spacing: 4px; --cell-spacing: 4px;
--cell-border: 1px solid var(--spectrum-global-color-gray-200); --cell-border: 1px solid var(--spectrum-global-color-gray-200);
@ -183,6 +199,9 @@
.grid.is-reordering :global(*) { .grid.is-reordering :global(*) {
cursor: grabbing !important; cursor: grabbing !important;
} }
.grid.stripe {
--cell-background-alt: var(--spectrum-global-color-gray-75);
}
.grid-data-outer, .grid-data-outer,
.grid-data-inner { .grid-data-inner {

View File

@ -36,7 +36,11 @@
<div bind:this={body} class="grid-body"> <div bind:this={body} class="grid-body">
<GridScrollWrapper scrollHorizontally scrollVertically wheelInteractive> <GridScrollWrapper scrollHorizontally scrollVertically wheelInteractive>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
<GridRow {row} {idx} invertY={idx >= $rowVerticalInversionIndex} /> <GridRow
{row}
top={idx === 0}
invertY={idx >= $rowVerticalInversionIndex}
/>
{/each} {/each}
{#if $config.allowAddRows && $renderedColumns.length} {#if $config.allowAddRows && $renderedColumns.length}
<div <div

View File

@ -3,7 +3,7 @@
import DataCell from "../cells/DataCell.svelte" import DataCell from "../cells/DataCell.svelte"
export let row export let row
export let idx export let top = false
export let invertY = false export let invertY = false
const { const {
@ -41,7 +41,8 @@
invertX={columnIdx >= $columnHorizontalInversionIndex} invertX={columnIdx >= $columnHorizontalInversionIndex}
highlighted={rowHovered || rowFocused || reorderSource === column.name} highlighted={rowHovered || rowFocused || reorderSource === column.name}
selected={rowSelected} selected={rowSelected}
rowIdx={idx} rowIdx={row.__idx}
topRow={top}
focused={$focusedCellId === cellId} focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]} selectedUser={$selectedCellMap[cellId]}
width={column.width} width={column.width}

View File

@ -61,7 +61,7 @@
border-right: var(--cell-border); border-right: var(--cell-border);
border-bottom: var(--cell-border); border-bottom: var(--cell-border);
background: var(--spectrum-global-color-gray-100); background: var(--spectrum-global-color-gray-100);
z-index: 20; z-index: 1;
} }
.add:hover { .add:hover {
background: var(--spectrum-global-color-gray-200); background: var(--spectrum-global-color-gray-200);

View File

@ -38,7 +38,7 @@
padding: 2px 6px; padding: 2px 6px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
background-color: var(--spectrum-global-color-gray-200); background-color: var(--spectrum-global-color-gray-300);
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
border-radius: 4px; border-radius: 4px;
text-align: center; text-align: center;

View File

@ -167,7 +167,7 @@
focused={$focusedCellId === cellId} focused={$focusedCellId === cellId}
width={$stickyColumn.width} width={$stickyColumn.width}
{updateValue} {updateValue}
rowIdx={0} topRow={offset === 0}
{invertY} {invertY}
> >
{#if $stickyColumn?.schema?.autocolumn} {#if $stickyColumn?.schema?.autocolumn}
@ -193,7 +193,7 @@
row={newRow} row={newRow}
focused={$focusedCellId === cellId} focused={$focusedCellId === cellId}
width={column.width} width={column.width}
rowIdx={0} topRow={offset === 0}
invertX={columnIdx >= $columnHorizontalInversionIndex} invertX={columnIdx >= $columnHorizontalInversionIndex}
{invertY} {invertY}
> >
@ -219,7 +219,7 @@
<Button size="M" secondary newStyles on:click={clear}> <Button size="M" secondary newStyles on:click={clear}>
<div class="button-with-keys"> <div class="button-with-keys">
Cancel Cancel
<KeyboardShortcut overlay keybind="Esc" /> <KeyboardShortcut keybind="Esc" />
</div> </div>
</Button> </Button>
</div> </div>

View File

@ -82,7 +82,8 @@
{rowFocused} {rowFocused}
selected={rowSelected} selected={rowSelected}
highlighted={rowHovered || rowFocused} highlighted={rowHovered || rowFocused}
rowIdx={idx} rowIdx={row.__idx}
topRow={idx === 0}
focused={$focusedCellId === cellId} focused={$focusedCellId === cellId}
selectedUser={$selectedCellMap[cellId]} selectedUser={$selectedCellMap[cellId]}
width={$stickyColumn.width} width={$stickyColumn.width}

View File

@ -1,13 +1,23 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import Avatar from "./Avatar.svelte" import UserAvatar from "../../UserAvatar.svelte"
const { users } = getContext("grid") const { users } = getContext("grid")
$: uniqueUsers = unique($users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script> </script>
<div class="users"> <div class="users">
{#each $users as user} {#each uniqueUsers as user}
<Avatar {user} /> <UserAvatar {user} />
{/each} {/each}
</div> </div>
@ -15,6 +25,6 @@
.users { .users {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 4px;
} }
</style> </style>

View File

@ -1,24 +1,9 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { io } from "socket.io-client" import { createWebsocket } from "../../../utils"
export const createWebsocket = context => { export const createGridWebsocket = context => {
const { rows, tableId, users, userId, focusedCellId } = context const { rows, tableId, users, focusedCellId, table } = context
const socket = createWebsocket("/socket/grid")
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
const socket = io(`${proto}//${host}:${port}`, {
path: "/socket/grid",
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
const connectToTable = tableId => { const connectToTable = tableId => {
if (!socket.connected) { if (!socket.connected) {
@ -28,27 +13,42 @@ export const createWebsocket = context => {
socket.emit("select-table", tableId, response => { socket.emit("select-table", tableId, response => {
// handle initial connection info // handle initial connection info
users.set(response.users) users.set(response.users)
userId.set(response.id)
}) })
} }
// Event handlers // Connection events
socket.on("connect", () => { socket.on("connect", () => {
connectToTable(get(tableId)) connectToTable(get(tableId))
}) })
socket.on("row-update", data => { socket.on("connect_error", err => {
if (data.id) { console.log("Failed to connect to grid websocket:", err.message)
rows.actions.refreshRow(data.id)
}
}) })
// User events
socket.on("user-update", user => { socket.on("user-update", user => {
users.actions.updateUser(user) users.actions.updateUser(user)
}) })
socket.on("user-disconnect", user => { socket.on("user-disconnect", user => {
users.actions.removeUser(user) users.actions.removeUser(user)
}) })
socket.on("connect_error", err => {
console.log("Failed to connect to grid websocket:", err.message) // Row events
socket.on("row-change", async data => {
if (data.id) {
rows.actions.replaceRow(data.id, data.row)
} else if (data.row.id) {
// Handle users table edge case
await rows.actions.refreshRow(data.row.id)
}
})
// Table events
socket.on("table-change", data => {
// Only update table if one exists. If the table was deleted then we don't
// want to know - let the builder navigate away
if (data.table) {
table.set(data.table)
}
}) })
// Change websocket connection when table changes // Change websocket connection when table changes

View File

@ -14,6 +14,7 @@
dispatch, dispatch,
selectedRows, selectedRows,
config, config,
menu,
} = getContext("grid") } = getContext("grid")
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
@ -61,6 +62,7 @@
} else { } else {
$focusedCellId = null $focusedCellId = null
} }
menu.actions.close()
return return
} else if (e.key === "Tab") { } else if (e.key === "Tab") {
e.preventDefault() e.preventDefault()
@ -224,10 +226,7 @@
if (!id || id === NewRowID) { if (!id || id === NewRowID) {
return return
} }
selectedRows.update(state => { selectedRows.actions.toggleRow(id)
state[id] = !state[id]
return state
})
} }
onMount(() => { onMount(() => {

View File

@ -46,7 +46,7 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { table, columns, stickyColumn, API, dispatch } = context const { table, columns, stickyColumn, API } = context
// Updates the tables primary display column // Updates the tables primary display column
const changePrimaryDisplay = async column => { const changePrimaryDisplay = async column => {
@ -90,10 +90,6 @@ export const deriveStores = context => {
// Update local state // Update local state
table.set(newTable) table.set(newTable)
// Broadcast event so that we can keep sync with external state
// (e.g. data section which maintains a list of table definitions)
dispatch("updatetable", newTable)
// Update server // Update server
await API.saveTable(newTable) await API.saveTable(newTable)
} }
@ -116,10 +112,24 @@ export const initialise = context => {
const schema = derived( const schema = derived(
[table, schemaOverrides], [table, schemaOverrides],
([$table, $schemaOverrides]) => { ([$table, $schemaOverrides]) => {
let newSchema = $table?.schema if (!$table?.schema) {
if (!newSchema) {
return null return null
} }
let newSchema = { ...$table?.schema }
// Edge case to temporarily allow deletion of duplicated user
// fields that were saved with the "disabled" flag set.
// By overriding the saved schema we ensure only overrides can
// set the disabled flag.
// TODO: remove in future
Object.keys(newSchema).forEach(field => {
newSchema[field] = {
...newSchema[field],
disabled: false,
}
})
// Apply schema overrides
Object.keys($schemaOverrides || {}).forEach(field => { Object.keys($schemaOverrides || {}).forEach(field => {
if (newSchema[field]) { if (newSchema[field]) {
newSchema[field] = { newSchema[field] = {
@ -160,7 +170,7 @@ export const initialise = context => {
fields fields
.map(field => ({ .map(field => ({
name: field, name: field,
label: $schema[field].name || field, label: $schema[field].displayName || field,
schema: $schema[field], schema: $schema[field],
width: $schema[field].width || DefaultColumnWidth, width: $schema[field].width || DefaultColumnWidth,
visible: $schema[field].visible ?? true, visible: $schema[field].visible ?? true,

View File

@ -4,9 +4,10 @@ const reorderInitialState = {
sourceColumn: null, sourceColumn: null,
targetColumn: null, targetColumn: null,
breakpoints: [], breakpoints: [],
initialMouseX: null,
scrollLeft: 0,
gridLeft: 0, gridLeft: 0,
width: 0,
latestX: 0,
increment: 0,
} }
export const createStores = () => { export const createStores = () => {
@ -23,14 +24,24 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { reorder, columns, visibleColumns, scroll, bounds, stickyColumn, ui } = const {
context reorder,
columns,
visibleColumns,
scroll,
bounds,
stickyColumn,
ui,
maxScrollLeft,
} = context
let autoScrollInterval
let isAutoScrolling
// Callback when dragging on a colum header and starting reordering // Callback when dragging on a colum header and starting reordering
const startReordering = (column, e) => { const startReordering = (column, e) => {
const $visibleColumns = get(visibleColumns) const $visibleColumns = get(visibleColumns)
const $bounds = get(bounds) const $bounds = get(bounds)
const $scroll = get(scroll)
const $stickyColumn = get(stickyColumn) const $stickyColumn = get(stickyColumn)
ui.actions.blur() ui.actions.blur()
@ -51,9 +62,8 @@ export const deriveStores = context => {
sourceColumn: column, sourceColumn: column,
targetColumn: null, targetColumn: null,
breakpoints, breakpoints,
initialMouseX: e.clientX,
scrollLeft: $scroll.left,
gridLeft: $bounds.left, gridLeft: $bounds.left,
width: $bounds.width,
}) })
// Add listeners to handle mouse movement // Add listeners to handle mouse movement
@ -66,12 +76,44 @@ export const deriveStores = context => {
// Callback when moving the mouse when reordering columns // Callback when moving the mouse when reordering columns
const onReorderMouseMove = e => { const onReorderMouseMove = e => {
// Immediately handle the current position
const x = e.clientX
reorder.update(state => ({
...state,
latestX: x,
}))
considerReorderPosition()
// Check if we need to start auto-scrolling
const $reorder = get(reorder) const $reorder = get(reorder)
const proximityCutoff = 140
const speedFactor = 8
const rightProximity = Math.max(0, $reorder.gridLeft + $reorder.width - x)
const leftProximity = Math.max(0, x - $reorder.gridLeft)
if (rightProximity < proximityCutoff) {
const weight = proximityCutoff - rightProximity
const increment = (weight / proximityCutoff) * speedFactor
reorder.update(state => ({ ...state, increment }))
startAutoScroll()
} else if (leftProximity < proximityCutoff) {
const weight = -1 * (proximityCutoff - leftProximity)
const increment = (weight / proximityCutoff) * speedFactor
reorder.update(state => ({ ...state, increment }))
startAutoScroll()
} else {
stopAutoScroll()
}
}
// Actual logic to consider the current position and determine the new order
const considerReorderPosition = () => {
const $reorder = get(reorder)
const $scroll = get(scroll)
// Compute the closest breakpoint to the current position // Compute the closest breakpoint to the current position
let targetColumn let targetColumn
let minDistance = Number.MAX_SAFE_INTEGER let minDistance = Number.MAX_SAFE_INTEGER
const mouseX = e.clientX - $reorder.gridLeft + $reorder.scrollLeft const mouseX = $reorder.latestX - $reorder.gridLeft + $scroll.left
$reorder.breakpoints.forEach(point => { $reorder.breakpoints.forEach(point => {
const distance = Math.abs(point.x - mouseX) const distance = Math.abs(point.x - mouseX)
if (distance < minDistance) { if (distance < minDistance) {
@ -79,7 +121,6 @@ export const deriveStores = context => {
targetColumn = point.column targetColumn = point.column
} }
}) })
if (targetColumn !== $reorder.targetColumn) { if (targetColumn !== $reorder.targetColumn) {
reorder.update(state => ({ reorder.update(state => ({
...state, ...state,
@ -88,8 +129,35 @@ export const deriveStores = context => {
} }
} }
// Commences auto-scrolling in a certain direction, triggered when the mouse
// approaches the edges of the grid
const startAutoScroll = () => {
if (isAutoScrolling) {
return
}
isAutoScrolling = true
autoScrollInterval = setInterval(() => {
const $maxLeft = get(maxScrollLeft)
const { increment } = get(reorder)
scroll.update(state => ({
...state,
left: Math.max(0, Math.min($maxLeft, state.left + increment)),
}))
considerReorderPosition()
}, 10)
}
// Stops auto scrolling
const stopAutoScroll = () => {
isAutoScrolling = false
clearInterval(autoScrollInterval)
}
// Callback when stopping reordering columns // Callback when stopping reordering columns
const stopReordering = async () => { const stopReordering = async () => {
// Ensure auto-scrolling is stopped
stopAutoScroll()
// Swap position of columns // Swap position of columns
let { sourceColumn, targetColumn } = get(reorder) let { sourceColumn, targetColumn } = get(reorder)
moveColumn(sourceColumn, targetColumn) moveColumn(sourceColumn, targetColumn)

View File

@ -268,27 +268,25 @@ export const deriveStores = context => {
return res?.rows?.[0] return res?.rows?.[0]
} }
// Refreshes a specific row, handling updates, addition or deletion // Replaces a row in state with the newly defined row, handling updates,
const refreshRow = async id => { // addition and deletion
// Fetch row from the server again const replaceRow = (id, row) => {
const newRow = await fetchRow(id)
// Get index of row to check if it exists // Get index of row to check if it exists
const $rows = get(rows) const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap) const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[id] const index = $rowLookupMap[id]
// Process as either an update, addition or deletion // Process as either an update, addition or deletion
if (newRow) { if (row) {
if (index != null) { if (index != null) {
// An existing row was updated // An existing row was updated
rows.update(state => { rows.update(state => {
state[index] = { ...newRow } state[index] = { ...row }
return state return state
}) })
} else { } else {
// A new row was created // A new row was created
handleNewRows([newRow]) handleNewRows([row])
} }
} else if (index != null) { } else if (index != null) {
// A row was removed // A row was removed
@ -296,6 +294,12 @@ export const deriveStores = context => {
} }
} }
// Refreshes a specific row
const refreshRow = async id => {
const row = await fetchRow(id)
replaceRow(id, row)
}
// Refreshes all data // Refreshes all data
const refreshData = () => { const refreshData = () => {
get(fetch)?.getInitialData() get(fetch)?.getInitialData()
@ -341,10 +345,15 @@ export const deriveStores = context => {
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] }) const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
// Update state after a successful change // Update state after a successful change
rows.update(state => { if (saved?._id) {
state[index] = saved rows.update(state => {
return state.slice() state[index] = saved
}) return state.slice()
})
} else if (saved?.id) {
// Handle users table edge case
await refreshRow(saved.id)
}
rowChangeCache.update(state => { rowChangeCache.update(state => {
delete state[rowId] delete state[rowId]
return state return state
@ -455,6 +464,7 @@ export const deriveStores = context => {
hasRow, hasRow,
loadNextPage, loadNextPage,
refreshRow, refreshRow,
replaceRow,
refreshData, refreshData,
refreshTableDefinition, refreshTableDefinition,
}, },

View File

@ -25,14 +25,33 @@ export const createStores = () => {
null null
) )
// Toggles whether a certain row ID is selected or not
const toggleSelectedRow = id => {
selectedRows.update(state => {
let newState = {
...state,
[id]: !state[id],
}
if (!newState[id]) {
delete newState[id]
}
return newState
})
}
return { return {
focusedCellId, focusedCellId,
focusedCellAPI, focusedCellAPI,
focusedRowId, focusedRowId,
previousFocusedRowId, previousFocusedRowId,
selectedRows,
hoveredRowId, hoveredRowId,
rowHeight, rowHeight,
selectedRows: {
...selectedRows,
actions: {
toggleRow: toggleSelectedRow,
},
},
} }
} }

View File

@ -1,95 +1,50 @@
import { writable, get, derived } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { helpers } from "@budibase/shared-core"
export const createStores = () => { export const createStores = () => {
const users = writable([]) const users = writable([])
const userId = writable(null)
// Enrich users with unique colours const enrichedUsers = derived(users, $users => {
const enrichedUsers = derived( return $users.map(user => ({
[users, userId], ...user,
([$users, $userId]) => { color: helpers.getUserColor(user),
return ( label: helpers.getUserLabel(user),
$users }))
.slice() })
// Place current user first
.sort((a, b) => {
if (a.id === $userId) {
return -1
} else if (b.id === $userId) {
return 1
} else {
return 0
}
})
// Enrich users with colors
.map((user, idx) => {
// Generate random colour hue
let hue = 1
for (let i = 0; i < user.email.length && i < 5; i++) {
hue *= user.email.charCodeAt(i + 1)
hue /= 17
}
hue = hue % 360
const color =
idx === 0
? "var(--spectrum-global-color-blue-400)"
: `hsl(${hue}, 50%, 40%)`
// Generate friendly label
let label = user.email
if (user.firstName) {
label = user.firstName
if (user.lastName) {
label += ` ${user.lastName}`
}
}
return {
...user,
color,
label,
}
})
)
},
[]
)
return { return {
users: { users: {
...users, ...users,
subscribe: enrichedUsers.subscribe, subscribe: enrichedUsers.subscribe,
}, },
userId,
} }
} }
export const deriveStores = context => { export const deriveStores = context => {
const { users, userId } = context const { users, focusedCellId } = context
// Generate a lookup map of cell ID to the user that has it selected, to make // Generate a lookup map of cell ID to the user that has it selected, to make
// lookups inside cells extremely fast // lookups inside cells extremely fast
const selectedCellMap = derived( const selectedCellMap = derived(
[users, userId], [users, focusedCellId],
([$enrichedUsers, $userId]) => { ([$users, $focusedCellId]) => {
let map = {} let map = {}
$enrichedUsers.forEach(user => { $users.forEach(user => {
if (user.focusedCellId && user.id !== $userId) { if (user.focusedCellId && user.focusedCellId !== $focusedCellId) {
map[user.focusedCellId] = user map[user.focusedCellId] = user
} }
}) })
return map return map
}, }
{}
) )
const updateUser = user => { const updateUser = user => {
const $users = get(users) const $users = get(users)
const index = $users.findIndex(x => x.id === user.id) if (!$users.some(x => x.sessionId === user.sessionId)) {
if (index === -1) {
users.set([...$users, user]) users.set([...$users, user])
} else { } else {
users.update(state => { users.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user state[index] = user
return state.slice() return state.slice()
}) })
@ -98,7 +53,7 @@ export const deriveStores = context => {
const removeUser = user => { const removeUser = user => {
users.update(state => { users.update(state => {
return state.filter(x => x.id !== user.id) return state.filter(x => x.sessionId !== user.sessionId)
}) })
} }

View File

@ -1,4 +1,5 @@
export { default as SplitPage } from "./SplitPage.svelte" export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte" export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as Testimonial } from "./Testimonial.svelte" export { default as Testimonial } from "./Testimonial.svelte"
export { default as UserAvatar } from "./UserAvatar.svelte"
export { Grid } from "./grid" export { Grid } from "./grid"

View File

@ -70,6 +70,7 @@ export const Features = {
ENFORCEABLE_SSO: "enforceableSSO", ENFORCEABLE_SSO: "enforceableSSO",
BRANDING: "branding", BRANDING: "branding",
SCIM: "scim", SCIM: "scim",
SYNC_AUTOMATIONS: "syncAutomations",
} }
// Role IDs // Role IDs

View File

@ -3,3 +3,4 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies" export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles" export * as RoleUtils from "./roles"
export * as Utils from "./utils" export * as Utils from "./utils"
export { createWebsocket } from "./websocket"

View File

@ -0,0 +1,23 @@
import { io } from "socket.io-client"
export const createWebsocket = path => {
if (!path) {
throw "A websocket path must be provided"
}
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
return io(`${proto}//${host}:${port}`, {
path,
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
}

@ -1 +1 @@
Subproject commit a590dc237a16983b8f39dc8e65005b7736d23467 Subproject commit 2adc101c1ede13f861f282d702f45b94ab91fd41

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/sdk", "name": "@budibase/sdk",
"version": "0.0.1", "version": "0.0.0",
"description": "Budibase Public API SDK", "description": "Budibase Public API SDK",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",

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