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 // differs to external, but the API is broadly the same
"jest/no-conditional-expect": "off", "jest/no-conditional-expect": "off",
// have to turn this off to allow function overloading in typescript // 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 - name: Check types
run: | run: |
if ${{ env.USE_NX_AFFECTED }}; then 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 else
yarn check:types yarn check:types --ignore @budibase/account-portal-server
fi fi
helm-lint: helm-lint:
@ -226,10 +226,11 @@ jobs:
if: ${{ steps.get_pro_commits.outputs.base_commit_excluding_merges != '' }} if: ${{ steps.get_pro_commits.outputs.base_commit_excluding_merges != '' }}
run: | run: |
cd packages/pro 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 }}' base_commit_excluding_merges='${{ steps.get_pro_commits.outputs.base_commit_excluding_merges }}'
pro_commit='${{ steps.get_pro_commits.outputs.pro_commit }}' 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 if [ -n "$any_commit" ]; then
echo $any_commit echo $any_commit

View File

@ -9,7 +9,7 @@ on:
jobs: jobs:
ensure-is-master-tag: ensure-is-master-tag:
name: Ensure is a master tag name: Ensure is a master tag
runs-on: qa-arc-runner-set runs-on: ubuntu-latest
steps: steps:
- name: Checkout monorepo - name: Checkout monorepo
uses: actions/checkout@v4 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/server/build/oldClientVersions/**/*
packages/builder/src/components/deploy/clientVersions.json packages/builder/src/components/deploy/clientVersions.json
packages/server/src/integrations/tests/utils/*.lock
# Logs # Logs
logs logs
*.log *.log

View File

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

View File

@ -17,6 +17,6 @@ version: 0.0.0
appVersion: 0.0.0 appVersion: 0.0.0
dependencies: dependencies:
- name: couchdb - name: couchdb
version: 4.3.0 version: 4.5.6
repository: https://apache.github.io/couchdb-helm repository: https://apache.github.io/couchdb-helm
condition: services.couchdb.enabled 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. | | 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.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.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.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.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.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>. | | 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.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.port | string | `"587"` | The port of your SMTP server. |
| globals.smtp.user | string | `""` | The username to use when authenticating with 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. | | 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. | | 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. | | 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.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.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.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.extraVolumeMounts | list | `[]` | Additional volumeMounts to the main apps container. |
| services.apps.extraVolumes | list | `[]` | Additional volumes to the apps pod. | | 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. | | 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.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.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.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.extraVolumeMounts | list | `[]` | Additional volumeMounts to the main automationWorkers container. |
| services.automationWorkers.extraVolumes | list | `[]` | Additional volumes to the automationWorkers pod. | | 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/> | | 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.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.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.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.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.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. | | 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.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.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.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.extraVolumeMounts | list | `[]` | Additional volumeMounts to the main worker container. |
| services.worker.extraVolumes | list | `[]` | Additional volumes to the worker pod. | | 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. | | 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 }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }} {{ 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 }} {{ if .Values.services.couchdb.enabled }}
- name: COUCH_DB_USER - name: COUCH_DB_USER
valueFrom: valueFrom:
@ -198,10 +206,21 @@ spec:
- name: APP_FEATURES - name: APP_FEATURES
value: "api" value: "api"
{{- end }} {{- end }}
{{- if .Values.globals.sqs.enabled }}
- name: SQS_SEARCH_ENABLE
value: "true"
{{- end }}
{{- range .Values.services.apps.extraEnv }} {{- range .Values.services.apps.extraEnv }}
- name: {{ .name }} - name: {{ .name }}
value: {{ .value | quote }} value: {{ .value | quote }}
{{- end }} {{- 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 }} image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.apps.startupProbe }} {{- if .Values.services.apps.startupProbe }}

View File

@ -201,6 +201,13 @@ spec:
- name: {{ .name }} - name: {{ .name }}
value: {{ .value | quote }} value: {{ .value | quote }}
{{- end }} {{- 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 }} image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
@ -272,4 +279,4 @@ spec:
{{- toYaml .Values.services.automationWorkers.extraVolumes | nindent 8 }} {{- toYaml .Values.services.automationWorkers.extraVolumes | nindent 8 }}
{{ end }} {{ end }}
status: {} status: {}
{{- end }} {{- end }}

View File

@ -56,6 +56,14 @@ spec:
{{ else }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }} {{ 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 - name: API_ENCRYPTION_KEY
value: {{ .Values.globals.apiEncryptionKey | quote }} value: {{ .Values.globals.apiEncryptionKey | quote }}
- name: HTTP_LOGGING - name: HTTP_LOGGING
@ -184,10 +192,21 @@ spec:
- name: NODE_TLS_REJECT_UNAUTHORIZED - name: NODE_TLS_REJECT_UNAUTHORIZED
value: {{ .Values.services.tlsRejectUnauthorized }} value: {{ .Values.services.tlsRejectUnauthorized }}
{{ end }} {{ end }}
{{- if .Values.globals.sqs.enabled }}
- name: SQS_SEARCH_ENABLE
value: "true"
{{- end }}
{{- range .Values.services.worker.extraEnv }} {{- range .Values.services.worker.extraEnv }}
- name: {{ .name }} - name: {{ .name }}
value: {{ .value | quote }} value: {{ .value | quote }}
{{- end }} {{- 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 }} image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.worker.startupProbe }} {{- if .Values.services.worker.startupProbe }}

View File

@ -138,6 +138,15 @@ globals:
# -- The password to use when authenticating with your SMTP server. # -- The password to use when authenticating with your SMTP server.
password: "" 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: services:
# -- The DNS suffix to use for service discovery. You only need to change this # -- 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. # 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 # -- Extra environment variables to set for apps pods. Takes a list of
# name=value pairs. # name=value pairs.
extraEnv: [] 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 # -- Startup probe configuration for apps pods. You shouldn't need to
# change this, but if you want to you can find more information here: # 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/> # <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 # -- Extra environment variables to set for automation worker pods. Takes a list of
# name=value pairs. # name=value pairs.
extraEnv: [] 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 # -- Startup probe configuration for automation worker pods. You shouldn't
# need to change this, but if you want to you can find more information # need to change this, but if you want to you can find more information
# here: # here:
@ -408,6 +431,13 @@ services:
# -- Extra environment variables to set for worker pods. Takes a list of # -- Extra environment variables to set for worker pods. Takes a list of
# name=value pairs. # name=value pairs.
extraEnv: [] 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 # -- Startup probe configuration for worker pods. You shouldn't need to
# change this, but if you want to you can find more information here: # 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/> # <https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/>
@ -611,10 +641,25 @@ couchdb:
# @ignore # @ignore
repository: budibase/couchdb repository: budibase/couchdb
# @ignore # @ignore
tag: v3.2.1 tag: v3.3.3
# @ignore # @ignore
pullPolicy: Always 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 # @ignore
# This should remain false. We ship Clouseau ourselves as part of the # 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 # 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", "@types/react": "17.0.39",
"eslint": "8.10.0", "eslint": "8.10.0",
"eslint-config-next": "12.1.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" concat-map "0.0.1"
braces@^3.0.1, braces@~3.0.2: braces@^3.0.1, braces@~3.0.2:
version "3.0.2" version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.1.1"
bulma@^0.9.3: bulma@^0.9.3:
version "0.9.3" version "0.9.3"
@ -781,10 +781,10 @@ file-entry-cache@^6.0.1:
dependencies: dependencies:
flat-cache "^3.0.4" flat-cache "^3.0.4"
fill-range@^7.0.1: fill-range@^7.1.1:
version "7.0.1" version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies: dependencies:
to-regex-range "^5.0.1" 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" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@4.6.2: typescript@5.2.2:
version "4.6.2" version "5.2.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.2.tgz#fe12d2727b708f4eef40f51598b3398baa9611d4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78"
integrity sha512-HM/hFigTBHZhLXshn9sN37H085+hQGeJHJ/X7LpBWLID/fbc2acUMfU+lGD98X81sKP+pFa9f0DZmCwB9GnbAg== integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==
unbox-primitive@^1.0.1: unbox-primitive@^1.0.1:
version "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-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" 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 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 # upstreams
set $apps ${APPS_UPSTREAM_URL}; set $apps ${APPS_UPSTREAM_URL};

View File

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

View File

@ -27,7 +27,7 @@
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"svelte": "^4.2.10", "svelte": "^4.2.10",
"svelte-eslint-parser": "^0.33.1", "svelte-eslint-parser": "^0.33.1",
"typescript": "5.2.2", "typescript": "5.5.2",
"typescript-eslint": "^7.3.1", "typescript-eslint": "^7.3.1",
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
@ -37,10 +37,10 @@
"build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream", "build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
"build:apps": "yarn build --scope @budibase/server --scope @budibase/worker", "build:apps": "yarn build --scope @budibase/server --scope @budibase/worker",
"build:cli": "yarn build --scope @budibase/cli", "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: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 --scope @budibase/account-portal-server --scope @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", "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", "build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset", "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",

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

View File

@ -16,7 +16,7 @@
"prepack": "cp package.json dist", "prepack": "cp package.json dist",
"build": "tsc -p tsconfig.build.json --paths null && node ./scripts/build.js", "build": "tsc -p tsconfig.build.json --paths null && node ./scripts/build.js",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"check:types": "tsc -p tsconfig.json --noEmit --paths null", "check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020",
"test": "bash scripts/test.sh", "test": "bash scripts/test.sh",
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
@ -79,7 +79,7 @@
"pouchdb-adapter-memory": "7.2.2", "pouchdb-adapter-memory": "7.2.2",
"testcontainers": "^10.7.2", "testcontainers": "^10.7.2",
"timekeeper": "2.2.0", "timekeeper": "2.2.0",
"typescript": "5.2.2" "typescript": "5.5.2"
}, },
"nx": { "nx": {
"targets": { "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_INVENTORY_TABLE_ID = "ta_bb_inventory"
export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses" export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses"
export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee" 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 = [ export {
"_id", CONSTANT_INTERNAL_ROW_COLS,
"_rev", CONSTANT_EXTERNAL_ROW_COLS,
"type", isInternalColumnName,
"createdAt", } from "@budibase/shared-core"
"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)
}

View File

@ -8,6 +8,7 @@ import {
DatabaseOpts, DatabaseOpts,
DatabasePutOpts, DatabasePutOpts,
DatabaseQueryOpts, DatabaseQueryOpts,
DBError,
Document, Document,
isDocument, isDocument,
RowResponse, RowResponse,
@ -41,7 +42,7 @@ function buildNano(couchInfo: { url: string; cookie: string }) {
type DBCall<T> = () => Promise<T> type DBCall<T> = () => Promise<T>
class CouchDBError extends Error { class CouchDBError extends Error implements DBError {
status: number status: number
statusCode: number statusCode: number
reason: string reason: string
@ -328,7 +329,14 @@ export class DatabaseImpl implements Database {
async sqlDiskCleanup(): Promise<void> { async sqlDiskCleanup(): Promise<void> {
const dbName = this.name const dbName = this.name
const url = `/${dbName}/_cleanup` 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 // removes a document from sqlite
@ -352,18 +360,15 @@ export class DatabaseImpl implements Database {
} }
async destroy() { 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 { 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) return await this.nano().db.destroy(this.name)
} catch (err: any) { } catch (err: any) {
// didn't exist, don't worry // didn't exist, don't worry

View File

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

View File

@ -3,7 +3,8 @@ import { Ctx } from "@budibase/types"
function validate( function validate(
schema: Joi.ObjectSchema | Joi.ArraySchema, schema: Joi.ObjectSchema | Joi.ArraySchema,
property: string property: string,
opts: { errorPrefix: string } = { errorPrefix: `Invalid ${property}` }
) { ) {
// Return a Koa middleware function // Return a Koa middleware function
return (ctx: Ctx, next: any) => { return (ctx: Ctx, next: any) => {
@ -29,16 +30,26 @@ function validate(
const { error } = schema.validate(params) const { error } = schema.validate(params)
if (error) { 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() return next()
} }
} }
export function body(schema: Joi.ObjectSchema | Joi.ArraySchema) { export function body(
return validate(schema, "body") schema: Joi.ObjectSchema | Joi.ArraySchema,
opts?: { errorPrefix: string }
) {
return validate(schema, "body", opts)
} }
export function params(schema: Joi.ObjectSchema | Joi.ArraySchema) { export function params(
return validate(schema, "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 { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises" import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3" import { HeadObjectOutput } from "aws-sdk/clients/s3"
import { ReadableStream } from "stream/web"
const streamPipeline = promisify(stream.pipeline) const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created // use this as a temporary store of buckets that are being created
@ -41,10 +42,7 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike path?: string | PathLike
} }
export type StreamTypes = export type StreamTypes = ReadStream | NodeJS.ReadableStream
| ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamUploadParams = BaseUploadParams & { export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes 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 // custom S3 is in use i.e. minio
if (env.MINIO_URL) { if (env.MINIO_URL) {
if (opts.presigning && env.MINIO_ENABLED) { if (opts.presigning && env.MINIO_ENABLED) {
@ -222,6 +225,9 @@ export async function streamUpload({
extra, extra,
ttl, ttl,
}: StreamUploadParams) { }: StreamUploadParams) {
if (!stream) {
throw new Error("Stream to upload is invalid/undefined")
}
const extension = filename.split(".").pop() const extension = filename.split(".").pop()
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
@ -251,14 +257,27 @@ export async function streamUpload({
: CONTENT_TYPE_MAP.txt : CONTENT_TYPE_MAP.txt
} }
const bucket = sanitizeBucket(bucketName),
objKey = sanitizeKey(filename)
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: bucket,
Key: sanitizeKey(filename), Key: objKey,
Body: stream, Body: stream,
ContentType: contentType, ContentType: contentType,
...extra, ...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 * 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 * available. Please note this is a queue service, not a notification service, so each
* consumer will receive different messages. * 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 * 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 * 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. * 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 () => { this._emitter.on("message", async () => {
if (this._messages.length <= 0) { if (this._messages.length <= 0) {
return return

View File

@ -21,6 +21,7 @@ let cleanupInterval: NodeJS.Timeout
async function cleanup() { async function cleanup() {
for (let queue of QUEUES) { for (let queue of QUEUES) {
await queue.clean(CLEANUP_PERIOD_MS, "completed") 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 { Knex, knex } from "knex"
import * as dbCore from "../db" import * as dbCore from "../db"
import { import {
isIsoDateString,
isValidFilter,
getNativeSql, getNativeSql,
isExternalTable, isExternalTable,
isIsoDateString,
isValidFilter,
} from "./utils" } from "./utils"
import { SqlStatements } from "./sqlStatements" import { SqlStatements } from "./sqlStatements"
import SqlTableQueryBuilder from "./sqlTable" import SqlTableQueryBuilder from "./sqlTable"
@ -12,21 +12,21 @@ import {
BBReferenceFieldMetadata, BBReferenceFieldMetadata,
FieldSchema, FieldSchema,
FieldType, FieldType,
INTERNAL_TABLE_SOURCE_ID,
JsonFieldMetadata, JsonFieldMetadata,
JsonTypes,
Operation, Operation,
prefixed,
QueryJson, QueryJson,
SqlQuery, QueryOptions,
RelationshipsJson, RelationshipsJson,
SearchFilters, SearchFilters,
SortDirection, SortOrder,
SqlClient,
SqlQuery,
SqlQueryBinding, SqlQueryBinding,
Table, Table,
TableSourceType, TableSourceType,
INTERNAL_TABLE_SOURCE_ID,
SqlClient,
QueryOptions,
JsonTypes,
prefixed,
} from "@budibase/types" } from "@budibase/types"
import environment from "../environment" import environment from "../environment"
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
@ -114,7 +114,7 @@ function generateSelectStatement(
): (string | Knex.Raw)[] | "*" { ): (string | Knex.Raw)[] | "*" {
const { resource, meta } = json const { resource, meta } = json
if (!resource) { if (!resource || !resource.fields || resource.fields.length === 0) {
return "*" return "*"
} }
@ -184,7 +184,11 @@ class InternalBuilder {
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
filters: SearchFilters | undefined, filters: SearchFilters | undefined,
table: Table, table: Table,
opts: { aliases?: Record<string, string>; relationship?: boolean } opts: {
aliases?: Record<string, string>
relationship?: boolean
columnPrefix?: string
}
): Knex.QueryBuilder { ): Knex.QueryBuilder {
if (!filters) { if (!filters) {
return query return query
@ -192,7 +196,10 @@ class InternalBuilder {
filters = parseFilters(filters) filters = parseFilters(filters)
// if all or specified in filters, then everything is an or // if all or specified in filters, then everything is an or
const allOr = filters.allOr 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 = const tableName =
this.client === SqlClient.SQL_LITE ? table._id! : table.name this.client === SqlClient.SQL_LITE ? table._id! : table.name
@ -397,9 +404,9 @@ class InternalBuilder {
contains(filters.containsAny, true) contains(filters.containsAny, true)
} }
const tableRef = opts?.aliases?.[table._id!] || table._id
// when searching internal tables make sure long looking for rows // when searching internal tables make sure long looking for rows
if (filters.documentType && !isExternalTable(table)) { if (filters.documentType && !isExternalTable(table) && tableRef) {
const tableRef = opts?.aliases?.[table._id!] || table._id
// has to be its own option, must always be AND onto the search // has to be its own option, must always be AND onto the search
query.andWhereLike( query.andWhereLike(
`${tableRef}._id`, `${tableRef}._id`,
@ -410,28 +417,50 @@ class InternalBuilder {
return query return query
} }
addSorting(query: Knex.QueryBuilder, json: QueryJson): Knex.QueryBuilder { addDistinctCount(
let { sort, paginate } = json query: Knex.QueryBuilder,
json: QueryJson
): Knex.QueryBuilder {
const table = json.meta.table 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 tableName = getTableName(table)
const aliases = json.tableAliases const aliases = json.tableAliases
const aliased = const aliased =
tableName && aliases?.[tableName] ? aliases[tableName] : table?.name 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) { if (sort && Object.keys(sort || {}).length > 0) {
for (let [key, value] of Object.entries(sort)) { for (let [key, value] of Object.entries(sort)) {
const direction = const direction =
value.direction === SortDirection.ASCENDING ? "asc" : "desc" value.direction === SortOrder.ASCENDING ? "asc" : "desc"
let nulls let nulls
if (this.client === SqlClient.POSTGRES) { 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 // 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) 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 return query
} }
@ -522,7 +551,7 @@ class InternalBuilder {
}) })
} }
} }
return query.limit(BASE_LIMIT) return query
} }
knexWithAlias( knexWithAlias(
@ -533,13 +562,12 @@ class InternalBuilder {
const tableName = endpoint.entityId const tableName = endpoint.entityId
const tableAlias = aliases?.[tableName] const tableAlias = aliases?.[tableName]
const query = knex( return knex(
this.tableNameWithSchema(tableName, { this.tableNameWithSchema(tableName, {
alias: tableAlias, alias: tableAlias,
schema: endpoint.schema, schema: endpoint.schema,
}) })
) )
return query
} }
create(knex: Knex, json: QueryJson, opts: QueryOptions): Knex.QueryBuilder { create(knex: Knex, json: QueryJson, opts: QueryOptions): Knex.QueryBuilder {
@ -571,52 +599,95 @@ class InternalBuilder {
return query.insert(parsedBody) return query.insert(parsedBody)
} }
read(knex: Knex, json: QueryJson, limit: number): Knex.QueryBuilder { bulkUpsert(knex: Knex, json: QueryJson): Knex.QueryBuilder {
let { endpoint, resource, filters, paginate, relationships, tableAliases } = const { endpoint, body } = json
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 const tableName = endpoint.entityId
// select all if not specified // start building the query
if (!resource) { let query = this.knexWithAlias(knex, endpoint, tableAliases)
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
// handle pagination // handle pagination
let foundOffset: number | null = null let foundOffset: number | null = null
let foundLimit = limits?.query || limits?.base
if (paginate && paginate.page && paginate.limit) { if (paginate && paginate.page && paginate.limit) {
// @ts-ignore // @ts-ignore
const page = paginate.page <= 1 ? 0 : paginate.page - 1 const page = paginate.page <= 1 ? 0 : paginate.page - 1
const offset = page * paginate.limit const offset = page * paginate.limit
foundLimit = paginate.limit foundLimit = paginate.limit
foundOffset = offset foundOffset = offset
} else if (paginate && paginate.offset && paginate.limit) {
foundLimit = paginate.limit
foundOffset = paginate.offset
} else if (paginate && paginate.limit) { } else if (paginate && paginate.limit) {
foundLimit = paginate.limit foundLimit = paginate.limit
} }
// start building the query // counting should not sort, limit or offset
let query = this.knexWithAlias(knex, endpoint, tableAliases) if (!counting) {
query = query.limit(foundLimit) // add the found limit if supplied
if (foundOffset) { if (foundLimit != null) {
query = query.offset(foundOffset) 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, { query = this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
aliases: tableAliases, aliases: tableAliases,
}) })
// add sorting to pre-query
query = this.addSorting(query, json)
const alias = tableAliases?.[tableName] || tableName const alias = tableAliases?.[tableName] || tableName
let preQuery = knex({ let preQuery: Knex.QueryBuilder = knex({
[alias]: query, // the typescript definition for the knex constructor doesn't support this
} as any).select(selectStatement) as any // 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) // 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) preQuery = this.addSorting(preQuery, json)
} }
// handle joins // handle joins
@ -627,7 +698,15 @@ class InternalBuilder {
endpoint.schema, endpoint.schema,
tableAliases 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, { return this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
relationship: true, relationship: true,
aliases: tableAliases, aliases: tableAliases,
}) })
@ -638,6 +717,7 @@ class InternalBuilder {
let query = this.knexWithAlias(knex, endpoint, tableAliases) let query = this.knexWithAlias(knex, endpoint, tableAliases)
const parsedBody = parseBody(body) const parsedBody = parseBody(body)
query = this.addFilters(query, filters, json.meta.table, { query = this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
aliases: tableAliases, aliases: tableAliases,
}) })
// mysql can't use returning // mysql can't use returning
@ -652,6 +732,7 @@ class InternalBuilder {
const { endpoint, filters, tableAliases } = json const { endpoint, filters, tableAliases } = json
let query = this.knexWithAlias(knex, endpoint, tableAliases) let query = this.knexWithAlias(knex, endpoint, tableAliases)
query = this.addFilters(query, filters, json.meta.table, { query = this.addFilters(query, filters, json.meta.table, {
columnPrefix: json.meta.columnPrefix,
aliases: tableAliases, aliases: tableAliases,
}) })
// mysql can't use returning // mysql can't use returning
@ -671,6 +752,19 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
this.limit = limit 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 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 * @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) query = builder.create(client, json, opts)
break break
case Operation.READ: 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 break
case Operation.UPDATE: case Operation.UPDATE:
query = builder.update(client, json, opts) query = builder.update(client, json, opts)
@ -705,6 +808,9 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
case Operation.BULK_CREATE: case Operation.BULK_CREATE:
query = builder.bulkCreate(client, json) query = builder.bulkCreate(client, json)
break break
case Operation.BULK_UPSERT:
query = builder.bulkUpsert(client, json)
break
case Operation.CREATE_TABLE: case Operation.CREATE_TABLE:
case Operation.UPDATE_TABLE: case Operation.UPDATE_TABLE:
case Operation.DELETE_TABLE: case Operation.DELETE_TABLE:
@ -713,15 +819,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
throw `Operation type is not supported by SQL query builder` throw `Operation type is not supported by SQL query builder`
} }
if (opts?.disableBindings) { return this.convertToNative(query, opts)
return { sql: query.toString() }
} else {
let native = getNativeSql(query)
if (sqlClient === SqlClient.SQL_LITE) {
native = convertBooleans(native)
}
return native
}
} }
async getReturningRow(queryFn: QueryFunction, json: QueryJson) { async getReturningRow(queryFn: QueryFunction, json: QueryJson) {
@ -797,6 +895,9 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
await this.getReturningRow(queryFn, this.checkLookupKeys(id, json)) await this.getReturningRow(queryFn, this.checkLookupKeys(id, json))
) )
} }
if (operation === Operation.COUNT) {
return results
}
if (operation !== Operation.READ) { if (operation !== Operation.READ) {
return row return row
} }

View File

@ -5,19 +5,27 @@ export class SqlStatements {
client: string client: string
table: Table table: Table
allOr: boolean | undefined allOr: boolean | undefined
columnPrefix: string | undefined
constructor( constructor(
client: string, client: string,
table: Table, table: Table,
{ allOr }: { allOr?: boolean } = {} { allOr, columnPrefix }: { allOr?: boolean; columnPrefix?: string } = {}
) { ) {
this.client = client this.client = client
this.table = table this.table = table
this.allOr = allOr this.allOr = allOr
this.columnPrefix = columnPrefix
} }
getField(key: string): FieldSchema | undefined { getField(key: string): FieldSchema | undefined {
const fieldName = key.split(".")[1] 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( between(

View File

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

View File

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

View File

@ -1,6 +1,21 @@
import { getDB } from "../db/db" import { getDB } from "../db/db"
import { getGlobalDBName } from "../context" import { getGlobalDBName } from "../context"
import { TenantInfo } from "@budibase/types"
export function getTenantDB(tenantId: string) { export function getTenantDB(tenantId: string) {
return getDB(getGlobalDBName(tenantId)) 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(), createdAt: Date.now(),
verified: true, verified: true,
verificationSent: true, verificationSent: true,
tier: "FREE", // DEPRECATED
authType: AuthType.PASSWORD, authType: AuthType.PASSWORD,
name: generator.name(), name: generator.name(),
size: "10+", size: "10+",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,4 +38,5 @@
{processFiles} {processFiles}
handleFileTooLarge={$admin.cloud ? handleFileTooLarge : null} handleFileTooLarge={$admin.cloud ? handleFileTooLarge : null}
{fileSizeLimit} {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?" placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText} 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} {/if}
</div> </div>
</div> </div>

View File

@ -83,6 +83,12 @@
placeholder="Are you sure you want to duplicate this row?" placeholder="Are you sure you want to duplicate this row?"
bind:value={parameters.confirmText} 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} {/if}
</div> </div>

View File

@ -74,6 +74,18 @@
placeholder="Are you sure you want to execute this query?" placeholder="Are you sure you want to execute this query?"
bind:value={parameters.confirmText} 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}
{#if query?.parameters?.length > 0} {#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"> <div class="params">
<Label small>Title</Label> <Label small>Title</Label>
<DrawerBindableInput <DrawerBindableInput
placeholder="Title" placeholder="Prompt User"
value={parameters.customTitleText} value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)} on:change={e => (parameters.customTitleText = e.detail)}
{bindings} {bindings}
@ -30,6 +30,22 @@
on:change={e => (parameters.confirmText = e.detail)} on:change={e => (parameters.confirmText = e.detail)}
{bindings} {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>
</div> </div>

View File

@ -80,6 +80,12 @@
placeholder="Are you sure you want to save this row?" placeholder="Are you sure you want to save this row?"
bind:value={parameters.confirmText} 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} {/if}
</div> </div>

View File

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

View File

@ -21,5 +21,7 @@ export { default as ShowNotification } from "./ShowNotification.svelte"
export { default as PromptUser } from "./PromptUser.svelte" export { default as PromptUser } from "./PromptUser.svelte"
export { default as OpenSidePanel } from "./OpenSidePanel.svelte" export { default as OpenSidePanel } from "./OpenSidePanel.svelte"
export { default as CloseSidePanel } from "./CloseSidePanel.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 ClearRowSelection } from "./ClearRowSelection.svelte"
export { default as DownloadFile } from "./DownloadFile.svelte" export { default as DownloadFile } from "./DownloadFile.svelte"

View File

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

View File

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

View File

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

View File

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

View File

@ -1,69 +1,124 @@
<script> <script>
import { import { Line, InfoWord, DocumentationLink, Text } from "../typography"
Line, import { FieldType } from "@budibase/types"
InfoWord, import { FIELDS } from "constants/backend"
DocumentationLink,
Text,
Period,
} from "../typography"
import subjects from "../subjects" import subjects from "../subjects"
export let columnName export let schema
export let columnIcon export let name
export let columnType
export let tableHref export let tableHref
export let setExplanationSubject 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 => { const getDocLink = columnType => {
if (columnType === "Number") { if (columnType === FieldType.NUMBER) {
return "https://docs.budibase.com/docs/number" return "https://docs.budibase.com/docs/number"
} }
if (columnType === "Text") { if (columnType === FieldType.STRING) {
return "https://docs.budibase.com/docs/text" 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" 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" return "https://docs.budibase.com/docs/multi-select"
} }
if (columnType === "JSON") { if (columnType === FieldType.JSON) {
return "https://docs.budibase.com/docs/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" return "https://docs.budibase.com/docs/datetime"
} }
if (columnType === "User") { if (columnType === FieldType.BB_REFERENCE_SINGLE) {
return "https://docs.budibase.com/docs/user" 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" return "https://docs.budibase.com/docs/barcodeqr"
} }
if (columnType === "Relationship") { if (columnType === FieldType.LINK) {
return "https://docs.budibase.com/docs/relationships" return "https://docs.budibase.com/docs/relationships"
} }
if (columnType === "Formula") { if (columnType === FieldType.FORMULA) {
return "https://docs.budibase.com/docs/formula" return "https://docs.budibase.com/docs/formula"
} }
if (columnType === "Options") { if (columnType === FieldType.OPTIONS) {
return "https://docs.budibase.com/docs/options" return "https://docs.budibase.com/docs/options"
} }
if (columnType === "BigInt") { if (columnType === FieldType.BOOLEAN) {
// No BigInt docs
return null
}
if (columnType === "Boolean") {
return "https://docs.budibase.com/docs/boolean-truefalse" return "https://docs.budibase.com/docs/boolean-truefalse"
} }
if (columnType === "Signature") { if (columnType === FieldType.SIGNATURE_SINGLE) {
// No Signature docs // No Signature docs
return null return null
} }
if (columnType === FieldType.BIGINT) {
// No BigInt docs
return null
}
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> </script>
<Line noWrap> <Line noWrap>
@ -71,14 +126,14 @@
on:mouseenter={() => setExplanationSubject(subjects.column)} on:mouseenter={() => setExplanationSubject(subjects.column)}
on:mouseleave={() => setExplanationSubject(subjects.none)} on:mouseleave={() => setExplanationSubject(subjects.none)}
href={tableHref} href={tableHref}
text={columnName} text={name}
/> />
<Text value=" is a " /> <Text value={` is ${indefiniteArticle} `} />
<DocumentationLink <DocumentationLink
disabled={docLink === null} disabled={docLink === null}
href={docLink} href={docLink}
icon={columnIcon} icon={columnIcon}
text={`${columnType} column`} text={columnTypeName}
/> />
<Period /> <Text value=" column." />
</Line> </Line>

View File

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

View File

@ -6,8 +6,6 @@
import { Explanation } from "./Explanation" import { Explanation } from "./Explanation"
import { debounce } from "lodash" import { debounce } from "lodash"
import { params } from "@roxi/routify" import { params } from "@roxi/routify"
import { Constants } from "@budibase/frontend-core"
import { FIELDS } from "constants/backend"
export let componentInstance = {} export let componentInstance = {}
export let value = "" export let value = ""
@ -60,35 +58,6 @@
const onOptionMouseleave = e => { const onOptionMouseleave = e => {
updateTooltip(e, null) 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> </script>
<Select <Select
@ -109,10 +78,9 @@
<Explanation <Explanation
tableHref={`/builder/app/${$params.application}/data/table/${datasource?.tableId}`} tableHref={`/builder/app/${$params.application}/data/table/${datasource?.tableId}`}
schema={schema[currentOption]} schema={schema[currentOption]}
columnIcon={getOptionIcon(currentOption)} name={currentOption}
columnName={currentOption}
columnType={getOptionIconTooltip(currentOption)}
{explanation} {explanation}
componentName={componentInstance._component}
/> />
</ContextTooltip> </ContextTooltip>
{/if} {/if}

View File

@ -9,7 +9,8 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding" import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
import FilterBuilder from "./FilterBuilder.svelte" 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() const dispatch = createEventDispatcher()
@ -23,7 +24,11 @@
$: tempValue = value $: tempValue = value
$: datasource = getDatasourceForProvider($selectedScreen, componentInstance) $: datasource = getDatasourceForProvider($selectedScreen, componentInstance)
$: dsSchema = getSchemaForDatasource($selectedScreen, datasource)?.schema $: 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)) $: text = getText(value?.filter(filter => filter.field))
async function saveFilter() { async function saveFilter() {

View File

@ -4,10 +4,8 @@
import { selectedScreen } from "stores/builder" import { selectedScreen } from "stores/builder"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { Explanation } from "./Explanation" import { Explanation } from "./Explanation"
import { FIELDS } from "constants/backend"
import { params } from "@roxi/routify" import { params } from "@roxi/routify"
import { debounce } from "lodash" import { debounce } from "lodash"
import { Constants } from "@budibase/frontend-core"
export let componentInstance = {} export let componentInstance = {}
export let value = "" export let value = ""
@ -37,40 +35,6 @@
dispatch("change", boundValue) 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) => { const updateTooltip = debounce((e, option) => {
if (option == null) { if (option == null) {
contextTooltipVisible = false contextTooltipVisible = false
@ -110,10 +74,9 @@
<Explanation <Explanation
tableHref={`/builder/app/${$params.application}/data/table/${datasource?.tableId}`} tableHref={`/builder/app/${$params.application}/data/table/${datasource?.tableId}`}
schema={schema[currentOption]} schema={schema[currentOption]}
columnIcon={getOptionIcon(currentOption)} name={currentOption}
columnName={currentOption}
columnType={getOptionIconTooltip(currentOption)}
{explanation} {explanation}
componentName={componentInstance._component}
/> />
</ContextTooltip> </ContextTooltip>
{/if} {/if}

View File

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

View File

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

View File

@ -70,7 +70,7 @@
<input <input
class="input" class="input"
value={title} value={title}
{title} title={componentName}
placeholder={componentName} placeholder={componentName}
on:keypress={e => { on:keypress={e => {
if (e.key.toLowerCase() === "enter") { if (e.key.toLowerCase() === "enter") {
@ -158,7 +158,32 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .panel-title-content {
display: contents; display: contents;
} }

View File

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

View File

@ -59,7 +59,14 @@
// Build up list of illegal children from ancestors // Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || [] let illegalChildren = definition.illegalChildren || []
path.forEach(ancestor => { 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 = [] illegalChildren = []
} }
const def = componentStore.getDefinition(ancestor._component) const def = componentStore.getDefinition(ancestor._component)

View File

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

View File

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

View File

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

View File

@ -125,7 +125,14 @@ export class ScreenStore extends BudiStore {
return 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 = [] illegalChildren = []
} }

View File

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

View File

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

View File

@ -11,7 +11,7 @@
"scripts": { "scripts": {
"tsc": "node ../../scripts/build.js", "tsc": "node ../../scripts/build.js",
"build": "yarn tsc", "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" "start": "ts-node ./src/index.ts"
}, },
"dependencies": { "dependencies": {
@ -32,7 +32,7 @@
"pouchdb": "7.3.0", "pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9", "pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5", "randomstring": "1.1.5",
"tar": "6.1.15", "tar": "6.2.1",
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {
@ -40,6 +40,6 @@
"@types/node-fetch": "2.6.4", "@types/node-fetch": "2.6.4",
"@types/pouchdb": "^6.4.0", "@types/pouchdb": "^6.4.0",
"ts-node": "10.8.1", "ts-node": "10.8.1",
"typescript": "5.2.2" "typescript": "5.5.2"
} }
} }

View File

@ -11,6 +11,7 @@
"continueIfAction": true, "continueIfAction": true,
"showNotificationAction": true, "showNotificationAction": true,
"sidePanel": true, "sidePanel": true,
"modal": true,
"skeletonLoader": true "skeletonLoader": true
}, },
"typeSupportPresets": { "typeSupportPresets": {
@ -22,17 +23,21 @@
{ "type": "bigint", "message": "stringAsNumber" }, { "type": "bigint", "message": "stringAsNumber" },
{ "type": "options", "message": "stringAsNumber" }, { "type": "options", "message": "stringAsNumber" },
{ "type": "formula", "message": "stringAsNumber" }, { "type": "formula", "message": "stringAsNumber" },
{ "type": "datetime", "message": "dateAsNumber"} { "type": "datetime", "message": "dateAsNumber" }
], ],
"unsupported": [ "unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }]
{ "type": "json", "message": "jsonPrimitivesOnly" }
]
}, },
"stringLike": { "stringLike": {
"supported": ["string", "number", "bigint", "options", "longform", "boolean", "datetime"], "supported": [
"unsupported": [ "string",
{ "type": "json", "message": "jsonPrimitivesOnly" } "number",
] "bigint",
"options",
"longform",
"boolean",
"datetime"
],
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }]
}, },
"datetimeLike": { "datetimeLike": {
"supported": ["datetime"], "supported": ["datetime"],
@ -42,11 +47,9 @@
{ "type": "options", "message": "stringAsDate" }, { "type": "options", "message": "stringAsDate" },
{ "type": "formula", "message": "stringAsDate" }, { "type": "formula", "message": "stringAsDate" },
{ "type": "bigint", "message": "stringAsDate" }, { "type": "bigint", "message": "stringAsDate" },
{ "type": "number", "message": "numberAsDate"} { "type": "number", "message": "numberAsDate" }
], ],
"unsupported": [ "unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }]
{ "type": "json", "message": "jsonPrimitivesOnly" }
]
} }
}, },
"layout": { "layout": {
@ -5223,6 +5226,7 @@
] ]
}, },
"chartblock": { "chartblock": {
"documentationLink": "https://docs.budibase.com/docs/chart",
"block": true, "block": true,
"name": "Chart Block", "name": "Chart Block",
"icon": "GraphPie", "icon": "GraphPie",
@ -6974,7 +6978,7 @@
"name": "Side Panel", "name": "Side Panel",
"icon": "RailRight", "icon": "RailRight",
"hasChildren": true, "hasChildren": true,
"illegalChildren": ["section", "sidepanel"], "illegalChildren": ["section", "sidepanel", "modal"],
"showEmptyState": false, "showEmptyState": false,
"draggable": false, "draggable": false,
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.", "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": { "rowexplorer": {
"block": true, "block": true,
"name": "Row Explorer Block", "name": "Row Explorer Block",

View File

@ -19,6 +19,8 @@
devToolsStore, devToolsStore,
devToolsEnabled, devToolsEnabled,
environmentStore, environmentStore,
sidePanelStore,
modalStore,
} from "stores" } from "stores"
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
@ -102,6 +104,21 @@
embedded: !!$appStore.embedded, 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 { styleable, builderStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
let handlingOnClick = false
export let disabled = false export let disabled = false
export let text = "" export let text = ""
export let onClick export let onClick
@ -19,17 +17,9 @@
// For internal use only for now - not defined in the manifest // For internal use only for now - not defined in the manifest
export let active = false export let active = false
const handleOnClick = async () => {
handlingOnClick = true
if (onClick) {
await onClick()
}
handlingOnClick = false
}
let node let node
let touched = false
let handlingOnClick = false
$: $component.editing && node?.focus() $: $component.editing && node?.focus()
$: componentText = getComponentText(text, $builderStore, $component) $: componentText = getComponentText(text, $builderStore, $component)
@ -42,7 +32,18 @@
} }
const updateText = e => { 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> </script>
@ -57,6 +58,7 @@
on:blur={$component.editing ? updateText : null} on:blur={$component.editing ? updateText : null}
bind:this={node} bind:this={node}
class:active class:active
on:input={() => (touched = true)}
> >
{#if icon} {#if icon}
<i class="{icon} {size}" /> <i class="{icon} {size}" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -68,6 +68,15 @@
maximum: schema?.constraints?.length?.maximum, 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) const fieldSchema = getFieldSchema(field)

View File

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

View File

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

View File

@ -74,7 +74,6 @@
}, },
}, },
xaxis: { xaxis: {
type: labelType,
categories, categories,
labels: { labels: {
formatter: xAxisFormatter, 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. // 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: { xaxis: {
type: labelType,
categories, categories,
labels: { labels: {
formatter: xAxisFormatter, formatter: xAxisFormatter,

View File

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

View File

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

View File

@ -16,15 +16,37 @@
export let onChange export let onChange
export let span export let span
export let helpText = null export let helpText = null
export let valueAsTimestamp = false
let fieldState let fieldState
let fieldApi let fieldApi
const handleChange = e => { const handleChange = e => {
const changed = fieldApi.setValue(e.detail) let value = e.detail
if (onChange && changed) { if (timeOnly && valueAsTimestamp) {
onChange({ value: e.detail }) 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> </script>

View File

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

View File

@ -206,7 +206,7 @@
error: initialError, error: initialError,
disabled: disabled:
disabled || fieldDisabled || (isAutoColumn && !editAutoColumns), disabled || fieldDisabled || (isAutoColumn && !editAutoColumns),
readonly: readonly || fieldReadOnly, readonly: readonly || fieldReadOnly || schema?.[field]?.readonly,
defaultValue, defaultValue,
validator, validator,
lastUpdate: Date.now(), 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 embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte" export { default as grid } from "./Grid.svelte"
export { default as sidepanel } from "./SidePanel.svelte" export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte" export { default as gridblock } from "./GridBlock.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"

View File

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

View File

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

View File

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

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