Merge branch 'develop' of github.com:Budibase/budibase into global-bindings

This commit is contained in:
Andrew Kingston 2023-08-07 13:04:34 +01:00
commit 9ddb8f4c5f
275 changed files with 8334 additions and 3835 deletions

View File

@ -25,13 +25,13 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -46,13 +46,13 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -70,13 +70,13 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -96,13 +96,13 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -119,7 +119,7 @@ jobs:
test-pro: test-pro:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -140,13 +140,13 @@ jobs:
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Checkout repo only - name: Checkout repo only
uses: actions/checkout@v3 uses: actions/checkout@v3
if: github.repository != github.event.pull_request.head.repo.full_name if: github.repository != 'Budibase/budibase'
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
@ -154,7 +154,7 @@ jobs:
node-version: 14.x node-version: 14.x
cache: "yarn" cache: "yarn"
- run: yarn - run: yarn
- run: yarn build - run: yarn build --projects=@budibase/server,@budibase/worker,@budibase/client
- name: Run tests - name: Run tests
run: | run: |
cd qa-core cd qa-core
@ -166,7 +166,7 @@ jobs:
check-pro-submodule: check-pro-submodule:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == github.event.pull_request.head.repo.full_name if: github.repository == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -190,6 +190,8 @@ jobs:
base_commit=$(git rev-parse origin/develop) base_commit=$(git rev-parse origin/develop)
fi fi
echo "target_branch=$branch"
echo "target_branch=$branch" >> "$GITHUB_OUTPUT"
echo "pro_commit=$pro_commit" echo "pro_commit=$pro_commit"
echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT" echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT"
echo "base_commit=$base_commit" echo "base_commit=$base_commit"
@ -204,7 +206,7 @@ jobs:
const baseCommit = '${{ steps.get_pro_commits.outputs.base_commit }}'; const baseCommit = '${{ steps.get_pro_commits.outputs.base_commit }}';
if (submoduleCommit !== baseCommit) { if (submoduleCommit !== baseCommit) {
console.error('Submodule commit does not match the latest commit on the develop branch.'); console.error('Submodule commit does not match the latest commit on the "${{ steps.get_pro_commits.outputs.target_branch }}"" branch.');
console.error('Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md') console.error('Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md')
process.exit(1); process.exit(1);
} else { } else {

View File

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

View File

@ -1,4 +1,4 @@
name: Deploy Budibase Single Container Image to DockerHub name: release-singleimage
on: on:
workflow_dispatch: workflow_dispatch:
@ -8,8 +8,8 @@ env:
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
REGISTRY_URL: registry.hub.docker.com REGISTRY_URL: registry.hub.docker.com
jobs: jobs:
build: build-amd64:
name: "build" name: "build-amd64"
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
@ -27,14 +27,12 @@ jobs:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0 fetch-depth: 0
- name: Fail if tag is not in master - name: Fail if tag is not in master
run: | run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch" echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1 exit 1
fi fi
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -70,9 +68,139 @@ jobs:
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }} tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile file: ./hosting/single/Dockerfile
- name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/amd64
build-args: TARGETBUILD=aas
tags: budibase/budibase-aas,budibase/budibase-aas:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
build-arm64:
name: "build-arm64"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build:docker:pre
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Get the latest release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo $release_version
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Budibase service docker image
uses: docker/build-push-action@v2
with:
context: .
push: true
platforms: linux/arm64
tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile
build-aas:
name: "build-aas"
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- name: Fail if not a tag
run: |
if [[ $GITHUB_REF != refs/tags/* ]]; then
echo "Workflow Dispatch can only be run on tags"
exit 1
fi
- name: "Checkout"
uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Setup QEMU
uses: docker/setup-qemu-action@v1
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Run Yarn
run: yarn
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Runt Yarn Lint
run: yarn lint
- name: Update versions
run: ./scripts/updateVersions.sh
- name: Run Yarn Build
run: yarn build:docker:pre
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_API_KEY }}
- name: Get the latest release version
id: version
run: |
release_version=$(cat lerna.json | jq -r '.version')
echo $release_version
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Tag and release Budibase Azure App Service docker image - name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:

2
.gitignore vendored
View File

@ -101,8 +101,6 @@ packages/builder/cypress.env.json
packages/builder/cypress/reports packages/builder/cypress/reports
stats.html stats.html
# TypeScript cache
*.tsbuildinfo
# plugins # plugins
budibase-component budibase-component

View File

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

View File

@ -201,25 +201,24 @@ spec:
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.apps.startupProbe }}
{{- with .Values.services.apps.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.apps.livenessProbe }}
{{- with .Values.services.apps.livenessProbe }}
livenessProbe: livenessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.apps.port }} {{- end }}
initialDelaySeconds: 10 {{- if .Values.services.apps.readinessProbe }}
periodSeconds: 5 {{- with .Values.services.apps.readinessProbe }}
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
readinessProbe: readinessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.apps.port }} {{- end }}
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
name: bbapps name: bbapps
ports: ports:
- containerPort: {{ .Values.services.apps.port }} - containerPort: {{ .Values.services.apps.port }}

View File

@ -40,24 +40,24 @@ spec:
- image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }} - image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: proxy-service name: proxy-service
{{- if .Values.services.proxy.startupProbe }}
{{- with .Values.services.proxy.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.proxy.livenessProbe }}
{{- with .Values.services.proxy.livenessProbe }}
livenessProbe: livenessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.proxy.port }} {{- end }}
initialDelaySeconds: 0 {{- if .Values.services.proxy.readinessProbe }}
periodSeconds: 5 {{- with .Values.services.proxy.readinessProbe }}
successThreshold: 1
failureThreshold: 2
timeoutSeconds: 3
readinessProbe: readinessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.proxy.port }} {{- end }}
initialDelaySeconds: 0
periodSeconds: 5
successThreshold: 1
failureThreshold: 2
timeoutSeconds: 3
ports: ports:
- containerPort: {{ .Values.services.proxy.port }} - containerPort: {{ .Values.services.proxy.port }}
env: env:

View File

@ -190,24 +190,24 @@ spec:
{{ end }} {{ end }}
image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.worker.startupProbe }}
{{- with .Values.services.worker.startupProbe }}
startupProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.worker.livenessProbe }}
{{- with .Values.services.worker.livenessProbe }}
livenessProbe: livenessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.worker.port }} {{- end }}
initialDelaySeconds: 10 {{- if .Values.services.worker.readinessProbe }}
periodSeconds: 5 {{- with .Values.services.worker.readinessProbe }}
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
readinessProbe: readinessProbe:
httpGet: {{- toYaml . | nindent 10 }}
path: /health {{- end }}
port: {{ .Values.services.worker.port }} {{- end }}
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
timeoutSeconds: 3
name: bbworker name: bbworker
ports: ports:
- containerPort: {{ .Values.services.worker.port }} - containerPort: {{ .Values.services.worker.port }}

View File

@ -119,15 +119,37 @@ services:
port: 10000 port: 10000
replicaCount: 1 replicaCount: 1
upstreams: upstreams:
apps: 'http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}' apps: "http://app-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.apps.port }}"
worker: 'http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}' worker: "http://worker-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.worker.port }}"
minio: 'http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}' minio: "http://minio-service.{{ .Release.Namespace }}.svc.{{ .Values.services.dns }}:{{ .Values.services.objectStore.port }}"
couchdb: 'http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}' couchdb: "http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}"
resources: {} resources: {}
# annotations: startupProbe:
# co.elastic.logs/module: nginx httpGet:
# co.elastic.logs/fileset.stdout: access path: /health
# co.elastic.logs/fileset.stderr: error port: 10000
scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 10000
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# annotations:
# co.elastic.logs/module: nginx
# co.elastic.logs/fileset.stdout: access
# co.elastic.logs/fileset.stderr: error
apps: apps:
port: 4002 port: 4002
@ -135,23 +157,67 @@ services:
logLevel: info logLevel: info
httpLogging: 1 httpLogging: 1
resources: {} resources: {}
# nodeDebug: "" # set the value of NODE_DEBUG startupProbe:
# annotations: httpGet:
# co.elastic.logs/multiline.type: pattern path: /health
# co.elastic.logs/multiline.pattern: '^[[:space:]]' port: 4002
# co.elastic.logs/multiline.negate: false scheme: HTTP
# co.elastic.logs/multiline.match: after failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 4002
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# nodeDebug: "" # set the value of NODE_DEBUG
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
worker: worker:
port: 4003 port: 4003
replicaCount: 1 replicaCount: 1
logLevel: info logLevel: info
httpLogging: 1 httpLogging: 1
resources: {} resources: {}
# annotations: startupProbe:
# co.elastic.logs/multiline.type: pattern httpGet:
# co.elastic.logs/multiline.pattern: '^[[:space:]]' path: /health
# co.elastic.logs/multiline.negate: false port: 4003
# co.elastic.logs/multiline.match: after scheme: HTTP
failureThreshold: 30
periodSeconds: 3
readinessProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
enabled: true
periodSeconds: 3
failureThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 4003
scheme: HTTP
failureThreshold: 3
periodSeconds: 5
# annotations:
# co.elastic.logs/multiline.type: pattern
# co.elastic.logs/multiline.pattern: '^[[:space:]]'
# co.elastic.logs/multiline.negate: false
# co.elastic.logs/multiline.match: after
couchdb: couchdb:
enabled: true enabled: true

View File

@ -1,5 +1,5 @@
{ {
"version": "2.8.22-alpha.3", "version": "2.8.32-alpha.4",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

13
nx.json
View File

@ -3,19 +3,10 @@
"default": { "default": {
"runner": "nx-cloud", "runner": "nx-cloud",
"options": { "options": {
"cacheableOperations": ["build", "test"], "cacheableOperations": ["build", "test", "check:types"],
"accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ=" "accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ="
} }
} }
}, },
"targetDefaults": { "targetDefaults": {}
"dev:builder": {
"dependsOn": [
{
"projects": ["@budibase/string-templates"],
"target": "build"
}
]
}
}
} }

View File

@ -6,8 +6,8 @@
"@nx/js": "16.4.3", "@nx/js": "16.4.3",
"@rollup/plugin-json": "^4.0.2", "@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "5.45.0",
"esbuild": "^0.17.18", "esbuild": "^0.18.17",
"esbuild-node-externals": "^1.7.0", "esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0", "eslint": "^8.44.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"husky": "^8.0.3", "husky": "^8.0.3",
@ -36,7 +36,7 @@
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build", "build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types --skip-nx-cache", "check:types": "lerna run check:types",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap", "backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'", "backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk", "build:sdk": "lerna run --stream build:sdk",
@ -51,7 +51,7 @@
"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 --stream", "dev": "yarn run kill-all && 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",
@ -108,5 +108,8 @@
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0" "@budibase/types": "0.0.0"
}, },
"engines": {
"node": ">=14.0.0 <15.0.0"
},
"dependencies": {} "dependencies": {}
} }

View File

@ -1,8 +1,6 @@
import { Config } from "@jest/types" import { Config } from "@jest/types"
const preset = require("ts-jest/jest-preset")
const baseConfig: Config.InitialProjectOptions = { const baseConfig: Config.InitialProjectOptions = {
...preset,
preset: "@trendyol/jest-testcontainers", preset: "@trendyol/jest-testcontainers",
setupFiles: ["./tests/jestEnv.ts"], setupFiles: ["./tests/jestEnv.ts"],
setupFilesAfterEnv: ["./tests/jestSetup.ts"], setupFilesAfterEnv: ["./tests/jestSetup.ts"],
@ -11,6 +9,7 @@ const baseConfig: Config.InitialProjectOptions = {
}, },
moduleNameMapper: { moduleNameMapper: {
"@budibase/types": "<rootDir>/../types/src", "@budibase/types": "<rootDir>/../types/src",
"@budibase/shared-core": ["<rootDir>/../shared-core/src"],
}, },
} }

View File

@ -16,14 +16,15 @@
"prepack": "cp package.json dist", "prepack": "cp package.json dist",
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"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",
"test": "bash scripts/test.sh", "test": "bash scripts/test.sh",
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.2", "@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/shared-core": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",
"aws-sdk": "2.1030.0", "aws-sdk": "2.1030.0",
@ -58,12 +59,13 @@
"uuid": "8.3.2" "uuid": "8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@jest/test-sequencer": "29.5.0", "@jest/test-sequencer": "29.6.2",
"@swc/core": "^1.3.25", "@shopify/jest-koa-mocks": "5.1.1",
"@swc/jest": "^0.2.24", "@swc/core": "1.3.71",
"@swc/jest": "0.2.27",
"@trendyol/jest-testcontainers": "^2.1.1", "@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/jest": "29.5.0", "@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": "14.18.20",
@ -75,32 +77,16 @@
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"chance": "1.1.8", "chance": "1.1.8",
"ioredis-mock": "8.7.0", "ioredis-mock": "8.7.0",
"jest": "29.5.0", "jest": "29.6.2",
"jest-environment-node": "29.5.0", "jest-environment-node": "29.6.2",
"jest-serial-runner": "^1.2.1", "jest-serial-runner": "1.2.1",
"koa": "2.13.4", "koa": "2.13.4",
"nodemon": "2.0.16", "nodemon": "2.0.16",
"pino-pretty": "10.0.0", "pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2", "pouchdb-adapter-memory": "7.2.2",
"timekeeper": "2.2.0", "timekeeper": "2.2.0",
"ts-jest": "29.0.5",
"ts-node": "10.8.1", "ts-node": "10.8.1",
"tsconfig-paths": "4.0.0", "tsconfig-paths": "4.0.0",
"typescript": "4.7.3" "typescript": "4.7.3"
}, }
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

@ -8,6 +8,6 @@ then
jest --coverage --runInBand --forceExit jest --coverage --runInBand --forceExit
else else
# --maxWorkers performs better in development # --maxWorkers performs better in development
echo "jest --coverage --forceExit" echo "jest --coverage --detectOpenHandles"
jest --coverage --forceExit jest --coverage --detectOpenHandles
fi fi

View File

@ -36,7 +36,7 @@ describe("writethrough", () => {
_id: docId, _id: docId,
value: 1, value: 1,
}) })
const output = await db.get(response.id) const output = await db.get<any>(response.id)
current = output current = output
expect(output.value).toBe(1) expect(output.value).toBe(1)
}) })
@ -45,7 +45,7 @@ describe("writethrough", () => {
it("second put shouldn't update DB", async () => { it("second put shouldn't update DB", async () => {
await config.doInTenant(async () => { await config.doInTenant(async () => {
const response = await writethrough.put({ ...current, value: 2 }) const response = await writethrough.put({ ...current, value: 2 })
const output = await db.get(response.id) const output = await db.get<any>(response.id)
expect(current._rev).toBe(output._rev) expect(current._rev).toBe(output._rev)
expect(output.value).toBe(1) expect(output.value).toBe(1)
}) })
@ -55,7 +55,7 @@ describe("writethrough", () => {
await config.doInTenant(async () => { await config.doInTenant(async () => {
tk.freeze(Date.now() + DELAY + 1) tk.freeze(Date.now() + DELAY + 1)
const response = await writethrough.put({ ...current, value: 3 }) const response = await writethrough.put({ ...current, value: 3 })
const output = await db.get(response.id) const output = await db.get<any>(response.id)
expect(response.rev).not.toBe(current._rev) expect(response.rev).not.toBe(current._rev)
expect(output.value).toBe(3) expect(output.value).toBe(3)
@ -79,7 +79,7 @@ describe("writethrough", () => {
expect.arrayContaining([current._rev, current._rev, newRev]) expect.arrayContaining([current._rev, current._rev, newRev])
) )
const output = await db.get(current._id) const output = await db.get<any>(current._id)
expect(output.value).toBe(4) expect(output.value).toBe(4)
expect(output._rev).toBe(newRev) expect(output._rev).toBe(newRev)
@ -107,7 +107,7 @@ describe("writethrough", () => {
}) })
expect(res.ok).toBe(true) expect(res.ok).toBe(true)
const output = await db.get(id) const output = await db.get<any>(id)
expect(output.value).toBe(3) expect(output.value).toBe(3)
expect(output._rev).toBe(res.rev) expect(output._rev).toBe(res.rev)
}) })
@ -130,8 +130,8 @@ describe("writethrough", () => {
const resp2 = await writethrough2.put({ _id: "db1", value: "second" }) const resp2 = await writethrough2.put({ _id: "db1", value: "second" })
expect(resp1.rev).toBeDefined() expect(resp1.rev).toBeDefined()
expect(resp2.rev).toBeDefined() expect(resp2.rev).toBeDefined()
expect((await db.get("db1")).value).toBe("first") expect((await db.get<any>("db1")).value).toBe("first")
expect((await db2.get("db1")).value).toBe("second") expect((await db2.get<any>("db1")).value).toBe("second")
}) })
}) })
}) })

View File

@ -1,5 +1,5 @@
export const SEPARATOR = "_" import { prefixed, DocumentType } from "@budibase/types"
export const UNICODE_MAX = "\ufff0" export { SEPARATOR, UNICODE_MAX, DocumentType } from "@budibase/types"
/** /**
* Can be used to create a few different forms of querying a view. * Can be used to create a few different forms of querying a view.
@ -14,8 +14,6 @@ export enum ViewName {
USER_BY_APP = "by_app", USER_BY_APP = "by_app",
USER_BY_EMAIL = "by_email2", USER_BY_EMAIL = "by_email2",
BY_API_KEY = "by_api_key", BY_API_KEY = "by_api_key",
/** @deprecated - could be deleted */
USER_BY_BUILDERS = "by_builders",
LINK = "by_link", LINK = "by_link",
ROUTING = "screen_routes", ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
@ -36,42 +34,6 @@ export enum InternalTable {
USER_METADATA = "ta_users", USER_METADATA = "ta_users",
} }
export enum DocumentType {
USER = "us",
GROUP = "gr",
WORKSPACE = "workspace",
CONFIG = "config",
TEMPLATE = "template",
APP = "app",
DEV = "dev",
APP_DEV = "app_dev",
APP_METADATA = "app_metadata",
ROLE = "role",
MIGRATIONS = "migrations",
DEV_INFO = "devinfo",
AUTOMATION_LOG = "log_au",
ACCOUNT_METADATA = "acc_metadata",
PLUGIN = "plg",
DATASOURCE = "datasource",
DATASOURCE_PLUS = "datasource_plus",
APP_BACKUP = "backup",
TABLE = "ta",
ROW = "ro",
AUTOMATION = "au",
LINK = "li",
WEBHOOK = "wh",
INSTANCE = "inst",
LAYOUT = "layout",
SCREEN = "screen",
QUERY = "query",
DEPLOYMENTS = "deployments",
METADATA = "metadata",
MEM_VIEW = "view",
USER_FLAG = "flag",
AUTOMATION_METADATA = "meta_au",
AUDIT_LOG = "al",
}
export const StaticDatabases = { export const StaticDatabases = {
GLOBAL: { GLOBAL: {
name: "global-db", name: "global-db",
@ -95,7 +57,7 @@ export const StaticDatabases = {
}, },
} }
export const APP_PREFIX = DocumentType.APP + SEPARATOR export const APP_PREFIX = prefixed(DocumentType.APP)
export const APP_DEV = DocumentType.APP_DEV + SEPARATOR export const APP_DEV = prefixed(DocumentType.APP_DEV)
export const APP_DEV_PREFIX = APP_DEV export const APP_DEV_PREFIX = APP_DEV
export const BUDIBASE_DATASOURCE_TYPE = "budibase" export const BUDIBASE_DATASOURCE_TYPE = "budibase"

View File

@ -0,0 +1,10 @@
export const CONSTANT_INTERNAL_ROW_COLS = [
"_id",
"_rev",
"type",
"createdAt",
"updatedAt",
"tableId",
] as const
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const

View File

@ -2,3 +2,4 @@ export * from "./connections"
export * from "./DatabaseImpl" export * from "./DatabaseImpl"
export * from "./utils" export * from "./utils"
export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB" export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB"
export * from "../constants"

View File

@ -105,16 +105,6 @@ export const createApiKeyView = async () => {
await createView(db, viewJs, ViewName.BY_API_KEY) await createView(db, viewJs, ViewName.BY_API_KEY)
} }
export const createUserBuildersView = async () => {
const db = getGlobalDB()
const viewJs = `function(doc) {
if (doc.builder && doc.builder.global === true) {
emit(doc._id, doc._id)
}
}`
await createView(db, viewJs, ViewName.USER_BY_BUILDERS)
}
export interface QueryViewOptions { export interface QueryViewOptions {
arrayResponse?: boolean arrayResponse?: boolean
} }
@ -223,7 +213,6 @@ export const queryPlatformView = async <T>(
const CreateFuncByName: any = { const CreateFuncByName: any = {
[ViewName.USER_BY_EMAIL]: createNewUserEmailView, [ViewName.USER_BY_EMAIL]: createNewUserEmailView,
[ViewName.BY_API_KEY]: createApiKeyView, [ViewName.BY_API_KEY]: createApiKeyView,
[ViewName.USER_BY_BUILDERS]: createUserBuildersView,
[ViewName.USER_BY_APP]: createUserAppView, [ViewName.USER_BY_APP]: createUserAppView,
} }

View File

@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
import { ServiceType } from "@budibase/types"
function isTest() { function isTest() {
return isCypress() || isJest() return isCypress() || isJest()
@ -83,10 +84,20 @@ function getPackageJsonFields(): {
} }
} }
function isWorker() {
return environment.SERVICE_TYPE === ServiceType.WORKER
}
function isApps() {
return environment.SERVICE_TYPE === ServiceType.APPS
}
const environment = { const environment = {
isTest, isTest,
isJest, isJest,
isDev, isDev,
isWorker,
isApps,
isProd: () => { isProd: () => {
return !isDev() return !isDev()
}, },
@ -153,6 +164,7 @@ const environment = {
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS, SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
DISABLE_JWT_WARNING: process.env.DISABLE_JWT_WARNING, DISABLE_JWT_WARNING: process.env.DISABLE_JWT_WARNING,
BLACKLIST_IPS: process.env.BLACKLIST_IPS, BLACKLIST_IPS: process.env.BLACKLIST_IPS,
SERVICE_TYPE: "unknown",
/** /**
* Enable to allow an admin user to login using a password. * Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO. * This can be useful to prevent lockout when configuring SSO.

View File

@ -21,6 +21,7 @@ import { processors } from "./processors"
import { newid } from "../utils" import { newid } from "../utils"
import * as installation from "../installation" import * as installation from "../installation"
import * as configs from "../configs" import * as configs from "../configs"
import * as users from "../users"
import { withCache, TTL, CacheKey } from "../cache/generic" import { withCache, TTL, CacheKey } from "../cache/generic"
/** /**
@ -164,8 +165,8 @@ const identifyUser = async (
const id = user._id as string const id = user._id as string
const tenantId = await getEventTenantId(user.tenantId) const tenantId = await getEventTenantId(user.tenantId)
const type = IdentityType.USER const type = IdentityType.USER
let builder = user.builder?.global || false let builder = users.hasBuilderPermissions(user)
let admin = user.admin?.global || false let admin = users.hasAdminPermissions(user)
let providerType let providerType
if (isSSOUser(user)) { if (isSSOUser(user)) {
providerType = user.providerType providerType = user.providerType

View File

@ -1,10 +1,8 @@
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
import { isAdmin } from "../users"
export default async (ctx: BBContext, next: any) => { export default async (ctx: UserCtx, next: any) => {
if ( if (!ctx.internal && !isAdmin(ctx.user)) {
!ctx.internal &&
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global)
) {
ctx.throw(403, "Admin user only endpoint.") ctx.throw(403, "Admin user only endpoint.")
} }
return next() return next()

View File

@ -1,10 +1,19 @@
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
import { isBuilder, hasBuilderPermissions } from "../users"
import { getAppId } from "../context"
import env from "../environment"
export default async (ctx: BBContext, next: any) => { export default async (ctx: UserCtx, next: any) => {
if ( const appId = getAppId()
!ctx.internal && const builderFn = env.isWorker()
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global) ? hasBuilderPermissions
) { : env.isApps()
? isBuilder
: undefined
if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.")
}
if (!ctx.internal && !builderFn(ctx.user, appId)) {
ctx.throw(403, "Builder user only endpoint.") ctx.throw(403, "Builder user only endpoint.")
} }
return next() return next()

View File

@ -1,12 +1,20 @@
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
import { isBuilder, isAdmin, hasBuilderPermissions } from "../users"
import { getAppId } from "../context"
import env from "../environment"
export default async (ctx: BBContext, next: any) => { export default async (ctx: UserCtx, next: any) => {
if ( const appId = getAppId()
!ctx.internal && const builderFn = env.isWorker()
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global) && ? hasBuilderPermissions
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global) : env.isApps()
) { ? isBuilder
ctx.throw(403, "Builder user only endpoint.") : undefined
if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.")
}
if (!ctx.internal && !builderFn(ctx.user, appId) && !isAdmin(ctx.user)) {
ctx.throw(403, "Admin/Builder user only endpoint.")
} }
return next() return next()
} }

View File

@ -0,0 +1,180 @@
import adminOnly from "../adminOnly"
import builderOnly from "../builderOnly"
import builderOrAdmin from "../builderOrAdmin"
import { structures } from "../../../tests"
import { ContextUser, ServiceType } from "@budibase/types"
import { doInAppContext } from "../../context"
import env from "../../environment"
env._set("SERVICE_TYPE", ServiceType.APPS)
const appId = "app_aaa"
const basicUser = structures.users.user()
const adminUser = structures.users.adminUser()
const adminOnlyUser = structures.users.adminOnlyUser()
const builderUser = structures.users.builderUser()
const appBuilderUser = structures.users.appBuilderUser(appId)
function buildUserCtx(user: ContextUser) {
return {
internal: false,
user,
throw: jest.fn(),
} as any
}
function passed(throwFn: jest.Func, nextFn: jest.Func) {
expect(throwFn).not.toBeCalled()
expect(nextFn).toBeCalled()
}
function threw(throwFn: jest.Func) {
// cant check next, the throw function doesn't actually throw - so it still continues
expect(throwFn).toBeCalled()
}
describe("adminOnly middleware", () => {
it("should allow admin user", () => {
const ctx = buildUserCtx(adminUser),
next = jest.fn()
adminOnly(ctx, next)
passed(ctx.throw, next)
})
it("should not allow basic user", () => {
const ctx = buildUserCtx(basicUser),
next = jest.fn()
adminOnly(ctx, next)
threw(ctx.throw)
})
it("should not allow builder user", () => {
const ctx = buildUserCtx(builderUser),
next = jest.fn()
adminOnly(ctx, next)
threw(ctx.throw)
})
})
describe("builderOnly middleware", () => {
it("should allow builder user", () => {
const ctx = buildUserCtx(builderUser),
next = jest.fn()
builderOnly(ctx, next)
passed(ctx.throw, next)
})
it("should allow app builder user", () => {
const ctx = buildUserCtx(appBuilderUser),
next = jest.fn()
doInAppContext(appId, () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
})
it("should allow admin and builder user", () => {
const ctx = buildUserCtx(adminUser),
next = jest.fn()
builderOnly(ctx, next)
passed(ctx.throw, next)
})
it("should not allow admin user", () => {
const ctx = buildUserCtx(adminOnlyUser),
next = jest.fn()
builderOnly(ctx, next)
threw(ctx.throw)
})
it("should not allow app builder user to different app", () => {
const ctx = buildUserCtx(appBuilderUser),
next = jest.fn()
doInAppContext("app_bbb", () => {
builderOnly(ctx, next)
})
threw(ctx.throw)
})
it("should not allow basic user", () => {
const ctx = buildUserCtx(basicUser),
next = jest.fn()
builderOnly(ctx, next)
threw(ctx.throw)
})
})
describe("builderOrAdmin middleware", () => {
it("should allow builder user", () => {
const ctx = buildUserCtx(builderUser),
next = jest.fn()
builderOrAdmin(ctx, next)
passed(ctx.throw, next)
})
it("should allow builder and admin user", () => {
const ctx = buildUserCtx(adminUser),
next = jest.fn()
builderOrAdmin(ctx, next)
passed(ctx.throw, next)
})
it("should allow admin user", () => {
const ctx = buildUserCtx(adminOnlyUser),
next = jest.fn()
builderOrAdmin(ctx, next)
passed(ctx.throw, next)
})
it("should allow app builder user", () => {
const ctx = buildUserCtx(appBuilderUser),
next = jest.fn()
doInAppContext(appId, () => {
builderOrAdmin(ctx, next)
})
passed(ctx.throw, next)
})
it("should not allow basic user", () => {
const ctx = buildUserCtx(basicUser),
next = jest.fn()
builderOrAdmin(ctx, next)
threw(ctx.throw)
})
})
describe("check service difference", () => {
it("should not allow without app ID in apps", () => {
env._set("SERVICE_TYPE", ServiceType.APPS)
const appId = "app_a"
const ctx = buildUserCtx({
...basicUser,
builder: {
apps: [appId],
},
})
const next = jest.fn()
doInAppContext(appId, () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
doInAppContext("app_b", () => {
builderOnly(ctx, next)
})
threw(ctx.throw)
})
it("should allow without app ID in worker", () => {
env._set("SERVICE_TYPE", ServiceType.WORKER)
const ctx = buildUserCtx({
...basicUser,
builder: {
apps: ["app_a"],
},
})
const next = jest.fn()
doInAppContext("app_b", () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
})
})

View File

@ -1,29 +1,12 @@
const { flatten } = require("lodash") import { PermissionType, PermissionLevel } from "@budibase/types"
const { cloneDeep } = require("lodash/fp") export { PermissionType, PermissionLevel } from "@budibase/types"
import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep"
export type RoleHierarchy = { export type RoleHierarchy = {
permissionId: string permissionId: string
}[] }[]
export enum PermissionLevel {
READ = "read",
WRITE = "write",
EXECUTE = "execute",
ADMIN = "admin",
}
// these are the global types, that govern the underlying default behaviour
export enum PermissionType {
APP = "app",
TABLE = "table",
USER = "user",
AUTOMATION = "automation",
WEBHOOK = "webhook",
BUILDER = "builder",
VIEW = "view",
QUERY = "query",
}
export class Permission { export class Permission {
type: PermissionType type: PermissionType
level: PermissionLevel level: PermissionLevel
@ -173,3 +156,4 @@ export function isPermissionLevelHigherThanRead(level: PermissionLevel) {
// utility as a lot of things need simply the builder permission // utility as a lot of things need simply the builder permission
export const BUILDER = PermissionType.BUILDER export const BUILDER = PermissionType.BUILDER
export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER

View File

@ -3,7 +3,7 @@ import { prefixRoleID, getRoleParams, DocumentType, SEPARATOR } from "../db"
import { getAppDB } from "../context" import { getAppDB } from "../context"
import { doWithDB } from "../db" import { doWithDB } from "../db"
import { Screen, Role as RoleDoc } from "@budibase/types" import { Screen, Role as RoleDoc } from "@budibase/types"
const { cloneDeep } = require("lodash/fp") import cloneDeep from "lodash/fp/cloneDeep"
export const BUILTIN_ROLE_IDS = { export const BUILTIN_ROLE_IDS = {
ADMIN: "ADMIN", ADMIN: "ADMIN",

View File

@ -1,4 +1,4 @@
import { cloneDeep } from "lodash" import cloneDeep from "lodash/cloneDeep"
import * as permissions from "../permissions" import * as permissions from "../permissions"
import { BUILTIN_ROLE_IDS } from "../roles" import { BUILTIN_ROLE_IDS } from "../roles"

View File

@ -0,0 +1,460 @@
import env from "../environment"
import * as eventHelpers from "./events"
import * as accounts from "../accounts"
import * as cache from "../cache"
import { getIdentity, getTenantId, getGlobalDB } from "../context"
import * as dbUtils from "../db"
import { EmailUnavailableError, HTTPError } from "../errors"
import * as platform from "../platform"
import * as sessions from "../security/sessions"
import * as usersCore from "./users"
import {
AllDocsResponse,
BulkUserCreated,
BulkUserDeleted,
RowResponse,
SaveUserOpts,
User,
Account,
isSSOUser,
isSSOAccount,
UserStatus,
} from "@budibase/types"
import * as accountSdk from "../accounts"
import {
validateUniqueUser,
getAccountHolderFromUserIds,
isAdmin,
} from "./utils"
import { searchExistingEmails } from "./lookup"
import { hash } from "../utils"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn }
type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn }
const bulkDeleteProcessing = async (dbUser: User) => {
const userId = dbUser._id as string
await platform.users.removeUser(dbUser)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
}
export class UserDB {
static quotas: QuotaFns
static groups: GroupFns
static features: FeatureFns
static init(quotaFns: QuotaFns, groupFns: GroupFns, featureFns: FeatureFns) {
UserDB.quotas = quotaFns
UserDB.groups = groupFns
UserDB.features = featureFns
}
static async isPreventPasswordActions(user: User, account?: Account) {
// when in maintenance mode we allow sso users with the admin role
// to perform any password action - this prevents lockout
if (env.ENABLE_SSO_MAINTENANCE_MODE && isAdmin(user)) {
return false
}
// SSO is enforced for all users
if (await UserDB.features.isSSOEnforced()) {
return true
}
// Check local sso
if (isSSOUser(user)) {
return true
}
// Check account sso
if (!account) {
account = await accountSdk.getAccountByTenantId(getTenantId())
}
return !!(account && account.email === user.email && isSSOAccount(account))
}
static async buildUser(
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
},
tenantId: string,
dbUser?: any,
account?: Account
): Promise<User> {
let { password, _id } = user
// don't require a password if the db user doesn't already have one
if (dbUser && !dbUser.password) {
opts.requirePassword = false
}
let hashedPassword
if (password) {
if (await UserDB.isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
hashedPassword = opts.hashPassword ? await hash(password) : password
} else if (dbUser) {
hashedPassword = dbUser.password
}
// passwords are never required if sso is enforced
const requirePasswords =
opts.requirePassword && !(await UserDB.features.isSSOEnforced())
if (!hashedPassword && requirePasswords) {
throw "Password must be specified."
}
_id = _id || dbUtils.generateGlobalUserID()
const fullUser = {
createdAt: Date.now(),
...dbUser,
...user,
_id,
password: hashedPassword,
tenantId,
}
// make sure the roles object is always present
if (!fullUser.roles) {
fullUser.roles = {}
}
// add the active status to a user if its not provided
if (fullUser.status == null) {
fullUser.status = UserStatus.ACTIVE
}
return fullUser
}
static async allUsers() {
const db = getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
return response.rows.map((row: any) => row.doc)
}
static async countUsersByApp(appId: string) {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
static async getUsersByAppAccess(appId?: string) {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
static async getUserByEmail(email: string) {
return usersCore.getGlobalUserByEmail(email)
}
/**
* Gets a user by ID from the global database, based on the current tenancy.
*/
static async getUser(userId: string) {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
}
return user
}
static async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// default booleans to true
if (opts.hashPassword == null) {
opts.hashPassword = true
}
if (opts.requirePassword == null) {
opts.requirePassword = true
}
const tenantId = getTenantId()
const db = getGlobalDB()
let { email, _id, userGroups = [], roles } = user
if (!email && !_id) {
throw new Error("_id or email is required")
}
if (
user.builder?.apps?.length &&
!(await UserDB.features.isAppBuildersEnabled())
) {
throw new Error("Unable to update app builders, please check license")
}
let dbUser: User | undefined
if (_id) {
// try to get existing user from db
try {
dbUser = (await db.get(_id)) as User
if (email && dbUser.email !== email) {
throw "Email address cannot be changed"
}
email = dbUser.email
} catch (e: any) {
if (e.status === 404) {
// do nothing, save this new user with the id specified - required for SSO auth
} else {
throw e
}
}
}
if (!dbUser && email) {
// no id was specified - load from email instead
dbUser = await usersCore.getGlobalUserByEmail(email)
if (dbUser && dbUser._id !== _id) {
throw new EmailUnavailableError(email)
}
}
const change = dbUser ? 0 : 1 // no change if there is existing user
return UserDB.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
// don't allow a user to update its own roles/perms
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
builtUser = usersCore.cleanseUserObject(builtUser, dbUser) as User
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []
if (!_id) {
_id = builtUser._id!
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(UserDB.groups.addUsers(groupId, [_id!]))
}
}
}
try {
// save the user to db
let response = await db.put(builtUser)
builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
await cache.user.invalidateUser(response.id)
await Promise.all(groupPromises)
// finally returned the saved user from the db
return db.get(builtUser._id!)
} catch (err: any) {
if (err.status === 409) {
throw "User exists already"
} else {
throw err
}
}
})
}
static async bulkCreate(
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserCreated> {
const tenantId = getTenantId()
let usersToSave: any[] = []
let newUsers: any[] = []
const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails)
const unsuccessful: { email: string; reason: string }[] = []
for (const newUser of newUsersRequested) {
if (
newUsers.find(
(x: User) => x.email.toLowerCase() === newUser.email.toLowerCase()
) ||
existingEmails.includes(newUser.email.toLowerCase())
) {
unsuccessful.push({
email: newUser.email,
reason: `Unavailable`,
})
continue
}
newUser.userGroups = groups
newUsers.push(newUser)
}
const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
UserDB.buildUser(
user,
{
hashPassword: true,
requirePassword: user.requirePassword,
},
tenantId,
undefined, // no dbUser
account
)
)
})
const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. events and cache operations
for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined)
}
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
})
}
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
const db = getGlobalDB()
const response: BulkUserDeleted = {
successful: [],
unsuccessful: [],
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
reason: "Account holder cannot be deleted",
})
}
// Get users and delete
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
include_docs: true,
keys: userIds,
})
const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
return user.doc
}
)
// Delete from DB
const toDelete = usersToDelete.map(user => ({
...user,
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await UserDB.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
// Build Response
// index users by id
const userIndex: { [key: string]: User } = {}
usersToDelete.reduce((prev, current) => {
prev[current._id!] = current
return prev
}, userIndex)
// add the successful and unsuccessful users to response
dbResponse.forEach(item => {
const email = userIndex[item.id].email
if (item.ok) {
response.successful.push({ _id: item.id, email })
} else {
response.unsuccessful.push({
_id: item.id,
email,
reason: "Database error",
})
}
})
return response
}
static async destroy(id: string) {
const db = getGlobalDB()
const dbUser = (await db.get(id)) as User
const userId = dbUser._id as string
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
// root account holder can't be deleted from inside budibase
const email = dbUser.email
const account = await accounts.getAccount(email)
if (account) {
if (dbUser.userId === getIdentity()!._id) {
throw new HTTPError('Please visit "Account" to delete this user', 400)
} else {
throw new HTTPError("Account holder cannot be deleted", 400)
}
}
}
await platform.users.removeUser(dbUser)
await db.remove(userId, dbUser._rev)
await UserDB.quotas.removeUsers(1)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
}
}

