Merge pull request #11704 from Budibase/release/aug-23

Release August 2023
This commit is contained in:
Martin McKeaveney 2023-09-07 12:28:58 +01:00 committed by GitHub
commit 953484c5c8
406 changed files with 9842 additions and 5216 deletions

View File

@ -115,77 +115,4 @@ This job is responsible for deploying to our production, cloud kubernetes enviro
### Rollback A Bad Cloud Deployment ### Rollback A Bad Cloud Deployment
- Kick off cloud deploy job - Kick off cloud deploy job
- Ensure you are running off master - Ensure you are running off master
- Enter the version number of the last known good version of budibase. For example `1.0.0` - Enter the version number of the last known good version of budibase. For example `1.0.0`
## Pro
| **NOTE**: When developing for both pro / budibase repositories, your branch names need to match, or else the correct pro doesn't get run within your CI job.
### Installing Pro
The pro package is always installed from source in our CI jobs.
This is done to prevent pro needing to be published prior to CI runs in budiabse. This is required for two reasons:
- To reduce developer need to manually bump versions, i.e:
- release pro, bump pro dep in budibase, now ci can run successfully
- The cyclic dependency on backend-core, i.e:
- pro depends on backend-core
- server depends on pro
- backend-core lives in the monorepo, so it can't be released independently to be used in pro
- therefore the only option is to pull pro from source and release it as a part of the monorepo release, as if it were a mono package
The install is performed using the same steps as local development, via the `yarn bootstrap` command, see the [Contributing Guide#Pro](../../docs/CONTRIBUTING.md#pro)
The branch to install pro from can vary depending on ref of the commit that triggered the budibase CI job. This is done to enable branches which have changes in both the monorepo and the pro repo to have their CI pass successfully.
This is done using the [pro/install.sh](../../scripts/pro/install.sh) script. The script will:
- Clone pro to it's default branch (`develop`)
- Check if the clone worked, on forked versions of budibase this will fail due to no access
- This is fine as the `yarn` command will install the version from NPM
- Community PRs should never touch pro so this will always work
- Checkout the `BRANCH` argument, if this fails fallback to `BASE_BRANCH`
- This enables the more complex case of a feature branch being merged to another feature branch, e.g.
- I am working on a branch `epic/stonks` which exists on budibase and pro.
- I want to merge a change to this branch in budibase from `feature/stonks-ui`, which only exists in budibase
- The base branch ensures that `epic/stonks` in pro will still be checked out for the CI run, rather than falling back to `develop`
- Run `yarn setup` to build and install dependencies
- `yarn`
- `yarn bootstrap`
- `yarn build`
- The will build .ts files, and also update the `main` and `types` of `package.json` to point to `dist` rather than src
- The build command will only ever work in CI, it is prevented in local dev
#### `BRANCH` and `BASE_BRANCH` arguments
These arguments are supplied by the various budibase build and release pipelines
- `budibase_ci`
- `BRANCH: ${{ github.event.pull_request.head.ref }}` -> The branch being merged
- `BASE_BRANCH: ${{ github.event.pull_request.base.ref}}` -> The base branch
- `release-develop`
- `BRANCH: develop` -> always use the `develop` branch in pro
- `release`
- `BRANCH: master` -> always use the `master` branch in pro
### Releasing Pro
After budibase dependencies have been released we will release the new version of pro to match the release version of budibase dependencies. This is to ensure that we are always keeping the version of `backend-core` in sync in the pro package and in budibase packages. Without this we could run into scenarios where different versions are being used when installed via `yarn` inside the docker images, creating very difficult to debug cases.
Pro is released using the [pro/release.sh](../../scripts/pro/release.sh) script. The script will:
- Inspect the `VERSION` from the `lerna.json` file in budibase
- Determine whether to use the `latest` or `develop` tag based on the command argument
- Go to pro directory
- install npm creds
- update the version of `backend-core` to be `VERSION`, the version just released by lerna
- publish to npm. Uses a `lerna publish` command, pro itself is a mono repo.
- force the version to be the same as `VERSION` to keep pro and budibase in sync
- reverts the changes to `main` and `types` in `package.json` that were made by the build step, to point back to source
- commit & push: `Prep next development iteration`
- Go to budibase
- Update to the new version of pro in `server` and `worker` so the latest pro version is used in the docker builds
- commit & push: `Update pro version to $VERSION`
#### `COMMAND` argument
This argument is supplied by the existing `release` and `release:develop` budibase commands, which invoke the pro release
- `release` will supply no command and default to use `latest`
- `release:develop` will supply `develop`

View File

@ -18,6 +18,8 @@ env:
BRANCH: ${{ github.event.pull_request.head.ref }} BRANCH: ${{ github.event.pull_request.head.ref }}
BASE_BRANCH: ${{ github.event.pull_request.base.ref}} BASE_BRANCH: ${{ github.event.pull_request.base.ref}}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
NX_BASE_BRANCH: origin/${{ github.base_ref }}
USE_NX_AFFECTED: ${{ github.event_name == 'pull_request' && github.base_ref != 'master'}}
jobs: jobs:
lint: lint:
@ -25,20 +27,20 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn lint - run: yarn lint
build: build:
@ -46,45 +48,66 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
# Run build all the projects # Run build all the projects
- run: yarn build - name: Build
run: |
yarn build
# Check the types of the projects built via esbuild # Check the types of the projects built via esbuild
- run: yarn check:types - name: Check types
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn check:types --since=${{ env.NX_BASE_BRANCH }}
else
yarn check:types
fi
test-libraries: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro - name: Test
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
fi
- 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
@ -96,21 +119,31 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn test --scope=@budibase/worker --scope=@budibase/server - name: Test worker and server
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/worker --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/worker --scope=@budibase/server
fi
- uses: codecov/codecov-action@v3 - uses: codecov/codecov-action@v3
with: with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
@ -119,42 +152,50 @@ jobs:
test-pro: test-pro:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn test --scope=@budibase/pro - name: Test
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/pro
fi
integration-test: integration-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != 'Budibase/budibase' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 18.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn --frozen-lockfile
- run: yarn build --projects=@budibase/server,@budibase/worker,@budibase/client - name: Build packages
run: yarn build --scope @budibase/server --scope @budibase/worker --scope @budibase/client --scope @budibase/backend-core
- name: Run tests - name: Run tests
run: | run: |
cd qa-core cd qa-core
@ -166,14 +207,14 @@ jobs:
check-pro-submodule: check-pro-submodule:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: true submodules: true
fetch-depth: 0
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Check pro commit - name: Check pro commit
id: get_pro_commits id: get_pro_commits
@ -211,4 +252,4 @@ jobs:
process.exit(1); process.exit(1);
} else { } else {
console.log('All good, the submodule had been merged and setup correctly!') console.log('All good, the submodule had been merged and setup correctly!')
} }

View File

@ -0,0 +1,29 @@
name: check_unreleased_changes
on:
pull_request:
branches:
- master
jobs:
check_unreleased:
runs-on: ubuntu-latest
steps:
- name: Check for unreleased changes
env:
REPO: "Budibase/budibase"
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \
"https://api.github.com/repos/$REPO/releases/latest" | \
jq -r .published_at)
COMMIT_TIMESTAMP=$(curl -s -H "Authorization: token $TOKEN" \
"https://api.github.com/repos/$REPO/commits/master" | \
jq -r .commit.committer.date)
RELEASE_SECONDS=$(date --date="$RELEASE_TIMESTAMP" "+%s")
COMMIT_SECONDS=$(date --date="$COMMIT_TIMESTAMP" "+%s")
if (( COMMIT_SECONDS > RELEASE_SECONDS )); then
echo "There are unreleased changes. Please release these changes before merging."
exit 1
fi
echo "No unreleased changes detected."

View File

@ -44,7 +44,7 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 18.x
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions

View File

@ -36,7 +36,7 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 18.x
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions

View File

@ -28,10 +28,10 @@ jobs:
exit 1 exit 1
fi fi
- name: Use Node.js 14.x - name: Use Node.js 18.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 18.x
- name: Get the latest budibase release version - name: Get the latest budibase release version
id: version id: version
@ -67,7 +67,6 @@ jobs:
- name: Bootstrap and build (CLI) - name: Bootstrap and build (CLI)
run: | run: |
yarn yarn
yarn bootstrap
yarn build yarn build
- name: Build OpenAPI spec - name: Build OpenAPI spec

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [14.x] node-version: [18.x]
steps: steps:
- name: Maximize build space - name: Maximize build space
uses: easimon/maximize-build-space@master uses: easimon/maximize-build-space@master

2
.nvmrc
View File

@ -1 +1 @@
v14.20.1 v18.17.0

View File

@ -1,3 +1,3 @@
nodejs 14.21.3 nodejs 18.17.0
python 3.10.0 python 3.10.0
yarn 1.22.19 yarn 1.22.19

71
.vscode/launch.json vendored
View File

@ -1,42 +1,31 @@
{ {
// Use IntelliSense to learn about possible attributes. // Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes. // Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Budibase Server", "name": "Budibase Server",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"runtimeArgs": [ "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
"--nolazy", "args": ["${workspaceFolder}/packages/server/src/index.ts"],
"-r", "cwd": "${workspaceFolder}/packages/server"
"ts-node/register/transpile-only" },
], {
"args": [ "name": "Budibase Worker",
"${workspaceFolder}/packages/server/src/index.ts" "type": "node",
], "request": "launch",
"cwd": "${workspaceFolder}/packages/server" "runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
}, "args": ["${workspaceFolder}/packages/worker/src/index.ts"],
{ "cwd": "${workspaceFolder}/packages/worker"
"name": "Budibase Worker", }
"type": "node", ],
"request": "launch", "compounds": [
"runtimeArgs": [ {
"--nolazy", "name": "Start Budibase",
"-r", "configurations": ["Budibase Server", "Budibase Worker"]
"ts-node/register/transpile-only" }
], ]
"args": [ }
"${workspaceFolder}/packages/worker/src/index.ts"
],
"cwd": "${workspaceFolder}/packages/worker"
},
],
"compounds": [
{
"name": "Start Budibase",
"configurations": ["Budibase Server", "Budibase Worker"]
}
]
}

View File

@ -90,7 +90,7 @@ Component libraries are collections of components as well as the definition of t
#### 1. Prerequisites #### 1. Prerequisites
- NodeJS version `14.x.x` - NodeJS version `18.x.x`
- Python version `3.x` - Python version `3.x`
### Using asdf (recommended) ### Using asdf (recommended)

View File

@ -55,7 +55,7 @@ yarn setup
The yarn setup command runs several build steps i.e. The yarn setup command runs several build steps i.e.
``` ```
node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev
``` ```
So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose. So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose.

View File

@ -55,7 +55,7 @@ yarn setup
The yarn setup command runs several build steps i.e. The yarn setup command runs several build steps i.e.
``` ```
node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev
``` ```
So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose. So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose.

View File

@ -74,7 +74,7 @@ yarn setup
The yarn setup command runs several build steps i.e. The yarn setup command runs several build steps i.e.
``` ```
node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev
``` ```
So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose. So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose.

View File

@ -1,47 +0,0 @@
version: "3"
# optional ports are specified throughout for more advanced use cases.
services:
minio-service:
restart: on-failure
# Last version that supports the "fs" backend
image: minio/minio:RELEASE.2022-10-24T18-35-07Z
ports:
- "9000"
- "9001"
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
couchdb-service:
# platform: linux/amd64
restart: on-failure
image: budibase/couchdb
environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER}
ports:
- "5984"
- "4369"
- "9100"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5984/_up"]
interval: 30s
timeout: 20s
retries: 3
redis-service:
restart: on-failure
image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
ports:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]

View File

@ -1,7 +1,7 @@
FROM node:14-slim as build FROM node:18-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 python3
# add pin script # add pin script
WORKDIR / WORKDIR /

View File

@ -58,7 +58,6 @@ Node setup:
``` ```
node ./hosting/scripts/setup.js node ./hosting/scripts/setup.js
yarn yarn
yarn bootstrap
yarn build yarn build
``` ```
#### Build Image #### Build Image

View File

@ -47,7 +47,6 @@ Node setup:
``` ```
node ./hosting/scripts/setup.js node ./hosting/scripts/setup.js
yarn yarn
yarn bootstrap
yarn build yarn build
``` ```
#### Build Image #### Build Image

View File

@ -1,9 +1,16 @@
module.exports = () => { module.exports = () => {
return { return {
dockerCompose: { couchdb: {
composeFilePath: "../../hosting", image: "budibase/couchdb",
composeFile: "docker-compose.test.yaml", ports: [5984],
startupTimeout: 10000, env: {
}, COUCHDB_PASSWORD: "budibase",
COUCHDB_USER: "budibase",
},
wait: {
type: "ports",
timeout: 20000,
}
}
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"version": "2.9.38", "version": "2.9.39-alpha.14",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -33,21 +33,18 @@
"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 build && yarn dev", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", "build": "lerna run build --stream",
"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", "check:types": "lerna run check:types",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk", "build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset", "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
"release:develop": "yarn release --dist-tag develop", "release:develop": "yarn release --dist-tag develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build", "restore": "yarn run clean && yarn && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",
"nuke:docker": "lerna run --stream dev:stack:nuke", "nuke:docker": "lerna run --stream dev:stack:nuke",
"clean": "lerna clean", "clean": "lerna clean -y",
"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",
@ -93,9 +90,8 @@
"mode:account": "yarn mode:cloud && yarn env:account:enable", "mode:account": "yarn mode:cloud && yarn env:account:enable",
"security:audit": "node scripts/audit.js", "security:audit": "node scripts/audit.js",
"postinstall": "husky install", "postinstall": "husky install",
"dep:clean": "yarn clean -y && yarn bootstrap", "submodules:load": "git submodule init && git submodule update && yarn",
"submodules:load": "git submodule init && git submodule update && yarn && yarn bootstrap", "submodules:unload": "git submodule deinit --all && yarn"
"submodules:unload": "git submodule deinit --all && yarn && yarn bootstrap"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
@ -109,7 +105,7 @@
"@budibase/types": "0.0.0" "@budibase/types": "0.0.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0 <15.0.0" "node": ">=18.0.0 <19.0.0"
}, },
"dependencies": {} "dependencies": {}
} }

View File

