This commit is contained in:
Martin McKeaveney 2024-06-04 12:43:34 +01:00
commit ec892a13b6
322 changed files with 9992 additions and 4220 deletions

View File

@ -55,7 +55,9 @@
}
],
"no-redeclare": "off",
"@typescript-eslint/no-redeclare": "error"
"@typescript-eslint/no-redeclare": "error",
// have to turn this off to allow function overloading in typescript
"no-dupe-class-members": "off"
}
},
{
@ -88,7 +90,9 @@
"jest/expect-expect": "off",
// We do this in some tests where the behaviour of internal tables
// differs to external, but the API is broadly the same
"jest/no-conditional-expect": "off"
"jest/no-conditional-expect": "off",
// have to turn this off to allow function overloading in typescript
"no-dupe-class-members": "off"
}
},
{

View File

@ -9,7 +9,7 @@ on:
jobs:
ensure-is-master-tag:
name: Ensure is a master tag
runs-on: qa-arc-runner-set
runs-on: ubuntu-latest
steps:
- name: Checkout monorepo
uses: actions/checkout@v4

View File

@ -17,6 +17,6 @@ version: 0.0.0
appVersion: 0.0.0
dependencies:
- name: couchdb
version: 4.3.0
version: 4.5.3
repository: https://apache.github.io/couchdb-helm
condition: services.couchdb.enabled

View File

@ -29,6 +29,7 @@ services:
BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL}
BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD}
PLUGINS_DIR: ${PLUGINS_DIR}
SQS_SEARCH_ENABLE: 1
depends_on:
- worker-service
- redis-service
@ -56,6 +57,7 @@ services:
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
SQS_SEARCH_ENABLE: 1
depends_on:
- redis-service
- minio-service

View File

@ -42,12 +42,13 @@ services:
couchdb-service:
container_name: budi-couchdb3-dev
restart: on-failure
image: budibase/couchdb
image: budibase/couchdb:v3.2.1-sqs
environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER}
ports:
- "${COUCH_DB_PORT}:5984"
- "${COUCH_DB_SQS_PORT}:4984"
volumes:
- couchdb_data:/data

View File

