Merge branch 'cheeks-lab-day-eject-blocks' of github.com:Budibase/budibase into form-block

This commit is contained in:
Andrew Kingston 2022-09-06 09:00:21 +01:00
commit 5577f31a17
173 changed files with 5252 additions and 2327 deletions

View File

@ -4,7 +4,7 @@
"singleQuote": false, "singleQuote": false,
"trailingComma": "es5", "trailingComma": "es5",
"arrowParens": "avoid", "arrowParens": "avoid",
"jsxBracketSameLine": false, "bracketSameLine": false,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"svelteSortOrder": "options-scripts-markup-styles" "svelteSortOrder": "options-scripts-markup-styles"
} }

View File

@ -130,6 +130,22 @@ spec:
- name: BB_ADMIN_USER_PASSWORD - name: BB_ADMIN_USER_PASSWORD
value: { { .Values.globals.bbAdminUserPassword | quote } } value: { { .Values.globals.bbAdminUserPassword | quote } }
{{ end }} {{ end }}
{{ if .Values.services.apps.nodeDebug }}
- name: NODE_DEBUG
value: {{ .Values.services.apps.nodeDebug | quote }}
{{ end }}
{{ if .Values.globals.elasticApmEnabled }}
- name: ELASTIC_APM_ENABLED
value: {{ .Values.globals.elasticApmEnabled | quote }}
{{ end }}
{{ if .Values.globals.elasticApmSecretToken }}
- name: ELASTIC_APM_SECRET_TOKEN
value: {{ .Values.globals.elasticApmSecretToken | quote }}
{{ end }}
{{ if .Values.globals.elasticApmServerUrl }}
- name: ELASTIC_APM_SERVER_URL
value: {{ .Values.globals.elasticApmServerUrl | quote }}
{{ end }}
image: budibase/apps:{{ .Values.globals.appVersion }} image: budibase/apps:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
@ -142,7 +158,10 @@ spec:
name: bbapps name: bbapps
ports: ports:
- containerPort: {{ .Values.services.apps.port }} - containerPort: {{ .Values.services.apps.port }}
resources: {} {{ with .Values.services.apps.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{ end }}
{{- with .Values.affinity }} {{- with .Values.affinity }}
affinity: affinity:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}

View File

@ -38,7 +38,10 @@ spec:
image: redgeoff/replicate-couchdb-cluster image: redgeoff/replicate-couchdb-cluster
imagePullPolicy: Always imagePullPolicy: Always
name: couchdb-backup name: couchdb-backup
resources: {} {{ with .Values.services.couchdb.backup.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{ end }}
{{- with .Values.affinity }} {{- with .Values.affinity }}
affinity: affinity:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}

View File