@ -1,4 +1,6 @@
* *
!dist/**/* !dist/**/*
dist/tsconfig.build.tsbuildinfo dist/tsconfig.build.tsbuildinfo
!package.json !package.json
!src/**
!tests/**

View File

@ -6,7 +6,7 @@
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
"./tests": "./dist/tests.js", "./tests": "./dist/tests/index.js",
"./*": "./dist/*.js" "./*": "./dist/*.js"
}, },
"author": "Budibase", "author": "Budibase",
@ -14,7 +14,7 @@
"scripts": { "scripts": {
"prebuild": "rimraf dist/", "prebuild": "rimraf dist/",
"prepack": "cp package.json dist", "prepack": "cp package.json dist",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null", "build": "tsc -p tsconfig.build.json --paths null && node ./scripts/build.js",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"check:types": "tsc -p tsconfig.json --noEmit --paths null", "check:types": "tsc -p tsconfig.json --noEmit --paths null",
"test": "bash scripts/test.sh", "test": "bash scripts/test.sh",

View File

@ -1,6 +1,4 @@
#!/usr/bin/node #!/usr/bin/node
const coreBuild = require("../../../scripts/build") const coreBuild = require("../../../scripts/build")
coreBuild("./src/plugin/index.ts", "./dist/plugins.js")
coreBuild("./src/index.ts", "./dist/index.js") coreBuild("./src/index.ts", "./dist/index.js")
coreBuild("./tests/index.ts", "./dist/tests.js")

View File

@ -4,6 +4,8 @@ import * as context from "../context"
import * as platform from "../platform" import * as platform from "../platform"
import env from "../environment" import env from "../environment"
import * as accounts from "../accounts" import * as accounts from "../accounts"
import { UserDB } from "../users"
import { sdk } from "@budibase/shared-core"
const EXPIRY_SECONDS = 3600 const EXPIRY_SECONDS = 3600
@ -60,6 +62,18 @@ export async function getUser(
// make sure the tenant ID is always correct/set // make sure the tenant ID is always correct/set
user.tenantId = tenantId user.tenantId = tenantId
} }
// if has groups, could have builder permissions granted by a group
if (user.userGroups && !sdk.users.isGlobalBuilder(user)) {
await context.doInTenant(tenantId, async () => {
const appIds = await UserDB.getGroupBuilderAppIds(user)
if (appIds.length) {
const existing = user.builder?.apps || []
user.builder = {
apps: [...new Set(existing.concat(appIds))],
}
}
})
}
return user return user
} }

View File

@ -11,7 +11,11 @@ export function getDB(dbName?: string, opts?: any): Database {
// we have to use a callback for this so that we can close // we have to use a callback for this so that we can close
// the DB when we're done, without this manual requests would // the DB when we're done, without this manual requests would
// need to close the database when done with it to avoid memory leaks // need to close the database when done with it to avoid memory leaks
export async function doWithDB(dbName: string, cb: any, opts = {}) { export async function doWithDB<T>(
dbName: string,
cb: (db: Database) => Promise<T>,
opts = {}
) {
const db = getDB(dbName, opts) const db = getDB(dbName, opts)
// need this to be async so that we can correctly close DB after all // need this to be async so that we can correctly close DB after all
// async operations have been completed // async operations have been completed

View File

@ -1,7 +1,6 @@
import fetch from "node-fetch" import fetch from "node-fetch"
import { getCouchInfo } from "./couch" import { getCouchInfo } from "./couch"
import { SearchFilters, Row } from "@budibase/types" import { SearchFilters, Row, EmptyFilterOption } from "@budibase/types"
import { createUserIndex } from "./searchIndexes/searchIndexes"
const QUERY_START_REGEX = /\d[0-9]*:/g const QUERY_START_REGEX = /\d[0-9]*:/g
@ -65,6 +64,7 @@ export class QueryBuilder<T> {
this.#index = index this.#index = index
this.#query = { this.#query = {
allOr: false, allOr: false,
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
string: {}, string: {},
fuzzy: {}, fuzzy: {},
range: {}, range: {},
@ -218,6 +218,10 @@ export class QueryBuilder<T> {
this.#query.allOr = true this.#query.allOr = true
} }
setOnEmptyFilter(value: EmptyFilterOption) {
this.#query.onEmptyFilter = value
}
handleSpaces(input: string) { handleSpaces(input: string) {
if (this.#noEscaping) { if (this.#noEscaping) {
return input return input
@ -289,8 +293,9 @@ export class QueryBuilder<T> {
const builder = this const builder = this
let allOr = this.#query && this.#query.allOr let allOr = this.#query && this.#query.allOr
let query = allOr ? "" : "*:*" let query = allOr ? "" : "*:*"
let allFiltersEmpty = true
const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true } const allPreProcessingOpts = { escape: true, lowercase: true, wrap: true }
let tableId let tableId: string = ""
if (this.#query.equal!.tableId) { if (this.#query.equal!.tableId) {
tableId = this.#query.equal!.tableId tableId = this.#query.equal!.tableId
delete this.#query.equal!.tableId delete this.#query.equal!.tableId
@ -305,7 +310,7 @@ export class QueryBuilder<T> {
} }
const contains = (key: string, value: any, mode = "AND") => { const contains = (key: string, value: any, mode = "AND") => {
if (Array.isArray(value) && value.length === 0) { if (!value || (Array.isArray(value) && value.length === 0)) {
return null return null
} }
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -384,6 +389,12 @@ export class QueryBuilder<T> {
built += ` ${mode} ` built += ` ${mode} `
} }
built += expression built += expression
if (
(typeof value !== "string" && value != null) ||
(typeof value === "string" && value !== tableId && value !== "")
) {
allFiltersEmpty = false
}
} }
if (opts?.returnBuilt) { if (opts?.returnBuilt) {
return built return built
@ -463,6 +474,13 @@ export class QueryBuilder<T> {
allOr = false allOr = false
build({ tableId }, equal) build({ tableId }, equal)
} }
if (allFiltersEmpty) {
if (this.#query.onEmptyFilter === EmptyFilterOption.RETURN_NONE) {
return ""
} else if (this.#query?.allOr) {
return query.replace("()", "(*:*)")
}
}
return query return query
} }

View File

@ -1,6 +1,6 @@
import { newid } from "../../docIds/newid" import { newid } from "../../docIds/newid"
import { getDB } from "../db" import { getDB } from "../db"
import { Database } from "@budibase/types" import { Database, EmptyFilterOption } from "@budibase/types"
import { QueryBuilder, paginatedSearch, fullSearch } from "../lucene" import { QueryBuilder, paginatedSearch, fullSearch } from "../lucene"
const INDEX_NAME = "main" const INDEX_NAME = "main"
@ -156,6 +156,76 @@ describe("lucene", () => {
expect(resp.rows.length).toBe(2) expect(resp.rows.length).toBe(2)
}) })
describe("empty filters behaviour", () => {
it("should return all rows by default", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addEqual("property", "")
builder.addEqual("number", null)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(3)
})
it("should return all rows when onEmptyFilter is ALL", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setOnEmptyFilter(EmptyFilterOption.RETURN_ALL)
builder.setAllOr()
builder.addEqual("property", "")
builder.addEqual("number", null)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(3)
})
it("should return no rows when onEmptyFilter is NONE", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setOnEmptyFilter(EmptyFilterOption.RETURN_NONE)
builder.addEqual("property", "")
builder.addEqual("number", null)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(0)
})
it("should return all matching rows when onEmptyFilter is NONE, but a filter value is provided", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.setOnEmptyFilter(EmptyFilterOption.RETURN_NONE)
builder.addEqual("property", "")
builder.addEqual("number", 1)
builder.addString("property", "")
builder.addFuzzy("property", "")
builder.addNotEqual("number", undefined)
builder.addOneOf("number", null)
builder.addContains("array", undefined)
builder.addNotContains("array", null)
builder.addContainsAny("array", null)
const resp = await builder.run()
expect(resp.rows.length).toBe(1)
})
})
describe("skip", () => { describe("skip", () => {
const skipDbName = `db-${newid()}` const skipDbName = `db-${newid()}`
let docs: { let docs: {

View File

@ -1,5 +1,6 @@
import env from "../environment" import env from "../environment"
import * as context from "../context" import * as context from "../context"
export * from "./installation"
/** /**
* Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant. * Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant.

View File

@ -0,0 +1,17 @@
export function processFeatureEnvVar<T>(
fullList: string[],
featureList?: string
) {
let list
if (!featureList) {
list = fullList
} else {
list = featureList.split(",")
}
for (let feature of list) {
if (!fullList.includes(feature)) {
throw new Error(`Feature: ${feature} is not an allowed option`)
}
}
return list as unknown as T[]
}

View File

@ -6,7 +6,8 @@ export * as roles from "./security/roles"
export * as permissions from "./security/permissions" export * as permissions from "./security/permissions"
export * as accounts from "./accounts" export * as accounts from "./accounts"
export * as installation from "./installation" export * as installation from "./installation"
export * as featureFlags from "./featureFlags" export * as featureFlags from "./features"
export * as features from "./features/installation"
export * as sessions from "./security/sessions" export * as sessions from "./security/sessions"
export * as platform from "./platform" export * as platform from "./platform"
export * as auth from "./auth" export * as auth from "./auth"

View File

@ -5,11 +5,12 @@ import env from "../environment"
export default async (ctx: UserCtx, next: any) => { export default async (ctx: UserCtx, next: any) => {
const appId = getAppId() const appId = getAppId()
const builderFn = env.isWorker() const builderFn =
? hasBuilderPermissions env.isWorker() || !appId
: env.isApps() ? hasBuilderPermissions
? isBuilder : env.isApps()
: undefined ? isBuilder
: undefined
if (!builderFn) { if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.") throw new Error("Service name unknown - middleware inactive.")
} }

View File

@ -5,11 +5,12 @@ import env from "../environment"
export default async (ctx: UserCtx, next: any) => { export default async (ctx: UserCtx, next: any) => {
const appId = getAppId() const appId = getAppId()
const builderFn = env.isWorker() const builderFn =
? hasBuilderPermissions env.isWorker() || !appId
: env.isApps() ? hasBuilderPermissions
? isBuilder : env.isApps()
: undefined ? isBuilder
: undefined
if (!builderFn) { if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.") throw new Error("Service name unknown - middleware inactive.")
} }

View File

@ -78,7 +78,6 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.READ),
new Permission(PermissionType.TABLE, PermissionLevel.READ), new Permission(PermissionType.TABLE, PermissionLevel.READ),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
], ],
}, },
WRITE: { WRITE: {
@ -87,8 +86,8 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.WRITE), new Permission(PermissionType.QUERY, PermissionLevel.WRITE),
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
POWER: { POWER: {
@ -98,8 +97,8 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.USER, PermissionLevel.READ), new Permission(PermissionType.USER, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.VIEW, PermissionLevel.READ),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
ADMIN: { ADMIN: {
@ -109,9 +108,9 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.ADMIN), new Permission(PermissionType.TABLE, PermissionLevel.ADMIN),
new Permission(PermissionType.USER, PermissionLevel.ADMIN), new Permission(PermissionType.USER, PermissionLevel.ADMIN),
new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN),
new Permission(PermissionType.VIEW, PermissionLevel.ADMIN),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
} }

View File

@ -253,7 +253,7 @@ export function checkForRoleResourceArray(
* Given an app ID this will retrieve all of the roles that are currently within that app. * Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found. * @return {Promise<object[]>} An array of the role objects that were found.
*/ */
export async function getAllRoles(appId?: string) { export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
if (appId) { if (appId) {
return doWithDB(appId, internal) return doWithDB(appId, internal)
} else { } else {
@ -312,37 +312,6 @@ export async function getAllRoles(appId?: string) {
} }
} }
/**
* This retrieves the required role for a resource
* @param permLevel The level of request
* @param resourceId The resource being requested
* @param subResourceId The sub resource being requested
* @return {Promise<{permissions}|Object>} returns the permissions required to access.
*/
export async function getRequiredResourceRole(
permLevel: string,
{ resourceId, subResourceId }: { resourceId?: string; subResourceId?: string }
) {
const roles = await getAllRoles()
let main = [],
sub = []
for (let role of roles) {
// no permissions, ignore it
if (!role.permissions) {
continue
}
const mainRes = resourceId ? role.permissions[resourceId] : undefined
const subRes = subResourceId ? role.permissions[subResourceId] : undefined
if (mainRes && mainRes.indexOf(permLevel) !== -1) {
main.push(role._id)
} else if (subRes && subRes.indexOf(permLevel) !== -1) {
sub.push(role._id)
}
}
// for now just return the IDs
return main.concat(sub)
}
export class AccessController { export class AccessController {
userHierarchies: { [key: string]: string[] } userHierarchies: { [key: string]: string[] }
constructor() { constructor() {
@ -411,8 +380,8 @@ export function getDBRoleID(roleName: string) {
export function getExternalRoleID(roleId: string, version?: string) { export function getExternalRoleID(roleId: string, version?: string) {
// for built-in roles we want to remove the DB role ID element (role_) // for built-in roles we want to remove the DB role ID element (role_)
if ( if (
(roleId.startsWith(DocumentType.ROLE) && isBuiltin(roleId)) || roleId.startsWith(DocumentType.ROLE) &&
version === RoleIDVersion.NAME (isBuiltin(roleId) || version === RoleIDVersion.NAME)
) { ) {
return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1] return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1]
} }

View File

@ -1,30 +1,32 @@
import env from "../environment" import env from "../environment"
import * as eventHelpers from "./events" import * as eventHelpers from "./events"
import * as accounts from "../accounts" import * as accounts from "../accounts"
import * as accountSdk from "../accounts"
import * as cache from "../cache" import * as cache from "../cache"
import { getIdentity, getTenantId, getGlobalDB } from "../context" import { getGlobalDB, getIdentity, getTenantId } from "../context"
import * as dbUtils from "../db" import * as dbUtils from "../db"
import { EmailUnavailableError, HTTPError } from "../errors" import { EmailUnavailableError, HTTPError } from "../errors"
import * as platform from "../platform" import * as platform from "../platform"
import * as sessions from "../security/sessions" import * as sessions from "../security/sessions"
import * as usersCore from "./users" import * as usersCore from "./users"
import { import {
Account,
AllDocsResponse, AllDocsResponse,
BulkUserCreated, BulkUserCreated,
BulkUserDeleted, BulkUserDeleted,
isSSOAccount,
isSSOUser,
RowResponse, RowResponse,
SaveUserOpts, SaveUserOpts,
User, User,
Account,
isSSOUser,
isSSOAccount,
UserStatus, UserStatus,
UserGroup,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import * as accountSdk from "../accounts"
import { import {
validateUniqueUser,
getAccountHolderFromUserIds, getAccountHolderFromUserIds,
isAdmin, isAdmin,
validateUniqueUser,
} from "./utils" } from "./utils"
import { searchExistingEmails } from "./lookup" import { searchExistingEmails } from "./lookup"
import { hash } from "../utils" import { hash } from "../utils"
@ -32,8 +34,14 @@ import { hash } from "../utils"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any> type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any> type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean> type FeatureFn = () => Promise<Boolean>
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
type GroupBuildersFn = (user: User) => Promise<string[]>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn } type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn } type GroupFns = {
addUsers: GroupUpdateFn
getBulk: GroupGetFn
getGroupBuilderAppIds: GroupBuildersFn
}
type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn } type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn }
const bulkDeleteProcessing = async (dbUser: User) => { const bulkDeleteProcessing = async (dbUser: User) => {
@ -179,6 +187,14 @@ export class UserDB {
return user return user
} }
static async bulkGet(userIds: string[]) {
return await usersCore.bulkGetGlobalUsersById(userIds)
}
static async bulkUpdate(users: User[]) {
return await usersCore.bulkUpdateGlobalUsers(users)
}
static async save(user: User, opts: SaveUserOpts = {}): Promise<User> { static async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// default booleans to true // default booleans to true
if (opts.hashPassword == null) { if (opts.hashPassword == null) {
@ -457,4 +473,12 @@ export class UserDB {
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" }) await sessions.invalidateSessions(userId, { reason: "deletion" })
} }
static async getGroups(groupIds: string[]) {
return await this.groups.getBulk(groupIds)
}
static async getGroupBuilderAppIds(user: User) {
return await this.groups.getGroupBuilderAppIds(user)
}
} }

View File

@ -86,6 +86,10 @@ export const useAuditLogs = () => {
return useFeature(Feature.AUDIT_LOGS) return useFeature(Feature.AUDIT_LOGS)
} }
export const usePublicApiUserRoles = () => {
return useFeature(Feature.USER_ROLE_PUBLIC_API)
}
export const useScimIntegration = () => { export const useScimIntegration = () => {
return useFeature(Feature.SCIM) return useFeature(Feature.SCIM)
} }
@ -98,6 +102,10 @@ export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS) return useFeature(Feature.APP_BUILDERS)
} }
export const useViewPermissions = () => {
return useFeature(Feature.VIEW_PERMISSIONS)
}
// QUOTAS // QUOTAS
export const setAutomationLogsQuota = (value: number) => { export const setAutomationLogsQuota = (value: number) => {

View File

@ -32,8 +32,8 @@ function getTestContainerSettings(
): string | null { ): string | null {
const entry = Object.entries(global).find( const entry = Object.entries(global).find(
([k]) => ([k]) =>
k.includes(`_${serverName.toUpperCase()}`) && k.includes(`${serverName.toUpperCase()}`) &&
k.includes(`_${key.toUpperCase()}__`) k.includes(`${key.toUpperCase()}`)
) )
if (!entry) { if (!entry) {
return null return null
@ -67,27 +67,14 @@ function getContainerInfo(containerName: string, port: number) {
} }
function getCouchConfig() { function getCouchConfig() {
return getContainerInfo("couchdb-service", 5984) return getContainerInfo("couchdb", 5984)
}
function getMinioConfig() {
return getContainerInfo("minio-service", 9000)
}
function getRedisConfig() {
return getContainerInfo("redis-service", 6379)
} }
export function setupEnv(...envs: any[]) { export function setupEnv(...envs: any[]) {
const couch = getCouchConfig(), const couch = getCouchConfig()
minio = getCouchConfig(),
redis = getRedisConfig()
const configs = [ const configs = [
{ key: "COUCH_DB_PORT", value: couch.port }, { key: "COUCH_DB_PORT", value: couch.port },
{ key: "COUCH_DB_URL", value: couch.url }, { key: "COUCH_DB_URL", value: couch.url },
{ key: "MINIO_PORT", value: minio.port },
{ key: "MINIO_URL", value: minio.url },
{ key: "REDIS_URL", value: redis.url },
] ]
for (const config of configs.filter(x => !!x.value)) { for (const config of configs.filter(x => !!x.value)) {

View File

@ -17,6 +17,8 @@ export default function positionDropdown(element, opts) {
maxWidth, maxWidth,
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate,
offsetBelow,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -33,33 +35,41 @@ export default function positionDropdown(element, opts) {
top: null, top: null,
} }
// Determine vertical styles if (typeof customUpdate === "function") {
if (align === "right-outside") { styles = customUpdate(anchorBounds, elementBounds, styles)
styles.top = anchorBounds.top
} else if (window.innerHeight - anchorBounds.bottom < 100) {
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else { } else {
styles.top = anchorBounds.bottom + offset // Determine vertical styles
styles.maxHeight = if (align === "right-outside") {
maxHeight || window.innerHeight - anchorBounds.bottom - 20 styles.top = anchorBounds.top
} } else if (
window.innerHeight - anchorBounds.bottom <
(maxHeight || 100)
) {
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + (offsetBelow || offset)
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
}
// Determine horizontal styles // Determine horizontal styles
if (!maxWidth && useAnchorWidth) { if (!maxWidth && useAnchorWidth) {
styles.maxWidth = anchorBounds.width styles.maxWidth = anchorBounds.width
} }
if (useAnchorWidth) { if (useAnchorWidth) {
styles.minWidth = anchorBounds.width styles.minWidth = anchorBounds.width
} }
if (align === "right") { if (align === "right") {
styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width styles.left =
} else if (align === "right-outside") { anchorBounds.left + anchorBounds.width - elementBounds.width
styles.left = anchorBounds.right + offset } else if (align === "right-outside") {
} else if (align === "left-outside") { styles.left = anchorBounds.right + offset
styles.left = anchorBounds.left - elementBounds.width - offset } else if (align === "left-outside") {
} else { styles.left = anchorBounds.left - elementBounds.width - offset
styles.left = anchorBounds.left } else {
styles.left = anchorBounds.left
}
} }
// Apply styles // Apply styles

View File

@ -1,8 +1,8 @@
<script> <script>
import Popover from "../Popover/Popover.svelte"
import Layout from "../Layout/Layout.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Input from "../Form/Input.svelte" import Input from "../Form/Input.svelte"
import { capitalise } from "../helpers" import { capitalise } from "../helpers"
@ -10,9 +10,11 @@
export let value export let value
export let size = "M" export let size = "M"
export let spectrumTheme export let spectrumTheme
export let alignRight = false export let offset
export let align
let open = false let dropdown
let preview
$: customValue = getCustomValue(value) $: customValue = getCustomValue(value)
$: checkColor = getCheckColor(value) $: checkColor = getCheckColor(value)
@ -82,7 +84,7 @@
const onChange = value => { const onChange = value => {
dispatch("change", value) dispatch("change", value)
open = false dropdown.hide()
} }
const getCustomValue = value => { const getCustomValue = value => {
@ -119,30 +121,25 @@
return "var(--spectrum-global-color-static-gray-900)" return "var(--spectrum-global-color-static-gray-900)"
} }
const handleOutsideClick = event => {
if (open) {
event.stopPropagation()
open = false
}
}
</script> </script>
<div class="container"> <div
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}> bind:this={preview}
<div class="preview size--{size || 'M'}"
class="fill {spectrumTheme || ''}" on:click={() => {
style={value ? `background: ${value};` : ""} dropdown.toggle()
class:placeholder={!value} }}
/> >
</div> <div
{#if open} class="fill {spectrumTheme || ''}"
<div style={value ? `background: ${value};` : ""}
use:clickOutside={handleOutsideClick} class:placeholder={!value}
transition:fly|local={{ y: -20, duration: 200 }} />
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" </div>
class:spectrum-Popover--align-right={alignRight}
> <Popover bind:this={dropdown} anchor={preview} maxHeight={320} {offset} {align}>
<Layout paddingX="XL" paddingY="L">
<div class="container">
{#each categories as category} {#each categories as category}
<div class="category"> <div class="category">
<div class="heading">{category.label}</div> <div class="heading">{category.label}</div>
@ -187,8 +184,8 @@
</div> </div>
</div> </div>
</div> </div>
{/if} </Layout>
</div> </Popover>
<style> <style>
.container { .container {
@ -248,20 +245,6 @@
width: 48px; width: 48px;
height: 48px; height: 48px;
} }
.spectrum-Popover {
width: 210px;
z-index: 999;
top: 100%;
padding: var(--spacing-l) var(--spacing-xl);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.spectrum-Popover--align-right {
right: 0;
}
.colors { .colors {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
@ -297,7 +280,11 @@
.category--custom .heading { .category--custom .heading {
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
} }
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.spectrum-wrapper { .spectrum-wrapper {
background-color: transparent; background-color: transparent;
} }

View File

@ -44,7 +44,9 @@
align-items: stretch; align-items: stretch;
border-bottom: var(--border-light); border-bottom: var(--border-light);
} }
.property-group-container:last-child {
border-bottom: 0px;
}
.property-group-name { .property-group-name {
cursor: pointer; cursor: pointer;
display: flex; display: flex;

View File

@ -4,6 +4,8 @@
import Body from "../Typography/Body.svelte" import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte" import Heading from "../Typography/Heading.svelte"
import { setContext } from "svelte" import { setContext } from "svelte"
import { createEventDispatcher } from "svelte"
import { generate } from "shortid"
export let title export let title
export let fillWidth export let fillWidth
@ -11,13 +13,17 @@
export let width = "calc(100% - 626px)" export let width = "calc(100% - 626px)"
export let headless = false export let headless = false
const dispatch = createEventDispatcher()
let visible = false let visible = false
let drawerId = generate()
export function show() { export function show() {
if (visible) { if (visible) {
return return
} }
visible = true visible = true
dispatch("drawerShow", drawerId)
} }
export function hide() { export function hide() {
@ -25,6 +31,7 @@
return return
} }
visible = false visible = false
dispatch("drawerHide", drawerId)
} }
setContext("drawer-actions", { setContext("drawer-actions", {

View File

@ -2,8 +2,9 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import FancyField from "./FancyField.svelte" import FancyField from "./FancyField.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Popover from "../Popover/Popover.svelte"
import FancyFieldLabel from "./FancyFieldLabel.svelte" import FancyFieldLabel from "./FancyFieldLabel.svelte"
import StatusLight from "../StatusLight/StatusLight.svelte"
import Picker from "../Form/Core/Picker.svelte"
export let label export let label
export let value export let value
@ -11,18 +12,30 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let options = [] export let options = []
export let isOptionEnabled = () => true
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => extractProperty(option, "subtitle")
export let getOptionColour = () => null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
let popover
let wrapper let wrapper
$: placeholder = !value $: placeholder = !value
$: selectedLabel = getSelectedLabel(value) $: selectedLabel = getSelectedLabel(value)
$: fieldColour = getFieldAttribute(getOptionColour, value, options)
const getFieldAttribute = (getAttribute, value, options) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex(
(option, idx) => getOptionValue(option, idx) === value
)
return index !== -1 ? getAttribute(options[index], index) : null
}
const extractProperty = (value, property) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
@ -64,46 +77,45 @@
<FancyFieldLabel {placeholder}>{label}</FancyFieldLabel> <FancyFieldLabel {placeholder}>{label}</FancyFieldLabel>
{/if} {/if}
{#if fieldColour}
<span class="align">
<StatusLight square color={fieldColour} />
</span>
{/if}
<div class="value" class:placeholder> <div class="value" class:placeholder>
{selectedLabel || ""} {selectedLabel || ""}
</div> </div>
<div class="arrow"> <div class="align arrow-alignment">
<Icon name="ChevronDown" /> <Icon name="ChevronDown" />
</div> </div>
</FancyField> </FancyField>
<Popover <div id="picker-wrapper">
anchor={wrapper} <Picker
align="left" customAnchor={wrapper}
portalTarget={document.documentElement} onlyPopover={true}
bind:this={popover} bind:open
{open} {error}
on:close={() => (open = false)} {disabled}
useAnchorWidth={true} {options}
maxWidth={null} {getOptionLabel}
> {getOptionValue}
<div class="popover-content"> {getOptionSubtitle}
{#if options.length} {getOptionColour}
{#each options as option, idx} {isOptionEnabled}
<div isPlaceholder={value == null || value === ""}
class="popover-option" placeholderOption={placeholder === false ? null : placeholder}
tabindex="0" onSelectOption={onChange}
on:click={() => onChange(getOptionValue(option, idx))} isOptionSelected={option => option === value}
> />
<span class="option-text"> </div>
{getOptionLabel(option, idx)}
</span>
{#if value === getOptionValue(option, idx)}
<Icon name="Checkmark" />
{/if}
</div>
{/each}
{/if}
</div>
</Popover>
<style> <style>
#picker-wrapper :global(.spectrum-Picker) {
display: none;
}
.value { .value {
display: block; display: block;
flex: 1 1 auto; flex: 1 1 auto;
@ -118,30 +130,23 @@
width: 0; width: 0;
transform: translateY(9px); transform: translateY(9px);
} }
.align {
display: block;
font-size: 15px;
line-height: 17px;
color: var(--spectrum-global-color-gray-900);
transition: transform 130ms ease-out, opacity 130ms ease-out;
transform: translateY(9px);
}
.arrow-alignment {
transform: translateY(-2px);
}
.value.placeholder { .value.placeholder {
transform: translateY(0); transform: translateY(0);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
margin-top: 0; margin-top: 0;
} }
.popover-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 7px 0;
}
.popover-option {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 7px 16px;
transition: background 130ms ease-out;
font-size: 15px;
}
.popover-option:hover {
background: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
</style> </style>

View File

@ -2,8 +2,8 @@
import "@spectrum-css/inputgroup/dist/index-vars.css" import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css"
import { fly } from "svelte/transition"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
export let value = null export let value = null
export let id = null export let id = null
@ -80,10 +80,11 @@
</svg> </svg>
</button> </button>
{#if open} {#if open}
<div class="overlay" on:mousedown|self={() => (open = false)} />
<div <div
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom is-open" class="spectrum-Popover spectrum-Popover--bottom is-open"
use:clickOutside={() => {
open = false
}}
> >
<ul class="spectrum-Menu" role="listbox"> <ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)} {#if options && Array.isArray(options)}
@ -125,14 +126,6 @@
.spectrum-Textfield-input { .spectrum-Textfield-input {
width: 0; width: 0;
} }
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
}
.spectrum-Popover { .spectrum-Popover {
max-height: 240px; max-height: 240px;
width: 100%; width: 100%;

View File

@ -17,6 +17,9 @@
export let fetchTerm = null export let fetchTerm = null
export let useFetch = false export let useFetch = false
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let open = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -88,6 +91,7 @@
isPlaceholder={!arrayValue.length} isPlaceholder={!arrayValue.length}
{autocomplete} {autocomplete}
bind:fetchTerm bind:fetchTerm
bind:open
{useFetch} {useFetch}
{isOptionSelected} {isOptionSelected}
{getOptionLabel} {getOptionLabel}
@ -96,4 +100,6 @@
{sort} {sort}
{autoWidth} {autoWidth}
{customPopoverHeight} {customPopoverHeight}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
/> />

View File

@ -8,6 +8,8 @@
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
import StatusLight from "../../StatusLight/StatusLight.svelte" import StatusLight from "../../StatusLight/StatusLight.svelte"
import Popover from "../../Popover/Popover.svelte" import Popover from "../../Popover/Popover.svelte"
import Tags from "../../Tags/Tags.svelte"
import Tag from "../../Tags/Tag.svelte"
export let id = null export let id = null
export let disabled = false export let disabled = false
@ -26,6 +28,7 @@
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let useOptionIconImage = false export let useOptionIconImage = false
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let open = false export let open = false
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
@ -35,9 +38,11 @@
export let fetchTerm = null export let fetchTerm = null
export let useFetch = false export let useFetch = false
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let align = "left" export let align = "left"
export let footer = null export let footer = null
export let customAnchor = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let searchTerm = null let searchTerm = null
@ -139,16 +144,17 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
<Popover <Popover
anchor={button} anchor={customAnchor ? customAnchor : button}
align={align || "left"} align={align || "left"}
bind:this={popover} bind:this={popover}
{open} {open}
on:close={() => (open = false)} on:close={() => (open = false)}
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
maxHeight={customPopoverMaxHeight}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
offsetBelow={customPopoverOffsetBelow}
> >
<div <div
class="popover-content" class="popover-content"
@ -215,8 +221,21 @@
</span> </span>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text"
>{getOptionSubtitle(option, idx)}</span
>
{/if}
{getOptionLabel(option, idx)} {getOptionLabel(option, idx)}
</span> </span>
{#if option.tag}
<span class="option-tag">
<Tags>
<Tag icon="LockClosed">{option.tag}</Tag>
</Tags>
</span>
{/if}
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false" focusable="false"
@ -242,6 +261,17 @@
width: 100%; width: 100%;
box-shadow: none; box-shadow: none;
} }
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
top: 10px;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-bottom: var(--spacing-s);
}
.spectrum-Picker-label.auto-width { .spectrum-Picker-label.auto-width {
margin-right: var(--spacing-xs); margin-right: var(--spacing-xs);
} }
@ -321,4 +351,12 @@
.option-extra.icon.field-icon { .option-extra.icon.field-icon {
display: flex; display: flex;
} }
.option-tag {
margin: 0 var(--spacing-m) 0 var(--spacing-m);
}
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px;
}
</style> </style>

View File

@ -21,11 +21,13 @@
export let sort = false export let sort = false
export let align export let align
export let footer = null export let footer = null
export let open = false
export let tag = null
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false
$: fieldText = getFieldText(value, options, placeholder) $: fieldText = getFieldText(value, options, placeholder)
$: fieldIcon = getFieldAttribute(getOptionIcon, value, options) $: fieldIcon = getFieldAttribute(getOptionIcon, value, options)
$: fieldColour = getFieldAttribute(getOptionColour, value, options) $: fieldColour = getFieldAttribute(getOptionColour, value, options)
@ -83,6 +85,9 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{sort} {sort}
{tag}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false ? null : placeholder} placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}

View File

@ -25,7 +25,7 @@
export let customPopoverHeight export let customPopoverHeight
export let align export let align
export let footer = null export let footer = null
export let tag = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -61,6 +61,7 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
{tag}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View File

@ -9,6 +9,7 @@
export let fixed = false export let fixed = false
export let inline = false export let inline = false
export let disableCancel = false export let disableCancel = false
export let autoFocus = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let visible = fixed || inline let visible = fixed || inline
@ -53,6 +54,9 @@
} }
async function focusModal(node) { async function focusModal(node) {
if (!autoFocus) {
return
}
await tick() await tick()
// Try to focus first input // Try to focus first input

View File

@ -19,10 +19,15 @@
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 5 export let offset = 5
export let offsetBelow
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex export let customZindex
export let handlePostionUpdate
export let showPopover = true
export let clickOutsideOverride = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
export const show = () => { export const show = () => {
@ -35,7 +40,18 @@
open = false open = false
} }
export const toggle = () => {
if (!open) {
show()
} else {
hide()
}
}
const handleOutsideClick = e => { const handleOutsideClick = e => {
if (clickOutsideOverride) {
return
}
if (open) { if (open) {
// Stop propagation if the source is the anchor // Stop propagation if the source is the anchor
let node = e.target let node = e.target
@ -54,6 +70,9 @@
} }
function handleEscape(e) { function handleEscape(e) {
if (!clickOutsideOverride) {
return
}
if (open && e.key === "Escape") { if (open && e.key === "Escape") {
hide() hide()
} }
@ -71,6 +90,8 @@
maxWidth, maxWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
offsetBelow,
customUpdate: handlePostionUpdate,
}} }}
use:clickOutside={{ use:clickOutside={{
callback: dismissible ? handleOutsideClick : () => {}, callback: dismissible ? handleOutsideClick : () => {},
@ -79,6 +100,7 @@
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class="spectrum-Popover is-open"
class:customZindex class:customZindex
class:hide-popover={open && !showPopover}
role="presentation" role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};" style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }} transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
@ -89,6 +111,10 @@
{/if} {/if}
<style> <style>
.hide-popover {
display: contents;
}
.spectrum-Popover { .spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000); min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);

View File

@ -57,10 +57,8 @@
function calculateIndicatorLength() { function calculateIndicatorLength() {
if (!vertical) { if (!vertical) {
width = $tab.info?.width + "px" width = $tab.info?.width + "px"
height = $tab.info?.height
} else { } else {
height = $tab.info?.height + 4 + "px" height = $tab.info?.height + 4 + "px"
width = $tab.info?.width
} }
} }

View File

@ -4,7 +4,7 @@
export let text = null export let text = null
export let condition = true export let condition = true
export let duration = 3000 export let duration = 5000
export let position export let position
export let type export let type

View File

@ -6,7 +6,7 @@ import {
findComponentPath, findComponentPath,
getComponentSettings, getComponentSettings,
} from "./componentUtils" } from "./componentUtils"
import { store } from "builderStore" import { store, currentAsset } from "builderStore"
import { import {
queries as queriesStores, queries as queriesStores,
tables as tablesStore, tables as tablesStore,
@ -22,6 +22,7 @@ import { TableNames } from "../constants"
import { JSONUtils } from "@budibase/frontend-core" import { JSONUtils } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal" import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
// Regex to match all instances of template strings // Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@ -328,7 +329,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (context.type === "form") { if (context.type === "form") {
// Forms do not need table schemas // Forms do not need table schemas
// Their schemas are built from their component field names // Their schemas are built from their component field names
schema = buildFormSchema(component) schema = buildFormSchema(component, asset)
readablePrefix = "Fields" readablePrefix = "Fields"
} else if (context.type === "static") { } else if (context.type === "static") {
// Static contexts are fully defined by the components // Static contexts are fully defined by the components
@ -350,12 +351,19 @@ const getProviderContextBindings = (asset, dataProviders) => {
schema = info.schema schema = info.schema
table = info.table table = info.table
// For JSON arrays, use the array name as the readable prefix. // Determine what to prefix bindings with
// Otherwise use the table name
if (datasource.type === "jsonarray") { if (datasource.type === "jsonarray") {
// For JSON arrays, use the array name as the readable prefix
const split = datasource.label.split(".") const split = datasource.label.split(".")
readablePrefix = split[split.length - 1] readablePrefix = split[split.length - 1]
} else if (datasource.type === "viewV2") {
// For views, use the view name
const view = Object.values(table?.views || {}).find(
view => view.id === datasource.id
)
readablePrefix = view?.name
} else { } else {
// Otherwise use the table name
readablePrefix = info.table?.name readablePrefix = info.table?.name
} }
} }
@ -370,6 +378,11 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (runtimeSuffix) { if (runtimeSuffix) {
providerId += `-${runtimeSuffix}` providerId += `-${runtimeSuffix}`
} }
if (!filterCategoryByContext(component, context)) {
return
}
const safeComponentId = makePropSafe(providerId) const safeComponentId = makePropSafe(providerId)
// Create bindable properties for each schema field // Create bindable properties for each schema field
@ -387,6 +400,12 @@ const getProviderContextBindings = (asset, dataProviders) => {
} }
readableBinding += `.${fieldSchema.name || key}` readableBinding += `.${fieldSchema.name || key}`
const bindingCategory = getComponentBindingCategory(
component,
context,
def
)
// Create the binding object // Create the binding object
bindings.push({ bindings.push({
type: "context", type: "context",
@ -399,8 +418,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// Table ID is used by JSON fields to know what table the field is in // Table ID is used by JSON fields to know what table the field is in
tableId: table?._id, tableId: table?._id,
component: component._component, component: component._component,
category: component._instanceName, category: bindingCategory.category,
icon: def.icon, icon: bindingCategory.icon,
display: { display: {
name: fieldSchema.name || key, name: fieldSchema.name || key,
type: fieldSchema.type, type: fieldSchema.type,
@ -413,12 +432,46 @@ const getProviderContextBindings = (asset, dataProviders) => {
return bindings return bindings
} }
// Exclude a data context based on the component settings
const filterCategoryByContext = (component, context) => {
const { _component } = component
if (_component.endsWith("formblock")) {
if (
(component.actionType == "Create" && context.type === "schema") ||
(component.actionType == "View" && context.type === "form")
) {
return false
}
}
return true
}
const getComponentBindingCategory = (component, context, def) => {
let icon = def.icon
let category = component._instanceName
if (component._component.endsWith("formblock")) {
let contextCategorySuffix = {
form: "Fields",
schema: "Row",
}
category = `${component._instanceName} - ${
contextCategorySuffix[context.type]
}`
icon = context.type === "form" ? "Form" : "Data"
}
return {
icon,
category,
}
}
/** /**
* Gets all bindable properties from the logged in user. * Gets all bindable properties from the logged in user.
*/ */
export const getUserBindings = () => { export const getUserBindings = () => {
let bindings = [] let bindings = []
const { schema } = getSchemaForTable(TableNames.USERS) const { schema } = getSchemaForDatasourcePlus(TableNames.USERS)
const keys = Object.keys(schema).sort() const keys = Object.keys(schema).sort()
const safeUser = makePropSafe("user") const safeUser = makePropSafe("user")
@ -507,6 +560,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`, )}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`, readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
icon: "ViewRow",
display: { name: block._instanceName }, display: { name: block._instanceName },
})) }))
) )
@ -582,24 +636,36 @@ const getRoleBindings = () => {
} }
/** /**
* Gets all bindable properties exposed in an event action flow up until * Gets all bindable event context properties provided in the component
* the specified action ID, as well as context provided for the action * setting
* setting as a whole by the component.
*/ */
export const getEventContextBindings = ( export const getEventContextBindings = ({
asset,
componentId,
settingKey, settingKey,
actions, componentInstance,
actionId componentId,
) => { componentDefinition,
asset,
}) => {
let bindings = [] let bindings = []
const selectedAsset = asset ?? get(currentAsset)
// Check if any context bindings are provided by the component for this // Check if any context bindings are provided by the component for this
// setting // setting
const component = findComponent(asset.props, componentId) const component =
const def = store.actions.components.getDefinition(component?._component) componentInstance ?? findComponent(selectedAsset.props, componentId)
if (!component) {
return bindings
}
const definition =
componentDefinition ??
store.actions.components.getDefinition(component?._component)
const settings = getComponentSettings(component?._component) const settings = getComponentSettings(component?._component)
const eventSetting = settings.find(setting => setting.key === settingKey) const eventSetting = settings.find(setting => setting.key === settingKey)
if (eventSetting?.context?.length) { if (eventSetting?.context?.length) {
eventSetting.context.forEach(contextEntry => { eventSetting.context.forEach(contextEntry => {
bindings.push({ bindings.push({
@ -608,14 +674,23 @@ export const getEventContextBindings = (
contextEntry.key contextEntry.key
)}`, )}`,
category: component._instanceName, category: component._instanceName,
icon: def.icon, icon: definition.icon,
display: { display: {
name: contextEntry.label, name: contextEntry.label,
}, },
}) })
}) })
} }
return bindings
}
/**
* Gets all bindable properties exposed in an event action flow up until
* the specified action ID, as well as context provided for the action
* setting as a whole by the component.
*/
export const getActionBindings = (actions, actionId) => {
let bindings = []
// Get the steps leading up to this value // Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId) const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) { if (index == null || index === -1) {
@ -642,22 +717,29 @@ export const getEventContextBindings = (
}) })
} }
}) })
return bindings return bindings
} }
/** /**
* Gets the schema for a certain table ID. * Gets the schema for a certain datasource plus.
* The options which can be passed in are: * The options which can be passed in are:
* formSchema: whether the schema is for a form * formSchema: whether the schema is for a form
* searchableSchema: whether to generate a searchable schema, which may have * searchableSchema: whether to generate a searchable schema, which may have
* fewer fields than a readable schema * fewer fields than a readable schema
* @param tableId the table ID to get the schema for * @param resourceId the DS+ resource ID
* @param options options for generating the schema * @param options options for generating the schema
* @return {{schema: Object, table: Object}} * @return {{schema: Object, table: Object}}
*/ */
export const getSchemaForTable = (tableId, options) => { export const getSchemaForDatasourcePlus = (resourceId, options) => {
return getSchemaForDatasource(null, { type: "table", tableId }, options) const isViewV2 = resourceId?.includes("view_")
const datasource = isViewV2
? {
type: "viewV2",
id: resourceId,
tableId: resourceId.split("_").slice(1, 3).join("_"),
}
: { type: "table", tableId: resourceId }
return getSchemaForDatasource(null, datasource, options)
} }
/** /**
@ -734,9 +816,21 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// Determine the schema from the backing entity if not already determined // Determine the schema from the backing entity if not already determined
if (table && !schema) { if (table && !schema) {
if (type === "view") { if (type === "view") {
// For views, the schema is pulled from the `views` property of the // Old views
// table
schema = cloneDeep(table.views?.[datasource.name]?.schema) schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "viewV2") {
// New views which are DS+
const view = Object.values(table.views || {}).find(
view => view.id === datasource.id
)
schema = cloneDeep(view?.schema)
// Strip hidden fields
Object.keys(schema || {}).forEach(field => {
if (!schema[field].visible) {
delete schema[field]
}
})
} else if ( } else if (
type === "query" && type === "query" &&
(options.formSchema || options.searchableSchema) (options.formSchema || options.searchableSchema)
@ -782,12 +876,12 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// Determine if we should add ID and rev to the schema // Determine if we should add ID and rev to the schema
const isInternal = table && !table.sql const isInternal = table && !table.sql
const isTable = ["table", "link"].includes(datasource.type) const isDSPlus = ["table", "link", "viewV2"].includes(datasource.type)
// ID is part of the readable schema for all tables // ID is part of the readable schema for all tables
// Rev is part of the readable schema for internal tables only // Rev is part of the readable schema for internal tables only
let addId = isTable let addId = isDSPlus
let addRev = isTable && isInternal let addRev = isDSPlus && isInternal
// Don't add ID or rev for form schemas // Don't add ID or rev for form schemas
if (options.formSchema) { if (options.formSchema) {
@ -797,7 +891,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// ID is only searchable for internal tables // ID is only searchable for internal tables
else if (options.searchableSchema) { else if (options.searchableSchema) {
addId = isTable && isInternal addId = isDSPlus && isInternal
} }
// Add schema properties if required // Add schema properties if required
@ -835,18 +929,38 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
* Builds a form schema given a form component. * Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form. * A form schema is a schema of all the fields nested anywhere within a form.
*/ */
export const buildFormSchema = component => { export const buildFormSchema = (component, asset) => {
let schema = {} let schema = {}
if (!component) { if (!component) {
return schema return schema
} }
// If this is a form block, simply use the fields setting
if (component._component.endsWith("formblock")) { if (component._component.endsWith("formblock")) {
let schema = {} let schema = {}
component.fields?.forEach(field => {
schema[field] = { type: "string" } const datasource = getDatasourceForProvider(asset, component)
}) const info = getSchemaForDatasource(component, datasource)
if (!component.fields) {
Object.values(info?.schema)
.filter(
({ autocolumn, name }) =>
!autocolumn && !["_rev", "_id"].includes(name)
)
.forEach(({ name }) => {
schema[name] = { type: info?.schema[name].type }
})
} else {
// Field conversion
const patched = convertOldFieldFormat(component.fields || [])
patched?.forEach(({ field, active }) => {
if (!active) return
if (info?.schema[field]) {
schema[field] = { type: info?.schema[field].type }
}
})
}
return schema return schema
} }
@ -862,7 +976,7 @@ export const buildFormSchema = component => {
} }
} }
component._children?.forEach(child => { component._children?.forEach(child => {
const childSchema = buildFormSchema(child) const childSchema = buildFormSchema(child, asset)
schema = { ...schema, ...childSchema } schema = { ...schema, ...childSchema }
}) })
return schema return schema