View File

@ -1,15 +1,18 @@
import env from "../../environment" import env from "../environment"
import { events, accounts, tenancy } from "@budibase/backend-core" import * as events from "../events"
import * as accounts from "../accounts"
import { getTenantId } from "../context"
import { User, UserRoles, CloudAccount } from "@budibase/types" import { User, UserRoles, CloudAccount } from "@budibase/types"
import { hasBuilderPermissions, hasAdminPermissions } from "./utils"
export const handleDeleteEvents = async (user: any) => { export const handleDeleteEvents = async (user: any) => {
await events.user.deleted(user) await events.user.deleted(user)
if (isBuilder(user)) { if (hasBuilderPermissions(user)) {
await events.user.permissionBuilderRemoved(user) await events.user.permissionBuilderRemoved(user)
} }
if (isAdmin(user)) { if (hasAdminPermissions(user)) {
await events.user.permissionAdminRemoved(user) await events.user.permissionAdminRemoved(user)
} }
} }
@ -55,7 +58,7 @@ export const handleSaveEvents = async (
user: User, user: User,
existingUser: User | undefined existingUser: User | undefined
) => { ) => {
const tenantId = tenancy.getTenantId() const tenantId = getTenantId()
let tenantAccount: CloudAccount | undefined let tenantAccount: CloudAccount | undefined
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
tenantAccount = await accounts.getAccountByTenantId(tenantId) tenantAccount = await accounts.getAccountByTenantId(tenantId)
@ -103,23 +106,20 @@ export const handleSaveEvents = async (
await handleAppRoleEvents(user, existingUser) await handleAppRoleEvents(user, existingUser)
} }
const isBuilder = (user: any) => user.builder && user.builder.global
const isAdmin = (user: any) => user.admin && user.admin.global
export const isAddingBuilder = (user: any, existingUser: any) => { export const isAddingBuilder = (user: any, existingUser: any) => {
return isAddingPermission(user, existingUser, isBuilder) return isAddingPermission(user, existingUser, hasBuilderPermissions)
} }
export const isRemovingBuilder = (user: any, existingUser: any) => { export const isRemovingBuilder = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isBuilder) return isRemovingPermission(user, existingUser, hasBuilderPermissions)
} }
const isAddingAdmin = (user: any, existingUser: any) => { const isAddingAdmin = (user: any, existingUser: any) => {
return isAddingPermission(user, existingUser, isAdmin) return isAddingPermission(user, existingUser, hasAdminPermissions)
} }
const isRemovingAdmin = (user: any, existingUser: any) => { const isRemovingAdmin = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isAdmin) return isRemovingPermission(user, existingUser, hasAdminPermissions)
} }
const isOnboardingComplete = (user: any, existingUser: any) => { const isOnboardingComplete = (user: any, existingUser: any) => {

View File

@ -0,0 +1,4 @@
export * from "./users"
export * from "./utils"
export * from "./lookup"
export { UserDB } from "./db"

View File

@ -0,0 +1,102 @@
import {
AccountMetadata,
PlatformUser,
PlatformUserByEmail,
User,
} from "@budibase/types"
import * as dbUtils from "../db"
import { ViewName } from "../constants"
/**
* Apply a system-wide search on emails:
* - in tenant
* - cross tenant
* - accounts
* return an array of emails that match the supplied emails.
*/
export async function searchExistingEmails(emails: string[]) {
let matchedEmails: string[] = []
const existingTenantUsers = await getExistingTenantUsers(emails)
matchedEmails.push(...existingTenantUsers.map(user => user.email))
const existingPlatformUsers = await getExistingPlatformUsers(emails)
matchedEmails.push(...existingPlatformUsers.map(user => user._id!))
const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
}
// lookup, could be email or userId, either will return a doc
export async function getPlatformUser(
identifier: string
): Promise<PlatformUser | null> {
// use the view here and allow to find anyone regardless of casing
// Use lowercase to ensure email login is case insensitive
return (await dbUtils.queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, {
keys: [identifier.toLowerCase()],
include_docs: true,
})) as PlatformUser
}
export async function getExistingTenantUsers(
emails: string[]
): Promise<User[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryGlobalView(
ViewName.USER_BY_EMAIL,
params,
undefined,
opts
)) as User[]
}
export async function getExistingPlatformUsers(
emails: string[]
): Promise<PlatformUserByEmail[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE,
params,
opts
)) as PlatformUserByEmail[]
}
export async function getExistingAccounts(
emails: string[]
): Promise<AccountMetadata[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.ACCOUNT_BY_EMAIL,
params,
opts
)) as AccountMetadata[]
}