@ -56,7 +56,10 @@ spec:
name: minio-service name: minio-service
ports: ports:
- containerPort: {{ .Values.services.objectStore.port }} - containerPort: {{ .Values.services.objectStore.port }}
resources: {} {{ with .Values.services.objectStore.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{ end }}
volumeMounts: volumeMounts:
- mountPath: /data - mountPath: /data
name: minio-data name: minio-data

View File

@ -30,7 +30,10 @@ spec:
name: proxy-service name: proxy-service
ports: ports:
- containerPort: {{ .Values.services.proxy.port }} - containerPort: {{ .Values.services.proxy.port }}
resources: {} {{ with .Values.services.proxy.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{ end }}
volumeMounts: volumeMounts:
{{- with .Values.affinity }} {{- with .Values.affinity }}
affinity: affinity:

View File

@ -35,7 +35,10 @@ spec:
name: redis-service name: redis-service
ports: ports:
- containerPort: {{ .Values.services.redis.port }} - containerPort: {{ .Values.services.redis.port }}
resources: {} {{ with .Values.services.redis.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{ end }}
volumeMounts: volumeMounts:
- mountPath: /data - mountPath: /data
name: redis-data name: redis-data

View File

@ -27,6 +27,8 @@ spec:
spec: spec:
containers: containers:
- env: - env:
- name: BUDIBASE_ENVIRONMENT
value: {{ .Values.globals.budibaseEnv }}
- name: DEPLOYMENT_ENVIRONMENT - name: DEPLOYMENT_ENVIRONMENT
value: "kubernetes" value: "kubernetes"
- name: CLUSTER_PORT - name: CLUSTER_PORT
@ -125,6 +127,19 @@ spec:
value: {{ .Values.globals.google.secret | quote }} value: {{ .Values.globals.google.secret | quote }}
- name: TENANT_FEATURE_FLAGS - name: TENANT_FEATURE_FLAGS
value: {{ .Values.globals.tenantFeatureFlags | quote }} value: {{ .Values.globals.tenantFeatureFlags | quote }}
{{ if .Values.globals.elasticApmEnabled }}
- name: ELASTIC_APM_ENABLED
value: {{ .Values.globals.elasticApmEnabled | quote }}
{{ end }}
{{ if .Values.globals.elasticApmSecretToken }}
- name: ELASTIC_APM_SECRET_TOKEN
value: {{ .Values.globals.elasticApmSecretToken | quote }}
{{ end }}
{{ if .Values.globals.elasticApmServerUrl }}
- name: ELASTIC_APM_SERVER_URL
value: {{ .Values.globals.elasticApmServerUrl | quote }}
{{ end }}
image: budibase/worker:{{ .Values.globals.appVersion }} image: budibase/worker:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
livenessProbe: livenessProbe:
@ -136,7 +151,10 @@ spec:
name: bbworker name: bbworker
ports: ports:
- containerPort: {{ .Values.services.worker.port }} - containerPort: {{ .Values.services.worker.port }}
resources: {} {{ with .Values.services.worker.resources }}
resources:
{{- toYaml . | nindent 10 }}
{{ end }}
{{- with .Values.affinity }} {{- with .Values.affinity }}
affinity: affinity:
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}

View File

@ -60,19 +60,6 @@ ingress:
port: port:
number: 10000 number: 10000
resources:
{}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling: autoscaling:
enabled: false enabled: false
minReplicas: 1 minReplicas: 1
@ -114,6 +101,10 @@ globals:
smtp: smtp:
enabled: false enabled: false
# elasticApmEnabled:
# elasticApmSecretToken:
# elasticApmServerUrl:
services: services:
budibaseVersion: latest budibaseVersion: latest
dns: cluster.local dns: cluster.local
@ -121,15 +112,19 @@ services:
proxy: proxy:
port: 10000 port: 10000
replicaCount: 1 replicaCount: 1
resources: {}
apps: apps:
port: 4002 port: 4002
replicaCount: 1 replicaCount: 1
logLevel: info logLevel: info
resources: {}
# nodeDebug: "" # set the value of NODE_DEBUG
worker: worker:
port: 4003 port: 4003
replicaCount: 1 replicaCount: 1
resources: {}
couchdb: couchdb:
enabled: true enabled: true
@ -143,6 +138,7 @@ services:
target: "" target: ""
# backup interval in seconds # backup interval in seconds
interval: "" interval: ""
resources: {}
redis: redis:
enabled: true # disable if using external redis enabled: true # disable if using external redis
@ -156,6 +152,7 @@ services:
## If undefined (the default) or set to null, no storageClassName spec is ## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. ## set, choosing the default provisioner.
storageClass: "" storageClass: ""
resources: {}
objectStore: objectStore:
minio: true minio: true
@ -172,6 +169,7 @@ services:
## If undefined (the default) or set to null, no storageClassName spec is ## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. ## set, choosing the default provisioner.
storageClass: "" storageClass: ""
resources: {}
# Override values in couchDB subchart # Override values in couchDB subchart
couchdb: couchdb:

View File

@ -78,6 +78,7 @@ services:
image: budibase/proxy image: budibase/proxy
environment: environment:
- PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 - PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
- PROXY_RATE_LIMIT_API_PER_SECOND=20
depends_on: depends_on:
- minio-service - minio-service
- worker-service - worker-service

View File

@ -15,7 +15,10 @@ http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for" '
'response_time=$upstream_response_time proxy_host=$proxy_host upstream_addr=$upstream_addr';
access_log /var/log/nginx/access.log main;
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default "upgrade"; default "upgrade";

View File

@ -11,7 +11,7 @@ events {
http { http {
# rate limiting # rate limiting
limit_req_status 429; limit_req_status 429;
limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s; limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=${PROXY_RATE_LIMIT_API_PER_SECOND}r/s;
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=${PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND}r/s; limit_req_zone $binary_remote_addr zone=webhooks:10m rate=${PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND}r/s;
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
@ -33,7 +33,10 @@ http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" ' '$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"'; '"$http_user_agent" "$http_x_forwarded_for" '
'response_time=$upstream_response_time proxy_host=$proxy_host upstream_addr=$upstream_addr';
access_log /var/log/nginx/access.log main;
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default "upgrade"; default "upgrade";

View File

@ -10,4 +10,5 @@ COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template
COPY error.html /usr/share/nginx/html/error.html COPY error.html /usr/share/nginx/html/error.html
# Default environment # Default environment
ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10 ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
ENV PROXY_RATE_LIMIT_API_PER_SECOND=20

View File

@ -1,5 +1,5 @@
{ {
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -20,7 +20,7 @@
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
"dependencies": { "dependencies": {
"@budibase/types": "1.2.44-alpha.6", "@budibase/types": "1.3.4-alpha.2",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-sdk": "2.1030.0", "aws-sdk": "2.1030.0",
"bcrypt": "5.0.1", "bcrypt": "5.0.1",

View File

@ -1,11 +1,11 @@
const passport = require("koa-passport") const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
const { getGlobalDB } = require("./tenancy") import { getGlobalDB } from "./tenancy"
const refresh = require("passport-oauth2-refresh") const refresh = require("passport-oauth2-refresh")
const { Configs } = require("./constants") import { Configs } from "./constants"
const { getScopedConfig } = require("./db/utils") import { getScopedConfig } from "./db/utils"
const { import {
jwt, jwt,
local, local,
authenticated, authenticated,
@ -13,7 +13,6 @@ const {
oidc, oidc,
auditLog, auditLog,
tenancy, tenancy,
appTenancy,
authError, authError,
ssoCallbackUrl, ssoCallbackUrl,
csrf, csrf,
@ -22,32 +21,36 @@ const {
builderOnly, builderOnly,
builderOrAdmin, builderOrAdmin,
joiValidator, joiValidator,
} = require("./middleware") } from "./middleware"
import { invalidateUser } from "./cache/user"
const { invalidateUser } = require("./cache/user") import { User } from "@budibase/types"
// Strategies // Strategies
passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new LocalStrategy(local.options, local.authenticate))
passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
passport.serializeUser((user, done) => done(null, user)) passport.serializeUser((user: User, done: any) => done(null, user))
passport.deserializeUser(async (user, done) => { passport.deserializeUser(async (user: User, done: any) => {
const db = getGlobalDB() const db = getGlobalDB()
try { try {
const user = await db.get(user._id) const dbUser = await db.get(user._id)
return done(null, user) return done(null, dbUser)
} catch (err) { } catch (err) {
console.error(`User not found`, err) console.error(`User not found`, err)
return done(null, false, { message: "User not found" }) return done(null, false, { message: "User not found" })
} }
}) })
async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { async function refreshOIDCAccessToken(
db: any,
chosenConfig: any,
refreshToken: string
) {
const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig) const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig)
let enrichedConfig let enrichedConfig: any
let strategy let strategy: any
try { try {
enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl) enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl)
@ -70,22 +73,28 @@ async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) {
refresh.requestNewAccessToken( refresh.requestNewAccessToken(
Configs.OIDC, Configs.OIDC,
refreshToken, refreshToken,
(err, accessToken, refreshToken, params) => { (err: any, accessToken: string, refreshToken: any, params: any) => {
resolve({ err, accessToken, refreshToken, params }) resolve({ err, accessToken, refreshToken, params })
} }
) )
}) })
} }
async function refreshGoogleAccessToken(db, config, refreshToken) { async function refreshGoogleAccessToken(
db: any,
config: any,
refreshToken: any
) {
let callbackUrl = await google.getCallbackUrl(db, config) let callbackUrl = await google.getCallbackUrl(db, config)
let strategy let strategy
try { try {
strategy = await google.strategyFactory(config, callbackUrl) strategy = await google.strategyFactory(config, callbackUrl)
} catch (err) { } catch (err: any) {
console.error(err) console.error(err)
throw new Error("Error constructing OIDC refresh strategy", err) throw new Error(
`Error constructing OIDC refresh strategy: message=${err.message}`
)
} }
refresh.use(strategy) refresh.use(strategy)
@ -94,14 +103,18 @@ async function refreshGoogleAccessToken(db, config, refreshToken) {
refresh.requestNewAccessToken( refresh.requestNewAccessToken(
Configs.GOOGLE, Configs.GOOGLE,
refreshToken, refreshToken,
(err, accessToken, refreshToken, params) => { (err: any, accessToken: string, refreshToken: string, params: any) => {
resolve({ err, accessToken, refreshToken, params }) resolve({ err, accessToken, refreshToken, params })
} }
) )
}) })
} }
async function refreshOAuthToken(refreshToken, configType, configId) { async function refreshOAuthToken(
refreshToken: string,
configType: string,
configId: string
) {
const db = getGlobalDB() const db = getGlobalDB()
const config = await getScopedConfig(db, { const config = await getScopedConfig(db, {
@ -113,7 +126,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
let refreshResponse let refreshResponse
if (configType === Configs.OIDC) { if (configType === Configs.OIDC) {
// configId - retrieved from cookie. // configId - retrieved from cookie.
chosenConfig = config.configs.filter(c => c.uuid === configId)[0] chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0]
if (!chosenConfig) { if (!chosenConfig) {
throw new Error("Invalid OIDC configuration") throw new Error("Invalid OIDC configuration")
} }
@ -134,7 +147,7 @@ async function refreshOAuthToken(refreshToken, configType, configId) {
return refreshResponse return refreshResponse
} }
async function updateUserOAuth(userId, oAuthConfig) { async function updateUserOAuth(userId: string, oAuthConfig: any) {
const details = { const details = {
accessToken: oAuthConfig.accessToken, accessToken: oAuthConfig.accessToken,
refreshToken: oAuthConfig.refreshToken, refreshToken: oAuthConfig.refreshToken,
@ -162,14 +175,13 @@ async function updateUserOAuth(userId, oAuthConfig) {
} }
} }
module.exports = { export = {
buildAuthMiddleware: authenticated, buildAuthMiddleware: authenticated,
passport, passport,
google, google,
oidc, oidc,
jwt: require("jsonwebtoken"), jwt: require("jsonwebtoken"),
buildTenancyMiddleware: tenancy, buildTenancyMiddleware: tenancy,
buildAppTenancyMiddleware: appTenancy,
auditLog, auditLog,
authError, authError,
buildCsrfMiddleware: csrf, buildCsrfMiddleware: csrf,

View File

@ -18,6 +18,7 @@ export enum ViewName {
LINK = "by_link", LINK = "by_link",
ROUTING = "screen_routes", ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
} }
export const DeprecatedViews = { export const DeprecatedViews = {
@ -41,6 +42,7 @@ export enum DocumentType {
MIGRATIONS = "migrations", MIGRATIONS = "migrations",
DEV_INFO = "devinfo", DEV_INFO = "devinfo",
AUTOMATION_LOG = "log_au", AUTOMATION_LOG = "log_au",
ACCOUNT_METADATA = "acc_metadata",
} }
export const StaticDatabases = { export const StaticDatabases = {

View File

@ -5,6 +5,8 @@ const {
SEPARATOR, SEPARATOR,
} = require("./utils") } = require("./utils")
const { getGlobalDB } = require("../tenancy") const { getGlobalDB } = require("../tenancy")
const { StaticDatabases } = require("./constants")
const { doWithDB } = require("./")
const DESIGN_DB = "_design/database" const DESIGN_DB = "_design/database"
@ -56,6 +58,31 @@ exports.createNewUserEmailView = async () => {
await db.put(designDoc) await db.put(designDoc)
} }
exports.createAccountEmailView = async () => {
await doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
let designDoc
try {
designDoc = await db.get(DESIGN_DB)
} catch (err) {
// no design doc, make one
designDoc = DesignDoc()
}
const view = {
// if using variables in a map function need to inject them before use
map: `function(doc) {
if (doc._id.startsWith("${DocumentType.ACCOUNT_METADATA}${SEPARATOR}")) {
emit(doc.email.toLowerCase(), doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewName.ACCOUNT_BY_EMAIL]: view,
}
await db.put(designDoc)
})
}
exports.createUserAppView = async () => { exports.createUserAppView = async () => {
const db = getGlobalDB() const db = getGlobalDB()
let designDoc let designDoc
@ -128,6 +155,39 @@ exports.createUserBuildersView = async () => {
await db.put(designDoc) await db.put(designDoc)
} }
exports.queryView = async (viewName, params, db, CreateFuncByName) => {
try {
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
if (params.arrayResponse) {
return response
} else {
return response.length <= 1 ? response[0] : response
}
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName)
await createFunc()
return exports.queryView(viewName, params, db, CreateFuncByName)
} else {
throw err
}
}
}
exports.queryPlatformView = async (viewName, params) => {
const CreateFuncByName = {
[ViewName.ACCOUNT_BY_EMAIL]: exports.createAccountEmailView,
}
return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => {
return exports.queryView(viewName, params, db, CreateFuncByName)
})
}
exports.queryGlobalView = async (viewName, params, db = null) => { exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = { const CreateFuncByName = {
[ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView, [ViewName.USER_BY_EMAIL]: exports.createNewUserEmailView,
@ -139,20 +199,5 @@ exports.queryGlobalView = async (viewName, params, db = null) => {
if (!db) { if (!db) {
db = getGlobalDB() db = getGlobalDB()
} }
try { return exports.queryView(viewName, params, db, CreateFuncByName)
let response = (await db.query(`database/${viewName}`, params)).rows
response = response.map(resp =>
params.include_docs ? resp.doc : resp.value
)
return response.length <= 1 ? response[0] : response
} catch (err) {
if (err != null && err.name === "not_found") {
const createFunc = CreateFuncByName[viewName]
await removeDeprecated(db, viewName)
await createFunc()
return exports.queryGlobalView(viewName, params)
} else {
throw err
}
}
} }

View File

@ -8,4 +8,5 @@ import { processors } from "./processors"
export const shutdown = () => { export const shutdown = () => {
processors.shutdown() processors.shutdown()
console.log("Events shutdown")
} }

View File

@ -17,6 +17,7 @@ import constants from "./constants"
import * as dbConstants from "./db/constants" import * as dbConstants from "./db/constants"
import logging from "./logging" import logging from "./logging"
import pino from "./pino" import pino from "./pino"
import * as middleware from "./middleware"
// mimic the outer package exports // mimic the outer package exports
import * as db from "./pkg/db" import * as db from "./pkg/db"
@ -57,6 +58,7 @@ const core = {
roles, roles,
...pino, ...pino,
...errorClasses, ...errorClasses,
middleware,
} }
export = core export = core

View File

@ -65,7 +65,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
* The tenancy modules should not be used here and it should be assumed that the tenancy context * The tenancy modules should not be used here and it should be assumed that the tenancy context
* has not yet been populated. * has not yet been populated.
*/ */
module.exports = ( export = (
noAuthPatterns = [], noAuthPatterns = [],
opts: { publicAllowed: boolean; populateUser?: Function } = { opts: { publicAllowed: boolean; populateUser?: Function } = {
publicAllowed: false, publicAllowed: false,

View File

@ -13,7 +13,8 @@ const adminOnly = require("./adminOnly")
const builderOrAdmin = require("./builderOrAdmin") const builderOrAdmin = require("./builderOrAdmin")
const builderOnly = require("./builderOnly") const builderOnly = require("./builderOnly")
const joiValidator = require("./joi-validator") const joiValidator = require("./joi-validator")
module.exports = {
const pkg = {
google, google,
oidc, oidc,
jwt, jwt,
@ -33,3 +34,5 @@ module.exports = {
builderOrAdmin, builderOrAdmin,
joiValidator, joiValidator,
} }
export = pkg

View File

@ -13,10 +13,13 @@ function validate(schema, property) {
params = ctx.request[property] params = ctx.request[property]
} }
schema = schema.append({ // not all schemas have the append property e.g. array schemas
createdAt: Joi.any().optional(), if (schema.append) {
updatedAt: Joi.any().optional(), schema = schema.append({
}) createdAt: Joi.any().optional(),
updatedAt: Joi.any().optional(),
})
}
const { error } = schema.validate(params) const { error } = schema.validate(params)
if (error) { if (error) {

View File

@ -66,15 +66,13 @@ const PUBLIC_BUCKETS = [ObjectStoreBuckets.APPS, ObjectStoreBuckets.GLOBAL]
* @constructor * @constructor
*/ */
export const ObjectStore = (bucket: any) => { export const ObjectStore = (bucket: any) => {
AWS.config.update({
accessKeyId: env.MINIO_ACCESS_KEY,
secretAccessKey: env.MINIO_SECRET_KEY,
region: env.AWS_REGION,
})
const config: any = { const config: any = {
s3ForcePathStyle: true, s3ForcePathStyle: true,
signatureVersion: "v4", signatureVersion: "v4",
apiVersion: "2006-03-01", apiVersion: "2006-03-01",
accessKeyId: env.MINIO_ACCESS_KEY,
secretAccessKey: env.MINIO_SECRET_KEY,
region: env.AWS_REGION,
} }
if (bucket) { if (bucket) {
config.params = { config.params = {

View File

@ -3,17 +3,27 @@ const { v4: uuidv4 } = require("uuid")
const { logWarn } = require("../logging") const { logWarn } = require("../logging")
const env = require("../environment") const env = require("../environment")
interface Session { interface CreateSession {
key: string
userId: string
sessionId: string sessionId: string
lastAccessedAt: string tenantId: string
createdAt: string
csrfToken?: string csrfToken?: string
value: string
} }
type SessionKey = { key: string }[] interface Session extends CreateSession {
userId: string
lastAccessedAt: string
createdAt: string
// make optional attributes required
csrfToken: string
}
interface SessionKey {
key: string
}
interface ScannedSession {
value: Session
}
// a week in seconds // a week in seconds
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = 86400 * 7
@ -22,14 +32,14 @@ function makeSessionID(userId: string, sessionId: string) {
return `${userId}/${sessionId}` return `${userId}/${sessionId}`
} }
export async function getSessionsForUser(userId: string) { export async function getSessionsForUser(userId: string): Promise<Session[]> {
if (!userId) { if (!userId) {
console.trace("Cannot get sessions for undefined userId") console.trace("Cannot get sessions for undefined userId")
return [] return []
} }
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const sessions = await client.scan(userId) const sessions: ScannedSession[] = await client.scan(userId)
return sessions.map((session: Session) => session.value) return sessions.map(session => session.value)
} }
export async function invalidateSessions( export async function invalidateSessions(
@ -39,33 +49,32 @@ export async function invalidateSessions(
try { try {
const reason = opts?.reason || "unknown" const reason = opts?.reason || "unknown"
let sessionIds: string[] = opts.sessionIds || [] let sessionIds: string[] = opts.sessionIds || []
let sessions: SessionKey let sessionKeys: SessionKey[]
// If no sessionIds, get all the sessions for the user // If no sessionIds, get all the sessions for the user
if (sessionIds.length === 0) { if (sessionIds.length === 0) {
sessions = await getSessionsForUser(userId) const sessions = await getSessionsForUser(userId)
sessions.forEach( sessionKeys = sessions.map(session => ({
(session: any) => key: makeSessionID(session.userId, session.sessionId),
(session.key = makeSessionID(session.userId, session.sessionId)) }))
)
} else { } else {
// use the passed array of sessionIds // use the passed array of sessionIds
sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds] sessionIds = Array.isArray(sessionIds) ? sessionIds : [sessionIds]
sessions = sessionIds.map((sessionId: string) => ({ sessionKeys = sessionIds.map(sessionId => ({
key: makeSessionID(userId, sessionId), key: makeSessionID(userId, sessionId),
})) }))
} }
if (sessions && sessions.length > 0) { if (sessionKeys && sessionKeys.length > 0) {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const promises = [] const promises = []
for (let session of sessions) { for (let sessionKey of sessionKeys) {
promises.push(client.delete(session.key)) promises.push(client.delete(sessionKey.key))
} }
if (!env.isTest()) { if (!env.isTest()) {
logWarn( logWarn(
`Invalidating sessions for ${userId} (reason: ${reason}) - ${sessions `Invalidating sessions for ${userId} (reason: ${reason}) - ${sessionKeys
.map(session => session.key) .map(sessionKey => sessionKey.key)
.join(", ")}` .join(", ")}`
) )
} }
@ -76,22 +85,26 @@ export async function invalidateSessions(
} }
} }
export async function createASession(userId: string, session: Session) { export async function createASession(
userId: string,
createSession: CreateSession
) {
// invalidate all other sessions // invalidate all other sessions
await invalidateSessions(userId, { reason: "creation" }) await invalidateSessions(userId, { reason: "creation" })
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const sessionId = session.sessionId const sessionId = createSession.sessionId
if (!session.csrfToken) { const csrfToken = createSession.csrfToken ? createSession.csrfToken : uuidv4()
session.csrfToken = uuidv4() const key = makeSessionID(userId, sessionId)
}
session = { const session: Session = {
...session, ...createSession,
csrfToken,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(),
userId, userId,
} }
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) await client.store(key, session, EXPIRY_SECONDS)
} }
export async function updateSessionTTL(session: Session) { export async function updateSessionTTL(session: Session) {
@ -106,7 +119,10 @@ export async function endSession(userId: string, sessionId: string) {
await client.delete(makeSessionID(userId, sessionId)) await client.delete(makeSessionID(userId, sessionId))
} }
export async function getSession(userId: string, sessionId: string) { export async function getSession(
userId: string,
sessionId: string
): Promise<Session> {
if (!userId || !sessionId) { if (!userId || !sessionId) {
throw new Error(`Invalid session details - ${userId} - ${sessionId}`) throw new Error(`Invalid session details - ${userId} - ${sessionId}`)
} }

View File

@ -11,7 +11,6 @@ const { UNICODE_MAX } = require("./db/constants")
* Given an email address this will use a view to search through * Given an email address this will use a view to search through
* all the users to find one with this email address. * all the users to find one with this email address.
* @param {string} email the email to lookup the user by. * @param {string} email the email to lookup the user by.
* @return {Promise<object|null>}
*/ */
exports.getGlobalUserByEmail = async email => { exports.getGlobalUserByEmail = async email => {
if (email == null) { if (email == null) {

View File

@ -0,0 +1,7 @@
export const getAccount = jest.fn()
export const getAccountByTenantId = jest.fn()
jest.mock("../../../src/cloud/accounts", () => ({
getAccount,
getAccountByTenantId,
}))

View File

@ -1,2 +0,0 @@
exports.MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
exports.MOCK_DATE_TIMESTAMP = 1577836800000

View File

@ -0,0 +1,2 @@
export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z")
export const MOCK_DATE_TIMESTAMP = 1577836800000

View File

@ -1,9 +0,0 @@
const posthog = require("./posthog")
const events = require("./events")
const date = require("./date")
module.exports = {
posthog,
date,
events,
}

View File

@ -0,0 +1,4 @@
import "./posthog"
import "./events"
export * as accounts from "./accounts"
export * as date from "./date"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.2.1", "@adobe/spectrum-css-workflow-icons": "^1.2.1",
"@budibase/string-templates": "1.2.44-alpha.6", "@budibase/string-templates": "1.3.4-alpha.2",
"@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actionbutton": "^1.0.1",
"@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1",
"@spectrum-css/avatar": "^3.0.2", "@spectrum-css/avatar": "^3.0.2",

View File

@ -1,4 +1,4 @@
export default function positionDropdown(element, { anchor, align }) { export default function positionDropdown(element, { anchor, align, maxWidth }) {
let positionSide = "top" let positionSide = "top"
let maxHeight = 0 let maxHeight = 0
let dimensions = getDimensions(anchor) let dimensions = getDimensions(anchor)
@ -34,13 +34,24 @@ export default function positionDropdown(element, { anchor, align }) {
} }
function calcLeftPosition() { function calcLeftPosition() {
return align === "right" let left
? dimensions.left + dimensions.width - dimensions.containerWidth
: dimensions.left if (align == "right") {
left = dimensions.left + dimensions.width - dimensions.containerWidth
} else if (align == "right-side") {
left = dimensions.left + dimensions.width
} else {
left = dimensions.left
}
return left
} }
element.style.position = "absolute" element.style.position = "absolute"
element.style.zIndex = "9999" element.style.zIndex = "9999"
if (maxWidth) {
element.style.maxWidth = `${maxWidth}px`
}
element.style.minWidth = `${dimensions.width}px` element.style.minWidth = `${dimensions.width}px`
element.style.maxHeight = `${maxHeight.toFixed(0)}px` element.style.maxHeight = `${maxHeight.toFixed(0)}px`
element.style.transformOrigin = `center ${positionSide}` element.style.transformOrigin = `center ${positionSide}`
@ -54,10 +65,8 @@ export default function positionDropdown(element, { anchor, align }) {
element.style.left = `${calcLeftPosition(dimensions).toFixed(0)}px` element.style.left = `${calcLeftPosition(dimensions).toFixed(0)}px`
}) })
}) })
resizeObserver.observe(anchor) resizeObserver.observe(anchor)
resizeObserver.observe(element) resizeObserver.observe(element)
return { return {
destroy() { destroy() {
resizeObserver.disconnect() resizeObserver.disconnect()

View File

@ -67,6 +67,13 @@
// If time only set date component to 2000-01-01 // If time only set date component to 2000-01-01
if (timeOnly) { if (timeOnly) {
// Classic flackpickr causing issues.
// When selecting a value for the first time for a "time only" field,
// the time is always offset by 1 hour for some reason (regardless of time
// zone) so we need to correct it.
if (!value && newValue) {
newValue = new Date(dates[0].getTime() + 60 * 60 * 1000).toISOString()
}
newValue = `2000-01-01T${newValue.split("T")[1]}` newValue = `2000-01-01T${newValue.split("T")[1]}`
} }

View File

@ -139,7 +139,13 @@
<div class="title"> <div class="title">
<div class="filename"> <div class="filename">
{#if selectedUrl} {#if selectedUrl}
<Link href={selectedUrl}>{selectedImage.name}</Link> <Link
target="_blank"
download={selectedImage.name}
href={selectedUrl}
>
{selectedImage.name}
</Link>
{:else} {:else}
{selectedImage.name} {selectedImage.name}
{/if} {/if}

View File

@ -10,6 +10,7 @@
export let disabled = false export let disabled = false
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionTitle = option => option
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => dispatch("change", e.target.value) const onChange = e => dispatch("change", e.target.value)
@ -19,7 +20,7 @@
{#if options && Array.isArray(options)} {#if options && Array.isArray(options)}
{#each options as option} {#each options as option}
<div <div
title={getOptionLabel(option)} title={getOptionTitle(option)}
class="spectrum-Radio spectrum-FieldGroup-item spectrum-Radio--emphasized" class="spectrum-Radio spectrum-FieldGroup-item spectrum-Radio--emphasized"
class:is-invalid={!!error} class:is-invalid={!!error}
> >

View File

@ -12,6 +12,7 @@
export let direction = "vertical" export let direction = "vertical"
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionTitle = option => extractProperty(option, "label")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -35,6 +36,7 @@
{direction} {direction}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionTitle}
on:change={onChange} on:change={onChange}
/> />
</Field> </Field>

View File

@ -8,12 +8,14 @@
export let secondary = false export let secondary = false
export let overBackground = false export let overBackground = false
export let target export let target
export let download
</script> </script>
<a <a
on:click on:click
{href} {href}
{target} {target}
{download}
class:spectrum-Link--primary={primary} class:spectrum-Link--primary={primary}
class:spectrum-Link--secondary={secondary} class:spectrum-Link--secondary={secondary}
class:spectrum-Link--overBackground={overBackground} class:spectrum-Link--overBackground={overBackground}

View File

@ -11,6 +11,7 @@
export let align = "right" export let align = "right"
export let portalTarget export let portalTarget
export let dataCy export let dataCy
export let maxWidth
export let direction = "bottom" export let direction = "bottom"
export let showTip = false export let showTip = false
@ -45,7 +46,7 @@
<Portal target={portalTarget}> <Portal target={portalTarget}>
<div <div
tabindex="0" tabindex="0"
use:positionDropdown={{ anchor, align }} use:positionDropdown={{ anchor, align, maxWidth }}
use:clickOutside={hide} use:clickOutside={hide}
on:keydown={handleEscape} on:keydown={handleEscape}
class={"spectrum-Popover is-open " + (tooltipClasses || "")} class={"spectrum-Popover is-open " + (tooltipClasses || "")}

View File

@ -15,14 +15,24 @@
{#each attachments as attachment} {#each attachments as attachment}
{#if isImage(attachment.extension)} {#if isImage(attachment.extension)}
<Link quiet target="_blank" href={attachment.url}> <Link
quiet
target="_blank"
download={attachment.name}
href={attachment.url}
>
<div class="center" title={attachment.name}> <div class="center" title={attachment.name}>
<img src={attachment.url} alt={attachment.extension} /> <img src={attachment.url} alt={attachment.extension} />
</div> </div>
</Link> </Link>
{:else} {:else}
<div class="file" title={attachment.name}> <div class="file" title={attachment.name}>
<Link quiet target="_blank" href={attachment.url}> <Link
quiet
target="_blank"
download={attachment.name}
href={attachment.url}
>
{attachment.extension} {attachment.extension}
</Link> </Link>
</div> </div>

View File

@ -102,7 +102,7 @@ filterTests(['all'], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 6000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 6000 })
cy.wait(500) cy.wait(500)
cy.get(interact.APP_TABLE_STATUS, { timeout: 1000 }).eq(0).contains("Unpublished") cy.get(interact.APP_TABLE_STATUS, { timeout: 10000 }).eq(0).contains("Unpublished")
}) })
}) })

View File

@ -10,7 +10,7 @@ filterTests(['smoke', 'all'], () => {
it("should add a current user binding", () => { it("should add a current user binding", () => {
cy.searchAndAddComponent("Paragraph").then(() => { cy.searchAndAddComponent("Paragraph").then(() => {
addSettingBinding("text", "Current User._id") addSettingBinding("text", ["Current User", "_id"], "Current User._id")
}) })
}) })
@ -28,7 +28,7 @@ filterTests(['smoke', 'all'], () => {
const paramName = "foo" const paramName = "foo"
cy.createScreen(`/test/:${paramName}`) cy.createScreen(`/test/:${paramName}`)
cy.searchAndAddComponent("Paragraph").then(componentId => { cy.searchAndAddComponent("Paragraph").then(componentId => {
addSettingBinding("text", `URL.${paramName}`) addSettingBinding("text", ["URL", paramName], `URL.${paramName}`)
// The builder preview pages don't have a real URL, so all we can do // The builder preview pages don't have a real URL, so all we can do
// is check that we were able to bind to the property, and that the // is check that we were able to bind to the property, and that the
// component exists on the page // component exists on the page
@ -47,11 +47,13 @@ filterTests(['smoke', 'all'], () => {
}) })
}) })
const addSettingBinding = (setting, bindingText, clickOption = true) => { const addSettingBinding = (setting, bindingCategories, bindingText, clickOption = true) => {
cy.get(`[data-cy="setting-${setting}"] [data-cy=text-binding-button]`).click() cy.get(`[data-cy="setting-${setting}"] [data-cy=text-binding-button]`).click()
cy.get(".category-list li").contains(bindingCategories[0])
cy.get(".drawer").within(() => { cy.get(".drawer").within(() => {
if (clickOption) { if (clickOption) {
cy.contains(bindingText).click() cy.get(".category-list li").contains(bindingCategories[0]).click()
cy.get("li.binding").contains(bindingCategories[1]).click()
cy.get("textarea").should("have.value", `{{ ${bindingText} }}`) cy.get("textarea").should("have.value", `{{ ${bindingText} }}`)
} else { } else {
cy.get("textarea").type(bindingText) cy.get("textarea").type(bindingText)

View File

@ -20,7 +20,7 @@ filterTests(["all"], () => {
//Use the tree to delete a selected component //Use the tree to delete a selected component
const deleteSelectedComponent = () => { const deleteSelectedComponent = () => {
cy.get( cy.get(
".nav-items-container .nav-item.selected .actions > div > .icon" ".nav-item.selected .actions > div > .icon"
).click({ ).click({
force: true, force: true,
}) })
@ -91,7 +91,7 @@ filterTests(["all"], () => {
cy.searchAndAddComponent("Paragraph").then(componentId => { cy.searchAndAddComponent("Paragraph").then(componentId => {
cy.get("[data-cy=setting-_instanceName] input").type(componentId).blur() cy.get("[data-cy=setting-_instanceName] input").type(componentId).blur()
cy.get( cy.get(
".nav-items-container .nav-item.selected .actions > div > .icon" ".nav-item.selected .actions > div > .icon"
).click({ ).click({
force: true, force: true,
}) })
@ -145,7 +145,7 @@ filterTests(["all"], () => {
return testFieldFocusOnCreate(label) return testFieldFocusOnCreate(label)
}) })
.then(() => { .then(() => {
cy.get(".nav-items-container .nav-item") cy.get(".nav-item")
.contains(formId) .contains(formId)
.click({ force: true }) .click({ force: true })
deleteSelectedComponent() deleteSelectedComponent()
@ -195,7 +195,7 @@ filterTests(["all"], () => {
return testFocusOnCreate(label) return testFocusOnCreate(label)
}) })
.then(() => { .then(() => {
cy.get(".nav-items-container .nav-item") cy.get(".nav-item")
.contains(providerId) .contains(providerId)
.click({ force: true }) .click({ force: true })
deleteSelectedComponent() deleteSelectedComponent()
@ -218,7 +218,7 @@ filterTests(["all"], () => {
.find(".component-placeholder") .find(".component-placeholder")
.should("not.exist") .should("not.exist")
cy.getComponent(imageId).find(`img[alt=${imageId}]`).should("exist") cy.getComponent(imageId).find(`img[alt=${imageId}]`).should("exist")
cy.get(".nav-items-container .nav-item") cy.get(".nav-item")
.contains(imageId) .contains(imageId)
.click({ force: true }) .click({ force: true })
deleteSelectedComponent() deleteSelectedComponent()
@ -242,7 +242,7 @@ filterTests(["all"], () => {
cy.getComponent(markdownId) cy.getComponent(markdownId)
.find(".editor-preview-full h1") .find(".editor-preview-full h1")
.contains("Hi") .contains("Hi")
cy.get(".nav-items-container .nav-item") cy.get(".nav-item")
.contains(markdownId) .contains(markdownId)
.click({ force: true }) .click({ force: true })
deleteSelectedComponent() deleteSelectedComponent()
@ -265,7 +265,7 @@ filterTests(["all"], () => {
.find(".component-placeholder") .find(".component-placeholder")
.should("not.exist") .should("not.exist")
cy.getComponent(iconId).find("i.ri-save-fill").should("exist") cy.getComponent(iconId).find("i.ri-save-fill").should("exist")
cy.get(".nav-items-container .nav-item") cy.get(".nav-item")
.contains(iconId) .contains(iconId)
.click({ force: true }) .click({ force: true })
deleteSelectedComponent() deleteSelectedComponent()

View File

@ -175,7 +175,10 @@ filterTests(["all"], () => {
cy.get("@query").its("response.statusCode").should("eq", 200) cy.get("@query").its("response.statusCode").should("eq", 200)
cy.get("@query").its("response.body").should("not.be.empty") cy.get("@query").its("response.body").should("not.be.empty")
// Save query // Save query
cy.intercept("POST", "**/queries").as("saveQuery")
cy.get(".spectrum-Button").contains("Save Query").click({ force: true }) cy.get(".spectrum-Button").contains("Save Query").click({ force: true })
cy.wait("@saveQuery")
cy.get("@saveQuery").its("response.statusCode").should("eq", 200)
cy.get(".nav-item").should("contain", queryName) cy.get(".nav-item").should("contain", queryName)
}) })

View File

@ -162,7 +162,7 @@ filterTests(["all"], () => {
switchSchema("randomText") switchSchema("randomText")
// No tables displayed // No tables displayed
cy.get(".spectrum-Body", { timeout: 5000 }).eq(2).should("contain", "No tables found") cy.get(".spectrum-Body", { timeout: 20000 }).eq(2).should("contain", "No tables found")
// Previously created query should be visible // Previously created query should be visible
cy.get(".spectrum-Table").should("contain", queryName) cy.get(".spectrum-Table").should("contain", queryName)
@ -173,7 +173,7 @@ filterTests(["all"], () => {
switchSchema("1") switchSchema("1")
// Confirm tables exist - Check for specific one // Confirm tables exist - Check for specific one
cy.get(".spectrum-Table", { timeout: 5000 }).eq(0).should("contain", "test") cy.get(".spectrum-Table", { timeout: 20000 }).eq(0).should("contain", "test")
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
.eq(0) .eq(0)
.find(".spectrum-Table-row") .find(".spectrum-Table-row")
@ -187,7 +187,7 @@ filterTests(["all"], () => {
switchSchema("public") switchSchema("public")
// Confirm tables exist - again // Confirm tables exist - again
cy.get(".spectrum-Table", { timeout: 5000 }).eq(0).should("contain", "REGIONS") cy.get(".spectrum-Table", { timeout: 20000 }).eq(0).should("contain", "REGIONS")
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
.eq(0) .eq(0)
.find(".spectrum-Table-row") .find(".spectrum-Table-row")
@ -252,7 +252,8 @@ filterTests(["all"], () => {
.contains("Delete Query") .contains("Delete Query")
.click({ force: true }) .click({ force: true })
// Confirm deletion // Confirm deletion
cy.reload({ timeout: 5000 }) cy.reload()
cy.get(".nav-item", { timeout: 30000 }).contains(datasource).click({ force: true })
cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename) cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryRename)
}) })

View File

@ -48,6 +48,7 @@ filterTests(['smoke', 'all'], () => {
cy.get(interact.AREA_LABEL_REVERT).click({ force: true }) cy.get(interact.AREA_LABEL_REVERT).click({ force: true })
}) })
cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => { cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => {
cy.get("input").type("Cypress Tests")
// Click Revert // Click Revert
cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true }) cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true })
cy.wait(2000) // Wait for app to finish reverting cy.wait(2000) // Wait for app to finish reverting

View File

@ -4,7 +4,7 @@ Cypress.on("uncaught:exception", () => {
// ACCOUNTS & USERS // ACCOUNTS & USERS
Cypress.Commands.add("login", (email, password) => { Cypress.Commands.add("login", (email, password) => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.url() cy.url()
.should("include", "/builder/") .should("include", "/builder/")
.then(url => { .then(url => {
@ -33,7 +33,7 @@ Cypress.Commands.add("login", (email, password) => {
}) })
Cypress.Commands.add("logOut", () => { Cypress.Commands.add("logOut", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 2000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.get(".user-dropdown .avatar > .icon").click({ force: true }) cy.get(".user-dropdown .avatar > .icon").click({ force: true })
cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => {
cy.get("li[data-cy='user-logout']").click({ force: true }) cy.get("li[data-cy='user-logout']").click({ force: true })
@ -43,7 +43,7 @@ Cypress.Commands.add("logOut", () => {
Cypress.Commands.add("logoutNoAppGrid", () => { Cypress.Commands.add("logoutNoAppGrid", () => {
// Logs user out when app grid is not present // Logs user out when app grid is not present
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.get(".avatar > .icon").click({ force: true }) cy.get(".avatar > .icon").click({ force: true })
cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => {
cy.get(".spectrum-Menu-item").contains("Log out").click({ force: true }) cy.get(".spectrum-Menu-item").contains("Log out").click({ force: true })
@ -68,11 +68,14 @@ Cypress.Commands.add("createUser", (email, permission) => {
.click({ force: true }) .click({ force: true })
}) })
} }
// Add user and wait for modal to change // Add user
cy.get(".spectrum-Button").contains("Add user").click({ force: true }) cy.get(".spectrum-Button").contains("Add users").click({ force: true })
cy.get(".spectrum-ActionButton").contains("Add email").should("not.exist") cy.get(".spectrum-ActionButton").contains("Add email").should("not.exist")
}) })
// Onboarding modal // Onboarding modal
cy.get(".spectrum-Dialog-grid", { timeout: 5000 }).contains(
"Choose your onboarding"
)
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".onboarding-type").eq(1).click() cy.get(".onboarding-type").eq(1).click()
cy.get(".spectrum-Button").contains("Done").click({ force: true }) cy.get(".spectrum-Button").contains("Done").click({ force: true })
@ -163,7 +166,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => {
const shouldCreateDefaultTable = const shouldCreateDefaultTable =
typeof addDefaultTable != "boolean" ? true : addDefaultTable typeof addDefaultTable != "boolean" ? true : addDefaultTable
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.url({ timeout: 30000 }).should("include", "/apps") cy.url({ timeout: 30000 }).should("include", "/apps")
cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ force: true }) cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ force: true })
@ -197,7 +200,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => {
}) })
Cypress.Commands.add("deleteApp", name => { Cypress.Commands.add("deleteApp", name => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.wait(2000) cy.wait(2000)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body") .its("body")
@ -254,7 +257,7 @@ Cypress.Commands.add("deleteApp", name => {
}) })
Cypress.Commands.add("deleteAllApps", () => { Cypress.Commands.add("deleteAllApps", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.wait(500) cy.wait(500)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`, { cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`, {
timeout: 5000, timeout: 5000,
@ -351,7 +354,7 @@ Cypress.Commands.add("alterAppVersion", (appId, version) => {
}) })
Cypress.Commands.add("importApp", (exportFilePath, name) => { Cypress.Commands.add("importApp", (exportFilePath, name) => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body") .its("body")
@ -386,7 +389,7 @@ Cypress.Commands.add("importApp", (exportFilePath, name) => {
// Filters visible with 1 or more // Filters visible with 1 or more
Cypress.Commands.add("searchForApplication", appName => { Cypress.Commands.add("searchForApplication", appName => {
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.wait(2000) cy.wait(2000)
// No app filter functionality if only 1 app exists // No app filter functionality if only 1 app exists
@ -409,7 +412,7 @@ Cypress.Commands.add("searchForApplication", appName => {
// Assumes there are no others // Assumes there are no others
Cypress.Commands.add("applicationInAppTable", appName => { Cypress.Commands.add("applicationInAppTable", appName => {
cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 30000 })
cy.get(".appTable", { timeout: 5000 }).within(() => { cy.get(".appTable", { timeout: 5000 }).within(() => {
cy.get(".title").contains(appName).should("exist") cy.get(".title").contains(appName).should("exist")
}) })
@ -448,10 +451,7 @@ Cypress.Commands.add("createTable", (tableName, initialTable) => {
.contains("Continue") .contains("Continue")
.click({ force: true }) .click({ force: true })
}) })
cy.get(".spectrum-Modal", { timeout: 10000 }).should( cy.get(".spectrum-Modal").contains("Create Table", { timeout: 10000 })
"not.contain",
"Add data source"
)
cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => { cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => {
cy.get("input", { timeout: 2000 }).first().type(tableName).blur() cy.get("input", { timeout: 2000 }).first().type(tableName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create").click() cy.get(".spectrum-ButtonGroup").contains("Create").click()
@ -742,8 +742,15 @@ Cypress.Commands.add("deleteAllScreens", () => {
Cypress.Commands.add("navigateToFrontend", () => { Cypress.Commands.add("navigateToFrontend", () => {
// Clicks on Design tab and then the Home nav item // Clicks on Design tab and then the Home nav item
cy.wait(500) cy.wait(500)
cy.intercept("**/preview").as("preview")
cy.contains("Design").click() cy.contains("Design").click()
cy.get(".spectrum-Search", { timeout: 2000 }).type("/") cy.wait("@preview")
cy.get("@preview").then(res => {
if (res.statusCode != 200) {
cy.reload()
}
})
cy.get(".spectrum-Search", { timeout: 20000 }).type("/")
cy.get(".nav-item", { timeout: 2000 }).contains("home").click({ force: true }) cy.get(".nav-item", { timeout: 2000 }).contains("home").click({ force: true })
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -69,10 +69,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "1.2.44-alpha.6", "@budibase/bbui": "1.3.4-alpha.2",
"@budibase/client": "1.2.44-alpha.6", "@budibase/client": "1.3.4-alpha.2",
"@budibase/frontend-core": "1.2.44-alpha.6", "@budibase/frontend-core": "1.3.4-alpha.2",
"@budibase/string-templates": "1.2.44-alpha.6", "@budibase/string-templates": "1.3.4-alpha.2",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -306,7 +306,10 @@ const getProviderContextBindings = (asset, dataProviders) => {
schema = {} schema = {}
const values = context.values || [] const values = context.values || []
values.forEach(value => { values.forEach(value => {
schema[value.key] = { name: value.label, type: "string" } schema[value.key] = {
name: value.label,
type: value.type || "string",
}
}) })
} else if (context.type === "schema") { } else if (context.type === "schema") {
// Schema contexts are generated dynamically depending on their data // Schema contexts are generated dynamically depending on their data
@ -366,6 +369,12 @@ const getProviderContextBindings = (asset, dataProviders) => {
providerId, providerId,
// Table ID is used by JSON fields to know what table the field is in // Table ID is used by JSON fields to know what table the field is in
tableId: table?._id, tableId: table?._id,
category: component._instanceName,
icon: def.icon,
display: {
name: fieldSchema.name || key,
type: fieldSchema.type,
},
}) })
}) })
}) })
@ -392,6 +401,9 @@ const getUserBindings = () => {
// datasource options, based on bindable properties // datasource options, based on bindable properties
fieldSchema, fieldSchema,
providerId: "user", providerId: "user",
category: "Current User",
icon: "User",
display: fieldSchema,
}) })
}) })
return bindings return bindings
@ -408,11 +420,17 @@ const getDeviceBindings = () => {
type: "context", type: "context",
runtimeBinding: `${safeDevice}.${makePropSafe("mobile")}`, runtimeBinding: `${safeDevice}.${makePropSafe("mobile")}`,
readableBinding: `Device.Mobile`, readableBinding: `Device.Mobile`,
category: "Device",
icon: "DevicePhone",
display: { type: "boolean", name: "mobile" },
}) })
bindings.push({ bindings.push({
type: "context", type: "context",
runtimeBinding: `${safeDevice}.${makePropSafe("tablet")}`, runtimeBinding: `${safeDevice}.${makePropSafe("tablet")}`,
readableBinding: `Device.Tablet`, readableBinding: `Device.Tablet`,
category: "Device",
icon: "DevicePhone",
display: { type: "boolean", name: "tablet" },
}) })
} }
return bindings return bindings
@ -436,6 +454,8 @@ const getSelectedRowsBindings = asset => {
"selectedRows" "selectedRows"
)}`, )}`,
readableBinding: `${table._instanceName}.Selected rows`, readableBinding: `${table._instanceName}.Selected rows`,
category: "Selected rows",
icon: "ViewRow",
})) }))
) )
@ -467,6 +487,9 @@ const getStateBindings = () => {
type: "context", type: "context",
runtimeBinding: `${safeState}.${makePropSafe(key)}`, runtimeBinding: `${safeState}.${makePropSafe(key)}`,
readableBinding: `State.${key}`, readableBinding: `State.${key}`,
category: "State",
icon: "AutomatedSegment",
display: { name: key },
})) }))
} }
return bindings return bindings
@ -489,11 +512,17 @@ const getUrlBindings = asset => {
type: "context", type: "context",
runtimeBinding: `${safeURL}.${makePropSafe(param)}`, runtimeBinding: `${safeURL}.${makePropSafe(param)}`,
readableBinding: `URL.${param}`, readableBinding: `URL.${param}`,
category: "URL",
icon: "RailTop",
display: { type: "string" },
})) }))
const queryParamsBinding = { const queryParamsBinding = {
type: "context", type: "context",
runtimeBinding: makePropSafe("query"), runtimeBinding: makePropSafe("query"),
readableBinding: "Query params", readableBinding: "Query params",
category: "URL",
icon: "RailTop",
display: { type: "object" },
} }
return urlParamBindings.concat([queryParamsBinding]) return urlParamBindings.concat([queryParamsBinding])
} }
@ -504,6 +533,9 @@ const getRoleBindings = () => {
type: "context", type: "context",
runtimeBinding: `trim "${role._id}"`, runtimeBinding: `trim "${role._id}"`,
readableBinding: `Role.${role.name}`, readableBinding: `Role.${role.name}`,
category: "Role",
icon: "UserGroup",
display: { type: "string", name: role.name },
} }
}) })
} }
@ -525,6 +557,7 @@ export const getEventContextBindings = (
// Check if any context bindings are provided by the component for this // Check if any context bindings are provided by the component for this
// setting // setting
const component = findComponent(asset.props, componentId) const component = findComponent(asset.props, componentId)
const def = store.actions.components.getDefinition(component?._component)
const settings = getComponentSettings(component?._component) const settings = getComponentSettings(component?._component)
const eventSetting = settings.find(setting => setting.key === settingKey) const eventSetting = settings.find(setting => setting.key === settingKey)
if (eventSetting?.context?.length) { if (eventSetting?.context?.length) {
@ -534,6 +567,8 @@ export const getEventContextBindings = (
runtimeBinding: `${makePropSafe("eventContext")}.${makePropSafe( runtimeBinding: `${makePropSafe("eventContext")}.${makePropSafe(
contextEntry.key contextEntry.key
)}`, )}`,
category: component._instanceName,
icon: def.icon,
}) })
}) })
} }
@ -555,6 +590,8 @@ export const getEventContextBindings = (
bindings.push({ bindings.push({
readableBinding: `Action ${idx + 1}.${contextValue.label}`, readableBinding: `Action ${idx + 1}.${contextValue.label}`,
runtimeBinding: `actions.${idx}.${contextValue.value}`, runtimeBinding: `actions.${idx}.${contextValue.value}`,
category: "Actions",
icon: "JourneyAction",
}) })
}) })
} }

View File

@ -19,7 +19,6 @@ import {
makeComponentUnique, makeComponentUnique,
} from "../componentUtils" } from "../componentUtils"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { DefaultAppTheme, LAYOUT_NAMES } from "../../constants"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
const INITIAL_FRONTEND_STATE = { const INITIAL_FRONTEND_STATE = {
@ -125,35 +124,6 @@ export const getFrontendStore = () => {
await integrations.init() await integrations.init()
await queries.init() await queries.init()
await tables.init() await tables.init()
// Add navigation settings to old apps
if (!application.navigation) {
const layout = layouts.find(x => x._id === LAYOUT_NAMES.MASTER.PRIVATE)
const customTheme = application.customTheme
let navigationSettings = {
navigation: "Top",
title: application.name,
navWidth: "Large",
navBackground:
customTheme?.navBackground || DefaultAppTheme.navBackground,
navTextColor:
customTheme?.navTextColor || DefaultAppTheme.navTextColor,
}
if (layout) {
navigationSettings.hideLogo = layout.props.hideLogo
navigationSettings.hideTitle = layout.props.hideTitle
navigationSettings.title = layout.props.title || application.name
navigationSettings.logoUrl = layout.props.logoUrl
navigationSettings.links = layout.props.links
navigationSettings.navigation = layout.props.navigation || "Top"
navigationSettings.sticky = layout.props.sticky
navigationSettings.navWidth = layout.props.width || "Large"
if (navigationSettings.navigation === "None") {
navigationSettings.navigation = "Top"
}
}
await store.actions.navigation.save(navigationSettings)
}
}, },
theme: { theme: {
save: async theme => { save: async theme => {

View File

@ -23,7 +23,7 @@
</script> </script>
<div class="automations-list"> <div class="automations-list">
{#each $automationStore.automations as automation, idx} {#each $automationStore.automations.sort(aut => aut.name) as automation, idx}
<NavItem <NavItem
border={idx > 0} border={idx > 0}
icon="ShareAndroid" icon="ShareAndroid"

View File

@ -14,7 +14,7 @@
$: { $: {
let fields = {} let fields = {}
for (const [key, type] of Object.entries(block?.inputs?.fields)) { for (const [key, type] of Object.entries(block?.inputs?.fields ?? {})) {
fields = { fields = {
...fields, ...fields,
[key]: { [key]: {

View File

@ -467,6 +467,7 @@
options={relationshipOptions} options={relationshipOptions}
getOptionLabel={option => option.name} getOptionLabel={option => option.name}
getOptionValue={option => option.value} getOptionValue={option => option.value}
getOptionTitle={option => option.alt}
/> />
{/if} {/if}
<Input <Input

View File

@ -16,6 +16,7 @@
export let scrollable = false export let scrollable = false
export let highlighted = false export let highlighted = false
export let rightAlignIcon = false export let rightAlignIcon = false
export let id
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -58,6 +59,7 @@
on:click={onClick} on:click={onClick}
ondragover="return false" ondragover="return false"
ondragenter="return false" ondragenter="return false"
{id}
> >
<div class="nav-item-content" bind:this={contentRef}> <div class="nav-item-content" bind:this={contentRef}>
{#if withArrow} {#if withArrow}

View File

@ -9,6 +9,9 @@
Body, Body,
Layout, Layout,
Button, Button,
ActionButton,
Icon,
Popover,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { import {
@ -45,9 +48,25 @@
let jsValue = initialValueJS ? value : null let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value let hbsValue = initialValueJS ? null : value
let selectedCategory = null
let popover
let popoverAnchor
let hoverTarget
$: usingJS = mode === "JavaScript" $: usingJS = mode === "JavaScript"
$: searchRgx = new RegExp(search, "ig") $: searchRgx = new RegExp(search, "ig")
$: categories = Object.entries(groupBy("category", bindings)) $: categories = Object.entries(groupBy("category", bindings))
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: filteredCategories = categories $: filteredCategories = categories
.map(([name, categoryBindings]) => ({ .map(([name, categoryBindings]) => ({
name, name,
@ -55,10 +74,19 @@
return binding.readableBinding.match(searchRgx) return binding.readableBinding.match(searchRgx)
}), }),
})) }))
.filter(category => category.bindings?.length > 0) .filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => { $: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx) return helper.label.match(searchRgx) || helper.description.match(searchRgx)
}) })
$: categoryNames = [...categories.map(cat => cat[0]), "Helpers"]
$: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`) $: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`)
const updateValue = val => { const updateValue = val => {
@ -140,58 +168,163 @@
}) })
</script> </script>
<span class="detailPopover">
<Popover
align="right-side"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<DrawerContent> <DrawerContent>
<svelte:fragment slot="sidebar"> <svelte:fragment slot="sidebar">
<div class="container"> <Layout noPadding gap="S">
<section> {#if selectedCategory}
<div>
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="heading">Search</div> <div class="heading">Search</div>
<Search placeholder="Search" bind:value={search} /> <Search placeholder="Search" bind:value={search} />
</section> {/if}
{#each filteredCategories as category}
{#if category.bindings?.length} {#if !selectedCategory && !search}
<section> <ul class="category-list">
<div class="heading">{category.name}</div> {#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul> <ul>
{#each category.bindings as binding} {#each category.bindings as binding}
<li on:click={() => addBinding(binding)}> <li
<span class="binding__label">{binding.readableBinding}</span> class="binding"
{#if binding.type} on:mouseenter={e => {
<span class="binding__type">{binding.type}</span> popoverAnchor = e.target
{/if} if (!binding.description) {
{#if binding.description} return
<br /> }
<div class="binding__description"> hoverTarget = {
{binding.description || ""} title: binding.display.name || binding.fieldSchema.name,
</div> description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if} {/if}
</li> </li>
{/each} {/each}
</ul> </ul>
</section> {/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, usingJS)}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(helper, usingJS),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
{/if}
{/if} {/if}
{/each}
{#if filteredHelpers?.length}
<section>
<div class="heading">Helpers</div>
<ul>
{#each filteredHelpers as helper}
<li on:click={() => addHelper(helper, usingJS)}>
<div class="helper">
<div class="helper__name">{helper.displayText}</div>
<div class="helper__description">
{@html helper.description}
</div>
<pre class="helper__example">{getHelperExample(
helper,
usingJS
)}</pre>
</div>
</li>
{/each}
</ul>
</section>
{/if} {/if}
</div> </Layout>
</svelte:fragment> </svelte:fragment>
<div class="main"> <div class="main">
<Tabs selected={mode} on:select={onChangeMode}> <Tabs selected={mode} on:select={onChangeMode}>
@ -241,6 +374,35 @@
</DrawerContent> </DrawerContent>
<style> <style>
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
.main :global(textarea) { .main :global(textarea) {
min-height: 202px !important; min-height: 202px !important;
} }
@ -251,23 +413,20 @@
padding: var(--spacing-s) var(--spacing-xl); padding: var(--spacing-s) var(--spacing-xl);
} }
.container { .heading,
margin: calc(-1 * var(--spacing-xl)); .cat-heading {
}
.heading {
font-size: var(--font-size-s); font-size: var(--font-size-s);
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
} }
section { .cat-heading {
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl); display: flex;
} gap: var(--spacing-m);
section:not(:first-child) { align-items: center;
border-top: var(--border-light);
} }
ul { ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -278,7 +437,7 @@
font-size: var(--font-size-s); font-size: var(--font-size-s);
padding: var(--spacing-m); padding: var(--spacing-m);
border-radius: 4px; border-radius: 4px;
border: var(--border-light); background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out, transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out; border-color 130ms ease-in-out;
word-wrap: break-word; word-wrap: break-word;
@ -292,22 +451,14 @@
li:hover { li:hover {
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50); background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-global-color-gray-500);
cursor: pointer; cursor: pointer;
} }
li:hover :global(*) {
color: var(--spectrum-global-color-gray-900) !important;
}
.binding__label { .binding__label {
font-weight: 600; font-weight: 600;
text-transform: capitalize; text-transform: capitalize;
} }
.binding__description {
color: var(--spectrum-global-color-gray-700);
margin: 0.5rem 0 0 0;
white-space: normal;
}
.binding__type { .binding__type {
font-family: monospace; font-family: monospace;
background-color: var(--spectrum-global-color-gray-200); background-color: var(--spectrum-global-color-gray-200);

View File

@ -15,7 +15,6 @@
} }
return bindings?.map(binding => ({ return bindings?.map(binding => ({
...binding, ...binding,
category: "Bindable Values",
type: null, type: null,
})) }))
} }

View File

@ -0,0 +1,13 @@
<script>
import { ActionButton } from "@budibase/bbui"
const eject = () => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "e", ctrlKey: true })
)
}
</script>
<div>
<ActionButton secondary on:click={eject}>Eject block</ActionButton>
</div>

View File

@ -20,6 +20,7 @@
export let componentBindings = [] export let componentBindings = []
export let nested = false export let nested = false
export let highlighted = false export let highlighted = false
export let info = null
$: nullishValue = value == null || value === "" $: nullishValue = value == null || value === ""
$: allBindings = getAllBindings(bindings, componentBindings, nested) $: allBindings = getAllBindings(bindings, componentBindings, nested)
@ -100,6 +101,9 @@
{...props} {...props}
/> />
</div> </div>
{#if info}
<div class="text">{@html info}</div>
{/if}
</div> </div>
<style> <style>
@ -124,4 +128,9 @@
.control { .control {
position: relative; position: relative;
} }
.text {
margin-top: var(--spectrum-global-dimension-size-65);
font-size: var(--spectrum-global-dimension-font-size-75);
color: var(--grey-6);
}
</style> </style>

View File

@ -193,7 +193,7 @@
$goto("./navigation") $goto("./navigation")
} }
} else if (type === "request-add-component") { } else if (type === "request-add-component") {
$goto(`./components/${$selectedComponent?._id}/new`) toggleAddComponent()
} else if (type === "highlight-setting") { } else if (type === "highlight-setting") {
store.actions.settings.highlight(data.setting) store.actions.settings.highlight(data.setting)
@ -240,9 +240,8 @@
if (isAddingComponent) { if (isAddingComponent) {
$goto(`../${$selectedScreen._id}/components/${$selectedComponent?._id}`) $goto(`../${$selectedScreen._id}/components/${$selectedComponent?._id}`)
} else { } else {
$goto( const id = $selectedComponent?._id || $selectedScreen?.props?._id
`../${$selectedScreen._id}/components/${$selectedComponent?._id}/new` $goto(`../${$selectedScreen._id}/components/${id}/new`)
)
} }
} }

View File

@ -9,14 +9,15 @@
$: isBlock = definition?.block === true $: isBlock = definition?.block === true
const keyboardEvent = (key, ctrlKey = false) => { const keyboardEvent = (key, ctrlKey = false) => {
// Ensure this component is selected first document.dispatchEvent(
if (component._id !== $store.selectedComponentId) { new CustomEvent("component-menu", {
store.update(state => { detail: {
state.selectedComponentId = component._id key,
return state ctrlKey,
id: component?._id,
},
}) })
} )
document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey }))
} }
</script> </script>

View File

@ -0,0 +1,131 @@
<script>
import { onMount } from "svelte"
import { selectedComponent, selectedScreen, store } from "builderStore"
import { findComponent } from "builderStore/componentUtils"
import { goto, isActive } from "@roxi/routify"
import { notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
let confirmDeleteDialog
let confirmEjectDialog
let componentToDelete
let componentToEject
const keyHandlers = {
["^ArrowUp"]: async component => {
await store.actions.components.moveUp(component)
},
["^ArrowDown"]: async component => {
await store.actions.components.moveDown(component)
},
["^c"]: component => {
store.actions.components.copy(component, false)
},
["^x"]: component => {
store.actions.components.copy(component, true)
},
["^v"]: async component => {
await store.actions.components.paste(component, "inside")
},
["^d"]: async component => {
store.actions.components.copy(component)
await store.actions.components.paste(component, "below")
},
["^e"]: component => {
componentToEject = component
confirmEjectDialog.show()
},
["^Enter"]: () => {
$goto("./new")
},
["Delete"]: component => {
// Don't show confirmation for the screen itself
if (component?._id === $selectedScreen.props._id) {
return false
}
componentToDelete = component
confirmDeleteDialog.show()
},
["ArrowUp"]: () => {
store.actions.components.selectPrevious()
},
["ArrowDown"]: () => {
store.actions.components.selectNext()
},
["Escape"]: () => {
if (!$isActive("/new")) {
return false
}
$goto("./")
},
}
const handleKeyAction = async (component, key, ctrlKey = false) => {
if (!component || !key) {
return false
}
try {
// Delete and backspace are the same
if (key === "Backspace") {
key = "Delete"
}
// Prefix key with a caret for ctrl modifier
if (ctrlKey) {
key = "^" + key
}
const handler = keyHandlers[key]
if (!handler) {
return false
}
return handler(component)
} catch (error) {
console.error(error)
notifications.error("Error handling key press")
}
}
const handleKeyPress = async e => {
// Ignore repeating events
if (e.repeat) {
return
}
// Ignore events when typing
const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") {
return
}
// Key events are always for the selected component
return handleKeyAction($selectedComponent, e.key, e.ctrlKey || e.metaKey)
}
const handleComponentMenu = async e => {
// Menu events can be for any component
const { id, key, ctrlKey } = e.detail
const component = findComponent($selectedScreen.props, id)
return await handleKeyAction(component, key, ctrlKey)
}
onMount(() => {
document.addEventListener("keydown", handleKeyPress)
document.addEventListener("component-menu", handleComponentMenu)
return () => {
document.removeEventListener("keydown", handleKeyPress)
document.removeEventListener("component-menu", handleComponentMenu)
}
})
</script>
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you want to delete "${componentToDelete?._instanceName}"?`}
okText="Delete Component"
onOk={() => store.actions.components.delete(componentToDelete)}
/>
<ConfirmDialog
bind:this={confirmEjectDialog}
title="Eject block"
body={`Ejecting a block breaks it down into multiple components and cannot be undone. Are you sure you want to eject "${componentToEject?._instanceName}"?`}
onOk={() => store.actions.components.requestEjectBlock(componentToEject?._id)}
okText="Eject block"
/>

View File

@ -2,62 +2,15 @@
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import { dndStore } from "./dndStore.js" import { dndStore } from "./dndStore.js"
import { goto, isActive } from "@roxi/routify" import { goto } from "@roxi/routify"
import { store, selectedScreen, selectedComponent } from "builderStore" import { store, selectedScreen } from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte" import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import { setContext, onMount } from "svelte"
import { get } from "svelte/store"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { DropPosition } from "./dndStore" import { DropPosition } from "./dndStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { notifications, Button } from "@budibase/bbui" import { notifications, Button } from "@budibase/bbui"
import ComponentKeyHandler from "./ComponentKeyHandler.svelte"
let scrollRef import ComponentScrollWrapper from "./ComponentScrollWrapper.svelte"
let confirmDeleteDialog
const scrollTo = bounds => {
if (!bounds) {
return
}
const sidebarWidth = 259
const navItemHeight = 32
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
let scrollBounds = scrollRef.getBoundingClientRect()
let newOffsets = {}
// Calculate left offset
const offsetX = bounds.left + bounds.width + scrollLeft - 36
if (offsetX > sidebarWidth) {
newOffsets.left = offsetX - sidebarWidth
} else {
newOffsets.left = 0
}
if (newOffsets.left === scrollLeft) {
delete newOffsets.left
}
// Calculate top offset
const offsetY = bounds.top - scrollBounds?.top + scrollTop
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
} else if (offsetY < scrollTop + navItemHeight) {
newOffsets.top = offsetY - navItemHeight
} else {
delete newOffsets.top
}
// Skip if offset is unchanged
if (newOffsets.left == null && newOffsets.top == null) {
return
}
// Smoothly scroll to the offset
scrollRef.scroll({
...newOffsets,
behavior: "smooth",
})
}
const onDrop = async () => { const onDrop = async () => {
try { try {
@ -67,98 +20,15 @@
notifications.error("Error saving component") notifications.error("Error saving component")
} }
} }
// Set scroll context so components can invoke scrolling when selected
setContext("scroll", {
scrollTo,
})
const deleteComponent = async () => {
await store.actions.components.delete(get(selectedComponent))
}
const handleKeyPress = async e => {
// Ignore repeating events
if (e.repeat) {
return
}
// Ignore events when typing
const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") {
return
}
const component = get(selectedComponent)
try {
if (e.ctrlKey || e.metaKey) {
if (e.key === "ArrowUp") {
e.preventDefault()
await store.actions.components.moveUp(component)
} else if (e.key === "ArrowDown") {
e.preventDefault()
await store.actions.components.moveDown(component)
} else if (e.key === "c") {
e.preventDefault()
await store.actions.components.copy(component, false)
} else if (e.key === "x") {
e.preventDefault()
store.actions.components.copy(component, true)
} else if (e.key === "v") {
e.preventDefault()
await store.actions.components.paste(component, "inside")
} else if (e.key === "d") {
e.preventDefault()
await store.actions.components.copy(component)
await store.actions.components.paste(component, "below")
} else if (e.key === "Enter") {
e.preventDefault()
$goto("./new")
} else if (e.key === "e") {
e.preventDefault()
await store.actions.components.requestEjectBlock(component?._id)
}
} else if (e.key === "Backspace" || e.key === "Delete") {
// Don't show confirmation for the screen itself
if (component._id === get(selectedScreen).props._id) {
return
}
e.preventDefault()
confirmDeleteDialog.show()
} else if (e.key === "ArrowUp") {
e.preventDefault()
await store.actions.components.selectPrevious()
} else if (e.key === "ArrowDown") {
e.preventDefault()
await store.actions.components.selectNext()
} else if (e.key === "Escape" && $isActive("./new")) {
e.preventDefault()
$goto("./")
}
} catch (error) {
console.log(error)
notifications.error("Error handling key press")
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyPress)
return () => {
document.removeEventListener("keydown", handleKeyPress)
}
})
</script> </script>
<Panel title="Components" showExpandIcon borderRight> <Panel title="Components" showExpandIcon borderRight>
<div class="add-component"> <div class="add-component">
<Button on:click={() => $goto("./new")} cta>Add component</Button> <Button on:click={() => $goto("./new")} cta>Add component</Button>
</div> </div>
<div class="nav-items-container" bind:this={scrollRef}> <ComponentScrollWrapper>
<ul> <ul>
<li <li>
on:click={() => {
$store.selectedComponentId = $selectedScreen?.props._id
}}
id={`component-${$selectedScreen?.props._id}`}
>
<NavItem <NavItem
text="Screen" text="Screen"
indentLevel={0} indentLevel={0}
@ -167,6 +37,10 @@
scrollable scrollable
icon="WebPage" icon="WebPage"
on:drop={onDrop} on:drop={onDrop}
on:click={() => {
$store.selectedComponentId = $selectedScreen?.props._id
}}
id={`component-${$selectedScreen?.props._id}`}
> >
<ScreenslotDropdownMenu component={$selectedScreen?.props} /> <ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem> </NavItem>
@ -190,15 +64,9 @@
{/if} {/if}
</li> </li>
</ul> </ul>
</div> </ComponentScrollWrapper>
</Panel> </Panel>
<ConfirmDialog <ComponentKeyHandler />
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you want to delete "${$selectedComponent?._instanceName}"?`}
okText="Delete Component"
onOk={deleteComponent}
/>
<style> <style>
.add-component { .add-component {
@ -208,12 +76,6 @@
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.nav-items-container {
padding: var(--spacing-xl) 0;
flex: 1 1 auto;
overflow: auto;
height: 0;
}
ul { ul {
list-style: none; list-style: none;
padding-left: 0; padding-left: 0;

View File

@ -0,0 +1,82 @@
<script>
import { setContext } from "svelte"
import { dndStore } from "./dndStore"
import { notifications } from "@budibase/bbui"
let scrollRef
const scrollTo = bounds => {
if (!bounds) {
return
}
const sidebarWidth = 259
const navItemHeight = 32
const { scrollLeft, scrollTop, offsetHeight } = scrollRef
let scrollBounds = scrollRef.getBoundingClientRect()
let newOffsets = {}
// Calculate left offset
const offsetX = bounds.left + bounds.width + scrollLeft - 36
if (offsetX > sidebarWidth) {
newOffsets.left = offsetX - sidebarWidth
} else {
newOffsets.left = 0
}
if (newOffsets.left === scrollLeft) {
delete newOffsets.left
}
// Calculate top offset
const offsetY = bounds.top - scrollBounds?.top + scrollTop
if (offsetY > scrollTop + offsetHeight - 2 * navItemHeight) {
newOffsets.top = offsetY - offsetHeight + 2 * navItemHeight
} else if (offsetY < scrollTop + navItemHeight) {
newOffsets.top = offsetY - navItemHeight
} else {
delete newOffsets.top
}
// Skip if offset is unchanged
if (newOffsets.left == null && newOffsets.top == null) {
return
}
// Smoothly scroll to the offset
scrollRef.scroll({
...newOffsets,
behavior: "smooth",
})
}
// Set scroll context so components can invoke scrolling when selected
setContext("scroll", {
scrollTo,
})
const onDrop = async () => {
try {
await dndStore.actions.drop()
} catch (error) {
console.error(error)
notifications.error("Error saving component")
}
}
</script>
<div
bind:this={scrollRef}
on:drop={onDrop}
ondragover="return false"
ondragenter="return false"
>
<slot />
</div>
<style>
div {
padding: var(--spacing-xl) 0;
flex: 1 1 auto;
overflow: auto;
height: 0;
}
</style>

View File

@ -68,7 +68,8 @@
closedNodes = closedNodes closedNodes = closedNodes
} }
const onDrop = async () => { const onDrop = async e => {
e.stopPropagation()
try { try {
await dndStore.actions.drop() await dndStore.actions.drop()
} catch (error) { } catch (error) {

View File

@ -4,6 +4,7 @@
import { store } from "builderStore" import { store } from "builderStore"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte" import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte"
import { getComponentForSetting } from "components/design/settings/componentSettings" import { getComponentForSetting } from "components/design/settings/componentSettings"
export let componentDefinition export let componentDefinition
@ -21,7 +22,6 @@
let sections = [ let sections = [
{ {
name: "General", name: "General",
info: componentDefinition?.info,
settings: generalSettings, settings: generalSettings,
}, },
...(customSections || []), ...(customSections || []),
@ -119,6 +119,7 @@
nested={setting.nested} nested={setting.nested}
onChange={val => updateSetting(setting.key, val)} onChange={val => updateSetting(setting.key, val)}
highlighted={$store.highlightedSettingKey === setting.key} highlighted={$store.highlightedSettingKey === setting.key}
info={setting.info}
props={{ props={{
// Generic settings // Generic settings
placeholder: setting.placeholder || null, placeholder: setting.placeholder || null,
@ -140,18 +141,9 @@
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")} {#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
<ResetFieldsButton {componentInstance} /> <ResetFieldsButton {componentInstance} />
{/if} {/if}
{#if section?.info} {#if idx === 0 && componentDefinition?.block}
<div class="text"> <EjectBlockButton />
{@html section.info}
</div>
{/if} {/if}
</DetailSummary> </DetailSummary>
{/if} {/if}
{/each} {/each}
<style>
.text {
font-size: var(--spectrum-global-dimension-font-size-75);
color: var(--grey-6);
}
</style>

View File

@ -44,7 +44,11 @@
] ]
} }
function validateInput(email, index) { function validateInput(input, index) {
if (input.email) {
input.email = input.email.trim()
}
const email = input.email
if (email) { if (email) {
const res = emailValidator(email) const res = emailValidator(email)
if (res === true) { if (res === true) {
@ -61,7 +65,7 @@
const onConfirm = () => { const onConfirm = () => {
let valid = true let valid = true
userData.forEach((input, index) => { userData.forEach((input, index) => {
valid = validateInput(input.email, index) && valid valid = validateInput(input, index) && valid
}) })
if (!valid) { if (!valid) {
return false return false
@ -95,7 +99,7 @@
bind:dropdownValue={input.role} bind:dropdownValue={input.role}
options={Constants.BudibaseRoleOptions} options={Constants.BudibaseRoleOptions}
error={input.error} error={input.error}
on:blur={() => validateInput(input.email, index)} on:blur={() => validateInput(input, index)}
/> />
</div> </div>
<div class="icon"> <div class="icon">

View File

@ -0,0 +1,73 @@
<script>
import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte"
export let userData
export let deleteUsersResponse
let successCount
let failureCount
let title
let unsuccessfulUsers
let message
const setTitle = () => {
if (successCount) {
title = `${successCount} users deleted`
} else {
title = "Oops!"
}
}
const setMessage = () => {
if (successCount) {
message = "However there was a problem deleting some users."
} else {
message = "There was a problem deleting some users."
}
}
const setUsers = () => {
unsuccessfulUsers = deleteUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
successCount = deleteUsersResponse.successful.length
failureCount = deleteUsersResponse.unsuccessful.length
setTitle()
setMessage()
setUsers()
})
const schema = {
email: {},
reason: {},
}
</script>
<ModalContent
size="M"
{title}
confirmText="Close"
showCloseIcon={false}
showCancelButton={false}
>
<Body size="XS">
{message}
</Body>
<Table
{schema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
</ModalContent>
<style>
</style>

View File

@ -62,7 +62,7 @@
csvString = e.target.result csvString = e.target.result
files = fileArray files = fileArray
userEmails = csvString.split("\n") userEmails = csvString.split(/\r?\n/)
}) })
reader.readAsText(fileArray[0]) reader.readAsText(fileArray[0])
} }

View File

@ -0,0 +1,75 @@
<script>
import { Body, ModalContent, Table } from "@budibase/bbui"
import { onMount } from "svelte"
export let inviteUsersResponse
let hasSuccess
let hasFailure
let title
let failureMessage
let unsuccessfulUsers
const setTitle = () => {
if (hasSuccess) {
title = "Users invited!"
} else if (hasFailure) {
title = "Oops!"
}
}
const setFailureMessage = () => {
if (hasSuccess) {
failureMessage = "However there was a problem inviting some users."
} else {
failureMessage = "There was a problem inviting users."
}
}
const setUsers = () => {
unsuccessfulUsers = inviteUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
hasSuccess = inviteUsersResponse.successful.length
hasFailure = inviteUsersResponse.unsuccessful.length
setTitle()
setFailureMessage()
setUsers()
})
const failedSchema = {
email: {},
reason: {},
}
</script>
<ModalContent showCancelButton={false} {title} confirmText="Done">
{#if hasSuccess}
<Body size="XS">
Your users should now receive an email invite to get access to their
Budibase account
</Body>
{/if}
{#if hasFailure}
<Body size="XS">
{failureMessage}
</Body>
<Table
schema={failedSchema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{/if}
</ModalContent>
<style>
</style>

View File

@ -2,24 +2,78 @@
import { Body, ModalContent, Table, Icon } from "@budibase/bbui" import { Body, ModalContent, Table, Icon } from "@budibase/bbui"
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte" import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte"
import { parseToCsv } from "helpers/data/utils" import { parseToCsv } from "helpers/data/utils"
import { onMount } from "svelte"
export let userData export let userData
export let createUsersResponse
$: mappedData = userData.map(user => { let hasSuccess
return { let hasFailure
email: user.email, let title
password: user.password, let failureMessage
let userDataIndex
let successfulUsers
let unsuccessfulUsers
const setTitle = () => {
if (hasSuccess) {
title = "Users created!"
} else if (hasFailure) {
title = "Oops!"
} }
}
const setFailureMessage = () => {
if (hasSuccess) {
failureMessage = "However there was a problem creating some users."
} else {
failureMessage = "There was a problem creating some users."
}
}
const setUsers = () => {
userDataIndex = userData.reduce((prev, current) => {
prev[current.email] = current
return prev
}, {})
successfulUsers = createUsersResponse.successful.map(user => {
return {
email: user.email,
password: userDataIndex[user.email].password,
}
})
unsuccessfulUsers = createUsersResponse.unsuccessful.map(user => {
return {
email: user.email,
reason: user.reason,
}
})
}
onMount(() => {
hasSuccess = createUsersResponse.successful.length
hasFailure = createUsersResponse.unsuccessful.length
setTitle()
setFailureMessage()
setUsers()
}) })
const schema = { const successSchema = {
email: {}, email: {},
password: {}, password: {},
} }
const failedSchema = {
email: {},
reason: {},
}
const downloadCsvFile = () => { const downloadCsvFile = () => {
const fileName = "passwords.csv" const fileName = "passwords.csv"
const content = parseToCsv(["email", "password"], mappedData) const content = parseToCsv(["email", "password"], successfulUsers)
download(fileName, content) download(fileName, content)
} }
@ -42,36 +96,52 @@
</script> </script>
<ModalContent <ModalContent
size="S" size="M"
title="Accounts created!" {title}
confirmText="Done" confirmText="Done"
showCancelButton={false} showCancelButton={false}
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
> >
<Body size="XS"> {#if hasFailure}
All your new users can be accessed through the autogenerated passwords. Take <Body size="XS">
note of these passwords or download the CSV file. {failureMessage}
</Body> </Body>
<Table
schema={failedSchema}
data={unsuccessfulUsers}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{/if}
{#if hasSuccess}
<Body size="XS">
All your new users can be accessed through the autogenerated passwords.
Take note of these passwords or download the CSV file.
</Body>
<div class="container" on:click={downloadCsvFile}> <div class="container" on:click={downloadCsvFile}>
<div class="inner"> <div class="inner">
<Icon name="Download" /> <Icon name="Download" />
<div style="margin-left: var(--spacing-m)"> <div style="margin-left: var(--spacing-m)">
<Body size="XS">Passwords CSV</Body> <Body size="XS">Passwords CSV</Body>
</div>
</div> </div>
</div> </div>
</div>
<Table <Table
{schema} schema={successSchema}
data={mappedData} data={successfulUsers}
allowEditColumns={false} allowEditColumns={false}
allowEditRows={false} allowEditRows={false}
allowSelectRows={false} allowSelectRows={false}
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]} customRenderers={[
/> { column: "password", component: PasswordCopyRenderer },
]}
/>
{/if}
</ModalContent> </ModalContent>
<style> <style>

View File

@ -7,7 +7,6 @@
Table, Table,
Layout, Layout,
Modal, Modal,
ModalContent,
Search, Search,
notifications, notifications,
Pagination, Pagination,
@ -23,6 +22,8 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte" import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte"
import PasswordModal from "./_components/PasswordModal.svelte" import PasswordModal from "./_components/PasswordModal.svelte"
import InvitedModal from "./_components/InvitedModal.svelte"
import DeletionFailureModal from "./_components/DeletionFailureModal.svelte"
import ImportUsersModal from "./_components/ImportUsersModal.svelte" import ImportUsersModal from "./_components/ImportUsersModal.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { get } from "svelte/store" import { get } from "svelte/store"
@ -33,7 +34,8 @@
inviteConfirmationModal, inviteConfirmationModal,
onboardingTypeModal, onboardingTypeModal,
passwordModal, passwordModal,
importUsersModal importUsersModal,
deletionFailureModal
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
let prevEmail = undefined, let prevEmail = undefined,
searchEmail = undefined searchEmail = undefined
@ -55,6 +57,9 @@
apps: {}, apps: {},
} }
$: userData = [] $: userData = []
$: createUsersResponse = { successful: [], unsuccessful: [] }
$: deleteUsersResponse = { successful: [], unsuccessful: [] }
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchUsers(page, searchEmail) $: fetchUsers(page, searchEmail)
$: { $: {
@ -92,8 +97,7 @@
admin: user.role === Constants.BudibaseRoles.Admin, admin: user.role === Constants.BudibaseRoles.Admin,
})) }))
try { try {
const res = await users.invite(payload) inviteUsersResponse = await users.invite(payload)
notifications.success(res.message)
inviteConfirmationModal.show() inviteConfirmationModal.show()
} catch (error) { } catch (error) {
notifications.error("Error inviting user") notifications.error("Error inviting user")
@ -116,8 +120,9 @@
newUsers.push(user) newUsers.push(user)
} }
if (!newUsers.length) if (!newUsers.length) {
notifications.info("Duplicated! There is no new users to add.") notifications.info("Duplicated! There is no new users to add.")
}
return { ...userData, users: newUsers } return { ...userData, users: newUsers }
} }
@ -139,12 +144,14 @@
userData = await removingDuplicities({ groups, users }) userData = await removingDuplicities({ groups, users })
if (!userData.users.length) return if (!userData.users.length) return
return createUser() return createUsers()
} }
async function createUser() { async function createUsers() {
try { try {
await users.create(await removingDuplicities(userData)) createUsersResponse = await users.create(
await removingDuplicities(userData)
)
notifications.success("Successfully created user") notifications.success("Successfully created user")
await groups.actions.init() await groups.actions.init()
passwordModal.show() passwordModal.show()
@ -157,7 +164,7 @@
if (onboardingType === "emailOnboarding") { if (onboardingType === "emailOnboarding") {
createUserFlow() createUserFlow()
} else { } else {
await createUser() await createUsers()
} }
} }
@ -176,8 +183,15 @@
notifications.error("You cannot delete yourself") notifications.error("You cannot delete yourself")
return return
} }
await users.bulkDelete(ids) deleteUsersResponse = await users.bulkDelete(ids)
notifications.success(`Successfully deleted ${selectedRows.length} rows`) if (deleteUsersResponse.unsuccessful?.length) {
deletionFailureModal.show()
} else {
notifications.success(
`Successfully deleted ${selectedRows.length} users`
)
}
selectedRows = [] selectedRows = []
await fetchUsers(page, searchEmail) await fetchUsers(page, searchEmail)
} catch (error) { } catch (error) {
@ -267,16 +281,7 @@
</Modal> </Modal>
<Modal bind:this={inviteConfirmationModal}> <Modal bind:this={inviteConfirmationModal}>
<ModalContent <InvitedModal {inviteUsersResponse} />
showCancelButton={false}
title="Invites sent!"
confirmText="Done"
>
<Body size="S"
>Your users should now recieve an email invite to get access to their
Budibase account</Body
></ModalContent
>
</Modal> </Modal>
<Modal bind:this={onboardingTypeModal}> <Modal bind:this={onboardingTypeModal}>
@ -284,7 +289,11 @@
</Modal> </Modal>
<Modal bind:this={passwordModal}> <Modal bind:this={passwordModal}>
<PasswordModal userData={userData.users} /> <PasswordModal {createUsersResponse} userData={userData.users} />
</Modal>
<Modal bind:this={deletionFailureModal}>
<DeletionFailureModal {deleteUsersResponse} />
</Modal> </Modal>
<Modal bind:this={importUsersModal}> <Modal bind:this={importUsersModal}>

View File

@ -63,10 +63,14 @@ export function createUsersStore() {
return body return body
}) })
await API.createUsers({ users: mappedUsers, groups: data.groups }) const response = await API.createUsers({
users: mappedUsers,
groups: data.groups,
})
// re-search from first page // re-search from first page
await search() await search()
return response
} }
async function del(id) { async function del(id) {
@ -79,7 +83,7 @@ export function createUsersStore() {
} }
async function bulkDelete(userIds) { async function bulkDelete(userIds) {
await API.deleteUsers(userIds) return API.deleteUsers(userIds)
} }
async function save(user) { async function save(user) {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {
@ -26,7 +26,7 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "1.2.41-alpha.5", "@budibase/backend-core": "1.3.4-alpha.2",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",

File diff suppressed because it is too large Load Diff

View File

@ -544,7 +544,8 @@
"values": [ "values": [
{ {
"label": "Row Index", "label": "Row Index",
"key": "index" "key": "index",
"type": "number"
} }
] ]
} }
@ -2314,19 +2315,23 @@
"values": [ "values": [
{ {
"label": "Value", "label": "Value",
"key": "__value" "key": "__value",
"type": "object"
}, },
{ {
"label": "Valid", "label": "Valid",
"key": "__valid" "key": "__valid",
"type": "boolean"
}, },
{ {
"label": "Current Step", "label": "Current Step",
"key": "__currentStep" "key": "__currentStep",
"type": "number"
}, },
{ {
"label": "Current Step Valid", "label": "Current Step Valid",
"key": "__currentStepValid" "key": "__currentStepValid",
"type": "boolean"
} }
] ]
}, },
@ -3437,7 +3442,6 @@
}, },
"s3upload": { "s3upload": {
"name": "S3 File Upload", "name": "S3 File Upload",
"info": "This component can't be used with S3 datasources that use custom endpoints.",
"icon": "UploadToCloud", "icon": "UploadToCloud",
"styles": [ "styles": [
"size" "size"
@ -3458,7 +3462,8 @@
{ {
"type": "dataSource/s3", "type": "dataSource/s3",
"label": "S3 Datasource", "label": "S3 Datasource",
"key": "datasourceId" "key": "datasourceId",
"info": "This component can't be used with S3 datasources that use custom endpoints"
}, },
{ {
"type": "text", "type": "text",
@ -3496,7 +3501,6 @@
}, },
"dataprovider": { "dataprovider": {
"name": "Data Provider", "name": "Data Provider",
"info": "Pagination is only available for data stored in tables.",
"icon": "Data", "icon": "Data",
"illegalChildren": [ "illegalChildren": [
"section" "section"
@ -3542,7 +3546,8 @@
"type": "boolean", "type": "boolean",
"label": "Paginate", "label": "Paginate",
"key": "paginate", "key": "paginate",
"defaultValue": true "defaultValue": true,
"info": "Pagination is only available for data stored in tables"
} }
], ],
"context": { "context": {
@ -3550,23 +3555,28 @@
"values": [ "values": [
{ {
"label": "Rows", "label": "Rows",
"key": "rows" "key": "rows",
"type": "array"
}, },
{ {
"label": "Extra Info", "label": "Extra Info",
"key": "info" "key": "info",
"type": "string"
}, },
{ {
"label": "Rows Length", "label": "Rows Length",
"key": "rowsLength" "key": "rowsLength",
"type": "number"
}, },
{ {
"label": "Schema", "label": "Schema",
"key": "schema" "key": "schema",
"type": "object"
}, },
{ {
"label": "Page Number", "label": "Page Number",
"key": "pageNumber" "key": "pageNumber",
"type": "number"
} }
] ]
} }
@ -3579,7 +3589,6 @@
], ],
"hasChildren": true, "hasChildren": true,
"showEmptyState": false, "showEmptyState": false,
"info": "Row selection is only compatible with internal or SQL tables",
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -3636,7 +3645,8 @@
"type": "boolean", "type": "boolean",
"label": "Allow row selection", "label": "Allow row selection",
"key": "allowSelectRows", "key": "allowSelectRows",
"defaultValue": false "defaultValue": false,
"info": "Row selection is only compatible with internal or SQL tables"
}, },
{ {
"type": "boolean", "type": "boolean",
@ -3677,13 +3687,13 @@
"size" "size"
], ],
"hasChildren": false, "hasChildren": false,
"info": "Your data provider will be automatically filtered to the given date range.",
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
"label": "Provider", "label": "Provider",
"key": "dataProvider", "key": "dataProvider",
"required": true "required": true,
"info": "Your data provider will be automatically filtered to the given date range."
}, },
{ {
"type": "field", "type": "field",
@ -3818,7 +3828,6 @@
"styles": [ "styles": [
"size" "size"
], ],
"info": "Only the first 5 search columns will be used",
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -3835,7 +3844,8 @@
"type": "searchfield", "type": "searchfield",
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns" "placeholder": "Choose search columns",
"info": "Only the first 5 search columns will be used"
}, },
{ {
"type": "filter", "type": "filter",
@ -3882,7 +3892,6 @@
{ {
"section": true, "section": true,
"name": "Table", "name": "Table",
"info": "Row selection is only compatible with internal or SQL tables",
"settings": [ "settings": [
{ {
"type": "number", "type": "number",
@ -3916,7 +3925,8 @@
{ {
"type": "boolean", "type": "boolean",
"label": "Allow row selection", "label": "Allow row selection",
"key": "allowSelectRows" "key": "allowSelectRows",
"info": "Row selection is only compatible with internal or SQL tables"
}, },
{ {
"type": "boolean", "type": "boolean",
@ -3983,7 +3993,6 @@
"styles": [ "styles": [
"size" "size"
], ],
"info": "Only the first 3 search columns will be used.",
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -4000,7 +4009,8 @@
"type": "searchfield", "type": "searchfield",
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns" "placeholder": "Choose search columns",
"info": "Only the first 5 search columns will be used"
}, },
{ {
"type": "filter", "type": "filter",
@ -4329,23 +4339,28 @@
"values": [ "values": [
{ {
"label": "Rows", "label": "Rows",
"key": "rows" "key": "rows",
"type": "array"
}, },
{ {
"label": "Extra Info", "label": "Extra Info",
"key": "info" "key": "info",
"type": "string"
}, },
{ {
"label": "Rows Length", "label": "Rows Length",
"key": "rowsLength" "key": "rowsLength",
"type": "number"
}, },
{ {
"label": "Schema", "label": "Schema",
"key": "schema" "key": "schema",
"type": "object"
}, },
{ {
"label": "Page Number", "label": "Page Number",
"key": "pageNumber" "key": "pageNumber",
"type": "number"
} }
] ]
}, },
@ -4355,7 +4370,8 @@
"values": [ "values": [
{ {
"label": "Row Index", "label": "Row Index",
"key": "index" "key": "index",
"type": "number"
} }
] ]
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "1.2.44-alpha.6", "@budibase/bbui": "1.3.4-alpha.2",
"@budibase/frontend-core": "1.2.44-alpha.6", "@budibase/frontend-core": "1.3.4-alpha.2",
"@budibase/string-templates": "1.2.44-alpha.6", "@budibase/string-templates": "1.3.4-alpha.2",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View File

@ -3,7 +3,7 @@
import Component from "./Component.svelte" import Component from "./Component.svelte"
import Provider from "./context/Provider.svelte" import Provider from "./context/Provider.svelte"
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
import { enrichButtonActions } from "utils/buttonActions.js" import { enrichButtonActions } from "../utils/buttonActions.js"
export let params = {} export let params = {}
@ -29,7 +29,9 @@
...$context, ...$context,
url: params, url: params,
}) })
actions() if (actions != null) {
actions()
}
} }
}) })
</script> </script>

View File

@ -59,8 +59,8 @@
} }
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }

View File

@ -28,8 +28,8 @@
} }
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }

View File

@ -18,8 +18,8 @@
let fieldApi let fieldApi
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }

View File

@ -268,7 +268,7 @@
// Skip if the value is the same // Skip if the value is the same
if (!skipCheck && fieldState.value === value) { if (!skipCheck && fieldState.value === value) {
return return false
} }
// Update field state // Update field state

View File

@ -37,8 +37,8 @@
const handleChange = e => { const handleChange = e => {
const value = parseValue(e.detail) const value = parseValue(e.detail)
fieldApi.setValue(value) const changed = fieldApi.setValue(value)
if (onChange) { if (onChange && changed) {
onChange({ value }) onChange({ value })
} }
} }

View File

@ -47,8 +47,8 @@
} }
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }

View File

@ -44,8 +44,8 @@
} }
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }

View File

@ -34,8 +34,8 @@
) )
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }
@ -77,6 +77,7 @@
{direction} {direction}
on:change={handleChange} on:change={handleChange}
getOptionLabel={flatOptions ? x => x : x => x.label} getOptionLabel={flatOptions ? x => x : x => x.label}
getOptionTitle={flatOptions ? x => x : x => x.label}
getOptionValue={flatOptions ? x => x : x => x.value} getOptionValue={flatOptions ? x => x : x => x.value}
/> />
{/if} {/if}

View File

@ -84,8 +84,8 @@
} }
const handleChange = value => { const handleChange = value => {
fieldApi.setValue(value) const changed = fieldApi.setValue(value)
if (onChange) { if (onChange && changed) {
onChange({ value }) onChange({ value })
} }
} }

View File

@ -90,8 +90,8 @@
} }
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }

View File

@ -16,8 +16,8 @@
let fieldApi let fieldApi
const handleChange = e => { const handleChange = e => {
fieldApi.setValue(e.detail) const changed = fieldApi.setValue(e.detail)
if (onChange) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value: e.detail })
} }
} }
@ -29,7 +29,6 @@
{disabled} {disabled}
{validation} {validation}
{defaultValue} {defaultValue}
{onChange}
type={type === "number" ? "number" : "string"} type={type === "number" ? "number" : "string"}
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi

View File

@ -1,12 +1,12 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"description": "Budibase frontend core libraries used in builder and client", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"dependencies": { "dependencies": {
"@budibase/bbui": "1.2.44-alpha.6", "@budibase/bbui": "1.3.4-alpha.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"svelte": "^3.46.2" "svelte": "^3.46.2"
} }

View File

@ -72,7 +72,7 @@ const cleanupQuery = query => {
continue continue
} }
for (let [key, value] of Object.entries(query[filterField])) { for (let [key, value] of Object.entries(query[filterField])) {
if (!value || value === "") { if (value == null || value === "") {
delete query[filterField][key] delete query[filterField][key]
} }
} }
@ -186,7 +186,7 @@ export const runLuceneQuery = (docs, query) => {
return docs return docs
} }
// make query consistent first // Make query consistent first
query = cleanupQuery(query) query = cleanupQuery(query)
// Iterates over a set of filters and evaluates a fail function against a doc // Iterates over a set of filters and evaluates a fail function against a doc
@ -218,7 +218,12 @@ export const runLuceneQuery = (docs, query) => {
// Process a range match // Process a range match
const rangeMatch = match("range", (docValue, testValue) => { const rangeMatch = match("range", (docValue, testValue) => {
return !docValue || docValue < testValue.low || docValue > testValue.high return (
docValue == null ||
docValue === "" ||
docValue < testValue.low ||
docValue > testValue.high
)
}) })
// Process an equal match (fails if the value is different) // Process an equal match (fails if the value is different)

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "1.2.44-alpha.6", "version": "1.3.4-alpha.2",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -77,11 +77,11 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "10.0.3", "@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "1.2.44-alpha.6", "@budibase/backend-core": "1.3.4-alpha.2",
"@budibase/client": "1.2.44-alpha.6", "@budibase/client": "1.3.4-alpha.2",
"@budibase/pro": "1.2.44-alpha.6", "@budibase/pro": "1.3.4-alpha.2",
"@budibase/string-templates": "1.2.44-alpha.6", "@budibase/string-templates": "1.3.4-alpha.2",
"@budibase/types": "1.2.44-alpha.6", "@budibase/types": "1.3.4-alpha.2",
"@bull-board/api": "3.7.0", "@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4", "@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
@ -99,6 +99,7 @@
"curlconverter": "3.21.0", "curlconverter": "3.21.0",
"dotenv": "8.2.0", "dotenv": "8.2.0",
"download": "8.0.0", "download": "8.0.0",
"elastic-apm-node": "3.38.0",
"fix-path": "3.0.0", "fix-path": "3.0.0",
"form-data": "4.0.0", "form-data": "4.0.0",
"fs-extra": "8.1.0", "fs-extra": "8.1.0",

View File

@ -47,7 +47,14 @@ import { checkAppMetadata } from "../../automations/logging"
import { getUniqueRows } from "../../utilities/usageQuota/rows" import { getUniqueRows } from "../../utilities/usageQuota/rows"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { errors, events, migrations } from "@budibase/backend-core" import { errors, events, migrations } from "@budibase/backend-core"
import { App, MigrationType } from "@budibase/types" import {
App,
Layout,
Screen,
MigrationType,
AppNavigation,
} from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
const URL_REGEX_SLASH = /\/|\\/g const URL_REGEX_SLASH = /\/|\\/g
@ -243,27 +250,19 @@ const performAppCreate = async (ctx: any) => {
} }
const instance = await createInstance(instanceConfig) const instance = await createInstance(instanceConfig)
const appId = instance._id const appId = instance._id
const db = context.getAppDB() const db = context.getAppDB()
let _rev
try { let newApplication: App = {
// if template there will be an existing doc
const existing = await db.get(DocumentType.APP_METADATA)
_rev = existing._rev
} catch (err) {
// nothing to do
}
const newApplication: App = {
_id: DocumentType.APP_METADATA, _id: DocumentType.APP_METADATA,
_rev, _rev: undefined,
appId: instance._id, appId,
type: "app", type: "app",
version: packageJson.version, version: packageJson.version,
componentLibraries: ["@budibase/standard-components"], componentLibraries: ["@budibase/standard-components"],
name: name, name: name,
url: url, url: url,
template: ctx.request.body.template, template: templateKey,
instance: instance, instance,
tenantId: getTenantId(), tenantId: getTenantId(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@ -285,6 +284,36 @@ const performAppCreate = async (ctx: any) => {
buttonBorderRadius: "16px", buttonBorderRadius: "16px",
}, },
} }
// If we used a template or imported an app there will be an existing doc.
// Fetch and migrate some metadata from the existing app.
try {
const existing: App = await db.get(DocumentType.APP_METADATA)
const keys: (keyof App)[] = [
"_rev",
"navigation",
"theme",
"customTheme",
"icon",
]
keys.forEach(key => {
if (existing[key]) {
// @ts-ignore
newApplication[key] = existing[key]
}
})
// Migrate navigation settings and screens if required
if (existing && !existing.navigation) {
const navigation = await migrateAppNavigation()
if (navigation) {
newApplication.navigation = navigation
}
}
} catch (err) {
// Nothing to do
}
const response = await db.put(newApplication, { force: true }) const response = await db.put(newApplication, { force: true })
newApplication._rev = response.rev newApplication._rev = response.rev
@ -567,3 +596,55 @@ const updateAppPackage = async (appPackage: any, appId: any) => {
return newAppPackage return newAppPackage
}) })
} }
const migrateAppNavigation = async () => {
const db = context.getAppDB()
const existing: App = await db.get(DocumentType.APP_METADATA)
const layouts: Layout[] = await getLayouts()
const screens: Screen[] = await getScreens()
// Migrate all screens, removing custom layouts
for (let screen of screens) {
if (!screen.layoutId) {
return
}
const layout = layouts.find(layout => layout._id === screen.layoutId)
screen.layoutId = undefined
screen.showNavigation = layout?.props.navigation !== "None"
screen.width = layout?.props.width || "Large"
await db.put(screen)
}
// Migrate layout navigation settings
const { name, customTheme } = existing
const layout = layouts?.find(
(layout: Layout) => layout._id === BASE_LAYOUT_PROP_IDS.PRIVATE
)
if (layout) {
let navigationSettings: any = {
navigation: "Top",
title: name,
navWidth: "Large",
navBackground:
customTheme?.navBackground || "var(--spectrum-global-color-gray-50)",
navTextColor:
customTheme?.navTextColor || "var(--spectrum-global-color-gray-800)",
}
if (layout) {
navigationSettings.hideLogo = layout.props.hideLogo
navigationSettings.hideTitle = layout.props.hideTitle
navigationSettings.title = layout.props.title || name
navigationSettings.logoUrl = layout.props.logoUrl
navigationSettings.links = layout.props.links
navigationSettings.navigation = layout.props.navigation || "Top"
navigationSettings.sticky = layout.props.sticky
navigationSettings.navWidth = layout.props.width || "Large"
if (navigationSettings.navigation === "None") {
navigationSettings.navigation = "Top"
}
}
return navigationSettings
} else {
return null
}
}

View File

@ -3,32 +3,36 @@ const { getComponentLibraryManifest } = require("../../utilities/fileSystem")
const { getAppDB } = require("@budibase/backend-core/context") const { getAppDB } = require("@budibase/backend-core/context")
exports.fetchAppComponentDefinitions = async function (ctx) { exports.fetchAppComponentDefinitions = async function (ctx) {
const db = getAppDB() try {
const app = await db.get(DocumentType.APP_METADATA) const db = getAppDB()
const app = await db.get(DocumentType.APP_METADATA)
let componentManifests = await Promise.all( let componentManifests = await Promise.all(
app.componentLibraries.map(async library => { app.componentLibraries.map(async library => {
let manifest = await getComponentLibraryManifest(library) let manifest = await getComponentLibraryManifest(library)
return { return {
manifest, manifest,
library, library,
} }
}) })
) )
const definitions = {} const definitions = {}
for (let { manifest, library } of componentManifests) { for (let { manifest, library } of componentManifests) {
for (let key of Object.keys(manifest)) { for (let key of Object.keys(manifest)) {
if (key === "features") { if (key === "features") {
definitions[key] = manifest[key] definitions[key] = manifest[key]
} else { } else {
const fullComponentName = `${library}/${key}`.toLowerCase() const fullComponentName = `${library}/${key}`.toLowerCase()
definitions[fullComponentName] = { definitions[fullComponentName] = {
component: fullComponentName, component: fullComponentName,
...manifest[key], ...manifest[key],
}
} }
} }
} }
ctx.body = definitions
} catch (err) {
console.error(`component-definitions=failed`, err)
} }
ctx.body = definitions
} }

View File

@ -375,6 +375,7 @@ exports.exportRows = async ctx => {
const table = await db.get(ctx.params.tableId) const table = await db.get(ctx.params.tableId)
const rowIds = ctx.request.body.rows const rowIds = ctx.request.body.rows
let format = ctx.query.format let format = ctx.query.format
const { columns } = ctx.request.body
let response = ( let response = (
await db.allDocs({ await db.allDocs({
include_docs: true, include_docs: true,
@ -382,7 +383,20 @@ exports.exportRows = async ctx => {
}) })
).rows.map(row => row.doc) ).rows.map(row => row.doc)
let rows = await outputProcessing(table, response) let result = await outputProcessing(table, response)
let rows = []
// Filter data to only specified columns if required
if (columns && columns.length) {
for (let i = 0; i < result.length; i++) {
rows[i] = {}
for (let column of columns) {
rows[i][column] = result[i][column]
}
}
} else {
rows = result
}
let headers = Object.keys(rows[0]) let headers = Object.keys(rows[0])
const exporter = exporters[format] const exporter = exporters[format]

View File

@ -17,6 +17,7 @@ const {
checkBuilderEndpoint, checkBuilderEndpoint,
} = require("./utilities/TestFunctions") } = require("./utilities/TestFunctions")
const setup = require("./utilities") const setup = require("./utilities")
const { basicScreen, basicLayout } = setup.structures
const { AppStatus } = require("../../../db/utils") const { AppStatus } = require("../../../db/utils")
const { events } = require("@budibase/backend-core") const { events } = require("@budibase/backend-core")
@ -81,6 +82,31 @@ describe("/applications", () => {
body: { name: "My App" }, body: { name: "My App" },
}) })
}) })
it("migrates navigation settings from old apps", async () => {
const res = await request
.post("/api/applications")
.field("name", "Old App")
.field("useTemplate", "true")
.set(config.defaultHeaders())
.attach("templateFile", "src/api/routes/tests/data/old-app.txt")
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toBeDefined()
expect(res.body.navigation).toBeDefined()
expect(res.body.navigation.hideLogo).toBe(true)
expect(res.body.navigation.title).toBe("Custom Title")
expect(res.body.navigation.hideLogo).toBe(true)
expect(res.body.navigation.navigation).toBe("Left")
expect(res.body.navigation.navBackground).toBe(
"var(--spectrum-global-color-blue-600)"
)
expect(res.body.navigation.navTextColor).toBe(
"var(--spectrum-global-color-gray-50)"
)
expect(events.app.created).toBeCalledTimes(1)
expect(events.app.fileImported).toBeCalledTimes(1)
})
}) })
describe("fetch", () => { describe("fetch", () => {

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,12 @@ const setup = require("./utilities")
const { basicRow } = setup.structures const { basicRow } = setup.structures
const { doInAppContext } = require("@budibase/backend-core/context") const { doInAppContext } = require("@budibase/backend-core/context")
const { doInTenant } = require("@budibase/backend-core/tenancy") const { doInTenant } = require("@budibase/backend-core/tenancy")
const { quotas, QuotaUsageType, StaticQuotaName, MonthlyQuotaName } = require("@budibase/pro") const {
quotas,
QuotaUsageType,
StaticQuotaName,
MonthlyQuotaName,
} = require("@budibase/pro")
describe("/rows", () => { describe("/rows", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -23,23 +28,30 @@ describe("/rows", () => {
await request await request
.get(`/api/${table._id}/rows/${id}`) .get(`/api/${table._id}/rows/${id}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(status) .expect(status)
const getRowUsage = async () => { const getRowUsage = async () => {
return config.doInContext(null, () => quotas.getCurrentUsageValue(QuotaUsageType.STATIC, StaticQuotaName.ROWS)) return config.doInContext(null, () =>
quotas.getCurrentUsageValue(QuotaUsageType.STATIC, StaticQuotaName.ROWS)
)
} }
const getQueryUsage = async () => { const getQueryUsage = async () => {
return config.doInContext(null, () => quotas.getCurrentUsageValue(QuotaUsageType.MONTHLY, MonthlyQuotaName.QUERIES)) return config.doInContext(null, () =>
quotas.getCurrentUsageValue(
QuotaUsageType.MONTHLY,
MonthlyQuotaName.QUERIES
)
)
} }
const assertRowUsage = async (expected) => { const assertRowUsage = async expected => {
const usage = await getRowUsage() const usage = await getRowUsage()
expect(usage).toBe(expected) expect(usage).toBe(expected)
} }
const assertQueryUsage = async (expected) => { const assertQueryUsage = async expected => {
const usage = await getQueryUsage() const usage = await getQueryUsage()
expect(usage).toBe(expected) expect(usage).toBe(expected)
} }
@ -76,10 +88,12 @@ describe("/rows", () => {
name: "Updated Name", name: "Updated Name",
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.res.statusMessage).toEqual(`${table.name} updated successfully.`) expect(res.res.statusMessage).toEqual(
`${table.name} updated successfully.`
)
expect(res.body.name).toEqual("Updated Name") expect(res.body.name).toEqual("Updated Name")
// await assertRowUsage(rowUsage) // await assertRowUsage(rowUsage)
// await assertQueryUsage(queryUsage + 1) // await assertQueryUsage(queryUsage + 1)
@ -92,7 +106,7 @@ describe("/rows", () => {
const res = await request const res = await request
.get(`/api/${table._id}/rows/${existing._id}`) .get(`/api/${table._id}/rows/${existing._id}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body).toEqual({ expect(res.body).toEqual({
@ -110,7 +124,7 @@ describe("/rows", () => {
const newRow = { const newRow = {
tableId: table._id, tableId: table._id,
name: "Second Contact", name: "Second Contact",
status: "new" status: "new",
} }
await config.createRow() await config.createRow()
await config.createRow(newRow) await config.createRow(newRow)
@ -119,7 +133,7 @@ describe("/rows", () => {
const res = await request const res = await request
.get(`/api/${table._id}/rows`) .get(`/api/${table._id}/rows`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.length).toBe(2) expect(res.body.length).toBe(2)
@ -135,17 +149,36 @@ describe("/rows", () => {
await request await request
.get(`/api/${table._id}/rows/not-a-valid-id`) .get(`/api/${table._id}/rows/not-a-valid-id`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(404) .expect(404)
await assertQueryUsage(queryUsage) // no change await assertQueryUsage(queryUsage) // no change
}) })
it("row values are coerced", async () => { it("row values are coerced", async () => {
const str = {type:"string", constraints: { type: "string", presence: false }} const str = {
const attachment = {type:"attachment", constraints: { type: "array", presence: false }} type: "string",
const bool = {type:"boolean", constraints: { type: "boolean", presence: false }} constraints: { type: "string", presence: false },
const number = {type:"number", constraints: { type: "number", presence: false }} }
const datetime = {type:"datetime", constraints: { type: "string", presence: false, datetime: {earliest:"", latest: ""} }} const attachment = {
type: "attachment",
constraints: { type: "array", presence: false },
}
const bool = {
type: "boolean",
constraints: { type: "boolean", presence: false },
}
const number = {
type: "number",
constraints: { type: "number", presence: false },
}
const datetime = {
type: "datetime",
constraints: {
type: "string",
presence: false,
datetime: { earliest: "", latest: "" },
},
}
table = await config.createTable({ table = await config.createTable({
name: "TestTable2", name: "TestTable2",
@ -171,9 +204,9 @@ describe("/rows", () => {
boolUndefined: bool, boolUndefined: bool,
boolString: bool, boolString: bool,
boolBool: bool, boolBool: bool,
attachmentNull : attachment, attachmentNull: attachment,
attachmentUndefined : attachment, attachmentUndefined: attachment,
attachmentEmpty : attachment, attachmentEmpty: attachment,
}, },
}) })
@ -198,9 +231,9 @@ describe("/rows", () => {
boolString: "true", boolString: "true",
boolBool: true, boolBool: true,
tableId: table._id, tableId: table._id,
attachmentNull : null, attachmentNull: null,
attachmentUndefined : undefined, attachmentUndefined: undefined,
attachmentEmpty : "", attachmentEmpty: "",
} }
const id = (await config.createRow(row))._id const id = (await config.createRow(row))._id
@ -218,7 +251,9 @@ describe("/rows", () => {
expect(saved.datetimeEmptyString).toBe(null) expect(saved.datetimeEmptyString).toBe(null)
expect(saved.datetimeNull).toBe(null) expect(saved.datetimeNull).toBe(null)
expect(saved.datetimeUndefined).toBe(undefined) expect(saved.datetimeUndefined).toBe(undefined)
expect(saved.datetimeString).toBe(new Date(row.datetimeString).toISOString()) expect(saved.datetimeString).toBe(
new Date(row.datetimeString).toISOString()
)
expect(saved.datetimeDate).toBe(row.datetimeDate.toISOString()) expect(saved.datetimeDate).toBe(row.datetimeDate.toISOString())
expect(saved.boolNull).toBe(null) expect(saved.boolNull).toBe(null)
expect(saved.boolEmpty).toBe(null) expect(saved.boolEmpty).toBe(null)
@ -247,10 +282,12 @@ describe("/rows", () => {
name: "Updated Name", name: "Updated Name",
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.res.statusMessage).toEqual(`${table.name} updated successfully.`) expect(res.res.statusMessage).toEqual(
`${table.name} updated successfully.`
)
expect(res.body.name).toEqual("Updated Name") expect(res.body.name).toEqual("Updated Name")
expect(res.body.description).toEqual(existing.description) expect(res.body.description).toEqual(existing.description)
@ -292,16 +329,14 @@ describe("/rows", () => {
const res = await request const res = await request
.delete(`/api/${table._id}/rows`) .delete(`/api/${table._id}/rows`)
.send({ .send({
rows: [ rows: [createdRow],
createdRow
]
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body[0]._id).toEqual(createdRow._id) expect(res.body[0]._id).toEqual(createdRow._id)
await assertRowUsage(rowUsage -1) await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage +1) await assertQueryUsage(queryUsage + 1)
}) })
}) })
@ -314,9 +349,9 @@ describe("/rows", () => {
.post(`/api/${table._id}/rows/validate`) .post(`/api/${table._id}/rows/validate`)
.send({ name: "ivan" }) .send({ name: "ivan" })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.valid).toBe(true) expect(res.body.valid).toBe(true)
expect(Object.keys(res.body.errors)).toEqual([]) expect(Object.keys(res.body.errors)).toEqual([])
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
@ -331,9 +366,9 @@ describe("/rows", () => {
.post(`/api/${table._id}/rows/validate`) .post(`/api/${table._id}/rows/validate`)
.send({ name: 1 }) .send({ name: 1 })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.valid).toBe(false) expect(res.body.valid).toBe(false)
expect(Object.keys(res.body.errors)).toEqual(["name"]) expect(Object.keys(res.body.errors)).toEqual(["name"])
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
@ -351,19 +386,16 @@ describe("/rows", () => {
const res = await request const res = await request
.delete(`/api/${table._id}/rows`) .delete(`/api/${table._id}/rows`)
.send({ .send({
rows: [ rows: [row1, row2],
row1,
row2,
]
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.length).toEqual(2) expect(res.body.length).toEqual(2)
await loadRow(row1._id, 404) await loadRow(row1._id, 404)
await assertRowUsage(rowUsage - 2) await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage +1) await assertQueryUsage(queryUsage + 1)
}) })
}) })
@ -376,12 +408,12 @@ describe("/rows", () => {
const res = await request const res = await request
.get(`/api/views/${table._id}`) .get(`/api/views/${table._id}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.length).toEqual(1) expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id) expect(res.body[0]._id).toEqual(row._id)
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage +1) await assertQueryUsage(queryUsage + 1)
}) })
it("should throw an error if view doesn't exist", async () => { it("should throw an error if view doesn't exist", async () => {
@ -406,7 +438,7 @@ describe("/rows", () => {
const res = await request const res = await request
.get(`/api/views/${view.name}`) .get(`/api/views/${view.name}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.length).toEqual(1) expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id) expect(res.body[0]._id).toEqual(row._id)
@ -418,21 +450,24 @@ describe("/rows", () => {
describe("fetchEnrichedRows", () => { describe("fetchEnrichedRows", () => {
it("should allow enriching some linked rows", async () => { it("should allow enriching some linked rows", async () => {
const { table, firstRow, secondRow } = await doInTenant(setup.structures.TENANT_ID, async () => { const { table, firstRow, secondRow } = await doInTenant(
const table = await config.createLinkedTable() setup.structures.TENANT_ID,
const firstRow = await config.createRow({ async () => {
name: "Test Contact", const table = await config.createLinkedTable()
description: "original description", const firstRow = await config.createRow({
tableId: table._id name: "Test Contact",
}) description: "original description",
const secondRow = await config.createRow({ tableId: table._id,
name: "Test 2", })
description: "og desc", const secondRow = await config.createRow({
link: [{_id: firstRow._id}], name: "Test 2",
tableId: table._id, description: "og desc",
}) link: [{ _id: firstRow._id }],
return { table, firstRow, secondRow } tableId: table._id,
}) })
return { table, firstRow, secondRow }
}
)
const rowUsage = await getRowUsage() const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage() const queryUsage = await getQueryUsage()
@ -440,7 +475,7 @@ describe("/rows", () => {
const resBasic = await request const resBasic = await request
.get(`/api/${table._id}/rows/${secondRow._id}`) .get(`/api/${table._id}/rows/${secondRow._id}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(resBasic.body.link[0]._id).toBe(firstRow._id) expect(resBasic.body.link[0]._id).toBe(firstRow._id)
expect(resBasic.body.link[0].primaryDisplay).toBe("Test Contact") expect(resBasic.body.link[0].primaryDisplay).toBe("Test Contact")
@ -449,14 +484,14 @@ describe("/rows", () => {
const resEnriched = await request const resEnriched = await request
.get(`/api/${table._id}/${secondRow._id}/enrich`) .get(`/api/${table._id}/${secondRow._id}/enrich`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(resEnriched.body.link.length).toBe(1) expect(resEnriched.body.link.length).toBe(1)
expect(resEnriched.body.link[0]._id).toBe(firstRow._id) expect(resEnriched.body.link[0]._id).toBe(firstRow._id)
expect(resEnriched.body.link[0].name).toBe("Test Contact") expect(resEnriched.body.link[0].name).toBe("Test Contact")
expect(resEnriched.body.link[0].description).toBe("original description") expect(resEnriched.body.link[0].description).toBe("original description")
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage +2) await assertQueryUsage(queryUsage + 2)
}) })
}) })
@ -466,9 +501,11 @@ describe("/rows", () => {
const row = await config.createRow({ const row = await config.createRow({
name: "test", name: "test",
description: "test", description: "test",
attachment: [{ attachment: [
key: `${config.getAppId()}/attachments/test/thing.csv`, {
}], key: `${config.getAppId()}/attachments/test/thing.csv`,
},
],
tableId: table._id, tableId: table._id,
}) })
// the environment needs configured for this // the environment needs configured for this
@ -482,4 +519,49 @@ describe("/rows", () => {
}) })
}) })
}) })
describe("exportData", () => {
it("should allow exporting all columns", async () => {
const existing = await config.createRow()
const res = await request
.post(`/api/${table._id}/rows/exportRows?format=json`)
.set(config.defaultHeaders())
.send({
rows: [existing._id],
})
.expect("Content-Type", /json/)
.expect(200)
const results = JSON.parse(res.text)
expect(results.length).toEqual(1)
const row = results[0]
// Ensure all original columns were exported
expect(Object.keys(row).length).toBeGreaterThanOrEqual(
Object.keys(existing).length
)
Object.keys(existing).forEach(key => {
expect(row[key]).toEqual(existing[key])
})
})
it("should allow exporting only certain columns", async () => {
const existing = await config.createRow()
const res = await request
.post(`/api/${table._id}/rows/exportRows?format=json`)
.set(config.defaultHeaders())
.send({
rows: [existing._id],
columns: ["_id"],
})
.expect("Content-Type", /json/)
.expect(200)
const results = JSON.parse(res.text)
expect(results.length).toEqual(1)
const row = results[0]
// Ensure only the _id column was exported
expect(Object.keys(row).length).toEqual(1)
expect(row._id).toEqual(existing._id)
})
})
}) })

