Merge branch 'master' of github.com:Budibase/budibase into BUDI-7656/add-migration

This commit is contained in:
mike12345567 2024-06-05 16:29:45 +01:00
commit 4f6076d2ba
31 changed files with 773 additions and 235 deletions

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.5.3 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,6 +206,10 @@ 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 }}

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,6 +192,10 @@ 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 }}

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.
@ -636,6 +645,21 @@ couchdb:
# @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

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

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

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

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

@ -728,7 +728,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

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

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

@ -1,7 +1,7 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { routeStore } from "./routes" import { routeStore } from "./routes"
const NOTIFICATION_TIMEOUT = 3000 const DEFAULT_NOTIFICATION_TIMEOUT = 3000
const createNotificationStore = () => { const createNotificationStore = () => {
let block = false let block = false
@ -18,13 +18,13 @@ const createNotificationStore = () => {
type = "info", type = "info",
icon, icon,
autoDismiss = true, autoDismiss = true,
duration,
count = 1 count = 1
) => { ) => {
if (block) { if (block) {
return return
} }
// If peeking, pass notifications back to parent window
if (get(routeStore).queryParams?.peek) { if (get(routeStore).queryParams?.peek) {
window.parent.postMessage({ window.parent.postMessage({
type: "notification", type: "notification",
@ -32,11 +32,13 @@ const createNotificationStore = () => {
message, message,
type, type,
icon, icon,
duration,
autoDismiss, autoDismiss,
}, },
}) })
return return
} }
const _id = id() const _id = id()
store.update(state => { store.update(state => {
const duplicateError = state.find(err => err.message === message) const duplicateError = state.find(err => err.message === message)
@ -60,7 +62,7 @@ const createNotificationStore = () => {
if (autoDismiss) { if (autoDismiss) {
setTimeout(() => { setTimeout(() => {
dismiss(_id) dismiss(_id)
}, NOTIFICATION_TIMEOUT) }, duration || DEFAULT_NOTIFICATION_TIMEOUT)
} }
} }
@ -74,14 +76,14 @@ const createNotificationStore = () => {
subscribe: store.subscribe, subscribe: store.subscribe,
actions: { actions: {
send, send,
info: (msg, autoDismiss) => info: (msg, autoDismiss, duration) =>
send(msg, "info", "Info", autoDismiss ?? true), send(msg, "info", "Info", autoDismiss ?? true, duration),
success: (msg, autoDismiss) => success: (msg, autoDismiss, duration) =>
send(msg, "success", "CheckmarkCircle", autoDismiss ?? true), send(msg, "success", "CheckmarkCircle", autoDismiss ?? true, duration),
warning: (msg, autoDismiss) => warning: (msg, autoDismiss, duration) =>
send(msg, "warning", "Alert", autoDismiss ?? true), send(msg, "warning", "Alert", autoDismiss ?? true, duration),
error: (msg, autoDismiss) => error: (msg, autoDismiss, duration) =>
send(msg, "error", "Alert", autoDismiss ?? false), send(msg, "error", "Alert", autoDismiss ?? false, duration),
blockNotifications, blockNotifications,
dismiss, dismiss,
}, },

View File

@ -416,11 +416,11 @@ const continueIfHandler = action => {
} }
const showNotificationHandler = action => { const showNotificationHandler = action => {
const { message, type, autoDismiss } = action.parameters const { message, type, autoDismiss, duration } = action.parameters
if (!message || !type) { if (!message || !type) {
return return
} }
notificationStore.actions[type]?.(message, autoDismiss) notificationStore.actions[type]?.(message, autoDismiss, duration)
} }
const promptUserHandler = () => {} const promptUserHandler = () => {}

View File

@ -33,7 +33,8 @@
column.schema.autocolumn || column.schema.autocolumn ||
column.schema.disabled || column.schema.disabled ||
column.schema.type === "formula" || column.schema.type === "formula" ||
(!$config.canEditRows && !row._isNewRow) (!$config.canEditRows && !row._isNewRow) ||
column.schema.readonly
// Register this cell API if the row is focused // Register this cell API if the row is focused
$: { $: {

View File

@ -1,49 +1,98 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Icon } from "@budibase/bbui" import { ActionButton, Popover, Icon, notifications } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte" import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
import { helpers } from "@budibase/shared-core"
export let allowViewReadonlyColumns = false
const { columns, datasource, stickyColumn, dispatch } = getContext("grid") const { columns, datasource, stickyColumn, dispatch } = getContext("grid")
let open = false let open = false
let anchor let anchor
$: anyHidden = $columns.some(col => !col.visible) $: allColumns = $stickyColumn ? [$stickyColumn, ...$columns] : $columns
$: text = getText($columns)
$: restrictedColumns = allColumns.filter(col => !col.visible || col.readonly)
$: anyRestricted = restrictedColumns.length
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
const toggleColumn = async (column, permission) => { const toggleColumn = async (column, permission) => {
const visible = permission !== PERMISSION_OPTIONS.HIDDEN const visible = permission !== PERMISSION_OPTIONS.HIDDEN
const readonly = permission === PERMISSION_OPTIONS.READONLY
datasource.actions.addSchemaMutation(column.name, { visible }) await datasource.actions.addSchemaMutation(column.name, {
visible,
readonly,
})
try {
await datasource.actions.saveSchemaMutations() await datasource.actions.saveSchemaMutations()
dispatch(visible ? "show-column" : "hide-column") } catch (e) {
notifications.error(e.message)
} finally {
await datasource.actions.resetSchemaMutations()
await datasource.actions.refreshDefinition()
} }
dispatch(visible ? "show-column" : "hide-column")
const getText = columns => {
const hidden = columns.filter(col => !col.visible).length
return hidden ? `Columns (${hidden} restricted)` : "Columns"
} }
const PERMISSION_OPTIONS = { const PERMISSION_OPTIONS = {
WRITABLE: "writable", WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden", HIDDEN: "hidden",
} }
$: displayColumns = allColumns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const isDisplayColumn = $stickyColumn === c
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
const options = [ const options = [
{ icon: "Edit", value: PERMISSION_OPTIONS.WRITABLE, tooltip: "Writable" },
{ {
icon: "VisibilityOff", icon: "Edit",
value: PERMISSION_OPTIONS.HIDDEN, value: PERMISSION_OPTIONS.WRITABLE,
tooltip: "Hidden", tooltip: (!editEnabled && requiredTooltip) || "Writable",
disabled: !editEnabled,
}, },
] ]
if ($datasource.type === "viewV2") {
options.push({
icon: "Visibility",
value: PERMISSION_OPTIONS.READONLY,
tooltip: allowViewReadonlyColumns
? requiredTooltip || "Read only"
: "Read only (premium feature)",
disabled: !allowViewReadonlyColumns || isRequired,
})
}
options.push({
icon: "VisibilityOff",
value: PERMISSION_OPTIONS.HIDDEN,
disabled: isDisplayColumn || isRequired,
tooltip:
(isDisplayColumn && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})
return { ...c, options }
})
function columnToPermissionOptions(column) { function columnToPermissionOptions(column) {
if (!column.visible) { if (!column.schema.visible) {
return PERMISSION_OPTIONS.HIDDEN return PERMISSION_OPTIONS.HIDDEN
} }
if (column.schema.readonly) {
return PERMISSION_OPTIONS.READONLY
}
return PERMISSION_OPTIONS.WRITABLE return PERMISSION_OPTIONS.WRITABLE
} }
</script> </script>
@ -54,7 +103,7 @@
quiet quiet
size="M" size="M"
on:click={() => (open = !open)} on:click={() => (open = !open)}
selected={open || anyHidden} selected={open || anyRestricted}
disabled={!$columns.length} disabled={!$columns.length}
> >
{text} {text}
@ -64,19 +113,7 @@
<Popover bind:open {anchor} align="left"> <Popover bind:open {anchor} align="left">
<div class="content"> <div class="content">
<div class="columns"> <div class="columns">
{#if $stickyColumn} {#each displayColumns as column}
<div class="column">
<Icon size="S" name={getColumnIcon($stickyColumn)} />
{$stickyColumn.label}
</div>
<ToggleActionButtonGroup
disabled
value={PERMISSION_OPTIONS.WRITABLE}
{options}
/>
{/if}
{#each $columns as column}
<div class="column"> <div class="column">
<Icon size="S" name={getColumnIcon(column)} /> <Icon size="S" name={getColumnIcon(column)} />
{column.label} {column.label}
@ -84,7 +121,7 @@
<ToggleActionButtonGroup <ToggleActionButtonGroup
on:click={e => toggleColumn(column, e.detail)} on:click={e => toggleColumn(column, e.detail)}
value={columnToPermissionOptions(column)} value={columnToPermissionOptions(column)}
{options} options={column.options}
/> />
{/each} {/each}
</div> </div>

View File

@ -7,7 +7,6 @@
export let value export let value
export let options export let options
export let disabled
</script> </script>
<div class="permissionPicker"> <div class="permissionPicker">
@ -15,7 +14,7 @@
<AbsTooltip text={option.tooltip} type={TooltipType.Info}> <AbsTooltip text={option.tooltip} type={TooltipType.Info}>
<ActionButton <ActionButton
on:click={() => dispatch("click", option.value)} on:click={() => dispatch("click", option.value)}
{disabled} disabled={option.disabled}
size="S" size="S"
icon={option.icon} icon={option.icon}
quiet quiet

View File

@ -57,6 +57,7 @@
export let buttons = null export let buttons = null
export let darkMode export let darkMode
export let isCloud = null export let isCloud = null
export let allowViewReadonlyColumns = false
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
const gridID = `grid-${Math.random().toString().slice(2)}` const gridID = `grid-${Math.random().toString().slice(2)}`
@ -153,7 +154,7 @@
<div class="controls-left"> <div class="controls-left">
<slot name="filter" /> <slot name="filter" />
<SortButton /> <SortButton />
<ColumnsSettingButton /> <ColumnsSettingButton {allowViewReadonlyColumns} />
<SizeButton /> <SizeButton />
<slot name="controls" /> <slot name="controls" />
</div> </div>

View File

@ -146,6 +146,7 @@ export const initialise = context => {
schema: fieldSchema, schema: fieldSchema,
width: fieldSchema.width || oldColumn?.width || DefaultColumnWidth, width: fieldSchema.width || oldColumn?.width || DefaultColumnWidth,
visible: fieldSchema.visible ?? true, visible: fieldSchema.visible ?? true,
readonly: fieldSchema.readonly,
order: fieldSchema.order ?? oldColumn?.order, order: fieldSchema.order ?? oldColumn?.order,
primaryDisplay: field === primaryDisplay, primaryDisplay: field === primaryDisplay,
} }

View File

@ -204,6 +204,10 @@ export const createActions = context => {
...$definition, ...$definition,
schema: newSchema, schema: newSchema,
}) })
resetSchemaMutations()
}
const resetSchemaMutations = () => {
schemaMutations.set({}) schemaMutations.set({})
} }
@ -253,6 +257,7 @@ export const createActions = context => {
addSchemaMutation, addSchemaMutation,
addSchemaMutations, addSchemaMutations,
saveSchemaMutations, saveSchemaMutations,
resetSchemaMutations,
}, },
}, },
} }

