Merge branch 'develop' into lab-theme-binding

This commit is contained in:
Andrew Kingston 2023-09-08 15:10:51 +01:00 committed by GitHub
commit 32892b1ef9
245 changed files with 4753 additions and 2812 deletions

View File

@ -7,7 +7,4 @@ packages/worker/coverage
packages/backend-core/coverage packages/backend-core/coverage
packages/server/client packages/server/client
packages/builder/.routify packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/builder/cypress/reports
packages/sdk/sdk packages/sdk/sdk

View File

@ -1,4 +1,3 @@
# Budibase CI Pipelines # Budibase CI Pipelines
Welcome to the budibase CI pipelines directory. This document details what each of the CI pipelines are for, and come common combinations. Welcome to the budibase CI pipelines directory. This document details what each of the CI pipelines are for, and come common combinations.
@ -6,27 +5,34 @@ Welcome to the budibase CI pipelines directory. This document details what each
## All CI Pipelines ## All CI Pipelines
### Note ### Note
- When running workflow dispatch jobs, ensure you always run them off the `master` branch. It defaults to `develop`, so double check before running any jobs. The exception to this case is the `deploy-release` job which requires the develop branch.
- When running workflow dispatch jobs, ensure you always run them off the `master` branch. It defaults to `develop`, so double check before running any jobs. The exception to this case is the `deploy-release` job which requires the develop branch.
### Standard CI Build Job (budibase_ci.yml) ### Standard CI Build Job (budibase_ci.yml)
Triggers: Triggers:
- PR or push to develop - PR or push to develop
- PR or push to master - PR or push to master
The standard CI Build job is what runs when you raise a PR to develop or master. The standard CI Build job is what runs when you raise a PR to develop or master.
- Installs all dependencies, - Installs all dependencies,
- builds the project - builds the project
- run the unit tests - run the unit tests
- Generate test coverage metrics with codecov - Generate test coverage metrics with codecov
- Run the cypress tests - Run the integration tests
### Release Develop Job (release-develop.yml) ### Release Develop Job (release-develop.yml)
Triggers: Triggers:
- Push to develop - Push to develop
The job responsible for building, tagging and pushing docker images out to the test and release environments. The job responsible for building, tagging and pushing docker images out to the test and release environments.
- Installs all dependencies - Installs all dependencies
- builds the project - builds the project
- run the unit tests - run the unit tests
- publish the budibase JS packages under a prerelease tag to NPM - publish the budibase JS packages under a prerelease tag to NPM
- build, tag and push docker images under the `develop` tag to docker hub - build, tag and push docker images under the `develop` tag to docker hub
@ -34,23 +40,29 @@ The job responsible for building, tagging and pushing docker images out to the t
These images will then be pulled by the test and release environments, updating the latest automatically. Discord notifications are sent to the #infra channel when this occurs. These images will then be pulled by the test and release environments, updating the latest automatically. Discord notifications are sent to the #infra channel when this occurs.
### Release Job (release.yml) ### Release Job (release.yml)
Triggers: Triggers:
- Push to master - Push to master
This job is responsible for building and pushing the latest code to NPM and docker hub, so that it can be deployed. This job is responsible for building and pushing the latest code to NPM and docker hub, so that it can be deployed.
- Installs all dependencies - Installs all dependencies
- builds the project - builds the project
- run the unit tests - run the unit tests
- publish the budibase JS packages under a release tag to NPM (always incremented by patch versions) - publish the budibase JS packages under a release tag to NPM (always incremented by patch versions)
- build, tag and push docker images under the `v.x.x.x` (the tag of the NPM release) tag to docker hub - build, tag and push docker images under the `v.x.x.x` (the tag of the NPM release) tag to docker hub
### Release Selfhost Job (release-selfhost.yml) ### Release Selfhost Job (release-selfhost.yml)
Triggers: Triggers:
- Manual Workflow Dispatch Trigger - Manual Workflow Dispatch Trigger
This job is responsible for delivering the latest version of budibase to those that are self-hosting. This job is responsible for delivering the latest version of budibase to those that are self-hosting.
This job relies on the release job to have run first, so the latest image is pushed to dockerhub. This job then will pull the latest version from `lerna.json` and try to find an image in dockerhub corresponding to that version. For example, if the version in `lerna.json` is `1.0.0`: This job relies on the release job to have run first, so the latest image is pushed to dockerhub. This job then will pull the latest version from `lerna.json` and try to find an image in dockerhub corresponding to that version. For example, if the version in `lerna.json` is `1.0.0`:
- Pull the images for all budibase services tagged `v1.0.0` from dockerhub - Pull the images for all budibase services tagged `v1.0.0` from dockerhub
- Tag these images as `latest` - Tag these images as `latest`
- Push them back to dockerhub. This now means anyone who pulls `latest` (self hosters using docker-compose) will get the latest version. - Push them back to dockerhub. This now means anyone who pulls `latest` (self hosters using docker-compose) will get the latest version.
@ -58,53 +70,61 @@ This job relies on the release job to have run first, so the latest image is pus
- Perform a github release with the latest version. You can see previous releases here (https://github.com/Budibase/budibase/releases) - Perform a github release with the latest version. You can see previous releases here (https://github.com/Budibase/budibase/releases)
### Deploy Release (deploy-release.yml) ### Deploy Release (deploy-release.yml)
Triggers: Triggers:
- Manual Workflow Dispatch Trigger - Manual Workflow Dispatch Trigger
This job is responsible for deploying to our release, cloud kubernetes environment. You must run the release job first, to ensure that the latest images have been built and pushed to docker hub. After kicking off this job, the following will occur: This job is responsible for deploying to our release, cloud kubernetes environment. You must run the release job first, to ensure that the latest images have been built and pushed to docker hub. After kicking off this job, the following will occur:
- Checks out the release branch - Checks out the release branch
- Pulls the latest `values.yaml` from budibase infra, a private repo containing budibases infrastructure configuration - Pulls the latest `values.yaml` from budibase infra, a private repo containing budibases infrastructure configuration
- Gets the latest budibase version from `lerna.json`, if it hasn't been specified in the workflow when you kicked it off - Gets the latest budibase version from `lerna.json`, if it hasn't been specified in the workflow when you kicked it off
- Configures AWS Credentials - Configures AWS Credentials
- Deploys the helm chart in the budibase repo to our preproduction EKS cluster, injecting the `values.yaml` we pulled from budibase-infra - Deploys the helm chart in the budibase repo to our preproduction EKS cluster, injecting the `values.yaml` we pulled from budibase-infra
- Fires off a discord webhook in the #infra channel to show that the deployment completely successfully. - Fires off a discord webhook in the #infra channel to show that the deployment completely successfully.
### Deploy Preprod (deploy-preprod.yml) ### Deploy Preprod (deploy-preprod.yml)
Triggers: Triggers:
- Manual Workflow Dispatch Trigger - Manual Workflow Dispatch Trigger
This job is responsible for deploying to our preprod, cloud kubernetes environment. You must run the release job first, to ensure that the latest images have been built and pushed to docker hub. After kicking off this job, the following will occur: This job is responsible for deploying to our preprod, cloud kubernetes environment. You must run the release job first, to ensure that the latest images have been built and pushed to docker hub. After kicking off this job, the following will occur:
- Checks out the master branch - Checks out the master branch
- Pulls the latest `values.yaml` from budibase infra, a private repo containing budibases infrastructure configuration - Pulls the latest `values.yaml` from budibase infra, a private repo containing budibases infrastructure configuration
- Gets the latest budibase version from `lerna.json`, if it hasn't been specified in the workflow when you kicked it off - Gets the latest budibase version from `lerna.json`, if it hasn't been specified in the workflow when you kicked it off
- Configures AWS Credentials - Configures AWS Credentials
- Deploys the helm chart in the budibase repo to our preprod EKS cluster, injecting the `values.yaml` we pulled from budibase-infra - Deploys the helm chart in the budibase repo to our preprod EKS cluster, injecting the `values.yaml` we pulled from budibase-infra
- Fires off a discord webhook in the #infra channel to show that the deployment completely successfully. - Fires off a discord webhook in the #infra channel to show that the deployment completely successfully.
### Deploy Production (deploy-cloud.yml) ### Deploy Production (deploy-cloud.yml)
Triggers: Triggers:
- Manual Workflow Dispatch Trigger - Manual Workflow Dispatch Trigger
This job is responsible for deploying to our production, cloud kubernetes environment. You must run the release job first, to ensure that the latest images have been built and pushed to docker hub. You can also manually enter a version number for this job, so you can perform rollbacks or upgrade to a specific version. After kicking off this job, the following will occur: This job is responsible for deploying to our production, cloud kubernetes environment. You must run the release job first, to ensure that the latest images have been built and pushed to docker hub. You can also manually enter a version number for this job, so you can perform rollbacks or upgrade to a specific version. After kicking off this job, the following will occur:
- Checks out the master branch - Checks out the master branch
- Pulls the latest `values.yaml` from budibase infra, a private repo containing budibases infrastructure configuration - Pulls the latest `values.yaml` from budibase infra, a private repo containing budibases infrastructure configuration
- Gets the latest budibase version from `lerna.json`, if it hasn't been specified in the workflow when you kicked it off - Gets the latest budibase version from `lerna.json`, if it hasn't been specified in the workflow when you kicked it off
- Configures AWS Credentials - Configures AWS Credentials
- Deploys the helm chart in the budibase repo to our production EKS cluster, injecting the `values.yaml` we pulled from budibase-infra - Deploys the helm chart in the budibase repo to our production EKS cluster, injecting the `values.yaml` we pulled from budibase-infra
- Fires off a discord webhook in the #infra channel to show that the deployment completely successfully. - Fires off a discord webhook in the #infra channel to show that the deployment completely successfully.
## Common Workflows ## Common Workflows
### Deploy Changes to Production (Release) ### Deploy Changes to Production (Release)
- Merge `develop` into `master` - Merge `develop` into `master`
- Wait for budibase CI job and release job to run - Wait for budibase CI job and release job to run
- Run cloud deploy job - Run cloud deploy job
- Run release selfhost job - Run release selfhost job
### Deploy Changes to Production (Hotfix) ### Deploy Changes to Production (Hotfix)
- Branch off `master` - Branch off `master`
- Perform your hotfix - Perform your hotfix
- Merge back into `master` - Merge back into `master`
@ -113,79 +133,7 @@ This job is responsible for deploying to our production, cloud kubernetes enviro
- Run release selfhost job - Run release selfhost job
### 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

@ -214,6 +214,7 @@ jobs:
with: with:
submodules: true submodules: true
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
@ -251,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

@ -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
.gitignore vendored
View File

@ -97,8 +97,6 @@ typings/
bin/ bin/
hosting/.generated* hosting/.generated*
packages/builder/cypress.env.json
packages/builder/cypress/reports
stats.html stats.html

View File

@ -9,6 +9,4 @@ packages/backend-core/coverage
packages/server/client packages/server/client
packages/server/src/definitions/openapi.ts packages/server/src/definitions/openapi.ts
packages/builder/.routify packages/builder/.routify
packages/builder/cypress/support/queryLevelTransformerFunction.js
packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js
packages/sdk/sdk packages/sdk/sdk

View File

@ -1,4 +1,5 @@
{{- if .Values.globals.createSecrets -}} {{- $existingSecret := lookup "v1" "Secret" .Release.Namespace (include "budibase.fullname" .) }}
{{- if .Values.globals.createSecrets }}
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@ -10,8 +11,15 @@ metadata:
heritage: "{{ .Release.Service }}" heritage: "{{ .Release.Service }}"
type: Opaque type: Opaque
data: data:
{{- if $existingSecret }}
internalApiKey: {{ index $existingSecret.data "internalApiKey" }}
jwtSecret: {{ index $existingSecret.data "jwtSecret" }}
objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" }}
objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" }}
{{- else }}
internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }} internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }}
jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }} jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }}
objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }} objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }}
objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }} objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }}
{{- end -}} {{- end }}
{{- end }}