View File

@ -1,6 +1,15 @@
// need to load environment first // need to load environment first
import { ExtendableContext } from "koa"
import * as env from "./environment" import * as env from "./environment"
// enable APM if configured
if (process.env.ELASTIC_APM_ENABLED) {
const apm = require("elastic-apm-node").start({
serviceName: process.env.SERVICE,
environment: process.env.BUDIBASE_ENVIRONMENT,
})
}
import { ExtendableContext } from "koa"
import db from "./db" import db from "./db"
db.init() db.init()
const Koa = require("koa") const Koa = require("koa")
@ -74,9 +83,7 @@ server.on("close", async () => {
return return
} }
shuttingDown = true shuttingDown = true
if (!env.isTest()) { console.log("Server Closed")
console.log("Server Closed")
}
await automations.shutdown() await automations.shutdown()
await redis.shutdown() await redis.shutdown()
await events.shutdown() await events.shutdown()
@ -158,3 +165,7 @@ process.on("uncaughtException", err => {
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
shutdown() shutdown()
}) })
process.on("SIGINT", () => {
shutdown()
})

View File

@ -8,12 +8,14 @@ const Queue = env.isTest()
const { JobQueues } = require("../constants") const { JobQueues } = require("../constants")
const { utils } = require("@budibase/backend-core/redis") const { utils } = require("@budibase/backend-core/redis")
const { opts, redisProtocolUrl } = utils.getRedisOptions() const { opts, redisProtocolUrl } = utils.getRedisOptions()
const listeners = require("./listeners")
const CLEANUP_PERIOD_MS = 60 * 1000 const CLEANUP_PERIOD_MS = 60 * 1000
const queueConfig = redisProtocolUrl || { redis: opts } const queueConfig = redisProtocolUrl || { redis: opts }
let cleanupInternal = null let cleanupInternal = null
let automationQueue = new Queue(JobQueues.AUTOMATIONS, queueConfig) let automationQueue = new Queue(JobQueues.AUTOMATIONS, queueConfig)
listeners.addListeners(automationQueue)
async function cleanup() { async function cleanup() {
await automationQueue.clean(CLEANUP_PERIOD_MS, "completed") await automationQueue.clean(CLEANUP_PERIOD_MS, "completed")
@ -51,6 +53,7 @@ exports.shutdown = async () => {
await automationQueue.close() await automationQueue.close()
automationQueue = null automationQueue = null
} }
console.log("Bull shutdown")
} }
exports.queue = automationQueue exports.queue = automationQueue