@ -61,7 +61,7 @@ http {
set $csp_img "img-src http: https: data: blob:";
set $csp_manifest "manifest-src 'self'";
set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live";
set $csp_worker "worker-src 'none'";
set $csp_worker "worker-src blob:";
error_page 502 503 504 /error.html;
location = /error.html {

View File

@ -1,3 +1,4 @@
ARG BASEIMG=budibase/couchdb:v3.3.3
FROM node:20-slim as build
# install node-gyp dependencies
@ -32,7 +33,7 @@ COPY packages/worker/dist packages/worker/dist
COPY packages/worker/pm2.config.js packages/worker/pm2.config.js
FROM budibase/couchdb:v3.3.3 as runner
FROM $BASEIMG as runner
ARG TARGETARCH
ENV TARGETARCH $TARGETARCH
#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service)

View File

@ -53,6 +53,11 @@ done
if [[ -z "${COUCH_DB_URL}" ]]; then
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@127.0.0.1:5984
fi
if [[ -z "${COUCH_DB_SQL_URL}" ]]; then
export COUCH_DB_SQL_URL=http://127.0.0.1:4984
fi
if [ ! -f "${DATA_DIR}/.env" ]; then
touch ${DATA_DIR}/.env
for ENV_VAR in "${ENV_VARS[@]}"

View File

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

View File

@ -35,10 +35,12 @@
"get-past-client-version": "node scripts/getPastClientVersion.js",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
"build:apps": "yarn build --scope @budibase/server --scope @budibase/worker",
"build:cli": "yarn build --scope @budibase/cli",
"build:oss": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui",
"build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal --scope @budibase/account-portal-server --scope @budibase/account-portal-ui",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types",
"check:types": "lerna run --concurrency 2 check:types",
"build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
@ -73,9 +75,10 @@
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
"build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single": "./scripts/build-single-image.sh",
"build:docker:single:sqs": "./scripts/build-single-image-sqs.sh",
"build:docker:dependencies": "docker build -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest ./hosting",
"publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.2.1 --push ./hosting/couchdb",
"publish:docker:couch-sqs": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile.v2 -t budibase/couchdb:v3.2.1-sqs --push ./hosting/couchdb",
"publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.3.3 --push ./hosting/couchdb",
"publish:docker:couch-sqs": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile.v2 -t budibase/couchdb:v3.3.3-sqs --push ./hosting/couchdb",
"publish:docker:dependencies": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest -t budibase/dependencies:v3.2.1 --push ./hosting",
"release:helm": "node scripts/releaseHelmChart",
"env:multi:enable": "lerna run --stream env:multi:enable",

@ -1 +1 @@
Subproject commit e8136bd1ea9fa4c61a4bcbeda482abea0b6c3d9f
Subproject commit a03225549e3ce61f43d0da878da162e08941b939

View File

@ -54,7 +54,8 @@
"sanitize-s3-objectkey": "0.0.1",
"semver": "^7.5.4",
"tar-fs": "2.1.1",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"knex": "2.4.2"
},
"devDependencies": {
"@shopify/jest-koa-mocks": "5.1.1",

View File

@ -65,5 +65,11 @@ export const StaticDatabases = {
export const APP_PREFIX = prefixed(DocumentType.APP)
export const APP_DEV = prefixed(DocumentType.APP_DEV)
export const APP_DEV_PREFIX = APP_DEV
export const SQS_DATASOURCE_INTERNAL = "internal"
export const BUDIBASE_DATASOURCE_TYPE = "budibase"
export const SQLITE_DESIGN_DOC_ID = "_design/sqlite"
export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs"
export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory"
export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses"
export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee"
export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default"

View File

@ -1,4 +1,6 @@
import { IdentityContext, Snippet, VM } from "@budibase/types"
import { OAuth2Client } from "google-auth-library"
import { GoogleSpreadsheet } from "google-spreadsheet"
// keep this out of Budibase types, don't want to expose context info
export type ContextMap = {
@ -12,4 +14,8 @@ export type ContextMap = {
vm?: VM
cleanup?: (() => void | Promise<void>)[]
snippets?: Snippet[]
googleSheets?: {
oauthClient: OAuth2Client
clients: Record<string, GoogleSpreadsheet>
}
}

View File

@ -1,14 +1,31 @@
import PouchDB from "pouchdb"
import { getPouchDB, closePouchDB } from "./couch"
import { DocumentType } from "../constants"
import { DocumentType } from "@budibase/types"
enum ReplicationDirection {
TO_PRODUCTION = "toProduction",
TO_DEV = "toDev",
}
class Replication {
source: PouchDB.Database
target: PouchDB.Database
direction: ReplicationDirection | undefined
constructor({ source, target }: { source: string; target: string }) {
this.source = getPouchDB(source)
this.target = getPouchDB(target)
if (
source.startsWith(DocumentType.APP_DEV) &&
target.startsWith(DocumentType.APP)
) {
this.direction = ReplicationDirection.TO_PRODUCTION
} else if (
source.startsWith(DocumentType.APP) &&
target.startsWith(DocumentType.APP_DEV)
) {
this.direction = ReplicationDirection.TO_DEV
}
}
async close() {
@ -40,12 +57,18 @@ class Replication {
}
const filter = opts.filter
const direction = this.direction
const toDev = direction === ReplicationDirection.TO_DEV
delete opts.filter
return {
...opts,
filter: (doc: any, params: any) => {
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
// don't sync design documents
if (toDev && doc._id?.startsWith("_design")) {
return false
}
if (doc._id?.startsWith(DocumentType.AUTOMATION_LOG)) {
return false
}
if (doc._id === DocumentType.APP_METADATA) {

View File

@ -12,6 +12,7 @@ import {
isDocument,
RowResponse,
RowValue,
SQLiteDefinition,
SqlQueryBinding,
} from "@budibase/types"
import { getCouchInfo } from "./connections"
@ -21,6 +22,8 @@ import { ReadStream, WriteStream } from "fs"
import { newid } from "../../docIds/newid"
import { SQLITE_DESIGN_DOC_ID } from "../../constants"
import { DDInstrumentedDatabase } from "../instrumentation"
import { checkSlashesInUrl } from "../../helpers"
import env from "../../environment"
const DATABASE_NOT_FOUND = "Database does not exist."
@ -281,25 +284,61 @@ export class DatabaseImpl implements Database {
})
}
async _sqlQuery<T>(
url: string,
method: "POST" | "GET",
body?: Record<string, any>
): Promise<T> {
url = checkSlashesInUrl(`${this.couchInfo.sqlUrl}/${url}`)
const args: { url: string; method: string; cookie: string; body?: any } = {
url,
method,
cookie: this.couchInfo.cookie,
}
if (body) {
args.body = body
}
return this.performCall(() => {
return async () => {
const response = await directCouchUrlCall(args)
const json = await response.json()
if (response.status > 300) {
throw json
}
return json as T
}
})
}
async sql<T extends Document>(
sql: string,
parameters?: SqlQueryBinding
): Promise<T[]> {
const dbName = this.name
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
const response = await directCouchUrlCall({
url: `${this.couchInfo.sqlUrl}/${url}`,
method: "POST",
cookie: this.couchInfo.cookie,
body: {
query: sql,
args: parameters,
},
return await this._sqlQuery<T[]>(url, "POST", {
query: sql,
args: parameters,
})
if (response.status > 300) {
throw new Error(await response.text())
}
// checks design document is accurate (cleans up tables)
// this will check the design document and remove anything from
// disk which is not supposed to be there
async sqlDiskCleanup(): Promise<void> {
const dbName = this.name
const url = `/${dbName}/_cleanup`
return await this._sqlQuery<void>(url, "POST")
}
// removes a document from sqlite
async sqlPurgeDocument(docIds: string[] | string): Promise<void> {
if (!Array.isArray(docIds)) {
docIds = [docIds]
}
return (await response.json()) as T[]
const dbName = this.name
const url = `/${dbName}/_purge`
return await this._sqlQuery<void>(url, "POST", { docs: docIds })
}
async query<T extends Document>(
@ -314,6 +353,17 @@ export class DatabaseImpl implements Database {
async destroy() {
try {
if (env.SQS_SEARCH_ENABLE) {
// delete the design document, then run the cleanup operation
try {
const definition = await this.get<SQLiteDefinition>(
SQLITE_DESIGN_DOC_ID
)
await this.remove(SQLITE_DESIGN_DOC_ID, definition._rev)
} finally {
await this.sqlDiskCleanup()
}
}
return await this.nano().db.destroy(this.name)
} catch (err: any) {
// didn't exist, don't worry

View File

@ -21,7 +21,7 @@ export async function directCouchUrlCall({
url: string
cookie: string
method: string
body?: any
body?: Record<string, any>
}) {
const params: any = {
method: method,

View File

@ -56,12 +56,17 @@ export class DDInstrumentedDatabase implements Database {
})
}
remove(idOrDoc: Document): Promise<DocumentDestroyResponse>
remove(idOrDoc: string, rev?: string): Promise<DocumentDestroyResponse>
remove(
id: string | Document,
rev?: string | undefined
idOrDoc: string | Document,
rev?: string
): Promise<DocumentDestroyResponse> {
return tracer.trace("db.remove", span => {
span?.addTags({ db_name: this.name, doc_id: id })
span?.addTags({ db_name: this.name, doc_id: idOrDoc })
const isDocument = typeof idOrDoc === "object"
const id = isDocument ? idOrDoc._id! : idOrDoc
rev = isDocument ? idOrDoc._rev : rev
return this.db.remove(id, rev)
})
}
@ -160,4 +165,18 @@ export class DDInstrumentedDatabase implements Database {
return this.db.sql(sql, parameters)
})
}
sqlPurgeDocument(docIds: string[] | string): Promise<void> {
return tracer.trace("db.sqlPurgeDocument", span => {
span?.addTags({ db_name: this.name })
return this.db.sqlPurgeDocument(docIds)
})
}
sqlDiskCleanup(): Promise<void> {
return tracer.trace("db.sqlDiskCleanup", span => {
span?.addTags({ db_name: this.name })
return this.db.sqlDiskCleanup()
})
}
}

View File

@ -18,6 +18,14 @@ export const generateAppID = (tenantId?: string | null) => {
return `${id}${newid()}`
}
/**
* Generates a new table ID.
* @returns The new table ID which the table doc can be stored under.
*/
export function generateTableID() {
return `${DocumentType.TABLE}${SEPARATOR}${newid()}`
}
/**
* Gets a new row ID for the specified table.
* @param tableId The table which the row is being created for.

View File

@ -109,6 +109,7 @@ const environment = {
API_ENCRYPTION_KEY: getAPIEncryptionKey(),
COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005",
COUCH_DB_SQL_URL: process.env.COUCH_DB_SQL_URL || "http://localhost:4006",
SQS_SEARCH_ENABLE: process.env.SQS_SEARCH_ENABLE,
COUCH_DB_USERNAME: process.env.COUCH_DB_USER,
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
@ -158,6 +159,9 @@ const environment = {
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
HTTP_LOGGING: httpLogging(),
ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR,
// Couch/search
SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE,
SQL_MAX_ROWS: process.env.SQL_MAX_ROWS,
// smtp
SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED,
SMTP_USER: process.env.SMTP_USER,

View File

@ -34,6 +34,7 @@ export * as docUpdates from "./docUpdates"
export * from "./utils/Duration"
export * as docIds from "./docIds"
export * as security from "./security"
export * as sql from "./sql"
// Add context to tenancy for backwards compatibility
// only do this for external usages to prevent internal
// circular dependencies

View File

@ -14,6 +14,7 @@ import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3"
import { ReadableStream } from "stream/web"
const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created
@ -41,10 +42,7 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike
}
export type StreamTypes =
| ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamTypes = ReadStream | NodeJS.ReadableStream
export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
@ -222,6 +220,9 @@ export async function streamUpload({
extra,
ttl,
}: StreamUploadParams) {
if (!stream) {
throw new Error("Stream to upload is invalid/undefined")
}
const extension = filename.split(".").pop()
const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
@ -251,14 +252,27 @@ export async function streamUpload({
: CONTENT_TYPE_MAP.txt
}
const bucket = sanitizeBucket(bucketName),
objKey = sanitizeKey(filename)
const params = {
Bucket: sanitizeBucket(bucketName),
Key: sanitizeKey(filename),
Bucket: bucket,
Key: objKey,
Body: stream,
ContentType: contentType,
...extra,
}
return objectStore.upload(params).promise()
const details = await objectStore.upload(params).promise()
const headDetails = await objectStore
.headObject({
Bucket: bucket,
Key: objKey,
})
.promise()
return {
...details,
ContentLength: headDetails.ContentLength,
}
}
/**

View File

@ -0,0 +1,17 @@
import { PreSaveSQLiteDefinition } from "@budibase/types"
import { SQLITE_DESIGN_DOC_ID } from "../constants"
// the table id property defines which property in the document
// to use when splitting the documents into different sqlite tables
export function base(tableIdProp: string): PreSaveSQLiteDefinition {
return {
_id: SQLITE_DESIGN_DOC_ID,
language: "sqlite",
sql: {
tables: {},
options: {
table_name: tableIdProp,
},
},
}
}

View File

@ -0,0 +1,5 @@
export * as utils from "./utils"
export { default as Sql } from "./sql"
export { default as SqlTable } from "./sqlTable"
export * as designDoc from "./designDoc"

View File

@ -1,13 +1,12 @@
import { Knex, knex } from "knex"
import { db as dbCore } from "@budibase/backend-core"
import { QueryOptions } from "../../definitions/datasource"
import * as dbCore from "../db"
import {
isIsoDateString,
SqlClient,
isValidFilter,
getNativeSql,
SqlStatements,
} from "../utils"
isExternalTable,
} from "./utils"
import { SqlStatements } from "./sqlStatements"
import SqlTableQueryBuilder from "./sqlTable"
import {
BBReferenceFieldMetadata,
@ -24,8 +23,12 @@ import {
Table,
TableSourceType,
INTERNAL_TABLE_SOURCE_ID,
SqlClient,
QueryOptions,
JsonTypes,
prefixed,
} from "@budibase/types"
import environment from "../../environment"
import environment from "../environment"
import { helpers } from "@budibase/shared-core"
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
@ -45,6 +48,7 @@ function likeKey(client: string, key: string): string {
case SqlClient.MY_SQL:
start = end = "`"
break
case SqlClient.SQL_LITE:
case SqlClient.ORACLE:
case SqlClient.POSTGRES:
start = end = '"'
@ -53,9 +57,6 @@ function likeKey(client: string, key: string): string {
start = "["
end = "]"
break
case SqlClient.SQL_LITE:
start = end = "'"
break
default:
throw new Error("Unknown client generating like key")
}
@ -122,11 +123,8 @@ function generateSelectStatement(
const fieldNames = field.split(/\./g)
const tableName = fieldNames[0]
const columnName = fieldNames[1]
if (
columnName &&
schema?.[columnName] &&
knex.client.config.client === SqlClient.POSTGRES
) {
const columnSchema = schema?.[columnName]
if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
const externalType = schema[columnName].externalType
if (externalType?.includes("money")) {
return knex.raw(
@ -134,6 +132,14 @@ function generateSelectStatement(
)
}
}
if (
knex.client.config.client === SqlClient.MS_SQL &&
columnSchema?.type === FieldType.DATETIME &&
columnSchema.timeOnly
) {
// Time gets returned as timestamp from mssql, not matching the expected HH:mm format
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
}
return `${field} as ${field}`
})
}
@ -202,17 +208,20 @@ class InternalBuilder {
const updatedKey = dbCore.removeKeyNumbering(key)
const isRelationshipField = updatedKey.includes(".")
if (!opts.relationship && !isRelationshipField) {
fn(`${getTableAlias(tableName)}.${updatedKey}`, value)
const alias = getTableAlias(tableName)
fn(alias ? `${alias}.${updatedKey}` : updatedKey, value)
}
if (opts.relationship && isRelationshipField) {
const [filterTableName, property] = updatedKey.split(".")
fn(`${getTableAlias(filterTableName)}.${property}`, value)
const alias = getTableAlias(filterTableName)
fn(alias ? `${alias}.${property}` : property, value)
}
}
}
const like = (key: string, value: any) => {
const fnc = allOr ? "orWhere" : "where"
const fuzzyOr = filters?.fuzzyOr
const fnc = fuzzyOr || allOr ? "orWhere" : "where"
// postgres supports ilike, nothing else does
if (this.client === SqlClient.POSTGRES) {
query = query[fnc](key, "ilike", `%${value}%`)
@ -226,8 +235,7 @@ class InternalBuilder {
}
const contains = (mode: object, any: boolean = false) => {
const fnc = allOr ? "orWhere" : "where"
const rawFnc = `${fnc}Raw`
const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
const not = mode === filters?.notContains ? "NOT " : ""
function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
for (let i in value) {
@ -240,24 +248,24 @@ class InternalBuilder {
if (this.client === SqlClient.POSTGRES) {
iterate(mode, (key: string, value: Array<any>) => {
const wrap = any ? "" : "'"
const containsOp = any ? "\\?| array" : "@>"
const op = any ? "\\?| array" : "@>"
const fieldNames = key.split(/\./g)
const tableName = fieldNames[0]
const columnName = fieldNames[1]
// @ts-ignore
const table = fieldNames[0]
const col = fieldNames[1]
query = query[rawFnc](
`${not}"${tableName}"."${columnName}"::jsonb ${containsOp} ${wrap}${stringifyArray(
`${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
value,
any ? "'" : '"'
)}${wrap}`
)}${wrap}, FALSE)`
)
})
} else if (this.client === SqlClient.MY_SQL) {
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
iterate(mode, (key: string, value: Array<any>) => {
// @ts-ignore
query = query[rawFnc](
`${not}${jsonFnc}(${key}, '${stringifyArray(value)}')`
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
value
)}'), FALSE)`
)
})
} else {
@ -272,7 +280,7 @@ class InternalBuilder {
}
statement +=
(statement ? andOr : "") +
`LOWER(${likeKey(this.client, key)}) LIKE ?`
`COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
}
if (statement === "") {
@ -337,14 +345,34 @@ class InternalBuilder {
}
if (filters.equal) {
iterate(filters.equal, (key, value) => {
const fnc = allOr ? "orWhere" : "where"
query = query[fnc]({ [key]: value })
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 1`,
[value]
)
} else {
query = query[fnc](
`COALESCE(${likeKey(this.client, key)} = ?, FALSE)`,
[value]
)
}
})
}
if (filters.notEqual) {
iterate(filters.notEqual, (key, value) => {
const fnc = allOr ? "orWhereNot" : "whereNot"
query = query[fnc]({ [key]: value })
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) {
query = query[fnc](
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 0`,
[value]
)
} else {
query = query[fnc](
`COALESCE(${likeKey(this.client, key)} != ?, TRUE)`,
[value]
)
}
})
}
if (filters.empty) {
@ -369,6 +397,16 @@ class InternalBuilder {
contains(filters.containsAny, true)
}
// when searching internal tables make sure long looking for rows
if (filters.documentType && !isExternalTable(table)) {
const tableRef = opts?.aliases?.[table._id!] || table._id
// has to be its own option, must always be AND onto the search
query.andWhereLike(
`${tableRef}._id`,
`${prefixed(filters.documentType)}%`
)
}
return query
}
@ -383,7 +421,13 @@ class InternalBuilder {
for (let [key, value] of Object.entries(sort)) {
const direction =
value.direction === SortDirection.ASCENDING ? "asc" : "desc"
query = query.orderBy(`${aliased}.${key}`, direction)
let nulls
if (this.client === SqlClient.POSTGRES) {
// All other clients already sort this as expected by default, and adding this to the rest of the clients is causing issues
nulls = value.direction === SortDirection.ASCENDING ? "first" : "last"
}
query = query.orderBy(`${aliased}.${key}`, direction, nulls)
}
} else if (this.client === SqlClient.MS_SQL && paginate?.limit) {
// @ts-ignore
@ -564,6 +608,7 @@ class InternalBuilder {
query = this.addFilters(query, filters, json.meta.table, {
aliases: tableAliases,
})
// add sorting to pre-query
query = this.addSorting(query, json)
const alias = tableAliases?.[tableName] || tableName
@ -634,12 +679,13 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
*/
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
const sqlClient = this.getSqlClient()
const config: { client: string; useNullAsDefault?: boolean } = {
const config: Knex.Config = {
client: sqlClient,
}
if (sqlClient === SqlClient.SQL_LITE) {
config.useNullAsDefault = true
}
const client = knex(config)
let query: Knex.QueryBuilder
const builder = new InternalBuilder(sqlClient)
@ -757,11 +803,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
return results.length ? results : [{ [operation.toLowerCase()]: true }]
}
convertJsonStringColumns(
convertJsonStringColumns<T extends Record<string, any>>(
table: Table,
results: Record<string, any>[],
results: T[],
aliases?: Record<string, string>
): Record<string, any>[] {
): T[] {
const tableName = getTableName(table)
for (const [name, field] of Object.entries(table.schema)) {
if (!this._isJsonColumn(field)) {
@ -770,11 +816,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
const aliasedTableName = (tableName && aliases?.[tableName]) || tableName
const fullName = `${aliasedTableName}.${name}`
for (let row of results) {
if (typeof row[fullName] === "string") {
row[fullName] = JSON.parse(row[fullName])
if (typeof row[fullName as keyof T] === "string") {
row[fullName as keyof T] = JSON.parse(row[fullName])
}
if (typeof row[name] === "string") {
row[name] = JSON.parse(row[name])
if (typeof row[name as keyof T] === "string") {
row[name as keyof T] = JSON.parse(row[name])
}
}
}
@ -785,9 +831,8 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
field: FieldSchema
): field is JsonFieldMetadata | BBReferenceFieldMetadata {
return (
field.type === FieldType.JSON ||
(field.type === FieldType.BB_REFERENCE &&
!helpers.schema.isDeprecatedSingleUserColumn(field))
JsonTypes.includes(field.type) &&
!helpers.schema.isDeprecatedSingleUserColumn(field)
)
}

View File

@ -0,0 +1,79 @@
import { FieldType, Table, FieldSchema, SqlClient } from "@budibase/types"
import { Knex } from "knex"
export class SqlStatements {
client: string
table: Table
allOr: boolean | undefined
constructor(
client: string,
table: Table,
{ allOr }: { allOr?: boolean } = {}
) {
this.client = client
this.table = table
this.allOr = allOr
}
getField(key: string): FieldSchema | undefined {
const fieldName = key.split(".")[1]
return this.table.schema[fieldName]
}
between(
query: Knex.QueryBuilder,
key: string,
low: number | string,
high: number | string
) {
// Use a between operator if we have 2 valid range values
const field = this.getField(key)
if (
field?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(
`CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`,
[low, high]
)
} else {
const fnc = this.allOr ? "orWhereBetween" : "whereBetween"
query = query[fnc](key, [low, high])
}
return query
}
lte(query: Knex.QueryBuilder, key: string, low: number | string) {
// Use just a single greater than operator if we only have a low
const field = this.getField(key)
if (
field?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`, [
low,
])
} else {
const fnc = this.allOr ? "orWhere" : "where"
query = query[fnc](key, ">=", low)
}
return query
}
gte(query: Knex.QueryBuilder, key: string, high: number | string) {
const field = this.getField(key)
// Use just a single less than operator if we only have a high
if (
field?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
query = query.whereRaw(`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`, [
high,
])
} else {
const fnc = this.allOr ? "orWhere" : "where"
query = query[fnc](key, "<=", high)
}
return query
}
}

View File

@ -9,8 +9,9 @@ import {
SqlQuery,
Table,
TableSourceType,
SqlClient,
} from "@budibase/types"
import { breakExternalTableId, getNativeSql, SqlClient } from "../utils"
import { breakExternalTableId, getNativeSql } from "./utils"
import { helpers, utils } from "@budibase/shared-core"
import SchemaBuilder = Knex.SchemaBuilder
import CreateTableBuilder = Knex.CreateTableBuilder
@ -79,9 +80,13 @@ function generateSchema(
schema.boolean(key)
break
case FieldType.DATETIME:
schema.datetime(key, {
useTz: !column.ignoreTimezones,
})
if (!column.timeOnly) {
schema.datetime(key, {
useTz: !column.ignoreTimezones,
})
} else {
schema.time(key)
}
break
case FieldType.ARRAY:
case FieldType.BB_REFERENCE:
@ -125,6 +130,7 @@ function generateSchema(
break
case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE:
case FieldType.SIGNATURE_SINGLE:
case FieldType.AUTO:
case FieldType.JSON:
case FieldType.INTERNAL:

View File

@ -0,0 +1,134 @@
import { DocumentType, SqlQuery, Table, TableSourceType } from "@budibase/types"
import { DEFAULT_BB_DATASOURCE_ID } from "../constants"
import { Knex } from "knex"
import { SEPARATOR } from "../db"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ")
export function isExternalTableID(tableId: string) {
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
}
export function isInternalTableID(tableId: string) {
return !isExternalTableID(tableId)
}
export function getNativeSql(
query: Knex.SchemaBuilder | Knex.QueryBuilder
): SqlQuery | SqlQuery[] {
let sql = query.toSQL()
if (Array.isArray(sql)) {
return sql as SqlQuery[]
}
let native: Knex.SqlNative | undefined
if (sql.toNative) {
native = sql.toNative()
}
return {
sql: native?.sql || sql.sql,
bindings: native?.bindings || sql.bindings,
} as SqlQuery
}
export function isExternalTable(table: Table) {
if (
table?.sourceId &&
table.sourceId.includes(DocumentType.DATASOURCE + SEPARATOR) &&
table?.sourceId !== DEFAULT_BB_DATASOURCE_ID
) {
return true
} else if (table?.sourceType === TableSourceType.EXTERNAL) {
return true
} else if (table?._id && isExternalTableID(table._id)) {
return true
}
return false
}
export function buildExternalTableId(datasourceId: string, tableName: string) {
// encode spaces
if (tableName.includes(" ")) {
tableName = encodeURIComponent(tableName)
}
return `${datasourceId}${DOUBLE_SEPARATOR}${tableName}`
}
export function breakExternalTableId(tableId: string | undefined) {
if (!tableId) {
return {}
}
const parts = tableId.split(DOUBLE_SEPARATOR)
let datasourceId = parts.shift()
// if they need joined
let tableName = parts.join(DOUBLE_SEPARATOR)
// if contains encoded spaces, decode it
if (tableName.includes(ENCODED_SPACE)) {
tableName = decodeURIComponent(tableName)
}
return { datasourceId, tableName }
}
export function generateRowIdField(keyProps: any[] = []) {
if (!Array.isArray(keyProps)) {
keyProps = [keyProps]
}
for (let index in keyProps) {
if (keyProps[index] instanceof Buffer) {
keyProps[index] = keyProps[index].toString()
}
}
// this conserves order and types
// we have to swap the double quotes to single quotes for use in HBS statements
// when using the literal helper the double quotes can break things
return encodeURIComponent(JSON.stringify(keyProps).replace(/"/g, "'"))
}
export function isRowId(field: any) {
return (
Array.isArray(field) ||
(typeof field === "string" && field.match(ROW_ID_REGEX) != null)
)
}
export function convertRowId(field: any) {
if (Array.isArray(field)) {
return field[0]
}
if (typeof field === "string" && field.match(ROW_ID_REGEX) != null) {
return field.substring(1, field.length - 1)
}
return field
}
// should always return an array
export function breakRowIdField(_id: string | { _id: string }): any[] {
if (!_id) {
return []
}
// have to replace on the way back as we swapped out the double quotes
// when encoding, but JSON can't handle the single quotes
const id = typeof _id === "string" ? _id : _id._id
const decoded: string = decodeURIComponent(id).replace(/'/g, '"')
try {
const parsed = JSON.parse(decoded)
return Array.isArray(parsed) ? parsed : [parsed]
} catch (err) {
// wasn't json - likely was handlebars for a many to many
return [_id]
}
}
export function isIsoDateString(str: string) {
const trimmedValue = str.trim()
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/.test(trimmedValue)) {
return false
}
let d = new Date(trimmedValue)
return d.toISOString() === trimmedValue
}
export function isValidFilter(value: any) {
return value != null && value !== ""
}

View File

@ -492,7 +492,7 @@ export class UserDB {
await platform.users.removeUser(dbUser)
await db.remove(userId, dbUser._rev)
await db.remove(userId, dbUser._rev!)
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
await UserDB.quotas.removeUsers(1, creatorsToDelete)

View File

@ -106,6 +106,10 @@ export const useViewPermissions = () => {
return useFeature(Feature.VIEW_PERMISSIONS)
}
export const useViewReadonlyColumns = () => {
return useFeature(Feature.VIEW_READONLY_COLUMNS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {

View File

@ -57,6 +57,7 @@
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
class:disabled
{disabled}
on:longPress
on:click|preventDefault
@ -109,19 +110,22 @@
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-500);
}
.noPadding {
padding: 0;
min-width: 0;
}
.spectrum-ActionButton--quiet {
padding: 0 8px;
}
.spectrum-ActionButton--quiet.is-selected {
color: var(--spectrum-global-color-gray-900);
}
.noPadding {
padding: 0;
min-width: 0;
}
.is-selected:not(.emphasized) .spectrum-Icon {
color: var(--spectrum-global-color-gray-900);
}
.is-selected.disabled .spectrum-Icon {
color: var(--spectrum-global-color-gray-500);
}
.tooltip {
position: absolute;
pointer-events: none;

View File

@ -71,8 +71,8 @@ const handleMouseDown = e => {
// Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener
document.removeEventListener("mouseup", handleMouseUp)
document.addEventListener("mouseup", handleMouseUp, true)
document.removeEventListener("click", handleMouseUp)
document.addEventListener("click", handleMouseUp, true)
}
// Global singleton listeners for our events

View File

@ -155,6 +155,8 @@ export default function positionDropdown(element, opts) {
applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") {
applyXStrategy(Strategies.EndToStart)
} else if (align === "center") {
applyXStrategy(Strategies.MidPoint)
} else {
applyXStrategy(Strategies.StartToStart)
}

View File

@ -4,13 +4,14 @@
export let max
export let hideArrows = false
export let width
export let type = "number"
$: style = width ? `width:${width}px;` : ""
</script>
<input
class:hide-arrows={hideArrows}
type="number"
{type}
{style}
{value}
{min}
@ -51,4 +52,7 @@
input.hide-arrows {
-moz-appearance: textfield;
}
input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
}
</style>

View File

@ -1,5 +1,4 @@
<script>
import { cleanInput } from "./utils"
import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
@ -8,39 +7,26 @@
const dispatch = createEventDispatcher()
$: displayValue = value || dayjs()
$: displayValue = value?.format("HH:mm")
const handleHourChange = e => {
dispatch("change", displayValue.hour(parseInt(e.target.value)))
const handleChange = e => {
if (!e.target.value) {
dispatch("change", undefined)
return
}
const [hour, minute] = e.target.value.split(":").map(x => parseInt(x))
dispatch("change", (value || dayjs()).hour(hour).minute(minute))
}
const handleMinuteChange = e => {
dispatch("change", displayValue.minute(parseInt(e.target.value)))
}
const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" })
const cleanMinute = cleanInput({ max: 59, pad: 2, fallback: "00" })
</script>
<div class="time-picker">
<NumberInput
hideArrows
value={displayValue.hour().toString().padStart(2, "0")}
min={0}
max={23}
width={20}
on:input={cleanHour}
on:change={handleHourChange}
/>
<span>:</span>
<NumberInput
hideArrows
value={displayValue.minute().toString().padStart(2, "0")}
min={0}
max={59}
width={20}
on:input={cleanMinute}
on:change={handleMinuteChange}
type={"time"}
value={displayValue}
on:input={handleChange}
on:change={handleChange}
/>
</div>
@ -50,10 +36,4 @@
flex-direction: row;
align-items: center;
}
.time-picker span {
font-weight: bold;
font-size: 18px;
z-index: 0;
margin-bottom: 1px;
}
</style>

View File

@ -17,6 +17,8 @@
export let customPopoverHeight
export let open = false
export let loading
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher()
@ -97,4 +99,6 @@
{autoWidth}
{customPopoverHeight}
{loading}
{onOptionMouseenter}
{onOptionMouseleave}
/>

View File

@ -41,6 +41,8 @@
export let footer = null
export let customAnchor = null
export let loading
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher()
@ -155,7 +157,7 @@
useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight}
maxHeight={240}
maxHeight={360}
>
<div
class="popover-content"
@ -199,6 +201,8 @@
aria-selected="true"
tabindex="0"
on:click={() => onSelectOption(getOptionValue(option, idx))}
on:mouseenter={e => onOptionMouseenter(e, option)}
on:mouseleave={e => onOptionMouseleave(e, option)}
class:is-disabled={!isOptionEnabled(option)}
>
{#if getOptionIcon(option, idx)}

View File

@ -26,6 +26,8 @@
export let tag = null
export let searchTerm = null
export let loading
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher()
@ -95,6 +97,8 @@
{autocomplete}
{sort}
{tag}
{onOptionMouseenter}
{onOptionMouseleave}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => compareOptionAndValue(option, value)}

View File

@ -0,0 +1,267 @@
<script>
import { onMount, createEventDispatcher } from "svelte"
import Atrament from "atrament"
import Icon from "../../Icon/Icon.svelte"
const dispatch = createEventDispatcher()
let last
export let value
export let disabled = false
export let editable = true
export let width = 400
export let height = 220
export let saveIcon = false
export let darkMode
export function toDataUrl() {
// PNG to preserve transparency
return canvasRef.toDataURL("image/png")
}
export function toFile() {
const data = canvasContext
.getImageData(0, 0, width, height)
.data.some(channel => channel !== 0)
if (!data) {
return
}
let dataURIParts = toDataUrl().split(",")
if (!dataURIParts.length) {
console.error("Could not retrieve signature data")
}
// Pull out the base64 encoded byte data
let binaryVal = atob(dataURIParts[1])
let blobArray = new Uint8Array(binaryVal.length)
let pos = 0
while (pos < binaryVal.length) {
blobArray[pos] = binaryVal.charCodeAt(pos)
pos++
}
const signatureBlob = new Blob([blobArray], {
type: "image/png",
})
return new File([signatureBlob], "signature.png", {
type: signatureBlob.type,
})
}
export function clearCanvas() {
return canvasContext.clearRect(0, 0, canvasWidth, canvasHeight)
}
let canvasRef
let canvasContext
let canvasWrap
let canvasWidth
let canvasHeight
let signature
let updated = false
let signatureFile
let urlFailed
$: if (value) {
signatureFile = value
}
$: if (signatureFile?.url) {
updated = false
}
$: if (last) {
dispatch("update")
}
onMount(() => {
if (!editable) {
return
}
const getPos = e => {
var rect = canvasRef.getBoundingClientRect()
const canvasX = e.offsetX || e.targetTouches?.[0].pageX - rect.left
const canvasY = e.offsetY || e.targetTouches?.[0].pageY - rect.top
return { x: canvasX, y: canvasY }
}
const checkUp = e => {
last = getPos(e)
}
canvasRef.addEventListener("pointerdown", e => {
const current = getPos(e)
//If the cursor didn't move at all, block the default pointerdown
if (last?.x === current?.x && last?.y === current?.y) {
e.preventDefault()
e.stopImmediatePropagation()
}
})
document.addEventListener("pointerup", checkUp)
signature = new Atrament(canvasRef, {
width,
height,
color: "white",
})
signature.weight = 4
signature.smoothing = 2
canvasWrap.style.width = `${width}px`
canvasWrap.style.height = `${height}px`
const { width: wrapWidth, height: wrapHeight } =
canvasWrap.getBoundingClientRect()
canvasHeight = wrapHeight
canvasWidth = wrapWidth
canvasContext = canvasRef.getContext("2d")
return () => {
signature.destroy()
document.removeEventListener("pointerup", checkUp)
}
})
</script>
<div class="signature" class:light={!darkMode} class:image-error={urlFailed}>
{#if !disabled}
<div class="overlay">
{#if updated && saveIcon}
<span class="save">
<Icon
name="Checkmark"
hoverable
tooltip={"Save"}
tooltipPosition={"top"}
tooltipType={"info"}
on:click={() => {
dispatch("change", toDataUrl())
}}
/>
</span>
{/if}
{#if signatureFile?.url && !updated}
<span class="delete">
<Icon
name="DeleteOutline"
hoverable
tooltip={"Delete"}
tooltipPosition={"top"}
tooltipType={"info"}
on:click={() => {
if (editable) {
clearCanvas()
}
dispatch("clear")
}}
/>
</span>
{/if}
</div>
{/if}
{#if !editable && signatureFile?.url}
<!-- svelte-ignore a11y-missing-attribute -->
{#if !urlFailed}
<img
src={signatureFile?.url}
on:error={() => {
urlFailed = true
}}
/>
{:else}
Could not load signature
{/if}
{:else}
<div bind:this={canvasWrap} class="canvas-wrap">
<canvas
id="signature-canvas"
bind:this={canvasRef}
style="--max-sig-width: {width}px; --max-sig-height: {height}px"
/>
{#if editable}
<div class="indicator-overlay">
<div class="sign-here">
<Icon name="Close" />
<hr />
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.indicator-overlay {
position: absolute;
width: 100%;
height: 100%;
top: 0px;
display: flex;
flex-direction: column;
justify-content: end;
padding: var(--spectrum-global-dimension-size-150);
box-sizing: border-box;
pointer-events: none;
}
.sign-here {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spectrum-global-dimension-size-150);
}
.sign-here hr {
border: 0;
border-top: 2px solid var(--spectrum-global-color-gray-200);
width: 100%;
}
.canvas-wrap {
position: relative;
margin: auto;
}
.signature img {
max-width: 100%;
}
#signature-canvas {
max-width: var(--max-sig-width);
max-height: var(--max-sig-height);
}
.signature.light img,
.signature.light #signature-canvas {
-webkit-filter: invert(100%);
filter: invert(100%);
}
.signature.image-error .overlay {
padding-top: 0px;
}
.signature {
width: 100%;
height: 100%;
position: relative;
text-align: center;
}
.overlay {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
padding: var(--spectrum-global-dimension-size-150);
text-align: right;
z-index: 2;
box-sizing: border-box;
}
.save,
.delete {
display: inline-block;
}
</style>

View File

@ -16,3 +16,4 @@ export { default as CoreStepper } from "./Stepper.svelte"
export { default as CoreRichTextField } from "./RichTextField.svelte"
export { default as CoreSlider } from "./Slider.svelte"
export { default as CoreFile } from "./File.svelte"
export { default as CoreSignature } from "./Signature.svelte"

View File

@ -19,6 +19,8 @@
export let searchTerm = null
export let customPopoverHeight
export let helpText = null
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher()
const onChange = e => {
@ -41,6 +43,8 @@
{autoWidth}
{autocomplete}
{customPopoverHeight}
{onOptionMouseenter}
{onOptionMouseleave}
bind:searchTerm
on:change={onChange}
on:click

View File

@ -29,6 +29,9 @@
export let tag = null
export let helpText = null
export let compare
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
const dispatch = createEventDispatcher()
const onChange = e => {
value = e.detail
@ -67,6 +70,8 @@
{customPopoverHeight}
{tag}
{compare}
{onOptionMouseenter}
{onOptionMouseleave}
on:change={onChange}
on:click
/>

View File

@ -173,6 +173,7 @@
}
.spectrum-Modal {
border: 2px solid var(--spectrum-global-color-gray-200);
overflow: visible;
max-height: none;
margin: 40px 0;

View File

@ -27,6 +27,7 @@
export let secondaryButtonText = undefined
export let secondaryAction = undefined
export let secondaryButtonWarning = false
export let custom = false
const { hide, cancel } = getContext(Context.Modal)
let loading = false
@ -63,12 +64,13 @@
class:spectrum-Dialog--medium={size === "M"}
class:spectrum-Dialog--large={size === "L"}
class:spectrum-Dialog--extraLarge={size === "XL"}
class:no-grid={custom}
style="position: relative;"
role="dialog"
tabindex="-1"
aria-modal="true"
>
<div class="spectrum-Dialog-grid">
<div class="modal-core" class:spectrum-Dialog-grid={!custom}>
{#if title || $$slots.header}
<h1
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
@ -153,6 +155,25 @@
.spectrum-Dialog-content {
overflow: visible;
}
.no-grid .spectrum-Dialog-content {
border-top: 2px solid var(--spectrum-global-color-gray-200);
border-bottom: 2px solid var(--spectrum-global-color-gray-200);
}
.no-grid .spectrum-Dialog-heading {
margin-top: 12px;
margin-left: 12px;
}
.spectrum-Dialog.no-grid {
width: 100%;
}
.spectrum-Dialog.no-grid .spectrum-Dialog-buttonGroup {
padding: 12px;
}
.spectrum-Dialog-heading {
font-family: var(--font-accent);
font-weight: 600;

View File

@ -1,252 +0,0 @@
<script>
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import Icon from "../Icon/Icon.svelte"
import Popover from "../Popover/Popover.svelte"
import { onMount } from "svelte"
const flipDurationMs = 150
export let constraints
export let optionColors = {}
let options = []
let colorPopovers = []
let anchors = []
let colorsArray = [
"hsla(0, 90%, 75%, 0.3)",
"hsla(50, 80%, 75%, 0.3)",
"hsla(120, 90%, 75%, 0.3)",
"hsla(200, 90%, 75%, 0.3)",
"hsla(240, 90%, 75%, 0.3)",
"hsla(320, 90%, 75%, 0.3)",
]
const removeInput = idx => {
delete optionColors[options[idx].name]
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
options = options.filter((e, i) => i !== idx)
colorPopovers.pop(undefined)
anchors.pop(undefined)
}
const addNewInput = () => {
options = [
...options,
{ name: `Option ${constraints.inclusion.length + 1}`, id: Math.random() },
]
constraints.inclusion = [
...constraints.inclusion,
`Option ${constraints.inclusion.length + 1}`,
]
colorPopovers.push(undefined)
anchors.push(undefined)
}
const handleDndConsider = e => {
options = e.detail.items
}
const handleDndFinalize = e => {
options = e.detail.items
constraints.inclusion = options.map(option => option.name)
}
const handleColorChange = (optionName, color, idx) => {
optionColors[optionName] = color
colorPopovers[idx].hide()
}
const handleNameChange = (optionName, idx, value) => {
constraints.inclusion[idx] = value
options[idx].name = value
optionColors[value] = optionColors[optionName]
delete optionColors[optionName]
}
const openColorPickerPopover = (optionIdx, target) => {
colorPopovers[optionIdx].show()
anchors[optionIdx] = target
}
onMount(() => {
// Initialize anchor arrays on mount, assuming 'options' is already populated
colorPopovers = constraints.inclusion.map(() => undefined)
anchors = constraints.inclusion.map(() => undefined)
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
})
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div>
<div
class="actions"
use:dndzone={{
items: options,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}
>
{#each options as option, idx (option.id)}
<div
class="no-border action-container"
animate:flip={{ duration: flipDurationMs }}
>
<div class="child drag-handle-spacing">
<Icon name="DragHandle" size="L" />
</div>
<div class="child color-picker">
<div
id="color-picker"
bind:this={anchors[idx]}
style="--color:{optionColors?.[option.name] ||
'hsla(0, 1%, 50%, 0.3)'}"
class="circle"
on:click={e => openColorPickerPopover(idx, e.target)}
>
<Popover
bind:this={colorPopovers[idx]}
anchor={anchors[idx]}
align="left"
offset={0}
style=""
popoverTarget={document.getElementById(`color-picker`)}
animate={false}
>
<div class="colors">
{#each colorsArray as color}
<div
on:click={() => handleColorChange(option.name, color, idx)}
style="--color:{color};"
class="circle circle-hover"
/>
{/each}
</div>
</Popover>
</div>
</div>
<div class="child">
<input
class="input-field"
type="text"
on:change={e => handleNameChange(option.name, idx, e.target.value)}
value={option.name}
placeholder="Option name"
/>
</div>
<div class="child">
<Icon name="Close" hoverable size="S" on:click={removeInput(idx)} />
</div>
</div>
{/each}
</div>
<div on:click={addNewInput} class="add-option">
<Icon hoverable name="Add" />
<div>Add option</div>
</div>
</div>
<style>
.action-container {
background-color: var(--spectrum-alias-background-color-primary);
border-radius: 0px;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
display: flex;
flex-direction: row;
align-items: center;
}
.no-border {
border-bottom: none;
}
.action-container:last-child {
border-bottom: 1px solid var(--spectrum-global-color-gray-300) !important;
}
.child {
height: 30px;
}
.child:hover,
.child:focus {
background: var(--spectrum-global-color-gray-200);
}
.add-option {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--spacing-m);
gap: var(--spacing-m);
cursor: pointer;
}
.input-field {
border: none;
outline: none;
background-color: transparent;
width: 100%;
color: var(--text);
}
.child input[type="text"] {
padding-left: 10px;
}
.input-field:hover,
.input-field:focus {
background: var(--spectrum-global-color-gray-200);
}
.action-container > :nth-child(1) {
flex-grow: 1;
justify-content: center;
display: flex;
}
.action-container > :nth-child(2) {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
}
.action-container > :nth-child(3) {
flex-grow: 4;
display: flex;
}
.action-container > :nth-child(4) {
flex-grow: 1;
justify-content: center;
display: flex;
}
.circle {
height: 20px;
width: 20px;
background-color: var(--color);
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
}
.circle-hover:hover {
border: 1px solid var(--spectrum-global-color-blue-400);
cursor: pointer;
}
.colors {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-xl);
justify-items: center;
margin: var(--spacing-m);
}
</style>

View File

@ -1,78 +1,123 @@
<script>
import { getContext, onMount, createEventDispatcher } from "svelte"
import { getContext, onDestroy, createEventDispatcher } from "svelte"
import Portal from "svelte-portal"
export let title
export let icon = ""
export let id
export let href = "#"
export let link = false
const dispatch = createEventDispatcher()
let selected = getContext("tab")
let tab_internal
let tabInfo
let observer
let ref
const setTabInfo = () => {
// If the tabs are being rendered inside a component which uses
// a svelte transition to enter, then this initial getBoundingClientRect
// will return an incorrect position.
// We just need to get this off the main thread to fix this, by using
// a 0ms timeout.
setTimeout(() => {
tabInfo = tab_internal?.getBoundingClientRect()
if (tabInfo && $selected.title === title) {
$selected.info = tabInfo
}
}, 0)
$: isSelected = $selected.title === title
$: {
if (isSelected && ref) {
observe()
} else {
stopObserving()
}
}
onMount(() => {
setTabInfo()
})
//Ensure that the underline is in the correct location
$: {
if ($selected.title === title && tab_internal) {
if ($selected.info?.left !== tab_internal.getBoundingClientRect().left) {
setTabInfo()
}
const setTabInfo = () => {
const tabInfo = ref?.getBoundingClientRect()
if (tabInfo) {
$selected.info = tabInfo
}
}
const onAnchorClick = e => {
if (e.metaKey || e.shiftKey || e.altKey || e.ctrlKey) return
e.preventDefault()
$selected = {
...$selected,
title,
info: ref.getBoundingClientRect(),
}
dispatch("click")
}
const onClick = () => {
$selected = {
...$selected,
title,
info: tab_internal.getBoundingClientRect(),
info: ref.getBoundingClientRect(),
}
dispatch("click")
}
const observe = () => {
if (!observer) {
observer = new ResizeObserver(setTabInfo)
observer.observe(ref)
}
}
const stopObserving = () => {
if (observer) {
observer.unobserve(ref)
observer = null
}
}
onDestroy(stopObserving)
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div
{id}
bind:this={tab_internal}
on:click={onClick}
class:is-selected={$selected.title === title}
class="spectrum-Tabs-item"
class:emphasized={$selected.title === title && $selected.emphasized}
tabindex="0"
>
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="true"
aria-label="Folder"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
<span class="spectrum-Tabs-itemLabel">{title}</span>
</div>
{#if $selected.title === title}
{#if link}
<a
{href}
{id}
bind:this={ref}
on:click={onAnchorClick}
class="spectrum-Tabs-item link"
class:is-selected={isSelected}
class:emphasized={isSelected && $selected.emphasized}
tabindex="0"
>
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="true"
aria-label="Folder"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
<span class="spectrum-Tabs-itemLabel">{title}</span>
</a>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div
{id}
bind:this={ref}
on:click={onClick}
on:click
class="spectrum-Tabs-item"
class:is-selected={isSelected}
class:emphasized={isSelected && $selected.emphasized}
tabindex="0"
>
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="true"
aria-label="Folder"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
<span class="spectrum-Tabs-itemLabel">{title}</span>
</div>
{/if}
{#if isSelected}
<Portal target=".spectrum-Tabs-content-{$selected.id}">
<slot />
</Portal>
@ -89,4 +134,7 @@
.spectrum-Tabs-item:hover {
color: var(--spectrum-global-color-gray-900);
}
.link {
user-select: none;
}
</style>

View File

@ -0,0 +1,79 @@
<script>
import Portal from "svelte-portal"
import { getContext } from "svelte"
import Context from "../context"
export let anchor
export let visible = false
export let offset = 0
$: target = getContext(Context.PopoverRoot) || "#app"
let hovering = false
let tooltip
let x = 0
let y = 0
const updatePosition = (anchor, tooltip) => {
if (anchor == null || tooltip == null) {
return
}
requestAnimationFrame(() => {
const rect = anchor.getBoundingClientRect()
const windowOffset =
window.innerHeight - offset - (tooltip.clientHeight + rect.y)
const tooltipWidth = tooltip.clientWidth
x = rect.x - tooltipWidth - offset
y = windowOffset < 0 ? rect.y + windowOffset : rect.y
})
}
$: updatePosition(anchor, tooltip)
const handleMouseenter = () => {
hovering = true
}
const handleMouseleave = () => {
hovering = false
}
</script>
<Portal {target}>
<div
role="tooltip"
on:mouseenter={handleMouseenter}
on:mouseleave={handleMouseleave}
style:left={`${x}px`}
style:top={`${y}px`}
class="wrapper"
class:visible={visible || hovering}
>
<div bind:this={tooltip} class="tooltip">
<slot />
</div>
</div>
</Portal>
<style>
.wrapper {
background-color: var(--spectrum-global-color-gray-100);
box-shadow: 2px 2px 5px 0px rgba(0, 0, 0, 0.42);
opacity: 0;
overflow: hidden;
border-radius: 5px;
box-sizing: border-box;
border: 1px solid var(--grey-4);
position: absolute;
pointer-events: none;
z-index: 1000;
}
.visible {
opacity: 1;
pointer-events: auto;
}
</style>

View File

@ -166,9 +166,14 @@ export const stringifyDate = (
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
if (offsetForTimezone) {
// Ensure we use the correct offset for the date
const referenceDate = timeOnly ? new Date() : value.toDate()
const referenceDate = value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000
return new Date(value.valueOf() - offset).toISOString().slice(0, -1)
const date = new Date(value.valueOf() - offset)
if (timeOnly) {
// Extract HH:mm
return date.toISOString().slice(11, 16)
}
return date.toISOString().slice(0, -1)
}
// For date-only fields, construct a manual timestamp string without a time
@ -177,7 +182,7 @@ export const stringifyDate = (
const year = value.year()
const month = `${value.month() + 1}`.padStart(2, "0")
const day = `${value.date()}`.padStart(2, "0")
return `${year}-${month}-${day}T00:00:00.000`
return `${year}-${month}-${day}`
}
// Otherwise use a normal ISO string with time and timezone

View File

@ -53,6 +53,7 @@ export { default as Link } from "./Link/Link.svelte"
export { default as Tooltip } from "./Tooltip/Tooltip.svelte"
export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte"
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte"
export { default as ContextTooltip } from "./Tooltip/Context.svelte"
export { default as Menu } from "./Menu/Menu.svelte"
export { default as MenuSection } from "./Menu/Section.svelte"
export { default as MenuSeparator } from "./Menu/Separator.svelte"
@ -88,7 +89,6 @@ export { default as ListItem } from "./List/ListItem.svelte"
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte"
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"

View File

@ -9,7 +9,7 @@
import TestDataModal from "./TestDataModal.svelte"
import { flip } from "svelte/animate"
import { fly } from "svelte/transition"
import { Icon, notifications, Modal } from "@budibase/bbui"
import { Icon, notifications, Modal, Toggle } from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
@ -73,6 +73,16 @@
Test details
</div>
</div>
<div class="setting-spacing">
<Toggle
text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
value={!automation.disabled}
/>
</div>
</div>
</div>
<div class="canvas" on:scroll={handleScroll}>

View File

@ -61,6 +61,7 @@
selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
disabled={automation.disabled}
>
<EditAutomationPopover {automation} />
</NavItem>

View File

@ -39,6 +39,15 @@
>Duplicate</MenuItem
>
<MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</MenuItem>
<MenuItem
icon={automation.disabled ? "CheckmarkCircle" : "Cancel"}
on:click={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
>
{automation.disabled ? "Activate" : "Pause"}
</MenuItem>
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
</ActionMenu>

View File

@ -364,6 +364,7 @@
value.customType !== "cron" &&
value.customType !== "triggerSchema" &&
value.customType !== "automationFields" &&
value.type !== "signature_single" &&
value.type !== "attachment" &&
value.type !== "attachment_single"
)
@ -456,7 +457,7 @@
value={inputData[key]}
options={Object.keys(table?.schema || {})}
/>
{:else if value.type === "attachment"}
{:else if value.type === "attachment" || value.type === "signature_single"}
<div class="attachment-field-wrapper">
<div class="label-wrapper">
<Label>{label}</Label>

View File

@ -24,6 +24,11 @@
let table
let schemaFields
let attachmentTypes = [
FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE,
FieldType.SIGNATURE_SINGLE,
]
$: {
table = $tables.list.find(table => table._id === value?.tableId)
@ -120,15 +125,9 @@
{#if schemaFields.length}
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn}
<div
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<div class:schema-fields={!attachmentTypes.includes(schema.type)}>
<Label>{field}</Label>
<div
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<div class:field-width={!attachmentTypes.includes(schema.type)}>
{#if isTestModal}
<RowSelectorTypes
{isTestModal}

View File

@ -21,6 +21,12 @@
return clone
})
let attachmentTypes = [
FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE,
FieldType.SIGNATURE_SINGLE,
]
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
@ -29,7 +35,8 @@
let params = {}
if (
schema.type === FieldType.ATTACHMENT_SINGLE &&
(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE) &&
Object.keys(keyValueObj).length === 0
) {
return []
@ -92,7 +99,7 @@
on:change={e => onChange(e, field)}
useLabel={false}
/>
{:else if schema.type === "bb_reference"}
{:else if schema.type === "bb_reference" || schema.type === "bb_reference_single"}
<LinkedRowSelector
linkedRows={value[field]}
{schema}
@ -100,16 +107,20 @@
on:change={e => onChange(e, field)}
useLabel={false}
/>
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
{:else if attachmentTypes.includes(schema.type)}
<div class="attachment-field-spacinng">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE
schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE
? e.detail.length > 0
? { url: e.detail[0].name, filename: e.detail[0].value }
? {
url: e.detail[0].name,
filename: e.detail[0].value,
}
: {}
: e.detail.map(({ name, value }) => ({
url: name,
@ -125,7 +136,8 @@
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE) &&
Object.keys(value[field]).length >= 1}
/>
</div>

View File

@ -1,4 +1,5 @@
<script>
import { API } from "api"
import {
Input,
Select,
@ -8,15 +9,19 @@
Label,
RichTextField,
TextArea,
CoreSignature,
ActionButton,
notifications,
} from "@budibase/bbui"
import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "helpers"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import Editor from "../../integration/QueryEditor.svelte"
import { SignatureModal } from "@budibase/frontend-core/src/components"
import { themeStore } from "stores/portal"
export let defaultValue
export let meta
export let value = defaultValue || (meta.type === "boolean" ? false : "")
export let value
export let readonly
export let error
@ -39,8 +44,35 @@
const timeStamp = resolveTimeStamp(value)
const isTimeStamp = !!timeStamp || meta?.timeOnly
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
let signatureModal
</script>
<SignatureModal
{darkMode}
onConfirm={async sigCanvas => {
const signatureFile = sigCanvas.toFile()
let attachRequest = new FormData()
attachRequest.append("file", signatureFile)
try {
const uploadReq = await API.uploadBuilderAttachment(attachRequest)
const [signatureAttachment] = uploadReq
value = signatureAttachment
} catch (error) {
$notifications.error(error.message || "Failed to save signature")
value = []
}
}}
title={meta.name}
{value}
bind:this={signatureModal}
/>
{#if type === "options" && meta.constraints.inclusion.length !== 0}
<Select
{label}
@ -59,7 +91,49 @@
bind:value
/>
{:else if type === "attachment"}
<Dropzone {label} {error} bind:value />
<Dropzone
{label}
{error}
{value}
on:change={e => {
value = e.detail
}}
/>
{:else if type === "attachment_single"}
<Dropzone
{label}
{error}
value={value ? [value] : []}
on:change={e => {
value = e.detail?.[0]
}}
maximum={1}
/>
{:else if type === "signature_single"}
<div class="signature">
<Label>{label}</Label>
<div class="sig-wrap" class:display={value}>
{#if value}
<CoreSignature
{darkMode}
{value}
editable={false}
on:clear={() => {
value = null
}}
/>
{:else}
<ActionButton
fullWidth
on:click={() => {
signatureModal.show()
}}
>
Add signature
</ActionButton>
{/if}
</div>
</div>
{:else if type === "boolean"}
<Toggle text={label} {error} bind:value />
{:else if type === "array" && meta.constraints.inclusion.length !== 0}
@ -95,3 +169,22 @@
{:else}
<Input {label} {type} {error} bind:value disabled={readonly} />
{/if}
<style>
.signature :global(label.spectrum-FieldLabel) {
padding-top: var(--spectrum-fieldlabel-padding-top);
padding-bottom: var(--spectrum-fieldlabel-padding-bottom);
}
.sig-wrap.display {
min-height: 50px;
justify-content: center;
width: 100%;
display: flex;
flex-direction: column;
background-color: var(--spectrum-global-color-gray-50);
box-sizing: border-box;
border: var(--spectrum-alias-border-size-thin)
var(--spectrum-alias-border-color) solid;
border-radius: var(--spectrum-alias-border-radius-regular);
}
</style>

View File

@ -1,5 +1,6 @@
<script>
import { datasources, tables, integrations, appStore } from "stores/builder"
import { themeStore, admin } from "stores/portal"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core"
@ -37,6 +38,9 @@
})
$: relationshipsEnabled = relationshipSupport(tableDatasource)
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source]
return !isInternal && integration?.relationships !== false
@ -55,6 +59,7 @@
<div class="wrapper">
<Grid
{API}
{darkMode}
datasource={gridDatasource}
canAddRows={!isUsersTable}
canDeleteRows={!isUsersTable}
@ -63,6 +68,7 @@
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
on:updatedatasource={handleGridTableUpdate}
isCloud={$admin.cloud}
>
<svelte:fragment slot="filter">
{#if isUsersTable && $appStore.features.disableUserMetadata}

View File

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

View File

@ -4,6 +4,8 @@
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
import { search } from "@budibase/frontend-core"
import { tables } from "stores/builder"
export let schema
export let filters
@ -15,11 +17,10 @@
let drawer
$: tempValue = filters || []
$: schemaFields = Object.entries(schema || {}).map(
([fieldName, fieldSchema]) => ({
name: fieldName, // Using the key as name if not defined in the schema, for example in some autogenerated columns
...fieldSchema,
})
$: schemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{ allowLinks: true }
)
$: text = getText(filters)

View File

@ -9,6 +9,7 @@ const MAX_DEPTH = 1
const TYPES_TO_SKIP = [
FieldType.FORMULA,
FieldType.LONGFORM,
FieldType.SIGNATURE_SINGLE,
FieldType.ATTACHMENTS,
//https://github.com/Budibase/budibase/issues/3030
FieldType.INTERNAL,
@ -55,7 +56,7 @@ export function getBindings({
)
}
const field = Object.values(FIELDS).find(
field => field.type === schema.type && field.subtype === schema.subtype
field => field.type === schema.type
)
const label = path == null ? column : `${path}.0.${column}`

View File

@ -9,7 +9,6 @@
DatePicker,
Modal,
notifications,
OptionSelectDnD,
Layout,
AbsTooltip,
ProgressCircle,
@ -42,6 +41,7 @@
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte"
const AUTO_TYPE = FieldType.AUTO
const FORMULA_TYPE = FieldType.FORMULA
@ -95,6 +95,7 @@
},
}
let autoColumnInfo = getAutoColumnInformation()
let optionsValid = true
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: if (primaryDisplay) {
@ -138,7 +139,8 @@
$: invalid =
!editableColumn?.name ||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0
Object.keys(errors).length !== 0 ||
!optionsValid
$: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
@ -412,6 +414,7 @@
FIELDS.FORMULA,
FIELDS.JSON,
FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT,
FIELDS.AUTO,
]
@ -558,9 +561,10 @@
bind:value={editableColumn.constraints.length.maximum}
/>
{:else if editableColumn.type === FieldType.OPTIONS}
<OptionSelectDnD
<OptionsEditor
bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors}
bind:valid={optionsValid}
/>
{:else if editableColumn.type === FieldType.LONGFORM}
<div>
@ -581,17 +585,22 @@
/>
</div>
{:else if editableColumn.type === FieldType.ARRAY}
<OptionSelectDnD
<OptionsEditor
bind:constraints={editableColumn.constraints}
bind:optionColors={editableColumn.optionColors}
bind:valid={optionsValid}
/>
{:else if editableColumn.type === FieldType.DATETIME && !editableColumn.autocolumn}
{:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
<div class="split-label">
<div class="label-length">
<Label size="M">Earliest</Label>
</div>
<div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.earliest} />
<DatePicker
bind:value={editableColumn.constraints.datetime.earliest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div>
</div>
@ -600,30 +609,36 @@
<Label size="M">Latest</Label>
</div>
<div class="input-length">
<DatePicker bind:value={editableColumn.constraints.datetime.latest} />
</div>
</div>
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
text={isCreating
? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
<DatePicker
bind:value={editableColumn.constraints.datetime.latest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div>
</div>
{#if !editableColumn.timeOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
text={isCreating
? null
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
</AbsTooltip>
</div>
<Toggle
bind:value={editableColumn.ignoreTimezones}
text="Ignore time zones"
/>
</div>
{/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn}
<div class="split-label">
<div class="label-length">

View File

@ -0,0 +1,252 @@
<script>
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { Icon, Popover } from "@budibase/bbui"
import { tick } from "svelte"
import { Constants } from "@budibase/frontend-core"
import { getSequentialName } from "helpers/duplicate"
import { derived, writable } from "svelte/store"
export let constraints
export let optionColors = {}
export let valid = true
const flipDurationMs = 130
const { OptionColours } = Constants
const getDefaultColor = idx => OptionColours[idx % OptionColours.length]
const options = writable(
constraints.inclusion.map((value, idx) => ({
id: Math.random(),
name: value,
color: optionColors?.[value] || getDefaultColor(idx),
invalid: false,
}))
)
const enrichedOptions = derived(options, $options => {
let enriched = []
$options.forEach(option => {
enriched.push({
...option,
valid: option.name && !enriched.some(opt => opt.name === option.name),
})
})
return enriched
})
let openOption = null
let anchor = null
$: options.subscribe(updateConstraints)
$: valid = $enrichedOptions.every(option => option.valid)
const updateConstraints = options => {
constraints.inclusion = options.map(option => option.name)
optionColors = options.reduce(
(colors, option) => ({ ...colors, [option.name]: option.color }),
{}
)
}
const addNewInput = async () => {
const newId = Math.random()
const newName = getSequentialName($options, "Option ", {
numberFirstItem: true,
getName: option => option.name,
})
options.update(state => {
return [
...state,
{
name: newName,
id: newId,
color: getDefaultColor(state.length),
},
]
})
// Focus new option
await tick()
document.getElementById(`option-${newId}`)?.focus()
}
const removeInput = id => {
options.update(state => state.filter(option => option.id !== id))
}
const openColorPicker = id => {
anchor = document.getElementById(`color-${id}`)
openOption = id
}
const handleColorChange = (id, color) => {
options.update(state => {
state.find(option => option.id === id).color = color
return state.slice()
})
openOption = null
}
const handleNameChange = (id, name) => {
options.update(state => {
state.find(option => option.id === id).name = name
return state.slice()
})
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="wrapper">
<div
class="options"
use:dndzone={{
items: $options,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={e => options.set(e.detail.items)}
on:finalize={e => options.set(e.detail.items)}
>
{#each $enrichedOptions as option (option.id)}
<div
class="option"
animate:flip={{ duration: flipDurationMs }}
class:invalid={!option.valid}
>
<div class="drag-handle">
<Icon name="DragHandle" size="L" />
</div>
<div
id="color-{option.id}"
class="color-picker"
on:click={() => openColorPicker(option.id)}
>
<div class="circle" style="--color:{option.color}">
<Popover
open={openOption === option.id}
{anchor}
align="left"
offset={0}
animate={false}
resizable={false}
>
<div class="colors" data-ignore-click-outside="true">
{#each OptionColours as colorOption}
<div
on:click={() => handleColorChange(option.id, colorOption)}
style="--color:{colorOption};"
class="circle"
class:selected={colorOption === option.color}
/>
{/each}
</div>
</Popover>
</div>
</div>
<input
class="option-name"
type="text"
value={option.name}
placeholder="Option name"
id="option-{option.id}"
on:input={e => handleNameChange(option.id, e.target.value)}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeInput(option.id)}
/>
</div>
{/each}
</div>
<div on:click={addNewInput} class="add-option">
<Icon name="Add" />
<div>Add option</div>
</div>
</div>
<style>
/* Container */
.wrapper {
overflow: hidden;
border-radius: 4px;
border: 1px solid var(--spectrum-global-color-gray-300);
background-color: var(--spectrum-global-color-gray-50);
}
.options > *,
.add-option {
height: 32px;
}
/* Options row */
.option {
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
display: flex;
flex-direction: row;
align-items: center;
border: 1px solid transparent;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
gap: var(--spacing-m);
padding: 0 var(--spacing-m) 0 var(--spacing-s);
outline: none !important;
}
.option.invalid {
border: 1px solid var(--spectrum-global-color-red-400);
}
.option:not(.invalid):hover,
.option:not(.invalid):focus {
background: var(--spectrum-global-color-gray-100);
}
/* Option row components */
.color-picker {
align-self: stretch;
display: grid;
place-items: center;
}
.circle {
height: 20px;
width: 20px;
background-color: var(--color);
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
border: 1px solid transparent;
transition: border 130ms ease-out;
}
.circle:hover,
.circle.selected {
border: 1px solid var(--spectrum-global-color-blue-600);
cursor: pointer;
}
.colors {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: var(--spacing-xl);
justify-items: center;
margin: var(--spacing-m);
}
.option-name {
border: none;
outline: none;
background-color: transparent;
width: 100%;
color: var(--text);
}
/* Add option */
.add-option {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: var(--spacing-m);
gap: var(--spacing-m);
}
.add-option:hover {
cursor: pointer !important;
background: var(--spectrum-global-color-gray-200);
}
</style>

View File

@ -86,8 +86,9 @@ export const createValidatedConfigStore = (integration, config) => {
([$configStore, $errorsStore, $selectedValidatorsStore]) => {
const validatedConfig = []
const allowedRestKeys = ["rejectUnauthorized", "downloadImages"]
Object.entries(integration.datasource).forEach(([key, properties]) => {
if (integration.name === "REST" && key !== "rejectUnauthorized") {
if (integration.name === "REST" && !allowedRestKeys.includes(key)) {
return
}

View File

@ -54,6 +54,10 @@
label: "Attachment",
value: FieldType.ATTACHMENT_SINGLE,
},
{
label: "Signature",
value: FieldType.SIGNATURE_SINGLE,
},
{
label: "Attachment list",
value: FieldType.ATTACHMENTS,

View File

@ -54,6 +54,7 @@
export let autofocus = false
export let jsBindingWrapping = true
export let readonly = false
export let readonlyLineNumbers = false
const dispatch = createEventDispatcher()
@ -240,6 +241,9 @@
if (readonly) {
complete.push(EditorState.readOnly.of(true))
if (readonlyLineNumbers) {
complete.push(lineNumbers())
}
} else {
complete = [
...complete,

View File

@ -0,0 +1,50 @@
import { it, expect, describe, vi } from "vitest"
import Dropzone from "./Dropzone.svelte"
import { render, fireEvent } from "@testing-library/svelte"
import { notifications } from "@budibase/bbui"
import { admin } from "stores/portal"
vi.spyOn(notifications, "error").mockImplementation(() => {})
describe("Dropzone", () => {
let instance = null
afterEach(() => {
vi.restoreAllMocks()
})
it("that the Dropzone is rendered", () => {
instance = render(Dropzone, {})
expect(instance).toBeDefined()
})
it("Ensure the correct error message is shown when uploading the file in cloud", async () => {
admin.subscribe = vi.fn().mockImplementation(callback => {
callback({ cloud: true })
return () => {}
})
instance = render(Dropzone, { props: { fileSizeLimit: 1000000 } }) // 1MB
const fileInput = instance.getByLabelText("Select a file to upload")
const file = new File(["hello".repeat(2000000)], "hello.png", {
type: "image/png",
})
await fireEvent.change(fileInput, { target: { files: [file] } })
expect(notifications.error).toHaveBeenCalledWith(
"Files cannot exceed 1MB. Please try again with smaller files."
)
})
it("Ensure the file size error message is not shown when running on self host", async () => {
admin.subscribe = vi.fn().mockImplementation(callback => {
callback({ cloud: false })
return () => {}
})
instance = render(Dropzone, { props: { fileSizeLimit: 1000000 } }) // 1MB
const fileInput = instance.getByLabelText("Select a file to upload")
const file = new File(["hello".repeat(2000000)], "hello.png", {
type: "image/png",
})
await fireEvent.change(fileInput, { target: { files: [file] } })
expect(notifications.error).not.toHaveBeenCalled()
})
})

View File

@ -1,9 +1,11 @@
<script>
import { Dropzone, notifications } from "@budibase/bbui"
import { admin } from "stores/portal"
import { API } from "api"
export let value = []
export let label
export let fileSizeLimit = undefined
const BYTES_IN_MB = 1000000
@ -34,5 +36,6 @@
{label}
{...$$restProps}
{processFiles}
{handleFileTooLarge}
handleFileTooLarge={$admin.cloud ? handleFileTooLarge : null}
{fileSizeLimit}
/>

View File

@ -7,6 +7,7 @@
export let app
export let color
export let autoSave = false
export let disabled = false
let modal
</script>
@ -14,12 +15,16 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="editable-icon">
<div class="hover" on:click={modal.show}>
<Icon name="Edit" {size} color="var(--spectrum-global-color-gray-600)" />
</div>
<div class="normal">
{#if !disabled}
<div class="hover" on:click={modal.show}>
<Icon name="Edit" {size} color="var(--spectrum-global-color-gray-600)" />
</div>
<div class="normal">
<Icon name={name || "Apps"} {size} {color} />
</div>
{:else}
<Icon {name} {size} {color} />
</div>
{/if}
</div>
<Modal bind:this={modal}>

View File

@ -43,7 +43,7 @@
<b>{linkedTable.name}</b>
table.
</Label>
{:else if schema.relationshipType === "one-to-many"}
{:else if schema.relationshipType === "one-to-many" || schema.type === "bb_reference_single"}
<Select
value={linkedIds?.[0]}
options={rows}

View File

@ -1,5 +1,5 @@
<script>
import { AbsTooltip, Icon } from "@budibase/bbui"
import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core"
import { UserAvatars } from "@budibase/frontend-core"
@ -25,6 +25,7 @@
export let selectedBy = null
export let compact = false
export let hovering = false
export let disabled = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@ -74,6 +75,7 @@
class:scrollable
class:highlighted
class:selectedBy
class:disabled
on:dragend
on:dragstart
on:dragover
@ -112,9 +114,14 @@
</div>
{:else if icon}
<div class="icon" class:right={rightAlignIcon}>
<AbsTooltip type="info" position="right" text={iconTooltip}>
<Icon color={iconColor} size="S" name={icon} />
</AbsTooltip>
<Icon
color={iconColor}
size="S"
name={icon}
tooltip={iconTooltip}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Right}
/>
</div>
{/if}
<div class="text" title={showTooltip ? text : null}>
@ -165,6 +172,9 @@
--avatars-background: var(--spectrum-global-color-gray-300);
color: var(--ink);
}
.nav-item.disabled span {
color: var(--spectrum-global-color-gray-700);
}
.nav-item:hover,
.hovering {
background-color: var(--spectrum-global-color-gray-200);

View File

@ -0,0 +1,214 @@
<script>
import { Button, Label, Icon, Input, notifications } from "@budibase/bbui"
import { AppStatus } from "constants"
import { appStore, initialise } from "stores/builder"
import { appsStore } from "stores/portal"
import { API } from "api"
import { writable } from "svelte/store"
import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
import EditableIcon from "components/common/EditableIcon.svelte"
import { isEqual } from "lodash"
import { createEventDispatcher } from "svelte"
export let alignActions = "left"
const values = writable({})
const validation = createValidationStore()
const dispatch = createEventDispatcher()
let updating = false
let edited = false
let initialised = false
$: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
$: appName = $appStore.name
$: appURL = $appStore.url
$: appIconName = $appStore.icon?.name
$: appIconColor = $appStore.icon?.color
$: appMeta = {
name: appName,
url: appURL,
iconName: appIconName,
iconColor: appIconColor,
}
const initForm = appMeta => {
edited = false
values.set({
...appMeta,
})
if (!initialised) {
setupValidation()
initialised = true
}
}
const validate = (vals, appMeta) => {
const { url } = vals || {}
validation.check({
...vals,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
edited = !isEqual(vals, appMeta)
}
// On app/apps update, reset the state.
$: initForm(appMeta)
$: validate($values, appMeta)
const resolveAppUrl = (template, name) => {
let parsedName
const resolvedName = resolveAppName(null, name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(null, appName)
tidyUrl(resolvedUrl)
}
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
}
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const updateIcon = e => {
const { name, color } = e.detail
$values.iconColor = color
$values.iconName = name
}
const setupValidation = async () => {
appValidation.name(validation, {
apps: $appsStore.apps,
currentApp: app,
})
appValidation.url(validation, {
apps: $appsStore.apps,
currentApp: app,
})
}
async function updateApp() {
try {
await appsStore.save($appStore.appId, {
name: $values.name?.trim(),
url: $values.url?.trim(),
icon: {
name: $values.iconName,
color: $values.iconColor,
},
})
await initialiseApp()
notifications.success("App update successful")
} catch (error) {
console.error(error)
notifications.error("Error updating app")
}
}
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($appStore.appId)
await initialise(applicationPkg)
}
</script>
<div class="form">
<div class="fields">
<div class="field">
<Label size="L">Name</Label>
<Input
bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
disabled={appDeployed}
/>
</div>
<div class="field">
<Label size="L">URL</Label>
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
placeholder={$values.url
? $values.url
: `/${resolveAppUrl(null, $values.name)}`}
disabled={appDeployed}
/>
</div>
<div class="field">
<Label size="L">Icon</Label>
<EditableIcon
{app}
size="XL"
name={$values.iconName}
color={$values.iconColor}
on:change={updateIcon}
disabled={appDeployed}
/>
</div>
<div class="actions" class:right={alignActions === "right"}>
{#if !appDeployed}
<Button
cta
on:click={async () => {
updating = true
await updateApp()
updating = false
dispatch("updated")
}}
disabled={appDeployed || updating || !edited || !$validation.valid}
>
Save
</Button>
{:else}
<div class="edit-info">
<Icon size="S" name="Info" /> Unpublish your app to edit name and URL
</div>
{/if}
</div>
</div>
</div>
<style>
.actions {
display: flex;
}
.actions.right {
justify-content: end;
}
.fields {
display: grid;
grid-gap: var(--spacing-l);
}
.field {
display: grid;
grid-template-columns: 80px 220px;
grid-gap: var(--spacing-l);
align-items: center;
}
.edit-info {
display: flex;
gap: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,68 @@
<script>
import { Popover, Layout, Icon } from "@budibase/bbui"
import UpdateAppForm from "./UpdateAppForm.svelte"
let formPopover
let formPopoverAnchor
let formPopoverOpen = false
</script>
<div bind:this={formPopoverAnchor}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="app-heading"
class:editing={formPopoverOpen}
on:click={() => {
formPopover.show()
}}
>
<slot />
<span class="edit-icon">
<Icon size="S" name="Edit" color={"var(--grey-7)"} />
</span>
</div>
</div>
<Popover
customZindex={998}
bind:this={formPopover}
align="center"
anchor={formPopoverAnchor}
offset={20}
on:close={() => {
formPopoverOpen = false
}}
on:open={() => {
formPopoverOpen = true
}}
>
<Layout noPadding gap="M">
<div class="popover-content">
<UpdateAppForm
on:updated={() => {
formPopover.hide()
}}
/>
</div>
</Layout>
</Popover>
<style>
.popover-content {
padding: var(--spacing-xl);
}
.app-heading {
display: flex;
cursor: pointer;
align-items: center;
gap: var(--spacing-s);
}
.edit-icon {
display: none;
}
.app-heading:hover .edit-icon,
.app-heading.editing .edit-icon {
display: inline;
}
</style>

View File

@ -11,6 +11,7 @@
import {
decodeJSBinding,
encodeJSBinding,
processObjectSync,
processStringSync,
} from "@budibase/string-templates"
import { readableToRuntimeBinding } from "dataBinding"
@ -153,13 +154,6 @@
debouncedEval(expression, context, snippets)
}
const getBindingValue = (binding, context, snippets) => {
const js = `return $("${binding.runtimeBinding}")`
const hbs = encodeJSBinding(js)
const res = processStringSync(hbs, { ...context, snippets })
return JSON.stringify(res, null, 2)
}
const highlightJSON = json => {
return formatHighlight(json, {
keyColor: "#e06c75",
@ -172,11 +166,27 @@
}
const enrichBindings = (bindings, context, snippets) => {
return bindings.map(binding => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
// Account for nasty hardcoded HBS bindings for roles, for legacy
// compatibility
return `{{ ${binding.runtimeBinding} }}`
} else {
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvauations = processObjectSync(bindingStrings, {
...context,
snippets,
})
// Enrich bindings with evaluations and highlighted HTML
return bindings.map((binding, idx) => {
if (!context) {
return binding
}
const value = getBindingValue(binding, context, snippets)
const value = JSON.stringify(bindingEvauations[idx], null, 2)
return {
...binding,
value,
@ -237,7 +247,12 @@
const onChangeJSValue = e => {
jsValue = encodeJSBinding(e.detail)
updateValue(jsValue)
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(jsValue)
}
}
onMount(() => {

View File

@ -75,13 +75,6 @@
if (!context || !binding.value || binding.value === "") {
return
}
// Roles have always been broken for JS. We need to exclude them from
// showing a popover as it will show "Error while executing JS".
if (binding.category === "Role") {
return
}
stopHidingPopover()
popoverAnchor = target
hoverTarget = {

View File

@ -4,7 +4,6 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"

View File

@ -28,6 +28,12 @@
let bindingDrawer
let currentVal = value
let attachmentTypes = [
FieldType.ATTACHMENT_SINGLE,
FieldType.ATTACHMENTS,
FieldType.SIGNATURE_SINGLE,
]
$: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
$: isJS = isJSBinding(value)
@ -105,6 +111,7 @@
boolean: isValidBoolean,
attachment: false,
attachment_single: false,
signature_single: false,
}
const isValid = value => {
@ -126,6 +133,7 @@
"bigint",
"barcodeqr",
"attachment",
"signature_single",
"attachment_single",
].includes(type)
) {
@ -169,7 +177,7 @@
{updateOnChange}
/>
{/if}
{#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
{#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
<div
class={`icon ${getIconClass(value, type)}`}
on:click={() => {

View File

@ -8,13 +8,11 @@
ActionButton,
Icon,
Link,
Modal,
StatusLight,
AbsTooltip,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics"
@ -26,7 +24,6 @@
isOnlyUser,
appStore,
deploymentStore,
initialise,
sortedScreens,
} from "stores/builder"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
@ -37,7 +34,6 @@
export let loaded
let unpublishModal
let updateAppModal
let revertModal
let versionModal
let appActionPopover
@ -61,11 +57,6 @@
$: canPublish = !publishing && loaded && $sortedScreens.length > 0
$: lastDeployed = getLastDeployedString($deploymentStore, lastOpened)
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($appStore.devId)
await initialise(applicationPkg)
}
const getLastDeployedString = deployments => {
return deployments?.length
? processStringSync("Published {{ duration time 'millisecond' }} ago", {
@ -247,16 +238,12 @@
appActionPopover.hide()
if (isPublished) {
viewApp()
} else {
updateAppModal.show()
}
}}
>
{$appStore.url}
{#if isPublished}
<Icon size="S" name="LinkOut" />
{:else}
<Icon size="S" name="Edit" />
{/if}
</span>
</Body>
@ -330,20 +317,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<Modal bind:this={updateAppModal} padding={false} width="600px">
<UpdateAppModal
app={{
name: $appStore.name,
url: $appStore.url,
icon: $appStore.icon,
appId: $appStore.appId,
}}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<RevertModal bind:this={revertModal} />
<VersionModal hideIcon bind:this={versionModal} />

View File

@ -76,6 +76,7 @@ const componentMap = {
"field/array": FormFieldSelect,
"field/json": FormFieldSelect,
"field/barcodeqr": FormFieldSelect,
"field/signature_single": FormFieldSelect,
"field/bb_reference": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
@ -85,6 +86,8 @@ const componentMap = {
"validation/boolean": ValidationEditor,
"validation/datetime": ValidationEditor,
"validation/attachment": ValidationEditor,
"validation/attachment_single": ValidationEditor,
"validation/signature_single": ValidationEditor,
"validation/link": ValidationEditor,
"validation/bb_reference": ValidationEditor,
}

View File

@ -24,7 +24,9 @@
parameters
}
$: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
.filter(
a => a.definition.trigger?.stepId === TriggerStepID.APP && !a.disabled
)
.map(automation => {
const schema = Object.entries(
automation.definition.trigger.inputs.fields || {}

View File

@ -1,5 +1,5 @@
<script>
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui"
import { runtimeToReadableBinding } from "dataBinding"
import { isJSBinding } from "@budibase/string-templates"

View File

@ -1,11 +1,11 @@
<script>
import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { search } from "@budibase/frontend-core"
import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
import { selectedScreen } from "stores/builder"
import { getFields } from "helpers/searchFields"
import { selectedScreen, tables } from "stores/builder"
export let componentInstance
export let value = []
@ -25,9 +25,13 @@
: enrichedSchemaFields?.map(field => field.name)
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
$: enrichedSchemaFields = getFields(Object.values(schema || {}), {
allowLinks: true,
})
$: enrichedSchemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{
allowLinks: true,
}
)
$: {
value = (value || []).filter(

View File

@ -309,7 +309,7 @@
{#if links?.length}
<DataSourceCategory
dividerState={true}
heading="Links"
heading="Relationships"
dataSet={links}
{value}
onSelect={handleSelected}

View File

@ -100,9 +100,6 @@
on:click={() => {
get(store).actions.select(draggableItem.id)
}}
on:mousedown={() => {
get(store).actions.select()
}}
bind:this={anchors[draggableItem.id]}
class:highlighted={draggableItem.id === $store.selected}
>

View File

@ -3,7 +3,6 @@
import { componentStore } from "stores/builder"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import { customPositionHandler } from "."
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
export let anchor
@ -18,76 +17,74 @@
let popover
let drawers = []
let open = false
let isOpen = false
// Auto hide the component when another item is selected
$: if (open && $draggable.selected !== componentInstance._id) {
popover.hide()
close()
}
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === componentInstance._id && popover) {
popover.show()
open = true
open()
}
$: componentDef = componentStore.getDefinition(componentInstance._component)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
const open = () => {
isOpen = true
drawers = []
$draggable.actions.select(componentInstance._id)
}
const close = () => {
// Slight delay allows us to be able to properly toggle open/close state by
// clicking again on the settings icon
setTimeout(() => {
isOpen = false
if ($draggable.selected === componentInstance._id) {
$draggable.actions.select()
}
}, 10)
}
const toggleOpen = () => {
if (isOpen) {
close()
} else {
open()
}
}
const processComponentDefinitionSettings = componentDef => {
if (!componentDef) {
return {}
}
const clone = cloneDeep(componentDef)
if (typeof parseSettings === "function") {
clone.settings = parseSettings(clone.settings)
}
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = componentStore.updateComponentSetting(setting.key, value)
patchFn(nestedComponentInstance)
dispatch("change", nestedComponentInstance)
}
</script>
<Icon
name="Settings"
hoverable
size="S"
on:click={() => {
if (!open) {
popover.show()
open = true
}
}}
/>
<Icon name="Settings" hoverable size="S" on:click={toggleOpen} />
<Popover
bind:this={popover}
on:open={() => {
drawers = []
$draggable.actions.select(componentInstance._id)
}}
on:close={() => {
open = false
if ($draggable.selected === componentInstance._id) {
$draggable.actions.select()
}
}}
open={isOpen}
on:close={close}
{anchor}
align="left-outside"
showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
offset={18}
handlePostionUpdate={customPositionHandler}
>
<span class="popover-wrap">
<Layout noPadding noGap>

View File

@ -1,18 +0,0 @@
export const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
let { left, top, offset } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - (offset || 5)
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}

View File

@ -0,0 +1,48 @@
<script>
import { ContextTooltip } from "@budibase/bbui"
import {
StringsAsDates,
NumbersAsDates,
ScalarJsonOnly,
Column,
Support,
NotRequired,
StringsAsNumbers,
DatesAsNumbers,
} from "./subjects"
import subjects from "../subjects"
export let anchor
export let schema
export let columnName
export let subject = subjects.none
</script>
<ContextTooltip visible={subject !== subjects.none} {anchor} offset={20}>
<div class="explanationModalContent">
{#if subject === subjects.column}
<Column {columnName} {schema} />
{:else if subject === subjects.support}
<Support />
{:else if subject === subjects.stringsAsNumbers}
<StringsAsNumbers />
{:else if subject === subjects.notRequired}
<NotRequired />
{:else if subject === subjects.datesAsNumbers}
<DatesAsNumbers />
{:else if subject === subjects.scalarJsonOnly}
<ScalarJsonOnly {columnName} {schema} />
{:else if subject === subjects.numbersAsDates}
<NumbersAsDates {columnName} />
{:else if subject === subjects.stringsAsDates}
<StringsAsDates {columnName} />
{/if}
</div>
</ContextTooltip>
<style>
.explanationModalContent {
max-width: 300px;
padding: 16px 12px 2px;
}
</style>

View File

@ -0,0 +1,147 @@
<script>
import { tables } from "stores/builder"
import {
BindingValue,
Block,
Subject,
JSONValue,
Property,
Section,
} from "./components"
export let schema
export let columnName
const parseDate = isoString => {
if ([null, undefined, ""].includes(isoString)) {
return "None"
}
const unixTime = Date.parse(isoString)
const date = new Date(unixTime)
return date.toLocaleString()
}
</script>
<Subject>
<div class="heading" slot="heading">
Column Overview for <Block>{columnName}</Block>
</div>
<Section>
{#if schema.type === "string"}
<Property
name="Max Length"
value={schema?.constraints?.length?.maximum ?? "None"}
/>
{:else if schema.type === "datetime"}
<Property
name="Earliest"
value={parseDate(schema?.constraints?.datetime?.earliest)}
/>
<Property
name="Latest"
value={parseDate(schema?.constraints?.datetime?.latest)}
/>
<Property
name="Ignore time zones"
value={schema?.ignoreTimeZones === true ? "Yes" : "No"}
/>
<Property
name="Date only"
value={schema?.dateOnly === true ? "Yes" : "No"}
/>
{:else if schema.type === "number"}
<Property
name="Min Value"
value={[null, undefined, ""].includes(
schema?.constraints?.numericality?.greaterThanOrEqualTo
)
? "None"
: schema?.constraints?.numericality?.greaterThanOrEqualTo}
/>
<Property
name="Max Value"
value={[null, undefined, ""].includes(
schema?.constraints?.numericality?.lessThanOrEqualTo
)
? "None"
: schema?.constraints?.numericality?.lessThanOrEqualTo}
/>
{:else if schema.type === "array"}
{#each schema?.constraints?.inclusion ?? [] as option, index}
<Property name={`Option ${index + 1}`} truncate>
<span
style:background-color={schema?.optionColors?.[option]}
class="optionCircle"
/>{option}
</Property>
{/each}
{:else if schema.type === "options"}
{#each schema?.constraints?.inclusion ?? [] as option, index}
<Property name={`Option ${index + 1}`} truncate>
<span
style:background-color={schema?.optionColors?.[option]}
class="optionCircle"
/>{option}
</Property>
{/each}
{:else if schema.type === "json"}
<Property name="Schema">
<JSONValue value={JSON.stringify(schema?.schema ?? {}, null, 2)} />
</Property>
{:else if schema.type === "formula"}
<Property name="Formula">
<BindingValue value={schema?.formula} />
</Property>
<Property
name="Formula type"
value={schema?.formulaType === "dynamic" ? "Dynamic" : "Static"}
/>
{:else if schema.type === "link"}
<Property name="Type" value={schema?.relationshipType} />
<Property
name="Related Table"
value={$tables?.list?.find(table => table._id === schema?.tableId)
?.name}
/>
<Property name="Column in Related Table" value={schema?.fieldName} />
{:else if schema.type === "bb_reference"}
<Property
name="Allow multiple users"
value={schema?.relationshipType === "many-to-many" ? "Yes" : "No"}
/>
{/if}
<Property
name="Required"
value={schema?.constraints?.presence?.allowEmpty === false ? "Yes" : "No"}
/>
</Section>
</Subject>
<style>
.heading {
display: flex;
align-items: center;
}
.heading > :global(.block) {
margin-left: 4px;
flex-grow: 0;
flex-shrink: 1;
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.optionCircle {
display: inline-block;
background-color: hsla(0, 1%, 50%, 0.3);
border-radius: 100%;
width: 10px;
height: 10px;
vertical-align: middle;
margin-right: 5px;
}
</style>

View File

@ -0,0 +1,63 @@
<script>
import { onMount } from "svelte"
import {
ExampleSection,
ExampleLine,
Block,
Subject,
Section,
} from "./components"
let timestamp = Date.now()
onMount(() => {
let run = true
const updateTimeStamp = () => {
timestamp = Date.now()
if (run) {
setTimeout(updateTimeStamp, 200)
}
}
updateTimeStamp()
return () => {
run = false
}
})
</script>
<Subject heading="Dates as Numbers">
<Section>
A datetime value can be used in place of a numeric value, but it will be
converted to a <Block>UNIX time</Block> timestamp, which is the number of milliseconds
since Jan 1st 1970. A more recent moment in time will be a higher number.
</Section>
<ExampleSection heading="Examples:">
<ExampleLine>
<Block>
{new Date(946684800000).toLocaleString()}
</Block>
<span class="separator">{"->"} </span><Block>946684800000</Block>
</ExampleLine>
<ExampleLine>
<Block>
{new Date(1577836800000).toLocaleString()}
</Block>
<span class="separator">{"->"} </span><Block>1577836800000</Block>
</ExampleLine>
<ExampleLine>
<Block>Now</Block><span class="separator">{"->"} </span><Block
>{timestamp}</Block
>
</ExampleLine>
</ExampleSection>
</Subject>
<style>
.separator {
margin: 0 5px;
}
</style>

View File

@ -0,0 +1,11 @@
<script>
import { Block, Subject, Section } from "./components"
</script>
<Subject heading="Required Constraint">
<Section>
A <Block>required</Block> constraint can be applied to columns to ensure a value
is always present. If a column doesn't have this constraint, then its value for
a particular row could he missing.
</Section>
</Subject>

View File

@ -0,0 +1,65 @@
<script>
import { onMount } from "svelte"
import {
ExampleSection,
ExampleLine,
Block,
Subject,
Section,
} from "./components"
let timestamp = Date.now()
onMount(() => {
let run = true
const updateTimeStamp = () => {
timestamp = Date.now()
if (run) {
setTimeout(updateTimeStamp, 200)
}
}
updateTimeStamp()
return () => {
run = false
}
})
</script>
<Subject heading="Numbers as Dates">
<Section>
A number value can be used in place of a datetime value, but it will be
parsed as a <Block>UNIX time</Block> timestamp, which is the number of milliseconds
since Jan 1st 1970. A more recent moment in time will be a higher number.
</Section>
<ExampleSection heading="Examples:">
<ExampleLine>
<Block>946684800000</Block>
<span class="separator">{"->"}</span>
<Block>
{new Date(946684800000).toLocaleString()}
</Block>
</ExampleLine>
<ExampleLine>
<Block>1577836800000</Block>
<span class="separator">{"->"}</span>
<Block>
{new Date(1577836800000).toLocaleString()}
</Block>
</ExampleLine>
<ExampleLine>
<Block>{timestamp}</Block>
<span class="separator">{"->"}</span>
<Block>Now</Block>
</ExampleLine>
</ExampleSection>
</Subject>
<style>
.separator {
margin: 0 5px;
}
</style>

View File

@ -0,0 +1,71 @@
<script>
import {
ExampleSection,
ExampleLine,
Block,
Subject,
Section,
} from "./components"
export let schema
export let columnName
const maxScalarDescendantsToFind = 3
const getScalarDescendants = schema => {
const newScalarDescendants = []
const getScalarDescendantFromSchema = (path, schema) => {
if (newScalarDescendants.length >= maxScalarDescendantsToFind) {
return
}
if (["string", "number", "boolean"].includes(schema.type)) {
newScalarDescendants.push({ name: path.join("."), type: schema.type })
} else if (schema.type === "json") {
Object.entries(schema.schema ?? {}).forEach(
([childName, childSchema]) =>
getScalarDescendantFromSchema([...path, childName], childSchema)
)
}
}
Object.entries(schema?.schema ?? {}).forEach(([childName, childSchema]) =>
getScalarDescendantFromSchema([columnName, childName], childSchema)
)
return newScalarDescendants
}
$: scalarDescendants = getScalarDescendants(schema)
</script>
<Subject heading="Using Scalar JSON Values">
<Section>
<Block>JSON objects</Block> can't be used here, but any <Block>number</Block
>, <Block>string</Block> or <Block>boolean</Block> values nested within said
object can be if they are otherwise compatible with the input. These scalar values
can be selected from the same menu as this parent and take the form <Block
>parent.child</Block
>.
</Section>
{#if scalarDescendants.length > 0}
<ExampleSection heading="Examples scalar descendants of this object:">
{#each scalarDescendants as descendant}
<ExampleLine>
<Block truncate>{descendant.name}</Block><span class="separator"
>-</span
><Block truncate noShrink>{descendant.type}</Block>
</ExampleLine>
{/each}
</ExampleSection>
{/if}
</Subject>
<style>
.separator {
margin: 0 4px;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,107 @@
<script>
import { onMount } from "svelte"
import {
ExampleSection,
ExampleLine,
Block,
Subject,
Section,
} from "./components"
let timestamp = Date.now()
$: iso = new Date(timestamp).toISOString()
onMount(() => {
let run = true
const updateTimeStamp = () => {
timestamp = Date.now()
if (run) {
setTimeout(updateTimeStamp, 200)
}
}
updateTimeStamp()
return () => {
run = false
}
})
</script>
<Subject heading="Strings as Dates">
<Section>
A string value can be used in place of a datetime value, but it will be
parsed as:
</Section>
<Section>
A <Block>UNIX time</Block> timestamp, which is the number of milliseconds since
Jan 1st 1970. A more recent moment in time will be a higher number.
</Section>
<ExampleSection heading="Examples:">
<ExampleLine>
<Block>946684800000</Block>
<span class="separator">{"->"}</span>
<Block>
{new Date(946684800000).toLocaleString()}
</Block>
</ExampleLine>
<ExampleLine>
<Block>1577836800000</Block>
<span class="separator">{"->"}</span>
<Block>
{new Date(1577836800000).toLocaleString()}
</Block>
</ExampleLine>
<ExampleLine>
<Block>{timestamp}</Block>
<span class="separator">{"->"}</span>
<Block>Now</Block>
</ExampleLine>
</ExampleSection>
<Section>
An <Block>ISO 8601</Block> datetime string, which represents an exact moment
in time as well as the potentional to store the timezone it occured in.
</Section>
<div class="isoExamples">
<ExampleSection heading="Examples:">
<ExampleLine>
<Block>2000-01-01T00:00:00.000Z</Block>
<span class="separator"></span>
<Block>
{new Date(946684800000).toLocaleString()}
</Block>
</ExampleLine>
<ExampleLine>
<Block>2000-01-01T00:00:00.000Z</Block>
<span class="separator"></span>
<Block>
{new Date(1577836800000).toLocaleString()}
</Block>
</ExampleLine>
<ExampleLine>
<Block>{iso}</Block>
<span class="separator"></span>
<Block>Now</Block>
</ExampleLine>
</ExampleSection>
</div>
</Subject>
<style>
.separator {
margin: 0 5px;
}
.isoExamples :global(.block) {
word-break: break-all;
}
.isoExamples :global(.exampleLine) {
align-items: center;
flex-direction: column;
margin-bottom: 16px;
width: 162px;
}
</style>

View File

@ -0,0 +1,56 @@
<script>
import {
ExampleSection,
ExampleLine,
Block,
Subject,
Section,
} from "./components"
</script>
<Subject heading="Text as Numbers">
<Section>
Text can be used in place of numbers in certain scenarios, but care needs to
be taken; if the value isn't purely numerical it may be converted in an
unexpected way.
</Section>
<ExampleSection heading="Examples:">
<ExampleLine>
<Block>"100"</Block><span class="separator">{"->"}</span><Block>100</Block
>
</ExampleLine>
<ExampleLine>
<Block>"100k"</Block><span class="separator">{"->"}</span><Block
>100</Block
>
</ExampleLine>
<ExampleLine>
<Block>"100,000"</Block><span class="separator">{"->"}</span><Block
>100</Block
>
</ExampleLine>
<ExampleLine>
<Block>"100 million"</Block><span class="separator">{"->"}</span><Block
>100</Block
>
</ExampleLine>
<ExampleLine>
<Block>"100.9"</Block><span class="separator">{"->"}</span><Block
>100.9</Block
>
</ExampleLine>
<ExampleLine>
<Block>"One hundred"</Block><span class="separator">{"->"}</span><Block
>Error</Block
>
</ExampleLine>
</ExampleSection>
</Subject>
<style>
.separator {
margin: 0 4px;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,35 @@
<script>
import { InfoWord } from "../../typography"
import { Subject, Section } from "./components"
</script>
<Subject heading="Data/Component Compatibility">
<Section>
<InfoWord icon="CheckmarkCircle" color="var(--green)" text="Compatible" />
<span class="body"
>Fully compatible with the input as long as the data is present.</span
>
</Section>
<Section>
<InfoWord
icon="AlertCheck"
color="var(--yellow)"
text="Partially compatible"
/>
<span class="body"
>Partially compatible with the input, but beware of other caveats
mentioned.</span
>
</Section>
<Section>
<InfoWord icon="Alert" color="var(--red)" text="Not compatible" />
<span class="body">Incompatible with the component.</span>
</Section>
</Subject>
<style>
.body {
display: block;
margin-top: 5px;
}
</style>

View File

@ -0,0 +1,39 @@
<script>
import { decodeJSBinding } from "@budibase/string-templates"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import { EditorModes } from "components/common/CodeEditor"
import {
runtimeToReadableBinding,
getDatasourceForProvider,
} from "dataBinding"
import { tables, selectedScreen, selectedComponent } from "stores/builder"
import { getBindings } from "components/backend/DataTable/formula"
export let value
$: datasource = getDatasourceForProvider($selectedScreen, $selectedComponent)
$: tableId = datasource.tableId
$: table = $tables?.list?.find(table => table._id === tableId)
$: bindings = getBindings({ table })
$: readableBinding = runtimeToReadableBinding(bindings, value)
$: isJs = value?.startsWith?.("{{ js ")
</script>
<div class="editor">
<CodeEditor
readonly
readonlyLineNumbers
value={isJs ? decodeJSBinding(readableBinding) : readableBinding}
jsBindingWrapping={isJs}
mode={isJs ? EditorModes.JS : EditorModes.Handlebars}
/>
</div>
<style>
.editor {
border: 1px solid var(--grey-2);
border-radius: 2px;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,30 @@
<script>
export let truncate = false
export let noShrink = false
</script>
<span class:truncate class:noShrink class="block">
<slot />
</span>
<style>
.block {
font-style: italic;
border-radius: 1px;
padding: 0px 5px 0px 3px;
border-radius: 1px;
background-color: var(--grey-3);
color: var(--ink);
word-break: break-word;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.noShrink {
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,12 @@
<li>
<div class="exampleLine">
<slot />
</div>
</li>
<style>
.exampleLine {
display: flex;
margin-bottom: 2px;
}
</style>

View File

@ -0,0 +1,32 @@
<script>
import Section from "./Section.svelte"
export let heading
</script>
<Section>
<span class="exampleSectionHeading">
<slot name="heading">
{heading}
</slot>
</span>
<ul>
<slot />
</ul>
</Section>
<style>
.exampleSectionHeading {
display: inline-block;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
ul {
padding: 0 0 0 23px;
margin: 0;
}
</style>

View File

@ -0,0 +1,22 @@
<script>
export let value
</script>
<pre class="pre">
{value}
</pre>
<style>
.pre {
border: 1px solid var(--grey-2);
border-radius: 2px;
overflow: hidden;
margin: 0;
margin-top: 3px;
padding: 4px;
border-radius: 3px;
width: 250px;
box-sizing: border-box;
background-color: black;
}
</style>

View File

@ -0,0 +1,49 @@
<script>
export let name
export let value
export let truncate = false
</script>
<div class:truncate class="property">
<span class="propertyName">
<slot name="name">
{name}
</slot>
</span>
<span class="propertyDivider">-</span>
<span class="propertyValue">
<slot>
{value}
</slot>
</span>
</div>
<style>
.property {
max-width: 100%;
margin-bottom: 8px;
}
.truncate {
display: flex;
align-items: center;
overflow: hidden;
}
.propertyName {
font-weight: 600;
flex-shrink: 0;
}
.propertyDivider {
padding: 0 4px;
flex-shrink: 0;
}
.propertyValue {
word-break: break-word;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

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