Merge branch 'develop' into backmerge-20230807

This commit is contained in:
Adria Navarro 2023-08-07 12:40:39 +03:00
commit 8015398d3a
505 changed files with 13893 additions and 8336 deletions

View File

@ -5,7 +5,7 @@
"jest": true, "jest": true,
"node": true "node": true
}, },
"parser": "babel-eslint", "parser": "@babel/eslint-parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": 2019, "ecmaVersion": 2019,
"sourceType": "module", "sourceType": "module",
@ -18,17 +18,23 @@
"*.spec.js", "*.spec.js",
"bundle.js" "bundle.js"
], ],
"plugins": ["svelte3"],
"extends": ["eslint:recommended"], "extends": ["eslint:recommended"],
"overrides": [ "overrides": [
{ {
"files": ["*.svelte"], "files": ["**/*.svelte"],
"processor": "svelte3/svelte3" "extends": "plugin:svelte/recommended",
"parser": "svelte-eslint-parser",
"parserOptions": {
"parser": "@babel/eslint-parser",
"ecmaVersion": 2019,
"sourceType": "module",
"allowImportExportEverywhere": true
}
}, },
{ {
"files": ["**/*.ts"], "files": ["**/*.ts"],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"plugins": [],
"extends": ["eslint:recommended"], "extends": ["eslint:recommended"],
"rules": { "rules": {
"no-unused-vars": "off", "no-unused-vars": "off",
@ -41,7 +47,8 @@
} }
], ],
"rules": { "rules": {
"no-self-assign": "off" "no-self-assign": "off",
"no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_", "destructuredArrayIgnorePattern": "^_" }]
}, },
"globals": { "globals": {
"GeolocationPositionError": true "GeolocationPositionError": true

19
.github/stale.yml vendored
View File

@ -1,19 +0,0 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Number of days of inactivity before an Issue or Pull Request becomes stale
daysUntilStale: 60
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
daysUntilClose: false
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- roadmap
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@ -12,9 +12,6 @@ on:
- master - master
- develop - develop
pull_request: pull_request:
branches:
- master
- develop
workflow_dispatch: workflow_dispatch:
env: env:
@ -157,12 +154,12 @@ 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
yarn setup yarn setup
yarn test:ci yarn serve:test:self:ci
env: env:
BB_ADMIN_USER_EMAIL: admin BB_ADMIN_USER_EMAIL: admin
BB_ADMIN_USER_PASSWORD: admin BB_ADMIN_USER_PASSWORD: admin
@ -185,7 +182,7 @@ jobs:
pro_commit=$(git rev-parse HEAD) pro_commit=$(git rev-parse HEAD)
branch="${{ github.base_ref || github.ref_name }}" branch="${{ github.base_ref || github.ref_name }}"
echo "Running on branch `$branch` (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})" echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})"
if [[ $branch == "master" ]]; then if [[ $branch == "master" ]]; then
base_commit=$(git rev-parse origin/master) base_commit=$(git rev-parse origin/master)

View File

@ -34,7 +34,6 @@ jobs:
exit 1 exit 1
fi fi
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 14.x
@ -58,9 +57,12 @@ jobs:
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release yarn release
- name: "Get Previous tag" - name: "Get Current tag"
id: previoustag id: currenttag
uses: "WyriHaximus/github-action-get-previous-tag@v1" run: |
version=v$(./scripts/getCurrentVersion.sh)
echo 'Using tag $version'
echo "::set-output name=tag::$resversionult"
- name: Build/release Docker images - name: Build/release Docker images
run: | run: |
@ -69,7 +71,7 @@ jobs:
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }} BUDIBASE_RELEASE_VERSION: ${{ steps.currenttag.outputs.tag }}
release-helm-chart: release-helm-chart:
needs: [release-images] needs: [release-images]

29
.github/workflows/stale_bot.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: Close stale issues and PRs # https://github.com/actions/stale
on:
workflow_dispatch:
schedule:
- cron: '30 1 * * *' # 1:30 every morning
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
with:
# stale rules
days-before-stale: 60
days-before-pr-stale: 7
stale-issue-label: stale
stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for 60 days."
# close rules
# days after being marked as stale to close
days-before-close: 30
close-issue-label: closed-stale
close-issue-message: This issue has been automatically closed it has not had any activity in 90 days."
days-before-pr-close: 7
# exemptions
exempt-pr-labels: pinned,security,roadmap

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.20.1 nodejs 14.21.3
python 3.10.0 python 3.10.0
yarn 1.22.19

3
babel.config.json Normal file
View File

@ -0,0 +1,3 @@
{
"presets": [["@babel/preset-env", { "targets": { "node": "current" } }]]
}

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,6 +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:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
{{- if .Values.services.proxy.readinessProbe }}
{{- with .Values.services.proxy.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 10 }}
{{- end }}
{{- end }}
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