View File

@ -264,16 +264,14 @@ Sometimes, things go wrong. This can be due to incompatible updates on the budib
### Running tests ### Running tests
#### End-to-end Tests #### Unit Tests
Budibase uses Cypress to run a number of E2E tests. To run the tests execute the following command in the root folder: Budibase uses Jest to run a number of tests. To run the tests execute the following command in the root folder:
``` ```
yarn test:e2e yarn test
``` ```
Or if you are in the builder you can run `yarn cy:test`.
### Other Useful Information ### Other Useful Information
- The contributors are listed in [AUTHORS.md](https://github.com/Budibase/budibase/blob/master/.github/AUTHORS.md) (add yourself). - The contributors are listed in [AUTHORS.md](https://github.com/Budibase/budibase/blob/master/.github/AUTHORS.md) (add yourself).

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

@ -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,5 +1,5 @@
{ {
"version": "2.9.33-alpha.3", "version": "2.9.40-alpha.8",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -9,7 +9,6 @@
"esbuild": "^0.18.17", "esbuild": "^0.18.17",
"esbuild-node-externals": "^1.8.0", "esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0", "eslint": "^8.44.0",
"eslint-plugin-cypress": "^2.11.3",
"husky": "^8.0.3", "husky": "^8.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kill-port": "^1.6.1", "kill-port": "^1.6.1",
@ -33,25 +32,22 @@
"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": "lerna run build --stream",
"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",
"dev": "yarn run kill-all && lerna run --stream dev:builder", "dev": "yarn run kill-all && lerna run --parallel prebuild && lerna run --stream dev:builder",
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream dev:builder --scope @budibase/worker --scope @budibase/server", "dev:server": "yarn run kill-server && lerna run --stream dev:builder --scope @budibase/worker --scope @budibase/server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
@ -93,9 +89,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": [

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",
@ -68,8 +68,8 @@
"@types/jest": "29.5.3", "@types/jest": "29.5.3",
"@types/koa": "2.13.4", "@types/koa": "2.13.4",
"@types/lodash": "4.14.180", "@types/lodash": "4.14.180",
"@types/node": "14.18.20", "@types/node": "18.17.0",
"@types/node-fetch": "2.6.1", "@types/node-fetch": "2.6.4",
"@types/pouchdb": "6.4.0", "@types/pouchdb": "6.4.0",
"@types/redlock": "4.0.3", "@types/redlock": "4.0.3",
"@types/semver": "7.3.7", "@types/semver": "7.3.7",

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

@ -55,7 +55,7 @@ export async function getAppMetadata(appId: string): Promise<App | DeletedApp> {
throw err throw err
} }
} }
// needed for cypress/some scenarios where the caching happens // needed for some scenarios where the caching happens
// so quickly the requests can get slightly out of sync // so quickly the requests can get slightly out of sync
// might store its invalid just before it stores its valid // might store its invalid just before it stores its valid
if (isInvalid(metadata)) { if (isInvalid(metadata)) {

View File

@ -22,6 +22,8 @@ export enum Header {
TENANT_ID = "x-budibase-tenant-id", TENANT_ID = "x-budibase-tenant-id",
VERIFICATION_CODE = "x-budibase-verification-code", VERIFICATION_CODE = "x-budibase-verification-code",
RETURN_VERIFICATION_CODE = "x-budibase-return-verification-code", RETURN_VERIFICATION_CODE = "x-budibase-return-verification-code",
RESET_PASSWORD_CODE = "x-budibase-reset-password-code",
RETURN_RESET_PASSWORD_CODE = "x-budibase-return-reset-password-code",
TOKEN = "x-budibase-token", TOKEN = "x-budibase-token",
CSRF_TOKEN = "x-csrf-token", CSRF_TOKEN = "x-csrf-token",
CORRELATION_ID = "x-budibase-correlation-id", CORRELATION_ID = "x-budibase-correlation-id",

View File

@ -8,7 +8,6 @@ import {
DatabasePutOpts, DatabasePutOpts,
DatabaseCreateIndexOpts, DatabaseCreateIndexOpts,
DatabaseDeleteIndexOpts, DatabaseDeleteIndexOpts,
DocExistsResponse,
Document, Document,
isDocument, isDocument,
} from "@budibase/types" } from "@budibase/types"
@ -121,19 +120,6 @@ export class DatabaseImpl implements Database {
return this.updateOutput(() => db.get(id)) return this.updateOutput(() => db.get(id))
} }
async docExists(docId: string): Promise<DocExistsResponse> {
const db = await this.checkSetup()
let _rev, exists
try {
const { etag } = await db.head(docId)
_rev = etag
exists = true
} catch (err) {
exists = false
}
return { _rev, exists }
}
async remove(idOrDoc: string | Document, rev?: string) { async remove(idOrDoc: string | Document, rev?: string) {
const db = await this.checkSetup() const db = await this.checkSetup()
let _id: string let _id: string

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

@ -2,15 +2,15 @@ import { existsSync, readFileSync } from "fs"
import { ServiceType } from "@budibase/types" import { ServiceType } from "@budibase/types"
function isTest() { function isTest() {
return isCypress() || isJest() return isJest()
} }
function isJest() { function isJest() {
return !!(process.env.NODE_ENV === "jest" || process.env.JEST_WORKER_ID) return (
} process.env.NODE_ENV === "jest" ||
(process.env.JEST_WORKER_ID != null &&
function isCypress() { process.env.JEST_WORKER_ID !== "null")
return process.env.NODE_ENV === "cypress" )
} }
function isDev() { function isDev() {

View File

@ -87,6 +87,7 @@ export const BUILTIN_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.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
POWER: { POWER: {
@ -97,6 +98,7 @@ export const BUILTIN_PERMISSIONS = {
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.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
ADMIN: { ADMIN: {
@ -108,6 +110,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), new Permission(PermissionType.AUTOMATION, 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

@ -18,6 +18,7 @@ export default function positionDropdown(element, opts) {
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
offsetBelow,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -47,7 +48,7 @@ export default function positionDropdown(element, opts) {
styles.top = anchorBounds.top - elementBounds.height - offset styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240 styles.maxHeight = maxHeight || 240
} else { } else {
styles.top = anchorBounds.bottom + offset styles.top = anchorBounds.bottom + (offsetBelow || offset)
styles.maxHeight = styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20 maxHeight || window.innerHeight - anchorBounds.bottom - 20
} }

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

@ -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,6 +19,7 @@
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
@ -89,6 +90,7 @@
maxWidth, maxWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
offsetBelow,
customUpdate: handlePostionUpdate, customUpdate: handlePostionUpdate,
}} }}
use:clickOutside={{ use:clickOutside={{

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

@ -5,6 +5,4 @@ package-lock.json
release/ release/
dist/ dist/
routify routify
cypress/videos
cypress/screenshots
.routify/ .routify/

View File

@ -43,21 +43,10 @@
"/node_modules/(?!svelte).+\\.js$", "/node_modules/(?!svelte).+\\.js$",
".*string-templates.*" ".*string-templates.*"
], ],
"modulePathIgnorePatterns": [
"<rootDir>/cypress/"
],
"setupFilesAfterEnv": [ "setupFilesAfterEnv": [
"@testing-library/jest-dom/extend-expect" "@testing-library/jest-dom/extend-expect"
] ]
}, },
"eslintConfig": {
"extends": [
"plugin:cypress/recommended"
],
"rules": {
"cypress/no-unnecessary-waiting": "off"
}
},
"dependencies": { "dependencies": {
"@budibase/bbui": "0.0.0", "@budibase/bbui": "0.0.0",
"@budibase/frontend-core": "0.0.0", "@budibase/frontend-core": "0.0.0",
@ -104,9 +93,6 @@
"@testing-library/jest-dom": "5.17.0", "@testing-library/jest-dom": "5.17.0",
"@testing-library/svelte": "^3.2.2", "@testing-library/svelte": "^3.2.2",
"babel-jest": "29.6.2", "babel-jest": "29.6.2",
"cypress": "^9.3.1",
"cypress-multi-reporters": "^1.6.0",
"cypress-terminal-report": "^1.4.1",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "29.6.2", "jest": "29.6.2",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",

View File

@ -351,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
} }
} }
@ -464,7 +471,7 @@ const getComponentBindingCategory = (component, context, def) => {
*/ */
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")
@ -725,17 +732,25 @@ export const getActionBindings = (actions, actionId) => {
} }
/** /**
* 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)
} }
/** /**
@ -812,9 +827,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)
@ -860,12 +887,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) {
@ -875,7 +902,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
@ -939,7 +966,9 @@ export const buildFormSchema = (component, asset) => {
const patched = convertOldFieldFormat(component.fields || []) const patched = convertOldFieldFormat(component.fields || [])
patched?.forEach(({ field, active }) => { patched?.forEach(({ field, active }) => {
if (!active) return if (!active) return
schema[field] = { type: info?.schema[field].type } if (info?.schema[field]) {
schema[field] = { type: info?.schema[field].type }
}
}) })
} }

View File

@ -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") {
@ -1245,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"

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

@ -10,6 +10,7 @@
import ManageAccessButton from "./buttons/ManageAccessButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte" import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { ROW_EXPORT_FORMATS } from "constants/backend"
export let view = {} export let view = {}
@ -19,6 +20,14 @@
let type = "internal" let type = "internal"
$: name = view.name $: name = view.name
$: calculation = view.calculation
$: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
if (calculation && key === ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA) {
return false
}
return true
})
// Fetch rows for specified view // Fetch rows for specified view
$: fetchViewData(name, view.field, view.groupBy, view.calculation) $: fetchViewData(name, view.field, view.groupBy, view.calculation)
@ -68,5 +77,5 @@
{/if} {/if}
<ManageAccessButton resourceId={decodeURI(name)} /> <ManageAccessButton resourceId={decodeURI(name)} />
<HideAutocolumnButton bind:hideAutocolumns /> <HideAutocolumnButton bind:hideAutocolumns />
<ExportButton view={view.name} /> <ExportButton view={view.name} formats={supportedFormats} />
</Table> </Table>

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

@ -7,6 +7,7 @@
export let sorting export let sorting
export let disabled = false export let disabled = false
export let selectedRows export let selectedRows
export let formats
let modal let modal
</script> </script>
@ -15,5 +16,5 @@
Export Export
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ExportModal {view} {filters} {sorting} {selectedRows} /> <ExportModal {view} {filters} {sorting} {selectedRows} {formats} />
</Modal> </Modal>

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

@ -55,7 +55,7 @@
let linkEditDisabled let linkEditDisabled
let primaryDisplay let primaryDisplay
let indexes = [...($tables.selected.indexes || [])] let indexes = [...($tables.selected.indexes || [])]
let isCreating let isCreating = undefined
let table = $tables.selected let table = $tables.selected
let confirmDeleteDialog let confirmDeleteDialog
@ -75,11 +75,11 @@
} }
const initialiseField = (field, savingColumn) => { const initialiseField = (field, savingColumn) => {
isCreating = !field
if (field && !savingColumn) { if (field && !savingColumn) {
editableColumn = cloneDeep(field) editableColumn = cloneDeep(field)
originalName = editableColumn.name ? editableColumn.name + "" : null originalName = editableColumn.name ? editableColumn.name + "" : null
linkEditDisabled = originalName != null linkEditDisabled = originalName != null
isCreating = originalName == null
primaryDisplay = primaryDisplay =
$tables.selected.primaryDisplay == null || $tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name $tables.selected.primaryDisplay === editableColumn.name
@ -584,6 +584,7 @@
{ label: "Dynamic", value: "dynamic" }, { label: "Dynamic", value: "dynamic" },
{ label: "Static", value: "static" }, { label: "Static", value: "static" },
]} ]}
disabled={!isCreating}
getOptionLabel={option => option.label} getOptionLabel={option => option.label}
getOptionValue={option => option.value} getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by, tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by,

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

@ -9,30 +9,43 @@
import download from "downloadjs" import download from "downloadjs"
import { API } from "api" import { API } from "api"
import { Constants, LuceneUtils } from "@budibase/frontend-core" import { Constants, LuceneUtils } from "@budibase/frontend-core"
import { ROW_EXPORT_FORMATS } from "constants/backend"
const FORMATS = [
{
name: "CSV",
key: "csv",
},
{
name: "JSON",
key: "json",
},
{
name: "JSON with Schema",
key: "jsonWithSchema",
},
]
export let view export let view
export let filters export let filters
export let sorting export let sorting
export let selectedRows = [] export let selectedRows = []
export let formats
let exportFormat = FORMATS[0].key const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
let exportFormat
let filterLookup let filterLookup
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
$: luceneFilter = LuceneUtils.buildLuceneQuery(filters) $: luceneFilter = LuceneUtils.buildLuceneQuery(filters)
$: exportOpDisplay = buildExportOpDisplay(sorting, filterDisplay, filters) $: exportOpDisplay = buildExportOpDisplay(sorting, filterDisplay, filters)
@ -190,7 +203,7 @@
<Select <Select
label="Format" label="Format"
bind:value={exportFormat} bind:value={exportFormat}
options={FORMATS} {options}
placeholder={null} placeholder={null}
getOptionLabel={x => x.name} getOptionLabel={x => x.name}
getOptionValue={x => x.key} getOptionValue={x => x.key}

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",

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

@ -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

@ -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

@ -33,17 +33,19 @@
let anchors = {} let anchors = {}
let draggableItems = [] let draggableItems = []
const buildDragable = items => { const buildDraggable = items => {
return items.map(item => { return items
return { .map(item => {
id: listItemKey ? item[listItemKey] : generate(), return {
item, id: listItemKey ? item[listItemKey] : generate(),
} item,
}) }
})
.filter(item => item.id)
} }
$: if (items) { $: if (items) {
draggableItems = buildDragable(items) draggableItems = buildDraggable(items)
} }
const updateRowOrder = e => { const updateRowOrder = e => {

View File

@ -21,6 +21,9 @@
let fieldList let fieldList
let schema let schema
let cachedValue let cachedValue
let options
let sanitisedValue
let unconfigured
$: bindings = getBindableProperties($selectedScreen, componentInstance._id) $: bindings = getBindableProperties($selectedScreen, componentInstance._id)
$: actionType = componentInstance.actionType $: actionType = componentInstance.actionType
@ -34,16 +37,24 @@
} }
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) $: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: resourceId = datasource.resourceId || datasource.tableId
$: if (!isEqual(value, cachedValue)) { $: if (!isEqual(value, cachedValue)) {
cachedValue = value cachedValue = cloneDeep(value)
schema = getSchema($currentAsset, datasource)
} }
$: options = Object.keys(schema || {}) const updateState = value => {
$: sanitisedValue = getValidColumns(convertOldFieldFormat(value), options) schema = getSchema($currentAsset, datasource)
$: updateSanitsedFields(sanitisedValue) options = Object.keys(schema || {})
$: unconfigured = buildUnconfiguredOptions(schema, sanitisedFields) 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 // Builds unused ones only
const buildUnconfiguredOptions = (schema, selected) => { const buildUnconfiguredOptions = (schema, selected) => {
@ -97,8 +108,10 @@
if (instance._component) { if (instance._component) {
return instance return instance
} }
const type = getComponentForField(instance.field, schema) const type = getComponentForField(instance.field, schema)
if (!type) {
return null
}
instance._component = `@budibase/standard-components/${type}` instance._component = `@budibase/standard-components/${type}`
const pseudoComponentInstance = store.actions.components.createInstance( const pseudoComponentInstance = store.actions.components.createInstance(
@ -115,10 +128,6 @@
return { ...instance, ...pseudoComponentInstance } return { ...instance, ...pseudoComponentInstance }
} }
$: if (sanitisedFields) {
fieldList = [...sanitisedFields, ...unconfigured].map(buildSudoInstance)
}
const processItemUpdate = e => { const processItemUpdate = e => {
const updatedField = e.detail const updatedField = e.detail
const parentFieldsUpdated = fieldList ? cloneDeep(fieldList) : [] const parentFieldsUpdated = fieldList ? cloneDeep(fieldList) : []

View File

@ -1,28 +1,48 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore } from "stores/backend" import { tables as tablesStore, viewsV2 } from "stores/backend"
export let value export let value
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(m => ({ $: tables = $tablesStore.list.map(table => ({
label: m.name,
tableId: m._id,
type: "table", type: "table",
label: table.name,
tableId: table._id,
resourceId: table._id,
})) }))
$: views = $viewsV2.list.map(view => ({
type: "viewV2",
id: view.id,
label: view.name,
tableId: view.tableId,
resourceId: view.id,
}))
$: options = [...(tables || []), ...(views || [])]
const onChange = e => { const onChange = e => {
const dataSource = tables?.find(x => x.tableId === e.detail) dispatch(
dispatch("change", dataSource) "change",
options.find(x => x.resourceId === e.detail)
)
} }
onMount(() => {
// Migrate old values before "resourceId" existed
if (value && !value.resourceId) {
const view = views.find(x => x.resourceId === value.id)
const table = tables.find(x => x.resourceId === value.tableId)
dispatch("change", view || table)
}
})
</script> </script>
<Select <Select
on:change={onChange} on:change={onChange}
value={value?.tableId} value={value?.resourceId}
options={tables} {options}
getOptionValue={x => x.tableId} getOptionValue={x => x.resourceId}
getOptionLabel={x => x.label} getOptionLabel={x => x.label}
/> />

View File

@ -40,7 +40,7 @@
return return
} }
try { try {
roleId = (await permissions.forResource(queryToFetch._id))["read"] roleId = (await permissions.forResource(queryToFetch._id))["read"].role
} catch (err) { } catch (err) {
roleId = Constants.Roles.BASIC roleId = Constants.Roles.BASIC
} }

View File

@ -287,3 +287,9 @@ export const DatasourceTypes = {
GRAPH: "Graph", GRAPH: "Graph",
API: "API", API: "API",
} }
export const ROW_EXPORT_FORMATS = {
CSV: "csv",
JSON: "json",
JSON_WITH_SCHEMA: "jsonWithSchema",
}

View File

@ -26,6 +26,9 @@ export const capitalise = s => {
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1) export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = s =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = s => (!s ? "" : last(s.split("/"))) export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise]) export const get_capitalised_name = name => pipe(name, [get_name, capitalise])

View File

@ -1,18 +1,27 @@
<script> <script>
import { import {
Icon, Icon,
Divider,
Heading, Heading,
Layout, Layout,
Input, Input,
clickOutside, clickOutside,
notifications, notifications,
ActionButton,
CopyInput, CopyInput,
Modal, Modal,
FancyForm,
FancyInput,
Button,
FancySelect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" import { groups, licensing, apps, users, auth, admin } from "stores/portal"
import { fetchData, Constants, Utils } from "@budibase/frontend-core" import {
fetchData,
Constants,
Utils,
RoleUtils,
} from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte" import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
@ -26,10 +35,15 @@
let loaded = false let loaded = false
let inviting = false let inviting = false
let searchFocus = false let searchFocus = false
let invitingFlow = false
// Initially filter entities without app access // Initially filter entities without app access
// Show all when false // Show all when false
let filterByAppAccess = true let filterByAppAccess = false
let email
let error
let form
let creationRoleType = Constants.BudibaseRoles.AppUser
let creationAccessType = Constants.Roles.BASIC
let appInvites = [] let appInvites = []
let filteredInvites = [] let filteredInvites = []
@ -40,8 +54,7 @@
let userLimitReachedModal let userLimitReachedModal
let inviteFailureResponse = "" let inviteFailureResponse = ""
$: validEmail = emailValidator(email) === true
$: queryIsEmail = emailValidator(query) === true
$: prodAppId = apps.getProdAppID($store.appId) $: prodAppId = apps.getProdAppID($store.appId)
$: promptInvite = showInvite( $: promptInvite = showInvite(
filteredInvites, filteredInvites,
@ -50,7 +63,6 @@
query query
) )
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
const showInvite = (invites, users, groups, query) => { const showInvite = (invites, users, groups, query) => {
return !invites?.length && !users?.length && !groups?.length && query return !invites?.length && !users?.length && !groups?.length && query
} }
@ -66,9 +78,9 @@
if (!filterByAppAccess && !query) { if (!filterByAppAccess && !query) {
filteredInvites = filteredInvites =
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites] appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
filteredInvites.sort(sortInviteRoles)
return return
} }
filteredInvites = appInvites.filter(invite => { filteredInvites = appInvites.filter(invite => {
const inviteInfo = invite.info?.apps const inviteInfo = invite.info?.apps
if (!query && inviteInfo && prodAppId) { if (!query && inviteInfo && prodAppId) {
@ -76,8 +88,8 @@
} }
return invite.email.includes(query) return invite.email.includes(query)
}) })
filteredInvites.sort(sortInviteRoles)
} }
$: filterByAppAccess, prodAppId, filterInvites(query) $: filterByAppAccess, prodAppId, filterInvites(query)
$: if (searchFocus === true) { $: if (searchFocus === true) {
filterByAppAccess = false filterByAppAccess = false
@ -107,24 +119,63 @@
}) })
await usersFetch.refresh() await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => { filteredUsers = $usersFetch.rows
const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId) .filter(user => user.email !== $auth.user.email)
let role = undefined .map(user => {
if (isAdminOrBuilder) { const isAdminOrGlobalBuilder = sdk.users.isAdminOrGlobalBuilder(
role = Constants.Roles.ADMIN user,
} else { prodAppId
const appRole = Object.keys(user.roles).find(x => x === prodAppId) )
if (appRole) { const isAppBuilder = sdk.users.hasAppBuilderPermissions(user, prodAppId)
role = user.roles[appRole] let role
if (isAdminOrGlobalBuilder) {
role = Constants.Roles.ADMIN
} else if (isAppBuilder) {
role = Constants.Roles.CREATOR
} else {
const appRole = user.roles[prodAppId]
if (appRole) {
role = appRole
}
} }
}
return { return {
...user, ...user,
role, role,
isAdminOrBuilder, isAdminOrGlobalBuilder,
} isAppBuilder,
}) }
})
.sort(sortRoles)
}
const sortInviteRoles = (a, b) => {
const aAppsEmpty = !a.info?.apps?.length && !a.info?.builder?.apps?.length
const bAppsEmpty = !b.info?.apps?.length && !b.info?.builder?.apps?.length
return aAppsEmpty && !bAppsEmpty ? 1 : !aAppsEmpty && bAppsEmpty ? -1 : 0
}
const sortRoles = (a, b) => {
const roleA = a.role
const roleB = b.role
const priorityA = RoleUtils.getRolePriority(roleA)
const priorityB = RoleUtils.getRolePriority(roleB)
if (roleA === undefined && roleB !== undefined) {
return 1
} else if (roleA !== undefined && roleB === undefined) {
return -1
}
if (priorityA < priorityB) {
return 1
} else if (priorityA > priorityB) {
return -1
}
return 0
} }
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250) const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
@ -160,6 +211,12 @@
if (user.role === role) { if (user.role === role) {
return return
} }
if (user.isAppBuilder) {
await removeAppBuilder(user._id, prodAppId)
}
if (role === Constants.Roles.CREATOR) {
await removeAppBuilder(user._id, prodAppId)
}
await updateAppUser(user, role) await updateAppUser(user, role)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -189,6 +246,9 @@
return return
} }
try { try {
if (group?.builder?.apps.includes(prodAppId)) {
await removeGroupAppBuilder(group._id)
}
await updateAppGroup(group, role) await updateAppGroup(group, role)
} catch { } catch {
notifications.error("Group update failed") notifications.error("Group update failed")
@ -225,14 +285,17 @@
return nameMatch return nameMatch
}) })
.map(enrichGroupRole) .map(enrichGroupRole)
.sort(sortRoles)
} }
const enrichGroupRole = group => { const enrichGroupRole = group => {
return { return {
...group, ...group,
role: group.roles?.[ role: group?.builder?.apps.includes(prodAppId)
groups.actions.getGroupAppIds(group).find(x => x === prodAppId) ? Constants.Roles.CREATOR
], : group.roles?.[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
],
} }
} }
@ -245,8 +308,7 @@
$: filteredGroups = searchGroups(enrichedGroups, query) $: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers) $: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers] $: allUsers = [...filteredUsers, ...groupUsers]
/*
/*
Create pseudo users from the "users" attribute on app groups. Create pseudo users from the "users" attribute on app groups.
These users will appear muted in the UI and show the ROLE These users will appear muted in the UI and show the ROLE
inherited from their parent group. The users allow assigning of user inherited from their parent group. The users allow assigning of user
@ -291,21 +353,29 @@
} }
async function inviteUser() { async function inviteUser() {
if (!queryIsEmail) { if (!validEmail) {
notifications.error("Email is not valid") notifications.error("Email is not valid")
return return
} }
const newUserEmail = query + "" const newUserEmail = email + ""
inviting = true inviting = true
const payload = [ const payload = [
{ {
email: newUserEmail, email: newUserEmail,
builder: false, builder: { global: creationRoleType === Constants.BudibaseRoles.Admin },
admin: false, admin: { global: creationRoleType === Constants.BudibaseRoles.Admin },
apps: { [prodAppId]: Constants.Roles.BASIC },
}, },
] ]
const notCreatingAdmin = creationRoleType !== Constants.BudibaseRoles.Admin
const isCreator = creationAccessType === Constants.Roles.CREATOR
if (notCreatingAdmin && isCreator) {
payload[0].builder.apps = [prodAppId]
} else if (notCreatingAdmin && !isCreator) {
payload[0].apps = { [prodAppId]: creationAccessType }
}
let userInviteResponse let userInviteResponse
try { try {
userInviteResponse = await users.onboard(payload) userInviteResponse = await users.onboard(payload)
@ -317,16 +387,23 @@
return userInviteResponse return userInviteResponse
} }
const openInviteFlow = () => {
$licensing.userLimitReached
? userLimitReachedModal.show()
: (invitingFlow = true)
}
const onInviteUser = async () => { const onInviteUser = async () => {
form.validate()
userOnboardResponse = await inviteUser() userOnboardResponse = await inviteUser()
const originalQuery = query + "" const originalQuery = email + ""
query = null email = null
const newUser = userOnboardResponse?.successful.find( const newUser = userOnboardResponse?.successful.find(
user => user.email === originalQuery user => user.email === originalQuery
) )
if (newUser) { if (newUser) {
query = originalQuery email = originalQuery
notifications.success( notifications.success(
userOnboardResponse.created userOnboardResponse.created
? "User created successfully" ? "User created successfully"
@ -344,16 +421,28 @@
notifications.error(inviteFailureResponse) notifications.error(inviteFailureResponse)
} }
userOnboardResponse = null userOnboardResponse = null
invitingFlow = false
// trigger reload of the users
query = ""
} }
const onUpdateUserInvite = async (invite, role) => { const onUpdateUserInvite = async (invite, role) => {
await users.updateInvite({ let updateBody = {
code: invite.code, code: invite.code,
apps: { apps: {
...invite.apps, ...invite.apps,
[prodAppId]: role, [prodAppId]: role,
}, },
}) }
if (role === Constants.Roles.CREATOR) {
updateBody.builder = updateBody.builder || {}
updateBody.builder.apps = [...(updateBody.builder.apps ?? []), prodAppId]
delete updateBody?.apps?.[prodAppId]
} else if (role !== Constants.Roles.CREATOR && invite?.builder?.apps) {
invite.builder.apps = []
}
await users.updateInvite(updateBody)
await filterInvites(query) await filterInvites(query)
} }
@ -373,6 +462,22 @@
}) })
} }
const addAppBuilder = async userId => {
await users.addAppBuilder(userId, prodAppId)
}
const removeAppBuilder = async userId => {
await users.removeAppBuilder(userId, prodAppId)
}
const addGroupAppBuilder = async groupId => {
await groups.actions.addGroupAppBuilder(groupId, prodAppId)
}
const removeGroupAppBuilder = async groupId => {
await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
}
const initSidePanel = async sidePaneOpen => { const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) { if (sidePaneOpen === true) {
await groups.actions.init() await groups.actions.init()
@ -383,19 +488,21 @@
$: initSidePanel($store.builderSidePanel) $: initSidePanel($store.builderSidePanel)
function handleKeyDown(evt) { function handleKeyDown(evt) {
if (evt.key === "Enter" && queryIsEmail && !inviting) { if (evt.key === "Enter" && validEmail && !inviting) {
onInviteUser() onInviteUser()
} }
} }
const userTitle = user => { const getInviteRoleValue = invite => {
if (sdk.users.isAdmin(user)) { if (invite.info?.admin?.global && invite.info?.builder?.global) {
return "Admin" return Constants.Roles.ADMIN
} else if (sdk.users.isBuilder(user, prodAppId)) {
return "Developer"
} else {
return "App user"
} }
if (invite.info?.builder?.apps?.includes(prodAppId)) {
return Constants.Roles.CREATOR
}
return invite.info.apps?.[prodAppId]
} }
const getRoleFooter = user => { const getRoleFooter = user => {
@ -403,7 +510,7 @@
const role = $roles.find(role => role._id === user.role) const role = $roles.find(role => role._id === user.role)
return `This user has been given ${role?.name} access from the ${user.group} group` return `This user has been given ${role?.name} access from the ${user.group} group`
} }
if (user.isAdminOrBuilder) { if (user.isAdminOrGlobalBuilder) {
return "This user's role grants admin access to all apps" return "This user's role grants admin access to all apps"
} }
return null return null
@ -423,227 +530,309 @@
}} }}
> >
<div class="builder-side-panel-header"> <div class="builder-side-panel-header">
<Heading size="S">Users</Heading> <div
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => { on:click={() => {
store.update(state => { invitingFlow = false
state.builderSidePanel = false
return state
})
}}
/>
</div>
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
placeholder={"Add users and groups to your app"}
autocomplete="off"
disabled={inviting}
value={query}
on:input={e => {
query = e.target.value.trim()
}}
on:focus={() => (searchFocus = true)}
on:blur={() => (searchFocus = false)}
/>
</span>
<span
class="search-input-icon"
class:searching={query || !filterByAppAccess}
on:click={() => {
if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) {
return
}
query = null
userOnboardResponse = null
filterByAppAccess = true
}} }}
class="header"
> >
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} /> {#if invitingFlow}
</span> <Icon name="BackAndroid" />
{/if}
<Heading size="S">{invitingFlow ? "Invite new user" : "Users"}</Heading>
</div>
<div class="header">
{#if !invitingFlow}
<Button on:click={openInviteFlow} size="S" cta>Invite user</Button>
{/if}
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => {
store.update(state => {
state.builderSidePanel = false
return state
})
}}
/>
</div>
</div> </div>
{#if !invitingFlow}
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
placeholder={"Add users and groups to your app"}
autocomplete="off"
disabled={inviting}
value={query}
on:input={e => {
query = e.target.value.trim()
}}
on:focus={() => (searchFocus = true)}
on:blur={() => (searchFocus = false)}
/>
</span>
<div class="body"> <span
{#if promptInvite && !userOnboardResponse} class="search-input-icon"
<Layout gap="S" paddingX="XL"> class:searching={query || !filterByAppAccess}
<div class="invite-header"> on:click={() => {
<Heading size="XS">No user found</Heading> if (!query) {
<div class="invite-directions"> return
Add a valid email to invite a new user }
</div> query = null
</div> userOnboardResponse = null
<div class="invite-form"> }}
<span>{query || ""}</span> >
<ActionButton <Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
icon="UserAdd" </span>
disabled={!queryIsEmail || inviting} </div>
on:click={$licensing.userLimitReached
? userLimitReachedModal.show
: onInviteUser}
>
Add user
</ActionButton>
</div>
</Layout>
{/if}
{#if !promptInvite} <div class="body">
<Layout gap="L" noPadding> {#if promptInvite && !userOnboardResponse}
{#if filteredInvites?.length} <Layout gap="S" paddingX="XL">
<Layout noPadding gap="XS"> <div class="invite-header">
<div class="auth-entity-header"> <Heading size="XS">No user found</Heading>
<div class="auth-entity-title">Pending invites</div> <div class="invite-directions">
<div class="auth-entity-access-title">Access</div> Try searching a different email or <span
</div> class="underlined"
{#each filteredInvites as invite} on:click={openInviteFlow}>invite a new user</span
<div class="auth-entity">
<div class="details">
<div class="user-email" title={invite.email}>
{invite.email}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={invite.info.apps?.[prodAppId]}
allowRemove={invite.info.apps?.[prodAppId]}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUserInvite(invite, e.detail)
}}
on:remove={() => {
onUninviteAppUser(invite)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if $licensing.groupsEnabled && filteredGroups?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Groups</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredGroups as group}
<div
class="auth-entity group"
on:click={() => {
if (selectedGroup != group._id) {
selectedGroup = group._id
} else {
selectedGroup = null
}
}}
on:keydown={() => {}}
> >
<div class="details">
<GroupIcon {group} size="S" />
<div>
{group.name}
</div>
<div class="auth-entity-meta">
{`${group.users?.length} user${
group.users?.length != 1 ? "s" : ""
}`}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={group.role}
allowRemove={group.role}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateGroup(group, e.detail)
}}
on:remove={() => {
onUpdateGroup(group)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if filteredUsers?.length}
<div class="auth-entity-section">
<div class="auth-entity-header">
<div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div>
</div> </div>
{#each allUsers as user}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={user.email}>
{user.email}
</div>
<div class="auth-entity-meta">
{userTitle(user)}
</div>
</div>
<div class="auth-entity-access" class:muted={user.group}>
<RoleSelect
footer={getRoleFooter(user)}
placeholder={false}
value={user.role}
allowRemove={user.role && !user.group}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUser(user, e.detail)
}}
on:remove={() => {
onUpdateUser(user)
}}
autoWidth
align="right"
allowedRoles={user.isAdminOrBuilder
? [Constants.Roles.ADMIN]
: null}
/>
</div>
</div>
{/each}
</div> </div>
{/if} </Layout>
</Layout> {/if}
{/if}
{#if userOnboardResponse?.created} {#if !promptInvite}
<Layout gap="S" paddingX="XL"> <Layout gap="L" noPadding>
<div class="invite-header"> {#if filteredInvites?.length}
<Heading size="XS">User added!</Heading> <Layout noPadding gap="XS">
<div class="invite-directions"> <div class="auth-entity-header">
Email invites are not available without SMTP configuration. Here is <div class="auth-entity-title">Pending invites</div>
the password that has been generated for this user. <div class="auth-entity-access-title">Access</div>
</div>
{#each filteredInvites as invite}
{@const user = {
isAdminOrGlobalBuilder:
invite.info?.admin?.global && invite.info?.builder?.global,
}}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={invite.email}>
{invite.email}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
footer={getRoleFooter(user)}
placeholder={false}
value={getInviteRoleValue(invite)}
allowRemove={invite.info.apps?.[prodAppId]}
allowPublic={false}
allowCreator={true}
quiet={true}
on:change={e => {
onUpdateUserInvite(invite, e.detail)
}}
on:remove={() => {
onUninviteAppUser(invite)
}}
autoWidth
align="right"
allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN]
: null}
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if $licensing.groupsEnabled && filteredGroups?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Groups</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredGroups as group}
<div
class="auth-entity group"
on:click={() => {
if (selectedGroup != group._id) {
selectedGroup = group._id
} else {
selectedGroup = null
}
}}
on:keydown={() => {}}
>
<div class="details">
<GroupIcon {group} size="S" />
<div>
{group.name}
</div>
<div class="auth-entity-meta">
{`${group.users?.length} user${
group.users?.length != 1 ? "s" : ""
}`}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={group.role}
allowRemove={group.role}
allowPublic={false}
quiet={true}
allowCreator={true}
on:change={e => {
if (e.detail === Constants.Roles.CREATOR) {
addGroupAppBuilder(group._id)
} else {
onUpdateGroup(group, e.detail)
}
}}
on:remove={() => {
onUpdateGroup(group)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if filteredUsers?.length}
<div class="auth-entity-section">
<div class="auth-entity-header">
<div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each allUsers as user}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={user.email}>
{user.email}
</div>
</div>
<div class="auth-entity-access" class:muted={user.group}>
<RoleSelect
footer={getRoleFooter(user)}
placeholder={false}
value={user.role}
allowRemove={user.role && !user.group}
allowPublic={false}
allowCreator={true}
quiet={true}
on:addcreator={() => {}}
on:change={e => {
if (e.detail === Constants.Roles.CREATOR) {
addAppBuilder(user._id)
} else {
onUpdateUser(user, e.detail)
}
}}
on:remove={() => {
onUpdateUser(user)
}}
autoWidth
align="right"
allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN]
: null}
/>
</div>
</div>
{/each}
</div>
{/if}
</Layout>
{/if}
{#if userOnboardResponse?.created}
<Layout gap="S" paddingX="XL">
<div class="invite-header">
<Heading size="XS">User added!</Heading>
<div class="invite-directions">
Email invites are not available without SMTP configuration. Here
is the password that has been generated for this user.
</div>
</div> </div>
</div> <div>
<div> <CopyInput
<CopyInput value={userOnboardResponse.successful[0]?.password}
value={userOnboardResponse.successful[0]?.password} label="Password"
label="Password" />
/> </div>
</Layout>
{/if}
</div>
{:else}
<Divider />
<div class="body">
<Layout gap="L" noPadding>
<div class="user-invite-form">
<FancyForm bind:this={form}>
<FancyInput
disabled={false}
label="Email"
value={email}
on:change={e => {
email = e.detail
}}
validate={() => {
if (!email) {
return "Please enter an email"
}
return null
}}
{error}
/>
<FancySelect
bind:value={creationRoleType}
options={sdk.users.isAdmin($auth.user)
? Constants.BudibaseRoleOptionsNew
: Constants.BudibaseRoleOptionsNew.filter(
option => option.value !== Constants.BudibaseRoles.Admin
)}
label="Role"
/>
{#if creationRoleType !== Constants.BudibaseRoles.Admin}
<RoleSelect
placeholder={false}
bind:value={creationAccessType}
allowPublic={false}
allowCreator={true}
quiet={true}
autoWidth
align="right"
fancySelect
/>
{/if}
</FancyForm>
{#if creationRoleType === Constants.BudibaseRoles.Admin}
<div class="admin-info">
<Icon name="Info" />
Admins will get full access to all apps and settings
</div>
{/if}
<span class="add-user">
<Button
newStyles
cta
disabled={!email?.length}
on:click={onInviteUser}>Add user</Button
>
</span>
</div> </div>
</Layout> </Layout>
{/if} </div>
</div> {/if}
<Modal bind:this={userLimitReachedModal}> <Modal bind:this={userLimitReachedModal}>
<UpgradeModal {isOwner} /> <UpgradeModal {isOwner} />
</Modal> </Modal>
@ -659,6 +848,27 @@
align-items: center; align-items: center;
} }
.add-user {
padding-top: var(--spacing-xl);
width: 100%;
display: grid;
}
.admin-info {
margin-top: var(--spacing-xl);
padding: var(--spacing-l) var(--spacing-l) var(--spacing-l) var(--spacing-l);
display: flex;
align-items: center;
gap: var(--spacing-xl);
height: 30px;
background-color: var(--background-alt);
}
.underlined {
text-decoration: underline;
cursor: pointer;
}
.search-input { .search-input {
flex: 1; flex: 1;
} }
@ -746,12 +956,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
.invite-form {
display: flex;
align-items: center;
justify-content: space-between;
}
#builder-side-panel-container .search { #builder-side-panel-container .search {
padding-top: var(--spacing-m); padding-top: var(--spacing-m);
padding-bottom: var(--spacing-m); padding-bottom: var(--spacing-m);
@ -798,6 +1002,16 @@
flex-direction: column; flex-direction: column;
} }
.header {
display: flex;
align-items: center;
gap: var(--spacing-l);
}
.user-invite-form {
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
}
.body { .body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,5 +1,5 @@
<script> <script>
import TableDataTable from "components/backend/DataTable/DataTable.svelte" import TableDataTable from "components/backend/DataTable/TableDataTable.svelte"
import { tables, database } from "stores/backend" import { tables, database } from "stores/backend"
import { Banner } from "@budibase/bbui" import { Banner } from "@budibase/bbui"

View File

@ -1,14 +1,17 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { views } from "stores/backend" import { views, viewsV2 } from "stores/backend"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
onMount(async () => { onMount(async () => {
const { list, selected } = $views if ($viewsV2.selected) {
if (selected) { $redirect(`./v2/${$viewsV2.selected.id}`)
$redirect(`./${encodeURIComponent(selected?.name)}`) } else if ($viewsV2.list?.length) {
} else if (list?.length) { $redirect(`./v2/${$viewsV2.list[0].id}`)
$redirect(`./${encodeURIComponent(list[0].name)}`) } else if ($views.selected) {
$redirect(`./${encodeURIComponent($views.selected?.name)}`)
} else if ($views.list?.length) {
$redirect(`./${encodeURIComponent($views.list[0].name)}`)
} else { } else {
$redirect("../") $redirect("../")
} }

View File

@ -5,15 +5,15 @@
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { store } from "builderStore" import { store } from "builderStore"
$: viewName = $views.selectedViewName $: name = $views.selectedViewName
$: store.actions.websocket.selectResource(viewName) $: store.actions.websocket.selectResource(name)
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "viewName", urlParam: "viewName",
stateKey: "selectedViewName", stateKey: "selectedViewName",
validate: name => $views.list?.some(view => view.name === name), validate: name => $views.list?.some(view => view.name === name),
update: views.select, update: views.select,
fallbackUrl: "../", fallbackUrl: "../../",
store: views, store: views,
routify, routify,
decode: decodeURIComponent, decode: decodeURIComponent,

View File

@ -0,0 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("../")
</script>

View File

@ -0,0 +1,25 @@
<script>
import { viewsV2 } from "stores/backend"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
import { store } from "builderStore"
$: id = $viewsV2.selectedViewId
$: store.actions.websocket.selectResource(id)
const stopSyncing = syncURLToState({
urlParam: "viewId",
stateKey: "selectedViewId",
validate: id => $viewsV2.list?.some(view => view.id === id),
update: viewsV2.select,
fallbackUrl: "../../",
store: viewsV2,
routify,
decode: decodeURIComponent,
})
onDestroy(stopSyncing)
</script>
<slot />

View File

@ -0,0 +1,5 @@
<script>
import ViewV2DataTable from "components/backend/DataTable/ViewV2DataTable.svelte"
</script>
<ViewV2DataTable />

View File

@ -0,0 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("../")
</script>

View File

@ -297,8 +297,12 @@
width: 100%; width: 100%;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
transition: background 130ms ease-out;
} }
.divider:hover { .divider:hover {
cursor: row-resize; cursor: row-resize;
} }
.divider:hover:after {
background: var(--spectrum-global-color-gray-300);
}
</style> </style>

View File

@ -110,7 +110,7 @@
if (mode === "table") { if (mode === "table") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
let templates = getTemplates($store, $tables.list) let templates = getTemplates($tables.list)
const blankScreenTemplate = templates.find( const blankScreenTemplate = templates.find(
t => t.id === "createFromScratch" t => t.id === "createFromScratch"
) )
@ -131,8 +131,7 @@
const completeDatasourceScreenCreation = async () => { const completeDatasourceScreenCreation = async () => {
const screens = selectedTemplates.map(template => { const screens = selectedTemplates.map(template => {
let screenTemplate = template.create() let screenTemplate = template.create()
screenTemplate.datasource = template.datasource screenTemplate.autoTableId = template.resourceId
screenTemplate.autoTableId = template.table
return screenTemplate return screenTemplate
}) })
await createScreens({ screens, screenAccessRole }) await createScreens({ screens, screenAccessRole })
@ -176,10 +175,10 @@
} }
</script> </script>
<Modal bind:this={datasourceModal}> <Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal <DatasourceModal
onConfirm={confirmScreenDatasources} onConfirm={confirmScreenDatasources}
initalScreens={!selectedTemplates ? [] : [...selectedTemplates]} initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
/> />
</Modal> </Modal>

View File

@ -1,41 +1,30 @@
<script> <script>
import { store } from "builderStore" import { ModalContent, Layout, notifications, Body } from "@budibase/bbui"
import { import { datasources } from "stores/backend"
ModalContent,
Layout,
notifications,
Icon,
Body,
} from "@budibase/bbui"
import { tables, datasources } from "stores/backend"
import getTemplates from "builderStore/store/screenTemplates"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { IntegrationNames } from "constants" import { IntegrationNames } from "constants"
import { onMount } from "svelte" import { onMount } from "svelte"
import rowListScreen from "builderStore/store/screenTemplates/rowListScreen"
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
export let onCancel export let onCancel
export let onConfirm export let onConfirm
export let initalScreens = [] export let initialScreens = []
let selectedScreens = [...initalScreens] let selectedScreens = [...initialScreens]
const toggleScreenSelection = (table, datasource) => { $: filteredSources = $datasources.list?.filter(datasource => {
if (selectedScreens.find(s => s.table === table._id)) { return datasource.source !== IntegrationNames.REST && datasource["entities"]
})
const toggleSelection = datasource => {
const { resourceId } = datasource
if (selectedScreens.find(s => s.resourceId === resourceId)) {
selectedScreens = selectedScreens.filter( selectedScreens = selectedScreens.filter(
screen => screen.table !== table._id screen => screen.resourceId !== resourceId
) )
} else { } else {
let partialTemplates = getTemplates($store, $tables.list).reduce( selectedScreens = [...selectedScreens, rowListScreen([datasource])[0]]
(acc, template) => {
if (template.table === table._id) {
template.datasource = datasource.name
acc.push(template)
}
return acc
},
[]
)
selectedScreens = [...partialTemplates, ...selectedScreens]
} }
} }
@ -45,18 +34,6 @@
}) })
} }
$: filteredSources = Array.isArray($datasources.list)
? $datasources.list.reduce((acc, datasource) => {
if (
datasource.source !== IntegrationNames.REST &&
datasource["entities"]
) {
acc.push(datasource)
}
return acc
}, [])
: []
onMount(async () => { onMount(async () => {
try { try {
await datasources.fetch() await datasources.fetch()
@ -81,6 +58,9 @@
</Body> </Body>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#each filteredSources as datasource} {#each filteredSources as datasource}
{@const entities = Array.isArray(datasource.entities)
? datasource.entities
: Object.values(datasource.entities || {})}
<div class="data-source-wrap"> <div class="data-source-wrap">
<div class="data-source-header"> <div class="data-source-header">
<svelte:component <svelte:component
@ -90,64 +70,45 @@
/> />
<div class="data-source-name">{datasource.name}</div> <div class="data-source-name">{datasource.name}</div>
</div> </div>
{#if Array.isArray(datasource.entities)} <!-- List all tables -->
{#each datasource.entities.filter(table => table._id !== "ta_users") as table} {#each entities.filter(table => table._id !== "ta_users") as table}
<div {@const views = Object.values(table.views || {}).filter(
class="data-source-entry" view => view.version === 2
class:selected={selectedScreens.find( )}
x => x.table === table._id {@const tableDS = {
)} tableId: table._id,
on:click={() => toggleScreenSelection(table, datasource)} label: table.name,
> resourceId: table._id,
<svg type: "table",
width="16px" }}
height="16px" {@const selected = selectedScreens.find(
class="spectrum-Icon" screen => screen.resourceId === tableDS.resourceId
style="color: white" )}
focusable="false" <DatasourceTemplateRow
> on:click={() => toggleSelection(tableDS)}
<use xlink:href="#spectrum-icon-18-Table" /> {selected}
</svg> datasource={tableDS}
{table.name} />
{#if selectedScreens.find(x => x.table === table._id)}
<span class="data-source-check"> <!-- List all views inside this table -->
<Icon size="S" name="CheckmarkCircle" /> {#each views as view}
</span> {@const viewDS = {
{/if} label: view.name,
</div> id: view.id,
resourceId: view.id,
tableId: view.tableId,
type: "viewV2",
}}
{@const selected = selectedScreens.find(
x => x.resourceId === viewDS.resourceId
)}
<DatasourceTemplateRow
on:click={() => toggleSelection(viewDS)}
{selected}
datasource={viewDS}
/>
{/each} {/each}
{/if} {/each}
{#if datasource["entities"] && !Array.isArray(datasource.entities)}
{#each Object.keys(datasource.entities).filter(table => table._id !== "ta_users") as table_key}
<div
class="data-source-entry"
class:selected={selectedScreens.find(
x => x.table === datasource.entities[table_key]._id
)}
on:click={() =>
toggleScreenSelection(
datasource.entities[table_key],
datasource
)}
>
<svg
width="16px"
height="16px"
class="spectrum-Icon"
style="color: white"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
{datasource.entities[table_key].name}
{#if selectedScreens.find(x => x.table === datasource.entities[table_key]._id)}
<span class="data-source-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
{/each}
{/if}
</div> </div>
{/each} {/each}
</Layout> </Layout>
@ -160,42 +121,10 @@
display: grid; display: grid;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
} }
.data-source-header { .data-source-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
} }
.data-source-entry {
cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
border-width: 1px;
display: flex;
align-items: center;
}
.data-source-entry:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.data-source-entry .data-source-check {
margin-left: auto;
}
.data-source-entry :global(.spectrum-Icon) {
min-width: 16px;
}
.data-source-entry .data-source-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
display: block;
}
</style> </style>

View File

@ -0,0 +1,42 @@
<script>
import { Icon } from "@budibase/bbui"
export let datasource
export let selected = false
$: icon = datasource.type === "viewV2" ? "Remove" : "Table"
</script>
<div class="data-source-entry" class:selected on:click>
<Icon name={icon} color="var(--spectrum-global-color-gray-600)" />
{datasource.label}
{#if selected}
<span class="data-source-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
<style>
.data-source-entry {
cursor: pointer;
grid-gap: var(--spacing-m);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
align-items: center;
}
.data-source-entry:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.data-source-check {
margin-left: auto;
}
.data-source-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
}
</style>

View File

@ -39,7 +39,7 @@
return publishedApps return publishedApps
} }
return publishedApps.filter(app => { return publishedApps.filter(app => {
if (sdk.users.isBuilder(user, app.appId)) { if (sdk.users.isBuilder(user, app.prodId)) {
return true return true
} }
if (!Object.keys(user?.roles).length && user?.userGroups) { if (!Object.keys(user?.roles).length && user?.userGroups) {
@ -142,7 +142,12 @@
<div class="group"> <div class="group">
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
{#each userApps as app (app.appId)} {#each userApps as app (app.appId)}
<a class="app" target="_blank" href={getUrl(app)}> <a
class="app"
target="_blank"
rel="noreferrer"
href={getUrl(app)}
>
<div class="preview" use:gradient={{ seed: app.name }} /> <div class="preview" use:gradient={{ seed: app.name }} />
<div class="app-info"> <div class="app-info">
<Heading size="XS">{app.name}</Heading> <Heading size="XS">{app.name}</Heading>

View File

@ -21,6 +21,7 @@
import GroupIcon from "./_components/GroupIcon.svelte" import GroupIcon from "./_components/GroupIcon.svelte"
import GroupUsers from "./_components/GroupUsers.svelte" import GroupUsers from "./_components/GroupUsers.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
export let groupId export let groupId
@ -57,8 +58,11 @@
) )
.map(app => ({ .map(app => ({
...app, ...app,
role: group?.roles?.[apps.getProdAppID(app.devId)], role: group?.builder?.apps.includes(apps.getProdAppID(app.devId))
? Constants.Roles.CREATOR
: group?.roles?.[apps.getProdAppID(app.devId)],
})) }))
$: { $: {
if (loaded && !group?._id) { if (loaded && !group?._id) {
$goto("./") $goto("./")

View File

@ -1,9 +1,19 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
export let value export let value
export let row
$: count = getCount(Object.keys(value || {}).length)
$: count = Object.keys(value || {}).length const getCount = () => {
return sdk.users.hasAppBuilderPermissions(row)
? row.builder.apps.length +
Object.keys(row.roles || {}).filter(appId =>
row.builder.apps.includes(appId)
).length
: value?.length || 0
}
</script> </script>
<div class="align"> <div class="align">

View File

@ -89,7 +89,7 @@
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider $: isSSO = !!user?.provider
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: privileged = sdk.users.isAdminOrBuilder(user) $: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm) $: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($apps, privileged, user?.roles)
@ -98,17 +98,14 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = sdk.users.isAdmin(user) $: globalRole = sdk.users.isAdmin(user) ? "admin" : "appUser"
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const getAvailableApps = (appList, privileged, roles) => { const getAvailableApps = (appList, privileged, roles) => {
let availableApps = appList.slice() let availableApps = appList.slice()
if (!privileged) { if (!privileged) {
availableApps = availableApps.filter(x => { availableApps = availableApps.filter(x => {
return Object.keys(roles || {}).find(y => { let roleKeys = Object.keys(roles || {})
return roleKeys.concat(user?.builder?.apps).find(y => {
return x.appId === apps.extractAppId(y) return x.appId === apps.extractAppId(y)
}) })
}) })
@ -119,7 +116,7 @@
name: app.name, name: app.name,
devId: app.devId, devId: app.devId,
icon: app.icon, icon: app.icon,
role: privileged ? Constants.Roles.ADMIN : roles[prodAppId], role: getRole(prodAppId, roles),
} }
}) })
} }
@ -132,6 +129,18 @@
return groups.filter(group => group.name?.toLowerCase().includes(search)) return groups.filter(group => group.name?.toLowerCase().includes(search))
} }
const getRole = (prodAppId, roles) => {
if (privileged) {
return Constants.Roles.ADMIN
}
if (user?.builder?.apps?.includes(prodAppId)) {
return Constants.Roles.CREATOR
}
return roles[prodAppId]
}
const getNameLabel = user => { const getNameLabel = user => {
const { firstName, lastName, email } = user || {} const { firstName, lastName, email } = user || {}
if (!firstName && !lastName) { if (!firstName && !lastName) {

View File

@ -2,12 +2,16 @@
import { StatusLight } from "@budibase/bbui" import { StatusLight } from "@budibase/bbui"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { Constants } from "@budibase/frontend-core"
import { capitalise } from "helpers"
export let value export let value
const getRoleLabel = roleId => { const getRoleLabel = roleId => {
const role = $roles.find(x => x._id === roleId) const role = $roles.find(x => x._id === roleId)
return role?.name || "Custom role" return roleId === Constants.Roles.CREATOR
? capitalise(Constants.Roles.CREATOR.toLowerCase())
: role?.name || "Custom role"
} }
</script> </script>

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