merge
This commit is contained in:
commit
ec892a13b6
|
@ -55,7 +55,9 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"no-redeclare": "off",
|
"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",
|
"jest/expect-expect": "off",
|
||||||
// We do this in some tests where the behaviour of internal tables
|
// We do this in some tests where the behaviour of internal tables
|
||||||
// differs to external, but the API is broadly the same
|
// 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:
|
jobs:
|
||||||
ensure-is-master-tag:
|
ensure-is-master-tag:
|
||||||
name: Ensure is a master tag
|
name: Ensure is a master tag
|
||||||
runs-on: qa-arc-runner-set
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout monorepo
|
- name: Checkout monorepo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
|
@ -17,6 +17,6 @@ version: 0.0.0
|
||||||
appVersion: 0.0.0
|
appVersion: 0.0.0
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: couchdb
|
- name: couchdb
|
||||||
version: 4.3.0
|
version: 4.5.3
|
||||||
repository: https://apache.github.io/couchdb-helm
|
repository: https://apache.github.io/couchdb-helm
|
||||||
condition: services.couchdb.enabled
|
condition: services.couchdb.enabled
|
||||||
|
|
|
@ -29,6 +29,7 @@ services:
|
||||||
BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL}
|
BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL}
|
||||||
BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD}
|
BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD}
|
||||||
PLUGINS_DIR: ${PLUGINS_DIR}
|
PLUGINS_DIR: ${PLUGINS_DIR}
|
||||||
|
SQS_SEARCH_ENABLE: 1
|
||||||
depends_on:
|
depends_on:
|
||||||
- worker-service
|
- worker-service
|
||||||
- redis-service
|
- redis-service
|
||||||
|
@ -56,6 +57,7 @@ services:
|
||||||
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
|
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
|
||||||
REDIS_URL: redis-service:6379
|
REDIS_URL: redis-service:6379
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
SQS_SEARCH_ENABLE: 1
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis-service
|
- redis-service
|
||||||
- minio-service
|
- minio-service
|
||||||
|
|
|
@ -42,12 +42,13 @@ services:
|
||||||
couchdb-service:
|
couchdb-service:
|
||||||
container_name: budi-couchdb3-dev
|
container_name: budi-couchdb3-dev
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
image: budibase/couchdb
|
image: budibase/couchdb:v3.2.1-sqs
|
||||||
environment:
|
environment:
|
||||||
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
|
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
|
||||||
- COUCHDB_USER=${COUCH_DB_USER}
|
- COUCHDB_USER=${COUCH_DB_USER}
|
||||||
ports:
|
ports:
|
||||||
- "${COUCH_DB_PORT}:5984"
|
- "${COUCH_DB_PORT}:5984"
|
||||||
|
- "${COUCH_DB_SQS_PORT}:4984"
|
||||||
volumes:
|
volumes:
|
||||||
- couchdb_data:/data
|
- couchdb_data:/data
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ http {
|
||||||
set $csp_img "img-src http: https: data: blob:";
|
set $csp_img "img-src http: https: data: blob:";
|
||||||
set $csp_manifest "manifest-src 'self'";
|
set $csp_manifest "manifest-src 'self'";
|
||||||
set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live";
|
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;
|
error_page 502 503 504 /error.html;
|
||||||
location = /error.html {
|
location = /error.html {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
ARG BASEIMG=budibase/couchdb:v3.3.3
|
||||||
FROM node:20-slim as build
|
FROM node:20-slim as build
|
||||||
|
|
||||||
# install node-gyp dependencies
|
# 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
|
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
|
ARG TARGETARCH
|
||||||
ENV TARGETARCH $TARGETARCH
|
ENV TARGETARCH $TARGETARCH
|
||||||
#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service)
|
#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
|
if [[ -z "${COUCH_DB_URL}" ]]; then
|
||||||
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@127.0.0.1:5984
|
export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@127.0.0.1:5984
|
||||||
fi
|
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
|
if [ ! -f "${DATA_DIR}/.env" ]; then
|
||||||
touch ${DATA_DIR}/.env
|
touch ${DATA_DIR}/.env
|
||||||
for ENV_VAR in "${ENV_VARS[@]}"
|
for ENV_VAR in "${ENV_VARS[@]}"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.26.3",
|
"version": "2.27.6",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -35,10 +35,12 @@
|
||||||
"get-past-client-version": "node scripts/getPastClientVersion.js",
|
"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",
|
"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": "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: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: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",
|
"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",
|
"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",
|
"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",
|
"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: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: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": "./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",
|
"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": "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.2.1-sqs --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",
|
"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",
|
"release:helm": "node scripts/releaseHelmChart",
|
||||||
"env:multi:enable": "lerna run --stream env:multi:enable",
|
"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",
|
"sanitize-s3-objectkey": "0.0.1",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"tar-fs": "2.1.1",
|
"tar-fs": "2.1.1",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2",
|
||||||
|
"knex": "2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shopify/jest-koa-mocks": "5.1.1",
|
"@shopify/jest-koa-mocks": "5.1.1",
|
||||||
|
|
|
@ -65,5 +65,11 @@ export const StaticDatabases = {
|
||||||
export const APP_PREFIX = prefixed(DocumentType.APP)
|
export const APP_PREFIX = prefixed(DocumentType.APP)
|
||||||
export const APP_DEV = prefixed(DocumentType.APP_DEV)
|
export const APP_DEV = prefixed(DocumentType.APP_DEV)
|
||||||
export const APP_DEV_PREFIX = APP_DEV
|
export const APP_DEV_PREFIX = APP_DEV
|
||||||
|
export const SQS_DATASOURCE_INTERNAL = "internal"
|
||||||
export const BUDIBASE_DATASOURCE_TYPE = "budibase"
|
export const BUDIBASE_DATASOURCE_TYPE = "budibase"
|
||||||
export const SQLITE_DESIGN_DOC_ID = "_design/sqlite"
|
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 { 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
|
// keep this out of Budibase types, don't want to expose context info
|
||||||
export type ContextMap = {
|
export type ContextMap = {
|
||||||
|
@ -12,4 +14,8 @@ export type ContextMap = {
|
||||||
vm?: VM
|
vm?: VM
|
||||||
cleanup?: (() => void | Promise<void>)[]
|
cleanup?: (() => void | Promise<void>)[]
|
||||||
snippets?: Snippet[]
|
snippets?: Snippet[]
|
||||||
|
googleSheets?: {
|
||||||
|
oauthClient: OAuth2Client
|
||||||
|
clients: Record<string, GoogleSpreadsheet>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,31 @@
|
||||||
import PouchDB from "pouchdb"
|
import PouchDB from "pouchdb"
|
||||||
import { getPouchDB, closePouchDB } from "./couch"
|
import { getPouchDB, closePouchDB } from "./couch"
|
||||||
import { DocumentType } from "../constants"
|
import { DocumentType } from "@budibase/types"
|
||||||
|
|
||||||
|
enum ReplicationDirection {
|
||||||
|
TO_PRODUCTION = "toProduction",
|
||||||
|
TO_DEV = "toDev",
|
||||||
|
}
|
||||||
|
|
||||||
class Replication {
|
class Replication {
|
||||||
source: PouchDB.Database
|
source: PouchDB.Database
|
||||||
target: PouchDB.Database
|
target: PouchDB.Database
|
||||||
|
direction: ReplicationDirection | undefined
|
||||||
|
|
||||||
constructor({ source, target }: { source: string; target: string }) {
|
constructor({ source, target }: { source: string; target: string }) {
|
||||||
this.source = getPouchDB(source)
|
this.source = getPouchDB(source)
|
||||||
this.target = getPouchDB(target)
|
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() {
|
async close() {
|
||||||
|
@ -40,12 +57,18 @@ class Replication {
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = opts.filter
|
const filter = opts.filter
|
||||||
|
const direction = this.direction
|
||||||
|
const toDev = direction === ReplicationDirection.TO_DEV
|
||||||
delete opts.filter
|
delete opts.filter
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...opts,
|
...opts,
|
||||||
filter: (doc: any, params: any) => {
|
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
|
return false
|
||||||
}
|
}
|
||||||
if (doc._id === DocumentType.APP_METADATA) {
|
if (doc._id === DocumentType.APP_METADATA) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
isDocument,
|
isDocument,
|
||||||
RowResponse,
|
RowResponse,
|
||||||
RowValue,
|
RowValue,
|
||||||
|
SQLiteDefinition,
|
||||||
SqlQueryBinding,
|
SqlQueryBinding,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { getCouchInfo } from "./connections"
|
import { getCouchInfo } from "./connections"
|
||||||
|
@ -21,6 +22,8 @@ import { ReadStream, WriteStream } from "fs"
|
||||||
import { newid } from "../../docIds/newid"
|
import { newid } from "../../docIds/newid"
|
||||||
import { SQLITE_DESIGN_DOC_ID } from "../../constants"
|
import { SQLITE_DESIGN_DOC_ID } from "../../constants"
|
||||||
import { DDInstrumentedDatabase } from "../instrumentation"
|
import { DDInstrumentedDatabase } from "../instrumentation"
|
||||||
|
import { checkSlashesInUrl } from "../../helpers"
|
||||||
|
import env from "../../environment"
|
||||||
|
|
||||||
const DATABASE_NOT_FOUND = "Database does not exist."
|
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>(
|
async sql<T extends Document>(
|
||||||
sql: string,
|
sql: string,
|
||||||
parameters?: SqlQueryBinding
|
parameters?: SqlQueryBinding
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const dbName = this.name
|
const dbName = this.name
|
||||||
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
|
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
|
||||||
const response = await directCouchUrlCall({
|
return await this._sqlQuery<T[]>(url, "POST", {
|
||||||
url: `${this.couchInfo.sqlUrl}/${url}`,
|
query: sql,
|
||||||
method: "POST",
|
args: parameters,
|
||||||
cookie: this.couchInfo.cookie,
|
|
||||||
body: {
|
|
||||||
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>(
|
async query<T extends Document>(
|
||||||
|
@ -314,6 +353,17 @@ export class DatabaseImpl implements Database {
|
||||||
|
|
||||||
async destroy() {
|
async destroy() {
|
||||||
try {
|
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)
|
return await this.nano().db.destroy(this.name)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// didn't exist, don't worry
|
// didn't exist, don't worry
|
||||||
|
|
|
@ -21,7 +21,7 @@ export async function directCouchUrlCall({
|
||||||
url: string
|
url: string
|
||||||
cookie: string
|
cookie: string
|
||||||
method: string
|
method: string
|
||||||
body?: any
|
body?: Record<string, any>
|
||||||
}) {
|
}) {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
method: method,
|
method: method,
|
||||||
|
|
|
@ -56,12 +56,17 @@ export class DDInstrumentedDatabase implements Database {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove(idOrDoc: Document): Promise<DocumentDestroyResponse>
|
||||||
|
remove(idOrDoc: string, rev?: string): Promise<DocumentDestroyResponse>
|
||||||
remove(
|
remove(
|
||||||
id: string | Document,
|
idOrDoc: string | Document,
|
||||||
rev?: string | undefined
|
rev?: string
|
||||||
): Promise<DocumentDestroyResponse> {
|
): Promise<DocumentDestroyResponse> {
|
||||||
return tracer.trace("db.remove", span => {
|
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)
|
return this.db.remove(id, rev)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -160,4 +165,18 @@ export class DDInstrumentedDatabase implements Database {
|
||||||
return this.db.sql(sql, parameters)
|
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()}`
|
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.
|
* Gets a new row ID for the specified table.
|
||||||
* @param tableId The table which the row is being created for.
|
* @param tableId The table which the row is being created for.
|
||||||
|
|
|
@ -109,6 +109,7 @@ const environment = {
|
||||||
API_ENCRYPTION_KEY: getAPIEncryptionKey(),
|
API_ENCRYPTION_KEY: getAPIEncryptionKey(),
|
||||||
COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005",
|
COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005",
|
||||||
COUCH_DB_SQL_URL: process.env.COUCH_DB_SQL_URL || "http://localhost:4006",
|
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_USERNAME: process.env.COUCH_DB_USER,
|
||||||
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
|
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
|
||||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
@ -158,6 +159,9 @@ const environment = {
|
||||||
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
|
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
|
||||||
HTTP_LOGGING: httpLogging(),
|
HTTP_LOGGING: httpLogging(),
|
||||||
ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR,
|
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
|
||||||
SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED,
|
SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED,
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
|
|
@ -34,6 +34,7 @@ export * as docUpdates from "./docUpdates"
|
||||||
export * from "./utils/Duration"
|
export * from "./utils/Duration"
|
||||||
export * as docIds from "./docIds"
|
export * as docIds from "./docIds"
|
||||||
export * as security from "./security"
|
export * as security from "./security"
|
||||||
|
export * as sql from "./sql"
|
||||||
// Add context to tenancy for backwards compatibility
|
// Add context to tenancy for backwards compatibility
|
||||||
// only do this for external usages to prevent internal
|
// only do this for external usages to prevent internal
|
||||||
// circular dependencies
|
// circular dependencies
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { v4 } from "uuid"
|
||||||
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
|
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
|
||||||
import fsp from "fs/promises"
|
import fsp from "fs/promises"
|
||||||
import { HeadObjectOutput } from "aws-sdk/clients/s3"
|
import { HeadObjectOutput } from "aws-sdk/clients/s3"
|
||||||
|
import { ReadableStream } from "stream/web"
|
||||||
|
|
||||||
const streamPipeline = promisify(stream.pipeline)
|
const streamPipeline = promisify(stream.pipeline)
|
||||||
// use this as a temporary store of buckets that are being created
|
// use this as a temporary store of buckets that are being created
|
||||||
|
@ -41,10 +42,7 @@ type UploadParams = BaseUploadParams & {
|
||||||
path?: string | PathLike
|
path?: string | PathLike
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StreamTypes =
|
export type StreamTypes = ReadStream | NodeJS.ReadableStream
|
||||||
| ReadStream
|
|
||||||
| NodeJS.ReadableStream
|
|
||||||
| ReadableStream<Uint8Array>
|
|
||||||
|
|
||||||
export type StreamUploadParams = BaseUploadParams & {
|
export type StreamUploadParams = BaseUploadParams & {
|
||||||
stream?: StreamTypes
|
stream?: StreamTypes
|
||||||
|
@ -222,6 +220,9 @@ export async function streamUpload({
|
||||||
extra,
|
extra,
|
||||||
ttl,
|
ttl,
|
||||||
}: StreamUploadParams) {
|
}: StreamUploadParams) {
|
||||||
|
if (!stream) {
|
||||||
|
throw new Error("Stream to upload is invalid/undefined")
|
||||||
|
}
|
||||||
const extension = filename.split(".").pop()
|
const extension = filename.split(".").pop()
|
||||||
const objectStore = ObjectStore(bucketName)
|
const objectStore = ObjectStore(bucketName)
|
||||||
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
|
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
|
||||||
|
@ -251,14 +252,27 @@ export async function streamUpload({
|
||||||
: CONTENT_TYPE_MAP.txt
|
: CONTENT_TYPE_MAP.txt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bucket = sanitizeBucket(bucketName),
|
||||||
|
objKey = sanitizeKey(filename)
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: sanitizeBucket(bucketName),
|
Bucket: bucket,
|
||||||
Key: sanitizeKey(filename),
|
Key: objKey,
|
||||||
Body: stream,
|
Body: stream,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
...extra,
|
...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 { Knex, knex } from "knex"
|
||||||
import { db as dbCore } from "@budibase/backend-core"
|
import * as dbCore from "../db"
|
||||||
import { QueryOptions } from "../../definitions/datasource"
|
|
||||||
import {
|
import {
|
||||||
isIsoDateString,
|
isIsoDateString,
|
||||||
SqlClient,
|
|
||||||
isValidFilter,
|
isValidFilter,
|
||||||
getNativeSql,
|
getNativeSql,
|
||||||
SqlStatements,
|
isExternalTable,
|
||||||
} from "../utils"
|
} from "./utils"
|
||||||
|
import { SqlStatements } from "./sqlStatements"
|
||||||
import SqlTableQueryBuilder from "./sqlTable"
|
import SqlTableQueryBuilder from "./sqlTable"
|
||||||
import {
|
import {
|
||||||
BBReferenceFieldMetadata,
|
BBReferenceFieldMetadata,
|
||||||
|
@ -24,8 +23,12 @@ import {
|
||||||
Table,
|
Table,
|
||||||
TableSourceType,
|
TableSourceType,
|
||||||
INTERNAL_TABLE_SOURCE_ID,
|
INTERNAL_TABLE_SOURCE_ID,
|
||||||
|
SqlClient,
|
||||||
|
QueryOptions,
|
||||||
|
JsonTypes,
|
||||||
|
prefixed,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import environment from "../../environment"
|
import environment from "../environment"
|
||||||
import { helpers } from "@budibase/shared-core"
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
|
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
|
||||||
|
@ -45,6 +48,7 @@ function likeKey(client: string, key: string): string {
|
||||||
case SqlClient.MY_SQL:
|
case SqlClient.MY_SQL:
|
||||||
start = end = "`"
|
start = end = "`"
|
||||||
break
|
break
|
||||||
|
case SqlClient.SQL_LITE:
|
||||||
case SqlClient.ORACLE:
|
case SqlClient.ORACLE:
|
||||||
case SqlClient.POSTGRES:
|
case SqlClient.POSTGRES:
|
||||||
start = end = '"'
|
start = end = '"'
|
||||||
|
@ -53,9 +57,6 @@ function likeKey(client: string, key: string): string {
|
||||||
start = "["
|
start = "["
|
||||||
end = "]"
|
end = "]"
|
||||||
break
|
break
|
||||||
case SqlClient.SQL_LITE:
|
|
||||||
start = end = "'"
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
throw new Error("Unknown client generating like key")
|
throw new Error("Unknown client generating like key")
|
||||||
}
|
}
|
||||||
|
@ -122,11 +123,8 @@ function generateSelectStatement(
|
||||||
const fieldNames = field.split(/\./g)
|
const fieldNames = field.split(/\./g)
|
||||||
const tableName = fieldNames[0]
|
const tableName = fieldNames[0]
|
||||||
const columnName = fieldNames[1]
|
const columnName = fieldNames[1]
|
||||||
if (
|
const columnSchema = schema?.[columnName]
|
||||||
columnName &&
|
if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
|
||||||
schema?.[columnName] &&
|
|
||||||
knex.client.config.client === SqlClient.POSTGRES
|
|
||||||
) {
|
|
||||||
const externalType = schema[columnName].externalType
|
const externalType = schema[columnName].externalType
|
||||||
if (externalType?.includes("money")) {
|
if (externalType?.includes("money")) {
|
||||||
return knex.raw(
|
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}`
|
return `${field} as ${field}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -202,17 +208,20 @@ class InternalBuilder {
|
||||||
const updatedKey = dbCore.removeKeyNumbering(key)
|
const updatedKey = dbCore.removeKeyNumbering(key)
|
||||||
const isRelationshipField = updatedKey.includes(".")
|
const isRelationshipField = updatedKey.includes(".")
|
||||||
if (!opts.relationship && !isRelationshipField) {
|
if (!opts.relationship && !isRelationshipField) {
|
||||||
fn(`${getTableAlias(tableName)}.${updatedKey}`, value)
|
const alias = getTableAlias(tableName)
|
||||||
|
fn(alias ? `${alias}.${updatedKey}` : updatedKey, value)
|
||||||
}
|
}
|
||||||
if (opts.relationship && isRelationshipField) {
|
if (opts.relationship && isRelationshipField) {
|
||||||
const [filterTableName, property] = updatedKey.split(".")
|
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 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
|
// postgres supports ilike, nothing else does
|
||||||
if (this.client === SqlClient.POSTGRES) {
|
if (this.client === SqlClient.POSTGRES) {
|
||||||
query = query[fnc](key, "ilike", `%${value}%`)
|
query = query[fnc](key, "ilike", `%${value}%`)
|
||||||
|
@ -226,8 +235,7 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const contains = (mode: object, any: boolean = false) => {
|
const contains = (mode: object, any: boolean = false) => {
|
||||||
const fnc = allOr ? "orWhere" : "where"
|
const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
const rawFnc = `${fnc}Raw`
|
|
||||||
const not = mode === filters?.notContains ? "NOT " : ""
|
const not = mode === filters?.notContains ? "NOT " : ""
|
||||||
function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
|
function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
|
||||||
for (let i in value) {
|
for (let i in value) {
|
||||||
|
@ -240,24 +248,24 @@ class InternalBuilder {
|
||||||
if (this.client === SqlClient.POSTGRES) {
|
if (this.client === SqlClient.POSTGRES) {
|
||||||
iterate(mode, (key: string, value: Array<any>) => {
|
iterate(mode, (key: string, value: Array<any>) => {
|
||||||
const wrap = any ? "" : "'"
|
const wrap = any ? "" : "'"
|
||||||
const containsOp = any ? "\\?| array" : "@>"
|
const op = any ? "\\?| array" : "@>"
|
||||||
const fieldNames = key.split(/\./g)
|
const fieldNames = key.split(/\./g)
|
||||||
const tableName = fieldNames[0]
|
const table = fieldNames[0]
|
||||||
const columnName = fieldNames[1]
|
const col = fieldNames[1]
|
||||||
// @ts-ignore
|
|
||||||
query = query[rawFnc](
|
query = query[rawFnc](
|
||||||
`${not}"${tableName}"."${columnName}"::jsonb ${containsOp} ${wrap}${stringifyArray(
|
`${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
|
||||||
value,
|
value,
|
||||||
any ? "'" : '"'
|
any ? "'" : '"'
|
||||||
)}${wrap}`
|
)}${wrap}, FALSE)`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else if (this.client === SqlClient.MY_SQL) {
|
} else if (this.client === SqlClient.MY_SQL) {
|
||||||
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
|
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
|
||||||
iterate(mode, (key: string, value: Array<any>) => {
|
iterate(mode, (key: string, value: Array<any>) => {
|
||||||
// @ts-ignore
|
|
||||||
query = query[rawFnc](
|
query = query[rawFnc](
|
||||||
`${not}${jsonFnc}(${key}, '${stringifyArray(value)}')`
|
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
|
||||||
|
value
|
||||||
|
)}'), FALSE)`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
@ -272,7 +280,7 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
statement +=
|
statement +=
|
||||||
(statement ? andOr : "") +
|
(statement ? andOr : "") +
|
||||||
`LOWER(${likeKey(this.client, key)}) LIKE ?`
|
`COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statement === "") {
|
if (statement === "") {
|
||||||
|
@ -337,14 +345,34 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
if (filters.equal) {
|
if (filters.equal) {
|
||||||
iterate(filters.equal, (key, value) => {
|
iterate(filters.equal, (key, value) => {
|
||||||
const fnc = allOr ? "orWhere" : "where"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
query = query[fnc]({ [key]: value })
|
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) {
|
if (filters.notEqual) {
|
||||||
iterate(filters.notEqual, (key, value) => {
|
iterate(filters.notEqual, (key, value) => {
|
||||||
const fnc = allOr ? "orWhereNot" : "whereNot"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
query = query[fnc]({ [key]: value })
|
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) {
|
if (filters.empty) {
|
||||||
|
@ -369,6 +397,16 @@ class InternalBuilder {
|
||||||
contains(filters.containsAny, true)
|
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
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -383,7 +421,13 @@ class InternalBuilder {
|
||||||
for (let [key, value] of Object.entries(sort)) {
|
for (let [key, value] of Object.entries(sort)) {
|
||||||
const direction =
|
const direction =
|
||||||
value.direction === SortDirection.ASCENDING ? "asc" : "desc"
|
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) {
|
} else if (this.client === SqlClient.MS_SQL && paginate?.limit) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -564,6 +608,7 @@ class InternalBuilder {
|
||||||
query = this.addFilters(query, filters, json.meta.table, {
|
query = this.addFilters(query, filters, json.meta.table, {
|
||||||
aliases: tableAliases,
|
aliases: tableAliases,
|
||||||
})
|
})
|
||||||
|
|
||||||
// add sorting to pre-query
|
// add sorting to pre-query
|
||||||
query = this.addSorting(query, json)
|
query = this.addSorting(query, json)
|
||||||
const alias = tableAliases?.[tableName] || tableName
|
const alias = tableAliases?.[tableName] || tableName
|
||||||
|
@ -634,12 +679,13 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
||||||
*/
|
*/
|
||||||
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
|
_query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] {
|
||||||
const sqlClient = this.getSqlClient()
|
const sqlClient = this.getSqlClient()
|
||||||
const config: { client: string; useNullAsDefault?: boolean } = {
|
const config: Knex.Config = {
|
||||||
client: sqlClient,
|
client: sqlClient,
|
||||||
}
|
}
|
||||||
if (sqlClient === SqlClient.SQL_LITE) {
|
if (sqlClient === SqlClient.SQL_LITE) {
|
||||||
config.useNullAsDefault = true
|
config.useNullAsDefault = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = knex(config)
|
const client = knex(config)
|
||||||
let query: Knex.QueryBuilder
|
let query: Knex.QueryBuilder
|
||||||
const builder = new InternalBuilder(sqlClient)
|
const builder = new InternalBuilder(sqlClient)
|
||||||
|
@ -757,11 +803,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
||||||
return results.length ? results : [{ [operation.toLowerCase()]: true }]
|
return results.length ? results : [{ [operation.toLowerCase()]: true }]
|
||||||
}
|
}
|
||||||
|
|
||||||
convertJsonStringColumns(
|
convertJsonStringColumns<T extends Record<string, any>>(
|
||||||
table: Table,
|
table: Table,
|
||||||
results: Record<string, any>[],
|
results: T[],
|
||||||
aliases?: Record<string, string>
|
aliases?: Record<string, string>
|
||||||
): Record<string, any>[] {
|
): T[] {
|
||||||
const tableName = getTableName(table)
|
const tableName = getTableName(table)
|
||||||
for (const [name, field] of Object.entries(table.schema)) {
|
for (const [name, field] of Object.entries(table.schema)) {
|
||||||
if (!this._isJsonColumn(field)) {
|
if (!this._isJsonColumn(field)) {
|
||||||
|
@ -770,11 +816,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
||||||
const aliasedTableName = (tableName && aliases?.[tableName]) || tableName
|
const aliasedTableName = (tableName && aliases?.[tableName]) || tableName
|
||||||
const fullName = `${aliasedTableName}.${name}`
|
const fullName = `${aliasedTableName}.${name}`
|
||||||
for (let row of results) {
|
for (let row of results) {
|
||||||
if (typeof row[fullName] === "string") {
|
if (typeof row[fullName as keyof T] === "string") {
|
||||||
row[fullName] = JSON.parse(row[fullName])
|
row[fullName as keyof T] = JSON.parse(row[fullName])
|
||||||
}
|
}
|
||||||
if (typeof row[name] === "string") {
|
if (typeof row[name as keyof T] === "string") {
|
||||||
row[name] = JSON.parse(row[name])
|
row[name as keyof T] = JSON.parse(row[name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -785,9 +831,8 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
||||||
field: FieldSchema
|
field: FieldSchema
|
||||||
): field is JsonFieldMetadata | BBReferenceFieldMetadata {
|
): field is JsonFieldMetadata | BBReferenceFieldMetadata {
|
||||||
return (
|
return (
|
||||||
field.type === FieldType.JSON ||
|
JsonTypes.includes(field.type) &&
|
||||||
(field.type === FieldType.BB_REFERENCE &&
|
!helpers.schema.isDeprecatedSingleUserColumn(field)
|
||||||
!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,
|
SqlQuery,
|
||||||
Table,
|
Table,
|
||||||
TableSourceType,
|
TableSourceType,
|
||||||
|
SqlClient,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { breakExternalTableId, getNativeSql, SqlClient } from "../utils"
|
import { breakExternalTableId, getNativeSql } from "./utils"
|
||||||
import { helpers, utils } from "@budibase/shared-core"
|
import { helpers, utils } from "@budibase/shared-core"
|
||||||
import SchemaBuilder = Knex.SchemaBuilder
|
import SchemaBuilder = Knex.SchemaBuilder
|
||||||
import CreateTableBuilder = Knex.CreateTableBuilder
|
import CreateTableBuilder = Knex.CreateTableBuilder
|
||||||
|
@ -79,9 +80,13 @@ function generateSchema(
|
||||||
schema.boolean(key)
|
schema.boolean(key)
|
||||||
break
|
break
|
||||||
case FieldType.DATETIME:
|
case FieldType.DATETIME:
|
||||||
schema.datetime(key, {
|
if (!column.timeOnly) {
|
||||||
useTz: !column.ignoreTimezones,
|
schema.datetime(key, {
|
||||||
})
|
useTz: !column.ignoreTimezones,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
schema.time(key)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case FieldType.ARRAY:
|
case FieldType.ARRAY:
|
||||||
case FieldType.BB_REFERENCE:
|
case FieldType.BB_REFERENCE:
|
||||||
|
@ -125,6 +130,7 @@ function generateSchema(
|
||||||
break
|
break
|
||||||
case FieldType.ATTACHMENTS:
|
case FieldType.ATTACHMENTS:
|
||||||
case FieldType.ATTACHMENT_SINGLE:
|
case FieldType.ATTACHMENT_SINGLE:
|
||||||
|
case FieldType.SIGNATURE_SINGLE:
|
||||||
case FieldType.AUTO:
|
case FieldType.AUTO:
|
||||||
case FieldType.JSON:
|
case FieldType.JSON:
|
||||||
case FieldType.INTERNAL:
|
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 platform.users.removeUser(dbUser)
|
||||||
|
|
||||||
await db.remove(userId, dbUser._rev)
|
await db.remove(userId, dbUser._rev!)
|
||||||
|
|
||||||
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
|
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
|
||||||
await UserDB.quotas.removeUsers(1, creatorsToDelete)
|
await UserDB.quotas.removeUsers(1, creatorsToDelete)
|
||||||
|
|
|
@ -106,6 +106,10 @@ export const useViewPermissions = () => {
|
||||||
return useFeature(Feature.VIEW_PERMISSIONS)
|
return useFeature(Feature.VIEW_PERMISSIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useViewReadonlyColumns = () => {
|
||||||
|
return useFeature(Feature.VIEW_READONLY_COLUMNS)
|
||||||
|
}
|
||||||
|
|
||||||
// QUOTAS
|
// QUOTAS
|
||||||
|
|
||||||
export const setAutomationLogsQuota = (value: number) => {
|
export const setAutomationLogsQuota = (value: number) => {
|
||||||
|
|
|
@ -57,6 +57,7 @@
|
||||||
class:fullWidth
|
class:fullWidth
|
||||||
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
|
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
|
||||||
class:active
|
class:active
|
||||||
|
class:disabled
|
||||||
{disabled}
|
{disabled}
|
||||||
on:longPress
|
on:longPress
|
||||||
on:click|preventDefault
|
on:click|preventDefault
|
||||||
|
@ -109,19 +110,22 @@
|
||||||
background: var(--spectrum-global-color-gray-300);
|
background: var(--spectrum-global-color-gray-300);
|
||||||
border-color: var(--spectrum-global-color-gray-500);
|
border-color: var(--spectrum-global-color-gray-500);
|
||||||
}
|
}
|
||||||
.noPadding {
|
|
||||||
padding: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.spectrum-ActionButton--quiet {
|
.spectrum-ActionButton--quiet {
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
.spectrum-ActionButton--quiet.is-selected {
|
.spectrum-ActionButton--quiet.is-selected {
|
||||||
color: var(--spectrum-global-color-gray-900);
|
color: var(--spectrum-global-color-gray-900);
|
||||||
}
|
}
|
||||||
|
.noPadding {
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.is-selected:not(.emphasized) .spectrum-Icon {
|
.is-selected:not(.emphasized) .spectrum-Icon {
|
||||||
color: var(--spectrum-global-color-gray-900);
|
color: var(--spectrum-global-color-gray-900);
|
||||||
}
|
}
|
||||||
|
.is-selected.disabled .spectrum-Icon {
|
||||||
|
color: var(--spectrum-global-color-gray-500);
|
||||||
|
}
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
|
@ -71,8 +71,8 @@ const handleMouseDown = e => {
|
||||||
|
|
||||||
// Clear any previous listeners in case of multiple down events, and register
|
// Clear any previous listeners in case of multiple down events, and register
|
||||||
// a single mouse up listener
|
// a single mouse up listener
|
||||||
document.removeEventListener("mouseup", handleMouseUp)
|
document.removeEventListener("click", handleMouseUp)
|
||||||
document.addEventListener("mouseup", handleMouseUp, true)
|
document.addEventListener("click", handleMouseUp, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global singleton listeners for our events
|
// Global singleton listeners for our events
|
||||||
|
|
|
@ -155,6 +155,8 @@ export default function positionDropdown(element, opts) {
|
||||||
applyXStrategy(Strategies.StartToEnd)
|
applyXStrategy(Strategies.StartToEnd)
|
||||||
} else if (align === "left-outside") {
|
} else if (align === "left-outside") {
|
||||||
applyXStrategy(Strategies.EndToStart)
|
applyXStrategy(Strategies.EndToStart)
|
||||||
|
} else if (align === "center") {
|
||||||
|
applyXStrategy(Strategies.MidPoint)
|
||||||
} else {
|
} else {
|
||||||
applyXStrategy(Strategies.StartToStart)
|
applyXStrategy(Strategies.StartToStart)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,13 +4,14 @@
|
||||||
export let max
|
export let max
|
||||||
export let hideArrows = false
|
export let hideArrows = false
|
||||||
export let width
|
export let width
|
||||||
|
export let type = "number"
|
||||||
|
|
||||||
$: style = width ? `width:${width}px;` : ""
|
$: style = width ? `width:${width}px;` : ""
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class:hide-arrows={hideArrows}
|
class:hide-arrows={hideArrows}
|
||||||
type="number"
|
{type}
|
||||||
{style}
|
{style}
|
||||||
{value}
|
{value}
|
||||||
{min}
|
{min}
|
||||||
|
@ -51,4 +52,7 @@
|
||||||
input.hide-arrows {
|
input.hide-arrows {
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
|
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<script>
|
<script>
|
||||||
import { cleanInput } from "./utils"
|
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import NumberInput from "./NumberInput.svelte"
|
import NumberInput from "./NumberInput.svelte"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
@ -8,39 +7,26 @@
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
$: displayValue = value || dayjs()
|
$: displayValue = value?.format("HH:mm")
|
||||||
|
|
||||||
const handleHourChange = e => {
|
const handleChange = e => {
|
||||||
dispatch("change", displayValue.hour(parseInt(e.target.value)))
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="time-picker">
|
<div class="time-picker">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
hideArrows
|
hideArrows
|
||||||
value={displayValue.hour().toString().padStart(2, "0")}
|
type={"time"}
|
||||||
min={0}
|
value={displayValue}
|
||||||
max={23}
|
on:input={handleChange}
|
||||||
width={20}
|
on:change={handleChange}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -50,10 +36,4 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.time-picker span {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 18px;
|
|
||||||
z-index: 0;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight
|
||||||
export let open = false
|
export let open = false
|
||||||
export let loading
|
export let loading
|
||||||
|
export let onOptionMouseenter = () => {}
|
||||||
|
export let onOptionMouseleave = () => {}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -97,4 +99,6 @@
|
||||||
{autoWidth}
|
{autoWidth}
|
||||||
{customPopoverHeight}
|
{customPopoverHeight}
|
||||||
{loading}
|
{loading}
|
||||||
|
{onOptionMouseenter}
|
||||||
|
{onOptionMouseleave}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -41,6 +41,8 @@
|
||||||
export let footer = null
|
export let footer = null
|
||||||
export let customAnchor = null
|
export let customAnchor = null
|
||||||
export let loading
|
export let loading
|
||||||
|
export let onOptionMouseenter = () => {}
|
||||||
|
export let onOptionMouseleave = () => {}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -155,7 +157,7 @@
|
||||||
useAnchorWidth={!autoWidth}
|
useAnchorWidth={!autoWidth}
|
||||||
maxWidth={autoWidth ? 400 : null}
|
maxWidth={autoWidth ? 400 : null}
|
||||||
customHeight={customPopoverHeight}
|
customHeight={customPopoverHeight}
|
||||||
maxHeight={240}
|
maxHeight={360}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="popover-content"
|
class="popover-content"
|
||||||
|
@ -199,6 +201,8 @@
|
||||||
aria-selected="true"
|
aria-selected="true"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
on:click={() => onSelectOption(getOptionValue(option, idx))}
|
on:click={() => onSelectOption(getOptionValue(option, idx))}
|
||||||
|
on:mouseenter={e => onOptionMouseenter(e, option)}
|
||||||
|
on:mouseleave={e => onOptionMouseleave(e, option)}
|
||||||
class:is-disabled={!isOptionEnabled(option)}
|
class:is-disabled={!isOptionEnabled(option)}
|
||||||
>
|
>
|
||||||
{#if getOptionIcon(option, idx)}
|
{#if getOptionIcon(option, idx)}
|
||||||
|
|
|
@ -26,6 +26,8 @@
|
||||||
export let tag = null
|
export let tag = null
|
||||||
export let searchTerm = null
|
export let searchTerm = null
|
||||||
export let loading
|
export let loading
|
||||||
|
export let onOptionMouseenter = () => {}
|
||||||
|
export let onOptionMouseleave = () => {}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -95,6 +97,8 @@
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{sort}
|
{sort}
|
||||||
{tag}
|
{tag}
|
||||||
|
{onOptionMouseenter}
|
||||||
|
{onOptionMouseleave}
|
||||||
isPlaceholder={value == null || value === ""}
|
isPlaceholder={value == null || value === ""}
|
||||||
placeholderOption={placeholder === false ? null : placeholder}
|
placeholderOption={placeholder === false ? null : placeholder}
|
||||||
isOptionSelected={option => compareOptionAndValue(option, value)}
|
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 CoreRichTextField } from "./RichTextField.svelte"
|
||||||
export { default as CoreSlider } from "./Slider.svelte"
|
export { default as CoreSlider } from "./Slider.svelte"
|
||||||
export { default as CoreFile } from "./File.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 searchTerm = null
|
||||||
export let customPopoverHeight
|
export let customPopoverHeight
|
||||||
export let helpText = null
|
export let helpText = null
|
||||||
|
export let onOptionMouseenter = () => {}
|
||||||
|
export let onOptionMouseleave = () => {}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const onChange = e => {
|
const onChange = e => {
|
||||||
|
@ -41,6 +43,8 @@
|
||||||
{autoWidth}
|
{autoWidth}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{customPopoverHeight}
|
{customPopoverHeight}
|
||||||
|
{onOptionMouseenter}
|
||||||
|
{onOptionMouseleave}
|
||||||
bind:searchTerm
|
bind:searchTerm
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
on:click
|
on:click
|
||||||
|
|
|
@ -29,6 +29,9 @@
|
||||||
export let tag = null
|
export let tag = null
|
||||||
export let helpText = null
|
export let helpText = null
|
||||||
export let compare
|
export let compare
|
||||||
|
export let onOptionMouseenter = () => {}
|
||||||
|
export let onOptionMouseleave = () => {}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const onChange = e => {
|
const onChange = e => {
|
||||||
value = e.detail
|
value = e.detail
|
||||||
|
@ -67,6 +70,8 @@
|
||||||
{customPopoverHeight}
|
{customPopoverHeight}
|
||||||
{tag}
|
{tag}
|
||||||
{compare}
|
{compare}
|
||||||
|
{onOptionMouseenter}
|
||||||
|
{onOptionMouseleave}
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
on:click
|
on:click
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -173,6 +173,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.spectrum-Modal {
|
.spectrum-Modal {
|
||||||
|
border: 2px solid var(--spectrum-global-color-gray-200);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
margin: 40px 0;
|
margin: 40px 0;
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
export let secondaryButtonText = undefined
|
export let secondaryButtonText = undefined
|
||||||
export let secondaryAction = undefined
|
export let secondaryAction = undefined
|
||||||
export let secondaryButtonWarning = false
|
export let secondaryButtonWarning = false
|
||||||
|
export let custom = false
|
||||||
|
|
||||||
const { hide, cancel } = getContext(Context.Modal)
|
const { hide, cancel } = getContext(Context.Modal)
|
||||||
let loading = false
|
let loading = false
|
||||||
|
@ -63,12 +64,13 @@
|
||||||
class:spectrum-Dialog--medium={size === "M"}
|
class:spectrum-Dialog--medium={size === "M"}
|
||||||
class:spectrum-Dialog--large={size === "L"}
|
class:spectrum-Dialog--large={size === "L"}
|
||||||
class:spectrum-Dialog--extraLarge={size === "XL"}
|
class:spectrum-Dialog--extraLarge={size === "XL"}
|
||||||
|
class:no-grid={custom}
|
||||||
style="position: relative;"
|
style="position: relative;"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div class="spectrum-Dialog-grid">
|
<div class="modal-core" class:spectrum-Dialog-grid={!custom}>
|
||||||
{#if title || $$slots.header}
|
{#if title || $$slots.header}
|
||||||
<h1
|
<h1
|
||||||
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
|
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
|
||||||
|
@ -153,6 +155,25 @@
|
||||||
.spectrum-Dialog-content {
|
.spectrum-Dialog-content {
|
||||||
overflow: visible;
|
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 {
|
.spectrum-Dialog-heading {
|
||||||
font-family: var(--font-accent);
|
font-family: var(--font-accent);
|
||||||
font-weight: 600;
|
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>
|
<script>
|
||||||
import { getContext, onMount, createEventDispatcher } from "svelte"
|
import { getContext, onDestroy, createEventDispatcher } from "svelte"
|
||||||
import Portal from "svelte-portal"
|
import Portal from "svelte-portal"
|
||||||
|
|
||||||
export let title
|
export let title
|
||||||
export let icon = ""
|
export let icon = ""
|
||||||
export let id
|
export let id
|
||||||
|
export let href = "#"
|
||||||
|
export let link = false
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
let selected = getContext("tab")
|
let selected = getContext("tab")
|
||||||
let tab_internal
|
let observer
|
||||||
let tabInfo
|
let ref
|
||||||
|
|
||||||
const setTabInfo = () => {
|
$: isSelected = $selected.title === title
|
||||||
// If the tabs are being rendered inside a component which uses
|
$: {
|
||||||
// a svelte transition to enter, then this initial getBoundingClientRect
|
if (isSelected && ref) {
|
||||||
// will return an incorrect position.
|
observe()
|
||||||
// We just need to get this off the main thread to fix this, by using
|
} else {
|
||||||
// a 0ms timeout.
|
stopObserving()
|
||||||
setTimeout(() => {
|
}
|
||||||
tabInfo = tab_internal?.getBoundingClientRect()
|
|
||||||
if (tabInfo && $selected.title === title) {
|
|
||||||
$selected.info = tabInfo
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
const setTabInfo = () => {
|
||||||
setTabInfo()
|
const tabInfo = ref?.getBoundingClientRect()
|
||||||
})
|
if (tabInfo) {
|
||||||
|
$selected.info = tabInfo
|
||||||
//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 onAnchorClick = e => {
|
||||||
|
if (e.metaKey || e.shiftKey || e.altKey || e.ctrlKey) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
$selected = {
|
||||||
|
...$selected,
|
||||||
|
title,
|
||||||
|
info: ref.getBoundingClientRect(),
|
||||||
|
}
|
||||||
|
dispatch("click")
|
||||||
|
}
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
$selected = {
|
$selected = {
|
||||||
...$selected,
|
...$selected,
|
||||||
title,
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
{#if link}
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<a
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
{href}
|
||||||
<div
|
{id}
|
||||||
{id}
|
bind:this={ref}
|
||||||
bind:this={tab_internal}
|
on:click={onAnchorClick}
|
||||||
on:click={onClick}
|
class="spectrum-Tabs-item link"
|
||||||
class:is-selected={$selected.title === title}
|
class:is-selected={isSelected}
|
||||||
class="spectrum-Tabs-item"
|
class:emphasized={isSelected && $selected.emphasized}
|
||||||
class:emphasized={$selected.title === title && $selected.emphasized}
|
tabindex="0"
|
||||||
tabindex="0"
|
>
|
||||||
>
|
{#if icon}
|
||||||
{#if icon}
|
<svg
|
||||||
<svg
|
class="spectrum-Icon spectrum-Icon--sizeM"
|
||||||
class="spectrum-Icon spectrum-Icon--sizeM"
|
focusable="false"
|
||||||
focusable="false"
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
aria-label="Folder"
|
||||||
aria-label="Folder"
|
>
|
||||||
>
|
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
</svg>
|
||||||
</svg>
|
{/if}
|
||||||
{/if}
|
<span class="spectrum-Tabs-itemLabel">{title}</span>
|
||||||
<span class="spectrum-Tabs-itemLabel">{title}</span>
|
</a>
|
||||||
</div>
|
{:else}
|
||||||
{#if $selected.title === title}
|
<!-- 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}">
|
<Portal target=".spectrum-Tabs-content-{$selected.id}">
|
||||||
<slot />
|
<slot />
|
||||||
</Portal>
|
</Portal>
|
||||||
|
@ -89,4 +134,7 @@
|
||||||
.spectrum-Tabs-item:hover {
|
.spectrum-Tabs-item:hover {
|
||||||
color: var(--spectrum-global-color-gray-900);
|
color: var(--spectrum-global-color-gray-900);
|
||||||
}
|
}
|
||||||
|
.link {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
</style>
|
</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
|
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
|
||||||
if (offsetForTimezone) {
|
if (offsetForTimezone) {
|
||||||
// Ensure we use the correct offset for the date
|
// 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
|
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
|
// For date-only fields, construct a manual timestamp string without a time
|
||||||
|
@ -177,7 +182,7 @@ export const stringifyDate = (
|
||||||
const year = value.year()
|
const year = value.year()
|
||||||
const month = `${value.month() + 1}`.padStart(2, "0")
|
const month = `${value.month() + 1}`.padStart(2, "0")
|
||||||
const day = `${value.date()}`.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
|
// 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 Tooltip } from "./Tooltip/Tooltip.svelte"
|
||||||
export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte"
|
export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte"
|
||||||
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.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 Menu } from "./Menu/Menu.svelte"
|
||||||
export { default as MenuSection } from "./Menu/Section.svelte"
|
export { default as MenuSection } from "./Menu/Section.svelte"
|
||||||
export { default as MenuSeparator } from "./Menu/Separator.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 IconSideNav } from "./IconSideNav/IconSideNav.svelte"
|
||||||
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
|
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
|
||||||
export { default as Accordion } from "./Accordion/Accordion.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 { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
|
||||||
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
|
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
import TestDataModal from "./TestDataModal.svelte"
|
import TestDataModal from "./TestDataModal.svelte"
|
||||||
import { flip } from "svelte/animate"
|
import { flip } from "svelte/animate"
|
||||||
import { fly } from "svelte/transition"
|
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 { ActionStepID } from "constants/backend/automations"
|
||||||
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
|
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
|
||||||
|
|
||||||
|
@ -73,6 +73,16 @@
|
||||||
Test details
|
Test details
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<div class="canvas" on:scroll={handleScroll}>
|
<div class="canvas" on:scroll={handleScroll}>
|
||||||
|
|
|
@ -61,6 +61,7 @@
|
||||||
selected={automation._id === selectedAutomationId}
|
selected={automation._id === selectedAutomationId}
|
||||||
on:click={() => selectAutomation(automation._id)}
|
on:click={() => selectAutomation(automation._id)}
|
||||||
selectedBy={$userSelectedResourceMap[automation._id]}
|
selectedBy={$userSelectedResourceMap[automation._id]}
|
||||||
|
disabled={automation.disabled}
|
||||||
>
|
>
|
||||||
<EditAutomationPopover {automation} />
|
<EditAutomationPopover {automation} />
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
|
|
@ -39,6 +39,15 @@
|
||||||
>Duplicate</MenuItem
|
>Duplicate</MenuItem
|
||||||
>
|
>
|
||||||
<MenuItem icon="Edit" on:click={updateAutomationDialog.show}>Edit</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>
|
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
|
||||||
</ActionMenu>
|
</ActionMenu>
|
||||||
|
|
||||||
|
|
|
@ -364,6 +364,7 @@
|
||||||
value.customType !== "cron" &&
|
value.customType !== "cron" &&
|
||||||
value.customType !== "triggerSchema" &&
|
value.customType !== "triggerSchema" &&
|
||||||
value.customType !== "automationFields" &&
|
value.customType !== "automationFields" &&
|
||||||
|
value.type !== "signature_single" &&
|
||||||
value.type !== "attachment" &&
|
value.type !== "attachment" &&
|
||||||
value.type !== "attachment_single"
|
value.type !== "attachment_single"
|
||||||
)
|
)
|
||||||
|
@ -456,7 +457,7 @@
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
options={Object.keys(table?.schema || {})}
|
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="attachment-field-wrapper">
|
||||||
<div class="label-wrapper">
|
<div class="label-wrapper">
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
|
|
|
@ -24,6 +24,11 @@
|
||||||
|
|
||||||
let table
|
let table
|
||||||
let schemaFields
|
let schemaFields
|
||||||
|
let attachmentTypes = [
|
||||||
|
FieldType.ATTACHMENTS,
|
||||||
|
FieldType.ATTACHMENT_SINGLE,
|
||||||
|
FieldType.SIGNATURE_SINGLE,
|
||||||
|
]
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
table = $tables.list.find(table => table._id === value?.tableId)
|
table = $tables.list.find(table => table._id === value?.tableId)
|
||||||
|
@ -120,15 +125,9 @@
|
||||||
{#if schemaFields.length}
|
{#if schemaFields.length}
|
||||||
{#each schemaFields as [field, schema]}
|
{#each schemaFields as [field, schema]}
|
||||||
{#if !schema.autocolumn}
|
{#if !schema.autocolumn}
|
||||||
<div
|
<div class:schema-fields={!attachmentTypes.includes(schema.type)}>
|
||||||
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
|
|
||||||
schema.type !== FieldType.ATTACHMENT_SINGLE}
|
|
||||||
>
|
|
||||||
<Label>{field}</Label>
|
<Label>{field}</Label>
|
||||||
<div
|
<div class:field-width={!attachmentTypes.includes(schema.type)}>
|
||||||
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
|
|
||||||
schema.type !== FieldType.ATTACHMENT_SINGLE}
|
|
||||||
>
|
|
||||||
{#if isTestModal}
|
{#if isTestModal}
|
||||||
<RowSelectorTypes
|
<RowSelectorTypes
|
||||||
{isTestModal}
|
{isTestModal}
|
||||||
|
|
|
@ -21,6 +21,12 @@
|
||||||
return clone
|
return clone
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let attachmentTypes = [
|
||||||
|
FieldType.ATTACHMENTS,
|
||||||
|
FieldType.ATTACHMENT_SINGLE,
|
||||||
|
FieldType.SIGNATURE_SINGLE,
|
||||||
|
]
|
||||||
|
|
||||||
function schemaHasOptions(schema) {
|
function schemaHasOptions(schema) {
|
||||||
return !!schema.constraints?.inclusion?.length
|
return !!schema.constraints?.inclusion?.length
|
||||||
}
|
}
|
||||||
|
@ -29,7 +35,8 @@
|
||||||
let params = {}
|
let params = {}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
schema.type === FieldType.ATTACHMENT_SINGLE &&
|
(schema.type === FieldType.ATTACHMENT_SINGLE ||
|
||||||
|
schema.type === FieldType.SIGNATURE_SINGLE) &&
|
||||||
Object.keys(keyValueObj).length === 0
|
Object.keys(keyValueObj).length === 0
|
||||||
) {
|
) {
|
||||||
return []
|
return []
|
||||||
|
@ -92,7 +99,7 @@
|
||||||
on:change={e => onChange(e, field)}
|
on:change={e => onChange(e, field)}
|
||||||
useLabel={false}
|
useLabel={false}
|
||||||
/>
|
/>
|
||||||
{:else if schema.type === "bb_reference"}
|
{:else if schema.type === "bb_reference" || schema.type === "bb_reference_single"}
|
||||||
<LinkedRowSelector
|
<LinkedRowSelector
|
||||||
linkedRows={value[field]}
|
linkedRows={value[field]}
|
||||||
{schema}
|
{schema}
|
||||||
|
@ -100,16 +107,20 @@
|
||||||
on:change={e => onChange(e, field)}
|
on:change={e => onChange(e, field)}
|
||||||
useLabel={false}
|
useLabel={false}
|
||||||
/>
|
/>
|
||||||
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
|
{:else if attachmentTypes.includes(schema.type)}
|
||||||
<div class="attachment-field-spacinng">
|
<div class="attachment-field-spacinng">
|
||||||
<KeyValueBuilder
|
<KeyValueBuilder
|
||||||
on:change={e =>
|
on:change={e =>
|
||||||
onChange(
|
onChange(
|
||||||
{
|
{
|
||||||
detail:
|
detail:
|
||||||
schema.type === FieldType.ATTACHMENT_SINGLE
|
schema.type === FieldType.ATTACHMENT_SINGLE ||
|
||||||
|
schema.type === FieldType.SIGNATURE_SINGLE
|
||||||
? e.detail.length > 0
|
? 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 }) => ({
|
: e.detail.map(({ name, value }) => ({
|
||||||
url: name,
|
url: name,
|
||||||
|
@ -125,7 +136,8 @@
|
||||||
customButtonText={"Add attachment"}
|
customButtonText={"Add attachment"}
|
||||||
keyPlaceholder={"URL"}
|
keyPlaceholder={"URL"}
|
||||||
valuePlaceholder={"Filename"}
|
valuePlaceholder={"Filename"}
|
||||||
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
|
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
|
||||||
|
schema.type === FieldType.SIGNATURE) &&
|
||||||
Object.keys(value[field]).length >= 1}
|
Object.keys(value[field]).length >= 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { API } from "api"
|
||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
|
@ -8,15 +9,19 @@
|
||||||
Label,
|
Label,
|
||||||
RichTextField,
|
RichTextField,
|
||||||
TextArea,
|
TextArea,
|
||||||
|
CoreSignature,
|
||||||
|
ActionButton,
|
||||||
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import Dropzone from "components/common/Dropzone.svelte"
|
import Dropzone from "components/common/Dropzone.svelte"
|
||||||
import { capitalise } from "helpers"
|
import { capitalise } from "helpers"
|
||||||
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
|
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
|
||||||
import Editor from "../../integration/QueryEditor.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 meta
|
||||||
export let value = defaultValue || (meta.type === "boolean" ? false : "")
|
export let value
|
||||||
export let readonly
|
export let readonly
|
||||||
export let error
|
export let error
|
||||||
|
|
||||||
|
@ -39,8 +44,35 @@
|
||||||
|
|
||||||
const timeStamp = resolveTimeStamp(value)
|
const timeStamp = resolveTimeStamp(value)
|
||||||
const isTimeStamp = !!timeStamp || meta?.timeOnly
|
const isTimeStamp = !!timeStamp || meta?.timeOnly
|
||||||
|
|
||||||
|
$: currentTheme = $themeStore?.theme
|
||||||
|
$: darkMode = !currentTheme.includes("light")
|
||||||
|
|
||||||
|
let signatureModal
|
||||||
</script>
|
</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}
|
{#if type === "options" && meta.constraints.inclusion.length !== 0}
|
||||||
<Select
|
<Select
|
||||||
{label}
|
{label}
|
||||||
|
@ -59,7 +91,49 @@
|
||||||
bind:value
|
bind:value
|
||||||
/>
|
/>
|
||||||
{:else if type === "attachment"}
|
{: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"}
|
{:else if type === "boolean"}
|
||||||
<Toggle text={label} {error} bind:value />
|
<Toggle text={label} {error} bind:value />
|
||||||
{:else if type === "array" && meta.constraints.inclusion.length !== 0}
|
{:else if type === "array" && meta.constraints.inclusion.length !== 0}
|
||||||
|
@ -95,3 +169,22 @@
|
||||||
{:else}
|
{:else}
|
||||||
<Input {label} {type} {error} bind:value disabled={readonly} />
|
<Input {label} {type} {error} bind:value disabled={readonly} />
|
||||||
{/if}
|
{/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>
|
<script>
|
||||||
import { datasources, tables, integrations, appStore } from "stores/builder"
|
import { datasources, tables, integrations, appStore } from "stores/builder"
|
||||||
|
import { themeStore, admin } from "stores/portal"
|
||||||
import EditRolesButton from "./buttons/EditRolesButton.svelte"
|
import EditRolesButton from "./buttons/EditRolesButton.svelte"
|
||||||
import { TableNames } from "constants"
|
import { TableNames } from "constants"
|
||||||
import { Grid } from "@budibase/frontend-core"
|
import { Grid } from "@budibase/frontend-core"
|
||||||
|
@ -37,6 +38,9 @@
|
||||||
})
|
})
|
||||||
$: relationshipsEnabled = relationshipSupport(tableDatasource)
|
$: relationshipsEnabled = relationshipSupport(tableDatasource)
|
||||||
|
|
||||||
|
$: currentTheme = $themeStore?.theme
|
||||||
|
$: darkMode = !currentTheme.includes("light")
|
||||||
|
|
||||||
const relationshipSupport = datasource => {
|
const relationshipSupport = datasource => {
|
||||||
const integration = $integrations[datasource?.source]
|
const integration = $integrations[datasource?.source]
|
||||||
return !isInternal && integration?.relationships !== false
|
return !isInternal && integration?.relationships !== false
|
||||||
|
@ -55,6 +59,7 @@
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<Grid
|
<Grid
|
||||||
{API}
|
{API}
|
||||||
|
{darkMode}
|
||||||
datasource={gridDatasource}
|
datasource={gridDatasource}
|
||||||
canAddRows={!isUsersTable}
|
canAddRows={!isUsersTable}
|
||||||
canDeleteRows={!isUsersTable}
|
canDeleteRows={!isUsersTable}
|
||||||
|
@ -63,6 +68,7 @@
|
||||||
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
|
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
|
||||||
showAvatars={false}
|
showAvatars={false}
|
||||||
on:updatedatasource={handleGridTableUpdate}
|
on:updatedatasource={handleGridTableUpdate}
|
||||||
|
isCloud={$admin.cloud}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="filter">
|
<svelte:fragment slot="filter">
|
||||||
{#if isUsersTable && $appStore.features.disableUserMetadata}
|
{#if isUsersTable && $appStore.features.disableUserMetadata}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { viewsV2 } from "stores/builder"
|
import { viewsV2 } from "stores/builder"
|
||||||
|
import { admin } from "stores/portal"
|
||||||
import { Grid } from "@budibase/frontend-core"
|
import { Grid } from "@budibase/frontend-core"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
|
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
|
||||||
|
@ -26,6 +27,7 @@
|
||||||
allowDeleteRows
|
allowDeleteRows
|
||||||
showAvatars={false}
|
showAvatars={false}
|
||||||
on:updatedatasource={handleGridViewUpdate}
|
on:updatedatasource={handleGridViewUpdate}
|
||||||
|
isCloud={$admin.cloud}
|
||||||
>
|
>
|
||||||
<svelte:fragment slot="filter">
|
<svelte:fragment slot="filter">
|
||||||
<GridFilterButton />
|
<GridFilterButton />
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
|
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
|
||||||
import { getUserBindings } from "dataBinding"
|
import { getUserBindings } from "dataBinding"
|
||||||
import { makePropSafe } from "@budibase/string-templates"
|
import { makePropSafe } from "@budibase/string-templates"
|
||||||
|
import { search } from "@budibase/frontend-core"
|
||||||
|
import { tables } from "stores/builder"
|
||||||
|
|
||||||
export let schema
|
export let schema
|
||||||
export let filters
|
export let filters
|
||||||
|
@ -15,11 +17,10 @@
|
||||||
let drawer
|
let drawer
|
||||||
|
|
||||||
$: tempValue = filters || []
|
$: tempValue = filters || []
|
||||||
$: schemaFields = Object.entries(schema || {}).map(
|
$: schemaFields = search.getFields(
|
||||||
([fieldName, fieldSchema]) => ({
|
$tables.list,
|
||||||
name: fieldName, // Using the key as name if not defined in the schema, for example in some autogenerated columns
|
Object.values(schema || {}),
|
||||||
...fieldSchema,
|
{ allowLinks: true }
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
$: text = getText(filters)
|
$: text = getText(filters)
|
||||||
|
|
|
@ -9,6 +9,7 @@ const MAX_DEPTH = 1
|
||||||
const TYPES_TO_SKIP = [
|
const TYPES_TO_SKIP = [
|
||||||
FieldType.FORMULA,
|
FieldType.FORMULA,
|
||||||
FieldType.LONGFORM,
|
FieldType.LONGFORM,
|
||||||
|
FieldType.SIGNATURE_SINGLE,
|
||||||
FieldType.ATTACHMENTS,
|
FieldType.ATTACHMENTS,
|
||||||
//https://github.com/Budibase/budibase/issues/3030
|
//https://github.com/Budibase/budibase/issues/3030
|
||||||
FieldType.INTERNAL,
|
FieldType.INTERNAL,
|
||||||
|
@ -55,7 +56,7 @@ export function getBindings({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const field = Object.values(FIELDS).find(
|
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}`
|
const label = path == null ? column : `${path}.0.${column}`
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Modal,
|
Modal,
|
||||||
notifications,
|
notifications,
|
||||||
OptionSelectDnD,
|
|
||||||
Layout,
|
Layout,
|
||||||
AbsTooltip,
|
AbsTooltip,
|
||||||
ProgressCircle,
|
ProgressCircle,
|
||||||
|
@ -42,6 +41,7 @@
|
||||||
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
|
||||||
import { RowUtils } from "@budibase/frontend-core"
|
import { RowUtils } from "@budibase/frontend-core"
|
||||||
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||||
|
import OptionsEditor from "./OptionsEditor.svelte"
|
||||||
|
|
||||||
const AUTO_TYPE = FieldType.AUTO
|
const AUTO_TYPE = FieldType.AUTO
|
||||||
const FORMULA_TYPE = FieldType.FORMULA
|
const FORMULA_TYPE = FieldType.FORMULA
|
||||||
|
@ -95,6 +95,7 @@
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let autoColumnInfo = getAutoColumnInformation()
|
let autoColumnInfo = getAutoColumnInformation()
|
||||||
|
let optionsValid = true
|
||||||
|
|
||||||
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
|
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
|
||||||
$: if (primaryDisplay) {
|
$: if (primaryDisplay) {
|
||||||
|
@ -138,7 +139,8 @@
|
||||||
$: invalid =
|
$: invalid =
|
||||||
!editableColumn?.name ||
|
!editableColumn?.name ||
|
||||||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
|
||||||
Object.keys(errors).length !== 0
|
Object.keys(errors).length !== 0 ||
|
||||||
|
!optionsValid
|
||||||
$: errors = checkErrors(editableColumn)
|
$: errors = checkErrors(editableColumn)
|
||||||
$: datasource = $datasources.list.find(
|
$: datasource = $datasources.list.find(
|
||||||
source => source._id === table?.sourceId
|
source => source._id === table?.sourceId
|
||||||
|
@ -412,6 +414,7 @@
|
||||||
FIELDS.FORMULA,
|
FIELDS.FORMULA,
|
||||||
FIELDS.JSON,
|
FIELDS.JSON,
|
||||||
FIELDS.BARCODEQR,
|
FIELDS.BARCODEQR,
|
||||||
|
FIELDS.SIGNATURE_SINGLE,
|
||||||
FIELDS.BIGINT,
|
FIELDS.BIGINT,
|
||||||
FIELDS.AUTO,
|
FIELDS.AUTO,
|
||||||
]
|
]
|
||||||
|
@ -558,9 +561,10 @@
|
||||||
bind:value={editableColumn.constraints.length.maximum}
|
bind:value={editableColumn.constraints.length.maximum}
|
||||||
/>
|
/>
|
||||||
{:else if editableColumn.type === FieldType.OPTIONS}
|
{:else if editableColumn.type === FieldType.OPTIONS}
|
||||||
<OptionSelectDnD
|
<OptionsEditor
|
||||||
bind:constraints={editableColumn.constraints}
|
bind:constraints={editableColumn.constraints}
|
||||||
bind:optionColors={editableColumn.optionColors}
|
bind:optionColors={editableColumn.optionColors}
|
||||||
|
bind:valid={optionsValid}
|
||||||
/>
|
/>
|
||||||
{:else if editableColumn.type === FieldType.LONGFORM}
|
{:else if editableColumn.type === FieldType.LONGFORM}
|
||||||
<div>
|
<div>
|
||||||
|
@ -581,17 +585,22 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if editableColumn.type === FieldType.ARRAY}
|
{:else if editableColumn.type === FieldType.ARRAY}
|
||||||
<OptionSelectDnD
|
<OptionsEditor
|
||||||
bind:constraints={editableColumn.constraints}
|
bind:constraints={editableColumn.constraints}
|
||||||
bind:optionColors={editableColumn.optionColors}
|
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="split-label">
|
||||||
<div class="label-length">
|
<div class="label-length">
|
||||||
<Label size="M">Earliest</Label>
|
<Label size="M">Earliest</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-length">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -600,30 +609,36 @@
|
||||||
<Label size="M">Latest</Label>
|
<Label size="M">Latest</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-length">
|
<div class="input-length">
|
||||||
<DatePicker bind:value={editableColumn.constraints.datetime.latest} />
|
<DatePicker
|
||||||
</div>
|
bind:value={editableColumn.constraints.datetime.latest}
|
||||||
</div>
|
enableTime={!editableColumn.dateOnly}
|
||||||
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
|
timeOnly={editableColumn.timeOnly}
|
||||||
<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>
|
</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}
|
{/if}
|
||||||
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
|
|
||||||
{:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn}
|
{:else if editableColumn.type === FieldType.NUMBER && !editableColumn.autocolumn}
|
||||||
<div class="split-label">
|
<div class="split-label">
|
||||||
<div class="label-length">
|
<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]) => {
|
([$configStore, $errorsStore, $selectedValidatorsStore]) => {
|
||||||
const validatedConfig = []
|
const validatedConfig = []
|
||||||
|
|
||||||
|
const allowedRestKeys = ["rejectUnauthorized", "downloadImages"]
|
||||||
Object.entries(integration.datasource).forEach(([key, properties]) => {
|
Object.entries(integration.datasource).forEach(([key, properties]) => {
|
||||||
if (integration.name === "REST" && key !== "rejectUnauthorized") {
|
if (integration.name === "REST" && !allowedRestKeys.includes(key)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,6 +54,10 @@
|
||||||
label: "Attachment",
|
label: "Attachment",
|
||||||
value: FieldType.ATTACHMENT_SINGLE,
|
value: FieldType.ATTACHMENT_SINGLE,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Signature",
|
||||||
|
value: FieldType.SIGNATURE_SINGLE,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Attachment list",
|
label: "Attachment list",
|
||||||
value: FieldType.ATTACHMENTS,
|
value: FieldType.ATTACHMENTS,
|
||||||
|
|
|
@ -54,6 +54,7 @@
|
||||||
export let autofocus = false
|
export let autofocus = false
|
||||||
export let jsBindingWrapping = true
|
export let jsBindingWrapping = true
|
||||||
export let readonly = false
|
export let readonly = false
|
||||||
|
export let readonlyLineNumbers = false
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -240,6 +241,9 @@
|
||||||
|
|
||||||
if (readonly) {
|
if (readonly) {
|
||||||
complete.push(EditorState.readOnly.of(true))
|
complete.push(EditorState.readOnly.of(true))
|
||||||
|
if (readonlyLineNumbers) {
|
||||||
|
complete.push(lineNumbers())
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
complete = [
|
complete = [
|
||||||
...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>
|
<script>
|
||||||
import { Dropzone, notifications } from "@budibase/bbui"
|
import { Dropzone, notifications } from "@budibase/bbui"
|
||||||
|
import { admin } from "stores/portal"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
|
||||||
export let value = []
|
export let value = []
|
||||||
export let label
|
export let label
|
||||||
|
export let fileSizeLimit = undefined
|
||||||
|
|
||||||
const BYTES_IN_MB = 1000000
|
const BYTES_IN_MB = 1000000
|
||||||
|
|
||||||
|
@ -34,5 +36,6 @@
|
||||||
{label}
|
{label}
|
||||||
{...$$restProps}
|
{...$$restProps}
|
||||||
{processFiles}
|
{processFiles}
|
||||||
{handleFileTooLarge}
|
handleFileTooLarge={$admin.cloud ? handleFileTooLarge : null}
|
||||||
|
{fileSizeLimit}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let app
|
export let app
|
||||||
export let color
|
export let color
|
||||||
export let autoSave = false
|
export let autoSave = false
|
||||||
|
export let disabled = false
|
||||||
|
|
||||||
let modal
|
let modal
|
||||||
</script>
|
</script>
|
||||||
|
@ -14,12 +15,16 @@
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<div class="editable-icon">
|
<div class="editable-icon">
|
||||||
<div class="hover" on:click={modal.show}>
|
{#if !disabled}
|
||||||
<Icon name="Edit" {size} color="var(--spectrum-global-color-gray-600)" />
|
<div class="hover" on:click={modal.show}>
|
||||||
</div>
|
<Icon name="Edit" {size} color="var(--spectrum-global-color-gray-600)" />
|
||||||
<div class="normal">
|
</div>
|
||||||
|
<div class="normal">
|
||||||
|
<Icon name={name || "Apps"} {size} {color} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<Icon {name} {size} {color} />
|
<Icon {name} {size} {color} />
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal bind:this={modal}>
|
<Modal bind:this={modal}>
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
<b>{linkedTable.name}</b>
|
<b>{linkedTable.name}</b>
|
||||||
table.
|
table.
|
||||||
</Label>
|
</Label>
|
||||||
{:else if schema.relationshipType === "one-to-many"}
|
{:else if schema.relationshipType === "one-to-many" || schema.type === "bb_reference_single"}
|
||||||
<Select
|
<Select
|
||||||
value={linkedIds?.[0]}
|
value={linkedIds?.[0]}
|
||||||
options={rows}
|
options={rows}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { AbsTooltip, Icon } from "@budibase/bbui"
|
import { Icon, TooltipType, TooltipPosition } from "@budibase/bbui"
|
||||||
import { createEventDispatcher, getContext } from "svelte"
|
import { createEventDispatcher, getContext } from "svelte"
|
||||||
import { helpers } from "@budibase/shared-core"
|
import { helpers } from "@budibase/shared-core"
|
||||||
import { UserAvatars } from "@budibase/frontend-core"
|
import { UserAvatars } from "@budibase/frontend-core"
|
||||||
|
@ -25,6 +25,7 @@
|
||||||
export let selectedBy = null
|
export let selectedBy = null
|
||||||
export let compact = false
|
export let compact = false
|
||||||
export let hovering = false
|
export let hovering = false
|
||||||
|
export let disabled = false
|
||||||
|
|
||||||
const scrollApi = getContext("scroll")
|
const scrollApi = getContext("scroll")
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
@ -74,6 +75,7 @@
|
||||||
class:scrollable
|
class:scrollable
|
||||||
class:highlighted
|
class:highlighted
|
||||||
class:selectedBy
|
class:selectedBy
|
||||||
|
class:disabled
|
||||||
on:dragend
|
on:dragend
|
||||||
on:dragstart
|
on:dragstart
|
||||||
on:dragover
|
on:dragover
|
||||||
|
@ -112,9 +114,14 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if icon}
|
{:else if icon}
|
||||||
<div class="icon" class:right={rightAlignIcon}>
|
<div class="icon" class:right={rightAlignIcon}>
|
||||||
<AbsTooltip type="info" position="right" text={iconTooltip}>
|
<Icon
|
||||||
<Icon color={iconColor} size="S" name={icon} />
|
color={iconColor}
|
||||||
</AbsTooltip>
|
size="S"
|
||||||
|
name={icon}
|
||||||
|
tooltip={iconTooltip}
|
||||||
|
tooltipType={TooltipType.Info}
|
||||||
|
tooltipPosition={TooltipPosition.Right}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="text" title={showTooltip ? text : null}>
|
<div class="text" title={showTooltip ? text : null}>
|
||||||
|
@ -165,6 +172,9 @@
|
||||||
--avatars-background: var(--spectrum-global-color-gray-300);
|
--avatars-background: var(--spectrum-global-color-gray-300);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
.nav-item.disabled span {
|
||||||
|
color: var(--spectrum-global-color-gray-700);
|
||||||
|
}
|
||||||
.nav-item:hover,
|
.nav-item:hover,
|
||||||
.hovering {
|
.hovering {
|
||||||
background-color: var(--spectrum-global-color-gray-200);
|
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 {
|
import {
|
||||||
decodeJSBinding,
|
decodeJSBinding,
|
||||||
encodeJSBinding,
|
encodeJSBinding,
|
||||||
|
processObjectSync,
|
||||||
processStringSync,
|
processStringSync,
|
||||||
} from "@budibase/string-templates"
|
} from "@budibase/string-templates"
|
||||||
import { readableToRuntimeBinding } from "dataBinding"
|
import { readableToRuntimeBinding } from "dataBinding"
|
||||||
|
@ -153,13 +154,6 @@
|
||||||
debouncedEval(expression, context, snippets)
|
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 => {
|
const highlightJSON = json => {
|
||||||
return formatHighlight(json, {
|
return formatHighlight(json, {
|
||||||
keyColor: "#e06c75",
|
keyColor: "#e06c75",
|
||||||
|
@ -172,11 +166,27 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrichBindings = (bindings, context, snippets) => {
|
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) {
|
if (!context) {
|
||||||
return binding
|
return binding
|
||||||
}
|
}
|
||||||
const value = getBindingValue(binding, context, snippets)
|
const value = JSON.stringify(bindingEvauations[idx], null, 2)
|
||||||
return {
|
return {
|
||||||
...binding,
|
...binding,
|
||||||
value,
|
value,
|
||||||
|
@ -237,7 +247,12 @@
|
||||||
|
|
||||||
const onChangeJSValue = e => {
|
const onChangeJSValue = e => {
|
||||||
jsValue = encodeJSBinding(e.detail)
|
jsValue = encodeJSBinding(e.detail)
|
||||||
updateValue(jsValue)
|
if (!e.detail?.trim()) {
|
||||||
|
// Don't bother saving empty values as JS
|
||||||
|
updateValue(null)
|
||||||
|
} else {
|
||||||
|
updateValue(jsValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|
|
@ -75,13 +75,6 @@
|
||||||
if (!context || !binding.value || binding.value === "") {
|
if (!context || !binding.value || binding.value === "") {
|
||||||
return
|
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()
|
stopHidingPopover()
|
||||||
popoverAnchor = target
|
popoverAnchor = target
|
||||||
hoverTarget = {
|
hoverTarget = {
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
readableToRuntimeBinding,
|
readableToRuntimeBinding,
|
||||||
runtimeToReadableBinding,
|
runtimeToReadableBinding,
|
||||||
} from "dataBinding"
|
} from "dataBinding"
|
||||||
|
|
||||||
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
|
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
|
||||||
import { createEventDispatcher, setContext } from "svelte"
|
import { createEventDispatcher, setContext } from "svelte"
|
||||||
import { isJSBinding } from "@budibase/string-templates"
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
|
@ -28,6 +28,12 @@
|
||||||
let bindingDrawer
|
let bindingDrawer
|
||||||
let currentVal = value
|
let currentVal = value
|
||||||
|
|
||||||
|
let attachmentTypes = [
|
||||||
|
FieldType.ATTACHMENT_SINGLE,
|
||||||
|
FieldType.ATTACHMENTS,
|
||||||
|
FieldType.SIGNATURE_SINGLE,
|
||||||
|
]
|
||||||
|
|
||||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||||
$: tempValue = readableValue
|
$: tempValue = readableValue
|
||||||
$: isJS = isJSBinding(value)
|
$: isJS = isJSBinding(value)
|
||||||
|
@ -105,6 +111,7 @@
|
||||||
boolean: isValidBoolean,
|
boolean: isValidBoolean,
|
||||||
attachment: false,
|
attachment: false,
|
||||||
attachment_single: false,
|
attachment_single: false,
|
||||||
|
signature_single: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = value => {
|
const isValid = value => {
|
||||||
|
@ -126,6 +133,7 @@
|
||||||
"bigint",
|
"bigint",
|
||||||
"barcodeqr",
|
"barcodeqr",
|
||||||
"attachment",
|
"attachment",
|
||||||
|
"signature_single",
|
||||||
"attachment_single",
|
"attachment_single",
|
||||||
].includes(type)
|
].includes(type)
|
||||||
) {
|
) {
|
||||||
|
@ -169,7 +177,7 @@
|
||||||
{updateOnChange}
|
{updateOnChange}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
|
{#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
|
||||||
<div
|
<div
|
||||||
class={`icon ${getIconClass(value, type)}`}
|
class={`icon ${getIconClass(value, type)}`}
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
|
@ -8,13 +8,11 @@
|
||||||
ActionButton,
|
ActionButton,
|
||||||
Icon,
|
Icon,
|
||||||
Link,
|
Link,
|
||||||
Modal,
|
|
||||||
StatusLight,
|
StatusLight,
|
||||||
AbsTooltip,
|
AbsTooltip,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import RevertModal from "components/deploy/RevertModal.svelte"
|
import RevertModal from "components/deploy/RevertModal.svelte"
|
||||||
import VersionModal from "components/deploy/VersionModal.svelte"
|
import VersionModal from "components/deploy/VersionModal.svelte"
|
||||||
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
|
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
import analytics, { Events, EventSource } from "analytics"
|
import analytics, { Events, EventSource } from "analytics"
|
||||||
|
@ -26,7 +24,6 @@
|
||||||
isOnlyUser,
|
isOnlyUser,
|
||||||
appStore,
|
appStore,
|
||||||
deploymentStore,
|
deploymentStore,
|
||||||
initialise,
|
|
||||||
sortedScreens,
|
sortedScreens,
|
||||||
} from "stores/builder"
|
} from "stores/builder"
|
||||||
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||||
|
@ -37,7 +34,6 @@
|
||||||
export let loaded
|
export let loaded
|
||||||
|
|
||||||
let unpublishModal
|
let unpublishModal
|
||||||
let updateAppModal
|
|
||||||
let revertModal
|
let revertModal
|
||||||
let versionModal
|
let versionModal
|
||||||
let appActionPopover
|
let appActionPopover
|
||||||
|
@ -61,11 +57,6 @@
|
||||||
$: canPublish = !publishing && loaded && $sortedScreens.length > 0
|
$: canPublish = !publishing && loaded && $sortedScreens.length > 0
|
||||||
$: lastDeployed = getLastDeployedString($deploymentStore, lastOpened)
|
$: lastDeployed = getLastDeployedString($deploymentStore, lastOpened)
|
||||||
|
|
||||||
const initialiseApp = async () => {
|
|
||||||
const applicationPkg = await API.fetchAppPackage($appStore.devId)
|
|
||||||
await initialise(applicationPkg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLastDeployedString = deployments => {
|
const getLastDeployedString = deployments => {
|
||||||
return deployments?.length
|
return deployments?.length
|
||||||
? processStringSync("Published {{ duration time 'millisecond' }} ago", {
|
? processStringSync("Published {{ duration time 'millisecond' }} ago", {
|
||||||
|
@ -247,16 +238,12 @@
|
||||||
appActionPopover.hide()
|
appActionPopover.hide()
|
||||||
if (isPublished) {
|
if (isPublished) {
|
||||||
viewApp()
|
viewApp()
|
||||||
} else {
|
|
||||||
updateAppModal.show()
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{$appStore.url}
|
{$appStore.url}
|
||||||
{#if isPublished}
|
{#if isPublished}
|
||||||
<Icon size="S" name="LinkOut" />
|
<Icon size="S" name="LinkOut" />
|
||||||
{:else}
|
|
||||||
<Icon size="S" name="Edit" />
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</Body>
|
</Body>
|
||||||
|
@ -330,20 +317,6 @@
|
||||||
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
|
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
|
||||||
</ConfirmDialog>
|
</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} />
|
<RevertModal bind:this={revertModal} />
|
||||||
<VersionModal hideIcon bind:this={versionModal} />
|
<VersionModal hideIcon bind:this={versionModal} />
|
||||||
|
|
||||||
|
|
|
@ -76,6 +76,7 @@ const componentMap = {
|
||||||
"field/array": FormFieldSelect,
|
"field/array": FormFieldSelect,
|
||||||
"field/json": FormFieldSelect,
|
"field/json": FormFieldSelect,
|
||||||
"field/barcodeqr": FormFieldSelect,
|
"field/barcodeqr": FormFieldSelect,
|
||||||
|
"field/signature_single": FormFieldSelect,
|
||||||
"field/bb_reference": FormFieldSelect,
|
"field/bb_reference": FormFieldSelect,
|
||||||
// Some validation types are the same as others, so not all types are
|
// Some validation types are the same as others, so not all types are
|
||||||
// explicitly listed here. e.g. options uses string validation
|
// explicitly listed here. e.g. options uses string validation
|
||||||
|
@ -85,6 +86,8 @@ const componentMap = {
|
||||||
"validation/boolean": ValidationEditor,
|
"validation/boolean": ValidationEditor,
|
||||||
"validation/datetime": ValidationEditor,
|
"validation/datetime": ValidationEditor,
|
||||||
"validation/attachment": ValidationEditor,
|
"validation/attachment": ValidationEditor,
|
||||||
|
"validation/attachment_single": ValidationEditor,
|
||||||
|
"validation/signature_single": ValidationEditor,
|
||||||
"validation/link": ValidationEditor,
|
"validation/link": ValidationEditor,
|
||||||
"validation/bb_reference": ValidationEditor,
|
"validation/bb_reference": ValidationEditor,
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,9 @@
|
||||||
parameters
|
parameters
|
||||||
}
|
}
|
||||||
$: automations = $automationStore.automations
|
$: automations = $automationStore.automations
|
||||||
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
|
.filter(
|
||||||
|
a => a.definition.trigger?.stepId === TriggerStepID.APP && !a.disabled
|
||||||
|
)
|
||||||
.map(automation => {
|
.map(automation => {
|
||||||
const schema = Object.entries(
|
const schema = Object.entries(
|
||||||
automation.definition.trigger.inputs.fields || {}
|
automation.definition.trigger.inputs.fields || {}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||||
import { Icon } from "@budibase/bbui"
|
import { Icon } from "@budibase/bbui"
|
||||||
import { runtimeToReadableBinding } from "dataBinding"
|
import { runtimeToReadableBinding } from "dataBinding"
|
||||||
import { isJSBinding } from "@budibase/string-templates"
|
import { isJSBinding } from "@budibase/string-templates"
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<script>
|
<script>
|
||||||
import { Button, ActionButton, Drawer } from "@budibase/bbui"
|
import { Button, ActionButton, Drawer } from "@budibase/bbui"
|
||||||
|
import { search } from "@budibase/frontend-core"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import ColumnDrawer from "./ColumnDrawer.svelte"
|
import ColumnDrawer from "./ColumnDrawer.svelte"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
|
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
|
||||||
import { selectedScreen } from "stores/builder"
|
import { selectedScreen, tables } from "stores/builder"
|
||||||
import { getFields } from "helpers/searchFields"
|
|
||||||
|
|
||||||
export let componentInstance
|
export let componentInstance
|
||||||
export let value = []
|
export let value = []
|
||||||
|
@ -25,9 +25,13 @@
|
||||||
: enrichedSchemaFields?.map(field => field.name)
|
: enrichedSchemaFields?.map(field => field.name)
|
||||||
$: sanitisedValue = getValidColumns(value, options)
|
$: sanitisedValue = getValidColumns(value, options)
|
||||||
$: updateBoundValue(sanitisedValue)
|
$: updateBoundValue(sanitisedValue)
|
||||||
$: enrichedSchemaFields = getFields(Object.values(schema || {}), {
|
$: enrichedSchemaFields = search.getFields(
|
||||||
allowLinks: true,
|
$tables.list,
|
||||||
})
|
Object.values(schema || {}),
|
||||||
|
{
|
||||||
|
allowLinks: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
value = (value || []).filter(
|
value = (value || []).filter(
|
||||||
|
|
|
@ -309,7 +309,7 @@
|
||||||
{#if links?.length}
|
{#if links?.length}
|
||||||
<DataSourceCategory
|
<DataSourceCategory
|
||||||
dividerState={true}
|
dividerState={true}
|
||||||
heading="Links"
|
heading="Relationships"
|
||||||
dataSet={links}
|
dataSet={links}
|
||||||
{value}
|
{value}
|
||||||
onSelect={handleSelected}
|
onSelect={handleSelected}
|
||||||
|
|
|
@ -100,9 +100,6 @@
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
get(store).actions.select(draggableItem.id)
|
get(store).actions.select(draggableItem.id)
|
||||||
}}
|
}}
|
||||||
on:mousedown={() => {
|
|
||||||
get(store).actions.select()
|
|
||||||
}}
|
|
||||||
bind:this={anchors[draggableItem.id]}
|
bind:this={anchors[draggableItem.id]}
|
||||||
class:highlighted={draggableItem.id === $store.selected}
|
class:highlighted={draggableItem.id === $store.selected}
|
||||||
>
|
>
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
import { componentStore } from "stores/builder"
|
import { componentStore } from "stores/builder"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { createEventDispatcher, getContext } from "svelte"
|
import { createEventDispatcher, getContext } from "svelte"
|
||||||
import { customPositionHandler } from "."
|
|
||||||
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
|
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
|
||||||
|
|
||||||
export let anchor
|
export let anchor
|
||||||
|
@ -18,76 +17,74 @@
|
||||||
|
|
||||||
let popover
|
let popover
|
||||||
let drawers = []
|
let drawers = []
|
||||||
let open = false
|
let isOpen = false
|
||||||
|
|
||||||
// Auto hide the component when another item is selected
|
// Auto hide the component when another item is selected
|
||||||
$: if (open && $draggable.selected !== componentInstance._id) {
|
$: if (open && $draggable.selected !== componentInstance._id) {
|
||||||
popover.hide()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open automatically if the component is marked as selected
|
// Open automatically if the component is marked as selected
|
||||||
$: if (!open && $draggable.selected === componentInstance._id && popover) {
|
$: if (!open && $draggable.selected === componentInstance._id && popover) {
|
||||||
popover.show()
|
open()
|
||||||
open = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: componentDef = componentStore.getDefinition(componentInstance._component)
|
$: componentDef = componentStore.getDefinition(componentInstance._component)
|
||||||
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
|
$: 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 => {
|
const processComponentDefinitionSettings = componentDef => {
|
||||||
if (!componentDef) {
|
if (!componentDef) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
const clone = cloneDeep(componentDef)
|
const clone = cloneDeep(componentDef)
|
||||||
|
|
||||||
if (typeof parseSettings === "function") {
|
if (typeof parseSettings === "function") {
|
||||||
clone.settings = parseSettings(clone.settings)
|
clone.settings = parseSettings(clone.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSetting = async (setting, value) => {
|
const updateSetting = async (setting, value) => {
|
||||||
const nestedComponentInstance = cloneDeep(componentInstance)
|
const nestedComponentInstance = cloneDeep(componentInstance)
|
||||||
|
|
||||||
const patchFn = componentStore.updateComponentSetting(setting.key, value)
|
const patchFn = componentStore.updateComponentSetting(setting.key, value)
|
||||||
patchFn(nestedComponentInstance)
|
patchFn(nestedComponentInstance)
|
||||||
|
|
||||||
dispatch("change", nestedComponentInstance)
|
dispatch("change", nestedComponentInstance)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Icon
|
<Icon name="Settings" hoverable size="S" on:click={toggleOpen} />
|
||||||
name="Settings"
|
|
||||||
hoverable
|
|
||||||
size="S"
|
|
||||||
on:click={() => {
|
|
||||||
if (!open) {
|
|
||||||
popover.show()
|
|
||||||
open = true
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Popover
|
<Popover
|
||||||
bind:this={popover}
|
open={isOpen}
|
||||||
on:open={() => {
|
on:close={close}
|
||||||
drawers = []
|
|
||||||
$draggable.actions.select(componentInstance._id)
|
|
||||||
}}
|
|
||||||
on:close={() => {
|
|
||||||
open = false
|
|
||||||
if ($draggable.selected === componentInstance._id) {
|
|
||||||
$draggable.actions.select()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{anchor}
|
{anchor}
|
||||||
align="left-outside"
|
align="left-outside"
|
||||||
showPopover={drawers.length === 0}
|
showPopover={drawers.length === 0}
|
||||||
clickOutsideOverride={drawers.length > 0}
|
clickOutsideOverride={drawers.length > 0}
|
||||||
maxHeight={600}
|
maxHeight={600}
|
||||||
offset={18}
|
offset={18}
|
||||||
handlePostionUpdate={customPositionHandler}
|
|
||||||
>
|
>
|
||||||
<span class="popover-wrap">
|
<span class="popover-wrap">
|
||||||
<Layout noPadding noGap>
|
<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