@ -231,18 +231,33 @@ An overview of the CI pipelines can be found [here](../.github/workflows/README.
### Pro ### Pro
@budibase/pro is the closed source package that supports licensed features in budibase. By default the package will be pulled from NPM and will not normally need to be touched in local development. If you require to update code inside the pro package it can be cloned to the same root level as budibase, e.g. @budibase/pro is the closed source package that supports licensed features in budibase. By default the package will be pulled from NPM and will not normally need to be touched in local development. If you need to make an update to pro and have access to the repo, then you can update your submodule within the mono-repo by running `git submodule update --init` - from here you can use normal submodule flow to develop a change within pro.
Once you have updated to use the pro submodule, it will be linked into all of your local dependencies by NX as with all other monorepo packages. If you have been using the NPM version of `@budibase/pro` then you may need to run a `git reset --hard` to fix all of the pro versions back to `0.0.0` to be monorepo aware.
From here - to develop a change in pro, you can follow the below flow:
``` ```
. # enter the pro submodule
|_ budibase cd packages/pro
|_ budibase-pro # get the base branch you are working from (same as monorepo)
git fetch
git checkout <develop | master>
# create a branch, named the same as the branch in your monorepo
git checkout -b <some branch>
... make changes
# commit the changes you've made, with a message for pro
git commit <something>
# within the monorepo, add the pro reference to your branch, commit it with a message like "Update pro ref"
cd ../..
git add packages/pro
git commit <add the new reference to main repo>
``` ```
From here, you will have created a branch in the pro repository and commited the reference to your branch on the monorepo. When you eventually PR this work back into the mainline branch, you will need to first merge your pro PR to the pro mainline, then go into your PR in the monorepo and update the reference again to the new mainline.
Note that only budibase maintainers will be able to access the pro repo. Note that only budibase maintainers will be able to access the pro repo.
By default, NX will make sure that dependencies are replaced with local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev.
### Troubleshooting ### Troubleshooting
Sometimes, things go wrong. This can be due to incompatible updates on the budibase platform. To clear down your development environment and start again follow **Step 6. Cleanup**, then proceed from **Step 3. Install and Build** in the setup guide above to create a fresh Budibase installation. Sometimes, things go wrong. This can be due to incompatible updates on the budibase platform. To clear down your development environment and start again follow **Step 6. Cleanup**, then proceed from **Step 3. Install and Build** in the setup guide above to create a fresh Budibase installation.

View File

@ -28,3 +28,4 @@ BB_ADMIN_USER_PASSWORD=
# A path that is watched for plugin bundles. Any bundles found are imported automatically/ # A path that is watched for plugin bundles. Any bundles found are imported automatically/
PLUGINS_DIR= PLUGINS_DIR=
ROLLING_LOG_MAX_SIZE=

View File

@ -1,9 +1,7 @@
{ {
"version": "2.8.31", "version": "2.8.31",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": ["packages/*"],
"packages/*"
],
"useNx": true, "useNx": true,
"command": { "command": {
"publish": { "publish": {
@ -19,4 +17,4 @@
"loadEnvFiles": false "loadEnvFiles": false
} }
} }
} }

16
nx.json
View File

@ -1,20 +1,12 @@
{ {
"tasksRunnerOptions": { "tasksRunnerOptions": {
"default": { "default": {
"runner": "nx/tasks-runners/default", "runner": "nx-cloud",
"options": { "options": {
"cacheableOperations": ["build", "test"] "cacheableOperations": ["build", "test", "check:types"],
"accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ="
} }
} }
}, },
"targetDefaults": { "targetDefaults": {}
"dev:builder": {
"dependsOn": [
{
"projects": ["@budibase/string-templates"],
"target": "build"
}
]
}
}
} }

View File

@ -3,27 +3,32 @@
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@esbuild-plugins/tsconfig-paths": "^0.1.2", "@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.2.1", "@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",
"babel-eslint": "^10.0.3",
"esbuild": "^0.18.17", "esbuild": "^0.18.17",
"esbuild-node-externals": "^1.8.0", "esbuild-node-externals": "^1.8.0",
"eslint": "^7.28.0", "eslint": "^8.44.0",
"eslint-plugin-cypress": "^2.11.3", "eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-svelte3": "^3.2.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kill-port": "^1.6.1", "kill-port": "^1.6.1",
"lerna": "7.0.2", "lerna": "7.1.1",
"madge": "^6.0.0", "madge": "^6.0.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"nx": "16.4.3",
"nx-cloud": "16.0.5",
"prettier": "2.8.8", "prettier": "2.8.8",
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0", "rollup-plugin-replace": "^2.2.0",
"svelte": "^3.38.2", "svelte": "^3.38.2",
"typescript": "4.7.3" "typescript": "4.7.3",
"@babel/core": "^7.22.5",
"@babel/eslint-parser": "^7.22.5",
"@babel/preset-env": "^7.22.5",
"eslint-plugin-svelte": "^2.32.2",
"svelte-eslint-parser": "^0.32.0"
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js", "preinstall": "node scripts/syncProPackage.js",
@ -31,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",
@ -41,21 +46,21 @@
"restore": "yarn run clean && yarn run bootstrap && yarn run build", "restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",
"nuke:docker": "lerna run --stream --parallel dev:stack:nuke", "nuke:docker": "lerna run --stream dev:stack:nuke",
"clean": "lerna clean", "clean": "lerna clean",
"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 --parallel dev:builder", "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 --parallel 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 --parallel 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 --parallel dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
"dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0", "dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream", "test": "lerna run --stream test --stream",
"lint:eslint": "eslint packages && eslint qa-core", "lint:eslint": "eslint packages qa-core --max-warnings=0",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix packages qa-core", "lint:fix:eslint": "eslint --fix --max-warnings=0 packages qa-core",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"",
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs", "build:specs": "lerna run --stream specs",
@ -103,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",
@ -51,18 +52,20 @@
"pouchdb": "7.3.0", "pouchdb": "7.3.0",
"pouchdb-find": "7.2.2", "pouchdb-find": "7.2.2",
"redlock": "4.2.0", "redlock": "4.2.0",
"rotating-file-stream": "3.1.0",
"sanitize-s3-objectkey": "0.0.1", "sanitize-s3-objectkey": "0.0.1",
"semver": "7.3.7", "semver": "7.3.7",
"tar-fs": "2.1.1", "tar-fs": "2.1.1",
"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",
@ -74,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

@ -159,7 +159,7 @@ export async function updateUserOAuth(userId: string, oAuthConfig: any) {
try { try {
const db = getGlobalDB() const db = getGlobalDB()
const dbUser = await db.get(userId) const dbUser = await db.get<any>(userId)
//Do not overwrite the refresh token if a valid one is not provided. //Do not overwrite the refresh token if a valid one is not provided.
if (typeof details.refreshToken !== "string") { if (typeof details.refreshToken !== "string") {

View File

@ -2,9 +2,14 @@ import { getAppClient } from "../redis/init"
import { doWithDB, DocumentType } from "../db" import { doWithDB, DocumentType } from "../db"
import { Database, App } from "@budibase/types" import { Database, App } from "@budibase/types"
const AppState = { export enum AppState {
INVALID: "invalid", INVALID = "invalid",
} }
export interface DeletedApp {
state: AppState
}
const EXPIRY_SECONDS = 3600 const EXPIRY_SECONDS = 3600
/** /**
@ -31,7 +36,7 @@ function isInvalid(metadata?: { state: string }) {
* @param {string} appId the id of the app to get metadata from. * @param {string} appId the id of the app to get metadata from.
* @returns {object} the app metadata. * @returns {object} the app metadata.
*/ */
export async function getAppMetadata(appId: string) { export async function getAppMetadata(appId: string): Promise<App | DeletedApp> {
const client = await getAppClient() const client = await getAppClient()
// try cache // try cache
let metadata = await client.get(appId) let metadata = await client.get(appId)
@ -61,11 +66,8 @@ export async function getAppMetadata(appId: string) {
} }
await client.store(appId, metadata, expiry) await client.store(appId, metadata, expiry)
} }
// we've stored in the cache an object to tell us that it is currently invalid
if (isInvalid(metadata)) { return metadata
throw { status: 404, message: "No app metadata found" }
}
return metadata as App
} }
/** /**

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

@ -12,7 +12,7 @@ const EXPIRY_SECONDS = 3600
*/ */
async function populateFromDB(userId: string, tenantId: string) { async function populateFromDB(userId: string, tenantId: string) {
const db = tenancy.getTenantDB(tenantId) const db = tenancy.getTenantDB(tenantId)
const user = await db.get(userId) const user = await db.get<any>(userId)
user.budibaseAccess = true user.budibaseAccess = true
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(user.email) const account = await accounts.getAccount(user.email)

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

@ -20,6 +20,8 @@ export enum Header {
TYPE = "x-budibase-type", TYPE = "x-budibase-type",
PREVIEW_ROLE = "x-budibase-role", PREVIEW_ROLE = "x-budibase-role",
TENANT_ID = "x-budibase-tenant-id", TENANT_ID = "x-budibase-tenant-id",
VERIFICATION_CODE = "x-budibase-verification-code",
RETURN_VERIFICATION_CODE = "x-budibase-return-verification-code",
TOKEN = "x-budibase-token", TOKEN = "x-budibase-token",
CSRF_TOKEN = "x-csrf-token", CSRF_TOKEN = "x-csrf-token",
CORRELATION_ID = "x-budibase-correlation-id", CORRELATION_ID = "x-budibase-correlation-id",

View File

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

@ -5,7 +5,7 @@ export async function createUserIndex() {
const db = getGlobalDB() const db = getGlobalDB()
let designDoc let designDoc
try { try {
designDoc = await db.get("_design/database") designDoc = await db.get<any>("_design/database")
} catch (err: any) { } catch (err: any) {
if (err.status === 404) { if (err.status === 404) {
designDoc = { _id: "_design/database" } designDoc = { _id: "_design/database" }

View File

@ -2,7 +2,7 @@ import env from "../environment"
import { DEFAULT_TENANT_ID, SEPARATOR, DocumentType } from "../constants" import { DEFAULT_TENANT_ID, SEPARATOR, DocumentType } from "../constants"
import { getTenantId, getGlobalDBName } from "../context" import { getTenantId, getGlobalDBName } from "../context"
import { doWithDB, directCouchAllDbs } from "./db" import { doWithDB, directCouchAllDbs } from "./db"
import { getAppMetadata } from "../cache/appMetadata" import { AppState, DeletedApp, getAppMetadata } from "../cache/appMetadata"
import { isDevApp, isDevAppID, getProdAppID } from "../docIds/conversions" import { isDevApp, isDevAppID, getProdAppID } from "../docIds/conversions"
import { App, Database } from "@budibase/types" import { App, Database } from "@budibase/types"
import { getStartEndKeyURL } from "../docIds" import { getStartEndKeyURL } from "../docIds"
@ -101,7 +101,9 @@ export async function getAllApps({
const response = await Promise.allSettled(appPromises) const response = await Promise.allSettled(appPromises)
const apps = response const apps = response
.filter( .filter(
(result: any) => result.status === "fulfilled" && result.value != null (result: any) =>
result.status === "fulfilled" &&
result.value?.state !== AppState.INVALID
) )
.map(({ value }: any) => value) .map(({ value }: any) => value)
if (!all) { if (!all) {
@ -126,7 +128,11 @@ export async function getAppsByIDs(appIds: string[]) {
) )
// have to list the apps which exist, some may have been deleted // have to list the apps which exist, some may have been deleted
return settled return settled
.filter(promise => promise.status === "fulfilled") .filter(
promise =>
promise.status === "fulfilled" &&
(promise.value as DeletedApp).state !== AppState.INVALID
)
.map(promise => (promise as PromiseFulfilledResult<App>).value) .map(promise => (promise as PromiseFulfilledResult<App>).value)
} }

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()
@ -47,7 +48,10 @@ function httpLogging() {
return process.env.HTTP_LOGGING return process.env.HTTP_LOGGING
} }
function findVersion() { function getPackageJsonFields(): {
VERSION: string
SERVICE_NAME: string
} {
function findFileInAncestors( function findFileInAncestors(
fileName: string, fileName: string,
currentDir: string currentDir: string
@ -69,17 +73,31 @@ function findVersion() {
try { try {
const packageJsonFile = findFileInAncestors("package.json", process.cwd()) const packageJsonFile = findFileInAncestors("package.json", process.cwd())
const content = readFileSync(packageJsonFile!, "utf-8") const content = readFileSync(packageJsonFile!, "utf-8")
return JSON.parse(content).version const parsedContent = JSON.parse(content)
return {
VERSION: parsedContent.version,
SERVICE_NAME: parsedContent.name,
}
} catch { } catch {
// throwing an error here is confusing/causes backend-core to be hard to import // throwing an error here is confusing/causes backend-core to be hard to import
return undefined return { VERSION: "", SERVICE_NAME: "" }
} }
} }
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()
}, },
@ -146,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.
@ -154,7 +173,7 @@ const environment = {
ENABLE_SSO_MAINTENANCE_MODE: selfHosted ENABLE_SSO_MAINTENANCE_MODE: selfHosted
? process.env.ENABLE_SSO_MAINTENANCE_MODE ? process.env.ENABLE_SSO_MAINTENANCE_MODE
: false, : false,
VERSION: findVersion(), ...getPackageJsonFields(),
DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER,
OFFLINE_MODE: process.env.OFFLINE_MODE, OFFLINE_MODE: process.env.OFFLINE_MODE,
_set(key: any, value: any) { _set(key: any, value: any) {
@ -162,6 +181,7 @@ const environment = {
// @ts-ignore // @ts-ignore
environment[key] = value environment[key] = value
}, },
ROLLING_LOG_MAX_SIZE: process.env.ROLLING_LOG_MAX_SIZE || "10M",
} }
// clean up any environment variable edge cases // clean up any environment variable edge cases

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,6 +1,4 @@
export * as correlation from "./correlation/correlation" export * as correlation from "./correlation/correlation"
export { logger } from "./pino/logger" export { logger } from "./pino/logger"
export * from "./alerts" export * from "./alerts"
export * as system from "./system"
// turn off or on context logging i.e. tenantId, appId etc
export let LOG_CONTEXT = true

View File

@ -1,37 +1,60 @@
import env from "../../environment"
import pino, { LoggerOptions } from "pino" import pino, { LoggerOptions } from "pino"
import pinoPretty from "pino-pretty"
import { IdentityType } from "@budibase/types"
import env from "../../environment"
import * as context from "../../context" import * as context from "../../context"
import * as correlation from "../correlation" import * as correlation from "../correlation"
import { IdentityType } from "@budibase/types"
import { LOG_CONTEXT } from "../index" import { localFileDestination } from "../system"
// LOGGER // LOGGER
let pinoInstance: pino.Logger | undefined let pinoInstance: pino.Logger | undefined
if (!env.DISABLE_PINO_LOGGER) { if (!env.DISABLE_PINO_LOGGER) {
const level = env.LOG_LEVEL
const pinoOptions: LoggerOptions = { const pinoOptions: LoggerOptions = {
level: env.LOG_LEVEL, level,
formatters: { formatters: {
level: label => { level: level => {
return { level: label.toUpperCase() } return { level: level.toUpperCase() }
}, },
bindings: () => { bindings: () => {
return {} if (env.SELF_HOSTED) {
// "service" is being injected in datadog using the pod names,
// so we should leave it blank to allow the default behaviour if it's not running self-hosted
return {
service: env.SERVICE_NAME,
}
} else {
return {}
}
}, },
}, },
timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`, timestamp: () => `,"timestamp":"${new Date(Date.now()).toISOString()}"`,
} }
if (env.isDev()) { const destinations: pino.StreamEntry[] = []
pinoOptions.transport = {
target: "pino-pretty", destinations.push(
options: { env.isDev()
singleLine: true, ? {
}, stream: pinoPretty({ singleLine: true }),
} level: level as pino.Level,
}
: { stream: process.stdout, level: level as pino.Level }
)
if (env.SELF_HOSTED) {
destinations.push({
stream: localFileDestination(),
level: level as pino.Level,
})
} }
pinoInstance = pino(pinoOptions) pinoInstance = destinations.length
? pino(pinoOptions, pino.multistream(destinations))
: pino(pinoOptions)
// CONSOLE OVERRIDES // CONSOLE OVERRIDES
@ -83,15 +106,13 @@ if (!env.DISABLE_PINO_LOGGER) {
let contextObject = {} let contextObject = {}
if (LOG_CONTEXT) { contextObject = {
contextObject = { tenantId: getTenantId(),
tenantId: getTenantId(), appId: getAppId(),
appId: getAppId(), automationId: getAutomationId(),
automationId: getAutomationId(), identityId: identity?._id,
identityId: identity?._id, identityType: identity?.type,
identityType: identity?.type, correlationId: correlation.getId(),
correlationId: correlation.getId(),
}
} }
const mergingObject: any = { const mergingObject: any = {

View File

@ -0,0 +1,81 @@
import fs from "fs"
import path from "path"
import * as rfs from "rotating-file-stream"
import env from "../environment"
import { budibaseTempDir } from "../objectStore"
const logsFileName = `budibase.log`
const budibaseLogsHistoryFileName = "budibase-logs-history.txt"
const logsPath = path.join(budibaseTempDir(), "systemlogs")
function getFullPath(fileName: string) {
return path.join(logsPath, fileName)
}
export function getSingleFileMaxSizeInfo(totalMaxSize: string) {
const regex = /(\d+)([A-Za-z])/
const match = totalMaxSize?.match(regex)
if (!match) {
console.warn(`totalMaxSize does not have a valid value`, {
totalMaxSize,
})
return undefined
}
const size = +match[1]
const unit = match[2]
if (size === 1) {
switch (unit) {
case "B":
return { size: `${size}B`, totalHistoryFiles: 1 }
case "K":
return { size: `${(size * 1000) / 2}B`, totalHistoryFiles: 1 }
case "M":
return { size: `${(size * 1000) / 2}K`, totalHistoryFiles: 1 }
case "G":
return { size: `${(size * 1000) / 2}M`, totalHistoryFiles: 1 }
default:
return undefined
}
}
if (size % 2 === 0) {
return { size: `${size / 2}${unit}`, totalHistoryFiles: 1 }
}
return { size: `1${unit}`, totalHistoryFiles: size - 1 }
}
export function localFileDestination() {
const fileInfo = getSingleFileMaxSizeInfo(env.ROLLING_LOG_MAX_SIZE)
const outFile = rfs.createStream(logsFileName, {
// As we have a rolling size, we want to half the max size
size: fileInfo?.size,
path: logsPath,
maxFiles: fileInfo?.totalHistoryFiles || 1,
immutable: true,
history: budibaseLogsHistoryFileName,
initialRotation: false,
})
return outFile
}
export function getLogReadStream() {
const streams = []
const historyFile = getFullPath(budibaseLogsHistoryFileName)
if (fs.existsSync(historyFile)) {
const fileContent = fs.readFileSync(historyFile, "utf-8")
const historyFiles = fileContent.split("\n")
for (const historyFile of historyFiles.filter(x => x)) {
streams.push(fs.readFileSync(historyFile))
}
}
streams.push(fs.readFileSync(getFullPath(logsFileName)))
const combinedContent = Buffer.concat(streams)
return combinedContent
}

View File

@ -0,0 +1,61 @@
import { getSingleFileMaxSizeInfo } from "../system"
describe("system", () => {
describe("getSingleFileMaxSizeInfo", () => {
it.each([
["100B", "50B"],
["200K", "100K"],
["20M", "10M"],
["4G", "2G"],
])(
"Halving even number (%s) returns halved size and 1 history file (%s)",
(totalValue, expectedMaxSize) => {
const result = getSingleFileMaxSizeInfo(totalValue)
expect(result).toEqual({
size: expectedMaxSize,
totalHistoryFiles: 1,
})
}
)
it.each([
["5B", "1B", 4],
["17K", "1K", 16],
["21M", "1M", 20],
["3G", "1G", 2],
])(
"Halving an odd number (%s) returns as many files as size (-1) (%s)",
(totalValue, expectedMaxSize, totalHistoryFiles) => {
const result = getSingleFileMaxSizeInfo(totalValue)
expect(result).toEqual({
size: expectedMaxSize,
totalHistoryFiles,
})
}
)
it.each([
["1B", "1B"],
["1K", "500B"],
["1M", "500K"],
["1G", "500M"],
])(
"Halving '%s' returns halved unit (%s)",
(totalValue, expectedMaxSize) => {
const result = getSingleFileMaxSizeInfo(totalValue)
expect(result).toEqual({
size: expectedMaxSize,
totalHistoryFiles: 1,
})
}
)
it.each([[undefined], [""], ["50"], ["wrongvalue"]])(
"Halving wrongly formatted value ('%s') returns undefined",
totalValue => {
const result = getSingleFileMaxSizeInfo(totalValue!)
expect(result).toBeUndefined()
}
)
})
})

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 }
@ -67,9 +73,9 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => {
export async function getById(id: string, opts?: GetOpts): Promise<User> { export async function getById(id: string, opts?: GetOpts): Promise<User> {
const db = context.getGlobalDB() const db = context.getGlobalDB()
let user = await db.get(id) let user = await db.get<User>(id)
if (opts?.cleanup) { if (opts?.cleanup) {
user = removeUserPassword(user) user = removeUserPassword(user) as User
} }
return user return user
} }
@ -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

@ -1,6 +1,7 @@
<script> <script>
import "@spectrum-css/button/dist/index-vars.css" import "@spectrum-css/button/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte" import AbsTooltip from "../Tooltip/AbsTooltip.svelte"
import { createEventDispatcher } from "svelte"
export let type export let type
export let disabled = false export let disabled = false
@ -17,65 +18,52 @@
export let newStyles = true export let newStyles = true
export let id export let id
let showTooltip = false const dispatch = createEventDispatcher()
</script> </script>
<button <AbsTooltip text={tooltip}>
{id} <button
{type} {id}
class:spectrum-Button--cta={cta} {type}
class:spectrum-Button--primary={primary} class:spectrum-Button--cta={cta}
class:spectrum-Button--secondary={secondary} class:spectrum-Button--primary={primary}
class:spectrum-Button--warning={warning} class:spectrum-Button--secondary={secondary}
class:spectrum-Button--overBackground={overBackground} class:spectrum-Button--warning={warning}
class:spectrum-Button--quiet={quiet} class:spectrum-Button--overBackground={overBackground}
class:new-styles={newStyles} class:spectrum-Button--quiet={quiet}
class:active class:new-styles={newStyles}
class:disabled class:active
class="spectrum-Button spectrum-Button--size{size.toUpperCase()}" class:is-disabled={disabled}
{disabled} class="spectrum-Button spectrum-Button--size{size.toUpperCase()}"
on:click|preventDefault on:click|preventDefault={() => {
on:mouseover={() => (showTooltip = true)} if (!disabled) {
on:focus={() => (showTooltip = true)} dispatch("click")
on:mouseleave={() => (showTooltip = false)} }
> }}
{#if icon} >
<svg {#if icon}
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-Button-label"><slot /></span>
{/if}
{#if !disabled && tooltip}
<div class="tooltip-icon">
<svg <svg
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}" class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
focusable="false" focusable="false"
aria-hidden="true" aria-hidden="true"
aria-label="Info" aria-label={icon}
> >
<use xlink:href="#spectrum-icon-18-InfoOutline" /> <use xlink:href="#spectrum-icon-18-{icon}" />
</svg> </svg>
</div> {/if}
{/if} {#if $$slots}
{#if showTooltip && tooltip} <span class="spectrum-Button-label"><slot /></span>
<div class="tooltip"> {/if}
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} /> </button>
</div> </AbsTooltip>
{/if}
</button>
<style> <style>
button { button {
position: relative; position: relative;
} }
button.is-disabled {
cursor: default;
}
.spectrum-Button-label { .spectrum-Button-label {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -84,21 +72,6 @@
.active { .active {
color: var(--spectrum-global-color-blue-600) !important; color: var(--spectrum-global-color-blue-600) !important;
} }
.tooltip {
position: absolute;
display: flex;
justify-content: center;
z-index: 100;
width: 160px;
text-align: center;
transform: translateX(-50%);
left: 50%;
top: calc(100% - 3px);
}
.tooltip-icon {
padding-left: var(--spacing-m);
line-height: 0;
}
.spectrum-Button--primary.new-styles { .spectrum-Button--primary.new-styles {
background: var(--spectrum-global-color-gray-800); background: var(--spectrum-global-color-gray-800);
border-color: transparent; border-color: transparent;
@ -112,10 +85,10 @@
border-color: transparent; border-color: transparent;
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
} }
.spectrum-Button--secondary.new-styles:not(.disabled):hover { .spectrum-Button--secondary.new-styles:not(.is-disabled):hover {
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
} }
.spectrum-Button--secondary.new-styles.disabled { .spectrum-Button--secondary.new-styles.is-disabled {
color: var(--spectrum-global-color-gray-500); color: var(--spectrum-global-color-gray-500);
} }
</style> </style>

View File

@ -15,8 +15,6 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: placeholder = !value
const extractProperty = (value, property) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]

View File

@ -12,23 +12,24 @@
export let getOptionValue = option => option export let getOptionValue = option => option
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
let tempValue = value const optionValue = e.target.value
let isChecked = e.target.checked if (e.target.checked && !value.includes(optionValue)) {
if (!tempValue.includes(e.target.value) && isChecked) { dispatch("change", [...value, optionValue])
tempValue.push(e.target.value) } else {
dispatch(
"change",
value.filter(x => x !== optionValue)
)
} }
value = tempValue
dispatch(
"change",
tempValue.filter(val => val !== e.target.value || isChecked)
)
} }
</script> </script>
<div class={`spectrum-FieldGroup spectrum-FieldGroup--${direction}`}> <div class={`spectrum-FieldGroup spectrum-FieldGroup--${direction}`}>
{#if options && Array.isArray(options)} {#if options && Array.isArray(options)}
{#each options as option} {#each options as option}
{@const optionValue = getOptionValue(option)}
<div <div
title={getOptionLabel(option)} title={getOptionLabel(option)}
class="spectrum-Checkbox spectrum-FieldGroup-item" class="spectrum-Checkbox spectrum-FieldGroup-item"
@ -39,11 +40,11 @@
> >
<input <input
on:change={onChange} on:change={onChange}
value={getOptionValue(option)}
type="checkbox" type="checkbox"
class="spectrum-Checkbox-input" class="spectrum-Checkbox-input"
value={optionValue}
checked={value.includes(optionValue)}
{disabled} {disabled}
checked={value.includes(getOptionValue(option))}
/> />
<span class="spectrum-Checkbox-box"> <span class="spectrum-Checkbox-box">
<svg <svg

View File

@ -150,7 +150,7 @@
</div> </div>
{:else if variables.length} {:else if variables.length}
<div style="max-height: 100px"> <div style="max-height: 100px">
{#each variables as variable, idx} {#each variables as variable}
<li <li
class="spectrum-Menu-item" class="spectrum-Menu-item"
role="option" role="option"

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

@ -1,6 +1,7 @@
<script> <script>
import "@spectrum-css/link/dist/index-vars.css" import "@spectrum-css/link/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
export let href = "#" export let href = "#"
export let size = "M" export let size = "M"
@ -10,18 +11,61 @@
export let overBackground = false export let overBackground = false
export let target export let target
export let download export let download
export let disabled = false
export let tooltip = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onClick = e => {
if (!disabled) {
dispatch("click")
e.stopPropagation()
}
}
</script> </script>
<a <a
on:click={e => dispatch("click") && e.stopPropagation()} on:click={onClick}
{href} {href}
{target} {target}
{download} {download}
class:disabled
class:spectrum-Link--primary={primary} class:spectrum-Link--primary={primary}
class:spectrum-Link--secondary={secondary} class:spectrum-Link--secondary={secondary}
class:spectrum-Link--overBackground={overBackground} class:spectrum-Link--overBackground={overBackground}
class:spectrum-Link--quiet={quiet} class:spectrum-Link--quiet={quiet}
class="spectrum-Link spectrum-Link--size{size}"><slot /></a class="spectrum-Link spectrum-Link--size{size}"
> >
<slot />
{#if tooltip}
<div class="tooltip">
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</a>
<style>
a {
position: relative;
}
a.disabled {
color: var(--spectrum-global-color-gray-500);
}
a.disabled:hover {
text-decoration: none;
cursor: default;
}
.tooltip {
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
opacity: 0;
transition: 130ms ease-out;
pointer-events: none;
z-index: 100;
}
a:hover .tooltip {
opacity: 1;
}
</style>

View File

@ -1,3 +1,7 @@
<script context="module">
export const keepOpen = Symbol("keepOpen")
</script>
<script> <script>
import "@spectrum-css/dialog/dist/index-vars.css" import "@spectrum-css/dialog/dist/index-vars.css"
import { getContext } from "svelte" import { getContext } from "svelte"
@ -30,7 +34,7 @@
async function secondary(e) { async function secondary(e) {
loading = true loading = true
if (!secondaryAction || (await secondaryAction(e)) !== false) { if (!secondaryAction || (await secondaryAction(e)) !== keepOpen) {
hide() hide()
} }
loading = false loading = false
@ -38,7 +42,7 @@
async function confirm() { async function confirm() {
loading = true loading = true
if (!onConfirm || (await onConfirm()) !== false) { if (!onConfirm || (await onConfirm()) !== keepOpen) {
hide() hide()
} }
loading = false loading = false
@ -46,7 +50,7 @@
async function close() { async function close() {
loading = true loading = true
if (!onCancel || (await onCancel()) !== false) { if (!onCancel || (await onCancel()) !== keepOpen) {
cancel() cancel()
} }
loading = false loading = false

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

@ -29,7 +29,6 @@
$: type = getType(schema) $: type = getType(schema)
$: customRenderer = customRenderers?.find(x => x.column === schema?.name) $: customRenderer = customRenderers?.find(x => x.column === schema?.name)
$: renderer = customRenderer?.component ?? typeMap[type] ?? StringRenderer $: renderer = customRenderer?.component ?? typeMap[type] ?? StringRenderer
$: width = schema?.width || "150px"
$: cellValue = getCellValue(value, schema.template) $: cellValue = getCellValue(value, schema.template)
const getType = schema => { const getType = schema => {

View File

@ -379,7 +379,7 @@
</div> </div>
{/if} {/if}
{#if sortedRows?.length} {#if sortedRows?.length}
{#each sortedRows as row, idx} {#each sortedRows as row}
<div class="spectrum-Table-row" class:clickable={allowClickRows}> <div class="spectrum-Table-row" class:clickable={allowClickRows}>
{#if showEditColumn} {#if showEditColumn}
<div <div

View File

@ -0,0 +1,157 @@
<script context="module">
export const TooltipPosition = {
Top: "top",
Right: "right",
Bottom: "bottom",
Left: "left",
}
export const TooltipType = {
Default: "default",
Info: "info",
Positive: "positive",
Negative: "negative",
}
</script>
<script>
import Portal from "svelte-portal"
import { fade } from "svelte/transition"
import "@spectrum-css/tooltip/dist/index-vars.css"
import { onDestroy } from "svelte"
export let position = TooltipPosition.Top
export let type = TooltipType.Default
export let text = ""
export let fixed = false
export let color = null
let wrapper
let hovered = false
let left
let top
let visible = false
let timeout
let interval
$: {
if (hovered || fixed) {
// Debounce showing by 200ms to avoid flashing tooltip
timeout = setTimeout(show, 200)
} else {
hide()
}
}
$: tooltipStyle = color ? `background:${color};` : null
$: tipStyle = color ? `border-top-color:${color};` : null
// Computes the position of the tooltip
const updateTooltipPosition = () => {
const node = wrapper?.children?.[0]
if (!node) {
left = null
top = null
return
}
const bounds = node.getBoundingClientRect()
// Determine where to render tooltip based on position prop
if (position === TooltipPosition.Top) {
left = bounds.left + bounds.width / 2
top = bounds.top
} else if (position === TooltipPosition.Right) {
left = bounds.left + bounds.width
top = bounds.top + bounds.height / 2
} else if (position === TooltipPosition.Bottom) {
left = bounds.left + bounds.width / 2
top = bounds.top + bounds.height
} else if (position === TooltipPosition.Left) {
left = bounds.left
top = bounds.top + bounds.height / 2
}
}
// Computes the position of the tooltip then shows it.
// We set up a poll to frequently update the position of the tooltip in case
// the target moves.
const show = () => {
updateTooltipPosition()
interval = setInterval(updateTooltipPosition, 100)
visible = true
}
// Hides the tooltip
const hide = () => {
clearTimeout(timeout)
clearInterval(interval)
visible = false
}
// Ensure we clean up interval and timeout
onDestroy(hide)
</script>
<div
bind:this={wrapper}
class="abs-tooltip"
on:focus={null}
on:mouseover={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}
>
<slot />
</div>
{#if visible && text && left != null && top != null}
<Portal target=".spectrum">
<span
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
style={`left:${left}px;top:${top}px;${tooltipStyle}`}
transition:fade|local={{ duration: 130 }}
>
<span class="spectrum-Tooltip-label">{text}</span>
<span class="spectrum-Tooltip-tip" style={tipStyle} />
</span>
</Portal>
{/if}
<style>
.abs-tooltip {
display: contents;
}
.spectrum-Tooltip {
position: absolute;
z-index: 9999;
pointer-events: none;
margin: 0;
max-width: 280px;
transition: top 130ms ease-out, left 130ms ease-out;
}
.spectrum-Tooltip-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-size: 12px;
font-weight: 600;
}
/* Colour overrides for default type */
.spectrum-Tooltip--default {
background: var(--spectrum-global-color-gray-500);
}
.spectrum-Tooltip--default .spectrum-Tooltip-tip {
border-top-color: var(--spectrum-global-color-gray-500);
}
/* Position styles */
.spectrum-Tooltip--top {
transform: translateX(-50%) translateY(calc(-100% - 8px));
}
.spectrum-Tooltip--right {
transform: translateX(8px) translateY(-50%);
}
.spectrum-Tooltip--bottom {
transform: translateX(-50%) translateY(8px);
}
.spectrum-Tooltip--left {
transform: translateX(calc(-100% - 8px)) translateY(-50%);
}
</style>

View File

@ -0,0 +1,39 @@
<script>
import AbsTooltip from "./AbsTooltip.svelte"
import { onDestroy } from "svelte"
export let text = null
export let condition = true
export let duration = 3000
export let position
export let type
let visible = false
let timeout
$: {
if (condition) {
showTooltip()
} else {
hideTooltip()
}
}
const showTooltip = () => {
visible = true
timeout = setTimeout(() => {
visible = false
}, duration)
}
const hideTooltip = () => {
visible = false
clearTimeout(timeout)
}
onDestroy(hideTooltip)
</script>
<AbsTooltip {position} {type} text={visible ? text : null} fixed={visible}>
<slot />
</AbsTooltip>

View File

@ -36,13 +36,19 @@ export { default as Layout } from "./Layout/Layout.svelte"
export { default as Page } from "./Layout/Page.svelte" export { default as Page } from "./Layout/Page.svelte"
export { default as Link } from "./Link/Link.svelte" export { default as Link } from "./Link/Link.svelte"
export { default as Tooltip } from "./Tooltip/Tooltip.svelte" export { default as Tooltip } from "./Tooltip/Tooltip.svelte"
export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte"
export {
default as AbsTooltip,
TooltipPosition,
TooltipType,
} from "./Tooltip/AbsTooltip.svelte"
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte" export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte"
export { default as Menu } from "./Menu/Menu.svelte" export { default as Menu } from "./Menu/Menu.svelte"
export { default as MenuSection } from "./Menu/Section.svelte" export { default as MenuSection } from "./Menu/Section.svelte"
export { default as MenuSeparator } from "./Menu/Separator.svelte" export { default as MenuSeparator } from "./Menu/Separator.svelte"
export { default as MenuItem } from "./Menu/Item.svelte" export { default as MenuItem } from "./Menu/Item.svelte"
export { default as Modal } from "./Modal/Modal.svelte" export { default as Modal } from "./Modal/Modal.svelte"
export { default as ModalContent } from "./Modal/ModalContent.svelte" export { default as ModalContent, keepOpen } from "./Modal/ModalContent.svelte"
export { default as NotificationDisplay } from "./Notification/NotificationDisplay.svelte" export { default as NotificationDisplay } from "./Notification/NotificationDisplay.svelte"
export { default as Notification } from "./Notification/Notification.svelte" export { default as Notification } from "./Notification/Notification.svelte"
export { default as SideNavigation } from "./SideNavigation/Navigation.svelte" export { default as SideNavigation } from "./SideNavigation/Navigation.svelte"
@ -78,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

@ -491,6 +491,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 },
})) }))
) )
@ -506,6 +507,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

@ -3,6 +3,7 @@ import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal" import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users" import { getUserStore } from "./store/users"
import { getDeploymentStore } from "./store/deployments"
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
@ -14,6 +15,7 @@ export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
export const userStore = getUserStore() export const userStore = getUserStore()
export const deploymentStore = getDeploymentStore()
// Setup history for screens // Setup history for screens
export const screenHistoryStore = createHistoryStore({ export const screenHistoryStore = createHistoryStore({
@ -118,3 +120,24 @@ export const selectedAutomation = derived(automationStore, $automationStore => {
x => x._id === $automationStore.selectedAutomationId x => x._id === $automationStore.selectedAutomationId
) )
}) })
// Derive map of resource IDs to other users.
// We only ever care about a single user in each resource, so if multiple users
// share the same datasource we can just overwrite them.
export const userSelectedResourceMap = derived(userStore, $userStore => {
let map = {}
$userStore.forEach(user => {
const resource = user.builderMetadata?.selectedResourceId
if (resource) {
if (!map[resource]) {
map[resource] = []
}
map[resource].push(user)
}
})
return map
})
export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2
})

View File

@ -248,4 +248,36 @@ const automationActions = store => ({
} }
await store.actions.save(newAutomation) await store.actions.save(newAutomation)
}, },
replace: async (automationId, automation) => {
if (!automation) {
store.update(state => {
// Remove the automation
state.automations = state.automations.filter(
x => x._id !== automationId
)
// Select a new automation if required
if (automationId === state.selectedAutomationId) {
store.actions.select(state.automations[0]?._id)
}
return state
})
} else {
const index = get(store).automations.findIndex(
x => x._id === automation._id
)
if (index === -1) {
// Automation addition
store.update(state => ({
...state,
automations: [...state.automations, automation],
}))
} else {
// Automation update
store.update(state => {
state.automations[index] = automation
return state
})
}
}
},
}) })

View File

@ -0,0 +1,22 @@
import { writable } from "svelte/store"
import { API } from "api"
import { notifications } from "@budibase/bbui"
export const getDeploymentStore = () => {
let store = writable([])
const load = async () => {
try {
store.set(await API.getAppDeployments())
} catch (err) {
notifications.error("Error fetching deployments")
}
}
return {
subscribe: store.subscribe,
actions: {
load,
},
}
}

View File

@ -38,6 +38,7 @@ import {
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields" import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket" import { createBuilderWebsocket } from "builderStore/websocket"
import { BuilderSocketEvent } from "@budibase/shared-core"
const INITIAL_FRONTEND_STATE = { const INITIAL_FRONTEND_STATE = {
initialised: false, initialised: false,
@ -353,6 +354,33 @@ export const getFrontendStore = () => {
} }
return await sequentialScreenPatch(patchFn, screenId) return await sequentialScreenPatch(patchFn, screenId)
}, },
replace: async (screenId, screen) => {
if (!screenId) {
return
}
if (!screen) {
// Screen deletion
store.update(state => ({
...state,
screens: state.screens.filter(x => x._id !== screenId),
}))
} else {
const index = get(store).screens.findIndex(x => x._id === screen._id)
if (index === -1) {
// Screen addition
store.update(state => ({
...state,
screens: [...state.screens, screen],
}))
} else {
// Screen update
store.update(state => {
state.screens[index] = screen
return state
})
}
}
},
delete: async screens => { delete: async screens => {
const screensToDelete = Array.isArray(screens) ? screens : [screens] const screensToDelete = Array.isArray(screens) ? screens : [screens]
@ -1305,7 +1333,7 @@ export const getFrontendStore = () => {
links: { links: {
save: async (url, title) => { save: async (url, title) => {
const navigation = get(store).navigation const navigation = get(store).navigation
let links = [...navigation?.links] let links = [...(navigation?.links ?? [])]
// Skip if we have an identical link // Skip if we have an identical link
if (links.find(link => link.url === url && link.text === title)) { if (links.find(link => link.url === url && link.text === title)) {
@ -1365,6 +1393,21 @@ export const getFrontendStore = () => {
}) })
}, },
}, },
websocket: {
selectResource: id => {
websocket.emit(BuilderSocketEvent.SelectResource, {
resourceId: id,
})
},
},
metadata: {
replace: metadata => {
store.update(state => ({
...state,
...metadata,
}))
},
},
} }
return store return store

View File

@ -1,10 +1,17 @@
import { createWebsocket } from "@budibase/frontend-core" import { createWebsocket } from "@budibase/frontend-core"
import { userStore, store } from "builderStore" import {
userStore,
store,
deploymentStore,
automationStore,
} from "builderStore"
import { datasources, tables } from "stores/backend" import { datasources, tables } from "stores/backend"
import { get } from "svelte/store" import { get } from "svelte/store"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core" import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core"
import { apps } from "stores/portal"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core"
export const createBuilderWebsocket = appId => { export const createBuilderWebsocket = appId => {
const socket = createWebsocket("/socket/builder") const socket = createWebsocket("/socket/builder")
@ -31,7 +38,6 @@ export const createBuilderWebsocket = appId => {
}) })
socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => { socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => {
if (userId === get(auth)?.user?._id) { if (userId === get(auth)?.user?._id) {
notifications.success("You can now edit screens and automations")
store.update(state => ({ store.update(state => ({
...state, ...state,
hasLock: true, hasLock: true,
@ -39,15 +45,37 @@ export const createBuilderWebsocket = appId => {
} }
}) })
// Table events // Data section events
socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => { socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => {
tables.replaceTable(id, table) tables.replaceTable(id, table)
}) })
// Datasource events
socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => { socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource) datasources.replaceDatasource(id, datasource)
}) })
// Design section events
socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => {
store.actions.screens.replace(id, screen)
})
socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => {
store.actions.metadata.replace(metadata)
})
socket.onOther(
BuilderSocketEvent.AppPublishChange,
async ({ user, published }) => {
await apps.load()
if (published) {
await deploymentStore.actions.load()
}
const verb = published ? "published" : "unpublished"
notifications.success(`${helpers.getUserLabel(user)} ${verb} this app`)
}
)
// Automations
socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => {
automationStore.actions.replace(id, automation)
})
return socket return socket
} }

View File

@ -168,7 +168,7 @@
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Detail size="S">Plugins</Detail> <Detail size="S">Plugins</Detail>
<div class="item-list"> <div class="item-list">
{#each Object.entries(plugins) as [idx, action]} {#each Object.entries(plugins) as [_, action]}
<div <div
class="item" class="item"
class:selected={selectedAction === action.name} class:selected={selectedAction === action.name}

View File

@ -60,6 +60,7 @@
</script> </script>
<div> <div>
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html html} {@html html}
</div> </div>

View File

@ -1,6 +1,10 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { automationStore, selectedAutomation } from "builderStore" import {
automationStore,
selectedAutomation,
userSelectedResourceMap,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte" import EditAutomationPopover from "./EditAutomationPopover.svelte"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -21,13 +25,13 @@
</script> </script>
<div class="automations-list"> <div class="automations-list">
{#each $automationStore.automations.sort(aut => aut.name) as automation, idx} {#each $automationStore.automations.sort(aut => aut.name) as automation}
<NavItem <NavItem
border={idx > 0}
icon="ShareAndroid" icon="ShareAndroid"
text={automation.name} text={automation.name}
selected={automation._id === selectedAutomationId} selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)} on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
> >
<EditAutomationPopover {automation} /> <EditAutomationPopover {automation} />
</NavItem> </NavItem>
@ -40,6 +44,5 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
margin: 0 calc(-1 * var(--spacing-xl));
} }
</style> </style>

View File

@ -11,8 +11,8 @@
<Panel title="Automations" borderRight> <Panel title="Automations" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S"> <Layout paddingX="L" paddingY="XL" gap="S">
<Button cta on:click={modal.show}>Add automation</Button> <Button cta on:click={modal.show}>Add automation</Button>
<AutomationList />
</Layout> </Layout>
<AutomationList />
</Panel> </Panel>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -71,7 +71,7 @@
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Label size="S">Trigger</Label> <Label size="S">Trigger</Label>
<div class="item-list"> <div class="item-list">
{#each triggers as [idx, trigger]} {#each triggers as [_, trigger]}
<div <div
class="item" class="item"
class:selected={selectedTrigger === trigger.name} class:selected={selectedTrigger === trigger.name}

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

@ -64,6 +64,13 @@
<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 />
@ -77,9 +84,8 @@
{:else} {:else}
<GridImportButton /> <GridImportButton />
{/if} {/if}
<GridExportButton /> <GridExportButton />
<GridAddColumnModal />
<GridEditColumnModal />
{#if isUsersTable} {#if isUsersTable}
<GridEditUserModal /> <GridEditUserModal />
{:else} {:else}

View File

@ -3,8 +3,6 @@
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { Table, Heading, Layout } from "@budibase/bbui" import { Table, Heading, Layout } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateEditRow from "./modals/CreateEditRow.svelte"
import CreateEditUser from "./modals/CreateEditUser.svelte"
import { import {
TableNames, TableNames,
UNEDITABLE_USER_FIELDS, UNEDITABLE_USER_FIELDS,
@ -33,7 +31,6 @@
$: selectedRows, dispatch("selectionUpdated", selectedRows) $: selectedRows, dispatch("selectionUpdated", selectedRows)
$: isUsersTable = tableId === TableNames.USERS $: isUsersTable = tableId === TableNames.USERS
$: data && resetSelectedRows() $: data && resetSelectedRows()
$: editRowComponent = isUsersTable ? CreateEditUser : CreateEditRow
$: { $: {
UNSORTABLE_TYPES.forEach(type => { UNSORTABLE_TYPES.forEach(type => {
Object.values(schema || {}).forEach(col => { Object.values(schema || {}).forEach(col => {
@ -112,6 +109,7 @@
{disableSorting} {disableSorting}
{customPlaceholder} {customPlaceholder}
allowEditRows={allowEditing} allowEditRows={allowEditing}
allowEditColumns={allowEditing}
showAutoColumns={!hideAutocolumns} showAutoColumns={!hideAutocolumns}
{allowClickRows} {allowClickRows}
on:clickrelationship={e => selectRelationship(e.detail)} on:clickrelationship={e => selectRelationship(e.detail)}

View File

@ -7,18 +7,18 @@
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"
import { import {
FIELDS, FIELDS,
RelationshipTypes, RelationshipType,
ALLOWABLE_STRING_OPTIONS, ALLOWABLE_STRING_OPTIONS,
ALLOWABLE_NUMBER_OPTIONS, ALLOWABLE_NUMBER_OPTIONS,
ALLOWABLE_STRING_TYPES, ALLOWABLE_STRING_TYPES,
@ -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
@ -58,15 +56,13 @@
let table = $tables.selected let table = $tables.selected
let confirmDeleteDialog let confirmDeleteDialog
let deletion
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,
} }
@ -84,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)
@ -183,9 +195,11 @@
indexes, indexes,
}) })
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column")
if ( if (
saveColumn.type === LINK_TYPE && saveColumn.type === LINK_TYPE &&
saveColumn.relationshipType === RelationshipTypes.MANY_TO_MANY saveColumn.relationshipType === RelationshipType.MANY_TO_MANY
) { ) {
// Fetching the new tables // Fetching the new tables
tables.fetch() tables.fetch()
@ -204,6 +218,7 @@
function cancelEdit() { function cancelEdit() {
editableColumn.name = originalName editableColumn.name = originalName
gridDispatch("close-edit-column")
} }
async function deleteColumn() { async function deleteColumn() {
@ -215,9 +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()
deletion = false
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}`)
@ -240,7 +254,7 @@
// Default relationships many to many // Default relationships many to many
if (editableColumn.type === LINK_TYPE) { if (editableColumn.type === LINK_TYPE) {
editableColumn.relationshipType = RelationshipTypes.MANY_TO_MANY editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} }
if (editableColumn.type === FORMULA_TYPE) { if (editableColumn.type === FORMULA_TYPE) {
editableColumn.formulaType = "dynamic" editableColumn.formulaType = "dynamic"
@ -253,27 +267,22 @@
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()
} }
function confirmDelete() { function confirmDelete() {
confirmDeleteDialog.show() confirmDeleteDialog.show()
deletion = true
} }
function hideDeleteDialog() { function hideDeleteDialog() {
confirmDeleteDialog.hide() confirmDeleteDialog.hide()
deleteColName = "" deleteColName = ""
deletion = false }
function extractColumnNumber(columnName) {
const match = columnName.match(/Column (\d+)/)
return match ? parseInt(match[1]) : 0
} }
function getRelationshipOptions(field) { function getRelationshipOptions(field) {
@ -290,17 +299,17 @@
{ {
name: `Many ${thisName} rows → many ${linkName} rows`, name: `Many ${thisName} rows → many ${linkName} rows`,
alt: `Many ${table.name} rows → many ${linkTable.name} rows`, alt: `Many ${table.name} rows → many ${linkTable.name} rows`,
value: RelationshipTypes.MANY_TO_MANY, value: RelationshipType.MANY_TO_MANY,
}, },
{ {
name: `One ${linkName} row → many ${thisName} rows`, name: `One ${linkName} row → many ${thisName} rows`,
alt: `One ${linkTable.name} rows → many ${table.name} rows`, alt: `One ${linkTable.name} rows → many ${table.name} rows`,
value: RelationshipTypes.ONE_TO_MANY, value: RelationshipType.ONE_TO_MANY,
}, },
{ {
name: `One ${thisName} row → many ${linkName} rows`, name: `One ${thisName} row → many ${linkName} rows`,
alt: `One ${table.name} rows → many ${linkTable.name} rows`, alt: `One ${table.name} rows → many ${linkTable.name} rows`,
value: RelationshipTypes.MANY_TO_ONE, value: RelationshipType.MANY_TO_ONE,
}, },
] ]
} }
@ -406,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)}
@ -423,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
@ -437,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"
@ -466,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>
@ -484,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
@ -513,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"
@ -551,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
@ -595,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}
@ -611,6 +642,7 @@
}} }}
/> />
</Modal> </Modal>
<ConfirmDialog <ConfirmDialog
bind:this={confirmDeleteDialog} bind:this={confirmDeleteDialog}
okText="Delete Column" okText="Delete Column"
@ -626,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,10 +1,9 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { notifications } from "@budibase/bbui" import { ModalContent, keepOpen, notifications } from "@budibase/bbui"
import RowFieldControl from "../RowFieldControl.svelte" import RowFieldControl from "../RowFieldControl.svelte"
import { API } from "api" import { API } from "api"
import { ModalContent } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
const FORMULA_TYPE = FIELDS.FORMULA.type const FORMULA_TYPE = FIELDS.FORMULA.type
@ -41,8 +40,8 @@
} else { } else {
notifications.error(`Failed to save row - ${error.message}`) notifications.error(`Failed to save row - ${error.message}`)
} }
// Prevent modal closing if there were errors
return false return keepOpen
} }
} }
</script> </script>

