This commit is contained in:
Martin McKeaveney 2022-10-26 11:47:25 +01:00
commit 3e348908f5
175 changed files with 4984 additions and 1308 deletions

View File

@ -79,7 +79,11 @@ spec:
- name: MINIO_URL - name: MINIO_URL
value: {{ .Values.services.objectStore.url }} value: {{ .Values.services.objectStore.url }}
- name: PLUGIN_BUCKET_NAME - name: PLUGIN_BUCKET_NAME
value: {{ .Values.services.objectStore.pluginBucketName | default "plugins" | quote }} value: {{ .Values.services.objectStore.pluginBucketName | quote }}
- name: APPS_BUCKET_NAME
value: {{ .Values.services.objectStore.appsBucketName | quote }}
- name: GLOBAL_CLOUD_BUCKET_NAME
value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.apps.port | quote }} value: {{ .Values.services.apps.port | quote }}
{{ if .Values.services.worker.publicApiRateLimitPerSecond }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }}

View File

@ -78,7 +78,11 @@ spec:
- name: MINIO_URL - name: MINIO_URL
value: {{ .Values.services.objectStore.url }} value: {{ .Values.services.objectStore.url }}
- name: PLUGIN_BUCKET_NAME - name: PLUGIN_BUCKET_NAME
value: {{ .Values.services.objectStore.pluginBucketName | default "plugins" | quote }} value: {{ .Values.services.objectStore.pluginBucketName | quote }}
- name: APPS_BUCKET_NAME
value: {{ .Values.services.objectStore.appsBucketName | quote }}
- name: GLOBAL_CLOUD_BUCKET_NAME
value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.worker.port | quote }} value: {{ .Values.services.worker.port | quote }}
- name: MULTI_TENANCY - name: MULTI_TENANCY

View File

@ -0,0 +1,24 @@
#!/bin/sh
# vim:sw=4:ts=4:et
set -e
ME=$(basename $0)
NGINX_CONF_FILE="/etc/nginx/nginx.conf"
DEFAULT_CONF_FILE="/etc/nginx/conf.d/default.conf"
# check if we have ipv6 available
if [ ! -f "/proc/net/if_inet6" ]; then
# ipv6 not available so delete lines from nginx conf
if [ -f "$NGINX_CONF_FILE" ]; then
sed -i '/listen \[::\]/d' $NGINX_CONF_FILE
fi
if [ -f "$DEFAULT_CONF_FILE" ]; then
sed -i '/listen \[::\]/d' $DEFAULT_CONF_FILE
fi
echo "$ME: info: ipv6 not available so delete lines from nginx conf"
else
echo "$ME: info: ipv6 is available so no need to delete lines from nginx conf"
fi
exit 0

View File

@ -5,7 +5,12 @@ FROM nginx:latest
# override the output dir to output directly to /etc/nginx instead of /etc/nginx/conf.d # override the output dir to output directly to /etc/nginx instead of /etc/nginx/conf.d
ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx
COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template
COPY 10-listen-on-ipv6-by-default.sh /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
# IPv6 removal needs to happen after envsubst
RUN rm -rf /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
COPY 80-listen-on-ipv6-by-default.sh /docker-entrypoint.d/80-listen-on-ipv6-by-default.sh
RUN chmod +x /docker-entrypoint.d/80-listen-on-ipv6-by-default.sh
# Error handling # Error handling
COPY error.html /usr/share/nginx/html/error.html COPY error.html /usr/share/nginx/html/error.html

View File

