Merge branch 'master' of github.com:Budibase/budibase into grid-layout-expansion

This commit is contained in:
Andrew Kingston 2024-08-20 10:26:39 +01:00
commit 346f55691b
No known key found for this signature in database
100 changed files with 3463 additions and 1191 deletions

View File

@ -43,6 +43,7 @@
}, },
"rules": { "rules": {
"no-unused-vars": "off", "no-unused-vars": "off",
"local-rules/no-barrel-imports": "error",
"local-rules/no-budibase-imports": "error", "local-rules/no-budibase-imports": "error",
"local-rules/no-console-error": "error", "local-rules/no-console-error": "error",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [

View File

@ -139,7 +139,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml
| 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.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.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 | `` | 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. |
| ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. | | ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. |

View File

@ -65,7 +65,10 @@ spec:
- name: ENABLE_ANALYTICS - name: ENABLE_ANALYTICS
value: {{ .Values.globals.enableAnalytics | quote }} value: {{ .Values.globals.enableAnalytics | quote }}
- name: API_ENCRYPTION_KEY - name: API_ENCRYPTION_KEY
value: {{ .Values.globals.apiEncryptionKey | quote }} valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: apiEncryptionKey
- name: HTTP_LOGGING - name: HTTP_LOGGING
value: {{ .Values.services.apps.httpLogging | quote }} value: {{ .Values.services.apps.httpLogging | quote }}
- name: INTERNAL_API_KEY - name: INTERNAL_API_KEY
@ -161,7 +164,10 @@ spec:
- name: TENANT_FEATURE_FLAGS - name: TENANT_FEATURE_FLAGS
value: {{ .Values.globals.tenantFeatureFlags | quote }} value: {{ .Values.globals.tenantFeatureFlags | quote }}
- name: ENCRYPTION_KEY - name: ENCRYPTION_KEY
value: {{ .Values.globals.bbEncryptionKey | quote }} valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: bbEncryptionKey
{{ if .Values.globals.bbAdminUserEmail }} {{ if .Values.globals.bbAdminUserEmail }}
- name: BB_ADMIN_USER_EMAIL - name: BB_ADMIN_USER_EMAIL
value: {{ .Values.globals.bbAdminUserEmail | quote }} value: {{ .Values.globals.bbAdminUserEmail | quote }}
@ -221,7 +227,7 @@ spec:
name: {{ .secretName }} name: {{ .secretName }}
key: {{ .secretKey | quote }} key: {{ .secretKey | quote }}
{{- end}} {{- end}}
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: {{ .Values.globals.dockerRegistry }}budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.apps.startupProbe }} {{- if .Values.services.apps.startupProbe }}
{{- with .Values.services.apps.startupProbe }} {{- with .Values.services.apps.startupProbe }}

View File

@ -58,7 +58,10 @@ spec:
- name: ENABLE_ANALYTICS - name: ENABLE_ANALYTICS
value: {{ .Values.globals.enableAnalytics | quote }} value: {{ .Values.globals.enableAnalytics | quote }}
- name: API_ENCRYPTION_KEY - name: API_ENCRYPTION_KEY
value: {{ .Values.globals.apiEncryptionKey | quote }} valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: apiEncryptionKey
- name: HTTP_LOGGING - name: HTTP_LOGGING
value: {{ .Values.services.automationWorkers.httpLogging | quote }} value: {{ .Values.services.automationWorkers.httpLogging | quote }}
- name: INTERNAL_API_KEY - name: INTERNAL_API_KEY
@ -154,7 +157,10 @@ spec:
- name: TENANT_FEATURE_FLAGS - name: TENANT_FEATURE_FLAGS
value: {{ .Values.globals.tenantFeatureFlags | quote }} value: {{ .Values.globals.tenantFeatureFlags | quote }}
- name: ENCRYPTION_KEY - name: ENCRYPTION_KEY
value: {{ .Values.globals.bbEncryptionKey | quote }} valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: bbEncryptionKey
{{ if .Values.globals.bbAdminUserEmail }} {{ if .Values.globals.bbAdminUserEmail }}
- name: BB_ADMIN_USER_EMAIL - name: BB_ADMIN_USER_EMAIL
value: {{ .Values.globals.bbAdminUserEmail | quote }} value: {{ .Values.globals.bbAdminUserEmail | quote }}
@ -209,7 +215,7 @@ spec:
key: {{ .secretKey | quote }} key: {{ .secretKey | quote }}
{{- end}} {{- end}}
image: budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: {{ .Values.globals.dockerRegistry }}budibase/apps:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.automationWorkers.startupProbe }} {{- if .Values.services.automationWorkers.startupProbe }}
{{- with .Values.services.automationWorkers.startupProbe }} {{- with .Values.services.automationWorkers.startupProbe }}

View File

@ -35,7 +35,7 @@ spec:
name: {{ template "budibase.fullname" . }} name: {{ template "budibase.fullname" . }}
key: objectStoreSecret key: objectStoreSecret
image: minio/minio image: {{ .Values.globals.dockerRegistry }}minio/minio
imagePullPolicy: "" imagePullPolicy: ""
livenessProbe: livenessProbe:
httpGet: httpGet:

View File

@ -32,7 +32,7 @@ spec:
{{ end }} {{ end }}
spec: spec:
containers: containers:
- image: budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }} - image: {{ .Values.globals.dockerRegistry }}budibase/proxy:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: proxy-service name: proxy-service
{{- if .Values.services.proxy.startupProbe }} {{- if .Values.services.proxy.startupProbe }}

View File

@ -22,7 +22,7 @@ spec:
- redis-server - redis-server
- --requirepass - --requirepass
- {{ .Values.services.redis.password }} - {{ .Values.services.redis.password }}
image: {{ .Values.services.redis.image }} image: {{ .Values.globals.dockerRegistry }}{{ .Values.services.redis.image }}
imagePullPolicy: "" imagePullPolicy: ""
name: redis-service name: redis-service
ports: ports:

View File

@ -16,10 +16,14 @@ data:
jwtSecret: {{ index $existingSecret.data "jwtSecret" }} jwtSecret: {{ index $existingSecret.data "jwtSecret" }}
objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" }} objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" }}
objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" }} objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" }}
bbEncryptionKey: {{ index $existingSecret.data "bbEncryptionKey" }}
apiEncryptionKey: {{ index $existingSecret.data "apiEncryptionKey" }}
{{- else }} {{- else }}
internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }} internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }}
jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }} jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }}
objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }} objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }}
objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }} objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }}
bbEncryptionKey: {{ template "budibase.defaultsecret" .Values.globals.bbEncryptionKey }}
apiEncryptionKey: {{ template "budibase.defaultsecret" .Values.globals.apiEncryptionKey }}
{{- end }} {{- end }}
{{- end }} {{- end }}

View File

@ -65,7 +65,10 @@ spec:
{{ end }} {{ end }}
{{ end }} {{ end }}
- name: API_ENCRYPTION_KEY - name: API_ENCRYPTION_KEY
value: {{ .Values.globals.apiEncryptionKey | quote }} valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: apiEncryptionKey
- name: HTTP_LOGGING - name: HTTP_LOGGING
value: {{ .Values.services.worker.httpLogging | quote }} value: {{ .Values.services.worker.httpLogging | quote }}
- name: INTERNAL_API_KEY - name: INTERNAL_API_KEY
@ -167,7 +170,10 @@ spec:
- name: TENANT_FEATURE_FLAGS - name: TENANT_FEATURE_FLAGS
value: {{ .Values.globals.tenantFeatureFlags | quote }} value: {{ .Values.globals.tenantFeatureFlags | quote }}
- name: ENCRYPTION_KEY - name: ENCRYPTION_KEY
value: {{ .Values.globals.bbEncryptionKey | quote }} valueFrom:
secretKeyRef:
name: {{ template "budibase.fullname" . }}
key: bbEncryptionKey
{{ if .Values.globals.datadogApmEnabled }} {{ if .Values.globals.datadogApmEnabled }}
- name: DD_LOGS_INJECTION - name: DD_LOGS_INJECTION
value: {{ .Values.globals.datadogApmEnabled | quote }} value: {{ .Values.globals.datadogApmEnabled | quote }}
@ -207,7 +213,7 @@ spec:
name: {{ .secretName }} name: {{ .secretName }}
key: {{ .secretKey | quote }} key: {{ .secretKey | quote }}
{{- end}} {{- end}}
image: budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }} image: {{ .Values.globals.dockerRegistry }}budibase/worker:{{ .Values.globals.appVersion | default .Chart.AppVersion }}
imagePullPolicy: Always imagePullPolicy: Always
{{- if .Values.services.worker.startupProbe }} {{- if .Values.services.worker.startupProbe }}
{{- with .Values.services.worker.startupProbe }} {{- with .Values.services.worker.startupProbe }}

View File

@ -62,7 +62,7 @@ globals:
budibaseEnv: PRODUCTION budibaseEnv: PRODUCTION
# -- Sets what feature flags are enabled and for which tenants. Should not ordinarily need to be # -- Sets what feature flags are enabled and for which tenants. Should not ordinarily need to be
# changed. # changed.
tenantFeatureFlags: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR" tenantFeatureFlags: ""
# -- Whether to enable analytics or not. You can read more about our analytics here: # -- Whether to enable analytics or not. You can read more about our analytics here:
# <https://docs.budibase.com/docs/analytics>. # <https://docs.budibase.com/docs/analytics>.
enableAnalytics: "1" enableAnalytics: "1"

View File

@ -1,6 +1,17 @@
const path = require("path")
const makeBarrelPath = finalPath => {
return path.resolve(__dirname, "..", finalPath)
}
const backendCoreBarrelPaths = [
makeBarrelPath(path.join("packages", "backend-core", "src", "index.ts")),
makeBarrelPath(path.join("packages", "backend-core", "src")),
makeBarrelPath(path.join("packages", "backend-core")),
]
module.exports = { module.exports = {
"no-console-error": { "no-console-error": {
create: function(context) { create: function (context) {
return { return {
CallExpression(node) { CallExpression(node) {
if ( if (
@ -13,11 +24,12 @@ module.exports = {
) { ) {
context.report({ context.report({
node, node,
message: 'Using console.error(err) on its own is not allowed. Either provide context to the error (console.error(msg, err)) or throw it.', message:
"Using console.error(err) on its own is not allowed. Either provide context to the error (console.error(msg, err)) or throw it.",
}) })
} }
}, },
}; }
}, },
}, },
"no-budibase-imports": { "no-budibase-imports": {
@ -106,4 +118,42 @@ module.exports = {
} }
}, },
}, },
"no-barrel-imports": {
meta: {
type: "problem",
docs: {
description:
"Disallow imports from the top-level backend-core barrel file",
category: "Best Practices",
recommended: false,
},
schema: [], // no options
messages: {
noBarrelImport:
"Avoid importing from the top-level barrel file 'backend-core/src/index.ts'. Import directly from the specific module instead.",
},
},
create(context) {
return {
ImportDeclaration(node) {
const importPath = node.source.value
const importFullPath = path.resolve(
context.getFilename(),
"..",
importPath
)
if (backendCoreBarrelPaths.includes(importFullPath)) {
context.report({
node,
messageId: "noBarrelImport",
data: {
importFullPath,
},
})
}
},
}
},
},
} }

View File