View File

@ -5,7 +5,7 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import RowFieldControl from "../RowFieldControl.svelte" import RowFieldControl from "../RowFieldControl.svelte"
import { API } from "api" import { API } from "api"
import { ModalContent, Select, Link } from "@budibase/bbui" import { keepOpen, ModalContent, Select, Link } from "@budibase/bbui"
import ErrorsBox from "components/common/ErrorsBox.svelte" import ErrorsBox from "components/common/ErrorsBox.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
@ -51,7 +51,7 @@
errors = [...errors, { message: "Role is required" }] errors = [...errors, { message: "Role is required" }]
} }
if (errors.length) { if (errors.length) {
return false return keepOpen
} }
try { try {
@ -79,8 +79,8 @@
} else { } else {
notifications.error("Error saving user") notifications.error("Error saving user")
} }
// Prevent closing the modal on errors
return false return keepOpen
} }
} }
</script> </script>
@ -95,9 +95,9 @@
{#if !creating} {#if !creating}
<div> <div>
A user's email, role, first and last names cannot be changed from within A user's email, role, first and last names cannot be changed from within
the app builder. Please go to the <Link the app builder. Please go to the
on:click={$goto("/builder/portal/manage/users")}>user portal</Link <Link on:click={$goto("/builder/portal/users/users")}>user portal</Link>
> to do this. to do this.
</div> </div>
{/if} {/if}
<RowFieldControl <RowFieldControl

View File

@ -1,5 +1,5 @@
<script> <script>
import { ModalContent, Select, Input, Button } from "@budibase/bbui" import { keepOpen, ModalContent, Select, Input, Button } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { API } from "api" import { API } from "api"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -76,7 +76,7 @@
errors.push({ message: "Please choose permissions" }) errors.push({ message: "Please choose permissions" })
} }
if (errors.length) { if (errors.length) {
return false return keepOpen
} }
// Save/create the role // Save/create the role
@ -85,7 +85,7 @@
notifications.success("Role saved successfully") notifications.success("Role saved successfully")
} catch (error) { } catch (error) {
notifications.error(`Error saving role - ${error.message}`) notifications.error(`Error saving role - ${error.message}`)
return false return keepOpen
} }
} }

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