View File

@ -11,10 +11,16 @@ import {
SEPARATOR, SEPARATOR,
UNICODE_MAX, UNICODE_MAX,
ViewName, ViewName,
} from "./db" } from "../db"
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types" import {
import { getGlobalDB } from "./context" BulkDocsResponse,
import * as context from "./context" SearchUsersRequest,
User,
ContextUser,
} from "@budibase/types"
import { getGlobalDB } from "../context"
import * as context from "../context"
import { user as userCache } from "../cache"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -178,7 +184,7 @@ export const getGlobalUserByAppPage = (appId: string, user: User) => {
* Performs a starts with search on the global email view. * Performs a starts with search on the global email view.
*/ */
export const searchGlobalUsersByEmail = async ( export const searchGlobalUsersByEmail = async (
email: string, email: string | unknown,
opts: any, opts: any,
getOpts?: GetOpts getOpts?: GetOpts
) => { ) => {
@ -248,3 +254,23 @@ export async function getUserCount() {
}) })
return response.total_rows return response.total_rows
} }
// used to remove the builder/admin permissions, for processing the
// user as an app user (they may have some specific role/group
export function removePortalUserPermissions(user: User | ContextUser) {
delete user.admin
delete user.builder
return user
}
export function cleanseUserObject(user: User | ContextUser, base?: User) {
delete user.admin
delete user.builder
delete user.roles
if (base) {
user.admin = base.admin
user.builder = base.builder
user.roles = base.roles
}
return user
}

View File

@ -0,0 +1,55 @@
import { CloudAccount } from "@budibase/types"
import * as accountSdk from "../accounts"
import env from "../environment"
import { getPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
// extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin
export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions
export async function validateUniqueUser(email: string, tenantId: string) {
// check budibase users in other tenants
if (env.MULTI_TENANCY) {
const tenantUser = await getPlatformUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw new EmailUnavailableError(email)
}
}
// check root account users in account portal
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accountSdk.getAccount(email)
if (account && account.verified && account.tenantId !== tenantId) {
throw new EmailUnavailableError(email)
}
}
}
/**
* For the given user id's, return the account holder if it is in the ids.
*/
export async function getAccountHolderFromUserIds(
userIds: string[]
): Promise<CloudAccount | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId()
const account = await getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
}
}

View File