@ -10,7 +10,6 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
[[ -z "${MINIO_URL}" ]] && [[ -z "${USE_S3}" ]] && export MINIO_URL=http://127.0.0.1:9000 [[ -z "${MINIO_URL}" ]] && [[ -z "${USE_S3}" ]] && export MINIO_URL=http://127.0.0.1:9000
[[ -z "${NODE_ENV}" ]] && export NODE_ENV=production [[ -z "${NODE_ENV}" ]] && export NODE_ENV=production
[[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU [[ -z "${POSTHOG_TOKEN}" ]] && export POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
[[ -z "${TENANT_FEATURE_FLAGS}" ]] && export TENANT_FEATURE_FLAGS="*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR"
[[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app [[ -z "${ACCOUNT_PORTAL_URL}" ]] && export ACCOUNT_PORTAL_URL=https://account.budibase.app
[[ -z "${REDIS_URL}" ]] && export REDIS_URL=127.0.0.1:6379 [[ -z "${REDIS_URL}" ]] && export REDIS_URL=127.0.0.1:6379
[[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1 [[ -z "${SELF_HOSTED}" ]] && export SELF_HOSTED=1
@ -45,8 +44,7 @@ fi
# randomise any unset environment variables # randomise any unset environment variables
for ENV_VAR in "${ENV_VARS[@]}" for ENV_VAR in "${ENV_VARS[@]}"
do do
temp=$(eval "echo \$$ENV_VAR") if [[ -z "${!ENV_VAR}" ]]; then
if [[ -z "${temp}" ]]; then
eval "export $ENV_VAR=$(uuidgen | sed -e 's/-//g')" eval "export $ENV_VAR=$(uuidgen | sed -e 's/-//g')"
fi fi
done done

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.30.3", "version": "2.30.7",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -2,7 +2,7 @@ import { testEnv } from "../../../tests/extra"
import * as context from "../" import * as context from "../"
import { DEFAULT_TENANT_ID } from "../../constants" import { DEFAULT_TENANT_ID } from "../../constants"
import { structures } from "../../../tests" import { structures } from "../../../tests"
import { db } from "../.." import * as db from "../../db"
import Context from "../Context" import Context from "../Context"
import { ContextMap } from "../types" import { ContextMap } from "../types"
import { IdentityType } from "@budibase/types" import { IdentityType } from "@budibase/types"

View File

@ -159,8 +159,9 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
specificallySetFalse.add(feature) specificallySetFalse.add(feature)
} }
// ignore unknown flags
if (!this.isFlagName(feature)) { if (!this.isFlagName(feature)) {
throw new Error(`Feature: ${feature} is not an allowed option`) continue
} }
if (typeof flagValues[feature] !== "boolean") { if (typeof flagValues[feature] !== "boolean") {
@ -169,7 +170,7 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// @ts-expect-error - TS does not like you writing into a generic type, // @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object. // but we know that it's okay in this case because it's just an object.
flagValues[feature] = value flagValues[feature as keyof FlagValues] = value
tags[`flags.${feature}.source`] = "environment" tags[`flags.${feature}.source`] = "environment"
} }
} }
@ -265,9 +266,5 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// All of the machinery in this file is to make sure that flags have their // All of the machinery in this file is to make sure that flags have their
// default values set correctly and their types flow through the system. // default values set correctly and their types flow through the system.
export const flags = new FlagSet({ export const flags = new FlagSet({
LICENSING: Flag.boolean(false),
GOOGLE_SHEETS: Flag.boolean(false),
USER_GROUPS: Flag.boolean(false),
ONBOARDING_TOUR: Flag.boolean(false),
DEFAULT_VALUES: Flag.boolean(false), DEFAULT_VALUES: Flag.boolean(false),
}) })

View File

@ -1,6 +1,6 @@
import { IdentityContext, IdentityType, UserCtx } from "@budibase/types" import { IdentityContext, IdentityType, UserCtx } from "@budibase/types"
import { Flag, FlagSet, FlagValues, init, shutdown } from "../" import { Flag, FlagSet, FlagValues, init, shutdown } from "../"
import { context } from "../.." import * as context from "../../context"
import environment, { withEnv } from "../../environment" import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
import nock from "nock" import nock from "nock"
@ -67,9 +67,9 @@ describe("feature flags", () => {
expected: flags.defaults(), expected: flags.defaults(),
}, },
{ {
it: "should fail when an environment flag is not recognised", it: "should ignore unknown feature flags",
environmentFlags: "default:TEST_BOOLEAN,default:FOO", environmentFlags: "default:TEST_BOOLEAN,default:FOO",
errorMessage: "Feature: FOO is not an allowed option", expected: { TEST_BOOLEAN: true },
}, },
{ {
it: "should be able to read boolean flags from PostHog", it: "should be able to read boolean flags from PostHog",

View File

@ -1,7 +1,7 @@
import { GenericContainer, StartedTestContainer } from "testcontainers" import { GenericContainer, StartedTestContainer } from "testcontainers"
import { generator, structures } from "../../../tests" import { generator, structures } from "../../../tests"
import RedisWrapper, { closeAll } from "../redis" import RedisWrapper, { closeAll } from "../redis"
import { env } from "../.." import env from "../../environment"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
jest.setTimeout(30000) jest.setTimeout(30000)

View File

@ -46,7 +46,7 @@
import { RowUtils } from "@budibase/frontend-core" import { RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte" import OptionsEditor from "./OptionsEditor.svelte"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled } from "helpers/featureFlags"
const AUTO_TYPE = FieldType.AUTO const AUTO_TYPE = FieldType.AUTO
const FORMULA_TYPE = FieldType.FORMULA const FORMULA_TYPE = FieldType.FORMULA
@ -168,8 +168,7 @@
$: canBeDisplay = $: canBeDisplay =
canBeDisplayColumn(editableColumn.type) && !editableColumn.autocolumn canBeDisplayColumn(editableColumn.type) && !editableColumn.autocolumn
$: canHaveDefault = $: canHaveDefault =
isEnabled(TENANT_FEATURE_FLAGS.DEFAULT_VALUES) && isEnabled("DEFAULT_VALUES") && canHaveDefaultColumn(editableColumn.type)
canHaveDefaultColumn(editableColumn.type)
$: canBeRequired = $: canBeRequired =
editableColumn?.type !== LINK_TYPE && editableColumn?.type !== LINK_TYPE &&
!uneditable && !uneditable &&

View File

@ -1,7 +1,6 @@
<script> <script>
import FontAwesomeIcon from "./FontAwesomeIcon.svelte" import FontAwesomeIcon from "./FontAwesomeIcon.svelte"
import { Popover, Heading, Body } from "@budibase/bbui" import { Popover, Heading, Body } from "@budibase/bbui"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { licensing } from "stores/portal" import { licensing } from "stores/portal"
import { isPremiumOrAbove } from "helpers/planTitle" import { isPremiumOrAbove } from "helpers/planTitle"
import { ChangelogURL } from "constants" import { ChangelogURL } from "constants"
@ -62,31 +61,26 @@
<Body size="S">Budibase University</Body> <Body size="S">Budibase University</Body>
</a> </a>
<div class="divider" /> <div class="divider" />
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING)} <a
<a href={premiumOrAboveLicense
href={premiumOrAboveLicense ? "mailto:support@budibase.com"
? "mailto:support@budibase.com" : "/builder/portal/account/usage"}
: "/builder/portal/account/usage"} >
> <div class="premiumLinkContent" class:disabled={!premiumOrAboveLicense}>
<div <div class="icon">
class="premiumLinkContent" <FontAwesomeIcon name="fa-solid fa-envelope" />
class:disabled={!premiumOrAboveLicense}
>
<div class="icon">
<FontAwesomeIcon name="fa-solid fa-envelope" />
</div>
<Body size="S">Email support</Body>
</div> </div>
{#if !premiumOrAboveLicense} <Body size="S">Email support</Body>
<div class="premiumBadge"> </div>
<div class="icon"> {#if !premiumOrAboveLicense}
<FontAwesomeIcon name="fa-solid fa-lock" /> <div class="premiumBadge">
</div> <div class="icon">
<Body size="XS">Premium</Body> <FontAwesomeIcon name="fa-solid fa-lock" />
</div> </div>
{/if} <Body size="XS">Premium</Body>
</a> </div>
{/if} {/if}
</a>
</nav> </nav>
</Popover> </Popover>
</div> </div>

View File

@ -6,8 +6,8 @@
export let onConfirm export let onConfirm
export let onCancel export let onCancel
export let screenUrl export let route
export let screenRole export let role
export let confirmText = "Continue" export let confirmText = "Continue"
const appPrefix = "/app" const appPrefix = "/app"
@ -15,17 +15,17 @@
let error let error
let modal let modal
$: appUrl = screenUrl $: appUrl = route
? `${window.location.origin}${appPrefix}${screenUrl}` ? `${window.location.origin}${appPrefix}${route}`
: `${window.location.origin}${appPrefix}` : `${window.location.origin}${appPrefix}`
const routeChanged = event => { const routeChanged = event => {
if (!event.detail.startsWith("/")) { if (!event.detail.startsWith("/")) {
screenUrl = "/" + event.detail route = "/" + event.detail
} }
touched = true touched = true
screenUrl = sanitizeUrl(screenUrl) route = sanitizeUrl(route)
if (routeExists(screenUrl)) { if (routeExists(route)) {
error = "This URL is already taken for this access role" error = "This URL is already taken for this access role"
} else { } else {
error = null error = null
@ -33,19 +33,19 @@
} }
const routeExists = url => { const routeExists = url => {
if (!screenRole) { if (!role) {
return false return false
} }
return get(screenStore).screens.some( return get(screenStore).screens.some(
screen => screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() && screen.routing.route.toLowerCase() === url.toLowerCase() &&
screen.routing.roleId === screenRole screen.routing.roleId === role
) )
} }
const confirmScreenDetails = async () => { const confirmScreenDetails = async () => {
await onConfirm({ await onConfirm({
screenUrl, route,
}) })
} }
</script> </script>
@ -58,13 +58,13 @@
onConfirm={confirmScreenDetails} onConfirm={confirmScreenDetails}
{onCancel} {onCancel}
cancelText={"Back"} cancelText={"Back"}
disabled={!screenUrl || error || !touched} disabled={!route || error || !touched}
> >
<form on:submit|preventDefault={() => modal.confirm()}> <form on:submit|preventDefault={() => modal.confirm()}>
<Input <Input
label="Enter a URL for the new screen" label="Enter a URL for the new screen"
{error} {error}
bind:value={screenUrl} bind:value={route}
on:change={routeChanged} on:change={routeChanged}
/> />
<div class="app-server" title={appUrl}> <div class="app-server" title={appUrl}>

View File

@ -34,6 +34,7 @@
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte" import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "api" import { API } from "api"
import { datasourceSelect as format } from "helpers/data/format"
export let value = {} export let value = {}
export let otherSources export let otherSources
@ -51,24 +52,15 @@
let modal let modal
$: text = value?.label ?? "Choose an option" $: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list.map(m => ({ $: tables = $tablesStore.list.map(table =>
label: m.name, format.table(table, $datasources.list)
tableId: m._id, )
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,
label: view.name, label: view.name,
type: "view", type: "view",
})) }))
$: viewsV2 = $viewsV2Store.list.map(view => ({ $: viewsV2 = $viewsV2Store.list.map(format.viewV2)
...view,
label: view.name,
type: "viewV2",
}))
$: views = [...(viewsV1 || []), ...(viewsV2 || [])] $: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list $: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable) .filter(q => showAllQueries || q.queryVerb === "read" || q.readable)

View File

@ -2,24 +2,14 @@
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore, viewsV2 } from "stores/builder" import { tables as tablesStore, viewsV2 } from "stores/builder"
import { tableSelect as format } from "helpers/data/format"
export let value export let value
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(table => ({ $: tables = $tablesStore.list.map(format.table)
type: "table", $: views = $viewsV2.list.map(format.viewV2)
label: table.name,
tableId: table._id,
resourceId: table._id,
}))
$: views = $viewsV2.list.map(view => ({
type: "viewV2",
id: view.id,
label: view.name,
tableId: view.tableId,
resourceId: view.id,
}))
$: options = [...(tables || []), ...(views || [])] $: options = [...(tables || []), ...(views || [])]
const onChange = e => { const onChange = e => {

View File

@ -7,7 +7,6 @@
import { ExpiringKeys } from "./constants" import { ExpiringKeys } from "./constants"
import { getBanners } from "./licensingBanners" import { getBanners } from "./licensingBanners"
import { banner } from "@budibase/bbui" import { banner } from "@budibase/bbui"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
const oneDayInSeconds = 86400 const oneDayInSeconds = 86400
@ -89,8 +88,7 @@
userLoaded && userLoaded &&
$licensing.usageMetrics && $licensing.usageMetrics &&
domLoaded && domLoaded &&
!licensingLoaded && !licensingLoaded
isEnabled(TENANT_FEATURE_FLAGS.LICENSING)
) { ) {
licensingLoaded = true licensingLoaded = true
queuedModals = processModals() queuedModals = processModals()

View File

@ -0,0 +1,31 @@
export const datasourceSelect = {
table: (table, datasources) => ({
label: table.name,
tableId: table._id,
type: "table",
datasource: datasources.find(
datasource => datasource._id === table.sourceId || table.datasourceId
),
}),
viewV2: view => ({
...view,
label: view.name,
type: "viewV2",
}),
}
export const tableSelect = {
table: table => ({
type: "table",
label: table.name,
tableId: table._id,
resourceId: table._id,
}),
viewV2: view => ({
type: "viewV2",
id: view.id,
label: view.name,
tableId: view.tableId,
resourceId: view.id,
}),
}

View File

@ -1,14 +1,6 @@
import { auth } from "../stores/portal" import { auth } from "../stores/portal"
import { get } from "svelte/store" import { get } from "svelte/store"
export const TENANT_FEATURE_FLAGS = {
LICENSING: "LICENSING",
USER_GROUPS: "USER_GROUPS",
ONBOARDING_TOUR: "ONBOARDING_TOUR",
GOOGLE_SHEETS: "GOOGLE_SHEETS",
DEFAULT_VALUES: "DEFAULT_VALUES",
}
export const isEnabled = featureFlag => { export const isEnabled = featureFlag => {
const user = get(auth).user const user = get(auth).user
return !!user?.flags?.[featureFlag] return !!user?.flags?.[featureFlag]

View File

@ -9,7 +9,6 @@
deploymentStore, deploymentStore,
} from "stores/builder" } from "stores/builder"
import { auth, appsStore } from "stores/portal" import { auth, appsStore } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
Icon, Icon,
Tabs, Tabs,
@ -90,16 +89,14 @@
const initTour = async () => { const initTour = async () => {
// Check if onboarding is enabled. // Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) { if (!$auth.user?.onboardedAt) {
if (!$auth.user?.onboardedAt) { builderStore.startBuilderOnboarding()
builderStore.startBuilderOnboarding() } else {
} else { // Feature tour date
// Feature tour date const release_date = new Date("2023-03-01T00:00:00.000Z")
const release_date = new Date("2023-03-01T00:00:00.000Z") const onboarded = new Date($auth.user?.onboardedAt)
const onboarded = new Date($auth.user?.onboardedAt) if (onboarded < release_date) {
if (onboarded < release_date) { builderStore.setTour(TOUR_KEYS.FEATURE_ONBOARDING)
builderStore.setTour(TOUR_KEYS.FEATURE_ONBOARDING)
}
} }
} }
} }

View File

@ -20,7 +20,7 @@
let confirmDeleteDialog let confirmDeleteDialog
let screenDetailsModal let screenDetailsModal
const createDuplicateScreen = async ({ screenName, screenUrl }) => { const createDuplicateScreen = async ({ route }) => {
// Create a dupe and ensure it is unique // Create a dupe and ensure it is unique
let duplicateScreen = Helpers.cloneDeep(screen) let duplicateScreen = Helpers.cloneDeep(screen)
delete duplicateScreen._id delete duplicateScreen._id
@ -28,9 +28,8 @@
duplicateScreen.props = makeComponentUnique(duplicateScreen.props) duplicateScreen.props = makeComponentUnique(duplicateScreen.props)
// Attach the new name and URL // Attach the new name and URL
duplicateScreen.routing.route = sanitizeUrl(screenUrl) duplicateScreen.routing.route = sanitizeUrl(route)
duplicateScreen.routing.homeScreen = false duplicateScreen.routing.homeScreen = false
duplicateScreen.props._instanceName = screenName
try { try {
// Create the screen // Create the screen
@ -136,8 +135,8 @@
<Modal bind:this={screenDetailsModal}> <Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal <ScreenDetailsModal
onConfirm={createDuplicateScreen} onConfirm={createDuplicateScreen}
screenUrl={screen?.routing.route} route={screen?.routing.route}
screenRole={screen?.routing.roleId} role={screen?.routing.roleId}
confirmText="Duplicate" confirmText="Duplicate"
/> />
</Modal> </Modal>

View File

@ -1,8 +1,9 @@
<script> <script>
import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte" import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte"
import DatasourceModal from "./DatasourceModal.svelte" import DatasourceModal from "./DatasourceModal.svelte"
import sanitizeUrl from "helpers/sanitizeUrl" import TypeModal from "./TypeModal.svelte"
import FormTypeModal from "./FormTypeModal.svelte" import tableTypes from "./tableTypes"
import formTypes from "./formTypes"
import { Modal, notifications } from "@budibase/bbui" import { Modal, notifications } from "@budibase/bbui"
import { import {
screenStore, screenStore,
@ -11,14 +12,9 @@
builderStore, builderStore,
} from "stores/builder" } from "stores/builder"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { get } from "svelte/store"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import blankScreen from "templates/blankScreen" import * as screenTemplating from "templates/screenTemplating"
import formScreen from "templates/formScreen"
import gridScreen from "templates/gridScreen"
import gridDetailsScreen from "templates/gridDetailsScreen"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
let mode let mode
@ -26,16 +22,19 @@
let screenDetailsModal let screenDetailsModal
let datasourceModal let datasourceModal
let formTypeModal let formTypeModal
let tableTypeModal
let selectedTablesAndViews = [] let selectedTablesAndViews = []
let permissions = {} let permissions = {}
$: screens = $screenStore.screens
export const show = newMode => { export const show = newMode => {
mode = newMode mode = newMode
selectedTablesAndViews = [] selectedTablesAndViews = []
permissions = {} permissions = {}
if (mode === "grid" || mode === "gridDetails" || mode === "form") { if (mode === "table" || mode === "form") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
screenDetailsModal.show() screenDetailsModal.show()
@ -44,136 +43,83 @@
} }
} }
const createScreen = async screen => { const createScreen = async screenTemplate => {
try { try {
// Check we aren't clashing with an existing URL return await screenStore.save(screenTemplate)
if (hasExistingUrl(screen.routing.route, screen.routing.roleId)) {
let suffix = 2
let candidateUrl = makeCandidateUrl(screen, suffix)
while (hasExistingUrl(candidateUrl, screen.routing.roleId)) {
candidateUrl = makeCandidateUrl(screen, ++suffix)
}
screen.routing.route = candidateUrl
}
screen.routing.route = sanitizeUrl(screen.routing.route)
return await screenStore.save(screen)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error("Error creating screens") notifications.error("Error creating screens")
} }
} }
const addNavigationLink = async screen => const createScreens = async screenTemplates => {
await navigationStore.saveLink( const newScreens = []
screen.routing.route,
capitalise(screen.routing.route.split("/")[1]),
screen.routing.roleId
)
// Checks if any screens exist in the store with the given route and for (let screenTemplate of screenTemplates) {
// currently selected role await addNavigationLink(
const hasExistingUrl = (url, screenAccessRole) => { screenTemplate.data,
const screens = get(screenStore).screens.filter( screenTemplate.navigationLinkLabel
s => s.routing.roleId === screenAccessRole )
) newScreens.push(await createScreen(screenTemplate.data))
return !!screens.find(s => s.routing?.route === url) }
return newScreens
} }
// Constructs a candidate URL for a new screen, appending a given suffix to the const addNavigationLink = async (screen, linkLabel) => {
// screen's URL if (linkLabel == null) return
// e.g. "/sales/:id" => "/sales-1/:id"
const makeCandidateUrl = (screen, suffix) => { await navigationStore.saveLink(
let url = screen.routing?.route || "" screen.routing.route,
if (url.startsWith("/")) { linkLabel,
url = url.slice(1) screen.routing.roleId
} )
if (!url.includes("/")) {
return `/${url}-${suffix}`
} else {
const split = url.split("/")
return `/${split[0]}-${suffix}/${split.slice(1).join("/")}`
}
} }
const onSelectDatasources = async () => { const onSelectDatasources = async () => {
if (mode === "form") { if (mode === "form") {
formTypeModal.show() formTypeModal.show()
} else if (mode === "grid") { } else if (mode === "table") {
await createGridScreen() tableTypeModal.show()
} else if (mode === "gridDetails") {
await createGridDetailsScreen()
} }
} }
const createBlankScreen = async ({ screenUrl }) => { const createBlankScreen = async ({ route }) => {
const screenTemplate = blankScreen(screenUrl) const screenTemplates = screenTemplating.blank({ route, screens })
const screen = await createScreen(screenTemplate)
await addNavigationLink(screenTemplate)
loadNewScreen(screen) const newScreens = await createScreens(screenTemplates)
loadNewScreen(newScreens[0])
} }
const createGridScreen = async () => { const createTableScreen = async type => {
let firstScreen = null const screenTemplates = selectedTablesAndViews.flatMap(tableOrView =>
screenTemplating.table({
for (let tableOrView of selectedTablesAndViews) { screens,
const screenTemplate = gridScreen(
tableOrView, tableOrView,
permissions[tableOrView.id] type,
) permissions: permissions[tableOrView.id],
})
)
const screen = await createScreen(screenTemplate) const newScreens = await createScreens(screenTemplates)
await addNavigationLink(screen) loadNewScreen(newScreens[0])
firstScreen ??= screen
}
loadNewScreen(firstScreen)
} }
const createGridDetailsScreen = async () => { const createFormScreen = async type => {
let firstScreen = null const screenTemplates = selectedTablesAndViews.flatMap(tableOrView =>
screenTemplating.form({
for (let tableOrView of selectedTablesAndViews) { screens,
const screenTemplate = gridDetailsScreen(
tableOrView, tableOrView,
permissions[tableOrView.id] type,
) permissions: permissions[tableOrView.id],
})
)
const screen = await createScreen(screenTemplate) const newScreens = await createScreens(screenTemplates)
await addNavigationLink(screen)
firstScreen ??= screen if (type === "update" || type === "create") {
}
loadNewScreen(firstScreen)
}
const createFormScreen = async formType => {
let firstScreen = null
for (let tableOrView of selectedTablesAndViews) {
const screenTemplate = formScreen(
tableOrView,
formType,
permissions[tableOrView.id]
)
const screen = await createScreen(screenTemplate)
// Only add a navigation link for `Create`, as both `Update` and `View`
// require an `id` in their URL in order to function.
if (formType === "Create") {
await addNavigationLink(screen)
}
firstScreen ??= screen
}
if (formType === "Update" || formType === "Create") {
const associatedTour = const associatedTour =
formType === "Update" type === "update"
? TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE ? TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE
: TOUR_KEYS.BUILDER_FORM_CREATE : TOUR_KEYS.BUILDER_FORM_CREATE
@ -183,7 +129,7 @@
} }
} }
loadNewScreen(firstScreen) loadNewScreen(newScreens[0])
} }
const loadNewScreen = screen => { const loadNewScreen = screen => {
@ -199,7 +145,11 @@
} }
const fetchPermission = resourceId => { const fetchPermission = resourceId => {
permissions[resourceId] = { loading: true, read: null, write: null } permissions[resourceId] = {
loading: true,
read: Roles.BASIC,
write: Roles.BASIC,
}
permissionsStore permissionsStore
.forResource(resourceId) .forResource(resourceId)
@ -218,8 +168,8 @@
if (permissions[resourceId]?.loading) { if (permissions[resourceId]?.loading) {
permissions[resourceId] = { permissions[resourceId] = {
loading: false, loading: false,
read: Roles.PUBLIC, read: Roles.BASIC,
write: Roles.PUBLIC, write: Roles.BASIC,
} }
} }
}) })
@ -250,18 +200,31 @@
<Modal bind:this={datasourceModal} autoFocus={false}> <Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal <DatasourceModal
{selectedTablesAndViews} {selectedTablesAndViews}
{permissions}
onConfirm={onSelectDatasources} onConfirm={onSelectDatasources}
on:toggle={handleTableOrViewToggle} on:toggle={handleTableOrViewToggle}
/> />
</Modal> </Modal>
<Modal bind:this={tableTypeModal}>
<TypeModal
title="Choose how you want to manage rows"
types={tableTypes}
onConfirm={createTableScreen}
onCancel={() => {
tableTypeModal.hide()
datasourceModal.show()
}}
/>
</Modal>
<Modal bind:this={screenDetailsModal}> <Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal onConfirm={createBlankScreen} /> <ScreenDetailsModal onConfirm={createBlankScreen} />
</Modal> </Modal>
<Modal bind:this={formTypeModal}> <Modal bind:this={formTypeModal}>
<FormTypeModal <TypeModal
title="Select form type"
types={formTypes}
onConfirm={createFormScreen} onConfirm={createFormScreen}
onCancel={() => { onCancel={() => {
formTypeModal.hide() formTypeModal.hide()

View File

@ -1,14 +1,14 @@
<script> <script>
import { ModalContent, Layout, notifications, Body } from "@budibase/bbui" import { Body, ModalContent, Layout, notifications } from "@budibase/bbui"
import { datasources as datasourcesStore } from "stores/builder" import { datasources as datasourcesStore } from "stores/builder"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { IntegrationNames } from "constants" import { IntegrationNames } from "constants"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import TableOrViewOption from "./TableOrViewOption.svelte" import TableOrViewOption from "./TableOrViewOption.svelte"
import * as format from "helpers/data/format"
export let onConfirm export let onConfirm
export let selectedTablesAndViews export let selectedTablesAndViews
export let permissions
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -21,38 +21,37 @@
icon: "Remove", icon: "Remove",
name: view.name, name: view.name,
id: view.id, id: view.id,
clientData: { tableSelectFormat: format.tableSelect.viewV2(view),
...view, datasourceSelectFormat: format.datasourceSelect.viewV2(view),
type: "viewV2",
label: view.name,
},
})) }))
} }
const getTablesAndViews = datasource => { const getTablesAndViews = datasource => {
let tablesAndViews = [] let tablesAndViews = []
const rawTables = Array.isArray(datasource.entities) const tables = Array.isArray(datasource.entities)
? datasource.entities ? datasource.entities
: Object.values(datasource.entities ?? {}) : Object.values(datasource.entities ?? {})
for (const rawTable of rawTables) { for (const table of tables) {
if (rawTable._id === "ta_users") { if (table._id === "ta_users") {
continue continue
} }
const table = { const formattedTable = {
icon: "Table", icon: "Table",
name: rawTable.name, name: table.name,
id: rawTable._id, id: table._id,
clientData: { tableSelectFormat: format.tableSelect.table(table),
...rawTable, datasourceSelectFormat: format.datasourceSelect.table(
label: rawTable.name, table,
tableId: rawTable._id, $datasourcesStore.list
type: "table", ),
},
} }
tablesAndViews = tablesAndViews.concat([table, ...getViews(rawTable)]) tablesAndViews = tablesAndViews.concat([
formattedTable,
...getViews(table),
])
} }
return tablesAndViews return tablesAndViews
@ -96,60 +95,76 @@
}) })
</script> </script>
<span> <ModalContent
<ModalContent title="Autogenerated screens"
title="Autogenerated screens" confirmText="Next"
confirmText="Confirm" cancelText="Cancel"
cancelText="Back" {onConfirm}
{onConfirm} disabled={!selectedTablesAndViews.length}
disabled={!selectedTablesAndViews.length} size="L"
size="L" >
> <Body size="S">
<Body size="S"> Select which datasources you would like to use to create your screens
Select which datasources you would like to use to create your screens </Body>
</Body> <Layout noPadding gap="S">
<Layout noPadding gap="S"> {#each datasources as datasource}
{#each datasources as datasource} <div class="datasource">
<div class="data-source-wrap"> <div class="header">
<div class="data-source-header"> <svelte:component
<svelte:component this={datasource.iconComponent}
this={datasource.iconComponent} height="18"
height="24" width="18"
width="24" />
/> <h2>{datasource.name}</h2>
<div class="data-source-name">{datasource.name}</div>
</div>
<!-- List all tables -->
{#each datasource.tablesAndViews as tableOrView}
{@const selected = selectedTablesAndViews.some(
selected => selected.id === tableOrView.id
)}
<TableOrViewOption
roles={permissions[tableOrView.id]}
on:click={() => toggleSelection(tableOrView)}
{selected}
{tableOrView}
/>
{/each}
</div> </div>
{/each} <!-- List all tables -->
</Layout> {#each datasource.tablesAndViews as tableOrView}
</ModalContent> {@const selected = selectedTablesAndViews.some(
</span> selected => selected.id === tableOrView.id
)}
<TableOrViewOption
on:click={() => toggleSelection(tableOrView)}
{selected}
{tableOrView}
/>
{/each}
</div>
{/each}
</Layout>
</ModalContent>
<style> <style>
.data-source-wrap { .datasource {
padding-bottom: var(--spectrum-alias-item-padding-s); padding-bottom: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
} }
.data-source-header {
.datasource:last-child {
padding-bottom: 0;
}
.header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-m); padding-bottom: var(--spacing-m);
padding-bottom: var(--spacing-xs); }
.header :global(svg) {
flex-shrink: 0;
}
.header h2 {
padding-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 400;
margin: 0;
margin-left: 10px;
} }
</style> </style>

View File

@ -1,115 +0,0 @@
<script>
import { ModalContent, Layout, Body, Icon } from "@budibase/bbui"
let type = null
export let onCancel = () => {}
export let onConfirm = () => {}
</script>
<span>
<ModalContent
title="Select form type"
confirmText="Done"
cancelText="Back"
onConfirm={() => onConfirm(type)}
{onCancel}
disabled={!type}
size="L"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Layout noPadding gap="S">
<div
class="form-type"
class:selected={type === "Create"}
on:click={() => (type = "Create")}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>Create a new row</Body>
<Body size="S">
For capturing and storing new data from your users
</Body>
</div>
{#if type === "Create"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
<div
class="form-type"
class:selected={type === "Update"}
on:click={() => (type = "Update")}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>Update an existing row</Body>
<Body size="S">For viewing and updating existing data</Body>
</div>
{#if type === "Update"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
<div
class="form-type"
class:selected={type === "View"}
on:click={() => (type = "View")}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>View an existing row</Body>
<Body size="S">For a read only view of your data</Body>
</div>
{#if type === "View"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
</Layout>
</ModalContent>
</span>
<style>
.form-type {
cursor: pointer;
gap: var(--spacing-s);
padding: var(--spacing-m) var(--spacing-xl);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.selected,
.form-type:hover {
background: var(--spectrum-alias-background-color-tertiary);
}
.form-type-wrap {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-type :global(p:nth-child(2)) {
color: var(--grey-6);
}
.form-type-check {
margin-left: auto;
}
.form-type-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
}
.form-type-content {
gap: var(--spacing-s);
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,49 +1,14 @@
<script> <script>
import { Icon, AbsTooltip } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import RoleIcon from "components/common/RoleIcon.svelte"
export let tableOrView export let tableOrView
export let roles
export let selected = false export let selected = false
$: hideRoles = roles == undefined || roles?.loading
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div role="button" tabindex="0" class="datasource" class:selected on:click> <div role="button" tabindex="0" class="datasource" class:selected on:click>
<div class="content"> <Icon name={tableOrView.icon} />
<Icon name={tableOrView.icon} /> <span>{tableOrView.name}</span>
<span>{tableOrView.name}</span>
</div>
<div class:hideRoles class="roles">
<AbsTooltip
type="info"
text={`Screens that only read data will be generated with access "${roles?.read?.toLowerCase()}"`}
>
<div class="role">
<span>read</span>
<RoleIcon
size="XS"
id={roles?.read}
disabled={roles?.loading !== false}
/>
</div>
</AbsTooltip>
<AbsTooltip
type="info"
text={`Screens that write data will be generated with access "${roles?.write?.toLowerCase()}"`}
>
<div class="role">
<span>write</span>
<RoleIcon
size="XS"
id={roles?.write}
disabled={roles?.loading !== false}
/>
</div>
</AbsTooltip>
</div>
</div> </div>
<style> <style>
@ -52,18 +17,8 @@
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
transition: 160ms all; transition: 160ms all;
border-radius: 4px; border-radius: 4px;
display: flex;
align-items: center;
user-select: none; user-select: none;
background-color: var(--background);
}
.datasource :global(svg) {
transition: 160ms all;
color: var(--spectrum-global-color-gray-600);
}
.content {
padding: var(--spectrum-alias-item-padding-s); padding: var(--spectrum-alias-item-padding-s);
display: flex; display: flex;
align-items: center; align-items: center;
@ -71,7 +26,12 @@
min-width: 0; min-width: 0;
} }
.content span { .datasource :global(svg) {
transition: 160ms all;
color: var(--spectrum-global-color-gray-600);
}
.datasource span {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -84,29 +44,4 @@
.selected { .selected {
border: 1px solid var(--blue) !important; border: 1px solid var(--blue) !important;
} }
.roles {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: end;
padding-right: var(--spectrum-alias-item-padding-s);
opacity: 0.5;
transition: opacity 160ms;
}
.hideRoles {
opacity: 0;
pointer-events: none;
}
.role {
display: flex;
align-items: center;
}
.role span {
font-size: 11px;
margin-right: 5px;
}
</style> </style>

View File

@ -0,0 +1,82 @@
<script>
import { ModalContent, Layout, Body } from "@budibase/bbui"
let selectedType = null
export let title
export let types
export let onCancel = () => {}
export let onConfirm = () => {}
</script>
<ModalContent
{title}
confirmText="Done"
cancelText="Back"
onConfirm={() => onConfirm(selectedType)}
{onCancel}
disabled={!selectedType}
size="L"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Layout noPadding gap="S">
{#each types as type}
<div
class="type"
class:selected={selectedType === type.id}
on:click={() => (selectedType = type.id)}
>
<div class="image">
<img alt={type.img.alt} src={type.img.src} />
</div>
<div class="typeContent">
<Body noPadding>{type.title}</Body>
<Body size="S">{type.description}</Body>
</div>
</div>
{/each}
</Layout>
</ModalContent>
<style>
.type {
cursor: pointer;
gap: var(--spacing-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
overflow: hidden;
}
.type:hover {
border: 1px solid var(--grey-5);
}
.type.selected {
border: 1px solid var(--blue);
}
.type :global(p:nth-child(2)) {
color: var(--grey-6);
}
.typeContent {
box-sizing: border-box;
padding: var(--spacing-m) var(--spacing-xl);
flex-grow: 1;
gap: var(--spacing-s);
display: flex;
flex-direction: column;
}
.image {
min-width: 133px;
height: 73px;
background-color: var(--grey-2);
}
.image img {
height: 100%;
}
</style>

View File

@ -0,0 +1,35 @@
import formView from "./images/formView.svg"
import formUpdate from "./images/formUpdate.svg"
import formCreate from "./images/formCreate.svg"
const tableTypes = [
{
id: "create",
img: {
alt: "A form containing new data",
src: formCreate,
},
title: "Create a new row",
description: "For capturing and storing new data from your users",
},
{
id: "update",
img: {
alt: "A form containing edited data",
src: formUpdate,
},
title: "Update an existing row",
description: "For viewing and updating existing data",
},
{
id: "view",
img: {
alt: "A form containing read-only data",
src: formView,
},
title: "View an existing row",
description: "For a read only view of your data",
},
]
export default tableTypes

View File

@ -0,0 +1,15 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#34BB84"/>
<rect width="118" height="65" fill="#34BB84"/>
<mask id="path-3-inside-1_51_2" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_51_2)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_51_2)"/>
<defs>
<linearGradient id="paint0_linear_51_2" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.8"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#FD4F01"/>
<rect width="118" height="65" fill="#FD4F01"/>
<mask id="path-3-inside-1_49_949" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_49_949)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_49_949)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.97"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.97"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.97"/>
<defs>
<linearGradient id="paint0_linear_49_949" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#FD4F01"/>
<rect width="118" height="65" fill="#FD4F01"/>
<mask id="path-3-inside-1_49_940" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_49_940)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_49_940)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.97"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.5"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_49_940" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#FD4F01"/>
<rect width="118" height="65" fill="#FD4F01"/>
<mask id="path-3-inside-1_49_931" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_49_931)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_49_931)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.5"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.5"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_49_931" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,44 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-2-inside-1_4_100" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_4_100)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-2-inside-1_4_100)"/>
<path d="M35.6901 23.0003H44.8169V28.0003H35.6901V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M35.6901 28.5004H44.8169V33.5004H35.6901V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 34H44.8169V39H35.6901V34Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 39.5002H44.8169V44.5002H35.6901V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 45.0003H44.8169V50.0003H35.6901V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 50.5004H44.8169V55.5004H35.6901V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 23.0003H54.4502V28.0003H45.3234V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M45.3234 28.5004H54.4502V33.5004H45.3234V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 34H54.4502V39H45.3234V34Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 39.5002H54.4502V44.5002H45.3234V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 45.0003H54.4502V50.0003H45.3234V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 50.5004H54.4502V55.5004H45.3234V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 23.0003H64.0844V28.0003H54.9576V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M54.9576 28.5004H64.0844V33.5004H54.9576V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 34H64.0844V39H54.9576V34Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 39.5002H64.0844V44.5002H54.9576V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 45.0003H64.0844V50.0003H54.9576V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 50.5004H64.0844V55.5004H54.9576V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 23.0003H73.7183V28.0003H64.5915V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M64.5915 28.5004H73.7183V33.5004H64.5915V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 34H73.7183V39H64.5915V34Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 39.5002H73.7183V44.5002H64.5915V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 45.0003H73.7183V50.0003H64.5915V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 50.5004H73.7183V55.5004H64.5915V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 23.0003H83.3521V28.0003H74.2253V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M74.2253 28.5004H83.3521V33.5004H74.2253V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 34H83.3521V39H74.2253V34Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 39.5002H83.3521V44.5002H74.2253V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 45.0003H83.3521V50.0003H74.2253V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 50.5004H83.3521V55.5004H74.2253V50.5004Z" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_4_100" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,28 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-2-inside-1_4_138" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_4_138)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-2-inside-1_4_138)"/>
<g filter="url(#filter0_d_4_138)">
<rect x="42" y="17" width="33" height="44" fill="white" fill-opacity="0.4" shape-rendering="crispEdges"/>
<rect x="42.5" y="17.5" width="32" height="43" stroke="white" stroke-opacity="0.3" shape-rendering="crispEdges"/>
</g>
<defs>
<filter id="filter0_d_4_138" x="39" y="15" width="39" height="50" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4_138"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4_138" result="shape"/>
</filter>
<linearGradient id="paint0_linear_4_138" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-3-inside-1_56_20" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_56_20)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_56_20)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.97"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.5"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_56_20" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,28 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-2-inside-1_4_103" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_4_103)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-2-inside-1_4_103)"/>
<g filter="url(#filter0_d_4_103)">
<rect x="70" y="17" width="20" height="44" fill="white" fill-opacity="0.4" shape-rendering="crispEdges"/>
<rect x="70.5" y="17.5" width="19" height="43" stroke="white" stroke-opacity="0.3" shape-rendering="crispEdges"/>
</g>
<defs>
<filter id="filter0_d_4_103" x="67" y="15" width="26" height="50" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4_103"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4_103" result="shape"/>
</filter>
<linearGradient id="paint0_linear_4_103" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,10 +1,9 @@
<script> <script>
import { Body } from "@budibase/bbui" import { Body } from "@budibase/bbui"
import CreationPage from "components/common/CreationPage.svelte" import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./images/blank.png" import blank from "./images/blank.svg"
import tableInline from "./images/tableInline.png" import table from "./images/tableInline.svg"
import tableDetails from "./images/tableDetails.png" import form from "./images/formUpdate.svg"
import formImage from "./images/form.png"
import CreateScreenModal from "./CreateScreenModal.svelte" import CreateScreenModal from "./CreateScreenModal.svelte"
import { screenStore } from "stores/builder" import { screenStore } from "stores/builder"
@ -30,37 +29,27 @@
<div class="cards"> <div class="cards">
<div class="card" on:click={() => createScreenModal.show("blank")}> <div class="card" on:click={() => createScreenModal.show("blank")}>
<div class="image"> <div class="image">
<img alt="" src={blankImage} /> <img alt="A blank screen" src={blank} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Blank screen</Body> <Body size="S">Blank</Body>
<Body size="XS">Add an empty blank screen</Body> <Body size="XS">Add an empty blank screen</Body>
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("grid")}> <div class="card" on:click={() => createScreenModal.show("table")}>
<div class="image"> <div class="image">
<img alt="" src={tableInline} /> <img alt="A table of data" src={table} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Table with inline editing</Body> <Body size="S">Table</Body>
<Body size="XS">View, edit and delete rows inline</Body> <Body size="XS">List rows in a table</Body>
</div>
</div>
<div class="card" on:click={() => createScreenModal.show("gridDetails")}>
<div class="image">
<img alt="" src={tableDetails} />
</div>
<div class="text">
<Body size="S">Table with details panel</Body>
<Body size="XS">Manage your row details in a side panel</Body>
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("form")}> <div class="card" on:click={() => createScreenModal.show("form")}>
<div class="image"> <div class="image">
<img alt="" src={formImage} /> <img alt="A form containing data" src={form} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Form</Body> <Body size="S">Form</Body>
@ -114,8 +103,9 @@
} }
.card .image { .card .image {
min-height: 130px;
min-width: 235px; min-width: 235px;
height: 127px;
background-color: var(--grey-2);
} }
.text { .text {

View File

@ -0,0 +1,45 @@
import tableInline from "./images/tableInline.svg"
import tableSidePanel from "./images/tableSidePanel.svg"
import tableModal from "./images/tableModal.svg"
import tableNewScreen from "./images/tableNewScreen.svg"
const tableTypes = [
{
id: "inline",
img: {
alt: "A table of data",
src: tableInline,
},
title: "Inline",
description: "Manage data directly on your table",
},
{
id: "sidePanel",
img: {
alt: "A side panel",
src: tableSidePanel,
},
title: "Side panel",
description: "Open row details in a side panel",
},
{
id: "modal",
img: {
alt: "A modal",
src: tableModal,
},
title: "Modal",
description: "Open row details in a modal",
},
{
id: "newScreen",
img: {
alt: "A new screen",
src: tableNewScreen,
},
title: "New screen",
description: "View row details on a separate screen",
},
]
export default tableTypes

View File

@ -2,11 +2,10 @@
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { auth, admin, licensing } from "stores/portal" import { auth, admin, licensing } from "stores/portal"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
</script> </script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan && !$licensing.isEnterpriseTrial} {#if !$licensing.isEnterprisePlan && !$licensing.isEnterpriseTrial}
{#if $admin.cloud && $auth?.user?.accountPortalAccess} {#if $admin.cloud && $auth?.user?.accountPortalAccess}
<Button <Button
cta cta

View File

@ -1,6 +1,6 @@
import { v4 } from "uuid" import { v4 } from "uuid"
import { Component } from "templates/Component" import { Component } from "templates/Component"
import { Screen } from "templates/Screen" import { Screen } from "templates/screenTemplating/Screen"
import { get } from "svelte/store" import { get } from "svelte/store"
import { import {
BUDIBASE_INTERNAL_DB_ID, BUDIBASE_INTERNAL_DB_ID,

View File

@ -3,7 +3,6 @@ import { API } from "api"
import { auth, admin } from "stores/portal" import { auth, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "components/portal/licensing/constants" import { StripeStatus } from "components/portal/licensing/constants"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { PlanModel } from "@budibase/types" import { PlanModel } from "@budibase/types"
const UNLIMITED = -1 const UNLIMITED = -1
@ -183,93 +182,91 @@ export const createLicensingStore = () => {
return usersLimitExceeded(userCount, get(store).userLimit) return usersLimitExceeded(userCount, get(store).userLimit)
}, },
setUsageMetrics: async () => { setUsageMetrics: async () => {
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) { const usage = get(store).quotaUsage
const usage = get(store).quotaUsage const license = get(auth).user.license
const license = get(auth).user.license const now = new Date()
const now = new Date()
const getMetrics = (keys, license, quota) => { const getMetrics = (keys, license, quota) => {
if (!license || !quota || !keys) { if (!license || !quota || !keys) {
return {} return {}
}
return keys.reduce((acc, key) => {
const quotaLimit = license[key].value
const quotaUsed = (quota[key] / quotaLimit) * 100
acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1
return acc
}, {})
} }
const monthlyMetrics = getMetrics( return keys.reduce((acc, key) => {
["dayPasses", "queries", "automations"], const quotaLimit = license[key].value
license.quotas.usage.monthly, const quotaUsed = (quota[key] / quotaLimit) * 100
usage.monthly.current acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1
) return acc
const staticMetrics = getMetrics( }, {})
["apps", "rows"],
license.quotas.usage.static,
usage.usageQuota
)
const getDaysBetween = (dateStart, dateEnd) => {
return dateEnd > dateStart
? Math.round(
(dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds
)
: 0
}
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
license?.billing?.subscription?.downgradeAt &&
license?.billing?.subscription?.downgradeAt <= now.getTime() &&
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license?.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds =
license?.billing?.subscription?.downgradeAt
let pastDueDaysRemaining
let pastDueEndDate
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value
const userCount = usage.usageQuota.users
const userLimitReached = usersLimitReached(userCount, userLimit)
const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
store.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitReached,
errUserLimit,
}
})
} }
const monthlyMetrics = getMetrics(
["dayPasses", "queries", "automations"],
license.quotas.usage.monthly,
usage.monthly.current
)
const staticMetrics = getMetrics(
["apps", "rows"],
license.quotas.usage.static,
usage.usageQuota
)
const getDaysBetween = (dateStart, dateEnd) => {
return dateEnd > dateStart
? Math.round(
(dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds
)
: 0
}
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
license?.billing?.subscription?.downgradeAt &&
license?.billing?.subscription?.downgradeAt <= now.getTime() &&
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license?.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds =
license?.billing?.subscription?.downgradeAt
let pastDueDaysRemaining
let pastDueEndDate
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value
const userCount = usage.usageQuota.users
const userLimitReached = usersLimitReached(userCount, userLimit)
const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
store.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitReached,
errUserLimit,
}
})
}, },
} }