@ -13,6 +13,7 @@
} from "helpers/data/utils" } from "helpers/data/utils"
import IntegrationIcon from "./IntegrationIcon.svelte" import IntegrationIcon from "./IntegrationIcon.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import { userSelectedResourceMap } from "builderStore"
let openDataSources = [] let openDataSources = []
@ -166,8 +167,9 @@
selected={$isActive("./table/:tableId") && selected={$isActive("./table/:tableId") &&
$tables.selected?._id === TableNames.USERS} $tables.selected?._id === TableNames.USERS}
on:click={() => selectTable(TableNames.USERS)} on:click={() => selectTable(TableNames.USERS)}
selectedBy={$userSelectedResourceMap[TableNames.USERS]}
/> />
{#each enrichedDataSources as datasource, idx} {#each enrichedDataSources as datasource}
<NavItem <NavItem
border border
text={datasource.name} text={datasource.name}
@ -176,6 +178,7 @@
withArrow={true} withArrow={true}
on:click={() => selectDatasource(datasource)} on:click={() => selectDatasource(datasource)}
on:iconClick={() => toggleNode(datasource)} on:iconClick={() => toggleNode(datasource)}
selectedBy={$userSelectedResourceMap[datasource._id]}
> >
<div class="datasource-icon" slot="icon"> <div class="datasource-icon" slot="icon">
<IntegrationIcon <IntegrationIcon
@ -201,6 +204,7 @@
selected={$isActive("./query/:queryId") && selected={$isActive("./query/:queryId") &&
$queries.selectedQueryId === query._id} $queries.selectedQueryId === query._id}
on:click={() => $goto(`./query/${query._id}`)} on:click={() => $goto(`./query/${query._id}`)}
selectedBy={$userSelectedResourceMap[query._id]}
> >
<EditQueryPopover {query} /> <EditQueryPopover {query} />
</NavItem> </NavItem>
@ -212,7 +216,7 @@
<style> <style>
.hierarchy-items-container { .hierarchy-items-container {
margin: 0 calc(-1 * var(--spacing-xl)); margin: 0 calc(-1 * var(--spacing-l));
} }
.datasource-icon { .datasource-icon {
display: grid; display: grid;

View File

@ -31,65 +31,65 @@
<path <path
class="st1" class="st1"
d="M-83.19,48h-41.79c-1.76,0-3.19-1.43-3.19-3.19V3.02c0-1.76,1.43-3.19,3.19-3.19h41.79 d="M-83.19,48h-41.79c-1.76,0-3.19-1.43-3.19-3.19V3.02c0-1.76,1.43-3.19,3.19-3.19h41.79
c1.76,0,3.19,1.43,3.19,3.19v41.79C-80,46.57-81.43,48-83.19,48z" c1.76,0,3.19,1.43,3.19,3.19v41.79C-80,46.57-81.43,48-83.19,48z"
/> />
<g> <g>
<g> <g>
<path <path
class="st0" class="st0"
d="M-99.62,12.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58c0.86,0.39,1.59,0.91,2.19,1.57 d="M-99.62,12.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58c0.86,0.39,1.59,0.91,2.19,1.57
c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89c-0.35,0.9-0.84,1.68-1.47,2.35 c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89c-0.35,0.9-0.84,1.68-1.47,2.35
c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V35h-4.89V12.57H-99.62z c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V35h-4.89V12.57H-99.62z
M-93.46,28.11c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69 M-93.46,28.11c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69
c-0.38-0.17-0.79-0.26-1.24-0.26c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01 c-0.38-0.17-0.79-0.26-1.24-0.26c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01
c-0.17,0.39-0.26,0.8-0.26,1.23c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68 c-0.17,0.39-0.26,0.8-0.26,1.23c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68
c0.39,0.17,0.8,0.26,1.23,0.26c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1 c0.39,0.17,0.8,0.26,1.23,0.26c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1
C-93.55,28.92-93.46,28.52-93.46,28.11z" C-93.55,28.92-93.46,28.52-93.46,28.11z"
/> />
</g> </g>
<g> <g>
<path <path
class="st0" class="st0"
d="M-114.76,12.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58 d="M-114.76,12.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58
c0.86,0.39,1.59,0.91,2.19,1.57c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89 c0.86,0.39,1.59,0.91,2.19,1.57c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89
c-0.35,0.9-0.84,1.68-1.47,2.35c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V35 c-0.35,0.9-0.84,1.68-1.47,2.35c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V35
h-4.89V12.57H-114.76z M-108.6,28.11c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69 h-4.89V12.57H-114.76z M-108.6,28.11c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69
c-0.38-0.17-0.79-0.26-1.24-0.26c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01 c-0.38-0.17-0.79-0.26-1.24-0.26c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01
c-0.17,0.39-0.26,0.8-0.26,1.23c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68 c-0.17,0.39-0.26,0.8-0.26,1.23c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68
c0.39,0.17,0.8,0.26,1.23,0.26c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1 c0.39,0.17,0.8,0.26,1.23,0.26c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1
C-108.68,28.92-108.6,28.52-108.6,28.11z" C-108.68,28.92-108.6,28.52-108.6,28.11z"
/> />
</g> </g>
</g> </g>
<path <path
class="st2" class="st2"
d="M44.81,159H3.02c-1.76,0-3.19-1.43-3.19-3.19v-41.79c0-1.76,1.43-3.19,3.19-3.19h41.79 d="M44.81,159H3.02c-1.76,0-3.19-1.43-3.19-3.19v-41.79c0-1.76,1.43-3.19,3.19-3.19h41.79
c1.76,0,3.19,1.43,3.19,3.19v41.79C48,157.57,46.57,159,44.81,159z" c1.76,0,3.19,1.43,3.19,3.19v41.79C48,157.57,46.57,159,44.81,159z"
/> />
<g> <g>
<g> <g>
<path <path
class="st1" class="st1"
d="M28.38,123.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58c0.86,0.39,1.59,0.91,2.19,1.57 d="M28.38,123.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58c0.86,0.39,1.59,0.91,2.19,1.57
c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89c-0.35,0.9-0.84,1.68-1.47,2.35 c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89c-0.35,0.9-0.84,1.68-1.47,2.35
c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V146h-4.89v-22.43H28.38z c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V146h-4.89v-22.43H28.38z
M34.54,139.11c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69 M34.54,139.11c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69
c-0.38-0.17-0.79-0.26-1.24-0.26c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01 c-0.38-0.17-0.79-0.26-1.24-0.26c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01
c-0.17,0.39-0.26,0.8-0.26,1.23c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68 c-0.17,0.39-0.26,0.8-0.26,1.23c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68
c0.39,0.17,0.8,0.26,1.23,0.26c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1 c0.39,0.17,0.8,0.26,1.23,0.26c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1
C34.45,139.92,34.54,139.52,34.54,139.11z" C34.45,139.92,34.54,139.52,34.54,139.11z"
/> />
</g> </g>
<g> <g>
<path <path
class="st1" class="st1"
d="M13.24,123.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58c0.86,0.39,1.59,0.91,2.19,1.57 d="M13.24,123.57v9.94c1.15-1.21,2.59-1.81,4.32-1.81c1.03,0,1.97,0.19,2.82,0.58c0.86,0.39,1.59,0.91,2.19,1.57
c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89c-0.35,0.9-0.84,1.68-1.47,2.35 c0.6,0.66,1.08,1.43,1.42,2.32c0.34,0.89,0.51,1.84,0.51,2.85c0,1.03-0.18,1.99-0.53,2.89c-0.35,0.9-0.84,1.68-1.47,2.35
c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V146H8.35v-22.43H13.24z M19.4,139.11 c-0.63,0.67-1.37,1.19-2.23,1.58c-0.86,0.39-1.78,0.58-2.77,0.58c-1.8,0-3.22-0.66-4.27-1.97V146H8.35v-22.43H13.24z M19.4,139.11
c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69c-0.38-0.17-0.79-0.26-1.24-0.26 c0-0.43-0.08-0.84-0.24-1.23c-0.16-0.39-0.39-0.72-0.68-1.01c-0.29-0.29-0.62-0.52-1-0.69c-0.38-0.17-0.79-0.26-1.24-0.26
c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01c-0.17,0.39-0.26,0.8-0.26,1.23 c-0.43,0-0.84,0.08-1.22,0.24c-0.38,0.16-0.71,0.39-0.99,0.68c-0.28,0.29-0.5,0.63-0.68,1.01c-0.17,0.39-0.26,0.8-0.26,1.23
c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68c0.39,0.17,0.8,0.26,1.23,0.26 c0,0.43,0.08,0.84,0.24,1.22c0.16,0.38,0.39,0.71,0.68,0.99c0.29,0.28,0.63,0.5,1.01,0.68c0.39,0.17,0.8,0.26,1.23,0.26
c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1C19.32,139.92,19.4,139.52,19.4,139.11z" c0.43,0,0.84-0.08,1.22-0.24c0.38-0.16,0.71-0.39,0.99-0.68c0.28-0.29,0.5-0.62,0.68-1C19.32,139.92,19.4,139.52,19.4,139.11z"
/> />
</g> </g>
</g> </g>
@ -102,24 +102,24 @@
<path <path
class="st1" class="st1"
d="M28.48,12v10.44c1.18-1.27,2.65-1.9,4.42-1.9c1.05,0,2.01,0.2,2.89,0.61c0.87,0.41,1.62,0.96,2.24,1.65 d="M28.48,12v10.44c1.18-1.27,2.65-1.9,4.42-1.9c1.05,0,2.01,0.2,2.89,0.61c0.87,0.41,1.62,0.96,2.24,1.65
c0.62,0.69,1.1,1.5,1.45,2.44c0.35,0.94,0.52,1.93,0.52,2.99c0,1.08-0.18,2.09-0.54,3.04c-0.36,0.95-0.86,1.77-1.51,2.47 c0.62,0.69,1.1,1.5,1.45,2.44c0.35,0.94,0.52,1.93,0.52,2.99c0,1.08-0.18,2.09-0.54,3.04c-0.36,0.95-0.86,1.77-1.51,2.47
c-0.64,0.7-1.4,1.25-2.28,1.66C34.8,35.8,33.86,36,32.84,36c-1.84,0-3.3-0.69-4.37-2.07v1.62h-5V12H28.48z M34.78,28.31 c-0.64,0.7-1.4,1.25-2.28,1.66C34.8,35.8,33.86,36,32.84,36c-1.84,0-3.3-0.69-4.37-2.07v1.62h-5V12H28.48z M34.78,28.31
c0-0.45-0.08-0.88-0.25-1.29c-0.17-0.41-0.4-0.76-0.69-1.06c-0.3-0.3-0.64-0.54-1.02-0.72c-0.39-0.18-0.81-0.27-1.27-0.27 c0-0.45-0.08-0.88-0.25-1.29c-0.17-0.41-0.4-0.76-0.69-1.06c-0.3-0.3-0.64-0.54-1.02-0.72c-0.39-0.18-0.81-0.27-1.27-0.27
c-0.44,0-0.86,0.09-1.24,0.26c-0.39,0.17-0.72,0.41-1.01,0.71c-0.29,0.3-0.52,0.66-0.69,1.06c-0.18,0.41-0.26,0.84-0.26,1.29 c-0.44,0-0.86,0.09-1.24,0.26c-0.39,0.17-0.72,0.41-1.01,0.71c-0.29,0.3-0.52,0.66-0.69,1.06c-0.18,0.41-0.26,0.84-0.26,1.29
s0.08,0.88,0.25,1.28c0.17,0.4,0.4,0.74,0.69,1.04c0.29,0.29,0.64,0.53,1.04,0.71c0.4,0.18,0.82,0.27,1.26,0.27 s0.08,0.88,0.25,1.28c0.17,0.4,0.4,0.74,0.69,1.04c0.29,0.29,0.64,0.53,1.04,0.71c0.4,0.18,0.82,0.27,1.26,0.27
c0.44,0,0.86-0.09,1.24-0.26c0.39-0.17,0.72-0.41,1.01-0.71c0.29-0.3,0.52-0.65,0.69-1.05C34.69,29.16,34.78,28.75,34.78,28.31z" c0.44,0,0.86-0.09,1.24-0.26c0.39-0.17,0.72-0.41,1.01-0.71c0.29-0.3,0.52-0.65,0.69-1.05C34.69,29.16,34.78,28.75,34.78,28.31z"
/> />
</g> </g>
<g> <g>
<path <path
class="st1" class="st1"
d="M13,12v10.44c1.18-1.27,2.65-1.9,4.42-1.9c1.05,0,2.01,0.2,2.89,0.61c0.87,0.41,1.62,0.96,2.24,1.65 d="M13,12v10.44c1.18-1.27,2.65-1.9,4.42-1.9c1.05,0,2.01,0.2,2.89,0.61c0.87,0.41,1.62,0.96,2.24,1.65
c0.62,0.69,1.1,1.5,1.45,2.44c0.35,0.94,0.52,1.93,0.52,2.99c0,1.08-0.18,2.09-0.54,3.04c-0.36,0.95-0.86,1.77-1.51,2.47 c0.62,0.69,1.1,1.5,1.45,2.44c0.35,0.94,0.52,1.93,0.52,2.99c0,1.08-0.18,2.09-0.54,3.04c-0.36,0.95-0.86,1.77-1.51,2.47
c-0.64,0.7-1.4,1.25-2.28,1.66C19.32,35.8,18.38,36,17.37,36c-1.84,0-3.3-0.69-4.37-2.07v1.62H8V12H13z M19.3,28.31 c-0.64,0.7-1.4,1.25-2.28,1.66C19.32,35.8,18.38,36,17.37,36c-1.84,0-3.3-0.69-4.37-2.07v1.62H8V12H13z M19.3,28.31
c0-0.45-0.08-0.88-0.25-1.29c-0.17-0.41-0.4-0.76-0.69-1.06c-0.3-0.3-0.64-0.54-1.02-0.72c-0.39-0.18-0.81-0.27-1.27-0.27 c0-0.45-0.08-0.88-0.25-1.29c-0.17-0.41-0.4-0.76-0.69-1.06c-0.3-0.3-0.64-0.54-1.02-0.72c-0.39-0.18-0.81-0.27-1.27-0.27
c-0.44,0-0.86,0.09-1.24,0.26c-0.39,0.17-0.72,0.41-1.01,0.71c-0.29,0.3-0.52,0.66-0.69,1.06c-0.18,0.41-0.26,0.84-0.26,1.29 c-0.44,0-0.86,0.09-1.24,0.26c-0.39,0.17-0.72,0.41-1.01,0.71c-0.29,0.3-0.52,0.66-0.69,1.06c-0.18,0.41-0.26,0.84-0.26,1.29
s0.08,0.88,0.25,1.28c0.17,0.4,0.4,0.74,0.69,1.04c0.29,0.29,0.64,0.53,1.04,0.71c0.4,0.18,0.82,0.27,1.26,0.27 s0.08,0.88,0.25,1.28c0.17,0.4,0.4,0.74,0.69,1.04c0.29,0.29,0.64,0.53,1.04,0.71c0.4,0.18,0.82,0.27,1.26,0.27
c0.44,0,0.86-0.09,1.24-0.26c0.39-0.17,0.72-0.41,1.01-0.71c0.29-0.3,0.52-0.65,0.69-1.05C19.21,29.16,19.3,28.75,19.3,28.31z" c0.44,0,0.86-0.09,1.24-0.26c0.39-0.17,0.72-0.41,1.01-0.71c0.29-0.3,0.52-0.65,0.69-1.05C19.21,29.16,19.3,28.75,19.3,28.31z"
/> />
</g> </g>
</g> </g>

View File

@ -1,6 +1,7 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { import {
keepOpen,
ModalContent, ModalContent,
notifications, notifications,
Body, Body,
@ -70,10 +71,9 @@
} }
notifications.success(`Imported successfully.`) notifications.success(`Imported successfully.`)
return true
} catch (error) { } catch (error) {
notifications.error("Error importing queries") notifications.error("Error importing queries")
return false return keepOpen
} }
} }
</script> </script>

