merge
This commit is contained in:
commit
ec892a13b6
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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[@]}"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.26.3",
|
||||
"version": "2.27.6",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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:
|
|
@ -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 !== ""
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -173,6 +173,7 @@
|
|||
}
|
||||
|
||||
.spectrum-Modal {
|
||||
border: 2px solid var(--spectrum-global-color-gray-200);
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
margin: 40px 0;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -61,6 +61,7 @@
|
|||
selected={automation._id === selectedAutomationId}
|
||||
on:click={() => selectAutomation(automation._id)}
|
||||
selectedBy={$userSelectedResourceMap[automation._id]}
|
||||
disabled={automation.disabled}
|
||||
>
|
||||
<EditAutomationPopover {automation} />
|
||||
</NavItem>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -54,6 +54,10 @@
|
|||
label: "Attachment",
|
||||
value: FieldType.ATTACHMENT_SINGLE,
|
||||
},
|
||||
{
|
||||
label: "Signature",
|
||||
value: FieldType.SIGNATURE_SINGLE,
|
||||
},
|
||||
{
|
||||
label: "Attachment list",
|
||||
value: FieldType.ATTACHMENTS,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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={() => {
|
||||
|
|
|
@ -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} />
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 || {}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -309,7 +309,7 @@
|
|||
{#if links?.length}
|
||||
<DataSourceCategory
|
||||
dividerState={true}
|
||||
heading="Links"
|
||||
heading="Relationships"
|
||||
dataSet={links}
|
||||
{value}
|
||||
onSelect={handleSelected}
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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>
|
|
@ -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 }
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,12 @@
|
|||
<li>
|
||||
<div class="exampleLine">
|
||||
<slot />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.exampleLine {
|
||||
display: flex;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
Loading…
Reference in New Issue