View File

@ -22,10 +22,7 @@ import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import merge from "lodash/merge" import merge from "lodash/merge"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { roles } from "@budibase/backend-core" import { db, roles } from "@budibase/backend-core"
import * as schemaUtils from "../../../utilities/schema"
jest.mock("../../../utilities/schema")
describe.each([ describe.each([
["internal", undefined], ["internal", undefined],
@ -120,6 +117,9 @@ describe.each([
const newView: CreateViewRequest = { const newView: CreateViewRequest = {
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: {
id: { visible: true },
},
} }
const res = await config.api.viewV2.create(newView) const res = await config.api.viewV2.create(newView)
@ -134,7 +134,7 @@ describe.each([
const newView: Required<CreateViewRequest> = { const newView: Required<CreateViewRequest> = {
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: generator.word(), primaryDisplay: "id",
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: SearchFilterOperator.EQUAL,
@ -148,6 +148,7 @@ describe.each([
type: SortType.STRING, type: SortType.STRING,
}, },
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
}, },
@ -158,6 +159,7 @@ describe.each([
expect(res).toEqual({ expect(res).toEqual({
...newView, ...newView,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
}, },
@ -172,6 +174,11 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: {
name: "id",
type: FieldType.NUMBER,
visible: true,
},
Price: { Price: {
name: "Price", name: "Price",
type: FieldType.NUMBER, type: FieldType.NUMBER,
@ -193,6 +200,7 @@ describe.each([
expect(createdView).toEqual({ expect(createdView).toEqual({
...newView, ...newView,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
order: 1, order: 1,
@ -209,6 +217,12 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: {
name: "id",
type: FieldType.AUTO,
autocolumn: true,
visible: true,
},
Price: { Price: {
name: "Price", name: "Price",
type: FieldType.NUMBER, type: FieldType.NUMBER,
@ -230,8 +244,9 @@ describe.each([
const newView: CreateViewRequest = { const newView: CreateViewRequest = {
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
primaryDisplay: generator.word(), primaryDisplay: "id",
schema: { schema: {
id: { visible: true },
Price: { visible: true }, Price: { visible: true },
Category: { visible: false }, Category: { visible: false },
}, },
@ -241,6 +256,7 @@ describe.each([
expect(res).toEqual({ expect(res).toEqual({
...newView, ...newView,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
}, },
@ -255,6 +271,7 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
nonExisting: { nonExisting: {
visible: true, visible: true,
}, },
@ -293,6 +310,7 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
name: { name: {
visible: true, visible: true,
readonly: true, readonly: true,
@ -306,6 +324,7 @@ describe.each([
const res = await config.api.viewV2.create(newView) const res = await config.api.viewV2.create(newView)
expect(res.schema).toEqual({ expect(res.schema).toEqual({
id: { visible: true },
name: { name: {
visible: true, visible: true,
readonly: true, readonly: true,
@ -318,15 +337,13 @@ describe.each([
}) })
it("required fields cannot be marked as readonly", async () => { it("required fields cannot be marked as readonly", async () => {
const isRequiredSpy = jest.spyOn(schemaUtils, "isRequired")
isRequiredSpy.mockReturnValueOnce(true)
const table = await config.api.table.save( const table = await config.api.table.save(
saveTableRequest({ saveTableRequest({
schema: { schema: {
name: { name: {
name: "name", name: "name",
type: FieldType.STRING, type: FieldType.STRING,
constraints: { presence: true },
}, },
description: { description: {
name: "description", name: "description",
@ -340,7 +357,9 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
name: { name: {
visible: true,
readonly: true, readonly: true,
}, },
}, },
@ -350,7 +369,7 @@ describe.each([
status: 400, status: 400,
body: { body: {
message: message:
'Field "name" cannot be readonly as it is a required field', 'You can\'t make "name" readonly because it is a required field.',
status: 400, status: 400,
}, },
}) })
@ -376,6 +395,7 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
name: { name: {
visible: false, visible: false,
readonly: true, readonly: true,
@ -414,6 +434,7 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
name: { name: {
visible: true, visible: true,
readonly: true, readonly: true,
@ -424,12 +445,84 @@ describe.each([
await config.api.viewV2.create(newView, { await config.api.viewV2.create(newView, {
status: 400, status: 400,
body: { body: {
message: "Readonly fields are not enabled for your tenant", message: "Readonly fields are not enabled",
status: 400, status: 400,
}, },
}) })
}) })
}) })
it("display fields must be visible", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
description: {
name: "description",
type: FieldType.STRING,
},
},
})
)
const newView: CreateViewRequest = {
name: generator.name(),
tableId: table._id!,
primaryDisplay: "name",
schema: {
id: { visible: true },
name: {
visible: false,
},
},
}
await config.api.viewV2.create(newView, {
status: 400,
body: {
message: 'You can\'t hide "name" because it is the display column.',
status: 400,
},
})
})
it("display fields can be readonly", async () => {
mocks.licenses.useViewReadonlyColumns()
const table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
description: {
name: "description",
type: FieldType.STRING,
},
},
})
)
const newView: CreateViewRequest = {
name: generator.name(),
tableId: table._id!,
primaryDisplay: "name",
schema: {
id: { visible: true },
name: {
visible: true,
readonly: true,
},
},
}
await config.api.viewV2.create(newView, {
status: 201,
})
})
}) })
describe("update", () => { describe("update", () => {
@ -441,6 +534,9 @@ describe.each([
view = await config.api.viewV2.create({ view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: {
id: { visible: true },
},
}) })
}) })
@ -475,7 +571,7 @@ describe.each([
id: view.id, id: view.id,
tableId, tableId,
name: view.name, name: view.name,
primaryDisplay: generator.word(), primaryDisplay: "Price",
query: [ query: [
{ {
operator: SearchFilterOperator.EQUAL, operator: SearchFilterOperator.EQUAL,
@ -489,6 +585,7 @@ describe.each([
type: SortType.STRING, type: SortType.STRING,
}, },
schema: { schema: {
id: { visible: true },
Category: { Category: {
visible: false, visible: false,
}, },
@ -506,7 +603,7 @@ describe.each([
schema: { schema: {
...table.schema, ...table.schema,
id: expect.objectContaining({ id: expect.objectContaining({
visible: false, visible: true,
}), }),
Category: expect.objectContaining({ Category: expect.objectContaining({
visible: false, visible: false,
@ -603,6 +700,9 @@ describe.each([
const anotherView = await config.api.viewV2.create({ const anotherView = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: {
id: { visible: true },
},
}) })
const result = await config const result = await config
.request!.put(`/api/v2/views/${anotherView.id}`) .request!.put(`/api/v2/views/${anotherView.id}`)
@ -621,6 +721,7 @@ describe.each([
const updatedView = await config.api.viewV2.update({ const updatedView = await config.api.viewV2.update({
...view, ...view,
schema: { schema: {
...view.schema,
Price: { Price: {
name: "Price", name: "Price",
type: FieldType.NUMBER, type: FieldType.NUMBER,
@ -640,6 +741,7 @@ describe.each([
expect(updatedView).toEqual({ expect(updatedView).toEqual({
...view, ...view,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
order: 1, order: 1,
@ -656,6 +758,7 @@ describe.each([
{ {
...view, ...view,
schema: { schema: {
...view.schema,
Price: { Price: {
name: "Price", name: "Price",
type: FieldType.NUMBER, type: FieldType.NUMBER,
@ -679,6 +782,7 @@ describe.each([
view = await config.api.viewV2.update({ view = await config.api.viewV2.update({
...view, ...view,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
readonly: true, readonly: true,
@ -690,7 +794,7 @@ describe.each([
await config.api.viewV2.update(view, { await config.api.viewV2.update(view, {
status: 400, status: 400,
body: { body: {
message: "Readonly fields are not enabled for your tenant", message: "Readonly fields are not enabled",
}, },
}) })
}) })
@ -701,6 +805,7 @@ describe.each([
view = await config.api.viewV2.update({ view = await config.api.viewV2.update({
...view, ...view,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
readonly: true, readonly: true,
@ -715,6 +820,7 @@ describe.each([
const res = await config.api.viewV2.update({ const res = await config.api.viewV2.update({
...view, ...view,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
readonly: false, readonly: false,
@ -725,6 +831,7 @@ describe.each([
expect.objectContaining({ expect.objectContaining({
...view, ...view,
schema: { schema: {
id: { visible: true },
Price: { Price: {
visible: true, visible: true,
readonly: false, readonly: false,
@ -733,6 +840,53 @@ describe.each([
}) })
) )
}) })
isInternal &&
it("updating schema will only validate modified field", async () => {
let view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
id: { visible: true },
Price: {
visible: true,
},
Category: { visible: true },
},
})
// Update the view to an invalid state
const tableToUpdate = await config.api.table.get(table._id!)
;(tableToUpdate.views![view.name] as ViewV2).schema!.id.visible = false
await db.getDB(config.appId!).put(tableToUpdate)
view = await config.api.viewV2.get(view.id)
await config.api.viewV2.update({
...view,
schema: {
...view.schema,
Price: {
visible: false,
},
},
})
expect(await config.api.viewV2.get(view.id)).toEqual(
expect.objectContaining({
schema: {
id: expect.objectContaining({
visible: false,
}),
Price: expect.objectContaining({
visible: false,
}),
Category: expect.objectContaining({
visible: true,
}),
},
})
)
})
}) })
describe("delete", () => { describe("delete", () => {
@ -742,6 +896,9 @@ describe.each([
view = await config.api.viewV2.create({ view = await config.api.viewV2.create({
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: {
id: { visible: true },
},
}) })
}) })
@ -764,6 +921,7 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
Price: { visible: false }, Price: { visible: false },
Category: { visible: true }, Category: { visible: true },
}, },
@ -786,6 +944,7 @@ describe.each([
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
id: { visible: true },
Price: { visible: true, readonly: true }, Price: { visible: true, readonly: true },
}, },
}) })
@ -821,6 +980,7 @@ describe.each([
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: { schema: {
id: { visible: true },
Country: { Country: {
visible: true, visible: true,
}, },
@ -855,6 +1015,7 @@ describe.each([
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: { schema: {
id: { visible: true },
two: { visible: true }, two: { visible: true },
}, },
}) })
@ -880,6 +1041,7 @@ describe.each([
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: { schema: {
id: { visible: true },
one: { visible: true, readonly: true }, one: { visible: true, readonly: true },
two: { visible: true }, two: { visible: true },
}, },
@ -921,6 +1083,7 @@ describe.each([
tableId: table._id!, tableId: table._id!,
name: generator.guid(), name: generator.guid(),
schema: { schema: {
id: { visible: true },
one: { visible: true, readonly: true }, one: { visible: true, readonly: true },
two: { visible: true }, two: { visible: true },
}, },
@ -988,6 +1151,7 @@ describe.each([
rows.map(r => ({ rows.map(r => ({
_viewId: view.id, _viewId: view.id,
tableId: table._id, tableId: table._id,
id: r.id,
_id: r._id, _id: r._id,
_rev: r._rev, _rev: r._rev,
...(isInternal ...(isInternal
@ -1028,6 +1192,7 @@ describe.each([
}, },
], ],
schema: { schema: {
id: { visible: true },
two: { visible: true }, two: { visible: true },
}, },
}) })
@ -1039,6 +1204,7 @@ describe.each([
{ {
_viewId: view.id, _viewId: view.id,
tableId: table._id, tableId: table._id,
id: two.id,
two: two.two, two: two.two,
_id: two._id, _id: two._id,
_rev: two._rev, _rev: two._rev,
@ -1192,7 +1358,11 @@ describe.each([
describe("sorting", () => { describe("sorting", () => {
let table: Table let table: Table
const viewSchema = { age: { visible: true }, name: { visible: true } } const viewSchema = {
id: { visible: true },
age: { visible: true },
name: { visible: true },
}
beforeAll(async () => { beforeAll(async () => {
table = await config.api.table.save( table = await config.api.table.save(
@ -1348,4 +1518,123 @@ describe.each([
}) })
}) })
}) })
describe("updating table schema", () => {
describe("existing columns changed to required", () => {
beforeEach(async () => {
table = await config.api.table.save(
saveTableRequest({
schema: {
id: {
name: "id",
type: FieldType.AUTO,
autocolumn: true,
},
name: {
name: "name",
type: FieldType.STRING,
},
},
})
)
})
it("allows updating when no views constrains the field", async () => {
await config.api.viewV2.create({
name: "view a",
tableId: table._id!,
schema: {
id: { visible: true },
name: { visible: true },
},
})
table = await config.api.table.get(table._id!)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: { allowEmpty: false } },
},
},
},
{ status: 200 }
)
})
it("rejects if field is readonly in any view", async () => {
mocks.licenses.useViewReadonlyColumns()
await config.api.viewV2.create({
name: "view a",
tableId: table._id!,
schema: {
id: { visible: true },
name: {
visible: true,
readonly: true,
},
},
})
table = await config.api.table.get(table._id!)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: true },
},
},
},
{
status: 400,
body: {
status: 400,
message:
'To make field "name" required, this field must be present and writable in views: view a.',
},
}
)
})
it("rejects if field is hidden in any view", async () => {
await config.api.viewV2.create({
name: "view a",
tableId: table._id!,
schema: { id: { visible: true } },
})
table = await config.api.table.get(table._id!)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
name: {
name: "name",
type: FieldType.STRING,
constraints: { presence: true },
},
},
},
{
status: 400,
body: {
status: 400,
message:
'To make field "name" required, this field must be present and writable in views: view a.',
},
}
)
})
})
})
}) })

View File

@ -1,17 +1,48 @@
import { auth, permissions } from "@budibase/backend-core" import { auth, permissions } from "@budibase/backend-core"
import { DataSourceOperation } from "../../../constants" import { DataSourceOperation } from "../../../constants"
import { WebhookActionType } from "@budibase/types" import { Table, WebhookActionType } from "@budibase/types"
import Joi from "joi" import Joi, { CustomValidator } from "joi"
import { ValidSnippetNameRegex } from "@budibase/shared-core" import { ValidSnippetNameRegex, helpers } from "@budibase/shared-core"
import sdk from "../../../sdk"
const { isRequired } = helpers.schema
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("") const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
const OPTIONAL_NUMBER = Joi.number().optional().allow(null) const OPTIONAL_NUMBER = Joi.number().optional().allow(null)
const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null) const OPTIONAL_BOOLEAN = Joi.boolean().optional().allow(null)
const APP_NAME_REGEX = /^[\w\s]+$/ const APP_NAME_REGEX = /^[\w\s]+$/
const validateViewSchemas: CustomValidator<Table> = (table, helpers) => {
if (table.views && Object.entries(table.views).length) {
const requiredFields = Object.entries(table.schema)
.filter(([_, v]) => isRequired(v.constraints))
.map(([key]) => key)
if (requiredFields.length) {
for (const view of Object.values(table.views)) {
if (!sdk.views.isV2(view)) {
continue
}
const editableViewFields = Object.entries(view.schema || {})
.filter(([_, f]) => f.visible && !f.readonly)
.map(([key]) => key)
const missingField = requiredFields.find(
f => !editableViewFields.includes(f)
)
if (missingField) {
return helpers.message({
custom: `To make field "${missingField}" required, this field must be present and writable in views: ${view.name}.`,
})
}
}
}
}
return table
}
export function tableValidator() { export function tableValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
_rev: OPTIONAL_STRING, _rev: OPTIONAL_STRING,
type: OPTIONAL_STRING.valid("table", "internal", "external"), type: OPTIONAL_STRING.valid("table", "internal", "external"),
@ -20,32 +51,39 @@ export function tableValidator() {
name: Joi.string().required(), name: Joi.string().required(),
views: Joi.object(), views: Joi.object(),
rows: Joi.array(), rows: Joi.array(),
}).unknown(true)) })
.custom(validateViewSchemas)
.unknown(true),
{ errorPrefix: "" }
)
} }
export function nameValidator() { export function nameValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
name: OPTIONAL_STRING, name: OPTIONAL_STRING,
})) })
)
} }
export function datasourceValidator() { export function datasourceValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
_id: Joi.string(), _id: Joi.string(),
_rev: Joi.string(), _rev: Joi.string(),
type: OPTIONAL_STRING.allow("datasource_plus"), type: OPTIONAL_STRING.allow("datasource_plus"),
relationships: Joi.array().items(Joi.object({ relationships: Joi.array().items(
Joi.object({
from: Joi.string().required(), from: Joi.string().required(),
to: Joi.string().required(), to: Joi.string().required(),
cardinality: Joi.valid("1:N", "1:1", "N:N").required() cardinality: Joi.valid("1:N", "1:1", "N:N").required(),
})), })
}).unknown(true)) ),
}).unknown(true)
)
} }
function filterObject() { function filterObject() {
// prettier-ignore
return Joi.object({ return Joi.object({
string: Joi.object().optional(), string: Joi.object().optional(),
fuzzy: Joi.object().optional(), fuzzy: Joi.object().optional(),
@ -62,8 +100,8 @@ function filterObject() {
} }
export function internalSearchValidator() { export function internalSearchValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
tableId: OPTIONAL_STRING, tableId: OPTIONAL_STRING,
query: filterObject(), query: filterObject(),
limit: OPTIONAL_NUMBER, limit: OPTIONAL_NUMBER,
@ -71,8 +109,11 @@ export function internalSearchValidator() {
sortOrder: OPTIONAL_STRING, sortOrder: OPTIONAL_STRING,
sortType: OPTIONAL_STRING, sortType: OPTIONAL_STRING,
paginate: Joi.boolean(), paginate: Joi.boolean(),
bookmark: Joi.alternatives().try(OPTIONAL_STRING, OPTIONAL_NUMBER).optional(), bookmark: Joi.alternatives()
})) .try(OPTIONAL_STRING, OPTIONAL_NUMBER)
.optional(),
})
)
} }
export function externalSearchValidator() { export function externalSearchValidator() {
@ -94,11 +135,13 @@ export function externalSearchValidator() {
} }
export function datasourceQueryValidator() { export function datasourceQueryValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
endpoint: Joi.object({ endpoint: Joi.object({
datasourceId: Joi.string().required(), datasourceId: Joi.string().required(),
operation: Joi.string().required().valid(...Object.values(DataSourceOperation)), operation: Joi.string()
.required()
.valid(...Object.values(DataSourceOperation)),
entityId: Joi.string().required(), entityId: Joi.string().required(),
}).required(), }).required(),
resource: Joi.object({ resource: Joi.object({
@ -111,12 +154,13 @@ export function datasourceQueryValidator() {
page: Joi.string().alphanum().optional(), page: Joi.string().alphanum().optional(),
limit: Joi.number().optional(), limit: Joi.number().optional(),
}).optional(), }).optional(),
})) })
)
} }
export function webhookValidator() { export function webhookValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
live: Joi.bool(), live: Joi.bool(),
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
_rev: OPTIONAL_STRING, _rev: OPTIONAL_STRING,
@ -126,38 +170,49 @@ export function webhookValidator() {
type: Joi.string().required().valid(WebhookActionType.AUTOMATION), type: Joi.string().required().valid(WebhookActionType.AUTOMATION),
target: Joi.string().required(), target: Joi.string().required(),
}).required(), }).required(),
}).unknown(true)) }).unknown(true)
)
} }
export function roleValidator() { export function roleValidator() {
const permLevelArray = Object.values(permissions.PermissionLevel) const permLevelArray = Object.values(permissions.PermissionLevel)
// prettier-ignore
return auth.joiValidator.body(Joi.object({ return auth.joiValidator.body(
Joi.object({
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
_rev: OPTIONAL_STRING, _rev: OPTIONAL_STRING,
name: Joi.string().regex(/^[a-zA-Z0-9_]*$/).required(), name: Joi.string()
.regex(/^[a-zA-Z0-9_]*$/)
.required(),
// this is the base permission ID (for now a built in) // this is the base permission ID (for now a built in)
permissionId: Joi.string().valid(...Object.values(permissions.BuiltinPermissionID)).required(), permissionId: Joi.string()
.valid(...Object.values(permissions.BuiltinPermissionID))
.required(),
permissions: Joi.object() permissions: Joi.object()
.pattern(/.*/, [Joi.string().valid(...permLevelArray)]) .pattern(/.*/, [Joi.string().valid(...permLevelArray)])
.optional(), .optional(),
inherits: OPTIONAL_STRING, inherits: OPTIONAL_STRING,
}).unknown(true)) }).unknown(true)
)
} }
export function permissionValidator() { export function permissionValidator() {
const permLevelArray = Object.values(permissions.PermissionLevel) const permLevelArray = Object.values(permissions.PermissionLevel)
// prettier-ignore
return auth.joiValidator.params(Joi.object({ return auth.joiValidator.params(
level: Joi.string().valid(...permLevelArray).required(), Joi.object({
level: Joi.string()
.valid(...permLevelArray)
.required(),
resourceId: Joi.string(), resourceId: Joi.string(),
roleId: Joi.string(), roleId: Joi.string(),
}).unknown(true)) }).unknown(true)
)
} }
export function screenValidator() { export function screenValidator() {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
name: Joi.string().required(), name: Joi.string().required(),
showNavigation: OPTIONAL_BOOLEAN, showNavigation: OPTIONAL_BOOLEAN,
width: OPTIONAL_STRING, width: OPTIONAL_STRING,
@ -165,7 +220,9 @@ export function screenValidator() {
route: Joi.string().required(), route: Joi.string().required(),
roleId: Joi.string().required().allow(""), roleId: Joi.string().required().allow(""),
homeScreen: OPTIONAL_BOOLEAN, homeScreen: OPTIONAL_BOOLEAN,
}).required().unknown(true), })
.required()
.unknown(true),
props: Joi.object({ props: Joi.object({
_id: Joi.string().required(), _id: Joi.string().required(),
_component: Joi.string().required(), _component: Joi.string().required(),
@ -174,12 +231,14 @@ export function screenValidator() {
type: OPTIONAL_STRING, type: OPTIONAL_STRING,
table: OPTIONAL_STRING, table: OPTIONAL_STRING,
layoutId: OPTIONAL_STRING, layoutId: OPTIONAL_STRING,
}).required().unknown(true), })
}).unknown(true)) .required()
.unknown(true),
}).unknown(true)
)
} }
function generateStepSchema(allowStepTypes: string[]) { function generateStepSchema(allowStepTypes: string[]) {
// prettier-ignore
return Joi.object({ return Joi.object({
stepId: Joi.string().required(), stepId: Joi.string().required(),
id: Joi.string().required(), id: Joi.string().required(),
@ -189,33 +248,39 @@ function generateStepSchema(allowStepTypes: string[]) {
icon: Joi.string().required(), icon: Joi.string().required(),
params: Joi.object(), params: Joi.object(),
args: Joi.object(), args: Joi.object(),
type: Joi.string().required().valid(...allowStepTypes), type: Joi.string()
.required()
.valid(...allowStepTypes),
}).unknown(true) }).unknown(true)
} }
export function automationValidator(existing = false) { export function automationValidator(existing = false) {
// prettier-ignore return auth.joiValidator.body(
return auth.joiValidator.body(Joi.object({ Joi.object({
_id: existing ? Joi.string().required() : OPTIONAL_STRING, _id: existing ? Joi.string().required() : OPTIONAL_STRING,
_rev: existing ? Joi.string().required() : OPTIONAL_STRING, _rev: existing ? Joi.string().required() : OPTIONAL_STRING,
name: Joi.string().required(), name: Joi.string().required(),
type: Joi.string().valid("automation").required(), type: Joi.string().valid("automation").required(),
definition: Joi.object({ definition: Joi.object({
steps: Joi.array().required().items(generateStepSchema(["ACTION", "LOGIC"])), steps: Joi.array()
.required()
.items(generateStepSchema(["ACTION", "LOGIC"])),
trigger: generateStepSchema(["TRIGGER"]).allow(null), trigger: generateStepSchema(["TRIGGER"]).allow(null),
}).required().unknown(true), })
}).unknown(true)) .required()
.unknown(true),
}).unknown(true)
)
} }
export function applicationValidator(opts = { isCreate: true }) { export function applicationValidator(opts = { isCreate: true }) {
// prettier-ignore
const base: any = { const base: any = {
_id: OPTIONAL_STRING, _id: OPTIONAL_STRING,
_rev: OPTIONAL_STRING, _rev: OPTIONAL_STRING,
url: OPTIONAL_STRING, url: OPTIONAL_STRING,
template: Joi.object({ template: Joi.object({
templateString: OPTIONAL_STRING, templateString: OPTIONAL_STRING,
}) }),
} }
const appNameValidator = Joi.string() const appNameValidator = Joi.string()

View File

@ -8,7 +8,8 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { HTTPError, db as dbCore } from "@budibase/backend-core" import { HTTPError, db as dbCore } from "@budibase/backend-core"
import { features } from "@budibase/pro" import { features } from "@budibase/pro"
import { cloneDeep } from "lodash" import { helpers } from "@budibase/shared-core"
import { cloneDeep } from "lodash/fp"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import { isExternalTableID } from "../../../integrations/utils" import { isExternalTableID } from "../../../integrations/utils"
@ -16,7 +17,6 @@ import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { isRequired } from "../../../utilities/schema"
function pickApi(tableId: any) { function pickApi(tableId: any) {
if (isExternalTableID(tableId)) { if (isExternalTableID(tableId)) {
@ -37,11 +37,9 @@ export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
async function guardViewSchema( async function guardViewSchema(
tableId: string, tableId: string,
viewSchema?: Record<string, ViewUIFieldMetadata> view: Omit<ViewV2, "id" | "version">
) { ) {
if (!viewSchema || !Object.keys(viewSchema).length) { const viewSchema = view.schema || {}
return
}
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
for (const field of Object.keys(viewSchema)) { for (const field of Object.keys(viewSchema)) {
@ -54,18 +52,11 @@ async function guardViewSchema(
} }
if (viewSchema[field].readonly) { if (viewSchema[field].readonly) {
if (!(await features.isViewReadonlyColumnsEnabled())) { if (
throw new HTTPError( !(await features.isViewReadonlyColumnsEnabled()) &&
`Readonly fields are not enabled for your tenant`, !(tableSchemaField as ViewUIFieldMetadata).readonly
400 ) {
) throw new HTTPError(`Readonly fields are not enabled`, 400)
}
if (isRequired(tableSchemaField.constraints)) {
throw new HTTPError(
`Field "${field}" cannot be readonly as it is a required field`,
400
)
} }
if (!viewSchema[field].visible) { if (!viewSchema[field].visible) {
@ -76,19 +67,61 @@ async function guardViewSchema(
} }
} }
} }
const existingView =
table?.views && (table.views[view.name] as ViewV2 | undefined)
for (const field of Object.values(table.schema)) {
if (!helpers.schema.isRequired(field.constraints)) {
continue
}
const viewSchemaField = viewSchema[field.name]
const existingViewSchema =
existingView?.schema && existingView.schema[field.name]
if (!viewSchemaField && !existingViewSchema?.visible) {
// Supporting existing configs with required columns but hidden in views
continue
}
if (!viewSchemaField?.visible) {
throw new HTTPError(
`You can't hide "${field.name}" because it is a required field.`,
400
)
}
if (viewSchemaField.readonly) {
throw new HTTPError(
`You can't make "${field.name}" readonly because it is a required field.`,
400
)
}
}
if (view.primaryDisplay) {
const viewSchemaField = viewSchema[view.primaryDisplay]
if (!viewSchemaField?.visible) {
throw new HTTPError(
`You can't hide "${view.primaryDisplay}" because it is the display column.`,
400
)
}
}
} }
export async function create( export async function create(
tableId: string, tableId: string,
viewRequest: Omit<ViewV2, "id" | "version"> viewRequest: Omit<ViewV2, "id" | "version">
): Promise<ViewV2> { ): Promise<ViewV2> {
await guardViewSchema(tableId, viewRequest.schema) await guardViewSchema(tableId, viewRequest)
return pickApi(tableId).create(tableId, viewRequest) return pickApi(tableId).create(tableId, viewRequest)
} }
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> { export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
await guardViewSchema(tableId, view.schema) await guardViewSchema(tableId, view)
return pickApi(tableId).update(tableId, view) return pickApi(tableId).update(tableId, view)
} }

View File

@ -4,9 +4,8 @@ import {
TableSchema, TableSchema,
FieldSchema, FieldSchema,
Row, Row,
FieldConstraints,
} from "@budibase/types" } from "@budibase/types"
import { ValidColumnNameRegex, utils } from "@budibase/shared-core" import { ValidColumnNameRegex, helpers, utils } from "@budibase/shared-core"
import { db } from "@budibase/backend-core" import { db } from "@budibase/backend-core"
import { parseCsvExport } from "../api/controllers/view/exporters" import { parseCsvExport } from "../api/controllers/view/exporters"
@ -41,15 +40,6 @@ export function isRows(rows: any): rows is Rows {
return Array.isArray(rows) && rows.every(row => typeof row === "object") return Array.isArray(rows) && rows.every(row => typeof row === "object")
} }
export function isRequired(constraints: FieldConstraints | undefined) {
const isRequired =
!!constraints &&
((typeof constraints.presence !== "boolean" &&
constraints.presence?.allowEmpty === false) ||
constraints.presence === true)
return isRequired
}
export function validate(rows: Rows, schema: TableSchema): ValidationResults { export function validate(rows: Rows, schema: TableSchema): ValidationResults {
const results: ValidationResults = { const results: ValidationResults = {
schemaValidation: {}, schemaValidation: {},
@ -109,7 +99,7 @@ export function validate(rows: Rows, schema: TableSchema): ValidationResults {
columnData, columnData,
columnType, columnType,
columnSubtype, columnSubtype,
isRequired(constraints) helpers.schema.isRequired(constraints)
) )
) { ) {
results.schemaValidation[columnName] = false results.schemaValidation[columnName] = false

View File

@ -1,5 +1,6 @@
import { import {
BBReferenceFieldSubType, BBReferenceFieldSubType,
FieldConstraints,
FieldSchema, FieldSchema,
FieldType, FieldType,
} from "@budibase/types" } from "@budibase/types"
@ -16,3 +17,12 @@ export function isDeprecatedSingleUserColumn(
schema.constraints?.type !== "array" schema.constraints?.type !== "array"
return result return result
} }
export function isRequired(constraints: FieldConstraints | undefined) {
const isRequired =
!!constraints &&
((typeof constraints.presence !== "boolean" &&
constraints.presence?.allowEmpty === false) ||
constraints.presence === true)
return isRequired
}

View File

@ -33,7 +33,12 @@ const removeSquareBrackets = (value: string) => {
// Our context getter function provided to JS code as $. // Our context getter function provided to JS code as $.
// Extracts a value from context. // Extracts a value from context.
const getContextValue = (path: string, context: any) => { const getContextValue = (path: string, context: any) => {
const literalStringRegex = /^(["'`]).*\1$/
let data = context let data = context
// check if it's a literal string - just return path if its quoted
if (literalStringRegex.test(path)) {
return path.substring(1, path.length - 1)
}
path.split(".").forEach(key => { path.split(".").forEach(key => {
if (data == null || typeof data !== "object") { if (data == null || typeof data !== "object") {
return null return null

View File

@ -149,4 +149,11 @@ describe("Javascript", () => {
expect(output).toMatch(UUID_REGEX) expect(output).toMatch(UUID_REGEX)
}) })
}) })
describe("JS literal strings", () => {
it("should be able to handle a literal string that is quoted (like role IDs)", () => {
const output = processJS(`return $("'Custom'")`)
expect(output).toBe("Custom")
})
})
}) })