View File

@ -1,5 +1,6 @@
<script> <script>
import { import {
keepOpen,
Modal, Modal,
notifications, notifications,
Body, Body,
@ -36,7 +37,7 @@
}) })
} }
return false return keepOpen
} }
let createVariableModal let createVariableModal

View File

@ -1,6 +1,7 @@
<script> <script>
import { RelationshipTypes } from "constants/backend" import { RelationshipType } from "constants/backend"
import { import {
keepOpen,
Button, Button,
Input, Input,
ModalContent, ModalContent,
@ -24,11 +25,11 @@
const relationshipTypes = [ const relationshipTypes = [
{ {
label: "One to Many", label: "One to Many",
value: RelationshipTypes.MANY_TO_ONE, value: RelationshipType.MANY_TO_ONE,
}, },
{ {
label: "Many to Many", label: "Many to Many",
value: RelationshipTypes.MANY_TO_MANY, value: RelationshipType.MANY_TO_MANY,
}, },
] ]
@ -57,8 +58,8 @@
value: table._id, value: table._id,
})) }))
$: valid = getErrorCount(errors) === 0 && allRequiredAttributesSet() $: valid = getErrorCount(errors) === 0 && allRequiredAttributesSet()
$: isManyToMany = relationshipType === RelationshipTypes.MANY_TO_MANY $: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
$: isManyToOne = relationshipType === RelationshipTypes.MANY_TO_ONE $: isManyToOne = relationshipType === RelationshipType.MANY_TO_ONE
function getTable(id) { function getTable(id) {
return plusTables.find(table => table._id === id) return plusTables.find(table => table._id === id)
@ -115,7 +116,7 @@
function allRequiredAttributesSet() { function allRequiredAttributesSet() {
const base = getTable(fromId) && getTable(toId) && fromColumn && toColumn const base = getTable(fromId) && getTable(toId) && fromColumn && toColumn
if (relationshipType === RelationshipTypes.MANY_TO_ONE) { if (relationshipType === RelationshipType.MANY_TO_ONE) {
return base && fromPrimary && fromForeign return base && fromPrimary && fromForeign
} else { } else {
return base && getTable(throughId) && throughFromKey && throughToKey return base && getTable(throughId) && throughFromKey && throughToKey
@ -180,12 +181,12 @@
} }
function otherRelationshipType(type) { function otherRelationshipType(type) {
if (type === RelationshipTypes.MANY_TO_ONE) { if (type === RelationshipType.MANY_TO_ONE) {
return RelationshipTypes.ONE_TO_MANY return RelationshipType.ONE_TO_MANY
} else if (type === RelationshipTypes.ONE_TO_MANY) { } else if (type === RelationshipType.ONE_TO_MANY) {
return RelationshipTypes.MANY_TO_ONE return RelationshipType.MANY_TO_ONE
} else if (type === RelationshipTypes.MANY_TO_MANY) { } else if (type === RelationshipType.MANY_TO_MANY) {
return RelationshipTypes.MANY_TO_MANY return RelationshipType.MANY_TO_MANY
} }
} }
@ -217,7 +218,7 @@
// if any to many only need to check from // if any to many only need to check from
const manyToMany = const manyToMany =
relateFrom.relationshipType === RelationshipTypes.MANY_TO_MANY relateFrom.relationshipType === RelationshipType.MANY_TO_MANY
if (!manyToMany) { if (!manyToMany) {
delete relateFrom.through delete relateFrom.through
@ -252,7 +253,7 @@
} }
relateTo = { relateTo = {
...relateTo, ...relateTo,
relationshipType: RelationshipTypes.ONE_TO_MANY, relationshipType: RelationshipType.ONE_TO_MANY,
foreignKey: relateFrom.fieldName, foreignKey: relateFrom.fieldName,
fieldName: fromPrimary, fieldName: fromPrimary,
} }
@ -277,7 +278,7 @@
async function saveRelationship() { async function saveRelationship() {
if (!validate()) { if (!validate()) {
return false return keepOpen
} }
buildRelationships() buildRelationships()
removeExistingRelationship() removeExistingRelationship()
@ -320,7 +321,7 @@
fromColumn = toRelationship.name fromColumn = toRelationship.name
} }
relationshipType = relationshipType =
fromRelationship.relationshipType || RelationshipTypes.MANY_TO_ONE fromRelationship.relationshipType || RelationshipType.MANY_TO_ONE
if (selectedFromTable) { if (selectedFromTable) {
fromId = selectedFromTable._id fromId = selectedFromTable._id
fromColumn = selectedFromTable.name fromColumn = selectedFromTable.name

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