Merge branch 'master' into BUDI-8312

This commit is contained in:
Martin McKeaveney 2024-07-02 09:23:52 +01:00 committed by GitHub
commit 5a6f42cb28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
273 changed files with 7453 additions and 5040 deletions

View File

@ -92,7 +92,8 @@
// differs to external, but the API is broadly the same
"jest/no-conditional-expect": "off",
// have to turn this off to allow function overloading in typescript
"no-dupe-class-members": "off"
"no-dupe-class-members": "off",
"no-redeclare": "off"
}
},
{

View File

@ -73,9 +73,9 @@ jobs:
- name: Check types
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn check:types --since=${{ env.NX_BASE_BRANCH }}
yarn check:types --since=${{ env.NX_BASE_BRANCH }} --ignore @budibase/account-portal-server
else
yarn check:types
yarn check:types --ignore @budibase/account-portal-server
fi
helm-lint:
@ -226,10 +226,11 @@ jobs:
if: ${{ steps.get_pro_commits.outputs.base_commit_excluding_merges != '' }}
run: |
cd packages/pro
base_commit='${{ steps.get_pro_commits.outputs.base_commit }}'
base_commit_excluding_merges='${{ steps.get_pro_commits.outputs.base_commit_excluding_merges }}'
pro_commit='${{ steps.get_pro_commits.outputs.pro_commit }}'
any_commit=$(git log --no-merges $base_commit_excluding_merges...$pro_commit)
any_commit=$(git log --no-merges $base_commit...$pro_commit)
if [ -n "$any_commit" ]; then
echo $any_commit

View File

@ -9,7 +9,7 @@ on:
jobs:
ensure-is-master-tag:
name: Ensure is a master tag
runs-on: qa-arc-runner-set
runs-on: ubuntu-latest
steps:
- name: Checkout monorepo
uses: actions/checkout@v4

38
.github/workflows/pr-labeler.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: PR labeler
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
on:
pull_request:
types: [opened, synchronize]
jobs:
size-labeler:
runs-on: ubuntu-latest
steps:
- uses: codelytv/pr-size-labeler@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_max_size: "10"
s_max_size: "100"
m_max_size: "500"
l_max_size: "1000"
fail_if_xl: "false"
files_to_ignore: "yarn.lock"
team-labeler:
runs-on: ubuntu-latest
if: ${{ github.event.action == 'opened' }}
steps:
- uses: rodrigoarias/auto-label-per-user@v1.0.0
with:
git-token: ${{ secrets.GITHUB_TOKEN }}
user-team-map: |
{
"adrinr": "firestorm",
"samwho": "firestorm",
"PClmnt": "firestorm",
"mike12345567": "firestorm"
}

2
.gitignore vendored
View File

@ -8,6 +8,8 @@ bb-airgapped.tar.gz
packages/server/build/oldClientVersions/**/*
packages/builder/src/components/deploy/clientVersions.json
packages/server/src/integrations/tests/utils/*.lock
# Logs
logs
*.log

View File

@ -1,6 +1,6 @@
dependencies:
- name: couchdb
repository: https://apache.github.io/couchdb-helm
version: 4.3.0
digest: sha256:94449a7f195b186f5af33ec5aa66d58b36bede240fae710f021ca87837b30606
generated: "2023-11-20T17:43:02.777596Z"
version: 4.5.6
digest: sha256:405f098633e632d6f4e140175f156ed4f02918b0d89193f1b66c9cbea211d6c9
generated: "2024-06-05T14:41:05.979052+01:00"

View File

@ -17,6 +17,6 @@ version: 0.0.0
appVersion: 0.0.0
dependencies:
- name: couchdb
version: 4.3.0
version: 4.5.6
repository: https://apache.github.io/couchdb-helm
condition: services.couchdb.enabled

View File

@ -112,7 +112,9 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| awsAlbIngress.enabled | bool | `false` | Whether to create an ALB Ingress resource pointing to the Budibase proxy. Requires the AWS ALB Ingress Controller. |
| couchdb.clusterSize | int | `1` | The number of replicas to run in the CouchDB cluster. We set this to 1 by default to make things simpler, but you can set it to 3 if you need a high-availability CouchDB cluster. |
| couchdb.couchdbConfig.couchdb.uuid | string | `"budibase-couchdb"` | Unique identifier for this CouchDB server instance. You shouldn't need to change this. |
| couchdb.extraPorts[0] | object | `{"containerPort":4984,"name":"sqs"}` | Extra ports to expose on the CouchDB service. We expose the SQS port by default, but you can add more ports here if you need to. |
| couchdb.image | object | `{}` | We use a custom CouchDB image for running Budibase and we don't support using any other CouchDB image. You shouldn't change this, and if you do we can't guarantee that Budibase will work. |
| couchdb.service.extraPorts[0] | object | `{"name":"sqs","port":4984,"protocol":"TCP","targetPort":4984}` | Extra ports to expose on the CouchDB service. We expose the SQS port by default, but you can add more ports here if you need to. |
| globals.apiEncryptionKey | string | `""` | Used for encrypting API keys and environment variables when stored in the database. You don't need to set this if `createSecrets` is true. |
| globals.appVersion | string | `""` | The version of Budibase to deploy. Defaults to what's specified by {{ .Chart.AppVersion }}. Ends up being used as the image version tag for the apps, proxy, and worker images. |
| globals.automationMaxIterations | string | `"200"` | The maximum number of iterations allows for an automation loop step. You can read more about looping here: <https://docs.budibase.com/docs/looping>. |
@ -135,6 +137,8 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| globals.smtp.password | string | `""` | The password to use when authenticating with your SMTP server. |
| globals.smtp.port | string | `"587"` | The port of your SMTP server. |
| globals.smtp.user | string | `""` | The username to use when authenticating with your SMTP server. |
| globals.sqs.enabled | bool | `false` | Whether to use the CouchDB "structured query service" or not. This is disabled by default for now, but will become the default in a future release. |
| globals.tempBucketName | string | `""` | |
| globals.tenantFeatureFlags | string | `"*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR"` | Sets what feature flags are enabled and for which tenants. Should not ordinarily need to be changed. |
| imagePullSecrets | list | `[]` | Passed to all pods created by this chart. Should not ordinarily need to be changed. |
| ingress.className | string | `""` | What ingress class to use. |
@ -152,6 +156,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| services.apps.autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization percentage for the apps service. Note that for autoscaling to work, you will need to have metrics-server configured, and resources set for the apps pods. |
| services.apps.extraContainers | list | `[]` | Additional containers to be added to the apps pod. |
| services.apps.extraEnv | list | `[]` | Extra environment variables to set for apps pods. Takes a list of name=value pairs. |
| services.apps.extraEnvFromSecret | list | `[]` | Name of the K8s Secret in the same namespace which contains the extra environment variables. This can be used to avoid storing sensitive information in the values.yaml file. |
| services.apps.extraVolumeMounts | list | `[]` | Additional volumeMounts to the main apps container. |
| services.apps.extraVolumes | list | `[]` | Additional volumes to the apps pod. |
| services.apps.httpLogging | int | `1` | Whether or not to log HTTP requests to the apps service. |
@ -168,6 +173,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| services.automationWorkers.enabled | bool | `true` | Whether or not to enable the automation worker service. If you disable this, automations will be processed by the apps service. |
| services.automationWorkers.extraContainers | list | `[]` | Additional containers to be added to the automationWorkers pod. |
| services.automationWorkers.extraEnv | list | `[]` | Extra environment variables to set for automation worker pods. Takes a list of name=value pairs. |
| services.automationWorkers.extraEnvFromSecret | list | `[]` | Name of the K8s Secret in the same namespace which contains the extra environment variables. This can be used to avoid storing sensitive information in the values.yaml file. |
| services.automationWorkers.extraVolumeMounts | list | `[]` | Additional volumeMounts to the main automationWorkers container. |
| services.automationWorkers.extraVolumes | list | `[]` | Additional volumes to the automationWorkers pod. |
| services.automationWorkers.livenessProbe | object | HTTP health checks. | Liveness probe configuration for automation worker pods. You shouldn't need to change this, but if you want to you can find more information here: <https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/> |
@ -195,7 +201,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| services.objectStore.region | string | `""` | AWS_REGION if using S3 |
| services.objectStore.resources | object | `{}` | The resources to use for Minio pods. See <https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/> for more information on how to set these. |
| services.objectStore.secretKey | string | `""` | AWS_SECRET_ACCESS_KEY if using S3 |
| services.objectStore.storage | string | `"100Mi"` | How much storage to give Minio in its PersistentVolumeClaim. |
| services.objectStore.storage | string | `"2Gi"` | How much storage to give Minio in its PersistentVolumeClaim. |
| services.objectStore.storageClass | string | `""` | If defined, storageClassName: <storageClass> If set to "-", storageClassName: "", which disables dynamic provisioning If undefined (the default) or set to null, no storageClassName spec is set, choosing the default provisioner. |
| services.objectStore.url | string | `"http://minio-service:9000"` | URL to use for object storage. Only change this if you're using an external object store, such as S3. Remember to set `minio: false` if you do this. |
| services.proxy.autoscaling.enabled | bool | `false` | Whether to enable horizontal pod autoscaling for the proxy service. |
@ -227,6 +233,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| services.worker.autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization percentage for the worker service. Note that for autoscaling to work, you will need to have metrics-server configured, and resources set for the worker pods. |
| services.worker.extraContainers | list | `[]` | Additional containers to be added to the worker pod. |
| services.worker.extraEnv | list | `[]` | Extra environment variables to set for worker pods. Takes a list of name=value pairs. |
| services.worker.extraEnvFromSecret | list | `[]` | Name of the K8s Secret in the same namespace which contains the extra environment variables. This can be used to avoid storing sensitive information in the values.yaml file. |
| services.worker.extraVolumeMounts | list | `[]` | Additional volumeMounts to the main worker container. |
| services.worker.extraVolumes | list | `[]` | Additional volumes to the worker pod. |
| services.worker.httpLogging | int | `1` | Whether or not to log HTTP requests to the worker service. |

Binary file not shown.

View File

@ -42,6 +42,14 @@ spec:
{{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }}
{{ if .Values.globals.sqs.enabled }}
- name: COUCH_DB_SQL_URL
{{ if .Values.globals.sqs.url }}
value: {{ .Values.globals.sqs.url }}
{{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
{{ end }}
{{ end }}
{{ if .Values.services.couchdb.enabled }}
- name: COUCH_DB_USER
valueFrom:
@ -198,10 +206,21 @@ spec:
- name: APP_FEATURES
value: "api"
{{- end }}
{{- if .Values.globals.sqs.enabled }}
- name: SQS_SEARCH_ENABLE
value: "true"
{{- end }}
{{- range .Values.services.apps.extraEnv }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- range .Values.services.apps.extraEnvFromSecret}}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey | quote }}
{{- end}}
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always
{{- if .Values.services.apps.startupProbe }}

View File

@ -201,6 +201,13 @@ spec:
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- range .Values.services.automationWorkers.extraEnvFromSecret}}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey | quote }}
{{- end}}
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always
@ -272,4 +279,4 @@ spec:
{{- toYaml .Values.services.automationWorkers.extraVolumes | nindent 8 }}
{{ end }}
status: {}
{{- end }}
{{- end }}

View File

@ -56,6 +56,14 @@ spec:
{{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }}
{{ if .Values.globals.sqs.enabled }}
- name: COUCH_DB_SQL_URL
{{ if .Values.globals.sqs.url }}
value: {{ .Values.globals.sqs.url }}
{{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
{{ end }}
{{ end }}
- name: API_ENCRYPTION_KEY
value: {{ .Values.globals.apiEncryptionKey | quote }}
- name: HTTP_LOGGING
@ -184,10 +192,21 @@ spec:
- name: NODE_TLS_REJECT_UNAUTHORIZED
value: {{ .Values.services.tlsRejectUnauthorized }}
{{ end }}
{{- if .Values.globals.sqs.enabled }}
- name: SQS_SEARCH_ENABLE
value: "true"
{{- end }}
{{- range .Values.services.worker.extraEnv }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- range .Values.services.worker.extraEnvFromSecret}}
- name: {{ .name }}
valueFrom:
secretKeyRef:
name: {{ .secretName }}
key: {{ .secretKey | quote }}
{{- end}}
image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always
{{- if .Values.services.worker.startupProbe }}

View File

@ -138,6 +138,15 @@ globals:
# -- The password to use when authenticating with your SMTP server.
password: ""
sqs:
# -- Whether to use the CouchDB "structured query service" or not. This is disabled by
# default for now, but will become the default in a future release.
enabled: false
# @ignore
url: ""
# @ignore
port: "4984"
services:
# -- The DNS suffix to use for service discovery. You only need to change this
# if you've configured your cluster to use a different DNS suffix.
@ -240,6 +249,13 @@ services:
# -- Extra environment variables to set for apps pods. Takes a list of
# name=value pairs.
extraEnv: []
# -- Name of the K8s Secret in the same namespace which contains the extra environment variables.
# This can be used to avoid storing sensitive information in the values.yaml file.
extraEnvFromSecret: []
# - name: MY_SECRET_KEY
# secretName : my-secret
# secretKey: my-secret-key
# -- Startup probe configuration for apps pods. You shouldn't need to
# change this, but if you want to you can find more information here:
# <https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/>
@ -323,6 +339,13 @@ services:
# -- Extra environment variables to set for automation worker pods. Takes a list of
# name=value pairs.
extraEnv: []
# -- Name of the K8s Secret in the same namespace which contains the extra environment variables.
# This can be used to avoid storing sensitive information in the values.yaml file.
extraEnvFromSecret: []
# - name: MY_SECRET_KEY
# secretName : my-secret
# secretKey: my-secret-key
# -- Startup probe configuration for automation worker pods. You shouldn't
# need to change this, but if you want to you can find more information
# here:
@ -408,6 +431,13 @@ services:
# -- Extra environment variables to set for worker pods. Takes a list of
# name=value pairs.
extraEnv: []
# -- Name of the K8s Secret in the same namespace which contains the extra environment variables.
# This can be used to avoid storing sensitive information in the values.yaml file.
extraEnvFromSecret: []
# - name: MY_SECRET_KEY
# secretName : my-secret
# secretKey: my-secret-key
# -- Startup probe configuration for worker pods. You shouldn't need to
# change this, but if you want to you can find more information here:
# <https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/>
@ -611,10 +641,25 @@ couchdb:
# @ignore
repository: budibase/couchdb
# @ignore
tag: v3.2.1
tag: v3.3.3
# @ignore
pullPolicy: Always
extraPorts:
# -- Extra ports to expose on the CouchDB service. We expose the SQS port
# by default, but you can add more ports here if you need to.
- name: sqs
containerPort: 4984
service:
extraPorts:
# -- Extra ports to expose on the CouchDB service. We expose the SQS port
# by default, but you can add more ports here if you need to.
- name: sqs
port: 4984
targetPort: 4984
protocol: TCP
# @ignore
# This should remain false. We ship Clouseau ourselves as part of the
# budibase/couchdb image, and it's not possible to disable it because it's a

View File

@ -22,6 +22,6 @@
"@types/react": "17.0.39",
"eslint": "8.10.0",
"eslint-config-next": "12.1.0",
"typescript": "5.2.2"
"typescript": "5.5.2"
}
}

View File

@ -333,11 +333,11 @@ brace-expansion@^1.1.7:
concat-map "0.0.1"
braces@^3.0.1, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.0.1"
fill-range "^7.1.1"
bulma@^0.9.3:
version "0.9.3"
@ -781,10 +781,10 @@ file-entry-cache@^6.0.1:
dependencies:
flat-cache "^3.0.4"
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
@ -1709,10 +1709,10 @@ type-fest@^0.20.2:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4"
integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg==
typescript@5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
unbox-primitive@^1.0.1:
version "1.0.1"

View File

@ -74,6 +74,7 @@ http {
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "${csp_default}; ${csp_script}; ${csp_style}; ${csp_object}; ${csp_base_uri}; ${csp_connect}; ${csp_font}; ${csp_frame}; ${csp_img}; ${csp_manifest}; ${csp_media}; ${csp_worker};" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# upstreams
set $apps ${APPS_UPSTREAM_URL};

View File

@ -1,5 +1,5 @@
{
"version": "2.27.5",
"version": "2.29.5",
"npmClient": "yarn",
"packages": [
"packages/*",

View File

@ -27,7 +27,7 @@
"proper-lockfile": "^4.1.2",
"svelte": "^4.2.10",
"svelte-eslint-parser": "^0.33.1",
"typescript": "5.2.2",
"typescript": "5.5.2",
"typescript-eslint": "^7.3.1",
"yargs": "^17.7.2"
},
@ -37,10 +37,10 @@
"build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
"build:apps": "yarn build --scope @budibase/server --scope @budibase/worker",
"build:cli": "yarn build --scope @budibase/cli",
"build:oss": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui",
"build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal --scope @budibase/account-portal-server --scope @budibase/account-portal-ui",
"build:oss": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui",
"build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal-server --scope @budibase/account-portal-ui",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run --concurrency 2 check:types",
"check:types": "lerna run --concurrency 2 check:types --ignore @budibase/account-portal-server",
"build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",

@ -1 +1 @@
Subproject commit 39acfff42a063e5a8a7d58d36721ec3103e16348
Subproject commit ff16525b73c5751d344f5c161a682609c0a993f2

View File

@ -16,7 +16,7 @@
"prepack": "cp package.json dist",
"build": "tsc -p tsconfig.build.json --paths null && node ./scripts/build.js",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",
"check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020",
"test": "bash scripts/test.sh",
"test:watch": "jest --watchAll"
},
@ -79,7 +79,7 @@
"pouchdb-adapter-memory": "7.2.2",
"testcontainers": "^10.7.2",
"timekeeper": "2.2.0",
"typescript": "5.2.2"
"typescript": "5.5.2"
},
"nx": {
"targets": {

View File

@ -72,4 +72,4 @@ export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs"
export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory"
export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses"
export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee"
export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default"
export { DEFAULT_BB_DATASOURCE_ID } from "@budibase/shared-core"

View File

@ -1,14 +1,5 @@
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
export function isInternalColumnName(name: string): boolean {
return (CONSTANT_INTERNAL_ROW_COLS as readonly string[]).includes(name)
}
export {
CONSTANT_INTERNAL_ROW_COLS,
CONSTANT_EXTERNAL_ROW_COLS,
isInternalColumnName,
} from "@budibase/shared-core"

View File

@ -8,6 +8,7 @@ import {
DatabaseOpts,
DatabasePutOpts,
DatabaseQueryOpts,
DBError,
Document,
isDocument,
RowResponse,
@ -41,7 +42,7 @@ function buildNano(couchInfo: { url: string; cookie: string }) {
type DBCall<T> = () => Promise<T>
class CouchDBError extends Error {
class CouchDBError extends Error implements DBError {
status: number
statusCode: number
reason: string
@ -328,7 +329,14 @@ export class DatabaseImpl implements Database {
async sqlDiskCleanup(): Promise<void> {
const dbName = this.name
const url = `/${dbName}/_cleanup`
return await this._sqlQuery<void>(url, "POST")
try {
await this._sqlQuery<void>(url, "POST")
} catch (err: any) {
// hack for now - SQS throws a 500 when there is nothing to clean-up
if (err.status !== 500) {
throw err
}
}
}
// removes a document from sqlite
@ -352,18 +360,15 @@ export class DatabaseImpl implements Database {
}
async destroy() {
if (env.SQS_SEARCH_ENABLE && (await this.exists(SQLITE_DESIGN_DOC_ID))) {
// delete the design document, then run the cleanup operation
const definition = await this.get<SQLiteDefinition>(SQLITE_DESIGN_DOC_ID)
// remove all tables - save the definition then trigger a cleanup
definition.sql.tables = {}
await this.put(definition)
await this.sqlDiskCleanup()
}
try {
if (env.SQS_SEARCH_ENABLE) {
// delete the design document, then run the cleanup operation
try {
const definition = await this.get<SQLiteDefinition>(
SQLITE_DESIGN_DOC_ID
)
await this.remove(SQLITE_DESIGN_DOC_ID, definition._rev)
} finally {
await this.sqlDiskCleanup()
}
}
return await this.nano().db.destroy(this.name)
} catch (err: any) {
// didn't exist, don't worry

View File

@ -93,15 +93,21 @@ function isApps() {
return environment.SERVICE_TYPE === ServiceType.APPS
}
function isQA() {
return environment.BUDIBASE_ENVIRONMENT === "QA"
}
const environment = {
isTest,
isJest,
isDev,
isWorker,
isApps,
isQA,
isProd: () => {
return !isDev()
},
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
JS_BCRYPT: process.env.JS_BCRYPT,
JWT_SECRET: process.env.JWT_SECRET,
JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK,
@ -120,6 +126,7 @@ const environment = {
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,
AWS_REGION: process.env.AWS_REGION,
MINIO_URL: process.env.MINIO_URL,
MINIO_ENABLED: process.env.MINIO_ENABLED || 1,

View File

@ -3,7 +3,8 @@ import { Ctx } from "@budibase/types"
function validate(
schema: Joi.ObjectSchema | Joi.ArraySchema,
property: string
property: string,
opts: { errorPrefix: string } = { errorPrefix: `Invalid ${property}` }
) {
// Return a Koa middleware function
return (ctx: Ctx, next: any) => {
@ -29,16 +30,26 @@ function validate(
const { error } = schema.validate(params)
if (error) {
ctx.throw(400, `Invalid ${property} - ${error.message}`)
let message = error.message
if (opts.errorPrefix) {
message = `Invalid ${property} - ${message}`
}
ctx.throw(400, message)
}
return next()
}
}
export function body(schema: Joi.ObjectSchema | Joi.ArraySchema) {
return validate(schema, "body")
export function body(
schema: Joi.ObjectSchema | Joi.ArraySchema,
opts?: { errorPrefix: string }
) {
return validate(schema, "body", opts)
}
export function params(schema: Joi.ObjectSchema | Joi.ArraySchema) {
return validate(schema, "params")
export function params(
schema: Joi.ObjectSchema | Joi.ArraySchema,
opts?: { errorPrefix: string }
) {
return validate(schema, "params", opts)
}

View File

@ -14,6 +14,7 @@ import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3"
import { ReadableStream } from "stream/web"
const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created
@ -41,10 +42,7 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike
}
export type StreamTypes =
| ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamTypes = ReadStream | NodeJS.ReadableStream
export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
@ -103,6 +101,11 @@ export function ObjectStore(
}
}
// for AWS Credentials using temporary session token
if (!env.MINIO_ENABLED && env.AWS_SESSION_TOKEN) {
config.sessionToken = env.AWS_SESSION_TOKEN
}
// custom S3 is in use i.e. minio
if (env.MINIO_URL) {
if (opts.presigning && env.MINIO_ENABLED) {
@ -222,6 +225,9 @@ export async function streamUpload({
extra,
ttl,
}: StreamUploadParams) {
if (!stream) {
throw new Error("Stream to upload is invalid/undefined")
}
const extension = filename.split(".").pop()
const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
@ -251,14 +257,27 @@ export async function streamUpload({
: CONTENT_TYPE_MAP.txt
}
const bucket = sanitizeBucket(bucketName),
objKey = sanitizeKey(filename)
const params = {
Bucket: sanitizeBucket(bucketName),
Key: sanitizeKey(filename),
Bucket: bucket,
Key: objKey,
Body: stream,
ContentType: contentType,
...extra,
}
return objectStore.upload(params).promise()
const details = await objectStore.upload(params).promise()
const headDetails = await objectStore
.headObject({
Bucket: bucket,
Key: objKey,
})
.promise()
return {
...details,
ContentLength: headDetails.ContentLength,
}
}
/**

View File

@ -63,12 +63,12 @@ class InMemoryQueue implements Partial<Queue> {
* Same callback API as Bull, each callback passed to this will consume messages as they are
* available. Please note this is a queue service, not a notification service, so each
* consumer will receive different messages.
* @param func The callback function which will return a "Job", the same
* as the Bull API, within this job the property "data" contains the JSON message. Please
* note this is incredibly limited compared to Bull as in reality the Job would contain
* a lot more information about the queue and current status of Bull cluster.
*/
async process(func: any) {
async process(concurrencyOrFunc: number | any, func?: any) {
func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc
this._emitter.on("message", async () => {
if (this._messages.length <= 0) {
return

View File

@ -21,6 +21,7 @@ let cleanupInterval: NodeJS.Timeout
async function cleanup() {
for (let queue of QUEUES) {
await queue.clean(CLEANUP_PERIOD_MS, "completed")
await queue.clean(CLEANUP_PERIOD_MS, "failed")
}
}

View File

@ -1,10 +1,10 @@
import { Knex, knex } from "knex"
import * as dbCore from "../db"
import {
isIsoDateString,
isValidFilter,
getNativeSql,
isExternalTable,
isIsoDateString,
isValidFilter,
} from "./utils"
import { SqlStatements } from "./sqlStatements"
import SqlTableQueryBuilder from "./sqlTable"
@ -12,21 +12,21 @@ import {
BBReferenceFieldMetadata,
FieldSchema,
FieldType,
INTERNAL_TABLE_SOURCE_ID,
JsonFieldMetadata,
JsonTypes,
Operation,
prefixed,
QueryJson,
SqlQuery,
QueryOptions,
RelationshipsJson,
SearchFilters,
SortDirection,
SortOrder,
SqlClient,
SqlQuery,
SqlQueryBinding,
Table,
TableSourceType,
INTERNAL_TABLE_SOURCE_ID,
SqlClient,
QueryOptions,
JsonTypes,
prefixed,
} from "@budibase/types"
import environment from "../environment"
import { helpers } from "@budibase/shared-core"
@ -114,7 +114,7 @@ function generateSelectStatement(
): (string | Knex.Raw)[] | "*" {
const { resource, meta } = json
if (!resource) {
if (!resource || !resource.fields || resource.fields.length === 0) {
return "*"
}
@ -184,7 +184,11 @@ class InternalBuilder {
query: Knex.QueryBuilder,
filters: SearchFilters | undefined,
table: Table,
opts: { aliases?: Record<string, string>; relationship?: boolean }
opts: {
aliases?: Record<string, string>
relationship?: boolean
columnPrefix?: string
}
): Knex.QueryBuilder {
if (!filters) {
return query
@ -192,7 +196,10 @@ class InternalBuilder {
filters = parseFilters(filters)
// if all or specified in filters, then everything is an or
const allOr = filters.allOr
const sqlStatements = new SqlStatements(this.client, table, { allOr })
const sqlStatements = new SqlStatements(this.client, table, {
allOr,
columnPrefix: opts.columnPrefix,
})
const tableName =
this.client === SqlClient.SQL_LITE ? table._id! : table.name
@ -397,9 +404,9 @@ class InternalBuilder {
contains(filters.containsAny, true)
}
const tableRef = opts?.aliases?.[table._id!] || table._id
// when searching internal tables make sure long looking for rows
if (filters.documentType && !isExternalTable(table)) {
const tableRef = opts?.aliases?.[table._id!] || table._id
if (filters.documentType && !isExternalTable(table) && tableRef) {
// has to be its own option, must always be AND onto the search
query.andWhereLike(
`${tableRef}._id`,
@ -410,28 +417,50 @@ class InternalBuilder {
return query
}
addSorting(query: Knex.QueryBuilder, json: QueryJson): Knex.QueryBuilder {
let { sort, paginate } = json
addDistinctCount(
query: Knex.QueryBuilder,
json: QueryJson
): Knex.QueryBuilder {
const table = json.meta.table
const primary = table.primary
const aliases = json.tableAliases
const aliased =
table.name && aliases?.[table.name] ? aliases[table.name] : table.name
if (!primary) {
throw new Error("SQL counting requires primary key to be supplied")
}
return query.countDistinct(`${aliased}.${primary[0]} as total`)
}
addSorting(query: Knex.QueryBuilder, json: QueryJson): Knex.QueryBuilder {
let { sort } = json
const table = json.meta.table
const primaryKey = table.primary
const tableName = getTableName(table)
const aliases = json.tableAliases
const aliased =
tableName && aliases?.[tableName] ? aliases[tableName] : table?.name
if (!Array.isArray(primaryKey)) {
throw new Error("Sorting requires primary key to be specified for table")
}
if (sort && Object.keys(sort || {}).length > 0) {
for (let [key, value] of Object.entries(sort)) {
const direction =
value.direction === SortDirection.ASCENDING ? "asc" : "desc"
value.direction === SortOrder.ASCENDING ? "asc" : "desc"
let nulls
if (this.client === SqlClient.POSTGRES) {
// All other clients already sort this as expected by default, and adding this to the rest of the clients is causing issues
nulls = value.direction === SortDirection.ASCENDING ? "first" : "last"
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
}
query = query.orderBy(`${aliased}.${key}`, direction, nulls)
}
} else if (this.client === SqlClient.MS_SQL && paginate?.limit) {
// @ts-ignore
query = query.orderBy(`${aliased}.${table?.primary[0]}`)
}
// add sorting by the primary key if the result isn't already sorted by it,
// to make sure result is deterministic
if (!sort || sort[primaryKey[0]] === undefined) {
query = query.orderBy(`${aliased}.${primaryKey[0]}`)
}
return query
}
@ -522,7 +551,7 @@ class InternalBuilder {
})
}
}
return query.limit(BASE_LIMIT)
return query
}
knexWithAlias(
@ -533,13 +562,12 @@ class InternalBuilder {
const tableName = endpoint.entityId
const tableAlias = aliases?.[tableName]
const query = knex(
return knex(
this.tableNameWithSchema(tableName, {
alias: tableAlias,
schema: endpoint.schema,
})
)
return query
}
create(knex: Knex, json: QueryJson, opts: QueryOptions): Knex.QueryBuilder {
@ -571,52 +599,95 @@ class InternalBuilder {
return query.insert(parsedBody)
}
read(knex: Knex, json: QueryJson, limit: number): Knex.QueryBuilder {
let { endpoint, resource, filters, paginate, relationships, tableAliases } =
json
bulkUpsert(knex: Knex, json: QueryJson): Knex.QueryBuilder {
const { endpoint, body } = json
let query = this.knexWithAlias(knex, endpoint)
if (!Array.isArray(body)) {
return query
}
const parsedBody = body.map(row => parseBody(row))
if (
this.client === SqlClient.POSTGRES ||
this.client === SqlClient.SQL_LITE ||
this.client === SqlClient.MY_SQL
) {
const primary = json.meta.table.primary
if (!primary) {
throw new Error("Primary key is required for upsert")
}
const ret = query.insert(parsedBody).onConflict(primary).merge()
return ret
} else if (this.client === SqlClient.MS_SQL) {
// No upsert or onConflict support in MSSQL yet, see:
// https://github.com/knex/knex/pull/6050
return query.insert(parsedBody)
}
return query.upsert(parsedBody)
}
read(
knex: Knex,
json: QueryJson,
opts: {
limits?: { base: number; query: number }
} = {}
): Knex.QueryBuilder {
let { endpoint, filters, paginate, relationships, tableAliases } = json
const { limits } = opts
const counting = endpoint.operation === Operation.COUNT
const tableName = endpoint.entityId
// select all if not specified
if (!resource) {
resource = { fields: [] }
}
let selectStatement: string | (string | Knex.Raw)[] = "*"
// handle select
if (resource.fields && resource.fields.length > 0) {
// select the resources as the format "table.columnName" - this is what is provided
// by the resource builder further up
selectStatement = generateSelectStatement(json, knex)
}
let foundLimit = limit || BASE_LIMIT
// start building the query
let query = this.knexWithAlias(knex, endpoint, tableAliases)
// handle pagination
let foundOffset: number | null = null
let foundLimit = limits?.query || limits?.base
if (paginate && paginate.page && paginate.limit) {
// @ts-ignore
const page = paginate.page <= 1 ? 0 : paginate.page - 1
const offset = page * paginate.limit
foundLimit = paginate.limit
foundOffset = offset
} else if (paginate && paginate.offset && paginate.limit) {
foundLimit = paginate.limit
foundOffset = paginate.offset
} else if (paginate && paginate.limit) {
foundLimit = paginate.limit
}
// start building the query
let query = this.knexWithAlias(knex, endpoint, tableAliases)
query = query.limit(foundLimit)
if (foundOffset) {
query = query.offset(foundOffset)
// counting should not sort, limit or offset
if (!counting) {
// add the found limit if supplied
if (foundLimit != null) {
query = query.limit(foundLimit)
}
// add overall pagination
if (foundOffset != null) {
query = query.offset(foundOffset)
}
// add sorting to pre-query
// no point in sorting when counting
query = this.addSorting(query, json)
}
// add filters to the query (where)
query = this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
aliases: tableAliases,
})
// add sorting to pre-query
query = this.addSorting(query, json)
const alias = tableAliases?.[tableName] || tableName
let preQuery = knex({
[alias]: query,
} as any).select(selectStatement) as any
let preQuery: Knex.QueryBuilder = knex({
// the typescript definition for the knex constructor doesn't support this
// syntax, but it is the only way to alias a pre-query result as part of
// a query - there is an alias dictionary type, but it assumes it can only
// be a table name, not a pre-query
[alias]: query as any,
})
// if counting, use distinct count, else select
preQuery = !counting
? preQuery.select(generateSelectStatement(json, knex))
: this.addDistinctCount(preQuery, json)
// have to add after as well (this breaks MS-SQL)
if (this.client !== SqlClient.MS_SQL) {
if (this.client !== SqlClient.MS_SQL && !counting) {
preQuery = this.addSorting(preQuery, json)
}
// handle joins
@ -627,7 +698,15 @@ class InternalBuilder {
endpoint.schema,
tableAliases
)
// add a base limit over the whole query
// if counting we can't set this limit
if (limits?.base) {
query = query.limit(limits.base)
}
return this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
relationship: true,
aliases: tableAliases,
})
@ -638,6 +717,7 @@ class InternalBuilder {
let query = this.knexWithAlias(knex, endpoint, tableAliases)
const parsedBody = parseBody(body)
query = this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
aliases: tableAliases,
})
// mysql can't use returning
@ -652,6 +732,7 @@ class InternalBuilder {
const { endpoint, filters, tableAliases } = json
let query = this.knexWithAlias(knex, endpoint, tableAliases)
query = this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
aliases: tableAliases,
})
// mysql can't use returning
@ -671,6 +752,19 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
this.limit = limit
}
private convertToNative(query: Knex.QueryBuilder, opts: QueryOptions = {}) {
const sqlClient = this.getSqlClient()
if (opts?.disableBindings) {
return { sql: query.toString() }
} else {
let native = getNativeSql(query)
if (sqlClient === SqlClient.SQL_LITE) {
native = convertBooleans(native)
}
return native
}
}
/**
* @param json The JSON query DSL which is to be converted to SQL.
* @param opts extra options which are to be passed into the query builder, e.g. disableReturning
@ -694,7 +788,16 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
query = builder.create(client, json, opts)
break
case Operation.READ:
query = builder.read(client, json, this.limit)
query = builder.read(client, json, {
limits: {
query: this.limit,
base: BASE_LIMIT,
},
})
break
case Operation.COUNT:
// read without any limits to count
query = builder.read(client, json)
break
case Operation.UPDATE:
query = builder.update(client, json, opts)
@ -705,6 +808,9 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
case Operation.BULK_CREATE:
query = builder.bulkCreate(client, json)
break
case Operation.BULK_UPSERT:
query = builder.bulkUpsert(client, json)
break
case Operation.CREATE_TABLE:
case Operation.UPDATE_TABLE:
case Operation.DELETE_TABLE:
@ -713,15 +819,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
throw `Operation type is not supported by SQL query builder`
}
if (opts?.disableBindings) {
return { sql: query.toString() }
} else {
let native = getNativeSql(query)
if (sqlClient === SqlClient.SQL_LITE) {
native = convertBooleans(native)
}
return native
}
return this.convertToNative(query, opts)
}
async getReturningRow(queryFn: QueryFunction, json: QueryJson) {
@ -797,6 +895,9 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
await this.getReturningRow(queryFn, this.checkLookupKeys(id, json))
)
}
if (operation === Operation.COUNT) {
return results
}
if (operation !== Operation.READ) {
return row
}

View File

@ -5,19 +5,27 @@ export class SqlStatements {
client: string
table: Table
allOr: boolean | undefined
columnPrefix: string | undefined
constructor(
client: string,
table: Table,
{ allOr }: { allOr?: boolean } = {}
{ allOr, columnPrefix }: { allOr?: boolean; columnPrefix?: string } = {}
) {
this.client = client
this.table = table
this.allOr = allOr
this.columnPrefix = columnPrefix
}
getField(key: string): FieldSchema | undefined {
const fieldName = key.split(".")[1]
return this.table.schema[fieldName]
let found = this.table.schema[fieldName]
if (!found && this.columnPrefix) {
const prefixRemovedFieldName = fieldName.replace(this.columnPrefix, "")
found = this.table.schema[prefixRemovedFieldName]
}
return found
}
between(

View File

@ -109,8 +109,10 @@ function generateSchema(
const { tableName } = breakExternalTableId(column.tableId)
// @ts-ignore
const relatedTable = tables[tableName]
if (!relatedTable) {
throw new Error("Referenced table doesn't exist")
if (!relatedTable || !relatedTable.primary) {
throw new Error(
"Referenced table doesn't exist or has no primary keys"
)
}
const relatedPrimary = relatedTable.primary[0]
const externalType = relatedTable.schema[relatedPrimary].externalType

View File

@ -55,10 +55,7 @@ export function buildExternalTableId(datasourceId: string, tableName: string) {
return `${datasourceId}${DOUBLE_SEPARATOR}${tableName}`
}
export function breakExternalTableId(tableId: string | undefined) {
if (!tableId) {
return {}
}
export function breakExternalTableId(tableId: string) {
const parts = tableId.split(DOUBLE_SEPARATOR)
let datasourceId = parts.shift()
// if they need joined
@ -67,6 +64,9 @@ export function breakExternalTableId(tableId: string | undefined) {
if (tableName.includes(ENCODED_SPACE)) {
tableName = decodeURIComponent(tableName)
}
if (!datasourceId || !tableName) {
throw new Error("Unable to get datasource/table name from table ID")
}
return { datasourceId, tableName }
}

View File

@ -1,6 +1,21 @@
import { getDB } from "../db/db"
import { getGlobalDBName } from "../context"
import { TenantInfo } from "@budibase/types"
export function getTenantDB(tenantId: string) {
return getDB(getGlobalDBName(tenantId))
}
export async function saveTenantInfo(tenantInfo: TenantInfo) {
const db = getTenantDB(tenantInfo.tenantId)
// save the tenant info to db
return db.put({
_id: "tenant_info",
...tenantInfo,
})
}
export async function getTenantInfo(tenantId: string): Promise<TenantInfo> {
const db = getTenantDB(tenantId)
return db.get("tenant_info")
}

View File

@ -24,7 +24,6 @@ export const account = (partial: Partial<Account> = {}): Account => {
createdAt: Date.now(),
verified: true,
verificationSent: true,
tier: "FREE", // DEPRECATED
authType: AuthType.PASSWORD,
name: generator.name(),
size: "10+",

View File

@ -162,6 +162,7 @@
max-height: 100%;
}
.modal-inner-wrapper {
padding: 40px;
flex: 1 1 auto;
display: flex;
flex-direction: row;
@ -176,7 +177,6 @@
border: 2px solid var(--spectrum-global-color-gray-200);
overflow: visible;
max-height: none;
margin: 40px 0;
transform: none;
--spectrum-dialog-confirm-border-radius: var(
--spectrum-global-dimension-size-100

View File

@ -15,6 +15,9 @@
Checkbox,
DatePicker,
DrawerContent,
Toggle,
Icon,
Divider,
} from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation, tables } from "stores/builder"
@ -40,7 +43,7 @@
EditorModes,
} from "components/common/CodeEditor"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { LuceneUtils, Utils } from "@budibase/frontend-core"
import { QueryUtils, Utils, search } from "@budibase/frontend-core"
import {
getSchemaForDatasourcePlus,
getEnvironmentBindings,
@ -72,7 +75,11 @@
$: schema = getSchemaForDatasourcePlus(tableId, {
searchableSchema: true,
}).schema
$: schemaFields = Object.values(schema || {})
$: schemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{ allowLinks: true }
)
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
@ -88,6 +95,8 @@
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: []
let testDataRowVisibility = {}
const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
@ -118,7 +127,6 @@
searchableSchema: true,
}).schema
}
try {
if (isTestModal) {
let newTestData = { schema }
@ -196,7 +204,8 @@
(automation.trigger?.event === "row:update" ||
automation.trigger?.event === "row:save")
) {
if (name !== "id" && name !== "revision") return `trigger.row.${name}`
let noRowKeywordBindings = ["id", "revision", "oldRow"]
if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}`
}
/* End special cases for generating custom schemas based on triggers */
@ -343,7 +352,7 @@
}
function saveFilters(key) {
const filters = LuceneUtils.buildLuceneQuery(tempFilters)
const filters = QueryUtils.buildQuery(tempFilters)
const defKey = `${key}-def`
onChange({ detail: filters }, key)
// need to store the builder definition in the automation
@ -372,7 +381,11 @@
function getFieldLabel(key, value) {
const requiredSuffix = requiredProperties.includes(key) ? "*" : ""
return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}`
return `${value.title || (key === "row" ? "Row" : key)} ${requiredSuffix}`
}
function toggleTestDataRowVisibility(key) {
testDataRowVisibility[key] = !testDataRowVisibility[key]
}
function handleAttachmentParams(keyValueObj) {
@ -385,6 +398,16 @@
return params
}
function toggleAttachmentBinding(e, key) {
onChange(
{
detail: "",
},
key
)
onChange({ detail: { useAttachmentBinding: e.detail } }, "meta")
}
onMount(async () => {
try {
await environment.loadVariables()
@ -462,27 +485,64 @@
<div class="label-wrapper">
<Label>{label}</Label>
</div>
<div class="attachment-field-width">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
key
)}
object={handleAttachmentParams(inputData[key])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
<div class="toggle-container">
<Toggle
value={inputData?.meta?.useAttachmentBinding}
text={"Use bindings"}
size={"XS"}
on:change={e => toggleAttachmentBinding(e, key)}
/>
</div>
<div class="attachment-field-width">
{#if !inputData?.meta?.useAttachmentBinding}
<KeyValueBuilder
on:change={e =>
onChange(
{
detail: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
key
)}
object={handleAttachmentParams(inputData[key])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
/>
{:else if isTestModal}
<ModalBindableInput
title={value.title || label}
value={inputData[key]}
panel={AutomationBindingPanel}
type={value.customType}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
/>
{:else}
<div class="test">
<DrawerBindableInput
title={value.title ?? label}
panel={AutomationBindingPanel}
type={value.customType}
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
placeholder={value.customType === "queryLimit"
? queryLimit
: ""}
drawerLeft="260px"
/>
</div>
{/if}
</div>
</div>
{:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
@ -560,20 +620,48 @@
on:change={e => onChange(e, key)}
/>
{:else if value.customType === "row"}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{#if isTestModal}
<div class="align-horizontally">
<Icon
name={testDataRowVisibility[key] ? "Remove" : "Add"}
hoverable
on:click={() => toggleTestDataRowVisibility(key)}
/>
<Label size="XL">{label}</Label>
</div>
{#if testDataRowVisibility[key]}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{/if}
<Divider />
{:else}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{/if}
{:else if value.customType === "webhookUrl"}
<WebhookDisplay
on:change={e => onChange(e, key)}
@ -689,6 +777,12 @@
width: 320px;
}
.align-horizontally {
display: flex;
gap: var(--spacing-s);
align-items: center;
}
.fields {
display: flex;
flex-direction: column;

View File

@ -10,12 +10,12 @@
import { TableNames } from "constants"
const dispatch = createEventDispatcher()
export let value
export let meta
export let bindings
export let isTestModal
export let isUpdateRow
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
@ -94,17 +94,22 @@
dispatch("change", newValue)
}
const onChangeSetting = (e, field) => {
let fields = {}
fields[field] = {
clearRelationships: e.detail,
const onChangeSetting = (field, key, value) => {
let newField = {}
newField[field] = {
[key]: value,
}
let updatedFields = {
...meta?.fields,
...newField,
}
dispatch("change", {
key: "meta",
fields,
fields: updatedFields,
})
}
// Ensure any nullish tableId values get set to empty string so
// that the select works
$: if (value?.tableId == null) value = { tableId: "" }
@ -157,6 +162,9 @@
bindings={parsedBindings}
{value}
{onChange}
useAttachmentBinding={meta?.fields?.[field]
?.useAttachmentBinding}
{onChangeSetting}
/>
</DrawerBindableSlot>
{/if}
@ -167,7 +175,8 @@
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e => onChangeSetting(e, field)}
on:change={e =>
onChangeSetting(field, "clearRelationships", e.detail)}
/>
</div>
{/if}

View File

@ -1,5 +1,11 @@
<script>
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import {
Select,
DatePicker,
Multiselect,
TextArea,
Toggle,
} from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
@ -14,6 +20,8 @@
export let value
export let bindings
export let isTestModal
export let useAttachmentBinding
export let onChangeSetting
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
@ -27,6 +35,8 @@
FieldType.SIGNATURE_SINGLE,
]
let previousBindingState = useAttachmentBinding
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
@ -34,13 +44,6 @@
function handleAttachmentParams(keyValueObj) {
let params = {}
if (
(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE) &&
Object.keys(keyValueObj).length === 0
) {
return []
}
if (!Array.isArray(keyValueObj) && keyValueObj) {
keyValueObj = [keyValueObj]
}
@ -52,6 +55,26 @@
}
return params
}
async function handleToggleChange(toggleField, event) {
if (event.detail === true) {
value[toggleField] = []
} else {
value[toggleField] = ""
}
previousBindingState = event.detail
onChangeSetting(toggleField, "useAttachmentBinding", event.detail)
onChange({ detail: value[toggleField] }, toggleField)
}
$: if (useAttachmentBinding !== previousBindingState) {
if (useAttachmentBinding) {
value[field] = []
} else {
value[field] = ""
}
previousBindingState = useAttachmentBinding
}
</script>
{#if schemaHasOptions(schema) && schema.type !== "array"}
@ -108,38 +131,65 @@
useLabel={false}
/>
{:else if attachmentTypes.includes(schema.type)}
<div class="attachment-field-spacinng">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE
? e.detail.length > 0
? {
url: e.detail[0].name,
filename: e.detail[0].value,
}
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
field
)}
object={handleAttachmentParams(value[field])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE) &&
Object.keys(value[field]).length >= 1}
/>
<div class="attachment-field-container">
<div class="toggle-container">
<Toggle
value={useAttachmentBinding}
text={"Use bindings"}
size={"XS"}
on:change={e => handleToggleChange(field, e)}
/>
</div>
{#if !useAttachmentBinding}
<div class="attachment-field-spacing">
<KeyValueBuilder
on:change={async e => {
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE
? e.detail.length > 0
? {
url: e.detail[0].name,
filename: e.detail[0].value,
}
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
field
)
}}
object={handleAttachmentParams(value[field])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE) &&
Object.keys(value[field]).length >= 1}
/>
</div>
{:else}
<div class="json-input-spacing">
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}
panel={AutomationBindingPanel}
value={value[field]}
on:change={e => onChange(e, field)}
type="string"
bindings={parsedBindings}
allowJS={true}
updateOnChange={false}
title={schema.name}
/>
</div>
{/if}
</div>
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component
@ -156,7 +206,8 @@
{/if}
<style>
.attachment-field-spacinng {
.attachment-field-spacing,
.json-input-spacing {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-l);
}

View File

@ -1,6 +1,6 @@
<script>
import { viewsV2 } from "stores/builder"
import { admin } from "stores/portal"
import { admin, licensing } from "stores/portal"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
@ -28,6 +28,7 @@
showAvatars={false}
on:updatedatasource={handleGridViewUpdate}
isCloud={$admin.cloud}
allowViewReadonlyColumns={$licensing.isViewReadonlyColumnsEnabled}
>
<svelte:fragment slot="filter">
<GridFilterButton />

View File

@ -17,6 +17,8 @@
SWITCHABLE_TYPES,
ValidColumnNameRegex,
helpers,
CONSTANT_INTERNAL_ROW_COLS,
CONSTANT_EXTERNAL_ROW_COLS,
} from "@budibase/shared-core"
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
@ -52,7 +54,6 @@
const DATE_TYPE = FieldType.DATETIME
const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { dispatch: gridDispatch, rows } = getContext("grid")
export let field
@ -334,7 +335,7 @@
// Add in defaults and initial definition
const definition = fieldDefinitions[type?.toUpperCase()]
if (definition?.constraints) {
editableColumn.constraints = definition.constraints
editableColumn.constraints = cloneDeep(definition.constraints)
}
editableColumn.type = definition.type
@ -487,20 +488,23 @@
})
}
const newError = {}
const prohibited = externalTable
? CONSTANT_EXTERNAL_ROW_COLS
: CONSTANT_INTERNAL_ROW_COLS
if (!externalTable && fieldInfo.name?.startsWith("_")) {
newError.name = `Column name cannot start with an underscore.`
} else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) {
newError.name = `Illegal character; must be alpha-numeric.`
} else if (PROHIBITED_COLUMN_NAMES.some(name => fieldInfo.name === name)) {
newError.name = `${PROHIBITED_COLUMN_NAMES.join(
} else if (prohibited.some(name => fieldInfo?.name === name)) {
newError.name = `${prohibited.join(
", "
)} are not allowed as column names`
)} are not allowed as column names - case insensitive.`
} else if (inUse($tables.selected, fieldInfo.name, originalName)) {
newError.name = `Column name already in use.`
}
if (fieldInfo.type === FieldType.AUTO && !fieldInfo.subtype) {
newError.subtype = `Auto Column requires a type`
newError.subtype = `Auto Column requires a type.`
}
if (fieldInfo.fieldName && fieldInfo.tableId) {

View File

@ -8,7 +8,7 @@
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { LuceneUtils } from "@budibase/frontend-core"
import { QueryUtils } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { ROW_EXPORT_FORMATS } from "constants/backend"
@ -49,7 +49,7 @@
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
$: luceneFilter = LuceneUtils.buildLuceneQuery(appliedFilters)
$: query = QueryUtils.buildQuery(appliedFilters)
$: exportOpDisplay = buildExportOpDisplay(
sorting,
filterDisplay,
@ -139,7 +139,7 @@
tableId: view,
format: exportFormat,
search: {
query: luceneFilter,
query,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,

View File

@ -1,9 +1,14 @@
<script>
import { FieldType, BBReferenceFieldSubType } from "@budibase/types"
import {
FieldType,
BBReferenceFieldSubType,
SourceName,
} from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api"
import { parseFile } from "./utils"
import { tables, datasources } from "stores/builder"
let error = null
let fileName = null
@ -80,6 +85,9 @@
schema = fetchSchema(tableId)
}
$: table = $tables.list.find(table => table._id === tableId)
$: datasource = $datasources.list.find(ds => ds._id === table?.sourceId)
async function fetchSchema(tableId) {
try {
const definition = await API.fetchTableDefinition(tableId)
@ -185,20 +193,25 @@
</div>
{/each}
</div>
{#if tableType === DB_TYPE_INTERNAL}
<br />
<br />
<!-- SQL Server doesn't yet support overwriting rows by existing keys -->
{#if datasource?.source !== SourceName.SQL_SERVER}
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{#if updateExistingRows}
{/if}
{#if updateExistingRows}
{#if tableType === DB_TYPE_INTERNAL}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
/>
{:else}
<p>Rows will be updated based on the table's primary key.</p>
{/if}
{/if}
{#if invalidColumns.length > 0}

View File

@ -38,4 +38,5 @@
{processFiles}
handleFileTooLarge={$admin.cloud ? handleFileTooLarge : null}
{fileSizeLimit}
on:change
/>

View File

@ -0,0 +1,8 @@
<div class="root">This action doesn't require any settings.</div>
<style>
.root {
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -53,6 +53,12 @@
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
/>
<Label small>Confirm Text</Label>
<Input placeholder="Confirm" bind:value={parameters.confirmButtonText} />
<Label small>Cancel Text</Label>
<Input placeholder="Cancel" bind:value={parameters.cancelButtonText} />
{/if}
</div>
</div>

View File

@ -83,6 +83,12 @@
placeholder="Are you sure you want to duplicate this row?"
bind:value={parameters.confirmText}
/>
<Label small>Confirm Text</Label>
<Input placeholder="Confirm" bind:value={parameters.confirmButtonText} />
<Label small>Cancel Text</Label>
<Input placeholder="Cancel" bind:value={parameters.cancelButtonText} />
{/if}
</div>

View File

@ -74,6 +74,18 @@
placeholder="Are you sure you want to execute this query?"
bind:value={parameters.confirmText}
/>
<Input
label="Confirm Text"
placeholder="Confirm"
bind:value={parameters.confirmButtonText}
/>
<Input
label="Cancel Text"
placeholder="Cancel"
bind:value={parameters.cancelButtonText}
/>
{/if}
{#if query?.parameters?.length > 0}

View File

@ -0,0 +1,36 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { selectedScreen } from "stores/builder"
import { findAllMatchingComponents } from "helpers/components"
export let parameters
$: modalOptions = getModalOptions($selectedScreen)
const getModalOptions = screen => {
const modalComponents = findAllMatchingComponents(screen.props, component =>
component._component.endsWith("/modal")
)
return modalComponents.map(modal => ({
label: modal._instanceName,
value: modal._id,
}))
}
</script>
<div class="root">
<Label small>Modal</Label>
<Select bind:value={parameters.id} options={modalOptions} />
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -18,7 +18,7 @@
<div class="params">
<Label small>Title</Label>
<DrawerBindableInput
placeholder="Title"
placeholder="Prompt User"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
@ -30,6 +30,22 @@
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/>
<Label small>Confirm Text</Label>
<DrawerBindableInput
placeholder="Confirm"
value={parameters.confirmButtonText}
on:change={e => (parameters.confirmButtonText = e.detail)}
{bindings}
/>
<Label small>Cancel Text</Label>
<DrawerBindableInput
placeholder="Cancel"
value={parameters.cancelButtonText}
on:change={e => (parameters.cancelButtonText = e.detail)}
{bindings}
/>
</div>
</div>

View File

@ -80,6 +80,12 @@
placeholder="Are you sure you want to save this row?"
bind:value={parameters.confirmText}
/>
<Label small>Confirm Text</Label>
<Input placeholder="Confirm" bind:value={parameters.confirmButtonText} />
<Label small>Cancel Text</Label>
<Input placeholder="Cancel" bind:value={parameters.cancelButtonText} />
{/if}
</div>

View File

@ -25,6 +25,8 @@
},
]
const MAX_DURATION = 120000 // Maximum duration in milliseconds (2 minutes)
onMount(() => {
if (!parameters.type) {
parameters.type = "success"
@ -33,6 +35,14 @@
parameters.autoDismiss = true
}
})
function handleDurationChange(event) {
let newDuration = event.detail
if (newDuration > MAX_DURATION) {
newDuration = MAX_DURATION
}
parameters.duration = newDuration
}
</script>
<div class="root">
@ -47,6 +57,16 @@
/>
<Label />
<Checkbox text="Auto dismiss" bind:value={parameters.autoDismiss} />
{#if parameters.autoDismiss}
<Label>Duration (ms)</Label>
<DrawerBindableInput
title="Duration"
{bindings}
value={parameters.duration}
placeholder="3000"
on:change={handleDurationChange}
/>
{/if}
</div>
<style>

View File

@ -21,5 +21,7 @@ export { default as ShowNotification } from "./ShowNotification.svelte"
export { default as PromptUser } from "./PromptUser.svelte"
export { default as OpenSidePanel } from "./OpenSidePanel.svelte"
export { default as CloseSidePanel } from "./CloseSidePanel.svelte"
export { default as OpenModal } from "./OpenModal.svelte"
export { default as CloseModal } from "./CloseModal.svelte"
export { default as ClearRowSelection } from "./ClearRowSelection.svelte"
export { default as DownloadFile } from "./DownloadFile.svelte"

View File

@ -157,6 +157,18 @@
"component": "CloseSidePanel",
"dependsOnFeature": "sidePanel"
},
{
"name": "Open Modal",
"type": "application",
"component": "OpenModal",
"dependsOnFeature": "modal"
},
{
"name": "Close Modal",
"type": "application",
"component": "CloseModal",
"dependsOnFeature": "modal"
},
{
"name": "Clear Row Selection",
"type": "data",

View File

@ -29,7 +29,7 @@
on:click={() => onSelect(data)}
>
<span class="spectrum-Menu-itemLabel">
{data.label}
{data.datasource?.name ? `${data.datasource.name} - ` : ""}{data.label}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"

View File

@ -55,6 +55,9 @@
label: m.name,
tableId: m._id,
type: "table",
datasource: $datasources.list.find(
ds => ds._id === m.sourceId || m.datasourceId
),
}))
$: viewsV1 = $viewsStore.list.map(view => ({
...view,

View File

@ -18,14 +18,11 @@
import subjects from "./subjects"
import { appStore } from "stores/builder"
export let explanation
export let columnIcon
export let columnType
export let columnName
export let tableHref = () => {}
export let schema
export let name
export let explanation
export let componentName
$: explanationWithPresets = getExplanationWithPresets(
explanation,
@ -54,14 +51,8 @@
</script>
<div bind:this={root} class="tooltipContents">
<Column
{columnName}
{columnIcon}
{columnType}
{tableHref}
{setExplanationSubject}
/>
<Support {support} {setExplanationSubject} />
<Column {name} {schema} {tableHref} {setExplanationSubject} />
<Support {componentName} {support} {setExplanationSubject} />
{#if messages.includes(messageConstants.stringAsNumber)}
<StringAsNumber {setExplanationSubject} />
{/if}
@ -84,7 +75,7 @@
{#if detailsModalSubject !== subjects.none}
<DetailsModal
{columnName}
columnName={name}
anchor={root}
{schema}
subject={detailsModalSubject}

View File

@ -1,69 +1,124 @@
<script>
import {
Line,
InfoWord,
DocumentationLink,
Text,
Period,
} from "../typography"
import { Line, InfoWord, DocumentationLink, Text } from "../typography"
import { FieldType } from "@budibase/types"
import { FIELDS } from "constants/backend"
import subjects from "../subjects"
export let columnName
export let columnIcon
export let columnType
export let schema
export let name
export let tableHref
export let setExplanationSubject
const getTypeName = schema => {
const fieldDefinition = Object.values(FIELDS).find(
f => f.type === schema?.type
)
if (schema?.type === "jsonarray") {
return "JSON Array"
}
if (schema?.type === "options") {
return "Options"
}
return fieldDefinition?.name || schema?.type || "Unknown"
}
const getTypeIcon = schema => {
const fieldDefinition = Object.values(FIELDS).find(
f => f.type === schema?.type
)
if (schema?.type === "jsonarray") {
return "BracketsSquare"
}
return fieldDefinition?.icon || "Circle"
}
const getDocLink = columnType => {
if (columnType === "Number") {
if (columnType === FieldType.NUMBER) {
return "https://docs.budibase.com/docs/number"
}
if (columnType === "Text") {
if (columnType === FieldType.STRING) {
return "https://docs.budibase.com/docs/text"
}
if (columnType === "Attachment") {
if (columnType === FieldType.LONGFORM) {
return "https://docs.budibase.com/docs/text"
}
if (columnType === FieldType.ATTACHMENT_SINGLE) {
return "https://docs.budibase.com/docs/attachments"
}
if (columnType === "Multi-select") {
if (columnType === FieldType.ATTACHMENTS) {
// No distinct multi attachment docs, link to attachment instead
return "https://docs.budibase.com/docs/attachments"
}
if (columnType === FieldType.ARRAY) {
return "https://docs.budibase.com/docs/multi-select"
}
if (columnType === "JSON") {
if (columnType === FieldType.JSON) {
return "https://docs.budibase.com/docs/json"
}
if (columnType === "Date/Time") {
if (columnType === "jsonarray") {
return "https://docs.budibase.com/docs/json"
}
if (columnType === FieldType.DATETIME) {
return "https://docs.budibase.com/docs/datetime"
}
if (columnType === "User") {
return "https://docs.budibase.com/docs/user"
if (columnType === FieldType.BB_REFERENCE_SINGLE) {
return "https://docs.budibase.com/docs/users-1"
}
if (columnType === "QR") {
if (columnType === FieldType.BB_REFERENCE) {
return "https://docs.budibase.com/docs/users-1"
}
if (columnType === FieldType.BARCODEQR) {
return "https://docs.budibase.com/docs/barcodeqr"
}
if (columnType === "Relationship") {
if (columnType === FieldType.LINK) {
return "https://docs.budibase.com/docs/relationships"
}
if (columnType === "Formula") {
if (columnType === FieldType.FORMULA) {
return "https://docs.budibase.com/docs/formula"
}
if (columnType === "Options") {
if (columnType === FieldType.OPTIONS) {
return "https://docs.budibase.com/docs/options"
}
if (columnType === "BigInt") {
// No BigInt docs
return null
}
if (columnType === "Boolean") {
if (columnType === FieldType.BOOLEAN) {
return "https://docs.budibase.com/docs/boolean-truefalse"
}
if (columnType === "Signature") {
if (columnType === FieldType.SIGNATURE_SINGLE) {
// No Signature docs
return null
}
if (columnType === FieldType.BIGINT) {
// No BigInt docs
return null
}
return null
}
$: docLink = getDocLink(columnType)
// NOTE The correct indefinite article is based on the pronounciation of the word it precedes, not the spelling. So simply checking if the word begins with a vowel is not sufficient.
// e.g., `an honor`, `a user`
const getIndefiniteArticle = schema => {
const anTypes = [
FieldType.OPTIONS,
null, // `null` gets parsed as "unknown"
undefined, // `undefined` gets parsed as "unknown"
]
if (anTypes.includes(schema?.type)) {
return "an"
}
return "a"
}
$: columnTypeName = getTypeName(schema)
$: columnIcon = getTypeIcon(schema)
$: docLink = getDocLink(schema?.type)
$: indefiniteArticle = getIndefiniteArticle(schema)
</script>
<Line noWrap>
@ -71,14 +126,14 @@
on:mouseenter={() => setExplanationSubject(subjects.column)}
on:mouseleave={() => setExplanationSubject(subjects.none)}
href={tableHref}
text={columnName}
text={name}
/>
<Text value=" is a " />
<Text value={` is ${indefiniteArticle} `} />
<DocumentationLink
disabled={docLink === null}
href={docLink}
icon={columnIcon}
text={`${columnType} column`}
text={columnTypeName}
/>
<Period />
<Text value=" column." />
</Line>

View File

@ -2,9 +2,16 @@
import { Line, InfoWord, DocumentationLink, Text } from "../typography"
import subjects from "../subjects"
import * as explanation from "../explanation"
import { componentStore } from "stores/builder"
export let setExplanationSubject
export let support
export let componentName
const getComponentDefinition = componentName => {
const components = $componentStore.components || {}
return components[componentName] || null
}
const getIcon = support => {
if (support === explanation.support.unsupported) {
@ -39,21 +46,24 @@
$: icon = getIcon(support)
$: color = getColor(support)
$: text = getText(support)
$: componentDefinition = getComponentDefinition(componentName)
</script>
<Line>
<InfoWord
on:mouseenter={() => setExplanationSubject(subjects.support)}
on:mouseleave={() => setExplanationSubject(subjects.none)}
{icon}
{color}
{text}
/>
<Text value=" with this " />
<DocumentationLink
href="https://docs.budibase.com/docs/chart"
icon="GraphPie"
text="Chart component"
/>
<Text value=" input." />
</Line>
{#if componentDefinition}
<Line>
<InfoWord
on:mouseenter={() => setExplanationSubject(subjects.support)}
on:mouseleave={() => setExplanationSubject(subjects.none)}
{icon}
{color}
{text}
/>
<Text value=" with this " />
<DocumentationLink
href={componentDefinition.documentationLink}
icon={componentDefinition.icon}
text={componentDefinition.name}
/>
<Text value=" input." />
</Line>
{/if}

View File

@ -6,8 +6,6 @@
import { Explanation } from "./Explanation"
import { debounce } from "lodash"
import { params } from "@roxi/routify"
import { Constants } from "@budibase/frontend-core"
import { FIELDS } from "constants/backend"
export let componentInstance = {}
export let value = ""
@ -60,35 +58,6 @@
const onOptionMouseleave = e => {
updateTooltip(e, null)
}
const getOptionIcon = optionKey => {
const option = schema[optionKey]
if (!option) return ""
if (option.autocolumn) {
return "MagicWand"
}
const { type, subtype } = option
const result =
typeof Constants.TypeIconMap[type] === "object" && subtype
? Constants.TypeIconMap[type][subtype]
: Constants.TypeIconMap[type]
return result || "Text"
}
const getOptionIconTooltip = optionKey => {
const option = schema[optionKey]
const type = option?.type
const field = Object.values(FIELDS).find(f => f.type === type)
if (field) {
return field.name
}
return ""
}
</script>
<Select
@ -109,10 +78,9 @@
<Explanation
tableHref={`/builder/app/${$params.application}/data/table/${datasource?.tableId}`}
schema={schema[currentOption]}
columnIcon={getOptionIcon(currentOption)}
columnName={currentOption}
columnType={getOptionIconTooltip(currentOption)}
name={currentOption}
{explanation}
componentName={componentInstance._component}
/>
</ContextTooltip>
{/if}

View File

@ -9,7 +9,8 @@
import { createEventDispatcher } from "svelte"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
import FilterBuilder from "./FilterBuilder.svelte"
import { selectedScreen } from "stores/builder"
import { tables, selectedScreen } from "stores/builder"
import { search } from "@budibase/frontend-core"
const dispatch = createEventDispatcher()
@ -23,7 +24,11 @@
$: tempValue = value
$: datasource = getDatasourceForProvider($selectedScreen, componentInstance)
$: dsSchema = getSchemaForDatasource($selectedScreen, datasource)?.schema
$: schemaFields = Object.values(schema || dsSchema || {})
$: schemaFields = search.getFields(
$tables.list,
Object.values(schema || dsSchema || {}),
{ allowLinks: true }
)
$: text = getText(value?.filter(filter => filter.field))
async function saveFilter() {

View File

@ -4,10 +4,8 @@
import { selectedScreen } from "stores/builder"
import { createEventDispatcher } from "svelte"
import { Explanation } from "./Explanation"
import { FIELDS } from "constants/backend"
import { params } from "@roxi/routify"
import { debounce } from "lodash"
import { Constants } from "@budibase/frontend-core"
export let componentInstance = {}
export let value = ""
@ -37,40 +35,6 @@
dispatch("change", boundValue)
}
const getOptionIcon = optionKey => {
const option = schema[optionKey]
if (!option) return ""
if (option.autocolumn) {
return "MagicWand"
}
const { type, subtype } = option
const result =
typeof Constants.TypeIconMap[type] === "object" && subtype
? Constants.TypeIconMap[type][subtype]
: Constants.TypeIconMap[type]
return result || "Text"
}
const getOptionIconTooltip = optionKey => {
const option = schema[optionKey]
const type = option?.type
const field = Object.values(FIELDS).find(f => f.type === type)
if (field) {
return field.name
} else if (type === "jsonarray") {
// `jsonarray` isn't present in the above FIELDS constant
return "JSON Array"
}
return ""
}
const updateTooltip = debounce((e, option) => {
if (option == null) {
contextTooltipVisible = false
@ -110,10 +74,9 @@
<Explanation
tableHref={`/builder/app/${$params.application}/data/table/${datasource?.tableId}`}
schema={schema[currentOption]}
columnIcon={getOptionIcon(currentOption)}
columnName={currentOption}
columnType={getOptionIconTooltip(currentOption)}
name={currentOption}
{explanation}
componentName={componentInstance._component}
/>
</ContextTooltip>
{/if}

View File

@ -233,9 +233,9 @@
response.info = response.info || { code: 200 }
// if existing schema, copy over what it is
if (schema) {
for (let [name, field] of Object.entries(schema)) {
if (response.schema[name]) {
response.schema[name] = field
for (let [name, field] of Object.entries(response.schema)) {
if (!schema[name]) {
schema[name] = field
}
}
}

View File

@ -30,6 +30,7 @@ import ActionDefinitions from "components/design/settings/controls/ButtonActionE
import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
import { FIELDS } from "constants/backend"
import { FieldType } from "@budibase/types"
const { ContextScopes } = Constants
@ -555,6 +556,9 @@ const getComponentBindingCategory = (component, context, def) => {
export const getUserBindings = () => {
let bindings = []
const { schema } = getSchemaForDatasourcePlus(TableNames.USERS)
// add props that are not in the user metadata table schema
// but will be there for logged-in user
schema["globalId"] = { type: FieldType.STRING }
const keys = Object.keys(schema).sort()
const safeUser = makePropSafe("user")
@ -728,7 +732,7 @@ const getRoleBindings = () => {
return (get(rolesStore) || []).map(role => {
return {
type: "context",
runtimeBinding: `trim "${role._id}"`,
runtimeBinding: `'${role._id}'`,
readableBinding: `Role.${role.name}`,
category: "Role",
icon: "UserGroup",

View File

@ -70,7 +70,7 @@
<input
class="input"
value={title}
{title}
title={componentName}
placeholder={componentName}
on:keypress={e => {
if (e.key.toLowerCase() === "enter") {
@ -158,7 +158,32 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
padding: 5px;
right: 6px;
border: 1px solid transparent;
border-radius: 3px;
transition: 150ms background-color, 150ms border-color, 150ms color;
}
.input:hover,
.input:focus {
cursor: text;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid white;
border-color: var(
--spectrum-textfield-m-border-color,
var(--spectrum-alias-border-color)
);
color: var(
--spectrum-textfield-m-text-color,
var(--spectrum-alias-text-color)
);
}
.panel-title-content {
display: contents;
}

View File

@ -12,7 +12,7 @@
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { QueryUtils, Constants } from "@budibase/frontend-core"
import { selectedComponent, componentStore } from "stores/builder"
import { getComponentForSetting } from "components/design/settings/componentSettings"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
@ -119,7 +119,7 @@
}
const getOperatorOptions = condition => {
return LuceneUtils.getValidOperatorsForType({ type: condition.valueType })
return QueryUtils.getValidOperatorsForType({ type: condition.valueType })
}
const onOperatorChange = (condition, newOperator) => {
@ -138,7 +138,7 @@
condition.referenceValue = null
// Ensure a valid operator is set
const validOperators = LuceneUtils.getValidOperatorsForType({
const validOperators = QueryUtils.getValidOperatorsForType({
type: newType,
}).map(x => x.value)
if (!validOperators.includes(condition.operator)) {

View File

@ -59,7 +59,14 @@
// Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || []
path.forEach(ancestor => {
if (ancestor._component === `@budibase/standard-components/sidepanel`) {
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
if (
[
"@budibase/standard-components/sidepanel",
"@budibase/standard-components/modal",
].includes(ancestor._component)
) {
illegalChildren = []
}
const def = componentStore.getDefinition(ancestor._component)

View File

@ -14,7 +14,7 @@
{
"name": "Layout",
"icon": "ClassicGridView",
"children": ["container", "section", "sidepanel"]
"children": ["container", "section", "sidepanel", "modal"]
},
{
"name": "Data",

View File

@ -33,7 +33,8 @@
</Body>
</Layout>
<Button
on:click={() => (window.location = "https://docs.budibase.com")}
on:click={() =>
(window.location = "https://docs.budibase.com/docs/migrations")}
>Migration guide</Button
>
{/if}

View File

@ -60,6 +60,7 @@
userLimitReachedModal
let searchEmail = undefined
let selectedRows = []
let selectedInvites = []
let bulkSaveResponse
let customRenderers = [
{ column: "email", component: EmailTableRenderer },
@ -123,7 +124,7 @@
return {}
}
let pendingSchema = JSON.parse(JSON.stringify(tblSchema))
pendingSchema.email.displayName = "Pending Invites"
pendingSchema.email.displayName = "Pending Users"
return pendingSchema
}
@ -132,6 +133,7 @@
const { admin, builder, userGroups, apps } = invite.info
return {
_id: invite.code,
email: invite.email,
builder,
admin,
@ -260,9 +262,26 @@
return
}
await users.bulkDelete(ids)
notifications.success(`Successfully deleted ${selectedRows.length} rows`)
if (ids.length > 0) {
await users.bulkDelete(ids)
}
if (selectedInvites.length > 0) {
await users.removeInvites(
selectedInvites.map(invite => ({
code: invite._id,
}))
)
pendingInvites = await users.getInvites()
}
notifications.success(
`Successfully deleted ${
selectedRows.length + selectedInvites.length
} users`
)
selectedRows = []
selectedInvites = []
await fetch.refresh()
} catch (error) {
notifications.error("Error deleting users")
@ -328,15 +347,15 @@
</div>
{/if}
<div class="controls-right">
<Search bind:value={searchEmail} placeholder="Search" />
{#if selectedRows.length > 0}
{#if selectedRows.length > 0 || selectedInvites.length > 0}
<DeleteRowsButton
item="user"
on:updaterows
{selectedRows}
selectedRows={[...selectedRows, ...selectedInvites]}
deleteRows={deleteUsers}
/>
{/if}
<Search bind:value={searchEmail} placeholder="Search" />
</div>
</div>
<Table
@ -362,10 +381,12 @@
</div>
<Table
bind:selectedRows={selectedInvites}
schema={pendingSchema}
data={parsedInvites}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={!readonly}
{customRenderers}
loading={!invitesLoaded}
allowClickRows={false}

View File

@ -125,7 +125,14 @@ export class ScreenStore extends BudiStore {
return
}
if (type === "@budibase/standard-components/sidepanel") {
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.
if (
[
"@budibase/standard-components/sidepanel",
"@budibase/standard-components/modal",
].includes(type)
) {
illegalChildren = []
}

View File

@ -138,6 +138,11 @@ export const createLicensingStore = () => {
const isViewPermissionsEnabled = license.features.includes(
Constants.Features.VIEW_PERMISSIONS
)
const isViewReadonlyColumnsEnabled = license.features.includes(
Constants.Features.VIEW_READONLY_COLUMNS
)
store.update(state => {
return {
...state,
@ -157,6 +162,7 @@ export const createLicensingStore = () => {
triggerAutomationRunEnabled,
isViewPermissionsEnabled,
perAppBuildersEnabled,
isViewReadonlyColumnsEnabled,
}
})
},

View File

@ -38,6 +38,10 @@ export function createUsersStore() {
return API.inviteUsers(payload)
}
async function removeInvites(payload) {
return API.removeUserInvites(payload)
}
async function acceptInvite(inviteCode, password, firstName, lastName) {
return API.acceptInvite({
inviteCode,
@ -154,6 +158,7 @@ export function createUsersStore() {
onboard,
fetchInvite,
getInvites,
removeInvites,
updateInvite,
getUserCountByApp,
addAppBuilder,

View File

@ -11,7 +11,7 @@
"scripts": {
"tsc": "node ../../scripts/build.js",
"build": "yarn tsc",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",
"check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020",
"start": "ts-node ./src/index.ts"
},
"dependencies": {
@ -32,7 +32,7 @@
"pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5",
"tar": "6.1.15",
"tar": "6.2.1",
"yaml": "^2.1.1"
},
"devDependencies": {
@ -40,6 +40,6 @@
"@types/node-fetch": "2.6.4",
"@types/pouchdb": "^6.4.0",
"ts-node": "10.8.1",
"typescript": "5.2.2"
"typescript": "5.5.2"
}
}

View File

@ -11,6 +11,7 @@
"continueIfAction": true,
"showNotificationAction": true,
"sidePanel": true,
"modal": true,
"skeletonLoader": true
},
"typeSupportPresets": {
@ -22,17 +23,21 @@
{ "type": "bigint", "message": "stringAsNumber" },
{ "type": "options", "message": "stringAsNumber" },
{ "type": "formula", "message": "stringAsNumber" },
{ "type": "datetime", "message": "dateAsNumber"}
{ "type": "datetime", "message": "dateAsNumber" }
],
"unsupported": [
{ "type": "json", "message": "jsonPrimitivesOnly" }
]
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }]
},
"stringLike": {
"supported": ["string", "number", "bigint", "options", "longform", "boolean", "datetime"],
"unsupported": [
{ "type": "json", "message": "jsonPrimitivesOnly" }
]
"supported": [
"string",
"number",
"bigint",
"options",
"longform",
"boolean",
"datetime"
],
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }]
},
"datetimeLike": {
"supported": ["datetime"],
@ -42,11 +47,9 @@
{ "type": "options", "message": "stringAsDate" },
{ "type": "formula", "message": "stringAsDate" },
{ "type": "bigint", "message": "stringAsDate" },
{ "type": "number", "message": "numberAsDate"}
{ "type": "number", "message": "numberAsDate" }
],
"unsupported": [
{ "type": "json", "message": "jsonPrimitivesOnly" }
]
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }]
}
},
"layout": {
@ -5223,6 +5226,7 @@
]
},
"chartblock": {
"documentationLink": "https://docs.budibase.com/docs/chart",
"block": true,
"name": "Chart Block",
"icon": "GraphPie",
@ -6974,7 +6978,7 @@
"name": "Side Panel",
"icon": "RailRight",
"hasChildren": true,
"illegalChildren": ["section", "sidepanel"],
"illegalChildren": ["section", "sidepanel", "modal"],
"showEmptyState": false,
"draggable": false,
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.",
@ -6992,6 +6996,52 @@
}
]
},
"modal": {
"name": "Modal",
"icon": "MBox",
"hasChildren": true,
"illegalChildren": ["section", "modal", "sidepanel"],
"showEmptyState": false,
"draggable": false,
"info": "Modals are hidden by default. They will only be revealed when triggered by the 'Open Modal' action.",
"settings": [
{
"type": "boolean",
"key": "ignoreClicksOutside",
"label": "Ignore clicks outside",
"defaultValue": false
},
{
"type": "event",
"key": "onClose",
"label": "On close"
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "small",
"options": [
{
"label": "Small",
"value": "small"
},
{
"label": "Medium",
"value": "medium"
},
{
"label": "Large",
"value": "large"
},
{
"label": "Fullscreen",
"value": "fullscreen"
}
]
}
]
},
"rowexplorer": {
"block": true,
"name": "Row Explorer Block",

View File

@ -19,6 +19,8 @@
devToolsStore,
devToolsEnabled,
environmentStore,
sidePanelStore,
modalStore,
} from "stores"
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
@ -102,6 +104,21 @@
embedded: !!$appStore.embedded,
})
}
const handleHashChange = () => {
const { open: sidePanelOpen } = $sidePanelStore
if (sidePanelOpen) {
sidePanelStore.actions.close()
}
const { open: modalOpen } = $modalStore
if (modalOpen) {
modalStore.actions.close()
}
}
window.addEventListener("hashchange", handleHashChange)
return () => {
window.removeEventListener("hashchange", handleHashChange)
}
})
$: {

View File

@ -5,8 +5,6 @@
const { styleable, builderStore } = getContext("sdk")
const component = getContext("component")
let handlingOnClick = false
export let disabled = false
export let text = ""
export let onClick
@ -19,17 +17,9 @@
// For internal use only for now - not defined in the manifest
export let active = false
const handleOnClick = async () => {
handlingOnClick = true
if (onClick) {
await onClick()
}
handlingOnClick = false
}
let node
let touched = false
let handlingOnClick = false
$: $component.editing && node?.focus()
$: componentText = getComponentText(text, $builderStore, $component)
@ -42,7 +32,18 @@
}
const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent)
if (touched) {
builderStore.actions.updateProp("text", e.target.textContent)
}
touched = false
}
const handleOnClick = async () => {
handlingOnClick = true
if (onClick) {
await onClick()
}
handlingOnClick = false
}
</script>
@ -57,6 +58,7 @@
on:blur={$component.editing ? updateText : null}
bind:this={node}
class:active
on:input={() => (touched = true)}
>
{#if icon}
<i class="{icon} {size}" />

View File

@ -1,7 +1,7 @@
<script>
import { getContext } from "svelte"
import { Pagination, ProgressCircle } from "@budibase/bbui"
import { fetchData, LuceneUtils } from "@budibase/frontend-core"
import { fetchData, QueryUtils } from "@budibase/frontend-core"
export let dataSource
export let filter
@ -19,7 +19,7 @@
// We need to manage our lucene query manually as we want to allow components
// to extend it
$: defaultQuery = LuceneUtils.buildLuceneQuery(filter)
$: defaultQuery = QueryUtils.buildQuery(filter)
$: query = extendQuery(defaultQuery, queryExtensions)
$: fetch = createFetch(dataSource)
$: fetch.update({

View File

@ -90,9 +90,11 @@
columns.forEach((column, idx) => {
overrides[column.field] = {
displayName: column.label,
width: column.width,
order: idx,
}
if (column.width) {
overrides[column.field].width = column.width
}
})
return overrides
}

View File

@ -14,6 +14,7 @@
export let size
let node
let touched = false
$: $component.editing && node?.focus()
$: placeholder = $builderStore.inBuilder && !text && !$component.editing
@ -47,7 +48,10 @@
// Convert contenteditable HTML to text and save
const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent)
if (touched) {
builderStore.actions.updateProp("text", e.target.textContent)
}
touched = false
}
</script>
@ -62,6 +66,7 @@
class:underline
class="spectrum-Heading {sizeClass} {alignClass}"
on:blur={$component.editing ? updateText : null}
on:input={() => (touched = true)}
>
{componentText}
</h1>

View File

@ -12,6 +12,7 @@
linkable,
builderStore,
sidePanelStore,
modalStore,
appStore,
} = sdk
const context = getContext("context")
@ -77,6 +78,7 @@
!$builderStore.inBuilder &&
$sidePanelStore.open &&
!$sidePanelStore.ignoreClicksOutside
$: screenId = $builderStore.inBuilder
? `${$builderStore.screen?._id}-screen`
: "screen"
@ -198,6 +200,7 @@
const handleClickLink = () => {
mobileOpen = false
sidePanelStore.actions.close()
modalStore.actions.close()
}
</script>

View File

@ -1,7 +1,7 @@
<script>
import { getContext } from "svelte"
const { linkable, styleable, builderStore, sidePanelStore } =
const { linkable, styleable, builderStore, sidePanelStore, modalStore } =
getContext("sdk")
const component = getContext("component")
@ -16,6 +16,7 @@
export let size
let node
let touched = false
$: $component.editing && node?.focus()
$: externalLink = url && typeof url === "string" && !url.startsWith("/")
@ -28,6 +29,11 @@
// overrides the color when it's passed as inline style.
$: styles = enrichStyles($component.styles, color)
const handleUrlChange = () => {
sidePanelStore.actions.close()
modalStore.actions.close()
}
const getSanitizedUrl = (url, externalLink, newTab) => {
if (!url) {
return externalLink || newTab ? "#/" : "/"
@ -62,7 +68,10 @@
}
const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent)
if (touched) {
builderStore.actions.updateProp("text", e.target.textContent)
}
touched = false
}
</script>
@ -76,6 +85,7 @@
class:underline
class="align--{align || 'left'} size--{size || 'M'}"
on:blur={$component.editing ? updateText : null}
on:input={() => (touched = true)}
>
{componentText}
</div>
@ -104,7 +114,7 @@
class:italic
class:underline
class="align--{align || 'left'} size--{size || 'M'}"
on:click={sidePanelStore.actions.close}
on:click={handleUrlChange}
>
{componentText}
</a>

View File

@ -0,0 +1,141 @@
<script>
import { getContext } from "svelte"
import { Modal, Icon } from "@budibase/bbui"
const component = getContext("component")
const { styleable, modalStore, builderStore, dndIsDragging } =
getContext("sdk")
export let onClose
export let ignoreClicksOutside
export let size
let modal
// Open modal automatically in builder
$: {
if ($builderStore.inBuilder) {
if (
$component.inSelectedPath &&
$modalStore.contentId !== $component.id
) {
modalStore.actions.open($component.id)
} else if (
!$component.inSelectedPath &&
$modalStore.contentId === $component.id &&
!$dndIsDragging
) {
modalStore.actions.close()
}
}
}
$: open = $modalStore.contentId === $component.id
const handleModalClose = async () => {
if (onClose) {
await onClose()
}
modalStore.actions.close()
}
const handleOpen = (open, modal) => {
if (!modal) return
if (open) {
modal.show()
} else {
modal.hide()
}
}
$: handleOpen(open, modal)
</script>
<!-- Conditional displaying in the builder is necessary otherwise previews don't update properly upon component deletion -->
{#if !$builderStore.inBuilder || open}
<Modal
on:cancel={handleModalClose}
bind:this={modal}
disableCancel={$builderStore.inBuilder}
zIndex={2}
>
<div use:styleable={$component.styles} class={`modal-content ${size}`}>
<div class="modal-header">
<Icon
color="var(--spectrum-global-color-gray-800)"
name="Close"
hoverable
on:click={handleModalClose}
/>
</div>
<div class="modal-main">
<div class="modal-main-inner">
<slot />
</div>
</div>
</div>
</Modal>
{/if}
<style>
.modal-content {
display: flex;
flex-direction: column;
max-width: 100%;
box-sizing: border-box;
padding: 12px 0px 40px;
}
.small {
width: 400px;
min-height: 200px;
}
.medium {
width: 600px;
min-height: 400px;
}
.large {
width: 800px;
min-height: 600px;
}
.fullscreen {
width: calc(100vw - 80px);
min-height: calc(100vh - 80px);
}
.modal-header {
display: flex;
flex-direction: row;
justify-content: flex-end;
flex-shrink: 0;
flex-grow: 0;
padding: 0 12px 12px;
box-sizing: border-box;
}
.modal-main {
padding: 0 40px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.modal-main :global(.component > *) {
max-width: 100%;
}
.modal-main-inner {
flex-grow: 1;
display: flex;
flex-direction: column;
word-break: break-word;
}
.modal-main-inner:empty {
border-radius: 3px;
border: 2px dashed var(--spectrum-global-color-gray-400);
}
</style>

View File

@ -29,10 +29,6 @@
}
}
// $: {
// }
// Derive visibility
$: open = $sidePanelStore.contentId === $component.id

View File

@ -13,6 +13,7 @@
export let size
let node
let touched = false
$: $component.editing && node?.focus()
$: placeholder = $builderStore.inBuilder && !text && !$component.editing
@ -46,7 +47,10 @@
// Convert contenteditable HTML to text and save
const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent)
if (touched) {
builderStore.actions.updateProp("text", e.target.textContent)
}
touched = false
}
</script>
@ -61,6 +65,7 @@
class:underline
class="spectrum-Body {sizeClass} {alignClass}"
on:blur={$component.editing ? updateText : null}
on:input={() => (touched = true)}
>
{componentText}
</p>

View File

@ -35,6 +35,7 @@
export let valueUnits
export let yAxisLabel
export let xAxisLabel
export let yAxisUnits
export let curve
// Area
@ -85,6 +86,7 @@
valueUnits,
yAxisLabel,
xAxisLabel,
yAxisUnits,
stacked,
horizontal,
curve,

View File

@ -68,6 +68,15 @@
maximum: schema?.constraints?.length?.maximum,
}
},
[FieldType.DATETIME]: (_field, schema) => {
const props = {
valueAsTimestamp: !schema?.timeOnly,
}
if (schema?.dateOnly) {
props.enableTime = false
}
return props
},
}
const fieldSchema = getFieldSchema(field)

View File

@ -31,41 +31,23 @@
let schema
$: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: fetchSchema(dataSource)
$: id = $component.id
// We could simply spread $$props into the inner form and append our
// additions, but that would create svelte warnings about unused props and
// make maintenance in future more confusing as we typically always have a
// proper mapping of schema settings to component exports, without having to
// search multiple files
$: innerProps = {
dataSource,
actionUrl,
actionType,
size,
disabled,
fields: fieldsOrDefault,
title,
description,
schema,
notificationOverride,
buttons:
buttons ||
Utils.buildFormBlockButtonConfig({
_id: id,
showDeleteButton,
showSaveButton,
saveButtonLabel,
deleteButtonLabel,
notificationOverride,
actionType,
actionUrl,
dataSource,
}),
buttonPosition: buttons ? buttonPosition : "top",
}
$: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: buttonsOrDefault =
buttons ||
Utils.buildFormBlockButtonConfig({
_id: id,
showDeleteButton,
showSaveButton,
saveButtonLabel,
deleteButtonLabel,
notificationOverride,
actionType,
actionUrl,
dataSource,
})
// Provide additional data context for live binding eval
export const getAdditionalDataContext = () => {
@ -123,5 +105,18 @@
</script>
<FormBlockWrapper {actionType} {dataSource} {rowId} {noRowsMessage}>
<InnerFormBlock {...innerProps} />
<InnerFormBlock
{dataSource}
{actionUrl}
{actionType}
{size}
{disabled}
fields={fieldsOrDefault}
{title}
{description}
{schema}
{notificationOverride}
buttons={buttonsOrDefault}
buttonPosition={buttons ? buttonPosition : "top"}
/>
</FormBlockWrapper>

View File

@ -91,15 +91,13 @@
{#if description}
<BlockComponent type="text" props={{ text: description }} order={1} />
{/if}
{#key fields}
<BlockComponent type="container">
<div class="form-block fields" class:mobile={$context.device.mobile}>
{#each fields as field, idx}
<FormBlockComponent {field} {schema} order={idx} />
{/each}
</div>
</BlockComponent>
{/key}
<BlockComponent type="container">
<div class="form-block fields" class:mobile={$context.device.mobile}>
{#each fields as field, idx}
<FormBlockComponent {field} {schema} order={idx} />
{/each}
</div>
</BlockComponent>
</BlockComponent>
{#if buttonPosition === "bottom"}
<BlockComponent

View File

@ -74,7 +74,6 @@
},
},
xaxis: {
type: labelType,
categories,
labels: {
formatter: xAxisFormatter,

View File

@ -72,7 +72,6 @@
},
// We can just always provide the categories to the xaxis and horizontal mode automatically handles "tranposing" the categories to the yaxis, but certain things like labels need to be manually put on a certain axis based on the selected mode. Titles do not need to be handled this way, they are exposed to the user as "X axis" and Y Axis" so flipping them would be confusing.
xaxis: {
type: labelType,
categories,
labels: {
formatter: xAxisFormatter,

View File

@ -66,7 +66,6 @@
},
},
xaxis: {
type: labelType,
categories,
labels: {
formatter: xAxisFormatter,

View File

@ -3,7 +3,7 @@
import { getContext, onDestroy } from "svelte"
import { ModalContent, Modal } from "@budibase/bbui"
import FilterModal from "./FilterModal.svelte"
import { LuceneUtils } from "@budibase/frontend-core"
import { QueryUtils } from "@budibase/frontend-core"
import Button from "../Button.svelte"
export let dataProvider
@ -36,7 +36,7 @@
// Add query extension to data provider
$: {
if (filters?.length) {
const queryExtension = LuceneUtils.buildLuceneQuery(filters)
const queryExtension = QueryUtils.buildQuery(filters)
addExtension?.($component.id, queryExtension)
} else {
removeExtension?.($component.id)

View File

@ -16,15 +16,37 @@
export let onChange
export let span
export let helpText = null
export let valueAsTimestamp = false
let fieldState
let fieldApi
const handleChange = e => {
const changed = fieldApi.setValue(e.detail)
if (onChange && changed) {
onChange({ value: e.detail })
let value = e.detail
if (timeOnly && valueAsTimestamp) {
if (!isValidDate(value)) {
// Handle time only fields that are timestamps under the hood
value = timeToDateISOString(value)
}
}
const changed = fieldApi.setValue(value)
if (onChange && changed) {
onChange({ value })
}
}
const isValidDate = value => !isNaN(new Date(value))
const timeToDateISOString = value => {
let [hours, minutes] = value.split(":").map(Number)
const date = new Date()
date.setHours(hours)
date.setMinutes(minutes)
date.setSeconds(0)
date.setMilliseconds(0)
return date.toISOString()
}
</script>

View File

@ -26,6 +26,10 @@
// Register field with form
const formApi = formContext?.formApi
const labelPos = fieldGroupContext?.labelPosition || "above"
let touched = false
let labelNode
$: formStep = formStepContext ? $formStepContext || 1 : 1
$: formField = formApi?.registerField(
field,
@ -36,14 +40,12 @@
validation,
formStep
)
$: schemaType =
fieldSchema?.type !== "formula" && fieldSchema?.type !== "bigint"
? fieldSchema?.type
: "string"
// Focus label when editing
let labelNode
$: $component.editing && labelNode?.focus()
// Update form properties in parent component on every store change
@ -57,7 +59,10 @@
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
const updateLabel = e => {
builderStore.actions.updateProp("label", e.target.textContent)
if (touched) {
builderStore.actions.updateProp("label", e.target.textContent)
}
touched = false
}
onDestroy(() => {
@ -79,6 +84,7 @@
bind:this={labelNode}
contenteditable={$component.editing}
on:blur={$component.editing ? updateLabel : null}
on:input={() => (touched = true)}
class:hidden={!label}
class:readonly
for={fieldState?.fieldId}

View File

@ -206,7 +206,7 @@
error: initialError,
disabled:
disabled || fieldDisabled || (isAutoColumn && !editAutoColumns),
readonly: readonly || fieldReadOnly,
readonly: readonly || fieldReadOnly || schema?.[field]?.readonly,
defaultValue,
validator,
lastUpdate: Date.now(),

View File

@ -37,6 +37,7 @@ export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte"
export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte"
export * from "./charts"
export * from "./forms"

View File

@ -8,6 +8,8 @@
<ModalContent
title={$confirmationStore.title}
onConfirm={confirmationStore.actions.confirm}
confirmText={$confirmationStore.confirmButtonText}
cancelText={$confirmationStore.cancelButtonText}
>
{$confirmationStore.text}
</ModalContent>

View File

@ -57,7 +57,9 @@
return
}
nextState.indicators[idx].visible =
nextState.indicators[idx].insideSidePanel || entries[0].isIntersecting
nextState.indicators[idx].insideModal ||
nextState.indicators[idx].insideSidePanel ||
entries[0].isIntersecting
if (++callbackCount === observers.length) {
state = nextState
updating = false
@ -139,6 +141,7 @@
height: elBounds.height + 4,
visible: false,
insideSidePanel: !!child.closest(".side-panel"),
insideModal: !!child.closest(".modal-content"),
})
})
}

View File

@ -41,7 +41,7 @@
allSettings.push(setting)
}
})
return allSettings.filter(setting => setting.showInBar)
return allSettings.filter(setting => setting.showInBar && !setting.hidden)
}
const updatePosition = () => {

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