@ -1,3 +1,5 @@
import { db } from "../../../src"
export function expectFunctionWasCalledTimesWith( export function expectFunctionWasCalledTimesWith(
jestFunction: any, jestFunction: any,
times: number, times: number,
@ -7,3 +9,22 @@ export function expectFunctionWasCalledTimesWith(
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
).toBe(times) ).toBe(times)
} }
export const expectAnyInternalColsAttributes: {
[K in (typeof db.CONSTANT_INTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
type: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
}
export const expectAnyExternalColsAttributes: {
[K in (typeof db.CONSTANT_EXTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
}

View File

@ -1,5 +1,3 @@
import * as events from "../../../../src/events"
beforeAll(async () => { beforeAll(async () => {
const processors = await import("../../../../src/events/processors") const processors = await import("../../../../src/events/processors")
const events = await import("../../../../src/events") const events = await import("../../../../src/events")

View File

@ -1,5 +1,5 @@
import { Feature, License, Quotas } from "@budibase/types" import { Feature, License, Quotas } from "@budibase/types"
import _ from "lodash" import cloneDeep from "lodash/cloneDeep"
let CLOUD_FREE_LICENSE: License let CLOUD_FREE_LICENSE: License
let UNLIMITED_LICENSE: License let UNLIMITED_LICENSE: License
@ -58,7 +58,7 @@ export const useCloudFree = () => {
// FEATURES // FEATURES
const useFeature = (feature: Feature) => { const useFeature = (feature: Feature) => {
const license = _.cloneDeep(UNLIMITED_LICENSE) const license = cloneDeep(UNLIMITED_LICENSE)
const opts: UseLicenseOpts = { const opts: UseLicenseOpts = {
features: [feature], features: [feature],
} }
@ -94,10 +94,14 @@ export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS) return useFeature(Feature.SYNC_AUTOMATIONS)
} }
export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS)
}
// QUOTAS // QUOTAS
export const setAutomationLogsQuota = (value: number) => { export const setAutomationLogsQuota = (value: number) => {
const license = _.cloneDeep(UNLIMITED_LICENSE) const license = cloneDeep(UNLIMITED_LICENSE)
license.quotas.constant.automationLogRetentionDays.value = value license.quotas.constant.automationLogRetentionDays.value = value
return useLicense(license) return useLicense(license)
} }

View File

@ -11,7 +11,7 @@ import {
CreateAccount, CreateAccount,
CreatePassswordAccount, CreatePassswordAccount,
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import sample from "lodash/sample"
export const account = (partial: Partial<Account> = {}): Account => { export const account = (partial: Partial<Account> = {}): Account => {
return { return {
@ -46,13 +46,11 @@ export const cloudAccount = (): CloudAccount => {
} }
function providerType(): AccountSSOProviderType { function providerType(): AccountSSOProviderType {
return _.sample( return sample(Object.values(AccountSSOProviderType)) as AccountSSOProviderType
Object.values(AccountSSOProviderType)
) as AccountSSOProviderType
} }
function provider(): AccountSSOProvider { function provider(): AccountSSOProvider {
return _.sample(Object.values(AccountSSOProvider)) as AccountSSOProvider return sample(Object.values(AccountSSOProvider)) as AccountSSOProvider
} }
export function ssoAccount(account: Account = cloudAccount()): SSOAccount { export function ssoAccount(account: Account = cloudAccount()): SSOAccount {

View File

@ -1,7 +1,6 @@
import { ScimCreateGroupRequest, ScimCreateUserRequest } from "@budibase/types" import { ScimCreateGroupRequest, ScimCreateUserRequest } from "@budibase/types"
import { uuid } from "./common" import { uuid } from "./common"
import { generator } from "./generator" import { generator } from "./generator"
import _ from "lodash"
interface CreateUserRequestFields { interface CreateUserRequestFields {
externalId: string externalId: string
@ -20,10 +19,10 @@ export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
username: generator.name(), username: generator.name(),
} }
const { externalId, email, firstName, lastName, username } = _.assign( const { externalId, email, firstName, lastName, username } = {
defaultValues, ...defaultValues,
userData ...userData,
) }
let user: ScimCreateUserRequest = { let user: ScimCreateUserRequest = {
schemas: [ schemas: [

View File

@ -15,7 +15,7 @@ import { generator } from "./generator"
import { email, uuid } from "./common" import { email, uuid } from "./common"
import * as shared from "./shared" import * as shared from "./shared"
import { user } from "./shared" import { user } from "./shared"
import _ from "lodash" import sample from "lodash/sample"
export function OAuth(): OAuth2 { export function OAuth(): OAuth2 {
return { return {
@ -47,7 +47,7 @@ export function authDetails(userDoc?: User): SSOAuthDetails {
} }
export function providerType(): SSOProviderType { export function providerType(): SSOProviderType {
return _.sample(Object.values(SSOProviderType)) as SSOProviderType return sample(Object.values(SSOProviderType)) as SSOProviderType
} }
export function ssoProfile(user?: User): SSOProfile { export function ssoProfile(user?: User): SSOProfile {

View File

@ -1,5 +1,6 @@
import { import {
AdminUser, AdminUser,
AdminOnlyUser,
BuilderUser, BuilderUser,
SSOAuthDetails, SSOAuthDetails,
SSOUser, SSOUser,
@ -21,6 +22,15 @@ export const adminUser = (userProps?: any): AdminUser => {
} }
} }
export const adminOnlyUser = (userProps?: any): AdminOnlyUser => {
return {
...user(userProps),
admin: {
global: true,
},
}
}
export const builderUser = (userProps?: any): BuilderUser => { export const builderUser = (userProps?: any): BuilderUser => {
return { return {
...user(userProps), ...user(userProps),
@ -30,6 +40,15 @@ export const builderUser = (userProps?: any): BuilderUser => {
} }
} }
export const appBuilderUser = (appId: string, userProps?: any): BuilderUser => {
return {
...user(userProps),
builder: {
apps: [appId],
},
}
}
export function ssoUser( export function ssoUser(
opts: { user?: any; details?: SSOAuthDetails } = {} opts: { user?: any; details?: SSOAuthDetails } = {}
): SSOUser { ): SSOUser {

View File

@ -4,9 +4,9 @@
"composite": true, "composite": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@budibase/types": ["../types/src"] "@budibase/types": ["../types/src"],
"@budibase/shared-core": ["../shared-core/src"]
} }
}, },
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -85,7 +85,8 @@
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"easymde": "^2.16.1", "easymde": "^2.16.1",
"svelte-flatpickr": "3.2.3", "svelte-flatpickr": "3.2.3",
"svelte-portal": "^1.0.0" "svelte-portal": "^1.0.0",
"svelte-dnd-action": "^0.9.8"
}, },
"resolutions": { "resolutions": {
"loader-utils": "1.4.1" "loader-utils": "1.4.1"
@ -96,13 +97,14 @@
"dependsOn": [ "dependsOn": [
{ {
"projects": [ "projects": [
"@budibase/string-templates" "@budibase/string-templates",
"@budibase/shared-core",
"@budibase/types"
], ],
"target": "build" "target": "build"
} }
] ]
} }
} }
}, }
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

@ -64,7 +64,7 @@ export default function positionDropdown(element, opts) {
// Apply styles // Apply styles
Object.entries(styles).forEach(([style, value]) => { Object.entries(styles).forEach(([style, value]) => {
if (value) { if (value != null) {
element.style[style] = `${value.toFixed(0)}px` element.style[style] = `${value.toFixed(0)}px`
} else { } else {
element.style[style] = null element.style[style] = null

View File

@ -1,5 +1,4 @@
<script> <script>
//import { createEventDispatcher } from "svelte"
import "@spectrum-css/popover/dist/index-vars.css" import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside" import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"

View File

@ -0,0 +1,252 @@
<script>
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import Icon from "../Icon/Icon.svelte"
import Popover from "../Popover/Popover.svelte"
import { onMount } from "svelte"
const flipDurationMs = 150
export let constraints
export let optionColors = {}
let options = []
let colorPopovers = []
let anchors = []
let colorsArray = [
"hsla(0, 90%, 75%, 0.3)",
"hsla(50, 80%, 75%, 0.3)",
"hsla(120, 90%, 75%, 0.3)",
"hsla(200, 90%, 75%, 0.3)",
"hsla(240, 90%, 75%, 0.3)",
"hsla(320, 90%, 75%, 0.3)",
]
$: {
if (constraints.inclusion.length) {
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
}
}
const removeInput = idx => {
delete optionColors[options[idx].name]
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
options = options.filter((e, i) => i !== idx)
colorPopovers.pop(undefined)
anchors.pop(undefined)
}
const addNewInput = () => {
options = [
...options,
{ name: `Option ${constraints.inclusion.length + 1}`, id: Math.random() },
]
constraints.inclusion = [
...constraints.inclusion,
`Option ${constraints.inclusion.length + 1}`,
]
colorPopovers.push(undefined)
anchors.push(undefined)
}
const handleDndConsider = e => {
options = e.detail.items
}
const handleDndFinalize = e => {
options = e.detail.items
constraints.inclusion = options.map(option => option.name)
}
const handleColorChange = (optionName, color, idx) => {
optionColors[optionName] = color
colorPopovers[idx].hide()
}
const handleNameChange = (optionName, idx, value) => {
constraints.inclusion[idx] = value
options[idx].name = value
optionColors[value] = optionColors[optionName]
delete optionColors[optionName]
}
const openColorPickerPopover = (optionIdx, target) => {
colorPopovers[optionIdx].show()
anchors[optionIdx] = target
}
onMount(() => {
// Initialize anchor arrays on mount, assuming 'options' is already populated
colorPopovers = constraints.inclusion.map(() => undefined)
anchors = constraints.inclusion.map(() => undefined)
})
</script>
<div>
<div
class="actions"
use:dndzone={{
items: options,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#each options as option, idx (option.id)}
<div
class="no-border action-container"
animate:flip={{ duration: flipDurationMs }}
>
<div class="child drag-handle-spacing">
<Icon name="DragHandle" size="L" />
</div>
<div class="child color-picker">
<div
id="color-picker"
bind:this={anchors[idx]}
style="--color:{optionColors?.[option.name] ||
'hsla(0, 1%, 50%, 0.3)'}"
class="circle"
on:click={e => openColorPickerPopover(idx, e.target)}
>
<Popover
bind:this={colorPopovers[idx]}
anchor={anchors[idx]}
align="left"
offset={0}
style=""
popoverTarget={document.getElementById(`color-picker`)}
animate={false}
>
<div class="colors">
{#each colorsArray as color}
<div
on:click={() => handleColorChange(option.name, color, idx)}
style="--color:{color};"
class="circle circle-hover"
/>
{/each}
</div>
</Popover>
</div>
</div>
<div class="child">
<input
class="input-field"
type="text"
on:change={e => handleNameChange(option.name, idx, e.target.value)}
value={option.name}
placeholder="Option name"
/>
</div>
<div class="child">
<Icon name="Close" hoverable size="S" on:click={removeInput(idx)} />
</div>
</div>
{/each}
</div>
<div on:click={addNewInput} class="add-option">
<Icon hoverable name="Add" />
<div>Add option</div>
</div>
</div>
<style>
.action-container {
background-color: var(--spectrum-alias-background-color-primary);
border-radius: 0px;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
display: flex;
flex-direction: row;
align-items: center;
}
.no-border {
border-bottom: none;
}
.action-container:last-child {
border-bottom: 1px solid var(--spectrum-global-color-gray-300) !important;
}
.child {
height: 30px;
}
.child:hover,
.child:focus {
background: var(--spectrum-global-color-gray-200);
}
.add-option {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--spacing-m);
gap: var(--spacing-m);
cursor: pointer;
}
.input-field {
border: none;
outline: none;
background-color: transparent;
width: 100%;
color: var(--text);
}
.child input[type="text"] {
padding-left: 10px;
}
.input-field:hover,
.input-field:focus {
background: var(--spectrum-global-color-gray-200);
}
.action-container > :nth-child(1) {
flex-grow: 1;
justify-content: center;
display: flex;
}
.action-container > :nth-child(2) {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
}
.action-container > :nth-child(3) {
flex-grow: 4;
display: flex;
}
.action-container > :nth-child(4) {
flex-grow: 1;
justify-content: center;
display: flex;
}
.circle {
height: 20px;
width: 20px;
background-color: var(--color);
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
}
.circle-hover:hover {
border: 1px solid var(--spectrum-global-color-blue-400);
cursor: pointer;
}
.colors {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-xl);
justify-items: center;
margin: var(--spacing-m);
}
</style>

View File

@ -21,6 +21,7 @@
export let offset = 5 export let offset = 5
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -77,8 +78,9 @@
}} }}
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class="spectrum-Popover is-open"
class:customZindex
role="presentation" role="presentation"
style="height: {customHeight}" style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }} transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
> >
<slot /> <slot />
@ -92,4 +94,8 @@
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
overflow: auto; overflow: auto;
} }
.customZindex {
z-index: var(--customZindex) !important;
}
</style> </style>

View File

@ -84,7 +84,7 @@ export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte
export { default as Slider } from "./Form/Slider.svelte" export { default as Slider } from "./Form/Slider.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte" export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as File } from "./Form/File.svelte" export { default as File } from "./Form/File.svelte"
export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte"
// Renderers // Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte" export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"

View File

@ -101,14 +101,14 @@
"@rollup/plugin-replace": "^2.4.2", "@rollup/plugin-replace": "^2.4.2",
"@roxi/routify": "2.18.5", "@roxi/routify": "2.18.5",
"@sveltejs/vite-plugin-svelte": "1.0.1", "@sveltejs/vite-plugin-svelte": "1.0.1",
"@testing-library/jest-dom": "^5.11.10", "@testing-library/jest-dom": "5.17.0",
"@testing-library/svelte": "^3.2.2", "@testing-library/svelte": "^3.2.2",
"babel-jest": "^26.6.3", "babel-jest": "29.6.2",
"cypress": "^9.3.1", "cypress": "^9.3.1",
"cypress-multi-reporters": "^1.6.0", "cypress-multi-reporters": "^1.6.0",
"cypress-terminal-report": "^1.4.1", "cypress-terminal-report": "^1.4.1",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3", "jest": "29.6.2",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",
"mochawesome": "^7.1.3", "mochawesome": "^7.1.3",
"mochawesome-merge": "^4.2.1", "mochawesome-merge": "^4.2.1",
@ -133,8 +133,21 @@
"dependsOn": [ "dependsOn": [
{ {
"projects": [ "projects": [
"@budibase/shared-core",
"@budibase/string-templates", "@budibase/string-templates",
"@budibase/shared-core" "@budibase/types"
],
"target": "build"
}
]
},
"dev:builder": {
"dependsOn": [
{
"projects": [
"@budibase/shared-core",
"@budibase/string-templates",
"@budibase/types"
], ],
"target": "build" "target": "build"
} }
@ -145,13 +158,13 @@
{ {
"projects": [ "projects": [
"@budibase/shared-core", "@budibase/shared-core",
"@budibase/string-templates" "@budibase/string-templates",
"@budibase/types"
], ],
"target": "build" "target": "build"
} }
] ]
} }
} }
}, }
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"
} }

View File

@ -507,6 +507,7 @@ const getSelectedRowsBindings = asset => {
readableBinding: `${table._instanceName}.Selected rows`, readableBinding: `${table._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
icon: "ViewRow", icon: "ViewRow",
display: { name: table._instanceName },
})) }))
) )
@ -522,6 +523,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`, )}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`, readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
display: { name: block._instanceName },
})) }))
) )
} }

View File

@ -108,7 +108,10 @@
/****************************************************/ /****************************************************/
const getInputData = (testData, blockInputs) => { const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs // Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
// Ensures the app action fields are populated
if (block.event === "app:trigger" && !newInputData?.fields) { if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs) newInputData = cloneDeep(blockInputs)
} }

View File

@ -50,6 +50,7 @@
type="string" type="string"
{bindings} {bindings}
fillWidth={true} fillWidth={true}
updateOnChange={false}
/> />
{/each} {/each}
</div> </div>

View File

@ -1,5 +1,5 @@
<script> <script>
import { datasources, tables } from "stores/backend" import { datasources, tables, integrations } from "stores/backend"
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
@ -27,6 +27,17 @@
$: isUsersTable = id === TableNames.USERS $: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external" $: isInternal = $tables.selected?.type !== "external"
$: datasource = $datasources.list.find(datasource => {
return datasource._id === $tables.selected?.sourceId
})
$: relationshipsEnabled = relationshipSupport(datasource)
const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source]
return !isInternal && integration?.relationships !== false
}
const handleGridTableUpdate = async e => { const handleGridTableUpdate = async e => {
tables.replaceTable(id, e.detail) tables.replaceTable(id, e.detail)
@ -53,12 +64,19 @@
<svelte:fragment slot="filter"> <svelte:fragment slot="filter">
<GridFilterButton /> <GridFilterButton />
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
{#if isInternal} {#if isInternal}
<GridCreateViewButton /> <GridCreateViewButton />
{/if} {/if}
<GridManageAccessButton /> <GridManageAccessButton />
{#if !isInternal} {#if relationshipsEnabled}
<GridRelationshipButton /> <GridRelationshipButton />
{/if} {/if}
{#if isUsersTable} {#if isUsersTable}
@ -66,9 +84,8 @@
{:else} {:else}
<GridImportButton /> <GridImportButton />
{/if} {/if}
<GridExportButton /> <GridExportButton />
<GridAddColumnModal />
<GridEditColumnModal />
{#if isUsersTable} {#if isUsersTable}
<GridEditUserModal /> <GridEditUserModal />
{:else} {:else}

View File

@ -7,12 +7,12 @@
Toggle, Toggle,
RadioGroup, RadioGroup,
DatePicker, DatePicker,
ModalContent,
Context,
Modal, Modal,
notifications, notifications,
OptionSelectDnD,
Layout,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/backend" import { tables, datasources } from "stores/backend"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -26,12 +26,10 @@
SWITCHABLE_TYPES, SWITCHABLE_TYPES,
} from "constants/backend" } from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils" import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
import ValuesList from "components/common/ValuesList.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { truncate } from "lodash" import { truncate } from "lodash"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula" import { getBindings } from "components/backend/DataTable/formula"
import { getContext } from "svelte"
import JSONSchemaModal from "./JSONSchemaModal.svelte" import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core" import { ValidColumnNameRegex } from "@budibase/shared-core"
@ -45,11 +43,11 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { hide } = getContext(Context.Modal) const { dispatch: gridDispatch } = getContext("grid")
let fieldDefinitions = cloneDeep(FIELDS)
export let field export let field
let fieldDefinitions = cloneDeep(FIELDS)
let originalName let originalName
let linkEditDisabled let linkEditDisabled
let primaryDisplay let primaryDisplay
@ -61,11 +59,10 @@
let savingColumn let savingColumn
let deleteColName let deleteColName
let jsonSchemaModal let jsonSchemaModal
let allowedTypes = []
let editableColumn = { let editableColumn = {
type: "string", type: "string",
constraints: fieldDefinitions.STRING.constraints, constraints: fieldDefinitions.STRING.constraints,
// Initial value for column name in other table for linked records // Initial value for column name in other table for linked records
fieldName: $tables.selected.name, fieldName: $tables.selected.name,
} }
@ -83,7 +80,23 @@
primaryDisplay = primaryDisplay =
$tables.selected.primaryDisplay == null || $tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name $tables.selected.primaryDisplay === editableColumn.name
} else if (!savingColumn) {
let highestNumber = 0
Object.keys(table.schema).forEach(columnName => {
const columnNumber = extractColumnNumber(columnName)
if (columnNumber > highestNumber) {
highestNumber = columnNumber
}
return highestNumber
})
if (highestNumber >= 1) {
editableColumn.name = `Column 0${highestNumber + 1}`
} else {
editableColumn.name = "Column 01"
}
} }
allowedTypes = getAllowedTypes()
} }
$: initialiseField(field, savingColumn) $: initialiseField(field, savingColumn)
@ -182,6 +195,8 @@
indexes, indexes,
}) })
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column")
if ( if (
saveColumn.type === LINK_TYPE && saveColumn.type === LINK_TYPE &&
saveColumn.relationshipType === RelationshipType.MANY_TO_MANY saveColumn.relationshipType === RelationshipType.MANY_TO_MANY
@ -203,6 +218,7 @@
function cancelEdit() { function cancelEdit() {
editableColumn.name = originalName editableColumn.name = originalName
gridDispatch("close-edit-column")
} }
async function deleteColumn() { async function deleteColumn() {
@ -214,8 +230,8 @@
await tables.deleteField(editableColumn) await tables.deleteField(editableColumn)
notifications.success(`Column ${editableColumn.name} deleted`) notifications.success(`Column ${editableColumn.name} deleted`)
confirmDeleteDialog.hide() confirmDeleteDialog.hide()
hide()
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column")
} }
} catch (error) { } catch (error) {
notifications.error(`Error deleting column: ${error.message}`) notifications.error(`Error deleting column: ${error.message}`)
@ -251,14 +267,6 @@
required = req required = req
} }
function onChangePrimaryDisplay(e) {
const isPrimary = e.detail
// primary display is always required
if (isPrimary) {
editableColumn.constraints.presence = { allowEmpty: false }
}
}
function openJsonSchemaEditor() { function openJsonSchemaEditor() {
jsonSchemaModal.show() jsonSchemaModal.show()
} }
@ -272,6 +280,11 @@
deleteColName = "" deleteColName = ""
} }
function extractColumnNumber(columnName) {
const match = columnName.match(/Column (\d+)/)
return match ? parseInt(match[1]) : 0
}
function getRelationshipOptions(field) { function getRelationshipOptions(field) {
if (!field || !field.tableId) { if (!field || !field.tableId) {
return null return null
@ -402,15 +415,8 @@
} }
</script> </script>
<ModalContent <Layout noPadding gap="S">
title={originalName ? "Edit Column" : "Create Column"}
confirmText="Save Column"
onConfirm={saveColumn}
onCancel={cancelEdit}
disabled={invalid}
>
<Input <Input
label="Name"
bind:value={editableColumn.name} bind:value={editableColumn.name}
disabled={uneditable || disabled={uneditable ||
(linkEditDisabled && editableColumn.type === LINK_TYPE)} (linkEditDisabled && editableColumn.type === LINK_TYPE)}
@ -419,12 +425,12 @@
<Select <Select
disabled={!typeEnabled} disabled={!typeEnabled}
label="Type"
bind:value={editableColumn.type} bind:value={editableColumn.type}
on:change={handleTypeChange} on:change={handleTypeChange}
options={getAllowedTypes()} options={allowedTypes}
getOptionLabel={field => field.name} getOptionLabel={field => field.name}
getOptionValue={field => field.type} getOptionValue={field => field.type}
getOptionIcon={field => field.icon}
isOptionEnabled={option => { isOptionEnabled={option => {
if (option.type == AUTO_TYPE) { if (option.type == AUTO_TYPE) {
return availableAutoColumnKeys?.length > 0 return availableAutoColumnKeys?.length > 0
@ -433,28 +439,6 @@
}} }}
/> />
{#if canBeRequired || canBeDisplay}
<div>
{#if canBeRequired}
<Toggle
value={required}
on:change={onChangeRequired}
disabled={primaryDisplay}
thin
text="Required"
/>
{/if}
{#if canBeDisplay}
<Toggle
bind:value={primaryDisplay}
on:change={onChangePrimaryDisplay}
thin
text="Use as table display column"
/>
{/if}
</div>
{/if}
{#if editableColumn.type === "string"} {#if editableColumn.type === "string"}
<Input <Input
type="number" type="number"
@ -462,9 +446,9 @@
bind:value={editableColumn.constraints.length.maximum} bind:value={editableColumn.constraints.length.maximum}
/> />
{:else if editableColumn.type === "options"} {:else if editableColumn.type === "options"}
<ValuesList <OptionSelectDnD
label="Options (one per line)" bind:constraints={editableColumn.constraints}
bind:values={editableColumn.constraints.inclusion} bind:optionColors={editableColumn.optionColors}
/> />
{:else if editableColumn.type === "longform"} {:else if editableColumn.type === "longform"}
<div> <div>
@ -480,19 +464,28 @@
/> />
</div> </div>
{:else if editableColumn.type === "array"} {:else if editableColumn.type === "array"}
<ValuesList <OptionSelectDnD
label="Options (one per line)" bind:constraints={editableColumn.constraints}
bind:values={editableColumn.constraints.inclusion} bind:optionColors={editableColumn.optionColors}
/> />
{:else if editableColumn.type === "datetime" && !editableColumn.autocolumn} {:else if editableColumn.type === "datetime" && !editableColumn.autocolumn}
<DatePicker <div class="split-label">
label="Earliest" <div class="label-length">
bind:value={editableColumn.constraints.datetime.earliest} <Label size="M">Earliest</Label>
/> </div>
<DatePicker <div class="input-length">
label="Latest" <DatePicker bind:value={editableColumn.constraints.datetime.earliest} />
bind:value={editableColumn.constraints.datetime.latest} </div>
/> </div>
<div class="split-label">
<div class="label-length">
<Label size="M">Latest</Label>
</div>
<div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.latest} />
</div>
</div>
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"} {#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
<div> <div>
<Label <Label
@ -509,16 +502,30 @@
</div> </div>
{/if} {/if}
{:else if editableColumn.type === "number" && !editableColumn.autocolumn} {:else if editableColumn.type === "number" && !editableColumn.autocolumn}
<Input <div class="split-label">
type="number" <div class="label-length">
label="Min Value" <Label size="M">Max Value</Label>
bind:value={editableColumn.constraints.numericality.greaterThanOrEqualTo} </div>
/> <div class="input-length">
<Input <Input
type="number" type="number"
label="Max Value" bind:value={editableColumn.constraints.numericality
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo} .greaterThanOrEqualTo}
/> />
</div>
</div>
<div class="split-label">
<div class="label-length">
<Label size="M">Max Value</Label>
</div>
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
/>
</div>
</div>
{:else if editableColumn.type === "link"} {:else if editableColumn.type === "link"}
<Select <Select
label="Table" label="Table"
@ -547,32 +554,44 @@
/> />
{:else if editableColumn.type === FORMULA_TYPE} {:else if editableColumn.type === FORMULA_TYPE}
{#if !table.sql} {#if !table.sql}
<Select <div class="split-label">
label="Formula type" <div class="label-length">
bind:value={editableColumn.formulaType} <Label size="M">Formula Type</Label>
options={[ </div>
{ label: "Dynamic", value: "dynamic" }, <div class="input-length">
{ label: "Static", value: "static" }, <Select
]} bind:value={editableColumn.formulaType}
getOptionLabel={option => option.label} options={[
getOptionValue={option => option.value} { label: "Dynamic", value: "dynamic" },
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by, { label: "Static", value: "static" },
]}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by,
while static formula are calculated when the row is saved." while static formula are calculated when the row is saved."
/> />
</div>
</div>
{/if} {/if}
<ModalBindableInput <div class="split-label">
title="Formula" <div class="label-length">
label="Formula" <Label size="M">Formula</Label>
value={editableColumn.formula} </div>
on:change={e => { <div class="input-length">
editableColumn = { <ModalBindableInput
...editableColumn, title="Formula"
formula: e.detail, value={editableColumn.formula}
} on:change={e => {
}} editableColumn = {
bindings={getBindings({ table })} ...editableColumn,
allowJS formula: e.detail,
/> }
}}
bindings={getBindings({ table })}
allowJS
/>
</div>
</div>
{:else if editableColumn.type === JSON_TYPE} {:else if editableColumn.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor} <Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button >Open schema editor</Button
@ -591,12 +610,28 @@
/> />
{/if} {/if}
<div slot="footer"> {#if canBeRequired || canBeDisplay}
{#if !uneditable && originalName != null} <div>
<Button warning text on:click={confirmDelete}>Delete</Button> {#if canBeRequired}
{/if} <Toggle
</div> value={required}
</ModalContent> on:change={onChangeRequired}
disabled={primaryDisplay}
thin
text="Required"
/>
{/if}
</div>
{/if}
</Layout>
<div class="action-buttons">
{#if !uneditable && originalName != null}
<Button quiet warning text on:click={confirmDelete}>Delete</Button>
{/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button disabled={invalid} newStyles cta on:click={saveColumn}>Save</Button>
</div>
<Modal bind:this={jsonSchemaModal}> <Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal <JSONSchemaModal
schema={editableColumn.schema} schema={editableColumn.schema}
@ -607,6 +642,7 @@
}} }}
/> />
</Modal> </Modal>
<ConfirmDialog <ConfirmDialog
bind:this={confirmDeleteDialog} bind:this={confirmDeleteDialog}
okText="Delete Column" okText="Delete Column"
@ -622,3 +658,24 @@
</p> </p>
<Input bind:value={deleteColName} placeholder={originalName} /> <Input bind:value={deleteColName} placeholder={originalName} />
</ConfirmDialog> </ConfirmDialog>
<style>
.action-buttons {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-s);
gap: var(--spacing-l);
}
.split-label {
display: flex;
align-items: center;
}
.label-length {
flex-basis: 40%;
}
.input-length {
flex-grow: 1;
}
</style>

View File

@ -1,15 +1,8 @@
<script> <script>
import { getContext, onMount } from "svelte" import { getContext } from "svelte"
import { Modal } from "@budibase/bbui"
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte" import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
const { rows, subscribe } = getContext("grid") const { rows } = getContext("grid")
let modal
onMount(() => subscribe("add-column", modal.show))
</script> </script>
<Modal bind:this={modal}> <CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} />
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} />
</Modal>

View File

@ -1,24 +1,19 @@
<script> <script>
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { Modal } from "@budibase/bbui"
import CreateEditColumn from "../CreateEditColumn.svelte" import CreateEditColumn from "../CreateEditColumn.svelte"
const { rows, subscribe } = getContext("grid") const { rows, subscribe } = getContext("grid")
let editableColumn let editableColumn
let editColumnModal
const editColumn = column => { const editColumn = column => {
editableColumn = column editableColumn = column
editColumnModal.show()
} }
onMount(() => subscribe("edit-column", editColumn)) onMount(() => subscribe("edit-column", editColumn))
</script> </script>
<Modal bind:this={editColumnModal}> <CreateEditColumn
<CreateEditColumn field={editableColumn}
field={editableColumn} on:updatecolumns={rows.actions.refreshData}
on:updatecolumns={rows.actions.refreshData} />
/>
</Modal>

View File

@ -341,7 +341,7 @@
</Tab> </Tab>
{/if} {/if}
<div class="drawer-actions"> <div class="drawer-actions">
{#if typeof drawerActions.hide === "function" && drawerActions.headless} {#if typeof drawerActions?.hide === "function" && drawerActions?.headless}
<Button <Button
secondary secondary
quiet quiet
@ -352,7 +352,7 @@
Cancel Cancel
</Button> </Button>
{/if} {/if}
{#if typeof bindingDrawerActions?.save === "function" && drawerActions.headless} {#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless}
<Button <Button
cta cta
disabled={!valid} disabled={!valid}

View File

@ -206,6 +206,11 @@
return allBindings return allBindings
} }
const toDisplay = eventKey => {
const type = actionTypes.find(action => action.name == eventKey)
return type?.displayName || type?.name
}
</script> </script>
<DrawerContent> <DrawerContent>
@ -231,7 +236,9 @@
<ul> <ul>
{#each category as actionType} {#each category as actionType}
<li on:click={onAddAction(actionType)}> <li on:click={onAddAction(actionType)}>
<span class="action-name">{actionType.name}</span> <span class="action-name">
{actionType.displayName || actionType.name}
</span>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -262,7 +269,7 @@
> >
<Icon name="DragHandle" size="XL" /> <Icon name="DragHandle" size="XL" />
<div class="action-header"> <div class="action-header">
{index + 1}.&nbsp;{action[EVENT_TYPE_KEY]} {index + 1}.&nbsp;{toDisplay(action[EVENT_TYPE_KEY])}
</div> </div>
<Icon <Icon
name="Close" name="Close"

View File

@ -1,5 +1,5 @@
<script> <script>
import { Select, Label, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -10,47 +10,59 @@
</script> </script>
<div class="root"> <div class="root">
<Label>Table</Label> <Body size="small">Please specify one or more rows to delete.</Body>
<Select <div class="params">
bind:value={parameters.tableId} <Label>Table</Label>
options={tableOptions} <Select
getOptionLabel={table => table.name} bind:value={parameters.tableId}
getOptionValue={table => table._id} options={tableOptions}
/> getOptionLabel={table => table.name}
getOptionValue={table => table._id}
<Label small>Row ID</Label>
<DrawerBindableInput
{bindings}
title="Row ID to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete this row?"
bind:value={parameters.confirmText}
/> />
{/if}
<Label small>Row IDs</Label>
<DrawerBindableInput
{bindings}
title="Rows to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
/>
{/if}
</div>
</div> </div>
<style> <style>
.root { .root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.params {
display: grid; display: grid;
column-gap: var(--spacing-l); column-gap: var(--spacing-l);
row-gap: var(--spacing-s); row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr; grid-template-columns: 60px 1fr;
align-items: center; align-items: center;
max-width: 800px;
margin: 0 auto;
} }
</style> </style>

View File

@ -73,9 +73,12 @@
{#if query?.parameters?.length > 0} {#if query?.parameters?.length > 0}
<div class="params"> <div class="params">
<BindingBuilder <BindingBuilder
bind:customParams={parameters.queryParams} customParams={parameters.queryParams}
queryBindings={query.parameters} queryBindings={query.parameters}
bind:bindings bind:bindings
on:change={v => {
parameters.queryParams = { ...v.detail }
}}
/> />
<IntegrationQueryEditor <IntegrationQueryEditor
height={200} height={200}

View File

@ -24,6 +24,7 @@
}, },
{ {
"name": "Delete Row", "name": "Delete Row",
"displayName": "Delete Rows",
"type": "data", "type": "data",
"component": "DeleteRow" "component": "DeleteRow"
}, },

View File

@ -143,13 +143,12 @@
} }
const openQueryParamsDrawer = () => { const openQueryParamsDrawer = () => {
tmpQueryParams = value.queryParams tmpQueryParams = { ...value.queryParams }
drawer.show() drawer.show()
} }
const getQueryValue = queries => { const getQueryValue = queries => {
value = queries.find(q => q._id === value._id) || value return queries.find(q => q._id === value._id) || value
return value
} }
const saveQueryParams = () => { const saveQueryParams = () => {
@ -176,7 +175,10 @@
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
{#if getQueryParams(value).length > 0} {#if getQueryParams(value).length > 0}
<BindingBuilder <BindingBuilder
bind:customParams={tmpQueryParams} customParams={tmpQueryParams}
on:change={v => {
tmpQueryParams = { ...v.detail }
}}
queryBindings={getQueryParams(value)} queryBindings={getQueryParams(value)}
bind:bindings bind:bindings
/> />

View File

@ -5,6 +5,9 @@
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let bindable = true export let bindable = true
export let queryBindings = [] export let queryBindings = []
@ -20,7 +23,10 @@
// The readable binding in the UI gets converted to a UUID value that the client understands // The readable binding in the UI gets converted to a UUID value that the client understands
// for parsing, then converted back so we can display it the readable form in the UI // for parsing, then converted back so we can display it the readable form in the UI
function onBindingChange(param, valueToParse) { function onBindingChange(param, valueToParse) {
customParams[param] = readableToRuntimeBinding(bindings, valueToParse) dispatch("change", {
...customParams,
[param]: readableToRuntimeBinding(bindings, valueToParse),
})
} }
</script> </script>

View File

@ -14,8 +14,9 @@
Tab, Tab,
Modal, Modal,
ModalContent, ModalContent,
notifications,
Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte" import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte" import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
@ -28,6 +29,7 @@
import KeyValueBuilder from "./KeyValueBuilder.svelte" import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils" import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte" import AccessLevelSelect from "./AccessLevelSelect.svelte"
import { ValidQueryNameRegex } from "@budibase/shared-core"
export let query export let query
@ -47,6 +49,7 @@
let saveModal let saveModal
let override = false let override = false
let navigateTo = null let navigateTo = null
let nameError = null
// seed the transformer // seed the transformer
if (query && !query.transformer) { if (query && !query.transformer) {
@ -77,7 +80,7 @@
$: queryConfig = integrationInfo?.query $: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = queryConfig && query.queryVerb $: shouldShowQueryConfig = queryConfig && query.queryVerb
$: readQuery = query.queryVerb === "read" || query.readable $: readQuery = query.queryVerb === "read" || query.readable
$: queryInvalid = !query.name || (readQuery && data.length === 0) $: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
//Cast field in query preview response to number if specified by schema //Cast field in query preview response to number if specified by schema
$: { $: {
@ -139,9 +142,10 @@
queryStr = JSON.stringify(query) queryStr = JSON.stringify(query)
} }
notifications.success("Query saved successfully")
return response return response
} catch (error) { } catch (error) {
notifications.error("Error saving query") notifications.error(error.message || "Error saving query")
} }
} }
</script> </script>
@ -183,8 +187,14 @@
value={query.name} value={query.name}
on:input={e => { on:input={e => {
let newValue = e.target.value || "" let newValue = e.target.value || ""
query.name = newValue.trim() if (newValue.match(ValidQueryNameRegex)) {
query.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}} }}
error={nameError}
/> />
</div> </div>
{#if queryConfig} {#if queryConfig}
@ -250,9 +260,9 @@
size="L" size="L"
/> />
</div> </div>
<Body size="S" <Body size="S">
>Add a JavaScript function to transform the query result.</Body Add a JavaScript function to transform the query result.
> </Body>
<CodeMirrorEditor <CodeMirrorEditor
height={200} height={200}
label="Transformer" label="Transformer"
@ -264,13 +274,12 @@
</div> </div>
<div class="viewer-controls"> <div class="viewer-controls">
<Heading size="S">Results</Heading> <Heading size="S">Results</Heading>
<ButtonGroup gap="XS"> <ButtonGroup gap="S">
<Button <Button
cta cta
disabled={queryInvalid} disabled={queryInvalid}
on:click={async () => { on:click={async () => {
await saveQuery() await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query // Go to the correct URL if we just created a new query
if (!query._rev) { if (!query._rev) {
$goto(`../../${query._id}`) $goto(`../../${query._id}`)

View File

@ -1,16 +1,21 @@
<script> <script>
import { Heading, Body, Button, Icon } from "@budibase/bbui" import { Heading, Body, Button, Icon } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { auth } from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
export let app export let app
export let lockedAction export let lockedAction
$: editing = app.sessions?.length $: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
const handleDefaultClick = () => { const handleDefaultClick = () => {
if (window.innerWidth < 640) { if (!isBuilder) {
goToApp()
} else if (window.innerWidth < 640) {
goToOverview() goToOverview()
} else { } else {
goToBuilder() goToBuilder()
@ -24,6 +29,10 @@
const goToOverview = () => { const goToOverview = () => {
$goto(`../../app/${app.devId}/settings`) $goto(`../../app/${app.devId}/settings`)
} }
const goToApp = () => {
window.open(`/app/${app.name}`, "_blank")
}
</script> </script>
<div class="app-row" on:click={lockedAction || handleDefaultClick}> <div class="app-row" on:click={lockedAction || handleDefaultClick}>
@ -39,7 +48,7 @@
</div> </div>
<div class="updated"> <div class="updated">
{#if editing} {#if editing && isBuilder}
Currently editing Currently editing
<UserAvatars users={app.sessions} /> <UserAvatars users={app.sessions} />
{:else if app.updatedAt} {:else if app.updatedAt}
@ -56,14 +65,21 @@
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body> <Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
</div> </div>
<div class="app-row-actions"> {#if isBuilder}
<Button size="S" secondary on:click={lockedAction || goToOverview}> <div class="app-row-actions">
Manage <Button size="S" secondary on:click={lockedAction || goToOverview}>
</Button> Manage
<Button size="S" primary on:click={lockedAction || goToBuilder}> </Button>
Edit <Button size="S" primary on:click={lockedAction || goToBuilder}>
</Button> Edit
</div> </Button>
</div>
{:else}
<!-- this can happen if an app builder has app user access to an app -->
<div class="app-row-actions">
<Button size="S" secondary>View</Button>
</div>
{/if}
</div> </div>
<style> <style>

View File

@ -2,6 +2,7 @@ export const FIELDS = {
STRING: { STRING: {
name: "Text", name: "Text",
type: "string", type: "string",
icon: "Text",
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -11,6 +12,7 @@ export const FIELDS = {
BARCODEQR: { BARCODEQR: {
name: "Barcode/QR", name: "Barcode/QR",
type: "barcodeqr", type: "barcodeqr",
icon: "Camera",
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -20,6 +22,7 @@ export const FIELDS = {
LONGFORM: { LONGFORM: {
name: "Long Form Text", name: "Long Form Text",
type: "longform", type: "longform",
icon: "TextAlignLeft",
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -29,6 +32,7 @@ export const FIELDS = {
OPTIONS: { OPTIONS: {
name: "Options", name: "Options",
type: "options", type: "options",
icon: "Dropdown",
constraints: { constraints: {
type: "string", type: "string",
presence: false, presence: false,
@ -38,6 +42,7 @@ export const FIELDS = {
ARRAY: { ARRAY: {
name: "Multi-select", name: "Multi-select",
type: "array", type: "array",
icon: "Duplicate",
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
@ -47,6 +52,7 @@ export const FIELDS = {
NUMBER: { NUMBER: {
name: "Number", name: "Number",
type: "number", type: "number",
icon: "123",
constraints: { constraints: {
type: "number", type: "number",
presence: false, presence: false,
@ -56,10 +62,12 @@ export const FIELDS = {
BIGINT: { BIGINT: {
name: "BigInt", name: "BigInt",
type: "bigint", type: "bigint",
icon: "TagBold",
}, },
BOOLEAN: { BOOLEAN: {
name: "Boolean", name: "Boolean",
type: "boolean", type: "boolean",
icon: "Boolean",
constraints: { constraints: {
type: "boolean", type: "boolean",
presence: false, presence: false,
@ -68,6 +76,7 @@ export const FIELDS = {
DATETIME: { DATETIME: {
name: "Date/Time", name: "Date/Time",
type: "datetime", type: "datetime",
icon: "Calendar",
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -81,6 +90,7 @@ export const FIELDS = {
ATTACHMENT: { ATTACHMENT: {
name: "Attachment", name: "Attachment",
type: "attachment", type: "attachment",
icon: "Folder",
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
@ -89,6 +99,7 @@ export const FIELDS = {
LINK: { LINK: {
name: "Relationship", name: "Relationship",
type: "link", type: "link",
icon: "Link",
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
@ -97,11 +108,13 @@ export const FIELDS = {
FORMULA: { FORMULA: {
name: "Formula", name: "Formula",
type: "formula", type: "formula",
icon: "Calculator",
constraints: {}, constraints: {},
}, },
JSON: { JSON: {
name: "JSON", name: "JSON",
type: "json", type: "json",
icon: "Brackets",
constraints: { constraints: {
type: "object", type: "object",
presence: false, presence: false,

View File

@ -12,12 +12,12 @@
} 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 } from "@budibase/frontend-core" import { fetchData, Constants, Utils } from "@budibase/frontend-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"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
import UpgradeModal from "components/common/users/UpgradeModal.svelte" import UpgradeModal from "components/common/users/UpgradeModal.svelte"
import { Constants, Utils } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation" import { emailValidator } from "helpers/validation"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
@ -108,9 +108,9 @@
await usersFetch.refresh() await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => { filteredUsers = $usersFetch.rows.map(user => {
const isBuilderOrAdmin = user.admin?.global || user.builder?.global const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId)
let role = undefined let role = undefined
if (isBuilderOrAdmin) { if (isAdminOrBuilder) {
role = Constants.Roles.ADMIN role = Constants.Roles.ADMIN
} else { } else {
const appRole = Object.keys(user.roles).find(x => x === prodAppId) const appRole = Object.keys(user.roles).find(x => x === prodAppId)
@ -122,7 +122,7 @@
return { return {
...user, ...user,
role, role,
isBuilderOrAdmin, isAdminOrBuilder,
} }
}) })
} }
@ -258,7 +258,7 @@
} }
// Must exclude users who have explicit privileges // Must exclude users who have explicit privileges
const userByEmail = filteredUsers.reduce((acc, user) => { const userByEmail = filteredUsers.reduce((acc, user) => {
if (user.role || user.admin?.global || user.builder?.global) { if (user.role || sdk.users.isAdminOrBuilder(user, prodAppId)) {
acc.push(user.email) acc.push(user.email)
} }
return acc return acc
@ -389,9 +389,9 @@
} }
const userTitle = user => { const userTitle = user => {
if (user.admin?.global) { if (sdk.users.isAdmin(user)) {
return "Admin" return "Admin"
} else if (user.builder?.global) { } else if (sdk.users.isBuilder(user, prodAppId)) {
return "Developer" return "Developer"
} else { } else {
return "App user" return "App user"
@ -403,7 +403,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.isBuilderOrAdmin) { if (user.isAdminOrBuilder) {
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
@ -614,7 +614,7 @@
}} }}
autoWidth autoWidth
align="right" align="right"
allowedRoles={user.isBuilderOrAdmin allowedRoles={user.isAdminOrBuilder
? [Constants.Roles.ADMIN] ? [Constants.Roles.ADMIN]
: null} : null}
/> />

View File

@ -2,14 +2,15 @@
import { Button, Layout } from "@budibase/bbui" import { Button, Layout } from "@budibase/bbui"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte" import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { isActive, goto, redirect } from "@roxi/routify" import { isActive, redirect, goto, params } from "@roxi/routify"
import BetaButton from "./_components/BetaButton.svelte" import BetaButton from "./_components/BetaButton.svelte"
import { datasources } from "stores/backend" import { datasources } from "stores/backend"
$: { $: {
// If we ever don't have any data other than the users table, prompt the // If we ever don't have any data other than the users table, prompt the
// user to add some // user to add some
if (!$datasources.hasData) { // Don't redirect if setting up google sheets, or we lose the query parameter
if (!$datasources.hasData && !$params["?continue_google_setup"]) {
$redirect("./new") $redirect("./new")
} }
} }

View File

@ -75,6 +75,14 @@
{ {
"name": "Chart", "name": "Chart",
"icon": "GraphBarVertical", "icon": "GraphBarVertical",
"children": ["bar", "line", "area", "candlestick", "pie", "donut"] "children": [
"bar",
"line",
"area",
"candlestick",
"pie",
"donut",
"histogram"
]
} }
] ]

View File

@ -4,6 +4,8 @@
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "builderStore" import { isOnlyUser } from "builderStore"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
let deleteModal let deleteModal
</script> </script>
@ -44,22 +46,24 @@
url={$url("./version")} url={$url("./version")}
active={$isActive("./version")} active={$isActive("./version")}
/> />
<div class="delete-action"> {#if sdk.users.isGlobalBuilder($auth.user)}
<AbsTooltip <div class="delete-action">
position={TooltipPosition.Bottom} <AbsTooltip
text={$isOnlyUser position={TooltipPosition.Bottom}
? null text={$isOnlyUser
: "Unavailable - another user is editing this app"} ? null
> : "Unavailable - another user is editing this app"}
<SideNavItem >
text="Delete app" <SideNavItem
disabled={!$isOnlyUser} text="Delete app"
on:click={() => { disabled={!$isOnlyUser}
deleteModal.show() on:click={() => {
}} deleteModal.show()
/> }}
</AbsTooltip> />
</div> </AbsTooltip>
</div>
{/if}
</SideNav> </SideNav>
<slot /> <slot />
</Content> </Content>

View File

@ -22,53 +22,39 @@
import Spaceman from "assets/bb-space-man.svg" import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core" import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core" import { helpers, sdk } from "@budibase/shared-core"
let loaded = false let loaded = false
let userInfoModal let userInfoModal
let changePasswordModal let changePasswordModal
onMount(async () => {
try {
await organisation.init()
await apps.load()
await groups.actions.init()
} catch (error) {
notifications.error("Error loading apps")
}
loaded = true
})
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
$: userGroups = $groups.filter(group => $: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id) group.users.find(user => user._id === $auth.user?._id)
) )
let userApps = [] $: publishedApps = $apps.filter(app => app.status === AppStatus.DEPLOYED)
$: publishedApps = $apps.filter(publishedAppsOnly) $: userApps = getUserApps(publishedApps, userGroups, $auth.user)
$: { function getUserApps(publishedApps, userGroups, user) {
if (!Object.keys($auth.user?.roles).length && $auth.user?.userGroups) { if (sdk.users.isAdmin(user)) {
userApps = return publishedApps
$auth.user?.builder?.global || $auth.user?.admin?.global
? publishedApps
: publishedApps.filter(app => {
return userGroups.find(group => {
return groups.actions
.getGroupAppIds(group)
.map(role => apps.extractAppId(role))
.includes(app.appId)
})
})
} else {
userApps =
$auth.user?.builder?.global || $auth.user?.admin?.global
? publishedApps
: publishedApps.filter(app =>
Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x))
.includes(app.appId)
)
} }
return publishedApps.filter(app => {
if (sdk.users.isBuilder(user, app.appId)) {
return true
}
if (!Object.keys(user?.roles).length && user?.userGroups) {
return userGroups.find(group => {
return groups.actions
.getGroupAppIds(group)
.map(role => apps.extractAppId(role))
.includes(app.appId)
})
} else {
return Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x))
.includes(app.appId)
}
})
} }
function getUrl(app) { function getUrl(app) {
@ -86,6 +72,17 @@
// Swallow error and do nothing // Swallow error and do nothing
} }
} }
onMount(async () => {
try {
await organisation.init()
await apps.load()
await groups.actions.init()
} catch (error) {
notifications.error("Error loading apps")
}
loaded = true
})
</script> </script>
{#if $auth.user && loaded} {#if $auth.user && loaded}
@ -109,7 +106,7 @@
> >
Update password Update password
</MenuItem> </MenuItem>
{#if $auth.isBuilder} {#if sdk.users.hasBuilderPermissions($auth.user)}
<MenuItem <MenuItem
icon="UserDeveloper" icon="UserDeveloper"
on:click={() => $goto("../portal")} on:click={() => $goto("../portal")}

View File

@ -1,11 +1,12 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
auth.checkQueryString() auth.checkQueryString()
$: { $: {
if ($auth.user?.builder?.global) { if (sdk.users.hasBuilderPermissions($auth.user)) {
$redirect(`./portal`) $redirect(`./portal`)
} else if ($auth.user) { } else if ($auth.user) {
$redirect(`./apps`) $redirect(`./apps`)

View File

@ -3,6 +3,7 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { auth, admin, licensing } from "stores/portal" import { auth, admin, licensing } from "stores/portal"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
</script> </script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan} {#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
@ -17,7 +18,7 @@
> >
Upgrade Upgrade
</Button> </Button>
{:else if !$admin.cloud && $auth.isAdmin} {:else if !$admin.cloud && sdk.users.isAdmin($auth.user)}
<Button <Button
cta cta
size="S" size="S"

View File

@ -8,13 +8,14 @@
import Logo from "./_components/Logo.svelte" import Logo from "./_components/Logo.svelte"
import UserDropdown from "./_components/UserDropdown.svelte" import UserDropdown from "./_components/UserDropdown.svelte"
import HelpMenu from "components/common/HelpMenu.svelte" import HelpMenu from "components/common/HelpMenu.svelte"
import { sdk } from "@budibase/shared-core"
let loaded = false let loaded = false
let mobileMenuVisible = false let mobileMenuVisible = false
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: fullscreen = !$apps.length $: isOnboarding = !$apps.length && sdk.users.isGlobalBuilder($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -33,7 +34,7 @@
onMount(async () => { onMount(async () => {
// Prevent non-builders from accessing the portal // Prevent non-builders from accessing the portal
if ($auth.user) { if ($auth.user) {
if (!$auth.user?.builder?.global) { if (!sdk.users.hasBuilderPermissions($auth.user)) {
$redirect("../") $redirect("../")
} else { } else {
try { try {
@ -49,7 +50,7 @@
</script> </script>
{#if $auth.user && loaded} {#if $auth.user && loaded}
{#if fullscreen} {#if isOnboarding}
<slot /> <slot />
{:else} {:else}
<HelpMenu /> <HelpMenu />

View File

@ -1,5 +1,5 @@
<script> <script>
import { Layout, Body, Button } from "@budibase/bbui" import { Layout, Heading, Body, Button } from "@budibase/bbui"
import { downloadStream } from "@budibase/frontend-core" import { downloadStream } from "@budibase/frontend-core"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
@ -18,6 +18,7 @@
</script> </script>
<Layout noPadding> <Layout noPadding>
<Heading>System logs</Heading>
<Body>Download your latest logs to share with the Budibase team</Body> <Body>Download your latest logs to share with the Budibase team</Body>
<div class="download-button"> <div class="download-button">
<Button cta on:click={download} disabled={loading}> <Button cta on:click={download} disabled={loading}>
@ -25,7 +26,7 @@
{#if loading} {#if loading}
<Spinner size="10" /> <Spinner size="10" />
{/if} {/if}
Download system logs Download
</div> </div>
</Button> </Button>
</div> </div>

View File

@ -19,6 +19,7 @@
import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte" import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
$: license = $auth.user.license $: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` $: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
@ -176,7 +177,7 @@
}) })
</script> </script>
{#if $auth.isAdmin} {#if sdk.users.isAdmin($auth.user)}
<DeleteLicenseKeyModal <DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal} bind:this={deleteLicenseKeyModal}
onConfirm={deleteLicenseKey} onConfirm={deleteLicenseKey}