View File

@ -1,5 +1,4 @@
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { admin } from "./admin" import { admin } from "./admin"
import { auth } from "./auth" import { auth } from "./auth"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
@ -15,12 +14,10 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/users/users", href: "/builder/portal/users/users",
}, },
] ]
if (isEnabled(TENANT_FEATURE_FLAGS.USER_GROUPS)) { userSubPages.push({
userSubPages.push({ title: "Groups",
title: "Groups", href: "/builder/portal/users/groups",
href: "/builder/portal/users/groups", })
})
}
// Pages that all devs and admins can access // Pages that all devs and admins can access
let menu = [ let menu = [
@ -83,50 +80,48 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
} }
// Add account page // Add account page
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) { let accountSubPages = [
let accountSubPages = [ {
{ title: "Usage",
title: "Usage", href: "/builder/portal/account/usage",
href: "/builder/portal/account/usage", },
}, ]
] if (isAdmin) {
if (isAdmin) { accountSubPages.push({
accountSubPages.push({ title: "Audit Logs",
title: "Audit Logs", href: "/builder/portal/account/auditLogs",
href: "/builder/portal/account/auditLogs", })
})
if (!cloud) { if (!cloud) {
accountSubPages.push({
title: "System Logs",
href: "/builder/portal/account/systemLogs",
})
}
}
if (cloud && user?.accountPortalAccess) {
accountSubPages.push({ accountSubPages.push({
title: "Upgrade", title: "System Logs",
href: $admin?.accountPortalUrl + "/portal/upgrade", href: "/builder/portal/account/systemLogs",
})
} else if (!cloud && isAdmin) {
accountSubPages.push({
title: "Upgrade",
href: "/builder/portal/account/upgrade",
}) })
} }
// add license check here }
if (user?.accountPortalAccess && user.account.stripeCustomerId) { if (cloud && user?.accountPortalAccess) {
accountSubPages.push({ accountSubPages.push({
title: "Billing", title: "Upgrade",
href: $admin?.accountPortalUrl + "/portal/billing", href: $admin?.accountPortalUrl + "/portal/upgrade",
}) })
} } else if (!cloud && isAdmin) {
menu.push({ accountSubPages.push({
title: "Account", title: "Upgrade",
href: "/builder/portal/account", href: "/builder/portal/account/upgrade",
subPages: accountSubPages,
}) })
} }
// add license check here
if (user?.accountPortalAccess && user.account.stripeCustomerId) {
accountSubPages.push({
title: "Billing",
href: $admin?.accountPortalUrl + "/portal/billing",
})
}
menu.push({
title: "Account",
href: "/builder/portal/account",
subPages: accountSubPages,
})
return menu return menu
}) })