@ -18,6 +18,7 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME
[[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002 [[ -z "${WORKER_URL}" ]] && export WORKER_URL=http://localhost:4002
[[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001 [[ -z "${APPS_URL}" ]] && export APPS_URL=http://localhost:4001
# export CUSTOM_DOMAIN=budi001.custom.com # export CUSTOM_DOMAIN=budi001.custom.com
# Azure App Service customisations # Azure App Service customisations
if [[ "${TARGETBUILD}" = "aas" ]]; then if [[ "${TARGETBUILD}" = "aas" ]]; then
DATA_DIR=/home DATA_DIR=/home
@ -27,6 +28,13 @@ else
DATA_DIR=${DATA_DIR:-/data} DATA_DIR=${DATA_DIR:-/data}
fi fi
# Mount NFS or GCP Filestore if env vars exist for it
if [[ -z ${FILESHARE_IP} && -z ${FILESHARE_NAME} ]]; then
echo "Mount file share ${FILESHARE_IP}:/${FILESHARE_NAME} to ${DATA_DIR}"
mount -o nolock ${FILESHARE_IP}:/${FILESHARE_NAME} ${DATA_DIR}
echo "Mounting completed."
fi
if [ -f "${DATA_DIR}/.env" ]; then if [ -f "${DATA_DIR}/.env" ]; then
# Read in the .env file and export the variables # Read in the .env file and export the variables
for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done

View File

@ -1,5 +1,5 @@
{ {
"version": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"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,12 +20,13 @@
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
"dependencies": { "dependencies": {
"@budibase/types": "2.0.30-alpha.13", "@budibase/types": "2.0.34-alpha.3",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@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",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.10.1",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"emitter-listener": "1.1.2", "emitter-listener": "1.1.2",
"ioredis": "4.28.0", "ioredis": "4.28.0",
@ -63,6 +64,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/ioredis": "4.28.0",
"@types/jest": "27.5.1", "@types/jest": "27.5.1",
"@types/koa": "2.0.52", "@types/koa": "2.0.52",
"@types/lodash": "4.14.180", "@types/lodash": "4.14.180",

View File

@ -1,6 +1,7 @@
import BaseCache from "./base" import BaseCache from "./base"
import { getWritethroughClient } from "../redis/init" import { getWritethroughClient } from "../redis/init"
import { logWarn } from "../logging" import { logWarn } from "../logging"
import PouchDB from "pouchdb"
const DEFAULT_WRITE_RATE_MS = 10000 const DEFAULT_WRITE_RATE_MS = 10000
let CACHE: BaseCache | null = null let CACHE: BaseCache | null = null

View File

@ -53,6 +53,9 @@ export const getTenantIDFromAppID = (appId: string) => {
if (!appId) { if (!appId) {
return null return null
} }
if (!isMultiTenant()) {
return DEFAULT_TENANT_ID
}
const split = appId.split(SEPARATOR) const split = appId.split(SEPARATOR)
const hasDev = split[1] === DocumentType.DEV const hasDev = split[1] === DocumentType.DEV
if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) { if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) {

View File

@ -21,6 +21,7 @@ export enum ViewName {
ACCOUNT_BY_EMAIL = "account_by_email", ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
USER_BY_GROUP = "by_group_user", USER_BY_GROUP = "by_group_user",
APP_BACKUP_BY_TRIGGER = "by_trigger",
} }
export const DeprecatedViews = { export const DeprecatedViews = {
@ -30,6 +31,10 @@ export const DeprecatedViews = {
], ],
} }
export enum InternalTable {
USER_METADATA = "ta_users",
}
export enum DocumentType { export enum DocumentType {
USER = "us", USER = "us",
GROUP = "gr", GROUP = "gr",
@ -46,9 +51,23 @@ export enum DocumentType {
AUTOMATION_LOG = "log_au", AUTOMATION_LOG = "log_au",
ACCOUNT_METADATA = "acc_metadata", ACCOUNT_METADATA = "acc_metadata",
PLUGIN = "plg", PLUGIN = "plg",
TABLE = "ta",
DATASOURCE = "datasource", DATASOURCE = "datasource",
DATASOURCE_PLUS = "datasource_plus", DATASOURCE_PLUS = "datasource_plus",
APP_BACKUP = "backup",
TABLE = "ta",
ROW = "ro",
AUTOMATION = "au",
LINK = "li",
WEBHOOK = "wh",
INSTANCE = "inst",
LAYOUT = "layout",
SCREEN = "screen",
QUERY = "query",
DEPLOYMENTS = "deployments",
METADATA = "metadata",
MEM_VIEW = "view",
USER_FLAG = "flag",
AUTOMATION_METADATA = "meta_au",
} }
export const StaticDatabases = { export const StaticDatabases = {

View File

@ -1,91 +0,0 @@
const pouch = require("./pouch")
const env = require("../environment")
const openDbs = []
let PouchDB
let initialised = false
const dbList = new Set()
if (env.MEMORY_LEAK_CHECK) {
setInterval(() => {
console.log("--- OPEN DBS ---")
console.log(openDbs)
}, 5000)
}
const put =
dbPut =>
async (doc, options = {}) => {
if (!doc.createdAt) {
doc.createdAt = new Date().toISOString()
}
doc.updatedAt = new Date().toISOString()
return dbPut(doc, options)
}
const checkInitialised = () => {
if (!initialised) {
throw new Error("init has not been called")
}
}
exports.init = opts => {
PouchDB = pouch.getPouch(opts)
initialised = true
}
// NOTE: THIS IS A DANGEROUS FUNCTION - USE WITH CAUTION
// this function is prone to leaks, should only be used
// in situations that using the function doWithDB does not work
exports.dangerousGetDB = (dbName, opts) => {
checkInitialised()
if (env.isTest()) {
dbList.add(dbName)
}
const db = new PouchDB(dbName, opts)
if (env.MEMORY_LEAK_CHECK) {
openDbs.push(db.name)
}
const dbPut = db.put
db.put = put(dbPut)
return db
}
// use this function if you have called dangerousGetDB - close
// the databases you've opened once finished
exports.closeDB = async db => {
if (!db || env.isTest()) {
return
}
if (env.MEMORY_LEAK_CHECK) {
openDbs.splice(openDbs.indexOf(db.name), 1)
}
try {
// specifically await so that if there is an error, it can be ignored
return await db.close()
} catch (err) {
// ignore error, already closed
}
}
// we have to use a callback for this so that we can close
// the DB when we're done, without this manual requests would
// need to close the database when done with it to avoid memory leaks
exports.doWithDB = async (dbName, cb, opts = {}) => {
const db = exports.dangerousGetDB(dbName, opts)
// need this to be async so that we can correctly close DB after all
// async operations have been completed
try {
return await cb(db)
} finally {
await exports.closeDB(db)
}
}
exports.allDbs = () => {
if (!env.isTest()) {
throw new Error("Cannot be used outside test environment.")
}
checkInitialised()
return [...dbList]
}

View File

@ -0,0 +1,133 @@
import * as pouch from "./pouch"
import env from "../environment"
import { checkSlashesInUrl } from "../helpers"
import fetch from "node-fetch"
import { PouchOptions, CouchFindOptions } from "@budibase/types"
import PouchDB from "pouchdb"
const openDbs: string[] = []
let Pouch: any
let initialised = false
const dbList = new Set()
if (env.MEMORY_LEAK_CHECK) {
setInterval(() => {
console.log("--- OPEN DBS ---")
console.log(openDbs)
}, 5000)
}
const put =
(dbPut: any) =>
async (doc: any, options = {}) => {
if (!doc.createdAt) {
doc.createdAt = new Date().toISOString()
}
doc.updatedAt = new Date().toISOString()
return dbPut(doc, options)
}
const checkInitialised = () => {
if (!initialised) {
throw new Error("init has not been called")
}
}
export async function init(opts?: PouchOptions) {
Pouch = pouch.getPouch(opts)
initialised = true
}
// NOTE: THIS IS A DANGEROUS FUNCTION - USE WITH CAUTION
// this function is prone to leaks, should only be used
// in situations that using the function doWithDB does not work
export function dangerousGetDB(dbName: string, opts?: any): PouchDB.Database {
checkInitialised()
if (env.isTest()) {
dbList.add(dbName)
}
const db = new Pouch(dbName, opts)
if (env.MEMORY_LEAK_CHECK) {
openDbs.push(db.name)
}
const dbPut = db.put
db.put = put(dbPut)
return db
}
// use this function if you have called dangerousGetDB - close
// the databases you've opened once finished
export async function closeDB(db: PouchDB.Database) {
if (!db || env.isTest()) {
return
}
if (env.MEMORY_LEAK_CHECK) {
openDbs.splice(openDbs.indexOf(db.name), 1)
}
try {
// specifically await so that if there is an error, it can be ignored
return await db.close()
} catch (err) {
// ignore error, already closed
}
}
// we have to use a callback for this so that we can close
// the DB when we're done, without this manual requests would
// need to close the database when done with it to avoid memory leaks
export async function doWithDB(dbName: string, cb: any, opts = {}) {
const db = dangerousGetDB(dbName, opts)
// need this to be async so that we can correctly close DB after all
// async operations have been completed
try {
return await cb(db)
} finally {
await closeDB(db)
}
}
export function allDbs() {
if (!env.isTest()) {
throw new Error("Cannot be used outside test environment.")
}
checkInitialised()
return [...dbList]
}
export async function directCouchQuery(
path: string,
method: string = "GET",
body?: any
) {
let { url, cookie } = pouch.getCouchInfo()
const couchUrl = `${url}/${path}`
const params: any = {
method: method,
headers: {
Authorization: cookie,
},
}
if (body && method !== "GET") {
params.body = JSON.stringify(body)
params.headers["Content-Type"] = "application/json"
}
const response = await fetch(checkSlashesInUrl(encodeURI(couchUrl)), params)
if (response.status < 300) {
return await response.json()
} else {
throw "Cannot connect to CouchDB instance"
}
}
export async function directCouchAllDbs(queryString?: string) {
let couchPath = "/_all_dbs"
if (queryString) {
couchPath += `?${queryString}`
}
return await directCouchQuery(couchPath)
}
export async function directCouchFind(dbName: string, opts: CouchFindOptions) {
const json = await directCouchQuery(`${dbName}/_find`, "POST", opts)
return { rows: json.docs, bookmark: json.bookmark }
}

View File

@ -1,7 +1,7 @@
const PouchDB = require("pouchdb") import PouchDB from "pouchdb"
const env = require("../environment") import env from "../environment"
exports.getUrlInfo = (url = env.COUCH_DB_URL) => { export const getUrlInfo = (url = env.COUCH_DB_URL) => {
let cleanUrl, username, password, host let cleanUrl, username, password, host
if (url) { if (url) {
// Ensure the URL starts with a protocol // Ensure the URL starts with a protocol
@ -44,8 +44,8 @@ exports.getUrlInfo = (url = env.COUCH_DB_URL) => {
} }
} }
exports.getCouchInfo = () => { export const getCouchInfo = () => {
const urlInfo = exports.getUrlInfo() const urlInfo = getUrlInfo()
let username let username
let password let password
if (env.COUCH_DB_USERNAME) { if (env.COUCH_DB_USERNAME) {
@ -82,11 +82,11 @@ exports.getCouchInfo = () => {
* This should be rarely used outside of the main application config. * This should be rarely used outside of the main application config.
* Exposed for exceptional cases such as in-memory views. * Exposed for exceptional cases such as in-memory views.
*/ */
exports.getPouch = (opts = {}) => { export const getPouch = (opts: any = {}) => {
let { url, cookie } = exports.getCouchInfo() let { url, cookie } = getCouchInfo()
let POUCH_DB_DEFAULTS = { let POUCH_DB_DEFAULTS = {
prefix: url, prefix: url,
fetch: (url, opts) => { fetch: (url: string, opts: any) => {
// use a specific authorization cookie - be very explicit about how we authenticate // use a specific authorization cookie - be very explicit about how we authenticate
opts.headers.set("Authorization", cookie) opts.headers.set("Authorization", cookie)
return PouchDB.fetch(url, opts) return PouchDB.fetch(url, opts)
@ -98,6 +98,7 @@ exports.getPouch = (opts = {}) => {
PouchDB.plugin(inMemory) PouchDB.plugin(inMemory)
POUCH_DB_DEFAULTS = { POUCH_DB_DEFAULTS = {
prefix: undefined, prefix: undefined,
// @ts-ignore
adapter: "memory", adapter: "memory",
} }
} }
@ -105,6 +106,7 @@ exports.getPouch = (opts = {}) => {
if (opts.onDisk) { if (opts.onDisk) {
POUCH_DB_DEFAULTS = { POUCH_DB_DEFAULTS = {
prefix: undefined, prefix: undefined,
// @ts-ignore
adapter: "leveldb", adapter: "leveldb",
} }
} }
@ -112,6 +114,7 @@ exports.getPouch = (opts = {}) => {
if (opts.replication) { if (opts.replication) {
const replicationStream = require("pouchdb-replication-stream") const replicationStream = require("pouchdb-replication-stream")
PouchDB.plugin(replicationStream.plugin) PouchDB.plugin(replicationStream.plugin)
// @ts-ignore
PouchDB.adapter("writableStream", replicationStream.adapters.writableStream) PouchDB.adapter("writableStream", replicationStream.adapters.writableStream)
} }

View File

@ -1,14 +1,17 @@
import { newid } from "../hashing" import { newid } from "../hashing"
import { DEFAULT_TENANT_ID, Configs } from "../constants" import { DEFAULT_TENANT_ID, Configs } from "../constants"
import env from "../environment" import env from "../environment"
import { SEPARATOR, DocumentType, UNICODE_MAX, ViewName } from "./constants" import {
SEPARATOR,
DocumentType,
UNICODE_MAX,
ViewName,
InternalTable,
} from "./constants"
import { getTenantId, getGlobalDB } from "../context" import { getTenantId, getGlobalDB } from "../context"
import { getGlobalDBName } from "./tenancy" import { getGlobalDBName } from "./tenancy"
import fetch from "node-fetch" import { doWithDB, allDbs, directCouchAllDbs } from "./index"
import { doWithDB, allDbs } from "./index"
import { getCouchInfo } from "./pouch"
import { getAppMetadata } from "../cache/appMetadata" import { getAppMetadata } from "../cache/appMetadata"
import { checkSlashesInUrl } from "../helpers"
import { isDevApp, isDevAppID, getProdAppID } from "./conversions" import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
import { APP_PREFIX } from "./constants" import { APP_PREFIX } from "./constants"
import * as events from "../events" import * as events from "../events"
@ -43,8 +46,8 @@ export const generateAppID = (tenantId = null) => {
* @returns {object} Parameters which can then be used with an allDocs request. * @returns {object} Parameters which can then be used with an allDocs request.
*/ */
export function getDocParams( export function getDocParams(
docType: any, docType: string,
docId: any = null, docId?: string | null,
otherProps: any = {} otherProps: any = {}
) { ) {
if (docId == null) { if (docId == null) {
@ -57,6 +60,28 @@ export function getDocParams(
} }
} }
/**
* Gets the DB allDocs/query params for retrieving a row.
* @param {string|null} tableId The table in which the rows have been stored.
* @param {string|null} rowId The ID of the row which is being specifically queried for. This can be
* left null to get all the rows in the table.
* @param {object} otherProps Any other properties to add to the request.
* @returns {object} Parameters which can then be used with an allDocs request.
*/
export function getRowParams(
tableId?: string | null,
rowId?: string | null,
otherProps = {}
) {
if (tableId == null) {
return getDocParams(DocumentType.ROW, null, otherProps)
}
const endOfKey = rowId == null ? `${tableId}${SEPARATOR}` : rowId
return getDocParams(DocumentType.ROW, endOfKey, otherProps)
}
/** /**
* Retrieve the correct index for a view based on default design DB. * Retrieve the correct index for a view based on default design DB.
*/ */
@ -64,6 +89,17 @@ export function getQueryIndex(viewName: ViewName) {
return `database/${viewName}` return `database/${viewName}`
} }
/**
* Gets a new row ID for the specified table.
* @param {string} tableId The table which the row is being created for.
* @param {string|null} id If an ID is to be used then the UUID can be substituted for this.
* @returns {string} The new ID which a row doc can be stored under.
*/
export function generateRowID(tableId: string, id?: string) {
id = id || newid()
return `${DocumentType.ROW}${SEPARATOR}${tableId}${SEPARATOR}${id}`
}
/** /**
* Check if a given ID is that of a table. * Check if a given ID is that of a table.
* @returns {boolean} * @returns {boolean}
@ -131,6 +167,33 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) {
} }
} }
/**
* Gets parameters for retrieving users, this is a utility function for the getDocParams function.
*/
export function getUserMetadataParams(userId?: string, otherProps = {}) {
return getRowParams(InternalTable.USER_METADATA, userId, otherProps)
}
/**
* Generates a new user ID based on the passed in global ID.
* @param {string} globalId The ID of the global user.
* @returns {string} The new user ID which the user doc can be stored under.
*/
export function generateUserMetadataID(globalId: string) {
return generateRowID(InternalTable.USER_METADATA, globalId)
}
/**
* Breaks up the ID to get the global ID.
*/
export function getGlobalIDFromUserMetadataID(id: string) {
const prefix = `${DocumentType.ROW}${SEPARATOR}${InternalTable.USER_METADATA}${SEPARATOR}`
if (!id || !id.includes(prefix)) {
return id
}
return id.split(prefix)[1]
}
export function getUsersByAppParams(appId: any, otherProps: any = {}) { export function getUsersByAppParams(appId: any, otherProps: any = {}) {
const prodAppId = getProdAppID(appId) const prodAppId = getProdAppID(appId)
return { return {
@ -191,9 +254,9 @@ export function getRoleParams(roleId = null, otherProps = {}) {
return getDocParams(DocumentType.ROLE, roleId, otherProps) return getDocParams(DocumentType.ROLE, roleId, otherProps)
} }
export function getStartEndKeyURL(base: any, baseKey: any, tenantId = null) { export function getStartEndKeyURL(baseKey: any, tenantId = null) {
const tenancy = tenantId ? `${SEPARATOR}${tenantId}` : "" const tenancy = tenantId ? `${SEPARATOR}${tenantId}` : ""
return `${base}?startkey="${baseKey}${tenancy}"&endkey="${baseKey}${tenancy}${UNICODE_MAX}"` return `startkey="${baseKey}${tenancy}"&endkey="${baseKey}${tenancy}${UNICODE_MAX}"`
} }
/** /**
@ -209,22 +272,10 @@ export async function getAllDbs(opts = { efficient: false }) {
return allDbs() return allDbs()
} }
let dbs: any[] = [] let dbs: any[] = []
let { url, cookie } = getCouchInfo() async function addDbs(queryString?: string) {
async function addDbs(couchUrl: string) { const json = await directCouchAllDbs(queryString)
const response = await fetch(checkSlashesInUrl(encodeURI(couchUrl)), { dbs = dbs.concat(json)
method: "GET",
headers: {
Authorization: cookie,
},
})
if (response.status === 200) {
let json = await response.json()
dbs = dbs.concat(json)
} else {
throw "Cannot connect to CouchDB instance"
}
} }
let couchUrl = `${url}/_all_dbs`
let tenantId = getTenantId() let tenantId = getTenantId()
if (!env.MULTI_TENANCY || (!efficient && tenantId === DEFAULT_TENANT_ID)) { if (!env.MULTI_TENANCY || (!efficient && tenantId === DEFAULT_TENANT_ID)) {
// just get all DBs when: // just get all DBs when:
@ -232,12 +283,12 @@ export async function getAllDbs(opts = { efficient: false }) {
// - default tenant // - default tenant
// - apps dbs don't contain tenant id // - apps dbs don't contain tenant id
// - non-default tenant dbs are filtered out application side in getAllApps // - non-default tenant dbs are filtered out application side in getAllApps
await addDbs(couchUrl) await addDbs()
} else { } else {
// get prod apps // get prod apps
await addDbs(getStartEndKeyURL(couchUrl, DocumentType.APP, tenantId)) await addDbs(getStartEndKeyURL(DocumentType.APP, tenantId))
// get dev apps // get dev apps
await addDbs(getStartEndKeyURL(couchUrl, DocumentType.APP_DEV, tenantId)) await addDbs(getStartEndKeyURL(DocumentType.APP_DEV, tenantId))
// add global db name // add global db name
dbs.push(getGlobalDBName(tenantId)) dbs.push(getGlobalDBName(tenantId))
} }

View File

@ -0,0 +1,12 @@
import { AppBackup, AppBackupRestoreEvent, Event } from "@budibase/types"
import { publishEvent } from "../events"
export async function appBackupRestored(backup: AppBackup) {
const properties: AppBackupRestoreEvent = {
appId: backup.appId,
backupName: backup.name!,
backupCreatedAt: backup.timestamp,
}
await publishEvent(Event.APP_BACKUP_RESTORED, properties)
}

View File

@ -19,3 +19,4 @@ export * as installation from "./installation"
export * as backfill from "./backfill" export * as backfill from "./backfill"
export * as group from "./group" export * as group from "./group"
export * as plugin from "./plugin" export * as plugin from "./plugin"
export * as backup from "./backup"

View File

@ -19,6 +19,7 @@ import pino from "./pino"
import * as middleware from "./middleware" import * as middleware from "./middleware"
import plugins from "./plugin" import plugins from "./plugin"
import encryption from "./security/encryption" import encryption from "./security/encryption"
import * as queue from "./queue"
// mimic the outer package exports // mimic the outer package exports
import * as db from "./pkg/db" import * as db from "./pkg/db"
@ -63,6 +64,7 @@ const core = {
...errorClasses, ...errorClasses,
middleware, middleware,
encryption, encryption,
queue,
} }
export = core export = core

View File

@ -18,11 +18,16 @@ const STATE = {
bucketCreationPromises: {}, bucketCreationPromises: {},
} }
type ListParams = {
ContinuationToken?: string
}
const CONTENT_TYPE_MAP: any = { const CONTENT_TYPE_MAP: any = {
html: "text/html", html: "text/html",
css: "text/css", css: "text/css",
js: "application/javascript", js: "application/javascript",
json: "application/json", json: "application/json",
gz: "application/gzip",
} }
const STRING_CONTENT_TYPES = [ const STRING_CONTENT_TYPES = [
CONTENT_TYPE_MAP.html, CONTENT_TYPE_MAP.html,
@ -32,16 +37,16 @@ const STRING_CONTENT_TYPES = [
] ]
// does normal sanitization and then swaps dev apps to apps // does normal sanitization and then swaps dev apps to apps
export function sanitizeKey(input: any) { export function sanitizeKey(input: string) {
return sanitize(sanitizeBucket(input)).replace(/\\/g, "/") return sanitize(sanitizeBucket(input)).replace(/\\/g, "/")
} }
// simply handles the dev app to app conversion // simply handles the dev app to app conversion
export function sanitizeBucket(input: any) { export function sanitizeBucket(input: string) {
return input.replace(new RegExp(APP_DEV_PREFIX, "g"), APP_PREFIX) return input.replace(new RegExp(APP_DEV_PREFIX, "g"), APP_PREFIX)
} }
function publicPolicy(bucketName: any) { function publicPolicy(bucketName: string) {
return { return {
Version: "2012-10-17", Version: "2012-10-17",
Statement: [ Statement: [
@ -69,7 +74,7 @@ const PUBLIC_BUCKETS = [
* @return {Object} an S3 object store object, check S3 Nodejs SDK for usage. * @return {Object} an S3 object store object, check S3 Nodejs SDK for usage.
* @constructor * @constructor
*/ */
export const ObjectStore = (bucket: any) => { export const ObjectStore = (bucket: string) => {
const config: any = { const config: any = {
s3ForcePathStyle: true, s3ForcePathStyle: true,
signatureVersion: "v4", signatureVersion: "v4",
@ -93,7 +98,7 @@ export const ObjectStore = (bucket: any) => {
* Given an object store and a bucket name this will make sure the bucket exists, * Given an object store and a bucket name this will make sure the bucket exists,
* if it does not exist then it will create it. * if it does not exist then it will create it.
*/ */
export const makeSureBucketExists = async (client: any, bucketName: any) => { export const makeSureBucketExists = async (client: any, bucketName: string) => {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
try { try {
await client await client
@ -145,7 +150,7 @@ export const upload = async ({
type, type,
metadata, metadata,
}: any) => { }: any) => {
const extension = [...filename.split(".")].pop() const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path) const fileBytes = fs.readFileSync(path)
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
@ -168,8 +173,8 @@ export const upload = async ({
* through to the object store. * through to the object store.
*/ */
export const streamUpload = async ( export const streamUpload = async (
bucketName: any, bucketName: string,
filename: any, filename: string,
stream: any, stream: any,
extra = {} extra = {}
) => { ) => {
@ -202,7 +207,7 @@ export const streamUpload = async (
* retrieves the contents of a file from the object store, if it is a known content type it * retrieves the contents of a file from the object store, if it is a known content type it
* will be converted, otherwise it will be returned as a buffer stream. * will be converted, otherwise it will be returned as a buffer stream.
*/ */
export const retrieve = async (bucketName: any, filepath: any) => { export const retrieve = async (bucketName: string, filepath: string) => {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: sanitizeBucket(bucketName),
@ -217,10 +222,38 @@ export const retrieve = async (bucketName: any, filepath: any) => {
} }
} }
export const listAllObjects = async (bucketName: string, path: string) => {
const objectStore = ObjectStore(bucketName)
const list = (params: ListParams = {}) => {
return objectStore
.listObjectsV2({
...params,
Bucket: sanitizeBucket(bucketName),
Prefix: sanitizeKey(path),
})
.promise()
}
let isTruncated = false,
token,
objects: AWS.S3.Types.Object[] = []
do {
let params: ListParams = {}
if (token) {
params.ContinuationToken = token
}
const response = await list(params)
if (response.Contents) {
objects = objects.concat(response.Contents)
}
isTruncated = !!response.IsTruncated
} while (isTruncated)
return objects
}
/** /**
* Same as retrieval function but puts to a temporary file. * Same as retrieval function but puts to a temporary file.
*/ */
export const retrieveToTmp = async (bucketName: any, filepath: any) => { export const retrieveToTmp = async (bucketName: string, filepath: string) => {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
filepath = sanitizeKey(filepath) filepath = sanitizeKey(filepath)
const data = await retrieve(bucketName, filepath) const data = await retrieve(bucketName, filepath)
@ -229,10 +262,31 @@ export const retrieveToTmp = async (bucketName: any, filepath: any) => {
return outputPath return outputPath
} }
export const retrieveDirectory = async (bucketName: string, path: string) => {
let writePath = join(budibaseTempDir(), v4())
fs.mkdirSync(writePath)
const objects = await listAllObjects(bucketName, path)
let fullObjects = await Promise.all(
objects.map(obj => retrieve(bucketName, obj.Key!))
)
let count = 0
for (let obj of objects) {
const filename = obj.Key!
const data = fullObjects[count++]
const possiblePath = filename.split("/")
if (possiblePath.length > 1) {
const dirs = possiblePath.slice(0, possiblePath.length - 1)
fs.mkdirSync(join(writePath, ...dirs), { recursive: true })
}
fs.writeFileSync(join(writePath, ...possiblePath), data)
}
return writePath
}
/** /**
* Delete a single file. * Delete a single file.
*/ */
export const deleteFile = async (bucketName: any, filepath: any) => { export const deleteFile = async (bucketName: string, filepath: string) => {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
const params = { const params = {
@ -242,7 +296,7 @@ export const deleteFile = async (bucketName: any, filepath: any) => {
return objectStore.deleteObject(params) return objectStore.deleteObject(params)
} }
export const deleteFiles = async (bucketName: any, filepaths: any) => { export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await makeSureBucketExists(objectStore, bucketName)
const params = { const params = {
@ -258,8 +312,8 @@ export const deleteFiles = async (bucketName: any, filepaths: any) => {
* Delete a path, including everything within. * Delete a path, including everything within.
*/ */
export const deleteFolder = async ( export const deleteFolder = async (
bucketName: any, bucketName: string,
folder: any folder: string
): Promise<any> => { ): Promise<any> => {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
folder = sanitizeKey(folder) folder = sanitizeKey(folder)
@ -292,9 +346,9 @@ export const deleteFolder = async (
} }
export const uploadDirectory = async ( export const uploadDirectory = async (
bucketName: any, bucketName: string,
localPath: any, localPath: string,
bucketPath: any bucketPath: string
) => { ) => {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
let uploads = [] let uploads = []
@ -326,7 +380,11 @@ exports.downloadTarballDirect = async (
await streamPipeline(response.body, zlib.Unzip(), tar.extract(path)) await streamPipeline(response.body, zlib.Unzip(), tar.extract(path))
} }
export const downloadTarball = async (url: any, bucketName: any, path: any) => { export const downloadTarball = async (
url: string,
bucketName: string,
path: string
) => {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
path = sanitizeKey(path) path = sanitizeKey(path)
const response = await fetch(url) const response = await fetch(url)

View File

@ -1,5 +1,6 @@
const { join } = require("path") const { join } = require("path")
const { tmpdir } = require("os") const { tmpdir } = require("os")
const fs = require("fs")
const env = require("../environment") const env = require("../environment")
/**************************************************** /****************************************************
@ -16,6 +17,11 @@ exports.ObjectStoreBuckets = {
PLUGINS: env.PLUGIN_BUCKET_NAME, PLUGINS: env.PLUGIN_BUCKET_NAME,
} }
exports.budibaseTempDir = function () { const bbTmp = join(tmpdir(), ".budibase")
return join(tmpdir(), ".budibase") if (!fs.existsSync(bbTmp)) {
fs.mkdirSync(bbTmp)
}
exports.budibaseTempDir = function () {
return bbTmp
} }

View File

@ -0,0 +1,4 @@
export enum JobQueue {
AUTOMATION = "automationQueue",
APP_BACKUP = "appBackupQueue",
}

View File

@ -0,0 +1,127 @@
import events from "events"
/**
* Bull works with a Job wrapper around all messages that contains a lot more information about
* the state of the message, this object constructor implements the same schema of Bull jobs
* for the sake of maintaining API consistency.
* @param {string} queue The name of the queue which the message will be carried on.
* @param {object} message The JSON message which will be passed back to the consumer.
* @returns {Object} A new job which can now be put onto the queue, this is mostly an
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
*/
function newJob(queue: string, message: any) {
return {
timestamp: Date.now(),
queue: queue,
data: message,
}
}
/**
* This is designed to replicate Bull (https://github.com/OptimalBits/bull) in memory as a sort of mock.
* It is relatively simple, using an event emitter internally to register when messages are available
* to the consumers - in can support many inputs and many consumers.
*/
class InMemoryQueue {
_name: string
_opts?: any
_messages: any[]
_emitter: EventEmitter
/**
* The constructor the queue, exactly the same as that of Bulls.
* @param {string} name The name of the queue which is being configured.
* @param {object|null} opts This is not used by the in memory queue as there is no real use
* case when in memory, but is the same API as Bull
*/
constructor(name: string, opts = null) {
this._name = name
this._opts = opts
this._messages = []
this._emitter = new events.EventEmitter()
}
/**
* Same callback API as Bull, each callback passed to this will consume messages as they are
* available. Please note this is a queue service, not a notification service, so each
* consumer will receive different messages.
* @param {function<object>} func The callback function which will return a "Job", the same
* as the Bull API, within this job the property "data" contains the JSON message. Please
* note this is incredibly limited compared to Bull as in reality the Job would contain
* a lot more information about the queue and current status of Bull cluster.
*/
process(func: any) {
this._emitter.on("message", async () => {
if (this._messages.length <= 0) {
return
}
let msg = this._messages.shift()
let resp = func(msg)
if (resp.then != null) {
await resp
}
})
}
// simply puts a message to the queue and emits to the queue for processing
/**
* Simple function to replicate the add message functionality of Bull, putting
* a new message on the queue. This then emits an event which will be used to
* return the message to a consumer (if one is attached).
* @param {object} msg A message to be transported over the queue, this should be
* a JSON message as this is required by Bull.
* @param {boolean} repeat serves no purpose for the import queue.
*/
// eslint-disable-next-line no-unused-vars
add(msg: any, repeat: boolean) {
if (typeof msg !== "object") {
throw "Queue only supports carrying JSON."
}
this._messages.push(newJob(this._name, msg))
this._emitter.emit("message")
}
/**
* replicating the close function from bull, which waits for jobs to finish.
*/
async close() {
return []
}
/**
* This removes a cron which has been implemented, this is part of Bull API.
* @param {string} cronJobId The cron which is to be removed.
*/
removeRepeatableByKey(cronJobId: string) {
// TODO: implement for testing
console.log(cronJobId)
}
/**
* Implemented for tests
*/
getRepeatableJobs() {
return []
}
// eslint-disable-next-line no-unused-vars
removeJobs(pattern: string) {
// no-op
}
/**
* Implemented for tests
*/
async clean() {
return []
}
async getJob() {
return {}
}
on() {
// do nothing
}
}
export = InMemoryQueue

View File

@ -0,0 +1,2 @@
export * from "./queue"
export * from "./constants"

View File

@ -0,0 +1,101 @@
import { Job, JobId, Queue } from "bull"
import { JobQueue } from "./constants"
export type StalledFn = (job: Job) => Promise<void>
export function addListeners(
queue: Queue,
jobQueue: JobQueue,
removeStalledCb?: StalledFn
) {
logging(queue, jobQueue)
if (removeStalledCb) {
handleStalled(queue, removeStalledCb)
}
}
function handleStalled(queue: Queue, removeStalledCb?: StalledFn) {
queue.on("stalled", async (job: Job) => {
if (removeStalledCb) {
await removeStalledCb(job)
} else if (job.opts.repeat) {
const jobId = job.id
const repeatJobs = await queue.getRepeatableJobs()
for (let repeatJob of repeatJobs) {
if (repeatJob.id === jobId) {
await queue.removeRepeatableByKey(repeatJob.key)
}
}
console.log(`jobId=${jobId} disabled`)
}
})
}
function logging(queue: Queue, jobQueue: JobQueue) {
let eventType: string
switch (jobQueue) {
case JobQueue.AUTOMATION:
eventType = "automation-event"
break
case JobQueue.APP_BACKUP:
eventType = "app-backup-event"
break
}
if (process.env.NODE_DEBUG?.includes("bull")) {
queue
.on("error", (error: any) => {
// An error occurred.
console.error(`${eventType}=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(`${eventType}=waiting jobId=${jobId}`)
})
.on("active", (job: Job, jobPromise: any) => {
// A job has started. You can use `jobPromise.cancel()`` to abort it.
console.log(`${eventType}=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(
`${eventType}=stalled jobId=${job.id} job=${JSON.stringify(job)}`
)
})
.on("progress", (job: Job, progress: any) => {
// A job's progress was updated!
console.log(
`${eventType}=progress jobId=${job.id} progress=${progress}`
)
})
.on("completed", (job: Job, result) => {
// A job successfully completed with a `result`.
console.log(`${eventType}=completed jobId=${job.id} result=${result}`)
})
.on("failed", (job, err: any) => {
// A job failed with reason `err`!
console.log(`${eventType}=failed jobId=${job.id} error=${err}`)
})
.on("paused", () => {
// The queue has been paused.
console.log(`${eventType}=paused`)
})
.on("resumed", (job: Job) => {
// The queue has been resumed.
console.log(`${eventType}=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(`${eventType}=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(`${eventType}=drained`)
})
.on("removed", (job: Job) => {
// A job successfully removed.
console.log(`${eventType}=removed jobId=${job.id}`)
})
}
}

View File

@ -0,0 +1,51 @@
import env from "../environment"
import { getRedisOptions } from "../redis/utils"
import { JobQueue } from "./constants"
import InMemoryQueue from "./inMemoryQueue"
import BullQueue from "bull"
import { addListeners, StalledFn } from "./listeners"
const { opts: redisOpts, redisProtocolUrl } = getRedisOptions()
const CLEANUP_PERIOD_MS = 60 * 1000
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
let cleanupInterval: NodeJS.Timeout
async function cleanup() {
for (let queue of QUEUES) {
await queue.clean(CLEANUP_PERIOD_MS, "completed")
}
}
export function createQueue<T>(
jobQueue: JobQueue,
opts: { removeStalledCb?: StalledFn } = {}
): BullQueue.Queue<T> {
const queueConfig: any = redisProtocolUrl || { redis: redisOpts }
let queue: any
if (!env.isTest()) {
queue = new BullQueue(jobQueue, queueConfig)
} else {
queue = new InMemoryQueue(jobQueue, queueConfig)
}
addListeners(queue, jobQueue, opts?.removeStalledCb)
QUEUES.push(queue)
if (!cleanupInterval) {
cleanupInterval = setInterval(cleanup, CLEANUP_PERIOD_MS)
// fire off an initial cleanup
cleanup().catch(err => {
console.error(`Unable to cleanup automation queue initially - ${err}`)
})
}
return queue
}
exports.shutdown = async () => {
if (QUEUES.length) {
clearInterval(cleanupInterval)
for (let queue of QUEUES) {
await queue.close()
}
QUEUES = []
}
console.log("Queues shutdown")
}

View File

@ -55,7 +55,12 @@ export const doWithLock = async (opts: LockOptions, task: any) => {
let lock let lock
try { try {
// aquire lock // aquire lock
let name: string = `${tenancy.getTenantId()}_${opts.name}` let name: string
if (opts.systemLock) {
name = opts.name
} else {
name = `${tenancy.getTenantId()}_${opts.name}`
}
if (opts.nameSuffix) { if (opts.nameSuffix) {
name = name + `_${opts.nameSuffix}` name = name + `_${opts.nameSuffix}`
} }

View File

@ -543,6 +543,36 @@
semver "^7.3.5" semver "^7.3.5"
tar "^6.1.11" tar "^6.1.11"
"@msgpackr-extract/msgpackr-extract-darwin-arm64@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-2.1.2.tgz#9571b87be3a3f2c46de05585470bc4f3af2f6f00"
integrity sha512-TyVLn3S/+ikMDsh0gbKv2YydKClN8HaJDDpONlaZR+LVJmsxLFUgA+O7zu59h9+f9gX1aj/ahw9wqa6rosmrYQ==
"@msgpackr-extract/msgpackr-extract-darwin-x64@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-2.1.2.tgz#bfbc6936ede2955218f5621a675679a5fe8e6f4c"
integrity sha512-YPXtcVkhmVNoMGlqp81ZHW4dMxK09msWgnxtsDpSiZwTzUBG2N+No2bsr7WMtBKCVJMSD6mbAl7YhKUqkp/Few==
"@msgpackr-extract/msgpackr-extract-linux-arm64@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-2.1.2.tgz#22555e28382af2922e7450634c8a2f240bb9eb82"
integrity sha512-vHZ2JiOWF2+DN9lzltGbhtQNzDo8fKFGrf37UJrgqxU0yvtERrzUugnfnX1wmVfFhSsF8OxrfqiNOUc5hko1Zg==
"@msgpackr-extract/msgpackr-extract-linux-arm@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-2.1.2.tgz#ffb6ae1beea7ac572b6be6bf2a8e8162ebdd8be7"
integrity sha512-42R4MAFeIeNn+L98qwxAt360bwzX2Kf0ZQkBBucJ2Ircza3asoY4CDbgiu9VWklq8gWJVSJSJBwDI+c/THiWkA==
"@msgpackr-extract/msgpackr-extract-linux-x64@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-2.1.2.tgz#7caf62eebbfb1345de40f75e89666b3d4194755f"
integrity sha512-RjRoRxg7Q3kPAdUSC5EUUPlwfMkIVhmaRTIe+cqHbKrGZ4M6TyCA/b5qMaukQ/1CHWrqYY2FbKOAU8Hg0pQFzg==
"@msgpackr-extract/msgpackr-extract-win32-x64@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-2.1.2.tgz#f2d8b9ddd8d191205ed26ce54aba3dfc5ae3e7c9"
integrity sha512-rIZVR48zA8hGkHIK7ED6+ZiXsjRCcAVBJbm8o89OKAMTmEAQ2QvoOxoiu3w2isAaWwzgtQIOFIqHwvZDyLKCvw==
"@shopify/jest-koa-mocks@5.0.1": "@shopify/jest-koa-mocks@5.0.1":
version "5.0.1" version "5.0.1"
resolved "https://registry.yarnpkg.com/@shopify/jest-koa-mocks/-/jest-koa-mocks-5.0.1.tgz#fba490b6b7985fbb571eb9974897d396a3642e94" resolved "https://registry.yarnpkg.com/@shopify/jest-koa-mocks/-/jest-koa-mocks-5.0.1.tgz#fba490b6b7985fbb571eb9974897d396a3642e94"
@ -733,6 +763,13 @@
resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1" resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w== integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
"@types/ioredis@4.28.0":
version "4.28.0"
resolved "https://registry.yarnpkg.com/@types/ioredis/-/ioredis-4.28.0.tgz#609b2ea0d91231df2dd7f67dd77436bc72584911"
integrity sha512-HSA/JQivJgV0e+353gvgu6WVoWvGRe0HyHOnAN2AvbVIhUlJBhNnnkP8gEEokrDWrxywrBkwo8NuDZ6TVPL9XA==
dependencies:
"@types/node" "*"
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
@ -1497,6 +1534,21 @@ buffer@^5.5.0, buffer@^5.6.0:
base64-js "^1.3.1" base64-js "^1.3.1"
ieee754 "^1.1.13" ieee754 "^1.1.13"
bull@4.10.1:
version "4.10.1"
resolved "https://registry.yarnpkg.com/bull/-/bull-4.10.1.tgz#f14974b6089358b62b495a2cbf838aadc098e43f"
integrity sha512-Fp21tRPb2EaZPVfmM+ONZKVz2RA+to+zGgaTLyCKt3JMSU8OOBqK8143OQrnGuGpsyE5G+9FevFAGhdZZfQP2g==
dependencies:
cron-parser "^4.2.1"
debuglog "^1.0.0"
get-port "^5.1.1"
ioredis "^4.28.5"
lodash "^4.17.21"
msgpackr "^1.5.2"
p-timeout "^3.2.0"
semver "^7.3.2"
uuid "^8.3.0"
cache-content-type@^1.0.0: cache-content-type@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c"
@ -1764,6 +1816,13 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cron-parser@^4.2.1:
version "4.6.0"
resolved "https://registry.yarnpkg.com/cron-parser/-/cron-parser-4.6.0.tgz#404c3fdbff10ae80eef6b709555d577ef2fd2e0d"
integrity sha512-guZNLMGUgg6z4+eGhmHGw7ft+v6OQeuHzd1gcLxCo9Yg/qoxmG3nindp2/uwGCLizEisf2H0ptqeVXeoCpP6FA==
dependencies:
luxon "^3.0.1"
cross-spawn@^7.0.3: cross-spawn@^7.0.3:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -1837,6 +1896,11 @@ debug@~3.1.0:
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debuglog@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
integrity sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==
decimal.js@^10.2.1: decimal.js@^10.2.1:
version "10.3.1" version "10.3.1"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"
@ -2318,6 +2382,11 @@ get-package-type@^0.1.0:
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
get-port@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
get-stream@^4.1.0: get-stream@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
@ -2652,6 +2721,23 @@ ioredis@4.28.0:
redis-parser "^3.0.0" redis-parser "^3.0.0"
standard-as-callback "^2.1.0" standard-as-callback "^2.1.0"
ioredis@^4.28.5:
version "4.28.5"
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.5.tgz#5c149e6a8d76a7f8fa8a504ffc85b7d5b6797f9f"
integrity sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A==
dependencies:
cluster-key-slot "^1.1.0"
debug "^4.3.1"
denque "^1.1.0"
lodash.defaults "^4.2.0"
lodash.flatten "^4.4.0"
lodash.isarguments "^3.1.0"
p-map "^2.1.0"
redis-commands "1.7.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.1.0"
is-arrayish@^0.2.1: is-arrayish@^0.2.1:
version "0.2.1" version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@ -3725,6 +3811,11 @@ ltgt@2.2.1, ltgt@^2.1.2, ltgt@~2.2.0:
resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5" resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5"
integrity sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA== integrity sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==
luxon@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.0.4.tgz#d179e4e9f05e092241e7044f64aaa54796b03929"
integrity sha512-aV48rGUwP/Vydn8HT+5cdr26YYQiUZ42NM6ToMoaGKwYfWbfLeRkEu1wXWMHBZT6+KyLfcbbtVcoQFCbbPjKlw==
make-dir@^3.0.0, make-dir@^3.1.0: make-dir@^3.0.0, make-dir@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@ -3872,6 +3963,27 @@ ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
msgpackr-extract@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-2.1.2.tgz#56272030f3e163e1b51964ef8b1cd5e7240c03ed"
integrity sha512-cmrmERQFb19NX2JABOGtrKdHMyI6RUyceaPBQ2iRz9GnDkjBWFjNJC0jyyoOfZl2U/LZE3tQCCQc4dlRyA8mcA==
dependencies:
node-gyp-build-optional-packages "5.0.3"
optionalDependencies:
"@msgpackr-extract/msgpackr-extract-darwin-arm64" "2.1.2"
"@msgpackr-extract/msgpackr-extract-darwin-x64" "2.1.2"
"@msgpackr-extract/msgpackr-extract-linux-arm" "2.1.2"
"@msgpackr-extract/msgpackr-extract-linux-arm64" "2.1.2"
"@msgpackr-extract/msgpackr-extract-linux-x64" "2.1.2"
"@msgpackr-extract/msgpackr-extract-win32-x64" "2.1.2"
msgpackr@^1.5.2:
version "1.7.2"
resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.7.2.tgz#68d6debf5999d6b61abb6e7046a689991ebf7261"
integrity sha512-mWScyHTtG6TjivXX9vfIy2nBtRupaiAj0HQ2mtmpmYujAmqZmaaEVPaSZ1NKLMvicaMLFzEaMk0ManxMRg8rMQ==
optionalDependencies:
msgpackr-extract "^2.1.2"
napi-macros@~2.0.0: napi-macros@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b"
@ -3919,6 +4031,11 @@ node-forge@^0.7.1:
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==
node-gyp-build-optional-packages@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17"
integrity sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==
node-gyp-build@~4.1.0: node-gyp-build@~4.1.0:
version "4.1.1" version "4.1.1"
resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.1.1.tgz#d7270b5d86717068d114cc57fff352f96d745feb" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.1.1.tgz#d7270b5d86717068d114cc57fff352f96d745feb"
@ -4075,6 +4192,11 @@ p-cancelable@^1.0.0:
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
p-finally@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
p-limit@^2.2.0: p-limit@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
@ -4094,6 +4216,13 @@ p-map@^2.1.0:
resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175"
integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==
p-timeout@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
dependencies:
p-finally "^1.0.0"
p-try@^2.0.0: p-try@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
@ -5360,7 +5489,7 @@ uuid@8.1.0:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d"
integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg==
uuid@8.3.2, uuid@^8.3.2: uuid@8.3.2, uuid@^8.3.0, uuid@^8.3.2:
version "8.3.2" version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==

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": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"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": "2.0.30-alpha.13", "@budibase/string-templates": "2.0.34-alpha.3",
"@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

@ -17,7 +17,7 @@
export let timeOnly = false export let timeOnly = false
export let ignoreTimezones = false export let ignoreTimezones = false
export let time24hr = false export let time24hr = false
export let range = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper` const flatpickrId = `${uuid()}-wrapper`
let open = false let open = false
@ -41,6 +41,7 @@
time_24hr: time24hr || false, time_24hr: time24hr || false,
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
wrap: true, wrap: true,
mode: range ? "range" : null,
appendTo, appendTo,
disableMobile: "true", disableMobile: "true",
onReady: () => { onReady: () => {
@ -64,7 +65,6 @@
if (newValue) { if (newValue) {
newValue = newValue.toISOString() newValue = newValue.toISOString()
} }
// 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. // Classic flackpickr causing issues.
@ -95,7 +95,11 @@
.slice(0, -1) .slice(0, -1)
} }
dispatch("change", newValue) if (range) {
dispatch("change", event.detail)
} else {
dispatch("change", newValue)
}
} }
const clearDateOnBackspace = event => { const clearDateOnBackspace = event => {
@ -160,7 +164,7 @@
{#key redrawOptions} {#key redrawOptions}
<Flatpickr <Flatpickr
bind:flatpickr bind:flatpickr
value={parseDate(value)} value={range ? value : parseDate(value)}
on:open={onOpen} on:open={onOpen}
on:close={onClose} on:close={onClose}
options={flatpickrOptions} options={flatpickrOptions}

View File

@ -14,11 +14,17 @@
export let placeholder = null export let placeholder = null
export let appendTo = undefined export let appendTo = undefined
export let ignoreTimezones = false export let ignoreTimezones = false
export let range = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail if (range) {
// Flatpickr cant take two dates and work out what to display, needs to be provided a string.
// Like - "Date1 to Date2". Hence passing in that specifically from the array
value = e?.detail[1]
} else {
value = e.detail
}
dispatch("change", e.detail) dispatch("change", e.detail)
} }
</script> </script>
@ -34,6 +40,7 @@
{time24hr} {time24hr}
{appendTo} {appendTo}
{ignoreTimezones} {ignoreTimezones}
{range}
on:change={onChange} on:change={onChange}
/> />
</Field> </Field>

View File

@ -56,6 +56,7 @@
{schema} {schema}
value={cellValue} value={cellValue}
on:clickrelationship on:clickrelationship
on:buttonclick
> >
<slot /> <slot />
</svelte:component> </svelte:component>

View File

@ -387,6 +387,7 @@
schema={schema[field]} schema={schema[field]}
value={deepGet(row, field)} value={deepGet(row, field)}
on:clickrelationship on:clickrelationship
on:buttonclick
> >
<slot /> <slot />
</CellRenderer> </CellRenderer>

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -71,10 +71,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.0.30-alpha.13", "@budibase/bbui": "2.0.34-alpha.3",
"@budibase/client": "2.0.30-alpha.13", "@budibase/client": "2.0.34-alpha.3",
"@budibase/frontend-core": "2.0.30-alpha.13", "@budibase/frontend-core": "2.0.34-alpha.3",
"@budibase/string-templates": "2.0.30-alpha.13", "@budibase/string-templates": "2.0.34-alpha.3",
"@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

@ -182,7 +182,70 @@ export const getFrontendStore = () => {
return state return state
}) })
}, },
validate: screen => {
// Recursive function to find any illegal children in component trees
const findIllegalChild = (
component,
illegalChildren = [],
legalDirectChildren = []
) => {
const type = component._component
if (illegalChildren.includes(type)) {
return type
}
if (
legalDirectChildren.length &&
!legalDirectChildren.includes(type)
) {
return type
}
if (!component?._children?.length) {
return
}
const definition = store.actions.components.getDefinition(
component._component
)
// Reset whitelist for direct children
legalDirectChildren = []
if (definition?.legalDirectChildren?.length) {
legalDirectChildren = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}`
})
}
// Append blacklisted components and remove duplicates
if (definition?.illegalChildren?.length) {
const blacklist = definition.illegalChildren.map(x => {
return `@budibase/standard-components/${x}`
})
illegalChildren = [...new Set([...illegalChildren, ...blacklist])]
}
// Recurse on all children
for (let child of component._children) {
const illegalChild = findIllegalChild(
child,
illegalChildren,
legalDirectChildren
)
if (illegalChild) {
return illegalChild
}
}
}
// Validate the entire tree and throw an error if an illegal child is
// found anywhere
const illegalChild = findIllegalChild(screen.props)
if (illegalChild) {
const def = store.actions.components.getDefinition(illegalChild)
throw `You can't place a ${def.name} here`
}
},
save: async screen => { save: async screen => {
store.actions.screens.validate(screen)
const state = get(store) const state = get(store)
const creatingNewScreen = screen._id === undefined const creatingNewScreen = screen._id === undefined
const savedScreen = await API.saveScreen(screen) const savedScreen = await API.saveScreen(screen)
@ -445,7 +508,11 @@ export const getFrontendStore = () => {
return { return {
_id: Helpers.uuid(), _id: Helpers.uuid(),
_component: definition.component, _component: definition.component,
_styles: { normal: {}, hover: {}, active: {} }, _styles: {
normal: {},
hover: {},
active: {},
},
_instanceName: `New ${definition.friendlyName || definition.name}`, _instanceName: `New ${definition.friendlyName || definition.name}`,
...cloneDeep(props), ...cloneDeep(props),
...extras, ...extras,
@ -533,12 +600,11 @@ export const getFrontendStore = () => {
}, },
patch: async (patchFn, componentId, screenId) => { patch: async (patchFn, componentId, screenId) => {
// Use selected component by default // Use selected component by default
if (!componentId && !screenId) { if (!componentId || !screenId) {
const state = get(store) const state = get(store)
componentId = state.selectedComponentId componentId = componentId || state.selectedComponentId
screenId = state.selectedScreenId screenId = screenId || state.selectedScreenId
} }
// Invalid if only a screen or component ID provided
if (!componentId || !screenId || !patchFn) { if (!componentId || !screenId || !patchFn) {
return return
} }
@ -601,16 +667,14 @@ export const getFrontendStore = () => {
}) })
// Select the parent if cutting // Select the parent if cutting
if (cut) { if (cut && selectParent) {
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen?.props, component._id) const parent = findComponentParent(screen?.props, component._id)
if (parent) { if (parent) {
if (selectParent) { store.update(state => {
store.update(state => { state.selectedComponentId = parent._id
state.selectedComponentId = parent._id return state
return state })
})
}
} }
} }
}, },
@ -621,16 +685,24 @@ export const getFrontendStore = () => {
} }
let newComponentId let newComponentId
// Remove copied component if cutting, regardless if pasting works
let componentToPaste = cloneDeep(state.componentToPaste)
if (componentToPaste.isCut) {
store.update(state => {
delete state.componentToPaste
return state
})
}
// Patch screen // Patch screen
const patch = screen => { const patch = screen => {
// Get up to date ref to target // Get up to date ref to target
targetComponent = findComponent(screen.props, targetComponent._id) targetComponent = findComponent(screen.props, targetComponent._id)
if (!targetComponent) { if (!targetComponent) {
return return false
} }
const cut = state.componentToPaste.isCut const cut = componentToPaste.isCut
const originalId = state.componentToPaste._id const originalId = componentToPaste._id
let componentToPaste = cloneDeep(state.componentToPaste)
delete componentToPaste.isCut delete componentToPaste.isCut
// Make new component unique if copying // Make new component unique if copying
@ -685,11 +757,8 @@ export const getFrontendStore = () => {
const targetScreenId = targetScreen?._id || state.selectedScreenId const targetScreenId = targetScreen?._id || state.selectedScreenId
await store.actions.screens.patch(patch, targetScreenId) await store.actions.screens.patch(patch, targetScreenId)
// Select the new component
store.update(state => { store.update(state => {
// Remove copied component if cutting
if (state.componentToPaste.isCut) {
delete state.componentToPaste
}
state.selectedScreenId = targetScreenId state.selectedScreenId = targetScreenId
state.selectedComponentId = newComponentId state.selectedComponentId = newComponentId
return state return state
@ -893,6 +962,15 @@ export const getFrontendStore = () => {
} }
}) })
}, },
updateStyles: async (styles, id) => {
const patchFn = component => {
component._styles.normal = {
...component._styles.normal,
...styles,
}
}
await store.actions.components.patch(patchFn, id)
},
updateCustomStyle: async style => { updateCustomStyle: async style => {
await store.actions.components.patch(component => { await store.actions.components.patch(component => {
component._styles.custom = style component._styles.custom = style

View File

@ -8,6 +8,7 @@
import CreateEditRow from "./modals/CreateEditRow.svelte" import CreateEditRow from "./modals/CreateEditRow.svelte"
import CreateEditUser from "./modals/CreateEditUser.svelte" import CreateEditUser from "./modals/CreateEditUser.svelte"
import CreateEditColumn from "./modals/CreateEditColumn.svelte" import CreateEditColumn from "./modals/CreateEditColumn.svelte"
import { cloneDeep } from "lodash/fp"
import { import {
TableNames, TableNames,
UNEDITABLE_USER_FIELDS, UNEDITABLE_USER_FIELDS,
@ -110,7 +111,7 @@
} }
const editColumn = field => { const editColumn = field => {
editableColumn = schema?.[field] editableColumn = cloneDeep(schema?.[field])
if (editableColumn) { if (editableColumn) {
editColumnModal.show() editColumnModal.show()
} }

View File

@ -488,7 +488,7 @@
]} ]}
getOptionLabel={option => option.label} getOptionLabel={option => option.label}
getOptionValue={option => option.value} getOptionValue={option => option.value}
tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered, tooltip="Dynamic formula are calculated when retrieved, but cannot be filtered or sorted by,
while static formula are calculated when the row is saved." while static formula are calculated when the row is saved."
/> />
{/if} {/if}

View File

@ -10,10 +10,14 @@
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { IntegrationTypes } from "constants/backend" import { IntegrationTypes } from "constants/backend"
import { createValidationStore } from "helpers/validation/yup"
import { createEventDispatcher } from "svelte"
export let datasource export let datasource
export let schema export let schema
export let creating export let creating
const validation = createValidationStore()
const dispatch = createEventDispatcher()
function filter([key, value]) { function filter([key, value]) {
if (!value) { if (!value) {
@ -31,6 +35,17 @@
.filter(el => filter(el)) .filter(el => filter(el))
.map(([key]) => key) .map(([key]) => key)
// setup the validation for each required field
$: configKeys.forEach(key => {
if (schema[key].required) {
validation.addValidatorType(key, schema[key].type, schema[key].required)
}
})
// run the validation whenever the config changes
$: validation.check(config)
// dispatch the validation result
$: dispatch("valid", $validation.valid)
let addButton let addButton
function getDisplayName(key) { function getDisplayName(key) {
@ -79,6 +94,7 @@
type={schema[configKey].type} type={schema[configKey].type}
on:change on:change
bind:value={config[configKey]} bind:value={config[configKey]}
error={$validation.errors[configKey]}
/> />
</div> </div>
{:else} {:else}
@ -88,6 +104,7 @@
type={schema[configKey].type} type={schema[configKey].type}
on:change on:change
bind:value={config[configKey]} bind:value={config[configKey]}
error={$validation.errors[configKey]}
/> />
</div> </div>
{/if} {/if}

View File

@ -13,6 +13,7 @@
// kill the reference so the input isn't saved // kill the reference so the input isn't saved
let datasource = cloneDeep(integration) let datasource = cloneDeep(integration)
let skipFetch = false let skipFetch = false
let isValid = false
$: name = $: name =
IntegrationNames[datasource.type] || datasource.name || datasource.type IntegrationNames[datasource.type] || datasource.name || datasource.type
@ -53,6 +54,7 @@
return true return true
}} }}
size="L" size="L"
disabled={!isValid}
> >
<Layout noPadding> <Layout noPadding>
<Body size="XS" <Body size="XS"
@ -63,5 +65,6 @@
schema={datasource.schema} schema={datasource.schema}
bind:datasource bind:datasource
creating={true} creating={true}
on:valid={e => (isValid = e.detail)}
/> />
</ModalContent> </ModalContent>

View File

@ -7,6 +7,7 @@ import TableSelect from "./controls/TableSelect.svelte"
import ColorPicker from "./controls/ColorPicker.svelte" import ColorPicker from "./controls/ColorPicker.svelte"
import { IconSelect } from "./controls/IconSelect" import { IconSelect } from "./controls/IconSelect"
import FieldSelect from "./controls/FieldSelect.svelte" import FieldSelect from "./controls/FieldSelect.svelte"
import SortableFieldSelect from "./controls/SortableFieldSelect.svelte"
import MultiFieldSelect from "./controls/MultiFieldSelect.svelte" import MultiFieldSelect from "./controls/MultiFieldSelect.svelte"
import SearchFieldSelect from "./controls/SearchFieldSelect.svelte" import SearchFieldSelect from "./controls/SearchFieldSelect.svelte"
import SchemaSelect from "./controls/SchemaSelect.svelte" import SchemaSelect from "./controls/SchemaSelect.svelte"
@ -41,6 +42,7 @@ const componentMap = {
filter: FilterEditor, filter: FilterEditor,
url: URLSelect, url: URLSelect,
columns: ColumnEditor, columns: ColumnEditor,
"field/sortable": SortableFieldSelect,
"field/string": FormFieldSelect, "field/string": FormFieldSelect,
"field/number": FormFieldSelect, "field/number": FormFieldSelect,
"field/options": FormFieldSelect, "field/options": FormFieldSelect,

View File

@ -0,0 +1,47 @@
<script>
import { Select } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { createEventDispatcher } from "svelte"
import { UNSORTABLE_TYPES } from "constants"
export let componentInstance = {}
export let value = ""
export let placeholder
const dispatch = createEventDispatcher()
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, datasource).schema
$: options = getSortableFields(schema)
$: boundValue = getValidValue(value, options)
const getSortableFields = schema => {
return Object.entries(schema || {})
.filter(entry => !UNSORTABLE_TYPES.includes(entry[1].type))
.map(entry => entry[0])
}
const getValidValue = (value, options) => {
// Reset value if there aren't any options
if (!Array.isArray(options)) {
return null
}
// Reset value if the value isn't found in the options
if (options.indexOf(value) === -1) {
return null
}
return value
}
const onChange = value => {
boundValue = getValidValue(value.detail, options)
dispatch("change", boundValue)
}
</script>
<Select {placeholder} value={boundValue} on:change={onChange} {options} />

View File

@ -0,0 +1,114 @@
<script>
import {
ActionMenu,
MenuItem,
Icon,
Input,
Heading,
Body,
Modal,
} from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import CreateRestoreModal from "./CreateRestoreModal.svelte"
import { createEventDispatcher } from "svelte"
export let row
let deleteDialog
let restoreDialog
let updateDialog
let name
let restoreBackupModal
const dispatch = createEventDispatcher()
const onClickRestore = name => {
dispatch("buttonclick", {
type: "backupRestore",
name,
backupId: row._id,
restoreBackupName: name,
})
}
const onClickDelete = () => {
dispatch("buttonclick", {
type: "backupDelete",
backupId: row._id,
})
}
const onClickUpdate = () => {
dispatch("buttonclick", {
type: "backupUpdate",
backupId: row._id,
name,
})
}
async function downloadExport() {
window.open(`/api/apps/${row.appId}/backups/${row._id}/file`, "_blank")
}
</script>
<div class="cell">
<ActionMenu align="right">
<div slot="control">
<Icon size="M" hoverable name="MoreSmallList" />
</div>
{#if row.type !== "restore"}
<MenuItem on:click={restoreDialog.show} icon="Revert">Restore</MenuItem>
<MenuItem on:click={deleteDialog.show} icon="Delete">Delete</MenuItem>
<MenuItem on:click={downloadExport} icon="Download">Download</MenuItem>
{/if}
<MenuItem on:click={updateDialog.show} icon="Edit">Update</MenuItem>
</ActionMenu>
</div>
<Modal bind:this={restoreBackupModal}>
<CreateRestoreModal confirm={name => onClickRestore(name)} />
</Modal>
<ConfirmDialog
bind:this={deleteDialog}
okText="Delete Backup"
onOk={onClickDelete}
title="Confirm Deletion"
>
Are you sure you wish to delete the backup
<i>{row.name}?</i>
This action cannot be undone.
</ConfirmDialog>
<ConfirmDialog
bind:this={restoreDialog}
okText="Continue"
onOk={restoreBackupModal?.show}
title="Confirm restore"
warning={false}
>
<Heading size="S">{row.name || "Backup"}</Heading>
<Body size="S">{new Date(row.timestamp).toLocaleString()}</Body>
</ConfirmDialog>
<ConfirmDialog
bind:this={updateDialog}
disabled={!name}
okText="Confirm"
onOk={onClickUpdate}
title="Update Backup"
warning={false}
>
<Input onlabel="Backup name" placeholder={row.name} bind:value={name} />
</ConfirmDialog>
<style>
.cell {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
align-items: center;
margin-left: auto;
}
</style>

View File

@ -0,0 +1,41 @@
<script>
import { Icon } from "@budibase/bbui"
export let row
$: automations = row?.automations
$: datasources = row?.datasources
$: screens = row?.screens
</script>
<div class="cell">
{#if automations != null && screens != null && datasources != null}
<div class="item">
<Icon name="Data" />
<div>{datasources || 0}</div>
</div>
<div class="item">
<Icon name="WebPage" />
<div>{screens || 0}</div>
</div>
<div class="item">
<Icon name="JourneyVoyager" />
<div>{automations || 0}</div>
</div>
{/if}
</div>
<style>
.cell {
display: flex;
flex-direction: row;
gap: calc(var(--spacing-xl) * 2);
align-items: center;
}
.item {
display: flex;
gap: var(--spacing-s);
flex-direction: row;
}
</style>

View File

@ -0,0 +1,345 @@
<script>
import {
ActionButton,
Button,
DatePicker,
Divider,
Layout,
Modal,
notifications,
Pagination,
Select,
Heading,
Body,
Tags,
Tag,
Table,
Page,
} from "@budibase/bbui"
import { backups, licensing, auth, admin } from "stores/portal"
import { createPaginationStore } from "helpers/pagination"
import AppSizeRenderer from "./AppSizeRenderer.svelte"
import CreateBackupModal from "./CreateBackupModal.svelte"
import ActionsRenderer from "./ActionsRenderer.svelte"
import DateRenderer from "./DateRenderer.svelte"
import UserRenderer from "./UserRenderer.svelte"
import StatusRenderer from "./StatusRenderer.svelte"
import TypeRenderer from "./TypeRenderer.svelte"
import BackupsDefault from "assets/backups-default.png"
import { onMount } from "svelte"
export let app
let backupData = null
let modal
let pageInfo = createPaginationStore()
let filterOpt = null
let startDate = null
let endDate = null
let filters = getFilters()
let loaded = false
$: page = $pageInfo.page
$: fetchBackups(filterOpt, page, startDate, endDate)
function getFilters() {
const options = []
let types = ["backup"]
let triggers = ["manual", "publish", "scheduled", "restoring"]
for (let type of types) {
for (let trigger of triggers) {
let label = `${trigger} ${type}`
label = label.charAt(0).toUpperCase() + label?.slice(1)
options.push({ label, value: { type, trigger } })
}
}
options.push({
label: `Manual restore`,
value: { type: "restore", trigger: "manual" },
})
return options
}
const schema = {
type: {
displayName: "Type",
width: "auto",
},
createdAt: {
displayName: "Date",
width: "auto",
},
name: {
displayName: "Name",
width: "auto",
},
appSize: {
displayName: "App size",
width: "auto",
},
createdBy: {
displayName: "User",
width: "auto",
},
status: {
displayName: "Status",
width: "auto",
},
actions: {
displayName: null,
width: "5%",
},
}
const customRenderers = [
{ column: "appSize", component: AppSizeRenderer },
{ column: "actions", component: ActionsRenderer },
{ column: "createdAt", component: DateRenderer },
{ column: "createdBy", component: UserRenderer },
{ column: "status", component: StatusRenderer },
{ column: "type", component: TypeRenderer },
]
function flattenBackups(backups) {
return backups.map(backup => {
return {
...backup,
...backup?.contents,
}
})
}
async function fetchBackups(filters, page, startDate, endDate) {
const response = await backups.searchBackups({
appId: app.instance._id,
...filters,
page,
startDate,
endDate,
})
pageInfo.fetched(response.hasNextPage, response.nextPage)
// flatten so we have an easier structure to use for the table schema
backupData = flattenBackups(response.data)
}
async function createManualBackup(name) {
try {
let response = await backups.createManualBackup({
appId: app.instance._id,
name,
})
await fetchBackups(filterOpt, page)
notifications.success(response.message)
} catch {
notifications.error("Unable to create backup")
}
}
async function handleButtonClick({ detail }) {
if (detail.type === "backupDelete") {
await backups.deleteBackup({
appId: app.instance._id,
backupId: detail.backupId,
})
await fetchBackups(filterOpt, page)
} else if (detail.type === "backupRestore") {
await backups.restoreBackup({
appId: app.instance._id,
backupId: detail.backupId,
name: detail.restoreBackupName,
})
await fetchBackups(filterOpt, page)
} else if (detail.type === "backupUpdate") {
await backups.updateBackup({
appId: app.instance._id,
backupId: detail.backupId,
name: detail.name,
})
await fetchBackups(filterOpt, page)
}
}
onMount(() => {
fetchBackups(filterOpt, page, startDate, endDate)
loaded = true
})
</script>
<div class="root">
{#if !$licensing.backupsEnabled}
<Page wide={false}>
<Layout gap="XS" noPadding>
<div class="title">
<Heading size="M">Backups</Heading>
<Tags>
<Tag icon="LockClosed">Pro plan</Tag>
</Tags>
</div>
<div>
<Body>
Back up your apps and restore them to their previous state.
{#if !$auth.accountPortalAccess && !$licensing.groupsEnabled && $admin.cloud}
Contact your account holder to upgrade your plan.
{/if}
</Body>
</div>
<Divider />
<div class="pro-buttons">
{#if $auth.accountPortalAccess}
<Button
newStyles
primary
disabled={!$auth.accountPortalAccess && $admin.cloud}
on:click={$licensing.goToUpgradePage()}
>
Upgrade
</Button>
{/if}
<!--Show the view plans button-->
<Button
newStyles
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}
>
View plans
</Button>
</div>
</Layout>
</Page>
{:else if backupData?.length === 0 && !loaded && !filterOpt && !startDate}
<Page wide={false}>
<div class="align">
<img
width="220px"
height="130px"
src={BackupsDefault}
alt="BackupsDefault"
/>
<Layout gap="S">
<Heading>You have no backups yet</Heading>
<div class="opacity">
<Body size="S">You can manually backup your app any time</Body>
</div>
<div class="padding">
<Button on:click={modal.show} cta>Create Backup</Button>
</div>
</Layout>
</div>
</Page>
{:else if loaded}
<Layout noPadding gap="M" alignContent="start">
<div class="search">
<div class="select">
<Select
placeholder="All"
label="Type"
options={filters}
getOptionValue={filter => filter.value}
getOptionLabel={filter => filter.label}
bind:value={filterOpt}
/>
</div>
<div>
<DatePicker
range={true}
label={"Filter Range"}
on:change={e => {
if (e.detail[0].length > 1) {
startDate = e.detail[0][0].toISOString()
endDate = e.detail[0][1].toISOString()
}
}}
/>
</div>
<div class="split-buttons">
<ActionButton on:click={modal.show} icon="SaveAsFloppy"
>Create new backup</ActionButton
>
</div>
</div>
<div>
<Table
{schema}
disableSorting
allowSelectRows={false}
allowEditColumns={false}
allowEditRows={false}
data={backupData}
{customRenderers}
placeholderText="No backups found"
border={false}
on:buttonclick={handleButtonClick}
/>
<div class="pagination">
<Pagination
page={$pageInfo.pageNumber}
hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage}
hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage}
goToPrevPage={pageInfo.prevPage}
goToNextPage={pageInfo.nextPage}
/>
</div>
</div>
</Layout>
{/if}
</div>
<Modal bind:this={modal}>
<CreateBackupModal {createManualBackup} />
</Modal>
<style>
.root {
display: grid;
grid-template-columns: 1fr;
height: 100%;
padding: var(--spectrum-alias-grid-gutter-medium)
var(--spectrum-alias-grid-gutter-large);
}
.search {
display: flex;
gap: var(--spacing-xl);
width: 100%;
align-items: flex-end;
}
.select {
flex-basis: 150px;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: var(--spacing-xl);
}
.split-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1;
gap: var(--spacing-xl);
}
.title {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-m);
}
.align {
margin-top: 5%;
text-align: center;
}
.pro-buttons {
display: flex;
gap: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,22 @@
<script>
import { ModalContent, Input } from "@budibase/bbui"
import { auth } from "stores/portal"
export let createManualBackup
let templateName = $auth.user.firstName
? `${$auth.user.firstName}'s Backup`
: "New Backup"
let name = templateName
</script>
<ModalContent
onConfirm={() => createManualBackup(name)}
title="Create new backup"
diabled={!name}
confirmText="Create"
><Input label="Backup name" bind:value={name} /></ModalContent
>
<style>
</style>

View File

@ -0,0 +1,27 @@
<script>
import { ModalContent, Input, Body } from "@budibase/bbui"
import { auth } from "stores/portal"
export let confirm
let templateName = $auth.user.firstName
? `${$auth.user.firstName}'s Backup`
: "Restore Backup"
let name = templateName
</script>
<ModalContent
onConfirm={() => confirm(name)}
title="Back up your current version"
confirmText="Confirm Restore"
disabled={!name}
>
<Body size="S"
>Create a backup of your current app to allow you to roll back after
restoring this backup</Body
>
<Input label="Backup name" bind:value={name} />
</ModalContent>
<style>
</style>

View File

@ -0,0 +1,21 @@
<script>
import { Icon } from "@budibase/bbui"
export let value
</script>
<div class="cell">
{#if value != null}
<Icon name="Data" />
<div>{value || 0}</div>
{/if}
</div>
<style>
.cell {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
align-items: center;
}
</style>

View File

@ -0,0 +1,22 @@
<script>
import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
dayjs.extend(relativeTime)
export let value
$: timeSince = dayjs(value).fromNow()
</script>
<div class="cell">
{timeSince} - <DateTimeRenderer {value} />
</div>
<style>
.cell {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
align-items: center;
}
</style>

View File

@ -0,0 +1,15 @@
<script>
import { Badge } from "@budibase/bbui"
export let value = "started"
$: status = value[0].toUpperCase() + value?.slice(1)
</script>
<Badge
grey={value === "started" || value === "pending"}
green={value === "complete"}
red={value === "failed"}
size="S"
>
{status}
</Badge>

View File

@ -0,0 +1,20 @@
<script>
export let row
$: baseTrig = row?.trigger || "manual"
$: type = row?.type || "backup"
$: trigger = baseTrig.charAt(0).toUpperCase() + baseTrig.slice(1)
</script>
<div class="cell">
{trigger}
{type}
</div>
<style>
.cell {
display: flex;
flex-direction: row;
align-items: center;
}
</style>

View File

@ -0,0 +1,24 @@
<script>
export let value
let firstName = value?.firstName
let lastName = value?.lastName || ""
$: username =
firstName && lastName ? `${firstName} ${lastName}` : value?.email
</script>
<div class="cell">
{#if value != null}
<div>{username}</div>
{/if}
</div>
<style>
.cell {
display: flex;
flex-direction: row;
gap: var(--spacing-m);
align-items: center;
}
</style>

View File

@ -17,7 +17,12 @@ export const convertCamel = str => {
export const pipe = (arg, funcs) => flow(funcs)(arg) export const pipe = (arg, funcs) => flow(funcs)(arg)
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1) export const capitalise = s => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1) export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)

View File

@ -1,5 +1,5 @@
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { object } from "yup" import { object, string, number } from "yup"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -20,6 +20,32 @@ export const createValidationStore = () => {
validator[propertyName] = propertyValidator validator[propertyName] = propertyValidator
} }
const addValidatorType = (propertyName, type, required) => {
if (!type || !propertyName) {
return
}
let propertyValidator
switch (type) {
case "number":
propertyValidator = number().transform(value =>
isNaN(value) ? undefined : value
)
break
case "email":
propertyValidator = string().email()
break
default:
propertyValidator = string()
}
if (required) {
propertyValidator = propertyValidator.required()
}
validator[propertyName] = propertyValidator
}
const check = async values => { const check = async values => {
const obj = object().shape(validator) const obj = object().shape(validator)
// clear the previous errors // clear the previous errors
@ -62,5 +88,6 @@ export const createValidationStore = () => {
set: validation.set, set: validation.set,
check, check,
addValidator, addValidator,
addValidatorType,
} }
} }

View File

@ -113,18 +113,23 @@
> >
Access Access
</MenuItem> </MenuItem>
{#if isPublished} <MenuItem
<MenuItem on:click={() =>
on:click={() => $goto(
$goto( `../../portal/overview/${application}?tab=${encodeURIComponent(
`../../portal/overview/${application}?tab=${encodeURIComponent( "Automation History"
"Automation History" )}`
)}` )}
)} >
> Automation history
Automation history </MenuItem>
</MenuItem> <MenuItem
{/if} on:click={() =>
$goto(`../../portal/overview/${application}?tab=Backups`)}
>
Backups
</MenuItem>
<MenuItem <MenuItem
on:click={() => on:click={() =>
$goto(`../../portal/overview/${application}?tab=Settings`)} $goto(`../../portal/overview/${application}?tab=Settings`)}

View File

@ -23,7 +23,8 @@
let importQueriesModal let importQueriesModal
let changed let changed,
isValid = true
let integration, baseDatasource, datasource let integration, baseDatasource, datasource
let queryList let queryList
const querySchema = { const querySchema = {
@ -101,12 +102,15 @@
<Divider /> <Divider />
<div class="config-header"> <div class="config-header">
<Heading size="S">Configuration</Heading> <Heading size="S">Configuration</Heading>
<Button disabled={!changed} cta on:click={saveDatasource}>Save</Button> <Button disabled={!changed || !isValid} cta on:click={saveDatasource}
>Save</Button
>
</div> </div>
<IntegrationConfigForm <IntegrationConfigForm
on:change={hasChanged} on:change={hasChanged}
schema={integration.datasource} schema={integration.datasource}
bind:datasource bind:datasource
on:valid={e => (isValid = e.detail)}
/> />
{#if datasource.plus} {#if datasource.plus}
<PlusConfigForm bind:datasource save={saveDatasource} /> <PlusConfigForm bind:datasource save={saveDatasource} />

View File

@ -86,7 +86,11 @@
: [], : [],
isBudibaseEvent: true, isBudibaseEvent: true,
usedPlugins: $store.usedPlugins, usedPlugins: $store.usedPlugins,
location: window.location, location: {
protocol: window.location.protocol,
hostname: window.location.hostname,
port: window.location.port,
},
} }
// Refresh the preview when required // Refresh the preview when required
@ -99,7 +103,7 @@
) )
// Register handler to send custom to the preview // Register handler to send custom to the preview
$: store.actions.preview.registerEventHandler((name, payload) => { $: sendPreviewEvent = (name, payload) => {
iframe?.contentWindow.postMessage( iframe?.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
name, name,
@ -108,120 +112,116 @@
runtimeEvent: true, runtimeEvent: true,
}) })
) )
}) }
$: store.actions.preview.registerEventHandler(sendPreviewEvent)
// Update the iframe with the builder info to render the correct preview // Update the iframe with the builder info to render the correct preview
const refreshContent = message => { const refreshContent = message => {
iframe?.contentWindow.postMessage(message) iframe?.contentWindow.postMessage(message)
} }
const receiveMessage = message => { const receiveMessage = async message => {
const handlers = { if (!message?.data?.type) {
[MessageTypes.READY]: () => {
// Initialise the app when mounted
if ($store.clientFeatures.messagePassing) {
if (!loading) return
}
// Display preview immediately if the intelligent loading feature
// is not supported
if (!$store.clientFeatures.intelligentLoading) {
loading = false
}
refreshContent(json)
},
[MessageTypes.ERROR]: event => {
// Catch any app errors
loading = false
error = event.error || "An unknown error occurred"
},
}
const messageHandler = handlers[message.data.type] || handleBudibaseEvent
messageHandler(message)
}
const handleBudibaseEvent = async event => {
const { type, data } = event.data || event.detail
if (!type) {
return return
} }
// Await the event handler
try { try {
if (type === "select-component" && data.id) { await handleBudibaseEvent(message)
$store.selectedComponentId = data.id
if (!$isActive("./components")) {
$goto("./components")
}
} else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "delete-component" && data.id) {
// Legacy type, can be deleted in future
confirmDeleteComponent(data.id)
} else if (type === "key-down") {
const { key, ctrlKey } = data
document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey }))
} else if (type === "duplicate-component" && data.id) {
const rootComponent = get(currentAsset).props
const component = findComponent(rootComponent, data.id)
store.actions.components.copy(component)
await store.actions.components.paste(component)
} else if (type === "preview-loaded") {
// Wait for this event to show the client library if intelligent
// loading is supported
loading = false
} else if (type === "move-component") {
const { componentId, destinationComponentId } = data
const rootComponent = get(currentAsset).props
// Get source and destination components
const source = findComponent(rootComponent, componentId)
const destination = findComponent(rootComponent, destinationComponentId)
// Stop if the target is a child of source
const path = findComponentPath(source, destinationComponentId)
const ids = path.map(component => component._id)
if (ids.includes(data.destinationComponentId)) {
return
}
// Cut and paste the component to the new destination
if (source && destination) {
store.actions.components.copy(source, true)
await store.actions.components.paste(destination, data.mode)
}
} else if (type === "click-nav") {
if (!$isActive("./navigation")) {
$goto("./navigation")
}
} else if (type === "request-add-component") {
toggleAddComponent()
} else if (type === "highlight-setting") {
store.actions.settings.highlight(data.setting)
// Also scroll setting into view
const selector = `[data-cy="${data.setting}-prop-control"`
const element = document.querySelector(selector)?.parentElement
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
})
}
} else if (type === "eject-block") {
const { id, definition } = data
await store.actions.components.handleEjectBlock(id, definition)
} else if (type === "reload-plugin") {
await store.actions.components.refreshDefinitions()
} else if (type === "drop-new-component") {
const { component, parent, index } = data
await store.actions.components.create(component, null, parent, index)
} else {
console.warn(`Client sent unknown event type: ${type}`)
}
} catch (error) { } catch (error) {
console.warn(error) notifications.error(error || "Error handling event from app preview")
notifications.error("Error handling event from app preview") }
// Reply that the event has been completed
if (message.data?.id) {
sendPreviewEvent("event-completed", message.data?.id)
}
}
const handleBudibaseEvent = async event => {
const { type, data } = event.data
if (type === MessageTypes.READY) {
// Initialise the app when mounted
if (!loading) {
return
}
refreshContent(json)
} else if (type === MessageTypes.ERROR) {
// Catch any app errors
loading = false
error = event.error || "An unknown error occurred"
} else if (type === "select-component" && data.id) {
$store.selectedComponentId = data.id
if (!$isActive("./components")) {
$goto("./components")
}
} else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "update-styles") {
await store.actions.components.updateStyles(data.styles, data.id)
} else if (type === "delete-component" && data.id) {
// Legacy type, can be deleted in future
confirmDeleteComponent(data.id)
} else if (type === "key-down") {
const { key, ctrlKey } = data
document.dispatchEvent(new KeyboardEvent("keydown", { key, ctrlKey }))
} else if (type === "duplicate-component" && data.id) {
const rootComponent = get(currentAsset).props
const component = findComponent(rootComponent, data.id)
store.actions.components.copy(component)
await store.actions.components.paste(component)
} else if (type === "preview-loaded") {
// Wait for this event to show the client library if intelligent
// loading is supported
loading = false
} else if (type === "move-component") {
const { componentId, destinationComponentId } = data
const rootComponent = get(currentAsset).props
// Get source and destination components
const source = findComponent(rootComponent, componentId)
const destination = findComponent(rootComponent, destinationComponentId)
// Stop if the target is a child of source
const path = findComponentPath(source, destinationComponentId)
const ids = path.map(component => component._id)
if (ids.includes(data.destinationComponentId)) {
return
}
// Cut and paste the component to the new destination
if (source && destination) {
store.actions.components.copy(source, true, false)
await store.actions.components.paste(destination, data.mode)
}
} else if (type === "click-nav") {
if (!$isActive("./navigation")) {
$goto("./navigation")
}
} else if (type === "request-add-component") {
toggleAddComponent()
} else if (type === "highlight-setting") {
store.actions.settings.highlight(data.setting)
// Also scroll setting into view
const selector = `[data-cy="${data.setting}-prop-control"`
const element = document.querySelector(selector)?.parentElement
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "center",
})
}
} else if (type === "eject-block") {
const { id, definition } = data
await store.actions.components.handleEjectBlock(id, definition)
} else if (type === "reload-plugin") {
await store.actions.components.refreshDefinitions()
} else if (type === "drop-new-component") {
const { component, parent, index } = data
await store.actions.components.create(component, null, parent, index)
} else {
console.warn(`Client sent unknown event type: ${type}`)
} }
} }
@ -254,42 +254,10 @@
onMount(() => { onMount(() => {
window.addEventListener("message", receiveMessage) window.addEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.addEventListener(
"ready",
() => {
receiveMessage({ data: { type: MessageTypes.READY } })
},
{ once: true }
)
iframe.contentWindow.addEventListener(
"error",
event => {
receiveMessage({
data: { type: MessageTypes.ERROR, error: event.detail },
})
},
{ once: true }
)
// Add listener for events sent by client library in preview
iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent)
}
}) })
// Remove all iframe event listeners on component destroy
onDestroy(() => { onDestroy(() => {
window.removeEventListener("message", receiveMessage) window.removeEventListener("message", receiveMessage)
if (iframe.contentWindow) {
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.removeEventListener(
"bb-event",
handleBudibaseEvent
)
}
}
}) })
</script> </script>

View File

@ -80,10 +80,9 @@
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
return handler(component) return await handler(component)
} catch (error) { } catch (error) {
console.error(error) notifications.error(error || "Error handling key press")
notifications.error("Error handling key press")
} }
} }

View File

@ -70,34 +70,12 @@
closedNodes = closedNodes closedNodes = closedNodes
} }
const onDrop = async (e, component) => { const onDrop = async e => {
e.stopPropagation() e.stopPropagation()
try { try {
const compDef = store.actions.components.getDefinition(
$dndStore.source?._component
)
if (!compDef) {
return
}
const compTypeName = compDef.name.toLowerCase()
const path = findComponentPath(currentScreen.props, component._id)
for (let pathComp of path) {
const pathCompDef = store.actions.components.getDefinition(
pathComp?._component
)
if (pathCompDef?.illegalChildren?.indexOf(compTypeName) > -1) {
notifications.warning(
`${compDef.name} cannot be a child of ${pathCompDef.name} (${pathComp._instanceName})`
)
return
}
}
await dndStore.actions.drop() await dndStore.actions.drop()
} catch (error) { } catch (error) {
console.error(error) notifications.error(error || "Error saving component")
notifications.error("Error saving component")
} }
} }
@ -137,9 +115,7 @@
on:dragstart={() => dndStore.actions.dragstart(component)} on:dragstart={() => dndStore.actions.dragstart(component)}
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => toggleNodeOpen(component._id)} on:iconClick={() => toggleNodeOpen(component._id)}
on:drop={e => { on:drop={onDrop}
onDrop(e, component)
}}
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
withArrow={componentHasChildren(component)} withArrow={componentHasChildren(component)}

View File

@ -29,6 +29,10 @@
// Filter out settings which shouldn't be rendered // Filter out settings which shouldn't be rendered
sections.forEach(section => { sections.forEach(section => {
section.visible = shouldDisplay(instance, section)
if (!section.visible) {
return
}
section.settings.forEach(setting => { section.settings.forEach(setting => {
setting.visible = canRenderControl(instance, setting, isScreen) setting.visible = canRenderControl(instance, setting, isScreen)
}) })
@ -46,17 +50,7 @@
} }
} }
const canRenderControl = (instance, setting, isScreen) => { const shouldDisplay = (instance, setting) => {
// Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) {
return false
}
const control = getComponentForSetting(setting)
if (!control) {
return false
}
// Parse dependant settings // Parse dependant settings
if (setting.dependsOn) { if (setting.dependsOn) {
let dependantSetting = setting.dependsOn let dependantSetting = setting.dependsOn
@ -93,6 +87,19 @@
return true return true
} }
const canRenderControl = (instance, setting, isScreen) => {
// Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) {
return false
}
const control = getComponentForSetting(setting)
if (!control) {
return false
}
return shouldDisplay(instance, setting)
}
</script> </script>
{#each sections as section, idx (section.name)} {#each sections as section, idx (section.name)}

View File

@ -11,9 +11,10 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import structure from "./componentStructure.json" import structure from "./componentStructure.json"
import { store, selectedComponent } from "builderStore" import { store, selectedComponent, selectedScreen } from "builderStore"
import { onMount } from "svelte" import { onMount } from "svelte"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import { findComponentPath } from "builderStore/componentUtils"
let section = "components" let section = "components"
let searchString let searchString
@ -21,8 +22,10 @@
let selectedIndex let selectedIndex
let componentList = [] let componentList = []
$: currentDefinition = store.actions.components.getDefinition( $: allowedComponents = getAllowedComponents(
$selectedComponent?._component $store.components,
$selectedScreen,
$selectedComponent
) )
$: enrichedStructure = enrichStructure( $: enrichedStructure = enrichStructure(
structure, structure,
@ -31,13 +34,50 @@
) )
$: filteredStructure = filterStructure( $: filteredStructure = filterStructure(
enrichedStructure, enrichedStructure,
section, allowedComponents,
currentDefinition,
searchString searchString
) )
$: blocks = enrichedStructure.find(x => x.name === "Blocks").children $: blocks = enrichedStructure.find(x => x.name === "Blocks").children
$: orderMap = createComponentOrderMap(componentList) $: orderMap = createComponentOrderMap(componentList)
const getAllowedComponents = (allComponents, screen, component) => {
const path = findComponentPath(screen?.props, component?._id)
if (!path?.length) {
return []
}
// Get initial set of allowed components
let allowedComponents = []
const definition = store.actions.components.getDefinition(
component?._component
)
if (definition.legalDirectChildren?.length) {
allowedComponents = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}`
})
} else {
allowedComponents = Object.keys(allComponents)
}
// Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || []
path.forEach(ancestor => {
const def = store.actions.components.getDefinition(ancestor._component)
const blacklist = def?.illegalChildren?.map(x => {
return `@budibase/standard-components/${x}`
})
illegalChildren = [...illegalChildren, ...(blacklist || [])]
})
illegalChildren = [...new Set(illegalChildren)]
// Filter out illegal children from allowed components
allowedComponents = allowedComponents.filter(x => {
return !illegalChildren.includes(x)
})
return allowedComponents
}
// Creates a simple lookup map from an array, so we can find the selected // Creates a simple lookup map from an array, so we can find the selected
// component much faster // component much faster
const createComponentOrderMap = list => { const createComponentOrderMap = list => {
@ -90,7 +130,7 @@
return enrichedStructure return enrichedStructure
} }
const filterStructure = (structure, section, currentDefinition, search) => { const filterStructure = (structure, allowedComponents, search) => {
selectedIndex = search ? 0 : null selectedIndex = search ? 0 : null
componentList = [] componentList = []
if (!structure?.length) { if (!structure?.length) {
@ -114,7 +154,7 @@
} }
// Check if the component is allowed as a child // Check if the component is allowed as a child
return !currentDefinition?.illegalChildren?.includes(name) return allowedComponents.includes(child.component)
}) })
if (matchedChildren.length) { if (matchedChildren.length) {
filteredStructure.push({ filteredStructure.push({
@ -138,7 +178,7 @@
await store.actions.components.create(component) await store.actions.components.create(component)
$goto("../") $goto("../")
} catch (error) { } catch (error) {
notifications.error("Error creating component") notifications.error(error || "Error creating component")
} }
} }

View File

@ -6,7 +6,8 @@
"tableblock", "tableblock",
"cardsblock", "cardsblock",
"repeaterblock", "repeaterblock",
"formblock" "formblock",
"chartblock"
] ]
}, },
{ {
@ -14,7 +15,8 @@
"icon": "ClassicGridView", "icon": "ClassicGridView",
"children": [ "children": [
"container", "container",
"section" "section",
"grid"
] ]
}, },
{ {

View File

@ -21,7 +21,6 @@
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, auth, admin, templates, licensing } from "stores/portal" import { apps, auth, admin, templates, licensing } from "stores/portal"
import download from "downloadjs"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants" import { AppStatus } from "constants"
@ -140,7 +139,7 @@
const initiateAppsExport = () => { const initiateAppsExport = () => {
try { try {
download(`/api/cloud/export`) window.location = `/api/cloud/export`
notifications.success("Apps exported successfully") notifications.success("Apps exported successfully")
} catch (err) { } catch (err) {
notifications.error(`Error exporting apps: ${err}`) notifications.error(`Error exporting apps: ${err}`)

View File

@ -55,7 +55,7 @@
Add plugin Add plugin
</Button> </Button>
</div> </div>
{#if filteredPlugins?.length} {#if $plugins?.length}
<div class="filters"> <div class="filters">
<div class="select"> <div class="select">
<Select <Select

View File

@ -33,6 +33,7 @@
import ExportAppModal from "components/start/ExportAppModal.svelte" import ExportAppModal from "components/start/ExportAppModal.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils" import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { onDestroy, onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import BackupsTab from "components/portal/overview/backups/BackupsTab.svelte"
export let application export let application
@ -318,16 +319,12 @@
<Tab title="Access"> <Tab title="Access">
<AccessTab app={selectedApp} /> <AccessTab app={selectedApp} />
</Tab> </Tab>
{#if isPublished} <Tab title="Automation History">
<Tab title="Automation History"> <HistoryTab app={selectedApp} />
<HistoryTab app={selectedApp} /> </Tab>
</Tab> <Tab title="Backups">
{/if} <BackupsTab app={selectedApp} />
{#if false} </Tab>
<Tab title="Backups">
<div class="container">Backups contents</div>
</Tab>
{/if}
<Tab title="Settings"> <Tab title="Settings">
<SettingsTab app={selectedApp} /> <SettingsTab app={selectedApp} />
</Tab> </Tab>

View File

@ -2,6 +2,9 @@ import { writable } from "svelte/store"
import { AppStatus } from "../../constants" import { AppStatus } from "../../constants"
import { API } from "api" import { API } from "api"
// properties that should always come from the dev app, not the deployed
const DEV_PROPS = ["updatedBy", "updatedAt"]
const extractAppId = id => { const extractAppId = id => {
const split = id?.split("_") || [] const split = id?.split("_") || []
return split.length ? split[split.length - 1] : null return split.length ? split[split.length - 1] : null
@ -57,9 +60,19 @@ export function createAppStore() {
return return
} }
let devProps = {}
if (appMap[id]) {
const entries = Object.entries(appMap[id]).filter(
([key]) => DEV_PROPS.indexOf(key) !== -1
)
entries.forEach(entry => {
devProps[entry[0]] = entry[1]
})
}
appMap[id] = { appMap[id] = {
...appMap[id], ...appMap[id],
...app, ...app,
...devProps,
prodId: app.appId, prodId: app.appId,
prodRev: app._rev, prodRev: app._rev,
} }

View File

@ -0,0 +1,52 @@
import { writable } from "svelte/store"
import { API } from "api"
export function createBackupsStore() {
const store = writable({})
function selectBackup(backupId) {
store.update(state => {
state.selectedBackup = backupId
return state
})
}
async function searchBackups({
appId,
trigger,
type,
page,
startDate,
endDate,
}) {
return API.searchBackups({ appId, trigger, type, page, startDate, endDate })
}
async function restoreBackup({ appId, backupId, name }) {
return API.restoreBackup({ appId, backupId, name })
}
async function deleteBackup({ appId, backupId }) {
return API.deleteBackup({ appId, backupId })
}
async function createManualBackup(appId, name) {
return API.createManualBackup(appId, name)
}
async function updateBackup({ appId, backupId, name }) {
return API.updateBackup({ appId, backupId, name })
}
return {
createManualBackup,
searchBackups,
selectBackup,
deleteBackup,
restoreBackup,
updateBackup,
subscribe: store.subscribe,
}
}
export const backups = createBackupsStore()

View File

@ -9,3 +9,4 @@ export { templates } from "./templates"
export { licensing } from "./licensing" export { licensing } from "./licensing"
export { groups } from "./groups" export { groups } from "./groups"
export { plugins } from "./plugins" export { plugins } from "./plugins"
export { backups } from "./backups"

View File

@ -14,6 +14,7 @@ export const createLicensingStore = () => {
isFreePlan: true, isFreePlan: true,
// features // features
groupsEnabled: false, groupsEnabled: false,
backupsEnabled: false,
// the currently used quotas from the db // the currently used quotas from the db
quotaUsage: undefined, quotaUsage: undefined,
// derived quota metrics for percentages used // derived quota metrics for percentages used
@ -56,12 +57,17 @@ export const createLicensingStore = () => {
const groupsEnabled = license.features.includes( const groupsEnabled = license.features.includes(
Constants.Features.USER_GROUPS Constants.Features.USER_GROUPS
) )
const backupsEnabled = license.features.includes(
Constants.Features.BACKUPS
)
store.update(state => { store.update(state => {
return { return {
...state, ...state,
license, license,
isFreePlan, isFreePlan,
groupsEnabled, groupsEnabled,
backupsEnabled,
} }
}) })
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"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,9 +26,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "2.0.30-alpha.13", "@budibase/backend-core": "2.0.34-alpha.3",
"@budibase/string-templates": "2.0.30-alpha.13", "@budibase/string-templates": "2.0.34-alpha.3",
"@budibase/types": "2.0.30-alpha.13", "@budibase/types": "2.0.34-alpha.3",
"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",

View File

@ -87,7 +87,7 @@
"showSettingsBar": true, "showSettingsBar": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 100 "height": 200
}, },
"styles": [ "styles": [
"padding", "padding",
@ -3654,7 +3654,7 @@
"key": "filter" "key": "filter"
}, },
{ {
"type": "field", "type": "field/sortable",
"label": "Sort Column", "label": "Sort Column",
"key": "sortColumn" "key": "sortColumn"
}, },
@ -3972,6 +3972,477 @@
} }
] ]
}, },
"chartblock": {
"block": true,
"name": "Chart block",
"icon": "GraphPie",
"hasChildren": false,
"settings": [
{
"type": "select",
"label": "Chart Type",
"key": "chartType",
"required": true,
"options": [
{
"label": "Pie",
"value": "pie"
},
{
"label": "Bar",
"value": "bar"
},
{
"label": "Line",
"value": "line"
},
{
"label": "Donut",
"value": "donut"
},
{
"label": "Candlestick",
"value": "candlestick"
},
{
"label": "Area",
"value": "area"
}
]
},
{
"type": "dataSource",
"label": "Data",
"key": "dataSource",
"required": true
},
{
"type": "text",
"label": "Title",
"key": "chartTitle"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": [
"Ascending",
"Descending"
],
"defaultValue": "Ascending"
},
{
"type": "number",
"label": "Limit",
"key": "limit",
"defaultValue": 50
},
{
"type": "text",
"label": "Width",
"key": "width"
},
{
"type": "text",
"label": "Height",
"key": "height",
"defaultValue": "400"
},
{
"type": "select",
"label": "Colors",
"key": "palette",
"defaultValue": "Palette 1",
"options": [
"Custom",
"Palette 1",
"Palette 2",
"Palette 3",
"Palette 4",
"Palette 5",
"Palette 6",
"Palette 7",
"Palette 8",
"Palette 9",
"Palette 10"
]
},
{
"type": "color",
"label": "C1",
"key": "c1",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C2",
"key": "c2",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C3",
"key": "c3",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C4",
"key": "c4",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "color",
"label": "C5",
"key": "c5",
"dependsOn": {
"setting": "palette",
"value": "Custom"
}
},
{
"type": "boolean",
"label": "Data Labels",
"key": "dataLabels",
"defaultValue": false
},
{
"type": "boolean",
"label": "Legend",
"key": "legend",
"defaultValue": true
},
{
"type": "boolean",
"label": "Animate",
"key": "animate",
"defaultValue": false
},
{
"section": true,
"name": "Pie Chart",
"icon": "GraphPie",
"dependsOn": {
"setting": "chartType",
"value": "pie"
},
"settings": [
{
"type": "field",
"label": "Label Col.",
"key": "labelColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "field",
"label": "Data Col.",
"key": "valueColumn",
"dependsOn": "dataSource",
"required": true
}
]
},
{
"section": true,
"name": "Donut Chart",
"icon": "GraphDonut",
"dependsOn": {
"setting": "chartType",
"value": "donut"
},
"settings": [
{
"type": "field",
"label": "Label Col.",
"key": "labelColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "field",
"label": "Data Col.",
"key": "valueColumn",
"dependsOn": "dataSource",
"required": true
}
]
},
{
"section": true,
"name": "Bar Chart",
"icon": "GraphBarVertical",
"dependsOn": {
"setting": "chartType",
"value": "bar"
},
"settings": [
{
"type": "field",
"label": "Label Col.",
"key": "labelColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "multifield",
"label": "Data Cols.",
"key": "valueColumns",
"dependsOn": "dataSource",
"required": true
},
{
"type": "select",
"label": "Format",
"key": "yAxisUnits",
"options": [
"Default",
"Thousands",
"Millions"
],
"defaultValue": "Default"
},
{
"type": "text",
"label": "Y Axis Label",
"key": "yAxisLabel"
},
{
"type": "text",
"label": "X Axis Label",
"key": "xAxisLabel"
},
{
"type": "boolean",
"label": "Stacked",
"key": "stacked",
"defaultValue": false
},
{
"type": "boolean",
"label": "Horizontal",
"key": "horizontal",
"defaultValue": false
}
]
},
{
"section": true,
"name": "Line Chart",
"icon": "GraphTrend",
"dependsOn": {
"setting": "chartType",
"value": "line"
},
"settings": [
{
"type": "field",
"label": "Label Col.",
"key": "labelColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "multifield",
"label": "Data Cols.",
"key": "valueColumns",
"dependsOn": "dataSource",
"required": true
},
{
"type": "select",
"label": "Format",
"key": "yAxisUnits",
"options": [
"Default",
"Thousands",
"Millions"
],
"defaultValue": "Default"
},
{
"type": "text",
"label": "Y Axis Label",
"key": "yAxisLabel"
},
{
"type": "text",
"label": "X Axis Label",
"key": "xAxisLabel"
},
{
"type": "select",
"label": "Curve",
"key": "curve",
"options": [
"Smooth",
"Straight",
"Stepline"
],
"defaultValue": "Smooth"
}
]
},
{
"section": true,
"name": "Area Chart",
"icon": "GraphAreaStacked",
"dependsOn": {
"setting": "chartType",
"value": "area"
},
"settings": [
{
"type": "field",
"label": "Label Col.",
"key": "labelColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "multifield",
"label": "Data Cols.",
"key": "valueColumns",
"dependsOn": "dataSource",
"required": true
},
{
"type": "select",
"label": "Format",
"key": "yAxisUnits",
"options": [
"Default",
"Thousands",
"Millions"
],
"defaultValue": "Default"
},
{
"type": "text",
"label": "Y Axis Label",
"key": "yAxisLabel"
},
{
"type": "text",
"label": "X Axis Label",
"key": "xAxisLabel"
},
{
"type": "select",
"label": "Curve",
"key": "curve",
"options": [
"Smooth",
"Straight",
"Stepline"
],
"defaultValue": "Smooth"
},
{
"type": "boolean",
"label": "Stacked",
"key": "stacked",
"defaultValue": true
},
{
"type": "boolean",
"label": "Gradient",
"key": "gradient",
"defaultValue": false
}
]
},
{
"section": true,
"name": "Candlestick Chart",
"icon": "GraphBarVerticalStacked",
"dependsOn": {
"setting": "chartType",
"value": "candlestick"
},
"settings": [
{
"type": "field",
"label": "Date Col.",
"key": "dateColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "field",
"label": "Open Col.",
"key": "openColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "field",
"label": "Close Col.",
"key": "closeColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "field",
"label": "High Col.",
"key": "highColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "field",
"label": "Low Col.",
"key": "lowColumn",
"dependsOn": "dataSource",
"required": true
},
{
"type": "select",
"label": "Format",
"key": "yAxisUnits",
"options": [
"Default",
"Thousands",
"Millions"
],
"defaultValue": "Default"
},
{
"type": "text",
"label": "Y Axis Label",
"key": "yAxisLabel"
},
{
"type": "text",
"label": "X Axis Label",
"key": "xAxisLabel"
}
]
}
]
},
"tableblock": { "tableblock": {
"block": true, "block": true,
"name": "Table block", "name": "Table block",
@ -4008,7 +4479,7 @@
"key": "filter" "key": "filter"
}, },
{ {
"type": "field", "type": "field/sortable",
"label": "Sort Column", "label": "Sort Column",
"key": "sortColumn" "key": "sortColumn"
}, },
@ -4177,7 +4648,7 @@
"key": "filter" "key": "filter"
}, },
{ {
"type": "field", "type": "field/sortable",
"label": "Sort Column", "label": "Sort Column",
"key": "sortColumn" "key": "sortColumn"
}, },
@ -4344,7 +4815,7 @@
"key": "filter" "key": "filter"
}, },
{ {
"type": "field", "type": "field/sortable",
"label": "Sort Column", "label": "Sort Column",
"key": "sortColumn" "key": "sortColumn"
}, },
@ -4566,6 +5037,45 @@
} }
] ]
}, },
"grid": {
"name": "Grid (Beta)",
"icon": "ViewGrid",
"hasChildren": true,
"styles": [
"size"
],
"illegalChildren": ["section", "grid"],
"legalDirectChildren": [
"container",
"tableblock",
"cardsblock",
"repeaterblock",
"formblock"
],
"size": {
"width": 800,
"height": 400
},
"showEmptyState": false,
"settings": [
{
"type": "number",
"label": "Rows",
"key": "rows",
"defaultValue": 12,
"min": 1,
"max": 32
},
{
"type": "number",
"label": "Columns",
"key": "cols",
"defaultValue": 12,
"min": 1,
"max": 32
}
]
},
"formblock": { "formblock": {
"name": "Form Block", "name": "Form Block",
"icon": "Form", "icon": "Form",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"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": "2.0.30-alpha.13", "@budibase/bbui": "2.0.34-alpha.3",
"@budibase/frontend-core": "2.0.30-alpha.13", "@budibase/frontend-core": "2.0.34-alpha.3",
"@budibase/string-templates": "2.0.30-alpha.13", "@budibase/string-templates": "2.0.34-alpha.3",
"@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

@ -30,6 +30,7 @@
import HoverIndicator from "components/preview/HoverIndicator.svelte" import HoverIndicator from "components/preview/HoverIndicator.svelte"
import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
import DNDHandler from "components/preview/DNDHandler.svelte" import DNDHandler from "components/preview/DNDHandler.svelte"
import GridDNDHandler from "components/preview/GridDNDHandler.svelte"
import KeyboardManager from "components/preview/KeyboardManager.svelte" import KeyboardManager from "components/preview/KeyboardManager.svelte"
import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" import DevToolsHeader from "components/devtools/DevToolsHeader.svelte"
import DevTools from "components/devtools/DevTools.svelte" import DevTools from "components/devtools/DevTools.svelte"
@ -196,6 +197,7 @@
{/if} {/if}
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<DNDHandler /> <DNDHandler />
<GridDNDHandler />
{/if} {/if}
</div> </div>
</QueryParamsProvider> </QueryParamsProvider>

View File

@ -21,8 +21,8 @@
devToolsStore, devToolsStore,
componentStore, componentStore,
appStore, appStore,
dndIsDragging,
dndComponentPath, dndComponentPath,
dndIsDragging,
} from "stores" } from "stores"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { getActiveConditions, reduceConditionActions } from "utils/conditions" import { getActiveConditions, reduceConditionActions } from "utils/conditions"
@ -90,6 +90,10 @@
let settingsDefinitionMap let settingsDefinitionMap
let missingRequiredSettings = false let missingRequiredSettings = false
// Temporary styles which can be added in the app preview for things like DND.
// We clear these whenever a new instance is received.
let ephemeralStyles
// Set up initial state for each new component instance // Set up initial state for each new component instance
$: initialise(instance) $: initialise(instance)
@ -171,6 +175,10 @@
children: children.length, children: children.length,
styles: { styles: {
...instance._styles, ...instance._styles,
normal: {
...instance._styles?.normal,
...ephemeralStyles,
},
custom: customCSS, custom: customCSS,
id, id,
empty: emptyState, empty: emptyState,
@ -449,6 +457,7 @@
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
getDataContext: () => get(context), getDataContext: () => get(context),
reload: () => initialise(instance, true), reload: () => initialise(instance, true),
setEphemeralStyles: styles => (ephemeralStyles = styles),
}) })
} }
}) })
@ -506,8 +515,8 @@
display: contents; display: contents;
} }
.component.pad :global(> *) { .component.pad :global(> *) {
padding: var(--spacing-l) !important; padding: var(--spacing-m) !important;
gap: var(--spacing-l) !important; gap: var(--spacing-m) !important;
border: 2px dashed var(--spectrum-global-color-gray-400) !important; border: 2px dashed var(--spectrum-global-color-gray-400) !important;
border-radius: 4px !important; border-radius: 4px !important;
transition: padding 260ms ease-out, border 260ms ease-out; transition: padding 260ms ease-out, border 260ms ease-out;

View File

@ -0,0 +1,102 @@
<script>
import { getContext } from "svelte"
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
export let cols = 12
export let rows = 12
// Deliberately non-reactive as we want this fixed whenever the grid renders
const defaultColSpan = Math.ceil((cols + 1) / 2)
const defaultRowSpan = Math.ceil((rows + 1) / 2)
$: coords = generateCoords(rows, cols)
const generateCoords = (rows, cols) => {
let grid = []
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
grid.push({ row, col })
}
}
return grid
}
</script>
<div
class="grid"
use:styleable={{
...$component.styles,
normal: {
...$component.styles?.normal,
"--cols": cols,
"--rows": rows,
"--default-col-span": defaultColSpan,
"--default-row-span": defaultRowSpan,
gap: "0 !important",
},
}}
data-rows={rows}
data-cols={cols}
>
{#if $builderStore.inBuilder}
<div class="underlay">
{#each coords as coord}
<div class="placeholder" />
{/each}
</div>
{/if}
<slot />
</div>
<style>
/*
Ensure all children of containers which are top level children of
grids do not overflow
*/
:global(.grid > .component > .valid-container > .component > *) {
max-height: 100%;
max-width: 100%;
}
/* Ensure all top level children have some grid styles set */
:global(.grid > .component > *) {
overflow: hidden;
width: auto;
height: auto;
grid-column-start: 1;
grid-column-end: var(--default-col-span);
grid-row-start: 1;
grid-row-end: var(--default-row-span);
max-height: 100%;
max-width: 100%;
}
.grid {
position: relative;
height: 400px;
}
.grid,
.underlay {
display: grid;
grid-template-rows: repeat(var(--rows), 1fr);
grid-template-columns: repeat(var(--cols), 1fr);
}
.underlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
grid-gap: 2px;
background-color: var(--spectrum-global-color-gray-200);
border: 2px solid var(--spectrum-global-color-gray-200);
}
.underlay {
z-index: -1;
}
.placeholder {
background-color: var(--spectrum-global-color-gray-100);
}
</style>

View File

@ -0,0 +1,99 @@
<script>
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
// Datasource
export let filter
export let sortColumn
export let sortOrder
export let limit
// Block
export let chartTitle
export let chartType
export let dataSource
export let palette
export let c1, c2, c3, c4, c5
export let labelColumn
export let legend
export let animate
export let dataLabels
export let height
export let width
// Pie/Donut
export let valueColumn
// Bar
export let stacked
export let horizontal
// Bar/Line/Area
export let valueColumns
export let yAxisUnits
export let yAxisLabel
export let xAxisLabel
export let curve
// Area
export let gradient
// Candlestick
export let closeColumn
export let openColumn
export let highColumn
export let lowColumn
export let dateColumn
let dataProviderId
$: colors = c1 && c2 && c3 && c4 && c5 ? [c1, c2, c3, c4, c5] : null
</script>
<Block>
<BlockComponent
type="dataprovider"
context="provider"
bind:id={dataProviderId}
props={{
dataSource,
filter,
sortColumn,
sortOrder,
limit,
}}
>
{#if dataProviderId && chartType}
<BlockComponent
type={chartType}
props={{
dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
height,
width,
title: chartTitle,
labelColumn,
valueColumn,
valueColumns,
palette,
dataLabels,
legend,
animate,
...colors,
yAxisUnits,
yAxisLabel,
xAxisLabel,
stacked,
horizontal,
curve,
gradient, //issue?
closeColumn,
openColumn,
highColumn,
lowColumn,
dateColumn,
}}
/>
{/if}
</BlockComponent>
</Block>

View File

@ -2,3 +2,4 @@ export { default as tableblock } from "./TableBlock.svelte"
export { default as cardsblock } from "./CardsBlock.svelte" export { default as cardsblock } from "./CardsBlock.svelte"
export { default as repeaterblock } from "./RepeaterBlock.svelte" export { default as repeaterblock } from "./RepeaterBlock.svelte"
export { default as formblock } from "./FormBlock.svelte" export { default as formblock } from "./FormBlock.svelte"
export { default as chartblock } from "./ChartBlock.svelte"

View File

@ -24,8 +24,11 @@
display: flex !important; display: flex !important;
text-transform: capitalize; text-transform: capitalize;
} }
div :global(.apexcharts-yaxis-label), div :global(.apexcharts-text.apexcharts-xaxis-title-text),
div :global(.apexcharts-xaxis-label) { div :global(.apexcharts-text.apexcharts-yaxis-title-text),
div :global(.apexcharts-text.apexcharts-xaxis-label),
div :global(.apexcharts-text.apexcharts-yaxis-label),
div :global(.apexcharts-title-text) {
fill: var(--spectrum-global-color-gray-600); fill: var(--spectrum-global-color-gray-600);
} }

View File

@ -184,6 +184,9 @@ export class ApexOptionsBuilder {
} }
palette(palette) { palette(palette) {
if (!palette) {
return this
}
return this.setOption( return this.setOption(
["theme", "palette"], ["theme", "palette"],
palette.toLowerCase().replace(/[\W]/g, "") palette.toLowerCase().replace(/[\W]/g, "")

View File

@ -34,6 +34,7 @@ export { default as spectrumcard } from "./SpectrumCard.svelte"
export { default as tag } from "./Tag.svelte" export { default as tag } from "./Tag.svelte"
export { default as markdownviewer } from "./MarkdownViewer.svelte" export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"
export * from "./table" export * from "./table"

View File

@ -22,6 +22,18 @@
$: target = $dndStore.target $: target = $dndStore.target
$: drop = $dndStore.drop $: drop = $dndStore.drop
// Local flag for whether we are awaiting an async drop event
let dropping = false
// Util to check if a DND event originates from a grid (or inside a grid).
// This is important as we do not handle grid DND in this handler.
const isGridEvent = e => {
return e.target
?.closest?.(".component")
?.parentNode?.closest?.(".component")
?.childNodes[0]?.classList.contains("grid")
}
// Util to get the inner DOM node by a component ID // Util to get the inner DOM node by a component ID
const getDOMNode = id => { const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0] const component = document.getElementsByClassName(id)[0]
@ -41,6 +53,10 @@
// Callback when drag stops (whether dropped or not) // Callback when drag stops (whether dropped or not)
const stopDragging = () => { const stopDragging = () => {
if (dropping) {
return
}
// Reset listener // Reset listener
if (source?.id) { if (source?.id) {
const component = document.getElementsByClassName(source?.id)[0] const component = document.getElementsByClassName(source?.id)[0]
@ -55,6 +71,9 @@
// Callback when initially starting a drag on a draggable component // Callback when initially starting a drag on a draggable component
const onDragStart = e => { const onDragStart = e => {
if (isGridEvent(e)) {
return
}
const component = e.target.closest(".component") const component = e.target.closest(".component")
if (!component?.classList.contains("draggable")) { if (!component?.classList.contains("draggable")) {
return return
@ -99,9 +118,9 @@
// Core logic for handling drop events and determining where to render the // Core logic for handling drop events and determining where to render the
// drop target placeholder // drop target placeholder
const processEvent = (mouseX, mouseY) => { const processEvent = Utils.throttle((mouseX, mouseY) => {
if (!target) { if (!target) {
return null return
} }
let { id, parent, node, acceptsChildren, empty } = target let { id, parent, node, acceptsChildren, empty } = target
@ -201,15 +220,15 @@
parent: id, parent: id,
index: idx, index: idx,
}) })
} }, ThrottleRate)
const throttledProcessEvent = Utils.throttle(processEvent, ThrottleRate)
const handleEvent = e => { const handleEvent = e => {
e.preventDefault() e.preventDefault()
throttledProcessEvent(e.clientX, e.clientY) e.stopPropagation()
processEvent(e.clientX, e.clientY)
} }
// Callback when on top of a component // Callback when on top of a component.
const onDragOver = e => { const onDragOver = e => {
if (!source || !target) { if (!source || !target) {
return return
@ -241,18 +260,21 @@
} }
// Callback when dropping a drag on top of some component // Callback when dropping a drag on top of some component
const onDrop = () => { const onDrop = async () => {
if (!source || !drop?.parent || drop?.index == null) { if (!source || !drop?.parent || drop?.index == null) {
return return
} }
// Check if we're adding a new component rather than moving one // Check if we're adding a new component rather than moving one
if (source.newComponentType) { if (source.newComponentType) {
builderStore.actions.dropNewComponent( dropping = true
await builderStore.actions.dropNewComponent(
source.newComponentType, source.newComponentType,
drop.parent, drop.parent,
drop.index drop.index
) )
dropping = false
stopDragging()
return return
} }
@ -289,11 +311,14 @@
} }
if (legacyDropTarget && legacyDropMode) { if (legacyDropTarget && legacyDropMode) {
builderStore.actions.moveComponent( dropping = true
await builderStore.actions.moveComponent(
source.id, source.id,
legacyDropTarget, legacyDropTarget,
legacyDropMode legacyDropMode
) )
dropping = false
stopDragging()
} }
} }

View File

@ -1,33 +0,0 @@
<script>
import { dndBounds } from "stores"
import { DNDPlaceholderID } from "constants"
$: style = getStyle($dndBounds)
const getStyle = bounds => {
if (!bounds) {
return null
}
return `--height: ${bounds.height}px; --width: ${bounds.width}px;`
}
</script>
{#if style}
<div class="wrapper">
<div class="placeholder" id={DNDPlaceholderID} {style} />
</div>
{/if}
<style>
.wrapper {
overflow: hidden;
}
.placeholder {
display: block;
height: var(--height);
width: var(--width);
max-height: 100%;
max-width: 100%;
opacity: 0;
}
</style>

View File

@ -6,7 +6,8 @@
let left, top, height, width let left, top, height, width
const updatePosition = () => { const updatePosition = () => {
const node = document.getElementById(DNDPlaceholderID) const node =
document.getElementsByClassName(DNDPlaceholderID)[0]?.childNodes[0]
if (!node) { if (!node) {
height = 0 height = 0
width = 0 width = 0

View File

@ -0,0 +1,227 @@
<script>
import { onMount, onDestroy } from "svelte"
import { builderStore, componentStore } from "stores"
import { Utils } from "@budibase/frontend-core"
let dragInfo
let gridStyles
let id
// Some memoisation of primitive types for performance
$: jsonStyles = JSON.stringify(gridStyles)
$: id = dragInfo?.id || id
// Set ephemeral grid styles on the dragged component
$: componentStore.actions.getComponentInstance(id)?.setEphemeralStyles({
...gridStyles,
...(gridStyles ? { "z-index": 999 } : null),
})
// Util to check if a DND event originates from a grid (or inside a grid).
// This is important as we do not handle grid DND in this handler.
const isGridEvent = e => {
return (
e.target
.closest?.(".component")
?.parentNode.closest(".component")
?.childNodes[0].classList.contains("grid") ||
e.target.classList.contains("anchor")
)
}
// Util to get the inner DOM node by a component ID
const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0]
return [...component.children][0]
}
const processEvent = Utils.throttle((mouseX, mouseY) => {
if (!dragInfo?.grid) {
return
}
const { mode, side, gridId, grid } = dragInfo
const {
startX,
startY,
rowStart,
rowEnd,
colStart,
colEnd,
rowDeltaMin,
rowDeltaMax,
colDeltaMin,
colDeltaMax,
} = grid
const domGrid = getDOMNode(gridId)
const cols = parseInt(domGrid.dataset.cols)
const rows = parseInt(domGrid.dataset.rows)
const { width, height } = domGrid.getBoundingClientRect()
const colWidth = width / cols
const diffX = mouseX - startX
let deltaX = Math.round(diffX / colWidth)
const rowHeight = height / rows
const diffY = mouseY - startY
let deltaY = Math.round(diffY / rowHeight)
if (mode === "move") {
deltaY = Math.min(Math.max(deltaY, rowDeltaMin), rowDeltaMax)
deltaX = Math.min(Math.max(deltaX, colDeltaMin), colDeltaMax)
const newStyles = {
"grid-row-start": rowStart + deltaY,
"grid-row-end": rowEnd + deltaY,
"grid-column-start": colStart + deltaX,
"grid-column-end": colEnd + deltaX,
}
if (JSON.stringify(newStyles) !== jsonStyles) {
gridStyles = newStyles
}
} else if (mode === "resize") {
let newStyles = {}
if (side === "right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1)
} else if (side === "left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1)
} else if (side === "top") {
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1)
} else if (side === "bottom") {
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "bottom-right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1)
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "bottom-left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1)
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "top-right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1)
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1)
} else if (side === "top-left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1)
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1)
}
if (JSON.stringify(newStyles) !== jsonStyles) {
gridStyles = newStyles
}
}
}, 100)
const handleEvent = e => {
e.preventDefault()
e.stopPropagation()
processEvent(e.clientX, e.clientY)
}
// Callback when initially starting a drag on a draggable component
const onDragStart = e => {
if (!isGridEvent(e)) {
return
}
// Hide drag ghost image
e.dataTransfer.setDragImage(new Image(), 0, 0)
// Extract state
let mode, id, side
if (e.target.classList.contains("anchor")) {
// Handle resize
mode = "resize"
id = e.target.dataset.id
side = e.target.dataset.side
} else {
// Handle move
mode = "move"
const component = e.target.closest(".component")
id = component.dataset.id
}
// Find grid parent
const domComponent = getDOMNode(id)
const gridId = domComponent?.closest(".grid")?.parentNode.dataset.id
if (!gridId) {
return
}
// Update state
dragInfo = {
domTarget: e.target,
id,
gridId,
mode,
side,
}
// Add event handler to clear all drag state when dragging ends
dragInfo.domTarget.addEventListener("dragend", stopDragging)
}
// Callback when entering a potential drop target
const onDragEnter = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo || dragInfo.grid) {
return
}
const domGrid = getDOMNode(dragInfo.gridId)
const gridCols = parseInt(domGrid.dataset.cols)
const gridRows = parseInt(domGrid.dataset.rows)
const domNode = getDOMNode(dragInfo.id)
const styles = window.getComputedStyle(domNode)
if (domGrid) {
const minMax = (value, min, max) => Math.min(max, Math.max(min, value))
const getStyle = x => parseInt(styles?.[x] || "0")
const getColStyle = x => minMax(getStyle(x), 1, gridCols + 1)
const getRowStyle = x => minMax(getStyle(x), 1, gridRows + 1)
dragInfo.grid = {
startX: e.clientX,
startY: e.clientY,
rowStart: getRowStyle("grid-row-start"),
rowEnd: getRowStyle("grid-row-end"),
colStart: getColStyle("grid-column-start"),
colEnd: getColStyle("grid-column-end"),
rowDeltaMin: 1 - getRowStyle("grid-row-start"),
rowDeltaMax: gridRows + 1 - getRowStyle("grid-row-end"),
colDeltaMin: 1 - getColStyle("grid-column-start"),
colDeltaMax: gridCols + 1 - getColStyle("grid-column-end"),
}
handleEvent(e)
}
}
const onDragOver = e => {
if (!dragInfo?.grid) {
return
}
handleEvent(e)
}
// Callback when drag stops (whether dropped or not)
const stopDragging = async () => {
// Save changes
if (gridStyles) {
await builderStore.actions.updateStyles(gridStyles, dragInfo.id)
}
// Reset listener
if (dragInfo?.domTarget) {
dragInfo.domTarget.removeEventListener("dragend", stopDragging)
}
// Reset state
dragInfo = null
gridStyles = null
}
onMount(() => {
document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragenter", onDragEnter, false)
document.addEventListener("dragover", onDragOver, false)
})
onDestroy(() => {
document.removeEventListener("dragstart", onDragStart, false)
document.removeEventListener("dragenter", onDragEnter, false)
document.removeEventListener("dragover", onDragOver, false)
})
</script>

View File

@ -4,11 +4,25 @@
import { builderStore, dndIsDragging } from "stores" import { builderStore, dndIsDragging } from "stores"
let componentId let componentId
$: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920 $: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920
const onMouseOver = e => { const onMouseOver = e => {
const element = e.target.closest(".interactive.component") // Ignore if dragging
const newId = element?.dataset?.id if (e.buttons > 0) {
return
}
let newId
if (e.target.classList.contains("anchor")) {
// Handle resize anchors
newId = e.target.dataset.id
} else {
// Handle normal components
const element = e.target.closest(".interactive.component")
newId = element?.dataset?.id
}
if (newId !== componentId) { if (newId !== componentId) {
componentId = newId componentId = newId
} }
@ -34,4 +48,5 @@
color="var(--spectrum-global-color-static-blue-200)" color="var(--spectrum-global-color-static-blue-200)"
transition transition
{zIndex} {zIndex}
allowResizeAnchors
/> />

View File

@ -10,9 +10,22 @@
export let icon export let icon
export let color export let color
export let zIndex export let zIndex
export let componentId
export let transition = false export let transition = false
export let line = false export let line = false
export let alignRight = false export let alignRight = false
export let showResizeAnchors = false
const AnchorSides = [
"right",
"left",
"top",
"bottom",
"bottom-right",
"bottom-left",
"top-right",
"top-left",
]
$: flipped = top < 24 $: flipped = top < 24
</script> </script>
@ -40,6 +53,18 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if showResizeAnchors}
{#each AnchorSides as side}
<div
draggable="true"
class="anchor {side}"
data-side={side}
data-id={componentId}
>
<div class="anchor-inner" />
</div>
{/each}
{/if}
</div> </div>
<style> <style>
@ -105,4 +130,64 @@
/* Icon styles */ /* Icon styles */
.label :global(.spectrum-Icon + .text) { .label :global(.spectrum-Icon + .text) {
} }
/* Anchor */
.anchor {
--size: 24px;
position: absolute;
width: var(--size);
height: var(--size);
pointer-events: all;
display: grid;
place-items: center;
border-radius: 50%;
}
.anchor-inner {
width: 12px;
height: 12px;
background: white;
border: 2px solid var(--color);
pointer-events: none;
}
.anchor.right {
right: calc(var(--size) / -2 - 1px);
top: calc(50% - var(--size) / 2);
cursor: e-resize;
}
.anchor.left {
left: calc(var(--size) / -2 - 1px);
top: calc(50% - var(--size) / 2);
cursor: w-resize;
}
.anchor.bottom {
left: calc(50% - var(--size) / 2 + 1px);
bottom: calc(var(--size) / -2 - 1px);
cursor: s-resize;
}
.anchor.top {
left: calc(50% - var(--size) / 2 + 1px);
top: calc(var(--size) / -2 - 1px);
cursor: n-resize;
}
.anchor.bottom-right {
right: calc(var(--size) / -2 - 1px);
bottom: calc(var(--size) / -2 - 1px);
cursor: se-resize;
}
.anchor.bottom-left {
left: calc(var(--size) / -2 - 1px);
bottom: calc(var(--size) / -2 - 1px);
cursor: sw-resize;
}
.anchor.top-right {
right: calc(var(--size) / -2 - 1px);
top: calc(var(--size) / -2 - 1px);
cursor: ne-resize;
}
.anchor.top-left {
left: calc(var(--size) / -2 - 1px);
top: calc(var(--size) / -2 - 1px);
cursor: nw-resize;
}
</style> </style>

View File

@ -9,11 +9,13 @@
export let transition export let transition
export let zIndex export let zIndex
export let prefix = null export let prefix = null
export let allowResizeAnchors = false
let indicators = [] let indicators = []
let interval let interval
let text let text
let icon let icon
let insideGrid = false
$: visibleIndicators = indicators.filter(x => x.visible) $: visibleIndicators = indicators.filter(x => x.visible)
$: offset = $builderStore.inBuilder ? 0 : 2 $: offset = $builderStore.inBuilder ? 0 : 2
@ -23,6 +25,20 @@
let callbackCount = 0 let callbackCount = 0
let nextIndicators = [] let nextIndicators = []
const checkInsideGrid = id => {
const component = document.getElementsByClassName(id)[0]
const domNode = component?.children[0]
// Ignore grid itself
if (domNode?.classList.contains("grid")) {
return false
}
return component?.parentNode
?.closest?.(".component")
?.childNodes[0]?.classList.contains("grid")
}
const createIntersectionCallback = idx => entries => { const createIntersectionCallback = idx => entries => {
if (callbackCount >= observers.length) { if (callbackCount >= observers.length) {
return return
@ -52,6 +68,11 @@
observers = [] observers = []
nextIndicators = [] nextIndicators = []
// Check if we're inside a grid
if (allowResizeAnchors) {
insideGrid = checkInsideGrid(componentId)
}
// Determine next set of indicators // Determine next set of indicators
const parents = document.getElementsByClassName(componentId) const parents = document.getElementsByClassName(componentId)
if (parents.length) { if (parents.length) {
@ -127,6 +148,8 @@
height={indicator.height} height={indicator.height}
text={idx === 0 ? text : null} text={idx === 0 ? text : null}
icon={idx === 0 ? icon : null} icon={idx === 0 ? icon : null}
showResizeAnchors={allowResizeAnchors && insideGrid}
{componentId}
{transition} {transition}
{zIndex} {zIndex}
{color} {color}

View File

@ -1,5 +1,5 @@
<script> <script>
import { builderStore, dndIsDragging } from "stores" import { builderStore } from "stores"
import IndicatorSet from "./IndicatorSet.svelte" import IndicatorSet from "./IndicatorSet.svelte"
$: color = $builderStore.editMode $: color = $builderStore.editMode
@ -8,8 +8,9 @@
</script> </script>
<IndicatorSet <IndicatorSet
componentId={$dndIsDragging ? null : $builderStore.selectedComponentId} componentId={$builderStore.selectedComponentId}
{color} {color}
zIndex="910" zIndex="910"
transition transition
allowResizeAnchors
/> />

View File

@ -17,6 +17,11 @@
$: definition = $componentStore.selectedComponentDefinition $: definition = $componentStore.selectedComponentDefinition
$: showBar = definition?.showSettingsBar && !$dndIsDragging $: showBar = definition?.showSettingsBar && !$dndIsDragging
$: {
if (!showBar) {
measured = false
}
}
$: settings = getBarSettings(definition) $: settings = getBarSettings(definition)
const getBarSettings = definition => { const getBarSettings = definition => {

View File

@ -32,5 +32,4 @@ export const ActionTypes = {
} }
export const DNDPlaceholderID = "dnd-placeholder" export const DNDPlaceholderID = "dnd-placeholder"
export const DNDPlaceholderType = "dnd-placeholder"
export const ScreenslotType = "screenslot" export const ScreenslotType = "screenslot"

View File

@ -7,6 +7,7 @@ import {
componentStore, componentStore,
environmentStore, environmentStore,
dndStore, dndStore,
eventStore,
} from "./stores" } from "./stores"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js"
import { get } from "svelte/store" import { get } from "svelte/store"
@ -46,7 +47,9 @@ const loadBudibase = async () => {
appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"]) appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"])
// Fetch environment info // Fetch environment info
await environmentStore.actions.fetchEnvironment() if (!get(environmentStore)?.loaded) {
await environmentStore.actions.fetchEnvironment()
}
// Enable dev tools or not. We need to be using a dev app and not inside // Enable dev tools or not. We need to be using a dev app and not inside
// the builder preview to enable them. // the builder preview to enable them.
@ -54,15 +57,17 @@ const loadBudibase = async () => {
devToolsStore.actions.setEnabled(enableDevTools) devToolsStore.actions.setEnabled(enableDevTools)
// Register handler for runtime events from the builder // Register handler for runtime events from the builder
window.handleBuilderRuntimeEvent = (name, payload) => { window.handleBuilderRuntimeEvent = (type, data) => {
if (!window["##BUDIBASE_IN_BUILDER##"]) { if (!window["##BUDIBASE_IN_BUILDER##"]) {
return return
} }
if (name === "eject-block") { if (type === "event-completed") {
const block = blockStore.actions.getBlock(payload) eventStore.actions.resolveEvent(data)
} else if (type === "eject-block") {
const block = blockStore.actions.getBlock(data)
block?.eject() block?.eject()
} else if (name === "dragging-new-component") { } else if (type === "dragging-new-component") {
const { dragging, component } = payload const { dragging, component } = data
if (dragging) { if (dragging) {
const definition = const definition =
componentStore.actions.getComponentDefinition(component) componentStore.actions.getComponentDefinition(component)

View File

@ -1,10 +1,7 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { devToolsStore } from "./devTools.js" import { devToolsStore } from "./devTools.js"
import { eventStore } from "./events.js"
const dispatchEvent = (type, data = {}) => {
window.parent.postMessage({ type, data })
}
const createBuilderStore = () => { const createBuilderStore = () => {
const initialState = { const initialState = {
@ -19,6 +16,7 @@ const createBuilderStore = () => {
navigation: null, navigation: null,
hiddenComponentIds: [], hiddenComponentIds: [],
usedPlugins: null, usedPlugins: null,
eventResolvers: {},
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,
@ -35,22 +33,25 @@ const createBuilderStore = () => {
selectedComponentId: id, selectedComponentId: id,
})) }))
devToolsStore.actions.setAllowSelection(false) devToolsStore.actions.setAllowSelection(false)
dispatchEvent("select-component", { id }) eventStore.actions.dispatchEvent("select-component", { id })
}, },
updateProp: (prop, value) => { updateProp: (prop, value) => {
dispatchEvent("update-prop", { prop, value }) eventStore.actions.dispatchEvent("update-prop", { prop, value })
},
updateStyles: async (styles, id) => {
await eventStore.actions.dispatchEvent("update-styles", { styles, id })
}, },
keyDown: (key, ctrlKey) => { keyDown: (key, ctrlKey) => {
dispatchEvent("key-down", { key, ctrlKey }) eventStore.actions.dispatchEvent("key-down", { key, ctrlKey })
}, },
duplicateComponent: id => { duplicateComponent: id => {
dispatchEvent("duplicate-component", { id }) eventStore.actions.dispatchEvent("duplicate-component", { id })
}, },
deleteComponent: id => { deleteComponent: id => {
dispatchEvent("delete-component", { id }) eventStore.actions.dispatchEvent("delete-component", { id })
}, },
notifyLoaded: () => { notifyLoaded: () => {
dispatchEvent("preview-loaded") eventStore.actions.dispatchEvent("preview-loaded")
}, },
analyticsPing: async () => { analyticsPing: async () => {
try { try {
@ -59,15 +60,15 @@ const createBuilderStore = () => {
// Do nothing // Do nothing
} }
}, },
moveComponent: (componentId, destinationComponentId, mode) => { moveComponent: async (componentId, destinationComponentId, mode) => {
dispatchEvent("move-component", { await eventStore.actions.dispatchEvent("move-component", {
componentId, componentId,
destinationComponentId, destinationComponentId,
mode, mode,
}) })
}, },
dropNewComponent: (component, parent, index) => { dropNewComponent: (component, parent, index) => {
dispatchEvent("drop-new-component", { eventStore.actions.dispatchEvent("drop-new-component", {
component, component,
parent, parent,
index, index,
@ -80,16 +81,16 @@ const createBuilderStore = () => {
store.update(state => ({ ...state, editMode: enabled })) store.update(state => ({ ...state, editMode: enabled }))
}, },
clickNav: () => { clickNav: () => {
dispatchEvent("click-nav") eventStore.actions.dispatchEvent("click-nav")
}, },
requestAddComponent: () => { requestAddComponent: () => {
dispatchEvent("request-add-component") eventStore.actions.dispatchEvent("request-add-component")
}, },
highlightSetting: setting => { highlightSetting: setting => {
dispatchEvent("highlight-setting", { setting }) eventStore.actions.dispatchEvent("highlight-setting", { setting })
}, },
ejectBlock: (id, definition) => { ejectBlock: (id, definition) => {
dispatchEvent("eject-block", { id, definition }) eventStore.actions.dispatchEvent("eject-block", { id, definition })
}, },
updateUsedPlugin: (name, hash) => { updateUsedPlugin: (name, hash) => {
// Check if we used this plugin // Check if we used this plugin
@ -106,7 +107,7 @@ const createBuilderStore = () => {
} }
// Notify the builder so we can reload component definitions // Notify the builder so we can reload component definitions
dispatchEvent("reload-plugin") eventStore.actions.dispatchEvent("reload-plugin")
}, },
} }
return { return {

View File

@ -5,9 +5,8 @@ import { devToolsStore } from "./devTools"
import { screenStore } from "./screens" import { screenStore } from "./screens"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import Router from "../components/Router.svelte" import Router from "../components/Router.svelte"
import DNDPlaceholder from "../components/preview/DNDPlaceholder.svelte"
import * as AppComponents from "../components/app/index.js" import * as AppComponents from "../components/app/index.js"
import { DNDPlaceholderType, ScreenslotType } from "../constants.js" import { ScreenslotType } from "../constants.js"
const budibasePrefix = "@budibase/standard-components/" const budibasePrefix = "@budibase/standard-components/"
@ -49,6 +48,9 @@ const createComponentStore = () => {
) )
const registerInstance = (id, instance) => { const registerInstance = (id, instance) => {
if (!id) {
return
}
store.update(state => { store.update(state => {
// If this is a custom component, flag it so we can reload this component // If this is a custom component, flag it so we can reload this component
// later if required // later if required
@ -68,6 +70,9 @@ const createComponentStore = () => {
} }
const unregisterInstance = id => { const unregisterInstance = id => {
if (!id) {
return
}
store.update(state => { store.update(state => {
// Remove from custom component map if required // Remove from custom component map if required
const component = state.mountedComponents[id]?.instance?.component const component = state.mountedComponents[id]?.instance?.component
@ -103,8 +108,6 @@ const createComponentStore = () => {
// Screenslot is an edge case // Screenslot is an edge case
if (type === ScreenslotType) { if (type === ScreenslotType) {
type = `${budibasePrefix}${type}` type = `${budibasePrefix}${type}`
} else if (type === DNDPlaceholderType) {
return {}
} }
// Handle built-in components // Handle built-in components
@ -124,8 +127,6 @@ const createComponentStore = () => {
} }
if (type === ScreenslotType) { if (type === ScreenslotType) {
return Router return Router
} else if (type === DNDPlaceholderType) {
return DNDPlaceholder
} }
// Handle budibase components // Handle budibase components
@ -140,6 +141,13 @@ const createComponentStore = () => {
return customComponentManifest?.[type]?.Component return customComponentManifest?.[type]?.Component
} }
const getComponentInstance = id => {
if (!id) {
return null
}
return get(store).mountedComponents[id]
}
const registerCustomComponent = ({ Component, schema, version }) => { const registerCustomComponent = ({ Component, schema, version }) => {
if (!Component || !schema?.schema?.name || !version) { if (!Component || !schema?.schema?.name || !version) {
return return
@ -171,6 +179,7 @@ const createComponentStore = () => {
getComponentById, getComponentById,
getComponentDefinition, getComponentDefinition,
getComponentConstructor, getComponentConstructor,
getComponentInstance,
registerCustomComponent, registerCustomComponent,
}, },
} }

View File

@ -1,4 +1,5 @@
import { writable, derived } from "svelte/store" import { writable } from "svelte/store"
import { computed } from "../utils/computed.js"
const createDndStore = () => { const createDndStore = () => {
const initialState = { const initialState = {
@ -77,14 +78,11 @@ export const dndStore = createDndStore()
// performance by deriving any state that needs to be externally observed. // performance by deriving any state that needs to be externally observed.
// By doing this and using primitives, we can avoid invalidating other stores // By doing this and using primitives, we can avoid invalidating other stores
// or components which depend on DND state unless values actually change. // or components which depend on DND state unless values actually change.
export const dndIsDragging = derived(dndStore, $dndStore => !!$dndStore.source) export const dndParent = computed(dndStore, x => x.drop?.parent)
export const dndParent = derived(dndStore, $dndStore => $dndStore.drop?.parent) export const dndIndex = computed(dndStore, x => x.drop?.index)
export const dndIndex = derived(dndStore, $dndStore => $dndStore.drop?.index) export const dndBounds = computed(dndStore, x => x.source?.bounds)
export const dndBounds = derived( export const dndIsDragging = computed(dndStore, x => !!x.source)
export const dndIsNewComponent = computed(
dndStore, dndStore,
$dndStore => $dndStore.source?.bounds x => x.source?.newComponentType != null
)
export const dndIsNewComponent = derived(
dndStore,
$dndStore => $dndStore.source?.newComponentType != null
) )

View File

@ -2,6 +2,7 @@ import { API } from "api"
import { writable } from "svelte/store" import { writable } from "svelte/store"
const initialState = { const initialState = {
loaded: false,
cloud: false, cloud: false,
} }
@ -15,6 +16,7 @@ const createEnvironmentStore = () => {
store.set({ store.set({
...initialState, ...initialState,
...environment, ...environment,
loaded: true,
}) })
} catch (error) { } catch (error) {
store.set(initialState) store.set(initialState)

View File

@ -0,0 +1,31 @@
import { writable, get } from "svelte/store"
const createEventStore = () => {
const initialState = {
eventResolvers: {},
}
const store = writable(initialState)
const actions = {
dispatchEvent: (type, data) => {
const id = Math.random()
return new Promise(resolve => {
window.parent.postMessage({ type, data, id })
store.update(state => {
state.eventResolvers[id] = resolve
return state
})
})
},
resolveEvent: data => {
get(store).eventResolvers[data]?.()
},
}
return {
subscribe: store.subscribe,
actions,
}
}
export const eventStore = createEventStore()

View File

@ -15,6 +15,7 @@ export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js" export { rowSelectionStore } from "./rowSelection.js"
export { blockStore } from "./blocks.js" export { blockStore } from "./blocks.js"
export { environmentStore } from "./environment" export { environmentStore } from "./environment"
export { eventStore } from "./events.js"
export { export {
dndStore, dndStore,
dndIndex, dndIndex,

View File

@ -2,11 +2,11 @@ import { derived } from "svelte/store"
import { routeStore } from "./routes" import { routeStore } from "./routes"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import { appStore } from "./app" import { appStore } from "./app"
import { dndIndex, dndParent, dndIsNewComponent } from "./dnd.js" import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { findComponentById, findComponentParent } from "../utils/components.js" import { findComponentById, findComponentParent } from "../utils/components.js"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { DNDPlaceholderID, DNDPlaceholderType } from "constants" import { DNDPlaceholderID } from "constants"
const createScreenStore = () => { const createScreenStore = () => {
const store = derived( const store = derived(
@ -17,6 +17,7 @@ const createScreenStore = () => {
dndParent, dndParent,
dndIndex, dndIndex,
dndIsNewComponent, dndIsNewComponent,
dndBounds,
], ],
([ ([
$appStore, $appStore,
@ -25,6 +26,7 @@ const createScreenStore = () => {
$dndParent, $dndParent,
$dndIndex, $dndIndex,
$dndIsNewComponent, $dndIsNewComponent,
$dndBounds,
]) => { ]) => {
let activeLayout, activeScreen let activeLayout, activeScreen
let screens let screens
@ -62,32 +64,43 @@ const createScreenStore = () => {
// Insert DND placeholder if required // Insert DND placeholder if required
if (activeScreen && $dndParent && $dndIndex != null) { if (activeScreen && $dndParent && $dndIndex != null) {
const { selectedComponentId } = $builderStore
// Extract and save the selected component as we need a reference to it
// later, and we may be removing it
let selectedParent = findComponentParent(
activeScreen.props,
selectedComponentId
)
// Remove selected component from tree if we are moving an existing // Remove selected component from tree if we are moving an existing
// component // component
const { selectedComponentId } = $builderStore if (!$dndIsNewComponent && selectedParent) {
if (!$dndIsNewComponent) { selectedParent._children = selectedParent._children?.filter(
let selectedParent = findComponentParent( x => x._id !== selectedComponentId
activeScreen.props,
selectedComponentId
) )
if (selectedParent) {
selectedParent._children = selectedParent._children?.filter(
x => x._id !== selectedComponentId
)
}
} }
// Insert placeholder component // Insert placeholder component
const placeholder = { const componentToInsert = {
_component: DNDPlaceholderID, _component: "@budibase/standard-components/container",
_id: DNDPlaceholderType, _id: DNDPlaceholderID,
_styles: {
normal: {
width: `${$dndBounds?.width || 400}px`,
height: `${$dndBounds?.height || 200}px`,
opacity: 0,
},
},
static: true, static: true,
} }
let parent = findComponentById(activeScreen.props, $dndParent) let parent = findComponentById(activeScreen.props, $dndParent)
if (!parent._children?.length) { if (parent) {
parent._children = [placeholder] if (!parent._children?.length) {
} else { parent._children = [componentToInsert]
parent._children.splice($dndIndex, 0, placeholder) } else {
parent._children.splice($dndIndex, 0, componentToInsert)
}
} }
} }

View File

@ -0,0 +1,38 @@
import { writable } from "svelte/store"
/**
* Extension of Svelte's built in "derived" stores, which the addition of deep
* comparison of non-primitives. Falls back to using shallow comparison for
* primitive types to avoid performance penalties.
* Useful for instances where a deep comparison is cheaper than an additional
* store invalidation.
* @param store the store to observer
* @param deriveValue the derivation function
* @returns {Writable<*>} a derived svelte store containing just the derived value
*/
export const computed = (store, deriveValue) => {
const initialValue = deriveValue(store)
const computedStore = writable(initialValue)
let lastKey = getKey(initialValue)
store.subscribe(state => {
const value = deriveValue(state)
const key = getKey(value)
if (key !== lastKey) {
lastKey = key
computedStore.set(value)
}
})
return computedStore
}
// Helper function to serialise any value into a primitive which can be cheaply
// and shallowly compared
const getKey = value => {
if (value == null || typeof value !== "object") {
return value
} else {
return JSON.stringify(value)
}
}

View File

@ -671,9 +671,15 @@ has@^1.0.3:
function-bind "^1.1.1" function-bind "^1.1.1"
html5-qrcode@^2.2.1: html5-qrcode@^2.2.1:
<<<<<<< HEAD
version "2.2.4" version "2.2.4"
resolved "https://registry.yarnpkg.com/html5-qrcode/-/html5-qrcode-2.2.4.tgz#99e4b36fbd8fbc4956036cf3f21ea3e98c3463d1" resolved "https://registry.yarnpkg.com/html5-qrcode/-/html5-qrcode-2.2.4.tgz#99e4b36fbd8fbc4956036cf3f21ea3e98c3463d1"
integrity sha512-X8wVVsHpNb35tl7KcoCGAboc6Nep2VyT3CIMjFvrfWrHbHTC0yYTjE+DhO/VcswY2MfHy1uB7b1G9+L13gM6dQ== integrity sha512-X8wVVsHpNb35tl7KcoCGAboc6Nep2VyT3CIMjFvrfWrHbHTC0yYTjE+DhO/VcswY2MfHy1uB7b1G9+L13gM6dQ==
=======
version "2.2.3"
resolved "https://registry.yarnpkg.com/html5-qrcode/-/html5-qrcode-2.2.3.tgz#5acb826860365e7c7ab91e1e14528ea16a502e8a"
integrity sha512-9CtEz5FVT56T76entiQxyrASzBWl8Rm30NHiQH8T163Eml5LS14BoZlYel9igxbikOt7O8KhvrT3awN1Y2HMqw==
>>>>>>> 50f0a0509d79dcee7a0f12608054279d65662b10
htmlparser2@^6.0.0: htmlparser2@^6.0.0:
version "6.1.0" version "6.1.0"

View File

@ -1,12 +1,12 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "2.0.30-alpha.13", "version": "2.0.34-alpha.3",
"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": "2.0.30-alpha.13", "@budibase/bbui": "2.0.34-alpha.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"svelte": "^3.46.2" "svelte": "^3.46.2"
} }

View File

@ -0,0 +1,50 @@
export const buildBackupsEndpoints = API => ({
/**
* Gets a list of users in the current tenant.
*/
searchBackups: async ({ appId, trigger, type, page, startDate, endDate }) => {
const opts = {}
if (page) {
opts.page = page
}
if (trigger && type) {
opts.trigger = trigger.toLowerCase()
opts.type = type.toLowerCase()
}
if (startDate && endDate) {
opts.startDate = startDate
opts.endDate = endDate
}
return await API.post({
url: `/api/apps/${appId}/backups/search`,
body: opts,
})
},
createManualBackup: async ({ appId, name }) => {
return await API.post({
url: `/api/apps/${appId}/backups`,
body: { name },
})
},
deleteBackup: async ({ appId, backupId }) => {
return await API.delete({
url: `/api/apps/${appId}/backups/${backupId}`,
})
},
updateBackup: async ({ appId, backupId, name }) => {
return await API.patch({
url: `/api/apps/${appId}/backups/${backupId}`,
body: { name },
})
},
restoreBackup: async ({ appId, backupId, name }) => {
return await API.post({
url: `/api/apps/${appId}/backups/${backupId}/import`,
body: { name },
})
},
})

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