View File

@ -0,0 +1,78 @@
import { Queue, Job, JobId } from "bull"
import { AutomationEvent } from "../definitions/automations"
import * as automation from "../threads/automation"
export const addListeners = (queue: Queue) => {
logging(queue)
handleStalled(queue)
}
const handleStalled = (queue: Queue) => {
queue.on("stalled", async (job: Job) => {
await automation.removeStalled(job as AutomationEvent)
})
}
const logging = (queue: Queue) => {
if (process.env.NODE_DEBUG?.includes("bull")) {
queue
.on("error", (error: any) => {
// An error occurred.
console.error(`automation-event=error error=${JSON.stringify(error)}`)
})
.on("waiting", (jobId: JobId) => {
// A Job is waiting to be processed as soon as a worker is idling.
console.log(`automation-event=waiting jobId=${jobId}`)
})
.on("active", (job: Job, jobPromise: any) => {
// A job has started. You can use `jobPromise.cancel()`` to abort it.
console.log(`automation-event=active jobId=${job.id}`)
})
.on("stalled", (job: Job) => {
// A job has been marked as stalled. This is useful for debugging job
// workers that crash or pause the event loop.
console.error(
`automation-event=stalled jobId=${job.id} job=${JSON.stringify(job)}`
)
})
.on("progress", (job: Job, progress: any) => {
// A job's progress was updated!
console.log(
`automation-event=progress jobId=${job.id} progress=${progress}`
)
})
.on("completed", (job: Job, result) => {
// A job successfully completed with a `result`.
console.log(
`automation-event=completed jobId=${job.id} result=${result}`
)
})
.on("failed", (job, err: any) => {
// A job failed with reason `err`!
console.log(`automation-event=failed jobId=${job.id} error=${err}`)
})
.on("paused", () => {
// The queue has been paused.
console.log(`automation-event=paused`)
})
.on("resumed", (job: Job) => {
// The queue has been resumed.
console.log(`automation-event=paused jobId=${job.id}`)
})
.on("cleaned", (jobs: Job[], type: string) => {
// Old jobs have been cleaned from the queue. `jobs` is an array of cleaned
// jobs, and `type` is the type of jobs cleaned.
console.log(
`automation-event=cleaned length=${jobs.length} type=${type}`
)
})
.on("drained", () => {
// Emitted every time the queue has processed all the waiting jobs (even if there can be some delayed jobs not yet processed)
console.log(`automation-event=drained`)
})
.on("removed", (job: Job) => {
// A job successfully removed.
console.log(`automation-event=removed jobId=${job.id}`)
})
}
}

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