View File

@ -4,7 +4,7 @@ import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users" import { getUserStore } from "./store/users"
import { getDeploymentStore } from "./store/deployments" import { getDeploymentStore } from "./store/deployments"
import { derived } from "svelte/store" import { derived, writable } 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"
import { createHistoryStore } from "builderStore/store/history" import { createHistoryStore } from "builderStore/store/history"
@ -61,6 +61,12 @@ export const selectedLayout = derived(store, $store => {
export const selectedComponent = derived( export const selectedComponent = derived(
[store, selectedScreen], [store, selectedScreen],
([$store, $selectedScreen]) => { ([$store, $selectedScreen]) => {
if (
$selectedScreen &&
$store.selectedComponentId?.startsWith(`${$selectedScreen._id}-`)
) {
return $selectedScreen?.props
}
if (!$selectedScreen || !$store.selectedComponentId) { if (!$selectedScreen || !$store.selectedComponentId) {
return null return null
} }
@ -141,3 +147,5 @@ export const userSelectedResourceMap = derived(userStore, $userStore => {
export const isOnlyUser = derived(userStore, $userStore => { export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2 return $userStore.length < 2
}) })
export const screensHeight = writable("210px")

View File

@ -111,6 +111,7 @@ export const getFrontendStore = () => {
} }
let clone = cloneDeep(screen) let clone = cloneDeep(screen)
const result = patchFn(clone) const result = patchFn(clone)
if (result === false) { if (result === false) {
return return
} }
@ -225,7 +226,6 @@ export const getFrontendStore = () => {
// Select new screen // Select new screen
store.update(state => { store.update(state => {
state.selectedScreenId = screen._id state.selectedScreenId = screen._id
state.selectedComponentId = screen.props?._id
return state return state
}) })
}, },
@ -627,6 +627,7 @@ export const getFrontendStore = () => {
component[setting.key] = { component[setting.key] = {
label: defaultDS.name, label: defaultDS.name,
tableId: defaultDS._id, tableId: defaultDS._id,
resourceId: defaultDS._id,
type: "table", type: "table",
} }
} else if (setting.type === "dataProvider") { } else if (setting.type === "dataProvider") {
@ -769,9 +770,13 @@ export const getFrontendStore = () => {
else { else {
await store.actions.screens.patch(screen => { await store.actions.screens.patch(screen => {
// Find the selected component // Find the selected component
let selectedComponentId = state.selectedComponentId
if (selectedComponentId.startsWith(`${screen._id}-`)) {
selectedComponentId = screen?.props._id
}
const currentComponent = findComponent( const currentComponent = findComponent(
screen.props, screen.props,
state.selectedComponentId selectedComponentId
) )
if (!currentComponent) { if (!currentComponent) {
return false return false
@ -834,6 +839,7 @@ export const getFrontendStore = () => {
return return
} }
const patchScreen = screen => { const patchScreen = screen => {
// findComponent looks in the tree not comp.settings[0]
let component = findComponent(screen.props, componentId) let component = findComponent(screen.props, componentId)
if (!component) { if (!component) {
return false return false
@ -994,12 +1000,20 @@ export const getFrontendStore = () => {
const componentId = state.selectedComponentId const componentId = state.selectedComponentId
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
// Check we aren't right at the top of the tree
const index = parent?._children.findIndex(x => x._id === componentId) const index = parent?._children.findIndex(x => x._id === componentId)
if (!parent || componentId === screen.props._id) {
// Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen`
const navComponentId = `${screen._id}-navigation`
if (componentId === screenComponentId) {
return null return null
} }
if (componentId === navComponentId) {
return screenComponentId
}
if (parent._id === screen.props._id && index === 0) {
return navComponentId
}
// If we have siblings above us, choose the sibling or a descendant // If we have siblings above us, choose the sibling or a descendant
if (index > 0) { if (index > 0) {
@ -1021,12 +1035,20 @@ export const getFrontendStore = () => {
return parent._id return parent._id
}, },
getNext: () => { getNext: () => {
const state = get(store)
const component = get(selectedComponent) const component = get(selectedComponent)
const componentId = component?._id const componentId = component?._id
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen.props, componentId) const parent = findComponentParent(screen.props, componentId)
const index = parent?._children.findIndex(x => x._id === componentId) const index = parent?._children.findIndex(x => x._id === componentId)
// Check for screen and navigation component edge cases
const screenComponentId = `${screen._id}-screen`
const navComponentId = `${screen._id}-navigation`
if (state.selectedComponentId === screenComponentId) {
return navComponentId
}
// If we have children, select first child // If we have children, select first child
if (component._children?.length) { if (component._children?.length) {
return component._children[0]._id return component._children[0]._id
@ -1207,7 +1229,12 @@ export const getFrontendStore = () => {
}) })
}, },
updateSetting: async (name, value) => { updateSetting: async (name, value) => {
await store.actions.components.patch(component => { await store.actions.components.patch(
store.actions.components.updateComponentSetting(name, value)
)
},
updateComponentSetting: (name, value) => {
return component => {
if (!name || !component) { if (!name || !component) {
return false return false
} }
@ -1219,6 +1246,13 @@ export const getFrontendStore = () => {
const settings = getComponentSettings(component._component) const settings = getComponentSettings(component._component)
const updatedSetting = settings.find(setting => setting.key === name) const updatedSetting = settings.find(setting => setting.key === name)
const resetFields = settings.filter(
setting => name === setting.resetOn
)
resetFields?.forEach(setting => {
component[setting.key] = null
})
if ( if (
updatedSetting?.type === "dataSource" || updatedSetting?.type === "dataSource" ||
updatedSetting?.type === "table" updatedSetting?.type === "table"
@ -1235,9 +1269,8 @@ export const getFrontendStore = () => {
component[key] = columnNames component[key] = columnNames
}) })
} }
component[name] = value component[name] = value
}) }
}, },
requestEjectBlock: componentId => { requestEjectBlock: componentId => {
store.actions.preview.sendEvent("eject-block", componentId) store.actions.preview.sendEvent("eject-block", componentId)

View File

@ -1,10 +1,10 @@
import rowListScreen from "./rowListScreen" import rowListScreen from "./rowListScreen"
import createFromScratchScreen from "./createFromScratchScreen" import createFromScratchScreen from "./createFromScratchScreen"
const allTemplates = tables => [...rowListScreen(tables)] const allTemplates = datasources => [...rowListScreen(datasources)]
// Allows us to apply common behaviour to all create() functions // Allows us to apply common behaviour to all create() functions
const createTemplateOverride = (frontendState, template) => () => { const createTemplateOverride = template => () => {
const screen = template.create() const screen = template.create()
screen.name = screen.props._id screen.name = screen.props._id
screen.routing.route = screen.routing.route.toLowerCase() screen.routing.route = screen.routing.route.toLowerCase()
@ -12,14 +12,13 @@ const createTemplateOverride = (frontendState, template) => () => {
return screen return screen
} }
export default (frontendState, tables) => { export default datasources => {
const enrichTemplate = template => ({ const enrichTemplate = template => ({
...template, ...template,
create: createTemplateOverride(frontendState, template), create: createTemplateOverride(template),
}) })
const fromScratch = enrichTemplate(createFromScratchScreen) const fromScratch = enrichTemplate(createFromScratchScreen)
const tableTemplates = allTemplates(tables).map(enrichTemplate) const tableTemplates = allTemplates(datasources).map(enrichTemplate)
return [ return [
fromScratch, fromScratch,
...tableTemplates.sort((templateA, templateB) => { ...tableTemplates.sort((templateA, templateB) => {

View File

@ -2,31 +2,29 @@ import sanitizeUrl from "./utils/sanitizeUrl"
import { Screen } from "./utils/Screen" import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component" import { Component } from "./utils/Component"
export default function (tables) { export default function (datasources) {
return tables.map(table => { if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return { return {
name: `${table.name} - List`, name: `${datasource.label} - List`,
create: () => createScreen(table), create: () => createScreen(datasource),
id: ROW_LIST_TEMPLATE, id: ROW_LIST_TEMPLATE,
table: table._id, resourceId: datasource.resourceId,
} }
}) })
} }
export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE" export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE"
export const rowListUrl = table => sanitizeUrl(`/${table.name}`) export const rowListUrl = datasource => sanitizeUrl(`/${datasource.label}`)
const generateTableBlock = table => { const generateTableBlock = datasource => {
const tableBlock = new Component("@budibase/standard-components/tableblock") const tableBlock = new Component("@budibase/standard-components/tableblock")
tableBlock tableBlock
.customProps({ .customProps({
title: table.name, title: datasource.label,
dataSource: { dataSource: datasource,
label: table.name,
name: table._id,
tableId: table._id,
type: "table",
},
sortOrder: "Ascending", sortOrder: "Ascending",
size: "spectrum--medium", size: "spectrum--medium",
paginate: true, paginate: true,
@ -36,14 +34,14 @@ const generateTableBlock = table => {
titleButtonText: "Create row", titleButtonText: "Create row",
titleButtonClickBehaviour: "new", titleButtonClickBehaviour: "new",
}) })
.instanceName(`${table.name} - Table block`) .instanceName(`${datasource.label} - Table block`)
return tableBlock return tableBlock
} }
const createScreen = table => { const createScreen = datasource => {
return new Screen() return new Screen()
.route(rowListUrl(table)) .route(rowListUrl(datasource))
.instanceName(`${table.name} - List`) .instanceName(`${datasource.label} - List`)
.addChild(generateTableBlock(table)) .addChild(generateTableBlock(datasource))
.json() .json()
} }

View File

@ -73,7 +73,7 @@
if (!perms["execute"]) { if (!perms["execute"]) {
role = "BASIC" role = "BASIC"
} else { } else {
role = perms["execute"] role = perms["execute"].role
} }
} }

View File

@ -39,7 +39,7 @@
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte" import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core" import { LuceneUtils } from "@budibase/frontend-core"
import { import {
getSchemaForTable, getSchemaForDatasourcePlus,
getEnvironmentBindings, getEnvironmentBindings,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
@ -67,7 +67,9 @@
$: table = tableId $: table = tableId
? $tables.list.find(table => table._id === inputData.tableId) ? $tables.list.find(table => table._id === inputData.tableId)
: { schema: {} } : { schema: {} }
$: schema = getSchemaForTable(tableId, { searchableSchema: true }).schema $: schema = getSchemaForDatasourcePlus(tableId, {
searchableSchema: true,
}).schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = block?.type === "TRIGGER" $: isTrigger = block?.type === "TRIGGER"
@ -158,7 +160,7 @@
// instead fetch the schema in the backend at runtime. // instead fetch the schema in the backend at runtime.
let schema let schema
if (e.detail?.tableId) { if (e.detail?.tableId) {
schema = getSchemaForTable(e.detail.tableId, { schema = getSchemaForDatasourcePlus(e.detail.tableId, {
searchableSchema: true, searchableSchema: true,
}).schema }).schema
} }

View File

@ -26,12 +26,14 @@
$: id = $tables.selected?._id $: id = $tables.selected?._id
$: isUsersTable = id === TableNames.USERS $: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external" $: isInternal = $tables.selected?.type !== "external"
$: gridDatasource = {
$: datasource = $datasources.list.find(datasource => { type: "table",
tableId: id,
}
$: tableDatasource = $datasources.list.find(datasource => {
return datasource._id === $tables.selected?.sourceId return datasource._id === $tables.selected?.sourceId
}) })
$: relationshipsEnabled = relationshipSupport(tableDatasource)
$: relationshipsEnabled = relationshipSupport(datasource)
const relationshipSupport = datasource => { const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source] const integration = $integrations[datasource?.source]
@ -54,12 +56,12 @@
<div class="wrapper"> <div class="wrapper">
<Grid <Grid
{API} {API}
tableId={id} datasource={gridDatasource}
allowAddRows={!isUsersTable} canAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable} canDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null} schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false} showAvatars={false}
on:updatetable={handleGridTableUpdate} on:updatedatasource={handleGridTableUpdate}
> >
<svelte:fragment slot="filter"> <svelte:fragment slot="filter">
<GridFilterButton /> <GridFilterButton />
@ -72,9 +74,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
{#if isInternal} <GridCreateViewButton />
<GridCreateViewButton />
{/if}
<GridManageAccessButton /> <GridManageAccessButton />
{#if relationshipsEnabled} {#if relationshipsEnabled}
<GridRelationshipButton /> <GridRelationshipButton />

View File

@ -0,0 +1,49 @@
<script>
import { viewsV2 } from "stores/backend"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
$: id = $viewsV2.selected?.id
$: datasource = {
type: "viewV2",
id,
tableId: $viewsV2.selected?.tableId,
}
const handleGridViewUpdate = async e => {
viewsV2.replaceView(id, e.detail)
}
</script>
<div class="wrapper">
<Grid
{API}
{datasource}
allowAddRows
allowDeleteRows
showAvatars={false}
on:updatedatasource={handleGridViewUpdate}
>
<svelte:fragment slot="filter">
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="controls">
<GridCreateEditRowModal />
<GridManageAccessButton />
</svelte:fragment>
</Grid>
</div>
<style>
.wrapper {
flex: 1 1 auto;
margin: -28px -40px -40px -40px;
display: flex;
flex-direction: column;
background: var(--background);
overflow: hidden;
}
</style>

View File

@ -9,19 +9,15 @@
let modal let modal
let resourcePermissions let resourcePermissions
async function openDropdown() { async function openModal() {
resourcePermissions = await permissions.forResource(resourceId) resourcePermissions = await permissions.forResourceDetailed(resourceId)
modal.show() modal.show()
} }
</script> </script>
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}> <ActionButton icon="LockClosed" quiet on:click={openModal} {disabled}>
Access Access
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ManageAccessModal <ManageAccessModal {resourceId} permissions={resourcePermissions} />
{resourceId}
levels={$permissions}
permissions={resourcePermissions}
/>
</Modal> </Modal>

View File

@ -15,6 +15,7 @@
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: text = getText(filters) $: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
const getText = filters => { const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length const count = filters?.filter(filter => filter.field)?.length
@ -22,13 +23,7 @@
} }
</script> </script>
<ActionButton <ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}>
icon="Filter"
quiet
{disabled}
on:click={modal.show}
selected={tempValue?.length > 0}
>
{text} {text}
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -1,18 +1,30 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Modal, ActionButton } from "@budibase/bbui" import { Modal, ActionButton, TooltipType, TempTooltip } from "@budibase/bbui"
import CreateViewModal from "../../modals/CreateViewModal.svelte" import GridCreateViewModal from "../../modals/grid/GridCreateViewModal.svelte"
const { rows, columns } = getContext("grid") const { rows, columns, filter } = getContext("grid")
let modal let modal
let firstFilterUsage = false
$: disabled = !$columns.length || !$rows.length $: disabled = !$columns.length || !$rows.length
$: {
if ($filter?.length && !firstFilterUsage) {
firstFilterUsage = true
}
}
</script> </script>
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}> <TempTooltip
Add view text="Create a view to save your filters"
</ActionButton> type={TooltipType.Info}
condition={firstFilterUsage}
>
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
Create view
</ActionButton>
</TempTooltip>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<CreateViewModal /> <GridCreateViewModal />
</Modal> </Modal>

View File

@ -2,7 +2,7 @@
import ExportButton from "../ExportButton.svelte" import ExportButton from "../ExportButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { rows, columns, tableId, sort, selectedRows, filter } = const { rows, columns, datasource, sort, selectedRows, filter } =
getContext("grid") getContext("grid")
$: disabled = !$rows.length || !$columns.length $: disabled = !$rows.length || !$columns.length
@ -12,7 +12,7 @@
<span data-ignore-click-outside="true"> <span data-ignore-click-outside="true">
<ExportButton <ExportButton
{disabled} {disabled}
view={$tableId} view={$datasource.tableId}
filters={$filter} filters={$filter}
sorting={{ sorting={{
sortColumn: $sort.column, sortColumn: $sort.column,

View File

@ -2,22 +2,19 @@
import TableFilterButton from "../TableFilterButton.svelte" import TableFilterButton from "../TableFilterButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { columns, tableId, filter, table } = getContext("grid") const { columns, datasource, filter, definition } = getContext("grid")
// Wipe filter whenever table ID changes to avoid using stale filters
$: $tableId, filter.set([])
const onFilter = e => { const onFilter = e => {
filter.set(e.detail || []) filter.set(e.detail || [])
} }
</script> </script>
{#key $tableId} {#key $datasource}
<TableFilterButton <TableFilterButton
schema={$table?.schema} schema={$definition?.schema}
filters={$filter} filters={$filter}
on:change={onFilter} on:change={onFilter}
disabled={!$columns.length} disabled={!$columns.length}
tableId={$tableId} tableId={$datasource.tableId}
/> />
{/key} {/key}

View File

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

View File

@ -2,7 +2,16 @@
import ManageAccessButton from "../ManageAccessButton.svelte" import ManageAccessButton from "../ManageAccessButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { tableId } = getContext("grid") const { datasource } = getContext("grid")
$: resourceId = getResourceID($datasource)
const getResourceID = datasource => {
if (!datasource) {
return null
}
return datasource.type === "table" ? datasource.tableId : datasource.id
}
</script> </script>
<ManageAccessButton resourceId={$tableId} /> <ManageAccessButton {resourceId} />

View File

@ -2,12 +2,12 @@
import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte" import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { table, rows } = getContext("grid") const { definition, rows } = getContext("grid")
</script> </script>
{#if $table} {#if $definition}
<ExistingRelationshipButton <ExistingRelationshipButton
table={$table} table={$definition}
on:updatecolumns={() => rows.actions.refreshData()} on:updatecolumns={() => rows.actions.refreshData()}
/> />
{/if} {/if}

View File

@ -6,13 +6,15 @@
Select, Select,
Toggle, Toggle,
RadioGroup, RadioGroup,
Icon,
DatePicker, DatePicker,
Modal, Modal,
notifications, notifications,
OptionSelectDnD, OptionSelectDnD,
Layout, Layout,
AbsTooltip,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/backend" import { tables, datasources } from "stores/backend"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -47,6 +49,7 @@
export let field export let field
let mounted = false
let fieldDefinitions = cloneDeep(FIELDS) let fieldDefinitions = cloneDeep(FIELDS)
let originalName let originalName
let linkEditDisabled let linkEditDisabled
@ -413,16 +416,22 @@
} }
return newError return newError
} }
onMount(() => {
mounted = true
})
</script> </script>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<Input {#if mounted}
bind:value={editableColumn.name} <Input
disabled={uneditable || autofocus
(linkEditDisabled && editableColumn.type === LINK_TYPE)} bind:value={editableColumn.name}
error={errors?.name} disabled={uneditable ||
/> (linkEditDisabled && editableColumn.type === LINK_TYPE)}
error={errors?.name}
/>
{/if}
<Select <Select
disabled={!typeEnabled} disabled={!typeEnabled}
bind:value={editableColumn.type} bind:value={editableColumn.type}
@ -452,12 +461,17 @@
/> />
{:else if editableColumn.type === "longform"} {:else if editableColumn.type === "longform"}
<div> <div>
<Label <div class="tooltip-alignment">
size="M" <Label size="M">Formatting</Label>
tooltip="Rich text includes support for images, links, tables, lists and more" <AbsTooltip
> position="top"
Formatting type="info"
</Label> text={"Rich text includes support for images, link"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle <Toggle
bind:value={editableColumn.useRichText} bind:value={editableColumn.useRichText}
text="Enable rich text support (markdown)" text="Enable rich text support (markdown)"
@ -488,13 +502,18 @@
</div> </div>
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"} {#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
<div> <div>
<Label <div>
tooltip={isCreating <Label>Time zones</Label>
? null <AbsTooltip
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"} position="top"
> type="info"
Time zones text={isCreating
</Label> ? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle <Toggle
bind:value={editableColumn.ignoreTimezones} bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones" text="Ignore time zones"
@ -671,6 +690,12 @@
align-items: center; align-items: center;
} }
.tooltip-alignment {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.label-length { .label-length {
flex-basis: 40%; flex-basis: 40%;
} }

View File

@ -1,38 +0,0 @@
<script>
import { Input, notifications, ModalContent } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { views as viewsStore } from "stores/backend"
import { tables } from "stores/backend"
let name
let field
$: views = $tables.list.flatMap(table => Object.keys(table.views || {}))
const saveView = async () => {
name = name?.trim()
if (views.includes(name)) {
notifications.error(`View exists with name ${name}`)
return
}
try {
await viewsStore.save({
name,
tableId: $tables.selected._id,
field,
})
notifications.success(`View ${name} created`)
$goto(`../../view/${encodeURIComponent(name)}`)
} catch (error) {
notifications.error("Error creating view")
}
}
</script>
<ModalContent
title="Create View"
confirmText="Create View"
onConfirm={saveView}
>
<Input label="View Name" thin bind:value={name} />
</ModalContent>

View File

@ -1,4 +1,5 @@
<script> <script>
import { PermissionSource } from "@budibase/types"
import { roles, permissions as permissionsStore } from "stores/backend" import { roles, permissions as permissionsStore } from "stores/backend"
import { import {
Label, Label,
@ -7,45 +8,130 @@
notifications, notifications,
Body, Body,
ModalContent, ModalContent,
Tags,
Tag,
Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { get } from "svelte/store"
export let resourceId export let resourceId
export let permissions export let permissions
const inheritedRoleId = "inherited"
async function changePermission(level, role) { async function changePermission(level, role) {
try { try {
await permissionsStore.save({ if (role === inheritedRoleId) {
level, await permissionsStore.remove({
role, level,
resource: resourceId, role,
}) resource: resourceId,
})
} else {
await permissionsStore.save({
level,
role,
resource: resourceId,
})
}
// Show updated permissions in UI: REMOVE // Show updated permissions in UI: REMOVE
permissions = await permissionsStore.forResource(resourceId) permissions = await permissionsStore.forResourceDetailed(resourceId)
notifications.success("Updated permissions") notifications.success("Updated permissions")
} catch (error) { } catch (error) {
notifications.error("Error updating permissions") notifications.error("Error updating permissions")
} }
} }
$: computedPermissions = Object.entries(permissions.permissions).reduce(
(p, [level, roleInfo]) => {
p[level] = {
selectedValue:
roleInfo.permissionType === PermissionSource.INHERITED
? inheritedRoleId
: roleInfo.role,
options: [...get(roles)],
}
if (roleInfo.inheritablePermission) {
p[level].inheritOption = roleInfo.inheritablePermission
p[level].options.unshift({
_id: inheritedRoleId,
name: `Inherit (${
get(roles).find(x => x._id === roleInfo.inheritablePermission).name
})`,
})
}
return p
},
{}
)
$: requiresPlanToModify = permissions.requiresPlanToModify
let dependantsInfoMessage
async function loadDependantInfo() {
const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
const resourceByType = dependantsInfo?.resourceByType
if (resourceByType) {
const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
let resourceDisplay =
Object.keys(resourceByType).length === 1 && resourceByType.view
? "view"
: "resource"
if (total === 1) {
dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access.`
} else if (total > 1) {
dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access.`
}
}
}
loadDependantInfo()
</script> </script>
<ModalContent title="Manage Access" showCancelButton={false} confirmText="Done"> <ModalContent showCancelButton={false} confirmText="Done">
<span slot="header">
Manage Access
{#if requiresPlanToModify}
<span class="lock-tag">
<Tags>
<Tag icon="LockClosed">{capitalise(requiresPlanToModify)}</Tag>
</Tags>
</span>
{/if}
</span>
<Body size="S">Specify the minimum access level role for this data.</Body> <Body size="S">Specify the minimum access level role for this data.</Body>
<div class="row"> <div class="row">
<Label extraSmall grey>Level</Label> <Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label> <Label extraSmall grey>Role</Label>
{#each Object.keys(permissions) as level} {#each Object.keys(computedPermissions) as level}
<Input value={capitalise(level)} disabled /> <Input value={capitalise(level)} disabled />
<Select <Select
value={permissions[level]} disabled={requiresPlanToModify}
placeholder={false}
value={computedPermissions[level].selectedValue}
on:change={e => changePermission(level, e.detail)} on:change={e => changePermission(level, e.detail)}
options={$roles} options={computedPermissions[level].options}
getOptionLabel={x => x.name} getOptionLabel={x => x.name}
getOptionValue={x => x._id} getOptionValue={x => x._id}
/> />
{/each} {/each}
</div> </div>
{#if dependantsInfoMessage}
<div class="inheriting-resources">
<Icon name="Alert" />
<Body size="S">
<i>
{dependantsInfoMessage}
</i>
</Body>
</div>
{/if}
</ModalContent> </ModalContent>
<style> <style>
@ -54,4 +140,13 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
} }
.lock-tag {
padding-left: var(--spacing-s);
}
.inheriting-resources {
display: flex;
gap: var(--spacing-s);
}
</style> </style>

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte" import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
const { rows } = getContext("grid") const { datasource } = getContext("grid")
</script> </script>
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} /> <CreateEditColumn on:updatecolumns={datasource.actions.refreshDefinition} />

View File

@ -0,0 +1,60 @@
<script>
import { getContext } from "svelte"
import { Input, notifications, ModalContent } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { viewsV2 } from "stores/backend"
const { filter, sort, definition } = getContext("grid")
let name
$: views = Object.keys($definition?.views || {}).map(x => x.toLowerCase())
$: nameExists = views.includes(name?.trim().toLowerCase())
const enrichSchema = schema => {
// We need to sure that "visible" is set to true for any fields which have
// not yet been saved with grid metadata attached
const cloned = { ...schema }
Object.entries(cloned).forEach(([field, fieldSchema]) => {
if (fieldSchema.visible == null) {
cloned[field] = { ...cloned[field], visible: true }
}
})
return cloned
}
const saveView = async () => {
name = name?.trim()
try {
const newView = await viewsV2.create({
name,
tableId: $definition._id,
query: $filter,
sort: {
field: $sort.column,
order: $sort.order,
},
schema: enrichSchema($definition.schema),
primaryDisplay: $definition.primaryDisplay,
})
notifications.success(`View ${name} created`)
$goto(`../../view/v2/${newView.id}`)
} catch (error) {
notifications.error("Error creating view")
}
}
</script>
<ModalContent
title="Create view"
confirmText="Create view"
onConfirm={saveView}
disabled={nameExists}
>
<Input
label="View name"
thin
bind:value={name}
error={nameExists ? "A view already exists with that name" : null}
/>
</ModalContent>

View File

@ -1,7 +1,14 @@
<script> <script>
import { goto, isActive, params } from "@roxi/routify" import { goto, isActive, params } from "@roxi/routify"
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend" import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
import { database, datasources, queries, tables, views } from "stores/backend" import {
database,
datasources,
queries,
tables,
views,
viewsV2,
} from "stores/backend"
import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte" import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte"
import EditQueryPopover from "./popovers/EditQueryPopover.svelte" import EditQueryPopover from "./popovers/EditQueryPopover.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
@ -24,6 +31,7 @@
$tables, $tables,
$queries, $queries,
$views, $views,
$viewsV2,
openDataSources openDataSources
) )
$: openDataSource = enrichedDataSources.find(x => x.open) $: openDataSource = enrichedDataSources.find(x => x.open)
@ -41,6 +49,7 @@
tables, tables,
queries, queries,
views, views,
viewsV2,
openDataSources openDataSources
) => { ) => {
if (!datasources?.list?.length) { if (!datasources?.list?.length) {
@ -57,7 +66,8 @@
isActive, isActive,
tables, tables,
queries, queries,
views views,
viewsV2
) )
const onlySource = datasources.list.length === 1 const onlySource = datasources.list.length === 1
return { return {
@ -106,7 +116,8 @@
isActive, isActive,
tables, tables,
queries, queries,
views views,
viewsV2
) => { ) => {
// Check for being on a datasource page // Check for being on a datasource page
if (params.datasourceId === datasource._id) { if (params.datasourceId === datasource._id) {
@ -152,10 +163,16 @@
// Check for a matching view // Check for a matching view
const selectedView = views.selected?.name const selectedView = views.selected?.name
const table = options.find(table => { const viewTable = options.find(table => {
return table.views?.[selectedView] != null return table.views?.[selectedView] != null
}) })
return table != null if (viewTable) {
return true
}
// Check for a matching viewV2
const viewV2Table = options.find(x => x._id === viewsV2.selected?.tableId)
return viewV2Table != null
} }
</script> </script>

View File

@ -290,11 +290,11 @@
datasource.entities[getTable(toId).name].schema[toRelationship.name] = datasource.entities[getTable(toId).name].schema[toRelationship.name] =
toRelationship toRelationship
await save() await save({ action: "saved" })
} }
async function deleteRelationship() { async function deleteRelationship() {
removeExistingRelationship() removeExistingRelationship()
await save() await save({ action: "deleted" })
await tables.fetch() await tables.fetch()
close() close()
} }

View File

@ -33,7 +33,7 @@
} }
// action is one of 'created', 'updated' or 'deleted' // action is one of 'created', 'updated' or 'deleted'
async function saveRelationship(action) { async function saveRelationship({ action }) {
try { try {
await beforeSave({ action, datasource }) await beforeSave({ action, datasource })

View File

@ -1,5 +1,5 @@
<script> <script>
import { tables, views, database } from "stores/backend" import { tables, views, viewsV2, database } from "stores/backend"
import { TableNames } from "constants" import { TableNames } from "constants"
import EditTablePopover from "./popovers/EditTablePopover.svelte" import EditTablePopover from "./popovers/EditTablePopover.svelte"
import EditViewPopover from "./popovers/EditViewPopover.svelte" import EditViewPopover from "./popovers/EditViewPopover.svelte"
@ -7,9 +7,6 @@
import { goto, isActive } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { userSelectedResourceMap } from "builderStore" import { userSelectedResourceMap } from "builderStore"
const alphabetical = (a, b) =>
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId export let sourceId
export let selectTable export let selectTable
@ -18,6 +15,17 @@
table => table.sourceId === sourceId && table._id !== TableNames.USERS table => table.sourceId === sourceId && table._id !== TableNames.USERS
) )
.sort(alphabetical) .sort(alphabetical)
const alphabetical = (a, b) => {
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
}
const isViewActive = (view, isActive, views, viewsV2) => {
return (
(isActive("./view/v1") && views.selected?.name === view.name) ||
(isActive("./view/v2") && viewsV2.selected?.id === view.id)
)
}
</script> </script>
{#if $database?._id} {#if $database?._id}
@ -37,18 +45,23 @@
<EditTablePopover {table} /> <EditTablePopover {table} />
{/if} {/if}
</NavItem> </NavItem>
{#each [...Object.keys(table.views || {})].sort() as viewName, idx (idx)} {#each [...Object.entries(table.views || {})].sort() as [name, view], idx (idx)}
<NavItem <NavItem
indentLevel={2} indentLevel={2}
icon="Remove" icon="Remove"
text={viewName} text={name}
selected={$isActive("./view") && $views.selected?.name === viewName} selected={isViewActive(view, $isActive, $views, $viewsV2)}
on:click={() => $goto(`./view/${encodeURIComponent(viewName)}`)} on:click={() => {
selectedBy={$userSelectedResourceMap[viewName]} if (view.version === 2) {
$goto(`./view/v2/${view.id}`)
} else {
$goto(`./view/v1/${encodeURIComponent(name)}`)
}
}}
selectedBy={$userSelectedResourceMap[name] ||
$userSelectedResourceMap[view.id]}
> >
<EditViewPopover <EditViewPopover {view} />
view={{ name: viewName, ...table.views[viewName] }}
/>
</NavItem> </NavItem>
{/each} {/each}
{/each} {/each}

View File

@ -35,7 +35,7 @@
screen => screen.autoTableId === table._id screen => screen.autoTableId === table._id
) )
willBeDeleted = ["All table data"].concat( willBeDeleted = ["All table data"].concat(
templateScreens.map(screen => `Screen ${screen.props._instanceName}`) templateScreens.map(screen => `Screen ${screen.routing?.route || ""}`)
) )
confirmDeleteDialog.show() confirmDeleteDialog.show()
} }
@ -44,7 +44,10 @@
const isSelected = $params.tableId === table._id const isSelected = $params.tableId === table._id
try { try {
await tables.delete(table) await tables.delete(table)
await store.actions.screens.delete(templateScreens) // Screens need deleted one at a time because of undo/redo
for (let screen of templateScreens) {
await store.actions.screens.delete(screen)
}
if (table.type === "external") { if (table.type === "external") {
await datasources.fetch() await datasources.fetch()
} }

View File

@ -1,6 +1,5 @@
<script> <script>
import { goto, params } from "@roxi/routify" import { views, viewsV2 } from "stores/backend"
import { views } from "stores/backend"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
@ -24,23 +23,29 @@
const updatedView = cloneDeep(view) const updatedView = cloneDeep(view)
updatedView.name = updatedName updatedView.name = updatedName
await views.save({ if (view.version === 2) {
originalName, await viewsV2.save({
...updatedView, originalName,
}) ...updatedView,
})
} else {
await views.save({
originalName,
...updatedView,
})
}
notifications.success("View renamed successfully") notifications.success("View renamed successfully")
} }
async function deleteView() { async function deleteView() {
try { try {
const isSelected = if (view.version === 2) {
decodeURIComponent($params.viewName) === $views.selectedViewName await viewsV2.delete(view)
const id = view.tableId } else {
await views.delete(view) await views.delete(view)
notifications.success("View deleted")
if (isSelected) {
$goto(`./table/${id}`)
} }
notifications.success("View deleted")
} catch (error) { } catch (error) {
notifications.error("Error deleting view") notifications.error("Error deleting view")
} }

View File

@ -109,7 +109,13 @@
type: "View", type: "View",
name: view.name, name: view.name,
icon: "Remove", icon: "Remove",
action: () => $goto(`./data/view/${view.name}`), action: () => {
if (view.version === 2) {
$goto(`./data/view/v2/${view.id}`)
} else {
$goto(`./data/view/${view.name}`)
}
},
})) ?? []), })) ?? []),
...($queries?.list?.map(query => ({ ...($queries?.list?.map(query => ({
type: "Query", type: "Query",
@ -121,7 +127,9 @@
type: "Screen", type: "Screen",
name: screen.routing.route, name: screen.routing.route,
icon: "WebPage", icon: "WebPage",
action: () => $goto(`./design/${screen._id}/components`), action: () => {
$goto(`./design/${screen._id}/${screen._id}-screen`)
},
})), })),
...($automationStore?.automations?.map(automation => ({ ...($automationStore?.automations?.map(automation => ({
type: "Automation", type: "Automation",

View File

@ -21,6 +21,7 @@
export let id export let id
export let showTooltip = false export let showTooltip = false
export let selectedBy = null export let selectedBy = null
export let compact = false
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -80,8 +81,9 @@
{#if withArrow} {#if withArrow}
<div <div
class:opened class:opened
class:relative={indentLevel === 0} class:relative={indentLevel === 0 && !compact}
class:absolute={indentLevel > 0} class:absolute={indentLevel > 0 && !compact}
class:compact
class="icon arrow" class="icon arrow"
on:click={onIconClick} on:click={onIconClick}
> >
@ -194,10 +196,21 @@
padding: 8px; padding: 8px;
margin-left: -8px; margin-left: -8px;
} }
.compact {
position: absolute;
left: 6px;
padding: 8px;
margin-left: -8px;
}
.icon.arrow :global(svg) { .icon.arrow :global(svg) {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
.icon.arrow.compact :global(svg) {
width: 9px;
height: 9px;
}
.icon.arrow.relative { .icon.arrow.relative {
position: relative; position: relative;
margin: 0 -6px 0 -4px; margin: 0 -6px 0 -4px;

View File

@ -1,8 +1,11 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select, FancySelect } from "@budibase/bbui"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { licensing } from "stores/portal"
import { Constants, RoleUtils } from "@budibase/frontend-core" import { Constants, RoleUtils } from "@budibase/frontend-core"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { capitalise } from "helpers"
export let value export let value
export let error export let error
@ -15,17 +18,43 @@
export let align export let align
export let footer = null export let footer = null
export let allowedRoles = null export let allowedRoles = null
export let allowCreator = false
export let fancySelect = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const RemoveID = "remove" const RemoveID = "remove"
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles) $: options = getOptions(
$roles,
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => { allowPublic,
allowRemove,
allowedRoles,
allowCreator
)
const getOptions = (
roles,
allowPublic,
allowRemove,
allowedRoles,
allowCreator
) => {
if (allowedRoles?.length) { if (allowedRoles?.length) {
return roles.filter(role => allowedRoles.includes(role._id)) return roles.filter(role => allowedRoles.includes(role._id))
} }
let newRoles = [...roles] let newRoles = [...roles]
if (allowCreator) {
newRoles = [
{
_id: Constants.Roles.CREATOR,
name: "Creator",
tag:
!$licensing.perAppBuildersEnabled &&
capitalise(Constants.PlanType.BUSINESS),
},
...newRoles,
]
}
if (allowRemove) { if (allowRemove) {
newRoles = [ newRoles = [
...newRoles, ...newRoles,
@ -64,19 +93,45 @@
} }
</script> </script>
<Select {#if fancySelect}
{autoWidth} <FancySelect
{quiet} {autoWidth}
{disabled} {quiet}
{align} {disabled}
{footer} {align}
bind:value {footer}
on:change={onChange} bind:value
{options} on:change={onChange}
getOptionLabel={role => role.name} {options}
getOptionValue={role => role._id} label="Access on this app"
getOptionColour={getColor} getOptionLabel={role => role.name}
getOptionIcon={getIcon} getOptionValue={role => role._id}
{placeholder} getOptionColour={getColor}
{error} getOptionIcon={getIcon}
/> isOptionEnabled={option =>
option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled}
{placeholder}
{error}
/>
{:else}
<Select
{autoWidth}
{quiet}
{disabled}
{align}
{footer}
bind:value
on:change={onChange}
{options}
getOptionLabel={role => role.name}
getOptionValue={role => role._id}
getOptionColour={getColor}
getOptionIcon={getIcon}
isOptionEnabled={option =>
option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled}
{placeholder}
{error}
/>
{/if}

View File

@ -74,6 +74,8 @@
{/if} {/if}
</div> </div>
<Drawer <Drawer
on:drawerHide
on:drawerShow
{fillWidth} {fillWidth}
bind:this={bindingDrawer} bind:this={bindingDrawer}
{title} {title}

View File

@ -1,5 +1,5 @@
<script> <script>
import { Icon, Heading } from "@budibase/bbui" import { Icon, Body } from "@budibase/bbui"
export let title export let title
export let icon export let icon
@ -25,7 +25,7 @@
<Icon name={icon} /> <Icon name={icon} />
{/if} {/if}
<div class="title"> <div class="title">
<Heading size="XXS">{title || ""}</Heading> <Body size="S">{title}</Body>
</div> </div>
{#if showAddButton} {#if showAddButton}
<div class="add-button" on:click={onClickAddButton}> <div class="add-button" on:click={onClickAddButton}>
@ -78,15 +78,14 @@
align-items: center; align-items: center;
padding: 0 var(--spacing-l); padding: 0 var(--spacing-l);
border-bottom: var(--border-light); border-bottom: var(--border-light);
gap: var(--spacing-l); gap: var(--spacing-m);
} }
.title { .title {
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;
} }
.title :global(h1) { .title :global(p) {
overflow: hidden; overflow: hidden;
font-weight: 600;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -13,9 +13,9 @@
import { generate } from "shortid" import { generate } from "shortid"
import { import {
getEventContextBindings, getEventContextBindings,
getActionBindings,
makeStateBinding, makeStateBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
const flipDurationMs = 150 const flipDurationMs = 150
@ -26,6 +26,7 @@
export let actions export let actions
export let bindings = [] export let bindings = []
export let nested export let nested
export let componentInstance
let actionQuery let actionQuery
let selectedAction = actions?.length ? actions[0] : null let selectedAction = actions?.length ? actions[0] : null
@ -68,15 +69,19 @@
acc[action.type].push(action) acc[action.type].push(action)
return acc return acc
}, {}) }, {})
// These are ephemeral bindings which only exist while executing actions // These are ephemeral bindings which only exist while executing actions
$: eventContexBindings = getEventContextBindings( $: eventContextBindings = getEventContextBindings({
$currentAsset, componentInstance,
$store.selectedComponentId, settingKey: key,
key, })
actions, $: actionContextBindings = getActionBindings(actions, selectedAction?.id)
selectedAction?.id
$: allBindings = getAllBindings(
bindings,
[...eventContextBindings, ...actionContextBindings],
actions
) )
$: allBindings = getAllBindings(bindings, eventContexBindings, actions)
$: { $: {
// Ensure each action has a unique ID // Ensure each action has a unique ID
if (actions) { if (actions) {

View File

@ -13,6 +13,7 @@
export let name export let name
export let bindings export let bindings
export let nested export let nested
export let componentInstance
let drawer let drawer
let tmpValue let tmpValue
@ -74,7 +75,7 @@
<ActionButton on:click={openDrawer}>{actionText}</ActionButton> <ActionButton on:click={openDrawer}>{actionText}</ActionButton>
</div> </div>
<Drawer bind:this={drawer} title={"Actions"}> <Drawer bind:this={drawer} title={"Actions"} on:drawerHide on:drawerShow>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Define what actions to run. Define what actions to run.
</svelte:fragment> </svelte:fragment>
@ -86,6 +87,7 @@
{bindings} {bindings}
{key} {key}
{nested} {nested}
{componentInstance}
/> />
</Drawer> </Drawer>

View File

@ -1,10 +1,12 @@
<script> <script>
import { Select, Label, Stepper } from "@budibase/bbui" import { Select, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding" import { getActionProviderComponents } from "builderStore/dataBinding"
import { onMount } from "svelte" import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = []
$: actionProviders = getActionProviderComponents( $: actionProviders = getActionProviderComponents(
$currentAsset, $currentAsset,
@ -51,7 +53,11 @@
<Select bind:value={parameters.type} options={typeOptions} /> <Select bind:value={parameters.type} options={typeOptions} />
{#if parameters.type === "specific"} {#if parameters.type === "specific"}
<Label small>Number</Label> <Label small>Number</Label>
<Stepper bind:value={parameters.number} /> <DrawerBindableInput
{bindings}
value={parameters.number}
on:change={e => (parameters.number = e.detail)}
/>
{/if} {/if}
</div> </div>

View File

@ -1,12 +1,20 @@
<script> <script>
import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui" import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = [] export let bindings = []
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
</script> </script>
<div class="root"> <div class="root">
@ -15,9 +23,9 @@
<Label>Table</Label> <Label>Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={table => table.name} getOptionLabel={x => x.label}
getOptionValue={table => table._id} getOptionValue={x => x.resourceId}
/> />
<Label small>Row IDs</Label> <Label small>Row IDs</Label>

View File

@ -1,10 +1,10 @@
<script> <script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore" import { store, currentAsset } from "builderStore"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import { import {
getContextProviderComponents, getContextProviderComponents,
getSchemaForTable, getSchemaForDatasourcePlus,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
@ -23,7 +23,15 @@
) )
$: providerOptions = getProviderOptions(formComponents, schemaComponents) $: providerOptions = getProviderOptions(formComponents, schemaComponents)
$: schemaFields = getSchemaFields($currentAsset, parameters?.tableId) $: schemaFields = getSchemaFields($currentAsset, parameters?.tableId)
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
// Gets a context definition of a certain type from a component definition // Gets a context definition of a certain type from a component definition
const extractComponentContext = (component, contextType) => { const extractComponentContext = (component, contextType) => {
@ -60,7 +68,7 @@
} }
const getSchemaFields = (asset, tableId) => { const getSchemaFields = (asset, tableId) => {
const { schema } = getSchemaForTable(tableId) const { schema } = getSchemaForDatasourcePlus(tableId)
delete schema._id delete schema._id
delete schema._rev delete schema._rev
return Object.values(schema || {}) return Object.values(schema || {})
@ -89,9 +97,9 @@
<Label small>Duplicate to Table</Label> <Label small>Duplicate to Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={option => option.name} getOptionLabel={option => option.label}
getOptionValue={option => option._id} getOptionValue={option => option.resourceId}
/> />
<Label small /> <Label small />

View File

@ -1,21 +1,29 @@
<script> <script>
import { Select, Label } from "@budibase/bbui" import { Select, Label } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = [] export let bindings = []
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
</script> </script>
<div class="root"> <div class="root">
<Label>Table</Label> <Label>Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={table => table.name} getOptionLabel={table => table.label}
getOptionValue={table => table._id} getOptionValue={table => table.resourceId}
/> />
<Label small>Row ID</Label> <Label small>Row ID</Label>

View File

@ -1,10 +1,10 @@
<script> <script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore" import { store, currentAsset } from "builderStore"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import { import {
getContextProviderComponents, getContextProviderComponents,
getSchemaForTable, getSchemaForDatasourcePlus,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
@ -24,8 +24,16 @@
"schema" "schema"
) )
$: providerOptions = getProviderOptions(formComponents, schemaComponents) $: providerOptions = getProviderOptions(formComponents, schemaComponents)
$: schemaFields = getSchemaFields($currentAsset, parameters?.tableId) $: schemaFields = getSchemaFields(parameters?.tableId)
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
// Gets a context definition of a certain type from a component definition // Gets a context definition of a certain type from a component definition
const extractComponentContext = (component, contextType) => { const extractComponentContext = (component, contextType) => {
@ -61,8 +69,8 @@
}) })
} }
const getSchemaFields = (asset, tableId) => { const getSchemaFields = resourceId => {
const { schema } = getSchemaForTable(tableId) const { schema } = getSchemaForDatasourcePlus(resourceId)
return Object.values(schema || {}) return Object.values(schema || {})
} }
@ -89,9 +97,9 @@
<Label small>Table</Label> <Label small>Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={option => option.name} getOptionLabel={option => option.label}
getOptionValue={option => option._id} getOptionValue={option => option.resourceId}
/> />
<Label small /> <Label small />

View File

@ -42,7 +42,6 @@
<ColorPicker <ColorPicker
value={column.background} value={column.background}
on:change={e => (column.background = e.detail)} on:change={e => (column.background = e.detail)}
alignRight
spectrumTheme={$store.theme} spectrumTheme={$store.theme}
/> />
</Layout> </Layout>
@ -51,7 +50,6 @@
<ColorPicker <ColorPicker
value={column.color} value={column.color}
on:change={e => (column.color = e.detail)} on:change={e => (column.color = e.detail)}
alignRight
spectrumTheme={$store.theme} spectrumTheme={$store.theme}
/> />
</Layout> </Layout>

View File

@ -15,6 +15,8 @@
import { import {
tables as tablesStore, tables as tablesStore,
queries as queriesStore, queries as queriesStore,
viewsV2 as viewsV2Store,
views as viewsStore,
} from "stores/backend" } from "stores/backend"
import { datasources, integrations } from "stores/backend" import { datasources, integrations } from "stores/backend"
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
@ -39,15 +41,17 @@
tableId: m._id, tableId: m._id,
type: "table", type: "table",
})) }))
$: views = $tablesStore.list.reduce((acc, cur) => { $: viewsV1 = $viewsStore.list.map(view => ({
let viewsArr = Object.entries(cur.views || {}).map(([key, value]) => ({ ...view,
label: key, label: view.name,
name: key, type: "view",
...value, }))
type: "view", $: viewsV2 = $viewsV2Store.list.map(view => ({
})) ...view,
return [...acc, ...viewsArr] label: view.name,
}, []) type: "viewV2",
}))
$: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list $: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable) .filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
.map(query => ({ .map(query => ({

View File

@ -0,0 +1,156 @@
<script>
import { Icon } from "@budibase/bbui"
import { dndzone } from "svelte-dnd-action"
import { createEventDispatcher } from "svelte"
import { generate } from "shortid"
import { setContext } from "svelte"
import { writable } from "svelte/store"
export let items = []
export let showHandle = true
export let listType
export let listTypeProps = {}
export let listItemKey
export let draggable = true
let store = writable({
selected: null,
actions: {
select: id => {
store.update(state => ({
...state,
selected: id,
}))
},
},
})
setContext("draggable", store)
const dispatch = createEventDispatcher()
const flipDurationMs = 150
let anchors = {}
let draggableItems = []
const buildDraggable = items => {
return items
.map(item => {
return {
id: listItemKey ? item[listItemKey] : generate(),
item,
}
})
.filter(item => item.id)
}
$: if (items) {
draggableItems = buildDraggable(items)
}
const updateRowOrder = e => {
draggableItems = e.detail.items
}
const serialiseUpdate = () => {
return draggableItems.reduce((acc, ele) => {
acc.push(ele.item)
return acc
}, [])
}
const handleFinalize = e => {
updateRowOrder(e)
dispatch("change", serialiseUpdate())
}
const onItemChanged = e => {
dispatch("itemChange", e.detail)
}
</script>
<ul
class="list-wrap"
use:dndzone={{
items: draggableItems,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled: !draggable,
}}
on:finalize={handleFinalize}
on:consider={updateRowOrder}
>
{#each draggableItems as draggable (draggable.id)}
<li
bind:this={anchors[draggable.id]}
class:highlighted={draggable.id === $store.selected}
>
<div class="left-content">
{#if showHandle}
<div class="handle" aria-label="drag-handle">
<Icon name="DragHandle" size="XL" />
</div>
{/if}
</div>
<div class="right-content">
<svelte:component
this={listType}
anchor={anchors[draggable.item._id]}
item={draggable.item}
{...listTypeProps}
on:change={onItemChanged}
/>
</div>
</li>
{/each}
</ul>
<style>
.list-wrap {
list-style-type: none;
margin: 0;
padding: 0;
width: 100%;
border-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
}
.list-wrap > li {
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
align-items: center;
border-bottom: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
}
.list-wrap > li:hover,
li.highlighted {
background-color: var(
--spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover)
);
}
.list-wrap > li:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.list-wrap > li:last-child {
border-top-left-radius: var(--spectrum-table-regular-border-radius);
border-top-right-radius: var(--spectrum-table-regular-border-radius);
}
.right-content {
flex: 1;
min-width: 0;
}
.list-wrap li {
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,160 @@
<script>
import { Icon, Popover, Layout } from "@budibase/bbui"
import { store } from "builderStore"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import { getContext } from "svelte"
export let anchor
export let field
export let componentBindings
export let bindings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
let popover
let drawers = []
let pseudoComponentInstance
let open = false
$: if (open && $draggable.selected && $draggable.selected != field._id) {
popover.hide()
}
$: if (field) {
pseudoComponentInstance = field
}
$: componentDef = store.actions.components.getDefinition(
pseudoComponentInstance._component
)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
const processComponentDefinitionSettings = componentDef => {
if (!componentDef) {
return {}
}
const clone = cloneDeep(componentDef)
const updatedSettings = clone.settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
clone.settings = updatedSettings
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(pseudoComponentInstance)
const patchFn = store.actions.components.updateComponentSetting(
setting.key,
value
)
patchFn(nestedComponentInstance)
const update = {
...nestedComponentInstance,
active: pseudoComponentInstance.active,
}
dispatch("change", update)
}
</script>
<Icon
name="Settings"
hoverable
size="S"
on:click={() => {
if (!open) {
popover.show()
open = true
}
}}
/>
<Popover
bind:this={popover}
on:open={() => {
drawers = []
$draggable.actions.select(field._id)
}}
on:close={() => {
open = false
if ($draggable.selected == field._id) {
$draggable.actions.select()
}
}}
{anchor}
align="left-outside"
showPopover={drawers.length == 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}}
>
<span class="popover-wrap">
<Layout noPadding noGap>
<div class="type-icon">
<Icon name={parsedComponentDef.icon} />
<span>{field.field}</span>
</div>
<ComponentSettingsSection
componentInstance={pseudoComponentInstance}
componentDefinition={parsedComponentDef}
isScreen={false}
onUpdateSetting={updateSetting}
showSectionTitle={false}
showInstanceName={false}
{bindings}
{componentBindings}
on:drawerShow={e => {
drawers = [...drawers, e.detail]
}}
on:drawerHide={() => {
drawers = drawers.slice(0, -1)
}}
/>
</Layout>
</span>
</Popover>
<style>
.popover-wrap {
background-color: var(--spectrum-alias-background-color-primary);
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View File

@ -1,45 +1,81 @@
<script> <script>
import { Button, ActionButton, Drawer } from "@budibase/bbui" import { cloneDeep, isEqual } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { import {
getDatasourceForProvider, getDatasourceForProvider,
getSchemaForDatasource, getSchemaForDatasource,
getBindableProperties,
getComponentBindableProperties,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { currentAsset } from "builderStore" import { currentAsset } from "builderStore"
import DraggableList from "../DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import { store, selectedScreen } from "builderStore"
import FieldSetting from "./FieldSetting.svelte"
import { convertOldFieldFormat, getComponentForField } from "./utils"
export let componentInstance export let componentInstance
export let value = [] export let value
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let sanitisedFields
let fieldList
let schema
let cachedValue
let options
let sanitisedValue
let unconfigured
let drawer $: bindings = getBindableProperties($selectedScreen, componentInstance._id)
let boundValue $: actionType = componentInstance.actionType
let componentBindings = []
$: text = getText(value) $: if (actionType) {
$: convertOldColumnFormat(value) componentBindings = getComponentBindableProperties(
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) $selectedScreen,
$: schema = getSchema($currentAsset, datasource) componentInstance._id
$: options = Object.keys(schema || {}) )
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
const getText = value => {
if (!value?.length) {
return "All fields"
}
let text = `${value.length} field`
if (value.length !== 1) {
text += "s"
}
return text
} }
const convertOldColumnFormat = oldColumns => { $: datasource = getDatasourceForProvider($currentAsset, componentInstance)
if (typeof oldColumns?.[0] === "string") { $: resourceId = datasource.resourceId || datasource.tableId
value = oldColumns.map(field => ({ name: field, displayName: field }))
$: if (!isEqual(value, cachedValue)) {
cachedValue = cloneDeep(value)
}
const updateState = value => {
schema = getSchema($currentAsset, datasource)
options = Object.keys(schema || {})
sanitisedValue = getValidColumns(convertOldFieldFormat(value), options)
updateSanitsedFields(sanitisedValue)
unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
fieldList = [...sanitisedFields, ...unconfigured]
.map(buildSudoInstance)
.filter(x => x != null)
}
$: updateState(cachedValue, resourceId)
// Builds unused ones only
const buildUnconfiguredOptions = (schema, selected) => {
if (!schema) {
return []
} }
let schemaClone = cloneDeep(schema)
selected.forEach(val => {
delete schemaClone[val.field]
})
return Object.keys(schemaClone)
.filter(key => !schemaClone[key].autocolumn)
.map(key => {
const col = schemaClone[key]
let toggleOn = !value
return {
field: key,
active: typeof col.active != "boolean" ? toggleOn : col.active,
}
})
} }
const getSchema = (asset, datasource) => { const getSchema = (asset, datasource) => {
@ -54,50 +90,83 @@
return schema return schema
} }
const updateBoundValue = value => { const updateSanitsedFields = value => {
boundValue = cloneDeep(value) sanitisedFields = cloneDeep(value)
} }
const getValidColumns = (columns, options) => { const getValidColumns = (columns, options) => {
if (!Array.isArray(columns) || !columns.length) { if (!Array.isArray(columns) || !columns.length) {
return [] return []
} }
// We need to account for legacy configs which would just be an array
// of strings
if (typeof columns[0] === "string") {
columns = columns.map(col => ({
name: col,
displayName: col,
}))
}
return columns.filter(column => { return columns.filter(column => {
return options.includes(column.name) return options.includes(column.field)
}) })
} }
const open = () => { const buildSudoInstance = instance => {
updateBoundValue(sanitisedValue) if (instance._component) {
drawer.show() return instance
}
const type = getComponentForField(instance.field, schema)
if (!type) {
return null
}
instance._component = `@budibase/standard-components/${type}`
const pseudoComponentInstance = store.actions.components.createInstance(
instance._component,
{
_instanceName: instance.field,
field: instance.field,
label: instance.field,
placeholder: instance.field,
},
{}
)
return { ...instance, ...pseudoComponentInstance }
} }
const save = () => { const processItemUpdate = e => {
dispatch("change", getValidColumns(boundValue, options)) const updatedField = e.detail
drawer.hide() const parentFieldsUpdated = fieldList ? cloneDeep(fieldList) : []
let parentFieldIdx = parentFieldsUpdated.findIndex(pSetting => {
return pSetting.field === updatedField?.field
})
if (parentFieldIdx == -1) {
parentFieldsUpdated.push(updatedField)
} else {
parentFieldsUpdated[parentFieldIdx] = updatedField
}
dispatch("change", getValidColumns(parentFieldsUpdated, options))
}
const listUpdated = e => {
const parsedColumns = getValidColumns(e.detail, options)
dispatch("change", parsedColumns)
} }
</script> </script>
<div class="field-configuration"> <div class="field-configuration">
<ActionButton on:click={open}>{text}</ActionButton> {#if fieldList?.length}
<DraggableList
on:change={listUpdated}
on:itemChange={processItemUpdate}
items={fieldList}
listItemKey={"_id"}
listType={FieldSetting}
listTypeProps={{
componentBindings,
bindings,
}}
/>
{/if}
</div> </div>
<Drawer bind:this={drawer} title="Form Fields">
<svelte:fragment slot="description">
Configure the fields in your form.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<ColumnDrawer slot="body" bind:columns={boundValue} {options} {schema} />
</Drawer>
<style> <style>
.field-configuration :global(.spectrum-ActionButton) { .field-configuration :global(.spectrum-ActionButton) {
width: 100%; width: 100%;

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