View File

@ -14,6 +14,7 @@
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { DashCard, Usage } from "components/usage" import { DashCard, Usage } from "components/usage"
import { PlanModel } from "constants" import { PlanModel } from "constants"
import { sdk } from "@budibase/shared-core"
let staticUsage = [] let staticUsage = []
let monthlyUsage = [] let monthlyUsage = []
@ -51,7 +52,8 @@
$: accountPortalAccess = $auth?.user?.accountPortalAccess $: accountPortalAccess = $auth?.user?.accountPortalAccess
$: quotaReset = quotaUsage?.quotaReset $: quotaReset = quotaUsage?.quotaReset
$: canManagePlan = $: canManagePlan =
($admin.cloud && accountPortalAccess) || (!$admin.cloud && $auth.isAdmin) ($admin.cloud && accountPortalAccess) ||
(!$admin.cloud && sdk.users.isAdmin($auth.user))
$: showButton = !usesInvoicing && accountPortalAccess $: showButton = !usesInvoicing && accountPortalAccess

View File

@ -1,11 +1,19 @@
<script> <script>
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { admin, apps, templates, licensing, groups } from "stores/portal" import {
admin,
apps,
templates,
licensing,
groups,
auth,
} from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = $apps.length > 0 let loaded = $apps.length != null
onMount(async () => { onMount(async () => {
try { try {
@ -25,7 +33,7 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length) { if (!$apps.length && sdk.users.isGlobalBuilder($auth.user)) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {

View File

@ -15,6 +15,7 @@
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte" import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
import { sdk } from "@budibase/shared-core"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { API } from "api" import { API } from "api"
@ -203,40 +204,40 @@
}) })
</script> </script>
{#if $apps.length} <Page>
<Page> <Layout noPadding gap="L">
<Layout noPadding gap="L"> {#each Object.keys(automationErrors || {}) as appId}
{#each Object.keys(automationErrors || {}) as appId} <Notification
<Notification wide
wide dismissable
dismissable action={() => goToAutomationError(appId)}
action={() => goToAutomationError(appId)} type="error"
type="error" icon="Alert"
icon="Alert" actionMessage={errorCount(automationErrors[appId]) > 1
actionMessage={errorCount(automationErrors[appId]) > 1 ? "View errors"
? "View errors" : "View error"}
: "View error"} on:dismiss={async () => {
on:dismiss={async () => { await automationStore.actions.clearLogErrors({ appId })
await automationStore.actions.clearLogErrors({ appId }) await apps.load()
await apps.load() }}
}} message={automationErrorMessage(appId)}
message={automationErrorMessage(appId)} />
/> {/each}
{/each} <div class="title">
<div class="title"> <div class="welcome">
<div class="welcome"> <Layout noPadding gap="XS">
<Layout noPadding gap="XS"> <Heading size="L">{welcomeHeader}</Heading>
<Heading size="L">{welcomeHeader}</Heading> <Body size="M">
<Body size="M"> Below you'll find the list of apps that you have access to
Manage your apps and get a head start with templates </Body>
</Body> </Layout>
</Layout>
</div>
</div> </div>
</div>
{#if enrichedApps.length} {#if enrichedApps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.isGlobalBuilder($auth.user)}
<div class="buttons"> <div class="buttons">
<Button <Button
size="M" size="M"
@ -266,41 +267,46 @@
</Button> </Button>
{/if} {/if}
</div> </div>
{#if enrichedApps.length > 1} {/if}
<div class="app-actions"> {#if enrichedApps.length > 1}
<Select <div class="app-actions">
autoWidth <Select
bind:value={sortBy} autoWidth
placeholder={null} bind:value={sortBy}
options={[ placeholder={null}
{ label: "Sort by name", value: "name" }, options={[
{ label: "Sort by recently updated", value: "updated" }, { label: "Sort by name", value: "name" },
{ label: "Sort by status", value: "status" }, { label: "Sort by recently updated", value: "updated" },
]} { label: "Sort by status", value: "status" },
/> ]}
<Search placeholder="Search" bind:value={searchTerm} /> />
</div> <Search placeholder="Search" bind:value={searchTerm} />
{/if} </div>
</div> {/if}
<div class="app-table">
{#each filteredApps as app (app.appId)}
<AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
</div> </div>
{/if}
</Layout> <div class="app-table">
</Page> {#each filteredApps as app (app.appId)}
{/if} <AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
{:else}
<div class="no-apps">
<img class="spaceman" alt="spaceman" src={Logo} width="100px" />
<Body weight="700">You haven't been given access to any apps yet</Body>
</div>
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
</div>
{/if}
</Layout>
</Page>
<Modal <Modal
bind:this={creationModal} bind:this={creationModal}
@ -368,6 +374,16 @@
height: 160px; height: 160px;
} }
.no-apps {
background-color: var(--spectrum-global-color-gray-100);
padding: calc(var(--spacing-xl) * 2);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: var(--spacing-xl);
}
@media (max-width: 1000px) { @media (max-width: 1000px) {
.img-logo { .img-logo {
display: none; display: none;

View File

@ -18,6 +18,7 @@
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
const imageExtensions = [ const imageExtensions = [
".png", ".png",
@ -206,7 +207,7 @@
}) })
</script> </script>
{#if $auth.isAdmin && mounted} {#if sdk.users.isAdmin($auth.user) && mounted}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<div class="title"> <div class="title">
@ -400,7 +401,7 @@
on:click={() => { on:click={() => {
if (isCloud && $auth?.user?.accountPortalAccess) { if (isCloud && $auth?.user?.accountPortalAccess) {
window.open($admin.accountPortalUrl + "/portal/upgrade", "_blank") window.open($admin.accountPortalUrl + "/portal/upgrade", "_blank")
} else if ($auth.isAdmin) { } else if (sdk.users.isAdmin($auth.user)) {
$goto("/builder/portal/account/upgrade") $goto("/builder/portal/account/upgrade")
} }
}} }}

View File

@ -13,6 +13,7 @@
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
let diagnosticInfo = "" let diagnosticInfo = ""
@ -46,7 +47,7 @@
}) })
</script> </script>
{#if $auth.isAdmin && diagnosticInfo} {#if sdk.users.isAdmin($auth.user) && diagnosticInfo}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS"> <Layout gap="XS">
<Heading size="M">Diagnostics</Heading> <Heading size="M">Diagnostics</Heading>

View File

@ -13,10 +13,11 @@
import { auth, organisation, admin } from "stores/portal" import { auth, organisation, admin } from "stores/portal"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
// Only admins allowed here // Only admins allowed here
$: { $: {
if (!$auth.isAdmin) { if (!sdk.users.isAdmin($auth.user)) {
$redirect("../../portal") $redirect("../../portal")
} }
} }
@ -50,7 +51,7 @@
} }
</script> </script>
{#if $auth.isAdmin} {#if sdk.users.isAdmin($auth.user)}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Organisation</Heading> <Heading size="M">Organisation</Heading>

View File

@ -14,6 +14,7 @@
import { API } from "api" import { API } from "api"
import { auth, admin } from "stores/portal" import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
let version let version
let loaded = false let loaded = false
@ -25,7 +26,7 @@
// Only admins allowed here // Only admins allowed here
$: { $: {
if (!$auth.isAdmin || $admin.cloud) { if (!sdk.users.isAdmin($auth.user) || $admin.cloud) {
$redirect("../../portal") $redirect("../../portal")
} }
} }
@ -89,7 +90,7 @@
}) })
</script> </script>
{#if $auth.isAdmin} {#if sdk.users.isAdmin($auth.user)}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Version</Heading> <Heading size="M">Version</Heading>

View File

@ -20,6 +20,7 @@
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
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"
export let groupId export let groupId
@ -46,7 +47,7 @@
let editModal, deleteModal let editModal, deleteModal
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)
$: groupApps = $apps $: groupApps = $apps
.filter(app => .filter(app =>

View File

@ -3,6 +3,7 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { auth, groups, users } from "stores/portal" import { auth, groups, users } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let groupId export let groupId
export let onUsersUpdated export let onUsersUpdated
@ -13,7 +14,7 @@
let prevSearch = undefined let prevSearch = undefined
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
$: readonly = !$auth.isAdmin $: readonly = !sdk.users.isAdmin($auth.user)
$: page = $pageInfo.page $: page = $pageInfo.page
$: searchUsers(page, searchTerm) $: searchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)

View File

@ -9,6 +9,7 @@
import { setContext } from "svelte" import { setContext } from "svelte"
import ScimBanner from "../../_components/SCIMBanner.svelte" import ScimBanner from "../../_components/SCIMBanner.svelte"
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte" import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
import { sdk } from "@budibase/shared-core"
export let groupId export let groupId
@ -49,7 +50,7 @@
] ]
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
const removeUser = async id => { const removeUser = async id => {
await groups.actions.removeUser(groupId, id) await groups.actions.removeUser(groupId, id)

View File

@ -2,6 +2,7 @@
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value export let value
@ -13,6 +14,10 @@
} }
</script> </script>
<ActionButton disabled={!$auth.isAdmin} size="S" on:click={onClick}> <ActionButton
disabled={!sdk.users.isAdmin($auth.user)}
size="S"
on:click={onClick}
>
Remove Remove
</ActionButton> </ActionButton>

View File

@ -22,6 +22,7 @@
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte" import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
const DefaultGroup = { const DefaultGroup = {
name: "", name: "",
@ -40,7 +41,7 @@
{ column: "roles", component: GroupAppsTableRenderer }, { column: "roles", component: GroupAppsTableRenderer },
] ]
$: readonly = !$auth.isAdmin $: readonly = !sdk.users.isAdmin($auth.user)
$: schema = { $: schema = {
name: { displayName: "Group", width: "2fr", minWidth: "200px" }, name: { displayName: "Group", width: "2fr", minWidth: "200px" },
users: { sortable: false, width: "1fr" }, users: { sortable: false, width: "1fr" },

View File

@ -31,6 +31,7 @@
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
export let userId export let userId
@ -87,8 +88,8 @@
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider $: isSSO = !!user?.provider
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global $: privileged = sdk.users.isAdminOrBuilder(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)
@ -97,9 +98,9 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = user?.admin?.global $: globalRole = sdk.users.isAdmin(user)
? "admin" ? "admin"
: user?.builder?.global : sdk.users.isBuilder(user)
? "developer" ? "developer"
: "appUser" : "appUser"
@ -285,7 +286,7 @@
<div class="field"> <div class="field">
<Label size="L">Role</Label> <Label size="L">Role</Label>
<Select <Select
disabled={!$auth.isAdmin} disabled={!sdk.users.isAdmin($auth.user)}
value={globalRole} value={globalRole}
options={Constants.BudibaseRoleOptions} options={Constants.BudibaseRoleOptions}
on:change={updateUserRole} on:change={updateUserRole}

View File

@ -1,11 +1,12 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value export let value
export let row export let row
$: priviliged = row?.admin?.global || row?.builder?.global $: priviliged = sdk.users.isAdminOrBuilder(row)
$: count = priviliged ? $apps.length : value?.length || 0 $: count = priviliged ? $apps.length : value?.length || 0
</script> </script>

View File

@ -2,6 +2,7 @@
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value export let value
@ -13,6 +14,10 @@
} }
</script> </script>
<ActionButton disabled={!$auth.isAdmin} size="S" on:click={onClick}> <ActionButton
disabled={!sdk.users.isAdmin($auth.user)}
size="S"
on:click={onClick}
>
Remove Remove
</ActionButton> </ActionButton>

View File

@ -2,6 +2,7 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { Body, Select, ModalContent, notifications } from "@budibase/bbui" import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
import { users } from "stores/portal" import { users } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let app export let app
export let user export let user
@ -15,7 +16,7 @@
.filter(role => role._id !== "PUBLIC") .filter(role => role._id !== "PUBLIC")
.map(role => ({ value: role._id, label: role.name })) .map(role => ({ value: role._id, label: role.name }))
if (!user?.builder?.global) { if (!sdk.users.isBuilder(user, app?.appId)) {
options.push({ value: NO_ACCESS, label: "No Access" }) options.push({ value: NO_ACCESS, label: "No Access" })
} }
let selectedRole = user?.roles?.[app?._id] let selectedRole = user?.roles?.[app?._id]

View File

@ -39,6 +39,7 @@
import { API } from "api" import { API } from "api"
import { OnboardingType } from "../../../../../constants" import { OnboardingType } from "../../../../../constants"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
const fetch = fetchData({ const fetch = fetchData({
API, API,
@ -66,7 +67,7 @@
let userData = [] let userData = []
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
$: readonly = !$auth.isAdmin || $features.isScimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || $features.isScimEnabled
$: debouncedUpdateFetch(searchEmail) $: debouncedUpdateFetch(searchEmail)
$: schema = { $: schema = {

View File

@ -2,6 +2,7 @@ import { derived, writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { admin } from "stores/portal" import { admin } from "stores/portal"
import analytics from "analytics" import analytics from "analytics"
import { sdk } from "@budibase/shared-core"
export function createAuthStore() { export function createAuthStore() {
const auth = writable({ const auth = writable({
@ -13,13 +14,6 @@ export function createAuthStore() {
postLogout: false, postLogout: false,
}) })
const store = derived(auth, $store => { const store = derived(auth, $store => {
let isAdmin = false
let isBuilder = false
if ($store.user) {
const user = $store.user
isAdmin = !!user.admin?.global
isBuilder = !!user.builder?.global
}
return { return {
user: $store.user, user: $store.user,
accountPortalAccess: $store.accountPortalAccess, accountPortalAccess: $store.accountPortalAccess,
@ -27,8 +21,6 @@ export function createAuthStore() {
tenantSet: $store.tenantSet, tenantSet: $store.tenantSet,
loaded: $store.loaded, loaded: $store.loaded,
postLogout: $store.postLogout, postLogout: $store.postLogout,
isAdmin,
isBuilder,
isSSO: !!$store.user?.provider, isSSO: !!$store.user?.provider,
} }
}) })
@ -57,8 +49,8 @@ export function createAuthStore() {
name: user.account?.name, name: user.account?.name,
user_id: user._id, user_id: user._id,
tenant: user.tenantId, tenant: user.tenantId,
admin: user?.admin?.global, admin: sdk.users.isAdmin(user),
builder: user?.builder?.global, builder: sdk.users.isBuilder(user),
"Company size": user.account?.size, "Company size": user.account?.size,
"Job role": user.account?.profession, "Job role": user.account?.profession,
}, },

View File

@ -2,8 +2,12 @@ import { derived } from "svelte/store"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { admin } from "./admin" import { admin } from "./admin"
import { auth } from "./auth" import { auth } from "./auth"
import { sdk } from "@budibase/shared-core"
export const menu = derived([admin, auth], ([$admin, $auth]) => { export const menu = derived([admin, auth], ([$admin, $auth]) => {
const user = $auth?.user
const isAdmin = sdk.users.isAdmin(user)
const cloud = $admin?.cloud
// Determine user sub pages // Determine user sub pages
let userSubPages = [ let userSubPages = [
{ {
@ -24,19 +28,21 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
title: "Apps", title: "Apps",
href: "/builder/portal/apps", href: "/builder/portal/apps",
}, },
{ ]
if (sdk.users.isGlobalBuilder(user)) {
menu.push({
title: "Users", title: "Users",
href: "/builder/portal/users", href: "/builder/portal/users",
subPages: userSubPages, subPages: userSubPages,
}, })
{ menu.push({
title: "Plugins", title: "Plugins",
href: "/builder/portal/plugins", href: "/builder/portal/plugins",
}, })
] }
// Add settings page for admins // Add settings page for admins
if ($auth.isAdmin) { if (isAdmin) {
let settingsSubPages = [ let settingsSubPages = [
{ {
title: "Auth", title: "Auth",
@ -59,7 +65,7 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/settings/environment", href: "/builder/portal/settings/environment",
}, },
] ]
if (!$admin.cloud) { if (!cloud) {
settingsSubPages.push({ settingsSubPages.push({
title: "Version", title: "Version",
href: "/builder/portal/settings/version", href: "/builder/portal/settings/version",
@ -84,38 +90,35 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/account/usage", href: "/builder/portal/account/usage",
}, },
] ]
if ($auth.isAdmin) { if (isAdmin) {
accountSubPages.push({ accountSubPages.push({
title: "Audit Logs", title: "Audit Logs",
href: "/builder/portal/account/auditLogs", href: "/builder/portal/account/auditLogs",
}) })
if (!$admin.cloud) { if (!cloud) {
accountSubPages.push({ accountSubPages.push({
title: "System Logs", title: "System Logs",
href: "/builder/portal/account/systemLogs", href: "/builder/portal/account/systemLogs",
}) })
} }
} }
if ($admin.cloud && $auth?.user?.accountPortalAccess) { if (cloud && user?.accountPortalAccess) {
accountSubPages.push({ accountSubPages.push({
title: "Upgrade", title: "Upgrade",
href: $admin.accountPortalUrl + "/portal/upgrade", href: $admin?.accountPortalUrl + "/portal/upgrade",
}) })
} else if (!$admin.cloud && $auth.isAdmin) { } else if (!cloud && isAdmin) {
accountSubPages.push({ accountSubPages.push({
title: "Upgrade", title: "Upgrade",
href: "/builder/portal/account/upgrade", href: "/builder/portal/account/upgrade",
}) })
} }
// add license check here // add license check here
if ( if (user?.accountPortalAccess && user.account.stripeCustomerId) {
$auth?.user?.accountPortalAccess &&
$auth.user.account.stripeCustomerId
) {
accountSubPages.push({ accountSubPages.push({
title: "Billing", title: "Billing",
href: $admin.accountPortalUrl + "/portal/billing", href: $admin?.accountPortalUrl + "/portal/billing",
}) })
} }
menu.push({ menu.push({

View File

@ -2,6 +2,7 @@ import { writable } from "svelte/store"
import { API } from "api" import { API } from "api"
import { update } from "lodash" import { update } from "lodash"
import { licensing } from "." import { licensing } from "."
import { sdk } from "@budibase/shared-core"
export function createUsersStore() { export function createUsersStore() {
const { subscribe, set } = writable({}) const { subscribe, set } = writable({})
@ -111,8 +112,12 @@ export function createUsersStore() {
return await API.saveUser(user) return await API.saveUser(user)
} }
const getUserRole = ({ admin, builder }) => const getUserRole = user =>
admin?.global ? "admin" : builder?.global ? "developer" : "appUser" sdk.users.isAdmin(user)
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const refreshUsage = const refreshUsage =
fn => fn =>

View File

@ -14,6 +14,7 @@
"tsc": "tsc -p tsconfig.build.json", "tsc": "tsc -p tsconfig.build.json",
"pkg": "pkg . --out-path build --no-bytecode --public --public-packages \"*\" -C GZip", "pkg": "pkg . --out-path build --no-bytecode --public --public-packages \"*\" -C GZip",
"build": "yarn prebuild && yarn rename && yarn tsc && yarn pkg && yarn postbuild", "build": "yarn prebuild && yarn rename && yarn tsc && yarn pkg && yarn postbuild",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",
"postbuild": "rm -rf prebuilds 2> /dev/null" "postbuild": "rm -rf prebuilds 2> /dev/null"
}, },
"pkg": { "pkg": {
@ -45,7 +46,7 @@
"lookpath": "1.1.0", "lookpath": "1.1.0",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"pkg": "5.8.0", "pkg": "5.8.0",
"posthog-node": "1.0.7", "posthog-node": "1.3.0",
"pouchdb": "7.3.0", "pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9", "pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5", "randomstring": "1.1.5",
@ -53,9 +54,9 @@
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
"@swc/core": "^1.3.25", "@swc/core": "1.3.71",
"@swc/jest": "^0.2.24", "@swc/jest": "0.2.27",
"@types/jest": "^29.4.0", "@types/jest": "29.5.3",
"@types/node-fetch": "2.6.1", "@types/node-fetch": "2.6.1",
"@types/pouchdb": "^6.4.0", "@types/pouchdb": "^6.4.0",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
@ -70,7 +71,8 @@
"dependsOn": [ "dependsOn": [
{ {
"projects": [ "projects": [
"@budibase/backend-core" "@budibase/backend-core",
"@budibase/string-templates"
], ],
"target": "build" "target": "build"
} }

View File

@ -16,7 +16,6 @@
"require": ["tsconfig-paths/register"], "require": ["tsconfig-paths/register"],
"swc": true "swc": true
}, },
"references": [{ "path": "../types" }, { "path": "../backend-core" }],
"include": ["src/**/*", "package.json"], "include": ["src/**/*", "package.json"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -2205,6 +2205,147 @@
} }
] ]
}, },
"histogram": {
"name": "Histogram Chart",
"description": "Histogram chart",
"icon": "Histogram",
"size": {
"width": 600,
"height": 400
},
"requiredAncestors": ["dataprovider"],
"settings": [
{
"type": "text",
"label": "Title",
"key": "title"
},
{
"type": "dataProvider",
"label": "Provider",
"key": "dataProvider",
"required": true
},
{
"type": "field",
"label": "Data column",
"key": "valueColumn",
"dependsOn": "dataProvider",
"required": true
},
{
"type": "text",
"label": "Y axis label",
"key": "yAxisLabel",
"defaultValue": "Frequency"
},
{
"type": "text",
"label": "X axis label",
"key": "xAxisLabel"
},
{
"type": "number",
"label": "Bucket count",
"key": "bucketCount",
"defaultValue": 10,
"min": 2
},
{
"type": "boolean",
"label": "Data labels",
"key": "dataLabels",
"defaultValue": false
},
{
"type": "text",
"label": "Width",
"key": "width"
},
{
"type": "text",
"label": "Height",
"key": "height",
"defaultValue": "400"
},
{
"type": "select",
"label": "Colors",
"key": "palette",
"defaultValue": "Palette 1",
"options": [
"Custom",
"Palette 1",
"Palette 2",
"Palette 3",
"Palette 4",
"Palette 5",
"Palette 6",
"Palette 7",
"Palette 8",
"Palette 9",
"Palette 10"
]
},
{
"type": "color",
"label": "C1",
"key": "c1",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C2",
"key": "c2",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C3",
"key": "c3",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C4",
"key": "c4",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C5",
"key": "c5",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "boolean",
"label": "Animate",
"key": "animate",
"defaultValue": true
},
{
"type": "boolean",
"label": "Horizontal",
"key": "horizontal",
"defaultValue": false
}
]
},
"form": { "form": {
"name": "Form", "name": "Form",
"icon": "Form", "icon": "Form",
@ -3954,6 +4095,10 @@
"label": "Bar", "label": "Bar",
"value": "bar" "value": "bar"
}, },
{
"label": "Histogram",
"value": "histogram"
},
{ {
"label": "Line", "label": "Line",
"value": "line" "value": "line"
@ -4204,6 +4349,47 @@
} }
] ]
}, },
{
"section": true,
"name": "Histogram Chart",
"icon": "Histogram",
"dependsOn": {
"setting": "chartType",
"value": "histogram"
},
"settings": [
{
"type": "field",
"label": "Value column",
"key": "valueColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "text",
"label": "Y axis label",
"key": "yAxisLabel"
},
{
"type": "text",
"label": "X axis label",
"key": "xAxisLabel"
},
{
"type": "boolean",
"label": "Horizontal",
"key": "horizontal",
"defaultValue": false
},
{
"type": "number",
"label": "Bucket count",
"key": "bucketCount",
"defaultValue": 10,
"min": 2
}
]
},
{ {
"section": true, "section": true,
"name": "Line Chart", "name": "Line Chart",
@ -5223,11 +5409,7 @@
"type": "boolean", "type": "boolean",
"label": "Hide notifications", "label": "Hide notifications",
"key": "notificationOverride", "key": "notificationOverride",
"defaultValue": false, "defaultValue": false
"dependsOn": {
"setting": "showSaveButton",
"value": true
}
} }
] ]
} }

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