View File

@ -2,11 +2,11 @@ import { Helpers } from "@budibase/bbui"
import { BaseStructure } from "./BaseStructure" import { BaseStructure } from "./BaseStructure"
export class Component extends BaseStructure { export class Component extends BaseStructure {
constructor(name) { constructor(name, _id = Helpers.uuid()) {
super(false) super(false)
this._children = [] this._children = []
this._json = { this._json = {
_id: Helpers.uuid(), _id,
_component: name, _component: name,
_styles: { _styles: {
normal: {}, normal: {},
@ -50,4 +50,8 @@ export class Component extends BaseStructure {
this._json.text = text this._json.text = text
return this return this
} }
getId() {
return this._json._id
}
} }

View File

@ -1,11 +0,0 @@
import { Screen } from "./Screen"
const blankScreen = route => {
return new Screen()
.instanceName("New Screen")
.customProps({ layout: "grid" })
.route(route)
.json()
}
export default blankScreen

View File

@ -1,49 +0,0 @@
import { Screen } from "./Screen"
import { Component } from "./Component"
import sanitizeUrl from "helpers/sanitizeUrl"
export const FORM_TEMPLATE = "FORM_TEMPLATE"
export const formUrl = (tableOrView, actionType) => {
if (actionType === "Create") {
return sanitizeUrl(`/${tableOrView.name}/new`)
} else if (actionType === "Update") {
return sanitizeUrl(`/${tableOrView.name}/edit/:id`)
} else if (actionType === "View") {
return sanitizeUrl(`/${tableOrView.name}/view/:id`)
}
}
export const getRole = (permissions, actionType) => {
if (actionType === "View") {
return permissions.read
}
return permissions.write
}
const generateMultistepFormBlock = (tableOrView, actionType) => {
const multistepFormBlock = new Component(
"@budibase/standard-components/multistepformblock"
)
multistepFormBlock
.customProps({
actionType,
dataSource: tableOrView.clientData,
steps: [{}],
rowId: actionType === "new" ? undefined : `{{ url.id }}`,
})
.instanceName(`${tableOrView.name} - Multistep Form block`)
return multistepFormBlock
}
const createScreen = (tableOrView, actionType, permissions) => {
return new Screen()
.route(formUrl(tableOrView, actionType))
.instanceName(`${tableOrView.name} - Form`)
.role(getRole(permissions, actionType))
.autoTableId(tableOrView.id)
.addChild(generateMultistepFormBlock(tableOrView, actionType))
.json()
}
export default createScreen

View File

@ -1,30 +0,0 @@
import sanitizeUrl from "helpers/sanitizeUrl"
import { Screen } from "./Screen"
import { Component } from "./Component"
const gridUrl = tableOrView => sanitizeUrl(`/${tableOrView.name}`)
const createScreen = (tableOrView, permissions) => {
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
const gridBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${tableOrView.name} - Table`)
.customProps({
table: tableOrView.clientData,
})
return new Screen()
.route(gridUrl(tableOrView))
.instanceName(`${tableOrView.name} - List`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(heading)
.addChild(gridBlock)
.json()
}
export default createScreen

View File

@ -1,4 +1,4 @@
import { BaseStructure } from "./BaseStructure" import { BaseStructure } from "../BaseStructure"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
export class Screen extends BaseStructure { export class Screen extends BaseStructure {

View File

@ -0,0 +1,25 @@
import { Screen } from "./Screen"
import { capitalise } from "helpers"
import getValidRoute from "./getValidRoute"
import { Roles } from "constants/backend"
const blank = ({ route, screens }) => {
const validRoute = getValidRoute(screens, route, Roles.BASIC)
const template = new Screen()
.instanceName("Blank screen")
.customProps({ layout: "grid" })
.role(Roles.BASIC)
.route(validRoute)
.json()
return [
{
data: template,
navigationLinkLabel:
validRoute === "/" ? null : capitalise(validRoute.split("/")[1]),
},
]
}
export default blank

View File

@ -0,0 +1,67 @@
import { Screen } from "./Screen"
import { Component } from "../Component"
import getValidRoute from "./getValidRoute"
export const getTypeSpecificRoute = (tableOrView, type) => {
if (type === "create") {
return `/${tableOrView.name}/new`
} else if (type === "update") {
return `/${tableOrView.name}/edit/:id`
} else if (type === "view") {
return `/${tableOrView.name}/view/:id`
}
}
const getRole = (permissions, type) => {
if (type === "view") {
return permissions.read
}
return permissions.write
}
const getActionType = type => {
if (type === "create") {
return "Create"
}
if (type === "update") {
return "Update"
}
if (type === "view") {
return "View"
}
}
const form = ({ tableOrView, type, permissions, screens }) => {
const typeSpecificRoute = getTypeSpecificRoute(tableOrView, type)
const role = getRole(permissions, type)
const multistepFormBlock = new Component(
"@budibase/standard-components/multistepformblock"
)
.customProps({
actionType: getActionType(type),
dataSource: tableOrView.tableSelectFormat,
steps: [{}],
rowId: type === "new" ? undefined : `{{ url.id }}`,
})
.instanceName(`${tableOrView.name} - Multistep Form block`)
const template = new Screen()
.route(getValidRoute(screens, typeSpecificRoute, role))
.instanceName(`${tableOrView.name} - Form`)
.role(role)
.autoTableId(tableOrView.id)
.addChild(multistepFormBlock)
.json()
return [
{
data: template,
navigationLinkLabel:
type === "create" ? `Create ${tableOrView.name}` : null,
},
]
}
export default form

View File

@ -0,0 +1,35 @@
import sanitizeUrl from "helpers/sanitizeUrl"
const arbitraryMax = 10000
const isScreenUrlValid = (screens, url, role) => {
return !screens.some(
screen => screen.routing?.route === url && screen.routing?.roleId === role
)
}
const getValidScreenUrl = (screens, url, role) => {
const [firstPathSegment = "", ...restPathSegments] = url
.split("/")
.filter(segment => segment !== "")
const restOfPath =
restPathSegments.length > 0 ? `/${restPathSegments.join("/")}` : ""
const naiveUrl = sanitizeUrl(`/${firstPathSegment}${restOfPath}`)
if (isScreenUrlValid(screens, naiveUrl, role)) {
return naiveUrl
}
for (let suffix = 2; suffix < arbitraryMax; suffix++) {
const suffixedUrl = sanitizeUrl(
`/${firstPathSegment}-${suffix}${restOfPath}`
)
if (isScreenUrlValid(screens, suffixedUrl, role)) {
return suffixedUrl
}
}
}
export default getValidScreenUrl

View File

@ -0,0 +1,3 @@
export { default as blank } from "./blank"
export { default as form } from "./form"
export { default as table } from "./table"

View File

@ -0,0 +1,25 @@
import inline from "./inline"
import modal from "./modal"
import sidePanel from "./sidePanel"
import newScreen from "./newScreen"
const createScreen = ({ tableOrView, type, permissions, screens }) => {
if (type === "inline") {
return inline({ tableOrView, permissions, screens })
}
if (type === "modal") {
return modal({ tableOrView, permissions, screens })
}
if (type === "sidePanel") {
return sidePanel({ tableOrView, permissions, screens })
}
if (type === "newScreen") {
return newScreen({ tableOrView, permissions, screens })
}
throw new Error(`Unrecognized table type ${type}`)
}
export default createScreen

View File

@ -0,0 +1,36 @@
import { Screen } from "../Screen"
import { Component } from "../../Component"
import { capitalise } from "helpers"
import getValidRoute from "../getValidRoute"
const inline = ({ tableOrView, permissions, screens }) => {
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
const tableBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${tableOrView.name} - Table`)
.customProps({
table: tableOrView.datasourceSelectFormat,
})
const screenTemplate = new Screen()
.route(getValidRoute(screens, tableOrView.name, permissions.write))
.instanceName(`${tableOrView.name} - List`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(heading)
.addChild(tableBlock)
.json()
return [
{
data: screenTemplate,
navigationLinkLabel: capitalise(tableOrView.name),
},
]
}
export default inline

View File

@ -0,0 +1,157 @@
import { Screen } from "../Screen"
import { Component } from "../../Component"
import { generate } from "shortid"
import { makePropSafe as safe } from "@budibase/string-templates"
import { Utils } from "@budibase/frontend-core"
import { capitalise } from "helpers"
import getValidRoute from "../getValidRoute"
const modal = ({ tableOrView, permissions, screens }) => {
/*
Create Row
*/
const createRowModal = new Component("@budibase/standard-components/modal")
.instanceName("New row modal")
.customProps({
size: "large",
})
const buttonGroup = new Component("@budibase/standard-components/buttongroup")
const createButton = new Component("@budibase/standard-components/button")
createButton.customProps({
onClick: [
{
id: 0,
"##eventHandlerType": "Open Modal",
parameters: {
id: createRowModal._json._id,
},
},
],
text: "Create row",
type: "cta",
})
buttonGroup.instanceName(`${tableOrView.name} - Create`).customProps({
hAlign: "right",
buttons: [createButton.json()],
})
const tableHeader = new Component("@budibase/standard-components/container")
.instanceName("Heading container")
.customProps({
direction: "row",
hAlign: "stretch",
})
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
tableHeader.addChild(heading)
tableHeader.addChild(buttonGroup)
const createFormBlock = new Component(
"@budibase/standard-components/formblock"
)
createFormBlock.instanceName("Create row form block").customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Create",
title: "Create row",
buttons: Utils.buildFormBlockButtonConfig({
_id: createFormBlock._json._id,
showDeleteButton: false,
showSaveButton: true,
saveButtonLabel: "Save",
actionType: "Create",
dataSource: tableOrView.tableSelectFormat,
}),
})
createRowModal.addChild(createFormBlock)
/*
Edit Row
*/
const stateKey = `ID_${generate()}`
const detailsModal = new Component("@budibase/standard-components/modal")
.instanceName("Edit row modal")
.customProps({
size: "large",
})
const editFormBlock = new Component("@budibase/standard-components/formblock")
editFormBlock.instanceName("Edit row form block").customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Update",
title: "Edit",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
buttons: Utils.buildFormBlockButtonConfig({
_id: editFormBlock._json._id,
showDeleteButton: true,
showSaveButton: true,
saveButtonLabel: "Save",
deleteButtonLabel: "Delete",
actionType: "Update",
dataSource: tableOrView.tableSelectFormat,
}),
})
detailsModal.addChild(editFormBlock)
const tableBlock = new Component("@budibase/standard-components/gridblock")
tableBlock
.customProps({
table: tableOrView.datasourceSelectFormat,
allowAddRows: false,
allowEditRows: false,
allowDeleteRows: false,
onRowClick: [
{
id: 0,
"##eventHandlerType": "Update State",
parameters: {
key: stateKey,
type: "set",
persist: false,
value: `{{ ${safe("eventContext")}.${safe("row")}._id }}`,
},
},
{
id: 1,
"##eventHandlerType": "Open Modal",
parameters: {
id: detailsModal._json._id,
},
},
],
})
.instanceName(`${tableOrView.name} - Table`)
const template = new Screen()
.route(getValidRoute(screens, tableOrView.name, permissions.write))
.instanceName(`${tableOrView.name} - List and details`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(tableHeader)
.addChild(tableBlock)
.addChild(createRowModal)
.addChild(detailsModal)
.json()
return [
{
data: template,
navigationLinkLabel: capitalise(tableOrView.name),
},
]
}
export default modal

View File

@ -0,0 +1,322 @@
import { Screen } from "../Screen"
import { Component } from "../../Component"
import { capitalise } from "helpers"
import { makePropSafe as safe } from "@budibase/string-templates"
import getValidRoute from "../getValidRoute"
import { Helpers } from "@budibase/bbui"
const getTableScreenTemplate = ({
route,
updateScreenRoute,
createScreenRoute,
tableOrView,
permissions,
}) => {
const newButton = new Component("@budibase/standard-components/button")
.instanceName("New button")
.customProps({
text: "Create row",
onClick: [
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: createScreenRoute,
},
},
],
})
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
const tableHeader = new Component("@budibase/standard-components/container")
.instanceName("Heading container")
.customProps({
direction: "row",
hAlign: "stretch",
})
.addChild(heading)
.addChild(newButton)
const updateScreenRouteSegments = updateScreenRoute.split(":id")
if (updateScreenRouteSegments.length !== 2) {
throw new Error("Provided edit screen route is invalid")
}
const tableBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${tableOrView.name} - Table`)
.customProps({
table: tableOrView.datasourceSelectFormat,
allowAddRows: false,
allowEditRows: false,
allowDeleteRows: false,
onRowClick: [
{
id: 0,
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: `${updateScreenRouteSegments[0]}{{ ${safe(
"eventContext"
)}.${safe("row")}._id }}${updateScreenRouteSegments[1]}`,
},
},
],
})
const template = new Screen()
.route(route)
.instanceName(`${tableOrView.name} - List`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(tableHeader)
.addChild(tableBlock)
.json()
return {
data: template,
navigationLinkLabel: capitalise(tableOrView.name),
}
}
const getUpdateScreenTemplate = ({
route,
tableScreenRoute,
tableOrView,
permissions,
}) => {
const formBlockId = Helpers.uuid()
const formId = `${formBlockId}-form`
const repeaterId = `${formBlockId}-repeater`
const backButton = new Component("@budibase/standard-components/button")
.instanceName("Back button")
.customProps({
type: "primary",
icon: "ri-arrow-go-back-fill",
text: "Back",
onClick: [
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const deleteButton = new Component("@budibase/standard-components/button")
.instanceName("Delete button")
.customProps({
type: "secondary",
text: "Delete",
onClick: [
{
"##eventHandlerType": "Delete Row",
parameters: {
confirm: true,
tableId: tableOrView.id,
rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`,
revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`,
},
},
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const saveButton = new Component("@budibase/standard-components/button")
.instanceName("Save button")
.customProps({
type: "cta",
text: "Save",
onClick: [
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: tableOrView.id,
},
},
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const updateFormBlock = new Component(
"@budibase/standard-components/formblock",
formBlockId
)
.instanceName("Update row form block")
.customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Update",
title: `Update ${tableOrView.name} row`,
buttons: [backButton.json(), saveButton.json(), deleteButton.json()],
})
const template = new Screen()
.route(route)
.instanceName(`Update row`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(updateFormBlock)
.json()
return {
data: template,
navigationLinkLabel: null,
}
}
const getCreateScreenTemplate = ({
route,
tableScreenRoute,
tableOrView,
permissions,
}) => {
const formBlockId = Helpers.uuid()
const formId = `${formBlockId}-form`
const backButton = new Component("@budibase/standard-components/button")
.instanceName("Back button")
.customProps({
type: "primary",
icon: "ri-arrow-go-back-fill",
text: "Back",
onClick: [
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const saveButton = new Component("@budibase/standard-components/button")
.instanceName("Save button")
.customProps({
type: "cta",
text: "Save",
onClick: [
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: tableOrView.id,
},
},
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const createFormBlock = new Component(
"@budibase/standard-components/formblock",
formBlockId
)
.instanceName("Create row form block")
.customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Create",
title: `Create ${tableOrView.name} row`,
buttons: [backButton.json(), saveButton.json()],
})
const template = new Screen()
.route(route)
.instanceName("Create row")
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(createFormBlock)
.json()
return {
data: template,
navigationLinkLabel: null,
}
}
const newScreen = ({ tableOrView, permissions, screens }) => {
const tableScreenRoute = getValidRoute(
screens,
tableOrView.name,
permissions.write
)
const updateScreenRoute = getValidRoute(
screens,
`/${tableOrView.name}/edit/:id`,
permissions.write
)
const createScreenRoute = getValidRoute(
screens,
`/${tableOrView.name}/new`,
permissions.write
)
const tableScreenTemplate = getTableScreenTemplate({
route: tableScreenRoute,
updateScreenRoute,
createScreenRoute,
permissions,
tableOrView,
})
const updateScreenTemplate = getUpdateScreenTemplate({
route: updateScreenRoute,
tableScreenRoute,
tableOrView,
permissions,
})
const createScreenTemplate = getCreateScreenTemplate({
route: createScreenRoute,
tableScreenRoute,
tableOrView,
permissions,
})
return [tableScreenTemplate, updateScreenTemplate, createScreenTemplate]
}
export default newScreen

View File

@ -1,13 +1,12 @@
import sanitizeUrl from "helpers/sanitizeUrl" import { Screen } from "../Screen"
import { Screen } from "./Screen" import { Component } from "../../Component"
import { Component } from "./Component"
import { generate } from "shortid" import { generate } from "shortid"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { capitalise } from "helpers"
import getValidRoute from "../getValidRoute"
const gridDetailsUrl = tableOrView => sanitizeUrl(`/${tableOrView.name}`) const sidePanel = ({ tableOrView, permissions, screens }) => {
const createScreen = (tableOrView, permissions) => {
/* /*
Create Row Create Row
*/ */
@ -37,7 +36,7 @@ const createScreen = (tableOrView, permissions) => {
buttons: [createButton.json()], buttons: [createButton.json()],
}) })
const gridHeader = new Component("@budibase/standard-components/container") const tableHeader = new Component("@budibase/standard-components/container")
.instanceName("Heading container") .instanceName("Heading container")
.customProps({ .customProps({
layout: "flex", layout: "flex",
@ -51,14 +50,14 @@ const createScreen = (tableOrView, permissions) => {
text: tableOrView.name, text: tableOrView.name,
}) })
gridHeader.addChild(heading) tableHeader.addChild(heading)
gridHeader.addChild(buttonGroup) tableHeader.addChild(buttonGroup)
const createFormBlock = new Component( const createFormBlock = new Component(
"@budibase/standard-components/formblock" "@budibase/standard-components/formblock"
) )
createFormBlock.instanceName("Create row form block").customProps({ createFormBlock.instanceName("Create row form block").customProps({
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
labelPosition: "left", labelPosition: "left",
buttonPosition: "top", buttonPosition: "top",
actionType: "Create", actionType: "Create",
@ -69,7 +68,7 @@ const createScreen = (tableOrView, permissions) => {
showSaveButton: true, showSaveButton: true,
saveButtonLabel: "Save", saveButtonLabel: "Save",
actionType: "Create", actionType: "Create",
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
}), }),
}) })
@ -85,7 +84,7 @@ const createScreen = (tableOrView, permissions) => {
const editFormBlock = new Component("@budibase/standard-components/formblock") const editFormBlock = new Component("@budibase/standard-components/formblock")
editFormBlock.instanceName("Edit row form block").customProps({ editFormBlock.instanceName("Edit row form block").customProps({
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
labelPosition: "left", labelPosition: "left",
buttonPosition: "top", buttonPosition: "top",
actionType: "Update", actionType: "Update",
@ -98,16 +97,16 @@ const createScreen = (tableOrView, permissions) => {
saveButtonLabel: "Save", saveButtonLabel: "Save",
deleteButtonLabel: "Delete", deleteButtonLabel: "Delete",
actionType: "Update", actionType: "Update",
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
}), }),
}) })
detailsSidePanel.addChild(editFormBlock) detailsSidePanel.addChild(editFormBlock)
const gridBlock = new Component("@budibase/standard-components/gridblock") const tableBlock = new Component("@budibase/standard-components/gridblock")
gridBlock tableBlock
.customProps({ .customProps({
table: tableOrView.clientData, table: tableOrView.datasourceSelectFormat,
allowAddRows: false, allowAddRows: false,
allowEditRows: false, allowEditRows: false,
allowDeleteRows: false, allowDeleteRows: false,
@ -133,16 +132,23 @@ const createScreen = (tableOrView, permissions) => {
}) })
.instanceName(`${tableOrView.name} - Table`) .instanceName(`${tableOrView.name} - Table`)
return new Screen() const template = new Screen()
.route(gridDetailsUrl(tableOrView)) .route(getValidRoute(screens, tableOrView.name, permissions.write))
.instanceName(`${tableOrView.name} - List and details`) .instanceName(`${tableOrView.name} - List and details`)
.role(permissions.write) .role(permissions.write)
.autoTableId(tableOrView.resourceId) .autoTableId(tableOrView.id)
.addChild(gridHeader) .addChild(tableHeader)
.addChild(gridBlock) .addChild(tableBlock)
.addChild(createRowSidePanel) .addChild(createRowSidePanel)
.addChild(detailsSidePanel) .addChild(detailsSidePanel)
.json() .json()
return [
{
data: template,
navigationLinkLabel: capitalise(tableOrView.name),
},
]
} }
export default createScreen export default sidePanel

View File

@ -1,95 +1,17 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Icon, notifications } from "@budibase/bbui" import { ActionButton, Popover } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils" import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
import { helpers } from "@budibase/shared-core"
export let allowViewReadonlyColumns = false export let allowViewReadonlyColumns = false
const { columns, datasource, dispatch } = getContext("grid") const { columns } = getContext("grid")
let open = false let open = false
let anchor let anchor
$: restrictedColumns = $columns.filter(col => !col.visible || col.readonly) $: anyRestricted = $columns.filter(col => !col.visible || col.readonly).length
$: anyRestricted = restrictedColumns.length
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns" $: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
const toggleColumn = async (column, permission) => {
const visible = permission !== PERMISSION_OPTIONS.HIDDEN
const readonly = permission === PERMISSION_OPTIONS.READONLY
await datasource.actions.addSchemaMutation(column.name, {
visible,
readonly,
})
try {
await datasource.actions.saveSchemaMutations()
} catch (e) {
notifications.error(e.message)
} finally {
await datasource.actions.resetSchemaMutations()
await datasource.actions.refreshDefinition()
}
dispatch(visible ? "show-column" : "hide-column")
}
const PERMISSION_OPTIONS = {
WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden",
}
$: displayColumns = $columns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
const options = [
{
icon: "Edit",
value: PERMISSION_OPTIONS.WRITABLE,
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: c.primaryDisplay || isRequired,
tooltip:
(c.primaryDisplay && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})
return { ...c, options }
})
function columnToPermissionOptions(column) {
if (!column.schema.visible) {
return PERMISSION_OPTIONS.HIDDEN
}
if (column.schema.readonly) {
return PERMISSION_OPTIONS.READONLY
}
return PERMISSION_OPTIONS.WRITABLE
}
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
@ -106,51 +28,5 @@
</div> </div>
<Popover bind:open {anchor} align="left"> <Popover bind:open {anchor} align="left">
<div class="content"> <ColumnsSettingContent columns={$columns} {allowViewReadonlyColumns} />
<div class="columns">
{#each displayColumns as column}
<div class="column">
<Icon size="S" name={getColumnIcon(column)} />
<div class="column-label" title={column.label}>
{column.label}
</div>
</div>
<ToggleActionButtonGroup
on:click={e => toggleColumn(column, e.detail)}
value={columnToPermissionOptions(column)}
options={column.options}
/>
{/each}
</div>
</div>
</Popover> </Popover>
<style>
.content {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.columns {
display: grid;
align-items: center;
grid-template-columns: 1fr auto;
grid-row-gap: 8px;
grid-column-gap: 24px;
}
.columns :global(.spectrum-Switch) {
margin-right: 0;
}
.column {
display: flex;
gap: 8px;
}
.column-label {
min-width: 80px;
max-width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,134 @@
<script>
import { getContext } from "svelte"
import { Icon, notifications } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils"
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
import { helpers } from "@budibase/shared-core"
export let allowViewReadonlyColumns = false
const { columns, datasource, dispatch } = getContext("grid")
const toggleColumn = async (column, permission) => {
const visible = permission !== PERMISSION_OPTIONS.HIDDEN
const readonly = permission === PERMISSION_OPTIONS.READONLY
await datasource.actions.addSchemaMutation(column.name, {
visible,
readonly,
})
try {
await datasource.actions.saveSchemaMutations()
} catch (e) {
notifications.error(e.message)
} finally {
await datasource.actions.resetSchemaMutations()
await datasource.actions.refreshDefinition()
}
dispatch(visible ? "show-column" : "hide-column")
}
const PERMISSION_OPTIONS = {
WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden",
}
$: displayColumns = $columns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
const options = [
{
icon: "Edit",
value: PERMISSION_OPTIONS.WRITABLE,
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: c.primaryDisplay || isRequired,
tooltip:
(c.primaryDisplay && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})
return { ...c, options }
})
function columnToPermissionOptions(column) {
if (column.schema.visible === false) {
return PERMISSION_OPTIONS.HIDDEN
}
if (column.schema.readonly) {
return PERMISSION_OPTIONS.READONLY
}
return PERMISSION_OPTIONS.WRITABLE
}
</script>
<div class="content">
<div class="columns">
{#each displayColumns as column}
<div class="column">
<Icon size="S" name={getColumnIcon(column)} />
<div class="column-label" title={column.label}>
{column.label}
</div>
</div>
<ToggleActionButtonGroup
on:click={e => toggleColumn(column, e.detail)}
value={columnToPermissionOptions(column)}
options={column.options}
/>
{/each}
</div>
</div>
<style>
.content {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.columns {
display: grid;
align-items: center;
grid-template-columns: 1fr auto;
grid-row-gap: 8px;
grid-column-gap: 24px;
}
.columns :global(.spectrum-Switch) {
margin-right: 0;
}
.column {
display: flex;
gap: 8px;
}
.column-label {
min-width: 80px;
max-width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@ -155,6 +155,7 @@ export const buildFormBlockButtonConfig = props => {
providerId: formId, providerId: formId,
tableId: resourceId, tableId: resourceId,
notificationOverride, notificationOverride,
confirm: null,
}, },
}, },
{ {

View File

@ -12,7 +12,6 @@ ENV COUCH_DB_URL=https://couchdb.budi.live:5984
ENV BUDIBASE_ENVIRONMENT=PRODUCTION ENV BUDIBASE_ENVIRONMENT=PRODUCTION
ENV SERVICE=app-service ENV SERVICE=app-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ENV TOP_LEVEL_PATH=/ ENV TOP_LEVEL_PATH=/

View File

@ -43,7 +43,6 @@ async function init() {
BB_ADMIN_USER_EMAIL: "", BB_ADMIN_USER_EMAIL: "",
BB_ADMIN_USER_PASSWORD: "", BB_ADMIN_USER_PASSWORD: "",
PLUGINS_DIR: "", PLUGINS_DIR: "",
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
HTTP_MIGRATIONS: "0", HTTP_MIGRATIONS: "0",
HTTP_LOGGING: "0", HTTP_LOGGING: "0",
VERSION: "0.0.0+local", VERSION: "0.0.0+local",

View File

@ -308,16 +308,21 @@ export async function downloadAttachment(ctx: UserCtx) {
if (attachments.length === 1) { if (attachments.length === 1) {
const attachment = attachments[0] const attachment = attachments[0]
ctx.attachment(attachment.name) ctx.attachment(attachment.name)
ctx.body = await objectStore.getReadStream( if (attachment.key) {
objectStore.ObjectStoreBuckets.APPS, ctx.body = await objectStore.getReadStream(
attachment.key objectStore.ObjectStoreBuckets.APPS,
) attachment.key
)
}
} else { } else {
const passThrough = new stream.PassThrough() const passThrough = new stream.PassThrough()
const archive = archiver.create("zip") const archive = archiver.create("zip")
archive.pipe(passThrough) archive.pipe(passThrough)
for (const attachment of attachments) { for (const attachment of attachments) {
if (!attachment.key) {
continue
}
const attachmentStream = await objectStore.getReadStream( const attachmentStream = await objectStore.getReadStream(
objectStore.ObjectStoreBuckets.APPS, objectStore.ObjectStoreBuckets.APPS,
attachment.key attachment.key

View File

@ -151,7 +151,10 @@ export function buildExternalRelationships(
return relationships return relationships
} }
export function buildInternalRelationships(table: Table): RelationshipsJson[] { export function buildInternalRelationships(
table: Table,
allTables: Table[]
): RelationshipsJson[] {
const relationships: RelationshipsJson[] = [] const relationships: RelationshipsJson[] = []
const links = Object.values(table.schema).filter( const links = Object.values(table.schema).filter(
column => column.type === FieldType.LINK column => column.type === FieldType.LINK
@ -164,6 +167,10 @@ export function buildInternalRelationships(table: Table): RelationshipsJson[] {
const linkTableId = link.tableId! const linkTableId = link.tableId!
const junctionTableId = generateJunctionTableID(tableId, linkTableId) const junctionTableId = generateJunctionTableID(tableId, linkTableId)
const isFirstTable = tableId > linkTableId const isFirstTable = tableId > linkTableId
// skip relationships with missing table definitions
if (!allTables.find(table => table._id === linkTableId)) {
continue
}
relationships.push({ relationships.push({
through: junctionTableId, through: junctionTableId,
column: link.name, column: link.name,
@ -192,10 +199,10 @@ export function buildSqlFieldList(
function extractRealFields(table: Table, existing: string[] = []) { function extractRealFields(table: Table, existing: string[] = []) {
return Object.entries(table.schema) return Object.entries(table.schema)
.filter( .filter(
column => ([columnName, column]) =>
column[1].type !== FieldType.LINK && column.type !== FieldType.LINK &&
column[1].type !== FieldType.FORMULA && column.type !== FieldType.FORMULA &&
!existing.find((field: string) => field === column[0]) !existing.find((field: string) => field === columnName)
) )
.map(column => `${table.name}.${column[0]}`) .map(column => `${table.name}.${column[0]}`)
} }

View File

@ -1664,7 +1664,7 @@ describe.each([
isInternal && isInternal &&
describe("attachments and signatures", () => { describe("attachments and signatures", () => {
const coreAttachmentEnrichment = async ( const coreAttachmentEnrichment = async (
schema: any, schema: TableSchema,
field: string, field: string,
attachmentCfg: string | string[] attachmentCfg: string | string[]
) => { ) => {
@ -1691,7 +1691,7 @@ describe.each([
await withEnv({ SELF_HOSTED: "true" }, async () => { await withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => { return context.doInAppContext(config.getAppId(), async () => {
const enriched: Row[] = await outputProcessing(table, [row]) const enriched: Row[] = await outputProcessing(testTable, [row])
const [targetRow] = enriched const [targetRow] = enriched
const attachmentEntries = Array.isArray(targetRow[field]) const attachmentEntries = Array.isArray(targetRow[field])
? targetRow[field] ? targetRow[field]

View File

@ -30,6 +30,7 @@ import {
withEnv as withCoreEnv, withEnv as withCoreEnv,
setEnv as setCoreEnv, setEnv as setCoreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import sdk from "../../../sdk"
describe.each([ describe.each([
["lucene", undefined], ["lucene", undefined],
@ -120,6 +121,7 @@ describe.each([
}) })
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks()
mocks.licenses.useCloudFree() mocks.licenses.useCloudFree()
}) })
@ -1602,6 +1604,28 @@ describe.each([
}) })
expect(response.rows).toHaveLength(0) expect(response.rows).toHaveLength(0)
}) })
it("queries the row api passing the view fields only", async () => {
const searchSpy = jest.spyOn(sdk.rows, "search")
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
id: { visible: true },
one: { visible: false },
},
})
await config.api.viewV2.search(view.id, { query: {} })
expect(searchSpy).toHaveBeenCalledTimes(1)
expect(searchSpy).toHaveBeenCalledWith(
expect.objectContaining({
fields: ["id"],
})
)
})
}) })
describe("permissions", () => { describe("permissions", () => {

View File

@ -0,0 +1,51 @@
import {
AutomationActionStepId,
AutomationCustomIOType,
AutomationIOType,
AutomationStepDefinition,
AutomationStepType,
} from "@budibase/types"
export const definition: AutomationStepDefinition = {
name: "Branch",
icon: "Branch3",
tagline: "Branch from this step",
description: "Branching",
stepId: AutomationActionStepId.BRANCH,
internal: true,
features: {},
inputs: {},
schema: {
inputs: {
properties: {
branches: {
properties: {
name: {
type: AutomationIOType.STRING,
},
condition: {
customType: AutomationCustomIOType.FILTERS,
},
},
},
children: {
type: AutomationIOType.ARRAY,
},
},
required: ["conditions"],
},
outputs: {
properties: {
branchName: {
type: AutomationIOType.STRING,
},
result: {
type: AutomationIOType.BOOLEAN,
description: "Whether the condition was met",
},
},
required: ["output"],
},
},
type: AutomationStepType.LOGIC,
}

View File

@ -7,7 +7,7 @@ import {
ServerLogStepOutputs, ServerLogStepOutputs,
FieldType, FieldType,
} from "@budibase/types" } from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
import { DatabaseName } from "../../../integrations/tests/utils" import { DatabaseName } from "../../../integrations/tests/utils"
describe("Automation Scenarios", () => { describe("Automation Scenarios", () => {
@ -23,6 +23,43 @@ describe("Automation Scenarios", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
// eslint-disable-next-line jest/no-commented-out-tests
// describe("Branching automations", () => {
// eslint-disable-next-line jest/no-commented-out-tests
// it("should run an automation with a trigger, loop, and create row step", async () => {
// const builder = createAutomationBuilder({
// name: "Test Trigger with Loop and Create Row",
// })
// builder
// .serverLog({ text: "Starting automation" })
// .branch({
// topLevelBranch1: {
// steps: stepBuilder =>
// stepBuilder.serverLog({ text: "Branch 1" }).branch({
// branch1: {
// steps: stepBuilder =>
// stepBuilder.serverLog({ text: "Branch 1.1" }),
// condition: { notEmpty: { column: 10 } },
// },
// branch2: {
// steps: stepBuilder =>
// stepBuilder.serverLog({ text: "Branch 1.2" }),
// condition: { fuzzy: { column: "sadsd" } },
// },
// }),
// condition: { equal: { column: 10 } },
// },
// topLevelBranch2: {
// steps: stepBuilder => stepBuilder.serverLog({ text: "Branch 2" }),
// condition: { equal: { column: 20 } },
// },
// })
// .run()
// })
// })
describe("Loop automations", () => { describe("Loop automations", () => {
it("should run an automation with a trigger, loop, and create row step", async () => { it("should run an automation with a trigger, loop, and create row step", async () => {
const builder = createAutomationBuilder({ const builder = createAutomationBuilder({

View File

@ -30,10 +30,13 @@ import {
AutomationStepInputs, AutomationStepInputs,
AutomationTriggerInputs, AutomationTriggerInputs,
ServerLogStepInputs, ServerLogStepInputs,
BranchStepInputs,
SearchFilters,
Branch,
} from "@budibase/types" } from "@budibase/types"
import {} from "../../steps/loop"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import * as setup from "../utilities" import * as setup from "../utilities"
import { definition } from "../../../automations/steps/branch"
type TriggerOutputs = type TriggerOutputs =
| RowCreatedTriggerOutputs | RowCreatedTriggerOutputs
@ -43,69 +46,56 @@ type TriggerOutputs =
| CronTriggerOutputs | CronTriggerOutputs
| undefined | undefined
class AutomationBuilder { type StepBuilderFunction = (stepBuilder: StepBuilder) => void
private automationConfig: Automation = {
name: "",
definition: {
steps: [],
trigger: {} as AutomationTrigger,
},
type: "automation",
appId: setup.getConfig().getAppId(),
}
private config: TestConfiguration = setup.getConfig()
private triggerOutputs: TriggerOutputs
private triggerSet: boolean = false
constructor(options: { name?: string } = {}) { type BranchConfig = {
this.automationConfig.name = options.name || `Test Automation ${uuidv4()}` [key: string]: {
steps: StepBuilderFunction
condition: SearchFilters
}
}
class BaseStepBuilder {
protected steps: AutomationStep[] = []
protected step<TStep extends AutomationActionStepId>(
stepId: TStep,
stepSchema: Omit<AutomationStep, "id" | "stepId" | "inputs">,
inputs: AutomationStepInputs<TStep>
): this {
this.steps.push({
...stepSchema,
inputs: inputs as any,
id: uuidv4(),
stepId,
})
return this
} }
// TRIGGERS protected addBranchStep(branchConfig: BranchConfig): void {
rowSaved(inputs: RowCreatedTriggerInputs, outputs: RowCreatedTriggerOutputs) { const branchStepInputs: BranchStepInputs = {
this.triggerOutputs = outputs branches: [] as Branch[],
return this.trigger( children: {},
TRIGGER_DEFINITIONS.ROW_SAVED, }
AutomationTriggerStepId.ROW_SAVED,
inputs,
outputs
)
}
rowUpdated( Object.entries(branchConfig).forEach(([key, branch]) => {
inputs: RowUpdatedTriggerInputs, const stepBuilder = new StepBuilder()
outputs: RowUpdatedTriggerOutputs branch.steps(stepBuilder)
) {
this.triggerOutputs = outputs
return this.trigger(
TRIGGER_DEFINITIONS.ROW_UPDATED,
AutomationTriggerStepId.ROW_UPDATED,
inputs,
outputs
)
}
rowDeleted( branchStepInputs.branches.push({
inputs: RowDeletedTriggerInputs, name: key,
outputs: RowDeletedTriggerOutputs condition: branch.condition,
) { })
this.triggerOutputs = outputs branchStepInputs.children![key] = stepBuilder.build()
return this.trigger( })
TRIGGER_DEFINITIONS.ROW_DELETED,
AutomationTriggerStepId.ROW_DELETED,
inputs,
outputs
)
}
appAction(outputs: AppActionTriggerOutputs, inputs?: AppActionTriggerInputs) { const branchStep: AutomationStep = {
this.triggerOutputs = outputs ...definition,
return this.trigger( id: uuidv4(),
TRIGGER_DEFINITIONS.APP, stepId: AutomationActionStepId.BRANCH,
AutomationTriggerStepId.APP, inputs: branchStepInputs,
inputs, }
outputs this.steps.push(branchStep)
)
} }
// STEPS // STEPS
@ -171,6 +161,84 @@ class AutomationBuilder {
input input
) )
} }
}
class StepBuilder extends BaseStepBuilder {
build(): AutomationStep[] {
return this.steps
}
branch(branchConfig: BranchConfig): this {
this.addBranchStep(branchConfig)
return this
}
}
class AutomationBuilder extends BaseStepBuilder {
private automationConfig: Automation
private config: TestConfiguration
private triggerOutputs: any
private triggerSet: boolean = false
constructor(options: { name?: string } = {}) {
super()
this.automationConfig = {
name: options.name || `Test Automation ${uuidv4()}`,
definition: {
steps: [],
trigger: {} as AutomationTrigger,
},
type: "automation",
appId: setup.getConfig().getAppId(),
}
this.config = setup.getConfig()
}
// TRIGGERS
rowSaved(inputs: RowCreatedTriggerInputs, outputs: RowCreatedTriggerOutputs) {
this.triggerOutputs = outputs
return this.trigger(
TRIGGER_DEFINITIONS.ROW_SAVED,
AutomationTriggerStepId.ROW_SAVED,
inputs,
outputs
)
}
rowUpdated(
inputs: RowUpdatedTriggerInputs,
outputs: RowUpdatedTriggerOutputs
) {
this.triggerOutputs = outputs
return this.trigger(
TRIGGER_DEFINITIONS.ROW_UPDATED,
AutomationTriggerStepId.ROW_UPDATED,
inputs,
outputs
)
}
rowDeleted(
inputs: RowDeletedTriggerInputs,
outputs: RowDeletedTriggerOutputs
) {
this.triggerOutputs = outputs
return this.trigger(
TRIGGER_DEFINITIONS.ROW_DELETED,
AutomationTriggerStepId.ROW_DELETED,
inputs,
outputs
)
}
appAction(outputs: AppActionTriggerOutputs, inputs?: AppActionTriggerInputs) {
this.triggerOutputs = outputs
return this.trigger(
TRIGGER_DEFINITIONS.APP,
AutomationTriggerStepId.APP,
inputs,
outputs
)
}
private trigger<TStep extends AutomationTriggerStepId>( private trigger<TStep extends AutomationTriggerStepId>(
triggerSchema: AutomationTriggerDefinition, triggerSchema: AutomationTriggerDefinition,
@ -181,7 +249,6 @@ class AutomationBuilder {
if (this.triggerSet) { if (this.triggerSet) {
throw new Error("Only one trigger can be set for an automation.") throw new Error("Only one trigger can be set for an automation.")
} }
this.automationConfig.definition.trigger = { this.automationConfig.definition.trigger = {
...triggerSchema, ...triggerSchema,
stepId, stepId,
@ -194,21 +261,20 @@ class AutomationBuilder {
return this return this
} }
private step<TStep extends AutomationActionStepId>( branch(branchConfig: BranchConfig): {
stepId: TStep, run: () => Promise<AutomationResults>
stepSchema: Omit<AutomationStep, "id" | "stepId" | "inputs">, } {
inputs: AutomationStepInputs<TStep> this.addBranchStep(branchConfig)
): this { return {
this.automationConfig.definition.steps.push({ run: () => this.run(),
...stepSchema, }
inputs: inputs as any,
id: uuidv4(),
stepId,
})
return this
} }
async run() { async run() {
if (!Object.keys(this.automationConfig.definition.trigger).length) {
throw new Error("Please add a trigger to this automation test")
}
this.automationConfig.definition.steps = this.steps
const automation = await this.config.createAutomation(this.automationConfig) const automation = await this.config.createAutomation(this.automationConfig)
const results = await testAutomation( const results = await testAutomation(
this.config, this.config,
@ -218,7 +284,9 @@ class AutomationBuilder {
return this.processResults(results) return this.processResults(results)
} }
private processResults(results: { body: AutomationResults }) { private processResults(results: {
body: AutomationResults
}): AutomationResults {
results.body.steps.shift() results.body.steps.shift()
return { return {
trigger: results.body.trigger, trigger: results.body.trigger,

View File

@ -34,7 +34,7 @@ type TemplateType = {
function rewriteAttachmentUrl(appId: string, attachment: RowAttachment) { function rewriteAttachmentUrl(appId: string, attachment: RowAttachment) {
// URL looks like: /prod-budi-app-assets/appId/attachments/file.csv // URL looks like: /prod-budi-app-assets/appId/attachments/file.csv
const urlParts = attachment.key.split("/") const urlParts = attachment.key?.split("/") || []
// remove the app ID // remove the app ID
urlParts.shift() urlParts.shift()
// add new app ID // add new app ID

View File

@ -0,0 +1,109 @@
import { db } from "@budibase/backend-core"
import {
FieldType,
isLogicalSearchOperator,
SearchFilters,
Table,
} from "@budibase/types"
import { cloneDeep } from "lodash/fp"
import sdk from "../../../sdk"
export const removeInvalidFilters = (
filters: SearchFilters,
validFields: string[]
) => {
const result = cloneDeep(filters)
validFields = validFields.map(f => f.toLowerCase())
for (const filterKey of Object.keys(result) as (keyof SearchFilters)[]) {
if (typeof result[filterKey] !== "object") {
continue
}
if (isLogicalSearchOperator(filterKey)) {
const resultingConditions: SearchFilters[] = []
for (const condition of result[filterKey].conditions) {
const resultingCondition = removeInvalidFilters(condition, validFields)
if (Object.keys(resultingCondition).length) {
resultingConditions.push(resultingCondition)
}
}
if (resultingConditions.length) {
result[filterKey].conditions = resultingConditions
} else {
delete result[filterKey]
}
continue
}
const filter = result[filterKey]
for (const columnKey of Object.keys(filter)) {
const possibleKeys = [columnKey, db.removeKeyNumbering(columnKey)].map(
c => c.toLowerCase()
)
if (!validFields.some(f => possibleKeys.includes(f.toLowerCase()))) {
delete filter[columnKey]
}
}
if (!Object.keys(filter).length) {
delete result[filterKey]
}
}
return result
}
export const getQueryableFields = async (
fields: string[],
table: Table
): Promise<string[]> => {
const extractTableFields = async (
table: Table,
allowedFields: string[],
fromTables: string[]
): Promise<string[]> => {
const result = []
for (const field of Object.keys(table.schema).filter(
f => allowedFields.includes(f) && table.schema[f].visible !== false
)) {
const subSchema = table.schema[field]
if (subSchema.type === FieldType.LINK) {
if (fromTables.includes(subSchema.tableId)) {
// avoid circular loops
continue
}
try {
const relatedTable = await sdk.tables.getTable(subSchema.tableId)
const relatedFields = await extractTableFields(
relatedTable,
Object.keys(relatedTable.schema),
[...fromTables, subSchema.tableId]
)
result.push(
...relatedFields.flatMap(f => [
`${subSchema.name}.${f}`,
// should be able to filter by relationship using table name
`${relatedTable.name}.${f}`,
])
)
} catch (err: any) {
// if related table is removed, ignore
if (err.status !== 404) {
throw err
}
}
} else {
result.push(field)
}
}
return result
}
const result = [
"_id", // Querying by _id is always allowed, even if it's never part of the schema
]
result.push(...(await extractTableFields(table, fields, [table._id!])))
return result
}

View File

@ -14,6 +14,7 @@ import sdk from "../../index"
import { searchInputMapping } from "./search/utils" import { searchInputMapping } from "./search/utils"
import { db as dbCore } from "@budibase/backend-core" import { db as dbCore } from "@budibase/backend-core"
import tracer from "dd-trace" import tracer from "dd-trace"
import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
export { isValidFilter } from "../../../integrations/utils" export { isValidFilter } from "../../../integrations/utils"
@ -73,6 +74,18 @@ export async function search(
const table = await sdk.tables.getTable(options.tableId) const table = await sdk.tables.getTable(options.tableId)
options = searchInputMapping(table, options) options = searchInputMapping(table, options)
if (options.query) {
const tableFields = Object.keys(table.schema).filter(
f => table.schema[f].visible !== false
)
const queriableFields = await getQueryableFields(
options.fields?.filter(f => tableFields.includes(f)) ?? tableFields,
table
)
options.query = removeInvalidFilters(options.query, queriableFields)
}
let result: SearchResponse<Row> let result: SearchResponse<Row>
if (isExternalTable) { if (isExternalTable) {
span?.addTags({ searchType: "external" }) span?.addTags({ searchType: "external" })

View File

@ -297,7 +297,7 @@ export async function search(
throw new Error("Unable to find table") throw new Error("Unable to find table")
} }
const relationships = buildInternalRelationships(table) const relationships = buildInternalRelationships(table, allTables)
const searchFilters: SearchFilters = { const searchFilters: SearchFilters = {
...cleanupFilters(query, table, allTables), ...cleanupFilters(query, table, allTables),

View File

@ -1,4 +1,11 @@
import { Datasource, FieldType, Row, Table } from "@budibase/types" import {
AutoColumnFieldMetadata,
AutoFieldSubType,
Datasource,
FieldType,
NumberFieldMetadata,
Table,
} from "@budibase/types"
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { search } from "../../../../../sdk/app/rows/search" import { search } from "../../../../../sdk/app/rows/search"
@ -32,7 +39,6 @@ describe.each([
let envCleanup: (() => void) | undefined let envCleanup: (() => void) | undefined
let datasource: Datasource | undefined let datasource: Datasource | undefined
let table: Table let table: Table
let rows: Row[]
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ SQS_SEARCH_ENABLE: isSqs ? "true" : "false" }, () => await withCoreEnv({ SQS_SEARCH_ENABLE: isSqs ? "true" : "false" }, () =>
@ -51,16 +57,28 @@ describe.each([
datasource: await dsProvider, datasource: await dsProvider,
}) })
} }
})
beforeEach(async () => {
const idFieldSchema: NumberFieldMetadata | AutoColumnFieldMetadata =
isInternal
? {
name: "id",
type: FieldType.AUTO,
subtype: AutoFieldSubType.AUTO_ID,
autocolumn: true,
}
: {
name: "id",
type: FieldType.NUMBER,
autocolumn: true,
}
table = await config.api.table.save( table = await config.api.table.save(
tableForDatasource(datasource, { tableForDatasource(datasource, {
primary: ["id"], primary: ["id"],
schema: { schema: {
id: { id: idFieldSchema,
name: "id",
type: FieldType.NUMBER,
autocolumn: true,
},
name: { name: {
name: "name", name: "name",
type: FieldType.STRING, type: FieldType.STRING,
@ -81,16 +99,13 @@ describe.each([
}) })
) )
rows = []
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
rows.push( await config.api.row.save(table._id!, {
await config.api.row.save(table._id!, { name: generator.first(),
name: generator.first(), surname: generator.last(),
surname: generator.last(), age: generator.age(),
age: generator.age(), address: generator.address(),
address: generator.address(), })
})
)
} }
}) })
@ -138,4 +153,100 @@ describe.each([
) )
}) })
}) })
it("does not allow accessing hidden fields", async () => {
await config.doInContext(config.appId, async () => {
await config.api.table.save({
...table,
schema: {
...table.schema,
name: {
...table.schema.name,
visible: true,
},
age: {
...table.schema.age,
visible: false,
},
},
})
const result = await search({
tableId: table._id!,
query: {},
})
expect(result.rows).toHaveLength(10)
for (const row of result.rows) {
const keys = Object.keys(row)
expect(keys).toContain("name")
expect(keys).toContain("surname")
expect(keys).toContain("address")
expect(keys).not.toContain("age")
}
})
})
it("does not allow accessing hidden fields even if requested", async () => {
await config.doInContext(config.appId, async () => {
await config.api.table.save({
...table,
schema: {
...table.schema,
name: {
...table.schema.name,
visible: true,
},
age: {
...table.schema.age,
visible: false,
},
},
})
const result = await search({
tableId: table._id!,
query: {},
fields: ["name", "age"],
})
expect(result.rows).toHaveLength(10)
for (const row of result.rows) {
const keys = Object.keys(row)
expect(keys).toContain("name")
expect(keys).not.toContain("age")
expect(keys).not.toContain("surname")
expect(keys).not.toContain("address")
}
})
})
!isLucene &&
it.each([
[["id", "name", "age"], 3],
[["name", "age"], 10],
])(
"cannot query by non search fields (fields: %s)",
async (queryFields, expectedRows) => {
await config.doInContext(config.appId, async () => {
const { rows } = await search({
tableId: table._id!,
query: {
$or: {
conditions: [
{
$and: {
conditions: [
{ range: { id: { low: 2, high: 4 } } },
{ range: { id: { low: 3, high: 5 } } },
],
},
},
{ equal: { id: 7 } },
],
},
},
fields: queryFields,
})
expect(rows).toHaveLength(expectedRows)
})
}
)
}) })

View File

@ -0,0 +1,563 @@
import {
FieldType,
RelationshipType,
SearchFilters,
Table,
} from "@budibase/types"
import { getQueryableFields, removeInvalidFilters } from "../queryUtils"
import { structures } from "../../../../api/routes/tests/utilities"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
describe("query utils", () => {
describe("removeInvalidFilters", () => {
const fullFilters: SearchFilters = {
equal: { one: "foo" },
$or: {
conditions: [
{
equal: { one: "foo2", two: "bar" },
notEmpty: { one: null },
$and: {
conditions: [
{
equal: { three: "baz" },
notEmpty: { forth: null },
},
],
},
},
],
},
$and: {
conditions: [{ equal: { one: "foo2" }, notEmpty: { one: null } }],
},
}
it("can filter empty queries", () => {
const filters: SearchFilters = {}
const result = removeInvalidFilters(filters, [])
expect(result).toEqual({})
})
it("does not trim any valid field", () => {
const result = removeInvalidFilters(fullFilters, [
"one",
"two",
"three",
"forth",
])
expect(result).toEqual(fullFilters)
})
it("trims invalid field", () => {
const result = removeInvalidFilters(fullFilters, [
"one",
"three",
"forth",
])
expect(result).toEqual({
equal: { one: "foo" },
$or: {
conditions: [
{
equal: { one: "foo2" },
notEmpty: { one: null },
$and: {
conditions: [
{
equal: { three: "baz" },
notEmpty: { forth: null },
},
],
},
},
],
},
$and: {
conditions: [{ equal: { one: "foo2" }, notEmpty: { one: null } }],
},
})
})
it("trims invalid field keeping a valid fields", () => {
const result = removeInvalidFilters(fullFilters, ["three", "forth"])
const expected: SearchFilters = {
$or: {
conditions: [
{
$and: {
conditions: [
{
equal: { three: "baz" },
notEmpty: { forth: null },
},
],
},
},
],
},
}
expect(result).toEqual(expected)
})
it("keeps filter key numering", () => {
const prefixedFilters: SearchFilters = {
equal: { "1:one": "foo" },
$or: {
conditions: [
{
equal: { "2:one": "foo2", "3:two": "bar" },
notEmpty: { "4:one": null },
$and: {
conditions: [
{
equal: { "5:three": "baz", two: "bar2" },
notEmpty: { forth: null },
},
],
},
},
],
},
$and: {
conditions: [{ equal: { "6:one": "foo2" }, notEmpty: { one: null } }],
},
}
const result = removeInvalidFilters(prefixedFilters, [
"one",
"three",
"forth",
])
expect(result).toEqual({
equal: { "1:one": "foo" },
$or: {
conditions: [
{
equal: { "2:one": "foo2" },
notEmpty: { "4:one": null },
$and: {
conditions: [
{
equal: { "5:three": "baz" },
notEmpty: { forth: null },
},
],
},
},
],
},
$and: {
conditions: [{ equal: { "6:one": "foo2" }, notEmpty: { one: null } }],
},
})
})
it("handles relationship filters", () => {
const prefixedFilters: SearchFilters = {
$or: {
conditions: [
{ equal: { "1:other.one": "foo" } },
{
equal: {
"2:other.one": "foo2",
"3:other.two": "bar",
"4:other.three": "baz",
},
},
{ equal: { "another.three": "baz2" } },
],
},
}
const result = removeInvalidFilters(prefixedFilters, [
"other.one",
"other.two",
"another.three",
])
expect(result).toEqual({
$or: {
conditions: [
{ equal: { "1:other.one": "foo" } },
{ equal: { "2:other.one": "foo2", "3:other.two": "bar" } },
{ equal: { "another.three": "baz2" } },
],
},
})
})
})
describe("getQueryableFields", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.init()
})
it("returns table schema fields and _id", async () => {
const table: Table = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
age: { name: "age", type: FieldType.NUMBER },
},
})
const result = await getQueryableFields(Object.keys(table.schema), table)
expect(result).toEqual(["_id", "name", "age"])
})
it("excludes hidden fields", async () => {
const table: Table = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
age: { name: "age", type: FieldType.NUMBER, visible: false },
},
})
const result = await getQueryableFields(Object.keys(table.schema), table)
expect(result).toEqual(["_id", "name"])
})
it("includes relationship fields", async () => {
const aux: Table = await config.api.table.save({
...structures.basicTable(),
name: "auxTable",
schema: {
title: { name: "title", type: FieldType.STRING },
name: { name: "name", type: FieldType.STRING },
},
})
const table: Table = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
aux: {
name: "aux",
type: FieldType.LINK,
tableId: aux._id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "table",
},
},
})
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(table.schema), table)
})
expect(result).toEqual([
"_id",
"name",
"aux.title",
"auxTable.title",
"aux.name",
"auxTable.name",
])
})
it("excludes hidden relationship fields", async () => {
const aux: Table = await config.api.table.save({
...structures.basicTable(),
name: "auxTable",
schema: {
title: { name: "title", type: FieldType.STRING, visible: false },
name: { name: "name", type: FieldType.STRING, visible: true },
},
})
const table: Table = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
aux: {
name: "aux",
type: FieldType.LINK,
tableId: aux._id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "table",
},
},
})
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(table.schema), table)
})
expect(result).toEqual(["_id", "name", "aux.name", "auxTable.name"])
})
it("excludes all relationship fields if hidden", async () => {
const aux: Table = await config.api.table.save({
...structures.basicTable(),
name: "auxTable",
schema: {
title: { name: "title", type: FieldType.STRING, visible: false },
name: { name: "name", type: FieldType.STRING, visible: true },
},
})
const table: Table = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
aux: {
name: "aux",
type: FieldType.LINK,
tableId: aux._id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "table",
visible: false,
},
},
})
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(table.schema), table)
})
expect(result).toEqual(["_id", "name"])
})
describe("nested relationship", () => {
describe("one-to-many", () => {
let table: Table, aux1: Table, aux2: Table
beforeAll(async () => {
const { _id: aux1Id } = await config.api.table.save({
...structures.basicTable(),
name: "aux1Table",
schema: {
name: { name: "name", type: FieldType.STRING },
},
})
const { _id: aux2Id } = await config.api.table.save({
...structures.basicTable(),
name: "aux2Table",
schema: {
title: { name: "title", type: FieldType.STRING },
aux1_1: {
name: "aux1_1",
type: FieldType.LINK,
tableId: aux1Id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "aux2_1",
},
aux1_2: {
name: "aux1_2",
type: FieldType.LINK,
tableId: aux1Id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "aux2_2",
},
},
})
const { _id: tableId } = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
aux1: {
name: "aux1",
type: FieldType.LINK,
tableId: aux1Id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "table",
},
aux2: {
name: "aux2",
type: FieldType.LINK,
tableId: aux2Id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "table",
},
},
})
// We need to refech them to get the updated foreign keys
aux1 = await config.api.table.get(aux1Id!)
aux2 = await config.api.table.get(aux2Id!)
table = await config.api.table.get(tableId!)
})
it("includes nested relationship fields from main table", async () => {
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(table.schema), table)
})
expect(result).toEqual([
"_id",
"name",
// deep 1 aux1 primitive props
"aux1.name",
"aux1Table.name",
// deep 2 aux1 primitive props
"aux1.aux2_1.title",
"aux1Table.aux2_1.title",
"aux1.aux2Table.title",
"aux1Table.aux2Table.title",
// deep 2 aux2 primitive props
"aux1.aux2_2.title",
"aux1Table.aux2_2.title",
"aux1.aux2Table.title",
"aux1Table.aux2Table.title",
// deep 1 aux2 primitive props
"aux2.title",
"aux2Table.title",
// deep 2 aux2 primitive props
"aux2.aux1_1.name",
"aux2Table.aux1_1.name",
"aux2.aux1Table.name",
"aux2Table.aux1Table.name",
"aux2.aux1_2.name",
"aux2Table.aux1_2.name",
"aux2.aux1Table.name",
"aux2Table.aux1Table.name",
])
})
it("includes nested relationship fields from aux 1 table", async () => {
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(aux1.schema), aux1)
})
expect(result).toEqual([
"_id",
"name",
// deep 1 aux2_1 primitive props
"aux2_1.title",
"aux2Table.title",
// deep 2 aux2_1 primitive props
"aux2_1.table.name",
"aux2Table.table.name",
"aux2_1.TestTable.name",
"aux2Table.TestTable.name",
// deep 1 aux2_2 primitive props
"aux2_2.title",
"aux2Table.title",
// deep 2 aux2_2 primitive props
"aux2_2.table.name",
"aux2Table.table.name",
"aux2_2.TestTable.name",
"aux2Table.TestTable.name",
// deep 1 table primitive props
"table.name",
"TestTable.name",
// deep 2 table primitive props
"table.aux2.title",
"TestTable.aux2.title",
"table.aux2Table.title",
"TestTable.aux2Table.title",
])
})
it("includes nested relationship fields from aux 2 table", async () => {
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(aux2.schema), aux2)
})
expect(result).toEqual([
"_id",
"title",
// deep 1 aux1_1 primitive props
"aux1_1.name",
"aux1Table.name",
// deep 2 aux1_1 primitive props
"aux1_1.table.name",
"aux1Table.table.name",
"aux1_1.TestTable.name",
"aux1Table.TestTable.name",
// deep 1 aux1_2 primitive props
"aux1_2.name",
"aux1Table.name",
// deep 2 aux1_2 primitive props
"aux1_2.table.name",
"aux1Table.table.name",
"aux1_2.TestTable.name",
"aux1Table.TestTable.name",
// deep 1 table primitive props
"table.name",
"TestTable.name",
// deep 2 table primitive props
"table.aux1.name",
"TestTable.aux1.name",
"table.aux1Table.name",
"TestTable.aux1Table.name",
])
})
})
describe("many-to-many", () => {
let table: Table, aux: Table
beforeAll(async () => {
const { _id: auxId } = await config.api.table.save({
...structures.basicTable(),
name: "auxTable",
schema: {
title: { name: "title", type: FieldType.STRING },
},
})
const { _id: tableId } = await config.api.table.save({
...structures.basicTable(),
schema: {
name: { name: "name", type: FieldType.STRING },
aux: {
name: "aux",
type: FieldType.LINK,
tableId: auxId!,
relationshipType: RelationshipType.MANY_TO_MANY,
fieldName: "table",
},
},
})
// We need to refech them to get the updated foreign keys
aux = await config.api.table.get(auxId!)
table = await config.api.table.get(tableId!)
})
it("includes nested relationship fields from main table", async () => {
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(table.schema), table)
})
expect(result).toEqual([
"_id",
"name",
// deep 1 aux primitive props
"aux.title",
"auxTable.title",
])
})
it("includes nested relationship fields from aux table", async () => {
const result = await config.doInContext(config.appId, () => {
return getQueryableFields(Object.keys(aux.schema), aux)
})
expect(result).toEqual([
"_id",
"title",
// deep 1 dependency primitive props
"table.name",
"TestTable.name",
])
})
})
})
})
})

View File

@ -44,8 +44,8 @@ export class AttachmentCleanup {
if (type === FieldType.ATTACHMENTS && Array.isArray(rowData)) { if (type === FieldType.ATTACHMENTS && Array.isArray(rowData)) {
return rowData return rowData
.filter(attachment => attachment.key) .filter(attachment => attachment.key)
.map(attachment => attachment.key) .map(attachment => attachment.key!)
} else if ("key" in rowData) { } else if ("key" in rowData && rowData.key) {
return [rowData.key] return [rowData.key]
} }

View File

@ -26,8 +26,13 @@ import {
processOutputBBReferences, processOutputBBReferences,
} from "./bbReferenceProcessor" } from "./bbReferenceProcessor"
import { isExternalTableID } from "../../integrations/utils" import { isExternalTableID } from "../../integrations/utils"
import { helpers } from "@budibase/shared-core" import {
helpers,
PROTECTED_EXTERNAL_COLUMNS,
PROTECTED_INTERNAL_COLUMNS,
} from "@budibase/shared-core"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
import { isUserMetadataTable } from "../../api/controllers/row/utils"
export * from "./utils" export * from "./utils"
export * from "./attachments" export * from "./attachments"
@ -53,9 +58,9 @@ export async function processAutoColumn(
row: Row, row: Row,
opts?: AutoColumnProcessingOpts opts?: AutoColumnProcessingOpts
) { ) {
let noUser = !userId const noUser = !userId
let isUserTable = table._id === InternalTables.USER_METADATA const isUserTable = table._id === InternalTables.USER_METADATA
let now = new Date().toISOString() const now = new Date().toISOString()
// if a row doesn't have a revision then it doesn't exist yet // if a row doesn't have a revision then it doesn't exist yet
const creating = !row._rev const creating = !row._rev
// check its not user table, or whether any of the processing options have been disabled // check its not user table, or whether any of the processing options have been disabled
@ -111,7 +116,7 @@ async function processDefaultValues(table: Table, row: Row) {
ctx.user = user ctx.user = user
} }
for (let [key, schema] of Object.entries(table.schema)) { for (const [key, schema] of Object.entries(table.schema)) {
if ("default" in schema && schema.default != null && row[key] == null) { if ("default" in schema && schema.default != null && row[key] == null) {
const processed = await processString(schema.default, ctx) const processed = await processString(schema.default, ctx)
@ -165,10 +170,10 @@ export async function inputProcessing(
row: Row, row: Row,
opts?: AutoColumnProcessingOpts opts?: AutoColumnProcessingOpts
) { ) {
let clonedRow = cloneDeep(row) const clonedRow = cloneDeep(row)
const dontCleanseKeys = ["type", "_id", "_rev", "tableId"] const dontCleanseKeys = ["type", "_id", "_rev", "tableId"]
for (let [key, value] of Object.entries(clonedRow)) { for (const [key, value] of Object.entries(clonedRow)) {
const field = table.schema[key] const field = table.schema[key]
// cleanse fields that aren't in the schema // cleanse fields that aren't in the schema
if (!field) { if (!field) {
@ -268,13 +273,13 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
// process complex types: attachments, bb references... // process complex types: attachments, bb references...
for (let [property, column] of Object.entries(table.schema)) { for (const [property, column] of Object.entries(table.schema)) {
if ( if (
column.type === FieldType.ATTACHMENTS || column.type === FieldType.ATTACHMENTS ||
column.type === FieldType.ATTACHMENT_SINGLE || column.type === FieldType.ATTACHMENT_SINGLE ||
column.type === FieldType.SIGNATURE_SINGLE column.type === FieldType.SIGNATURE_SINGLE
) { ) {
for (let row of enriched) { for (const row of enriched) {
if (row[property] == null) { if (row[property] == null) {
continue continue
} }
@ -299,7 +304,7 @@ export async function outputProcessing<T extends Row[] | Row>(
!opts.skipBBReferences && !opts.skipBBReferences &&
column.type == FieldType.BB_REFERENCE column.type == FieldType.BB_REFERENCE
) { ) {
for (let row of enriched) { for (const row of enriched) {
row[property] = await processOutputBBReferences( row[property] = await processOutputBBReferences(
row[property], row[property],
column.subtype column.subtype
@ -309,14 +314,14 @@ export async function outputProcessing<T extends Row[] | Row>(
!opts.skipBBReferences && !opts.skipBBReferences &&
column.type == FieldType.BB_REFERENCE_SINGLE column.type == FieldType.BB_REFERENCE_SINGLE
) { ) {
for (let row of enriched) { for (const row of enriched) {
row[property] = await processOutputBBReference( row[property] = await processOutputBBReference(
row[property], row[property],
column.subtype column.subtype
) )
} }
} else if (column.type === FieldType.DATETIME && column.timeOnly) { } else if (column.type === FieldType.DATETIME && column.timeOnly) {
for (let row of enriched) { for (const row of enriched) {
if (row[property] instanceof Date) { if (row[property] instanceof Date) {
const hours = row[property].getUTCHours().toString().padStart(2, "0") const hours = row[property].getUTCHours().toString().padStart(2, "0")
const minutes = row[property] const minutes = row[property]
@ -343,14 +348,36 @@ export async function outputProcessing<T extends Row[] | Row>(
)) as Row[] )) as Row[]
} }
// remove null properties to match internal API // remove null properties to match internal API
if (isExternalTableID(table._id!)) { const isExternal = isExternalTableID(table._id!)
for (let row of enriched) { if (isExternal) {
for (let key of Object.keys(row)) { for (const row of enriched) {
for (const key of Object.keys(row)) {
if (row[key] === null) { if (row[key] === null) {
delete row[key] delete row[key]
} }
} }
} }
} }
if (!isUserMetadataTable(table._id!)) {
const protectedColumns = isExternal
? PROTECTED_EXTERNAL_COLUMNS
: PROTECTED_INTERNAL_COLUMNS
const tableFields = Object.keys(table.schema).filter(
f => table.schema[f].visible !== false
)
const fields = [...tableFields, ...protectedColumns].map(f =>
f.toLowerCase()
)
for (const row of enriched) {
for (const key of Object.keys(row)) {
if (!fields.includes(key.toLowerCase())) {
delete row[key]
}
}
}
}
return (wasArray ? enriched : enriched[0]) as T return (wasArray ? enriched : enriched[0]) as T
} }

View File

@ -1,17 +1,18 @@
import { Context, createContext, runInNewContext } from "vm" import { Context, createContext, runInNewContext } from "vm"
import { create, TemplateDelegate } from "handlebars" import { create, TemplateDelegate } from "handlebars"
import { registerAll, registerMinimum } from "./helpers/index" import { registerAll, registerMinimum } from "./helpers/index"
import { preprocess, postprocess } from "./processors" import { postprocess, preprocess } from "./processors"
import { import {
atob, atob,
btoa, btoa,
isBackendService,
FIND_HBS_REGEX,
FIND_ANY_HBS_REGEX, FIND_ANY_HBS_REGEX,
FIND_HBS_REGEX,
findDoubleHbsInstances, findDoubleHbsInstances,
isBackendService,
prefixStrings,
} from "./utilities" } from "./utilities"
import { convertHBSBlock } from "./conversion" import { convertHBSBlock } from "./conversion"
import { setJSRunner, removeJSRunner } from "./helpers/javascript" import { removeJSRunner, setJSRunner } from "./helpers/javascript"
import manifest from "./manifest.json" import manifest from "./manifest.json"
import { ProcessOptions } from "./types" import { ProcessOptions } from "./types"
@ -23,6 +24,7 @@ export { iifeWrapper } from "./iife"
const hbsInstance = create() const hbsInstance = create()
registerAll(hbsInstance) registerAll(hbsInstance)
const helperNames = Object.keys(hbsInstance.helpers)
const hbsInstanceNoHelpers = create() const hbsInstanceNoHelpers = create()
registerMinimum(hbsInstanceNoHelpers) registerMinimum(hbsInstanceNoHelpers)
const defaultOpts: ProcessOptions = { const defaultOpts: ProcessOptions = {
@ -45,12 +47,25 @@ function testObject(object: any) {
} }
} }
function findOverlappingHelpers(context?: object) {
if (!context) {
return []
}
const contextKeys = Object.keys(context)
return contextKeys.filter(key => helperNames.includes(key))
}
/** /**
* Creates a HBS template function for a given string, and optionally caches it. * Creates a HBS template function for a given string, and optionally caches it.
*/ */
const templateCache: Record<string, TemplateDelegate<any>> = {} const templateCache: Record<string, TemplateDelegate<any>> = {}
function createTemplate(string: string, opts?: ProcessOptions) { function createTemplate(
string: string,
opts?: ProcessOptions,
context?: object
) {
opts = { ...defaultOpts, ...opts } opts = { ...defaultOpts, ...opts }
const helpersEnabled = !opts?.noHelpers
// Finalising adds a helper, can't do this with no helpers // Finalising adds a helper, can't do this with no helpers
const key = `${string}-${JSON.stringify(opts)}` const key = `${string}-${JSON.stringify(opts)}`
@ -60,7 +75,25 @@ function createTemplate(string: string, opts?: ProcessOptions) {
return templateCache[key] return templateCache[key]
} }
string = preprocess(string, opts) const overlappingHelpers = helpersEnabled
? findOverlappingHelpers(context)
: []
string = preprocess(string, {
...opts,
disabledHelpers: overlappingHelpers,
})
if (context && helpersEnabled) {
if (overlappingHelpers.length > 0) {
for (const block of findHBSBlocks(string)) {
string = string.replace(
block,
prefixStrings(block, overlappingHelpers, "./")
)
}
}
}
// Optionally disable built in HBS escaping // Optionally disable built in HBS escaping
if (opts.noEscaping) { if (opts.noEscaping) {
@ -70,6 +103,7 @@ function createTemplate(string: string, opts?: ProcessOptions) {
// This does not throw an error when template can't be fulfilled, // This does not throw an error when template can't be fulfilled,
// have to try correct beforehand // have to try correct beforehand
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const template = instance.compile(string, { const template = instance.compile(string, {
strict: false, strict: false,
}) })
@ -171,7 +205,8 @@ export function processStringSync(
throw "Cannot process non-string types." throw "Cannot process non-string types."
} }
function process(stringPart: string) { function process(stringPart: string) {
const template = createTemplate(stringPart, opts) // context is needed to check for overlap between helpers and context
const template = createTemplate(stringPart, opts, context)
const now = Math.floor(Date.now() / 1000) * 1000 const now = Math.floor(Date.now() / 1000) * 1000
const processedString = template({ const processedString = template({
now: new Date(now).toISOString(), now: new Date(now).toISOString(),

View File

@ -29,9 +29,9 @@ export function preprocess(string: string, opts: ProcessOptions) {
processor => processor.name !== preprocessor.PreprocessorNames.FINALISE processor => processor.name !== preprocessor.PreprocessorNames.FINALISE
) )
} }
return process(string, processors, opts) return process(string, processors, opts)
} }
export function postprocess(string: string) { export function postprocess(string: string) {
let processors = postprocessor.processors return process(string, postprocessor.processors)
return process(string, processors)
} }

View File

@ -1,19 +1,21 @@
import { LITERAL_MARKER } from "../helpers/constants" import { LITERAL_MARKER } from "../helpers/constants"
export const PostProcessorNames = { export enum PostProcessorNames {
CONVERT_LITERALS: "convert-literals", CONVERT_LITERALS = "convert-literals",
} }
class Postprocessor { type PostprocessorFn = (statement: string) => string
name: string
private fn: any
constructor(name: string, fn: any) { class Postprocessor {
name: PostProcessorNames
private readonly fn: PostprocessorFn
constructor(name: PostProcessorNames, fn: PostprocessorFn) {
this.name = name this.name = name
this.fn = fn this.fn = fn
} }
process(statement: any) { process(statement: string) {
return this.fn(statement) return this.fn(statement)
} }
} }

View File

@ -1,25 +1,28 @@
import { HelperNames } from "../helpers" import { HelperNames } from "../helpers"
import { swapStrings, isAlphaNumeric } from "../utilities" import { swapStrings, isAlphaNumeric } from "../utilities"
import { ProcessOptions } from "../types"
const FUNCTION_CASES = ["#", "else", "/"] const FUNCTION_CASES = ["#", "else", "/"]
export const PreprocessorNames = { export enum PreprocessorNames {
SWAP_TO_DOT: "swap-to-dot-notation", SWAP_TO_DOT = "swap-to-dot-notation",
FIX_FUNCTIONS: "fix-functions", FIX_FUNCTIONS = "fix-functions",
FINALISE: "finalise", FINALISE = "finalise",
NORMALIZE_SPACES: "normalize-spaces", NORMALIZE_SPACES = "normalize-spaces",
} }
type PreprocessorFn = (statement: string, opts?: ProcessOptions) => string
class Preprocessor { class Preprocessor {
name: string name: string
private fn: any private readonly fn: PreprocessorFn
constructor(name: string, fn: any) { constructor(name: PreprocessorNames, fn: PreprocessorFn) {
this.name = name this.name = name
this.fn = fn this.fn = fn
} }
process(fullString: string, statement: string, opts: Object) { process(fullString: string, statement: string, opts: ProcessOptions) {
const output = this.fn(statement, opts) const output = this.fn(statement, opts)
const idx = fullString.indexOf(statement) const idx = fullString.indexOf(statement)
return swapStrings(fullString, idx, statement.length, output) return swapStrings(fullString, idx, statement.length, output)
@ -56,8 +59,9 @@ export const processors = [
}), }),
new Preprocessor( new Preprocessor(
PreprocessorNames.FINALISE, PreprocessorNames.FINALISE,
(statement: string, opts: { noHelpers: any }) => { (statement: string, opts?: ProcessOptions) => {
const noHelpers = opts && opts.noHelpers const noHelpers = opts?.noHelpers
const helpersEnabled = !noHelpers
let insideStatement = statement.slice(2, statement.length - 2) let insideStatement = statement.slice(2, statement.length - 2)
if (insideStatement.charAt(0) === " ") { if (insideStatement.charAt(0) === " ") {
insideStatement = insideStatement.slice(1) insideStatement = insideStatement.slice(1)
@ -74,7 +78,8 @@ export const processors = [
} }
const testHelper = possibleHelper.trim().toLowerCase() const testHelper = possibleHelper.trim().toLowerCase()
if ( if (
!noHelpers && helpersEnabled &&
!opts?.disabledHelpers?.includes(testHelper) &&
HelperNames().some(option => testHelper === option.toLowerCase()) HelperNames().some(option => testHelper === option.toLowerCase())
) { ) {
insideStatement = `(${insideStatement})` insideStatement = `(${insideStatement})`

View File

@ -5,4 +5,5 @@ export interface ProcessOptions {
noFinalise?: boolean noFinalise?: boolean
escapeNewlines?: boolean escapeNewlines?: boolean
onlyFound?: boolean onlyFound?: boolean
disabledHelpers?: string[]
} }

View File

@ -66,3 +66,16 @@ export const btoa = (plainText: string) => {
export const atob = (base64: string) => { export const atob = (base64: string) => {
return Buffer.from(base64, "base64").toString("utf-8") return Buffer.from(base64, "base64").toString("utf-8")
} }
export const prefixStrings = (
baseString: string,
strings: string[],
prefix: string
) => {
// Escape any special characters in the strings to avoid regex errors
const escapedStrings = strings.map(str =>
str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
)
const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g")
return baseString.replace(regexPattern, `${prefix}$1`)
}

View File

@ -483,3 +483,37 @@ describe("uuid", () => {
expect(output).toMatch(UUID_REGEX) expect(output).toMatch(UUID_REGEX)
}) })
}) })
describe("helper overlap", () => {
it("should use context over helpers (regex test helper)", async () => {
const output = await processString("{{ test }}", { test: "a" })
expect(output).toEqual("a")
})
it("should use helper if no sum in context, return the context value otherwise", async () => {
const hbs = "{{ sum 1 2 }}"
const output = await processString(hbs, {})
expect(output).toEqual("3")
const secondaryOutput = await processString(hbs, { sum: "a" })
expect(secondaryOutput).toEqual("a")
})
it("should handle multiple cases", async () => {
const output = await processString("{{ literal (split test sum) }}", {
test: "a-b",
sum: "-",
})
expect(output).toEqual(["a", "b"])
})
it("should work as expected when no helpers are set", async () => {
const output = await processString(
"{{ sum }}",
{
sum: "a",
},
{ noHelpers: true }
)
expect(output).toEqual("a")
})
})

View File

@ -111,10 +111,15 @@ export type LoopStepOutputs = {
} }
export type BranchStepInputs = { export type BranchStepInputs = {
conditions: SearchFilters branches: Branch[]
children?: Record<string, AutomationStep[]> children?: Record<string, AutomationStep[]>
} }
export type Branch = {
name: string
condition: SearchFilters
}
export type MakeIntegrationInputs = { export type MakeIntegrationInputs = {
url: string url: string
body: any body: any

View File

@ -131,7 +131,7 @@ export interface RowAttachment {
size: number size: number
name: string name: string
extension: string extension: string
key: string key?: string
// Populated on read // Populated on read
url?: string url?: string
} }

View File

@ -1,5 +1,4 @@
export enum FeatureFlag { export enum FeatureFlag {
LICENSING = "LICENSING",
PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE", PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE",
PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT", PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT",
} }

View File

@ -44,7 +44,6 @@ ENV NODE_OPTIONS="--no-node-snapshot"
ENV CLUSTER_MODE=${CLUSTER_MODE} ENV CLUSTER_MODE=${CLUSTER_MODE}
ENV SERVICE=worker-service ENV SERVICE=worker-service
ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU ENV POSTHOG_TOKEN=phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
ENV TENANT_FEATURE_FLAGS=*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR
ENV ACCOUNT_PORTAL_URL=https://account.budibase.app ENV ACCOUNT_PORTAL_URL=https://account.budibase.app
ARG BUDIBASE_VERSION ARG BUDIBASE_VERSION

View File

@ -26,7 +26,6 @@ async function init() {
APPS_URL: "http://localhost:4001", APPS_URL: "http://localhost:4001",
SERVICE: "worker-service", SERVICE: "worker-service",
DEPLOYMENT_ENVIRONMENT: "development", DEPLOYMENT_ENVIRONMENT: "development",
TENANT_FEATURE_FLAGS: "*:LICENSING,*:USER_GROUPS,*:ONBOARDING_TOUR",
ENABLE_EMAIL_TEST_MODE: "1", ENABLE_EMAIL_TEST_MODE: "1",
HTTP_LOGGING: "0", HTTP_LOGGING: "0",
VERSION: "0.0.0+local", VERSION: "0.0.0+local",

662
yarn.lock

File diff suppressed because it is too large Load Diff