1
0
Fork 0
mirror of synced 2024-09-10 06:26:02 +12:00

Merge branch 'master' into prefill-cards

This commit is contained in:
Andrew Kingston 2024-04-26 15:45:24 +01:00 committed by GitHub
commit e6aa5bcbda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 3356 additions and 1428 deletions

View file

@ -42,6 +42,8 @@
}, },
"rules": { "rules": {
"no-unused-vars": "off", "no-unused-vars": "off",
"local-rules/no-budibase-imports": "error",
"local-rules/no-console-error": "error",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",
{ {

View file

@ -92,8 +92,6 @@ jobs:
test-libraries: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
REUSE_CONTAINERS: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -150,8 +148,6 @@ jobs:
test-server: test-server:
runs-on: budi-tubby-tornado-quad-core-150gb runs-on: budi-tubby-tornado-quad-core-150gb
env:
REUSE_CONTAINERS: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4

View file

@ -106,6 +106,8 @@ spec:
value: {{ .Values.services.objectStore.globalBucketName | quote }} value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: BACKUPS_BUCKET_NAME - name: BACKUPS_BUCKET_NAME
value: {{ .Values.services.objectStore.backupsBucketName | quote }} value: {{ .Values.services.objectStore.backupsBucketName | quote }}
- name: TEMP_BUCKET_NAME
value: {{ .Values.globals.tempBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.apps.port | quote }} value: {{ .Values.services.apps.port | quote }}
{{ if .Values.services.worker.publicApiRateLimitPerSecond }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }}

View file

@ -107,6 +107,8 @@ spec:
value: {{ .Values.services.objectStore.globalBucketName | quote }} value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: BACKUPS_BUCKET_NAME - name: BACKUPS_BUCKET_NAME
value: {{ .Values.services.objectStore.backupsBucketName | quote }} value: {{ .Values.services.objectStore.backupsBucketName | quote }}
- name: TEMP_BUCKET_NAME
value: {{ .Values.globals.tempBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.automationWorkers.port | quote }} value: {{ .Values.services.automationWorkers.port | quote }}
{{ if .Values.services.worker.publicApiRateLimitPerSecond }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }}

View file

@ -106,6 +106,8 @@ spec:
value: {{ .Values.services.objectStore.globalBucketName | quote }} value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: BACKUPS_BUCKET_NAME - name: BACKUPS_BUCKET_NAME
value: {{ .Values.services.objectStore.backupsBucketName | quote }} value: {{ .Values.services.objectStore.backupsBucketName | quote }}
- name: TEMP_BUCKET_NAME
value: {{ .Values.globals.tempBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.worker.port | quote }} value: {{ .Values.services.worker.port | quote }}
- name: MULTI_TENANCY - name: MULTI_TENANCY

View file

@ -121,6 +121,9 @@ globals:
# to the old value for the duration of the rotation. # to the old value for the duration of the rotation.
jwtSecretFallback: "" jwtSecretFallback: ""
## -- If using S3 the bucket name to be used for storing temporary files
tempBucketName: ""
smtp: smtp:
# -- Whether to enable SMTP or not. # -- Whether to enable SMTP or not.
enabled: false enabled: false

View file

@ -1,4 +1,25 @@
module.exports = { module.exports = {
"no-console-error": {
create: function(context) {
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.name === "console" &&
node.callee.property.name === "error" &&
node.arguments.length === 1 &&
node.arguments[0].name &&
node.arguments[0].name.startsWith("err")
) {
context.report({
node,
message: 'Using console.error(err) on its own is not allowed. Either provide context to the error (console.error(msg, err)) or throw it.',
})
}
},
};
},
},
"no-budibase-imports": { "no-budibase-imports": {
create: function (context) { create: function (context) {
return { return {

View file

@ -1,16 +1,49 @@
import { GenericContainer, Wait } from "testcontainers" import {
GenericContainer,
Wait,
getContainerRuntimeClient,
} from "testcontainers"
import { ContainerInfo } from "dockerode"
import path from "path" import path from "path"
import lockfile from "proper-lockfile" import lockfile from "proper-lockfile"
async function getBudibaseContainers() {
const client = await getContainerRuntimeClient()
const conatiners = await client.container.list()
return conatiners.filter(
container =>
container.Labels["com.budibase"] === "true" &&
container.Labels["org.testcontainers"] === "true"
)
}
async function killContainers(containers: ContainerInfo[]) {
const client = await getContainerRuntimeClient()
for (const container of containers) {
const c = client.container.getById(container.Id)
await c.kill()
await c.remove()
}
}
export default async function setup() { export default async function setup() {
const lockPath = path.resolve(__dirname, "globalSetup.ts") const lockPath = path.resolve(__dirname, "globalSetup.ts")
if (process.env.REUSE_CONTAINERS) { // If you run multiple tests at the same time, it's possible for the CouchDB
// If you run multiple tests at the same time, it's possible for the CouchDB // shared container to get started multiple times despite having an
// shared container to get started multiple times despite having an // identical reuse hash. To avoid that, we do a filesystem-based lock so
// identical reuse hash. To avoid that, we do a filesystem-based lock so // that only one globalSetup.ts is running at a time.
// that only one globalSetup.ts is running at a time. lockfile.lockSync(lockPath)
lockfile.lockSync(lockPath)
} // Remove any containers that are older than 24 hours. This is to prevent
// containers getting full volumes or accruing any other problems from being
// left up for very long periods of time.
const threshold = new Date(Date.now() - 1000 * 60 * 60 * 24)
const containers = (await getBudibaseContainers()).filter(container => {
const created = new Date(container.Created * 1000)
return created < threshold
})
await killContainers(containers)
try { try {
let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
@ -28,20 +61,16 @@ export default async function setup() {
target: "/opt/couchdb/etc/local.d/test-couchdb.ini", target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
}, },
]) ])
.withLabels({ "com.budibase": "true" })
.withReuse()
.withWaitStrategy( .withWaitStrategy(
Wait.forSuccessfulCommand( Wait.forSuccessfulCommand(
"curl http://budibase:budibase@localhost:5984/_up" "curl http://budibase:budibase@localhost:5984/_up"
).withStartupTimeout(20000) ).withStartupTimeout(20000)
) )
if (process.env.REUSE_CONTAINERS) {
couchdb = couchdb.withReuse()
}
await couchdb.start() await couchdb.start()
} finally { } finally {
if (process.env.REUSE_CONTAINERS) { lockfile.unlockSync(lockPath)
lockfile.unlockSync(lockPath)
}
} }
} }

View file

@ -19,9 +19,6 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json
RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
# We will never want to sync pro, but the script is still required
RUN echo '' > scripts/syncProPackage.js
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile

View file

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

View file

@ -32,7 +32,6 @@
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js",
"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",
@ -60,7 +59,8 @@
"dev:all": "yarn run kill-all && lerna run --stream dev", "dev:all": "yarn run kill-all && lerna run --stream dev",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
"dev:docker": "./scripts/devDocker.sh", "dev:docker": "./scripts/devDocker.sh",
"test": "REUSE_CONTAINERS=1 lerna run --concurrency 1 --stream test --stream", "test": "lerna run --concurrency 1 --stream test --stream",
"test:containers:kill": "./scripts/killTestcontainers.sh",
"lint:eslint": "eslint packages --max-warnings=0", "lint:eslint": "eslint packages --max-warnings=0",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
@ -107,6 +107,7 @@
"@budibase/shared-core": "0.0.0", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@budibase/pro": "npm:@budibase/pro@latest",
"tough-cookie": "4.1.3", "tough-cookie": "4.1.3",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"semver": "7.5.3", "semver": "7.5.3",

@ -1 +1 @@
Subproject commit eb7d5da233885c5cffd9c255d3e954d0cd39185e Subproject commit c167c331ff9b8161fc18e2ecbaaf1ea5815ba964

View file

@ -64,7 +64,6 @@ async function refreshOIDCAccessToken(
} }
strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp) strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp)
} catch (err) { } catch (err) {
console.error(err)
throw new Error("Could not refresh OAuth Token") throw new Error("Could not refresh OAuth Token")
} }
@ -99,7 +98,6 @@ async function refreshGoogleAccessToken(
ssoSaveUserNoOp ssoSaveUserNoOp
) )
} catch (err: any) { } catch (err: any) {
console.error(err)
throw new Error( throw new Error(
`Error constructing OIDC refresh strategy: message=${err.message}` `Error constructing OIDC refresh strategy: message=${err.message}`
) )

View file

@ -29,6 +29,7 @@ const DefaultBucketName = {
TEMPLATES: "templates", TEMPLATES: "templates",
GLOBAL: "global", GLOBAL: "global",
PLUGINS: "plugins", PLUGINS: "plugins",
TEMP: "tmp-file-attachments",
} }
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "") const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
@ -146,6 +147,7 @@ const environment = {
process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL, process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL,
PLUGIN_BUCKET_NAME: PLUGIN_BUCKET_NAME:
process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS,
TEMP_BUCKET_NAME: process.env.TEMP_BUCKET_NAME || DefaultBucketName.TEMP,
USE_COUCH: process.env.USE_COUCH || true, USE_COUCH: process.env.USE_COUCH || true,
MOCK_REDIS: process.env.MOCK_REDIS, MOCK_REDIS: process.env.MOCK_REDIS,
DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE,

View file

@ -138,7 +138,6 @@ export default function (
} catch (err: any) { } catch (err: any) {
authenticated = false authenticated = false
console.error(`Auth Error: ${err.message}`) console.error(`Auth Error: ${err.message}`)
console.error(err)
// remove the cookie as the user does not exist anymore // remove the cookie as the user does not exist anymore
clearCookie(ctx, Cookie.Auth) clearCookie(ctx, Cookie.Auth)
} }
@ -187,7 +186,6 @@ export default function (
} }
} catch (err: any) { } catch (err: any) {
console.error(`Auth Error: ${err.message}`) console.error(`Auth Error: ${err.message}`)
console.error(err)
// invalid token, clear the cookie // invalid token, clear the cookie
if (err?.name === "JsonWebTokenError") { if (err?.name === "JsonWebTokenError") {
clearCookie(ctx, Cookie.Auth) clearCookie(ctx, Cookie.Auth)

View file

@ -12,7 +12,7 @@ export async function errorHandling(ctx: any, next: any) {
if (status >= 400 && status < 500) { if (status >= 400 && status < 500) {
console.warn(err) console.warn(err)
} else { } else {
console.error(err) console.error("Got 400 response code", err)
} }
let error: APIError = { let error: APIError = {

View file

@ -68,7 +68,6 @@ export async function strategyFactory(
verify verify
) )
} catch (err: any) { } catch (err: any) {
console.error(err)
throw new Error(`Error constructing google authentication strategy: ${err}`) throw new Error(`Error constructing google authentication strategy: ${err}`)
} }
} }

View file

@ -103,7 +103,6 @@ export async function strategyFactory(
strategy.name = "oidc" strategy.name = "oidc"
return strategy return strategy
} catch (err: any) { } catch (err: any) {
console.error(err)
throw new Error(`Error constructing OIDC authentication strategy - ${err}`) throw new Error(`Error constructing OIDC authentication strategy - ${err}`)
} }
} }
@ -142,7 +141,6 @@ export async function fetchStrategyConfig(
callbackURL: callbackUrl, callbackURL: callbackUrl,
} }
} catch (err) { } catch (err) {
console.error(err)
throw new Error( throw new Error(
`Error constructing OIDC authentication configuration - ${err}` `Error constructing OIDC authentication configuration - ${err}`
) )

View file

@ -26,7 +26,6 @@ export const getMigrationsDoc = async (db: any) => {
if (err.status && err.status === 404) { if (err.status && err.status === 404) {
return { _id: DocumentType.MIGRATIONS } return { _id: DocumentType.MIGRATIONS }
} else { } else {
console.error(err)
throw err throw err
} }
} }

View file

@ -7,31 +7,41 @@ import tar from "tar-fs"
import zlib from "zlib" import zlib from "zlib"
import { promisify } from "util" import { promisify } from "util"
import { join } from "path" import { join } from "path"
import fs, { ReadStream } from "fs" import fs, { PathLike, ReadStream } from "fs"
import env from "../environment" import env from "../environment"
import { budibaseTempDir } from "./utils" import { bucketTTLConfig, budibaseTempDir } from "./utils"
import { v4 } from "uuid" 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"
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
const STATE = { const STATE = {
bucketCreationPromises: {}, bucketCreationPromises: {},
} }
const signedFilePrefix = "/files/signed"
type ListParams = { type ListParams = {
ContinuationToken?: string ContinuationToken?: string
} }
type UploadParams = { type BaseUploadParams = {
bucket: string bucket: string
filename: string filename: string
path: string
type?: string | null type?: string | null
// can be undefined, we will remove it metadata?: { [key: string]: string | undefined }
metadata?: { body?: ReadableStream | Buffer
[key: string]: string | undefined ttl?: number
} addTTL?: boolean
extra?: any
}
type UploadParams = BaseUploadParams & {
path?: string | PathLike
}
type StreamUploadParams = BaseUploadParams & {
stream: ReadStream
} }
const CONTENT_TYPE_MAP: any = { const CONTENT_TYPE_MAP: any = {
@ -41,6 +51,8 @@ const CONTENT_TYPE_MAP: any = {
js: "application/javascript", js: "application/javascript",
json: "application/json", json: "application/json",
gz: "application/gzip", gz: "application/gzip",
svg: "image/svg+xml",
form: "multipart/form-data",
} }
const STRING_CONTENT_TYPES = [ const STRING_CONTENT_TYPES = [
@ -105,7 +117,10 @@ export function ObjectStore(
* Given an object store and a bucket name this will make sure the bucket exists, * Given an object store and a bucket name this will make sure the bucket exists,
* if it does not exist then it will create it. * if it does not exist then it will create it.
*/ */
export async function makeSureBucketExists(client: any, bucketName: string) { export async function createBucketIfNotExists(
client: any,
bucketName: string
): Promise<{ created: boolean; exists: boolean }> {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
try { try {
await client await client
@ -113,15 +128,16 @@ export async function makeSureBucketExists(client: any, bucketName: string) {
Bucket: bucketName, Bucket: bucketName,
}) })
.promise() .promise()
return { created: false, exists: true }
} catch (err: any) { } catch (err: any) {
const promises: any = STATE.bucketCreationPromises const promises: any = STATE.bucketCreationPromises
const doesntExist = err.statusCode === 404, const doesntExist = err.statusCode === 404,
noAccess = err.statusCode === 403 noAccess = err.statusCode === 403
if (promises[bucketName]) { if (promises[bucketName]) {
await promises[bucketName] await promises[bucketName]
return { created: false, exists: true }
} else if (doesntExist || noAccess) { } else if (doesntExist || noAccess) {
if (doesntExist) { if (doesntExist) {
// bucket doesn't exist create it
promises[bucketName] = client promises[bucketName] = client
.createBucket({ .createBucket({
Bucket: bucketName, Bucket: bucketName,
@ -129,13 +145,15 @@ export async function makeSureBucketExists(client: any, bucketName: string) {
.promise() .promise()
await promises[bucketName] await promises[bucketName]
delete promises[bucketName] delete promises[bucketName]
return { created: true, exists: false }
} else {
throw new Error("Access denied to object store bucket." + err)
} }
} else { } else {
throw new Error("Unable to write to object store bucket.") throw new Error("Unable to write to object store bucket.")
} }
} }
} }
/** /**
* Uploads the contents of a file given the required parameters, useful when * Uploads the contents of a file given the required parameters, useful when
* temp files in use (for example file uploaded as an attachment). * temp files in use (for example file uploaded as an attachment).
@ -146,12 +164,22 @@ export async function upload({
path, path,
type, type,
metadata, metadata,
body,
ttl,
}: UploadParams) { }: UploadParams) {
const extension = filename.split(".").pop() const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path)
const fileBytes = path ? (await fsp.open(path)).createReadStream() : body
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) {
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
}
let contentType = type let contentType = type
if (!contentType) { if (!contentType) {
@ -174,6 +202,7 @@ export async function upload({
} }
config.Metadata = metadata config.Metadata = metadata
} }
return objectStore.upload(config).promise() return objectStore.upload(config).promise()
} }
@ -181,14 +210,24 @@ export async function upload({
* Similar to the upload function but can be used to send a file stream * Similar to the upload function but can be used to send a file stream
* through to the object store. * through to the object store.
*/ */
export async function streamUpload( export async function streamUpload({
bucketName: string, bucket: bucketName,
filename: string, stream,
stream: ReadStream | ReadableStream, filename,
extra = {} type,
) { extra,
ttl,
}: StreamUploadParams) {
const extension = filename.split(".").pop()
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) {
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
}
// Set content type for certain known extensions // Set content type for certain known extensions
if (filename?.endsWith(".js")) { if (filename?.endsWith(".js")) {
@ -203,10 +242,18 @@ export async function streamUpload(
} }
} }
let contentType = type
if (!contentType) {
contentType = extension
? CONTENT_TYPE_MAP[extension.toLowerCase()]
: CONTENT_TYPE_MAP.txt
}
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: sanitizeBucket(bucketName),
Key: sanitizeKey(filename), Key: sanitizeKey(filename),
Body: stream, Body: stream,
ContentType: contentType,
...extra, ...extra,
} }
return objectStore.upload(params).promise() return objectStore.upload(params).promise()
@ -286,7 +333,7 @@ export function getPresignedUrl(
const signedUrl = new URL(url) const signedUrl = new URL(url)
const path = signedUrl.pathname const path = signedUrl.pathname
const query = signedUrl.search const query = signedUrl.search
return `/files/signed${path}${query}` return `${signedFilePrefix}${path}${query}`
} }
} }
@ -341,7 +388,7 @@ export async function retrieveDirectory(bucketName: string, path: string) {
*/ */
export async function deleteFile(bucketName: string, filepath: string) { export async function deleteFile(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await createBucketIfNotExists(objectStore, bucketName)
const params = { const params = {
Bucket: bucketName, Bucket: bucketName,
Key: sanitizeKey(filepath), Key: sanitizeKey(filepath),
@ -351,7 +398,7 @@ export async function deleteFile(bucketName: string, filepath: string) {
export async function deleteFiles(bucketName: string, filepaths: string[]) { export async function deleteFiles(bucketName: string, filepaths: string[]) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await createBucketIfNotExists(objectStore, bucketName)
const params = { const params = {
Bucket: bucketName, Bucket: bucketName,
Delete: { Delete: {
@ -412,7 +459,13 @@ export async function uploadDirectory(
if (file.isDirectory()) { if (file.isDirectory()) {
uploads.push(uploadDirectory(bucketName, local, path)) uploads.push(uploadDirectory(bucketName, local, path))
} else { } else {
uploads.push(streamUpload(bucketName, path, fs.createReadStream(local))) uploads.push(
streamUpload({
bucket: bucketName,
filename: path,
stream: fs.createReadStream(local),
})
)
} }
} }
await Promise.all(uploads) await Promise.all(uploads)
@ -467,3 +520,23 @@ export async function getReadStream(
} }
return client.getObject(params).createReadStream() return client.getObject(params).createReadStream()
} }
/*
Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract
the bucket and the path from it
*/
export function extractBucketAndPath(
url: string
): { bucket: string; path: string } | null {
const baseUrl = url.split("?")[0]
const regex = new RegExp(`^${signedFilePrefix}/(?<bucket>[^/]+)/(?<path>.+)$`)
const match = baseUrl.match(regex)
if (match && match.groups) {
const { bucket, path } = match.groups
return { bucket, path }
}
return null
}

View file

@ -2,6 +2,7 @@ import { join } from "path"
import { tmpdir } from "os" import { tmpdir } from "os"
import fs from "fs" import fs from "fs"
import env from "../environment" import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"
/**************************************************** /****************************************************
* NOTE: When adding a new bucket - name * * NOTE: When adding a new bucket - name *
@ -15,6 +16,7 @@ export const ObjectStoreBuckets = {
TEMPLATES: env.TEMPLATES_BUCKET_NAME, TEMPLATES: env.TEMPLATES_BUCKET_NAME,
GLOBAL: env.GLOBAL_BUCKET_NAME, GLOBAL: env.GLOBAL_BUCKET_NAME,
PLUGINS: env.PLUGIN_BUCKET_NAME, PLUGINS: env.PLUGIN_BUCKET_NAME,
TEMP: env.TEMP_BUCKET_NAME,
} }
const bbTmp = join(tmpdir(), ".budibase") const bbTmp = join(tmpdir(), ".budibase")
@ -29,3 +31,27 @@ try {
export function budibaseTempDir() { export function budibaseTempDir() {
return bbTmp return bbTmp
} }
export const bucketTTLConfig = (
bucketName: string,
days: number
): PutBucketLifecycleConfigurationRequest => {
const lifecycleRule = {
ID: `${bucketName}-ExpireAfter${days}days`,
Prefix: "",
Status: "Enabled",
Expiration: {
Days: days,
},
}
const lifecycleConfiguration = {
Rules: [lifecycleRule],
}
const params = {
Bucket: bucketName,
LifecycleConfiguration: lifecycleConfiguration,
}
return params
}

View file

@ -4,3 +4,6 @@ export { generator } from "./structures"
export * as testContainerUtils from "./testContainerUtils" export * as testContainerUtils from "./testContainerUtils"
export * as utils from "./utils" export * as utils from "./utils"
export * from "./jestUtils" export * from "./jestUtils"
import * as minio from "./minio"
export const objectStoreTestProviders = { minio }

View file

@ -0,0 +1,34 @@
import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
import env from "../../../src/environment"
let container: StartedTestContainer | undefined
class ObjectStoreWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
const logs = Wait.forListeningPorts()
await logs.waitUntilReady(container, boundPorts, startTime)
}
}
export async function start(): Promise<void> {
container = await new GenericContainer("minio/minio")
.withExposedPorts(9000)
.withCommand(["server", "/data"])
.withEnvironment({
MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase",
})
.withWaitStrategy(new ObjectStoreWaitStrategy().withStartupTimeout(30000))
.start()
const port = container.getMappedPort(9000)
env._set("MINIO_URL", `http://0.0.0.0:${port}`)
}
export async function stop() {
if (container) {
await container.stop()
container = undefined
}
}

View file

@ -28,7 +28,11 @@ function getTestcontainers(): ContainerInfo[] {
.split("\n") .split("\n")
.filter(x => x.length > 0) .filter(x => x.length > 0)
.map(x => JSON.parse(x) as ContainerInfo) .map(x => JSON.parse(x) as ContainerInfo)
.filter(x => x.Labels.includes("org.testcontainers=true")) .filter(
x =>
x.Labels.includes("org.testcontainers=true") &&
x.Labels.includes("com.budibase=true")
)
} }
export function getContainerByImage(image: string) { export function getContainerByImage(image: string) {

View file

@ -43,6 +43,7 @@
"@spectrum-css/avatar": "3.0.2", "@spectrum-css/avatar": "3.0.2",
"@spectrum-css/button": "3.0.1", "@spectrum-css/button": "3.0.1",
"@spectrum-css/buttongroup": "3.0.2", "@spectrum-css/buttongroup": "3.0.2",
"@spectrum-css/calendar": "3.2.7",
"@spectrum-css/checkbox": "3.0.2", "@spectrum-css/checkbox": "3.0.2",
"@spectrum-css/dialog": "3.0.1", "@spectrum-css/dialog": "3.0.1",
"@spectrum-css/divider": "1.0.3", "@spectrum-css/divider": "1.0.3",

View file

@ -38,7 +38,15 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close> <Popover
bind:this={dropdown}
{anchor}
{align}
{portalTarget}
resizable={false}
on:open
on:close
>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

View file

@ -1,7 +1,13 @@
const ignoredClasses = [ const ignoredClasses = [
".flatpickr-calendar",
".spectrum-Popover",
".download-js-link", ".download-js-link",
".flatpickr-calendar",
".spectrum-Menu",
".date-time-popover",
]
const conditionallyIgnoredClasses = [
".spectrum-Underlay",
".drawer-wrapper",
".spectrum-Popover",
] ]
let clickHandlers = [] let clickHandlers = []
@ -9,6 +15,9 @@ let clickHandlers = []
* Handle a body click event * Handle a body click event
*/ */
const handleClick = event => { const handleClick = event => {
// Treat right clicks (context menu events) as normal clicks
const eventType = event.type === "contextmenu" ? "click" : event.type
// Ignore click if this is an ignored class // Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) { if (event.target.closest('[data-ignore-click-outside="true"]')) {
return return
@ -21,26 +30,23 @@ const handleClick = event => {
// Process handlers // Process handlers
clickHandlers.forEach(handler => { clickHandlers.forEach(handler => {
// Check that we're the right kind of click event
if (handler.allowedType && eventType !== handler.allowedType) {
return
}
// Check that the click isn't inside the target
if (handler.element.contains(event.target)) { if (handler.element.contains(event.target)) {
return return
} }
// Ignore clicks for modals, unless the handler is registered from a modal // Ignore clicks for certain classes unless we're nested inside them
const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null for (let className of conditionallyIgnoredClasses) {
const clickInModal = event.target.closest(".spectrum-Underlay") != null const sourceInside = handler.anchor.closest(className) != null
if (clickInModal && !sourceInModal) { const clickInside = event.target.closest(className) != null
return if (clickInside && !sourceInside) {
} return
}
// Ignore clicks for drawers, unless the handler is registered from a drawer
const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
const clickInDrawer = event.target.closest(".drawer-wrapper") != null
if (clickInDrawer && !sourceInDrawer) {
return
}
if (handler.allowedType && event.type !== handler.allowedType) {
return
} }
handler.callback?.(event) handler.callback?.(event)
@ -48,6 +54,7 @@ const handleClick = event => {
} }
document.documentElement.addEventListener("click", handleClick, true) document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("mousedown", handleClick, true) document.documentElement.addEventListener("mousedown", handleClick, true)
document.documentElement.addEventListener("contextmenu", handleClick, true)
/** /**
* Adds or updates a click handler * Adds or updates a click handler

View file

@ -1,3 +1,22 @@
/**
* Valid alignment options are
* - left
* - right
* - left-outside
* - right-outside
**/
// Strategies are defined as [Popover]To[Anchor].
// They can apply for both horizontal and vertical alignment.
const Strategies = {
StartToStart: "StartToStart", // e.g. left alignment
EndToEnd: "EndToEnd", // e.g. right alignment
StartToEnd: "StartToEnd", // e.g. right-outside alignment
EndToStart: "EndToStart", // e.g. left-outside alignment
MidPoint: "MidPoint", // centers relative to midpoints
ScreenEdge: "ScreenEdge", // locks to screen edge
}
export default function positionDropdown(element, opts) { export default function positionDropdown(element, opts) {
let resizeObserver let resizeObserver
let latestOpts = opts let latestOpts = opts
@ -19,6 +38,8 @@ export default function positionDropdown(element, opts) {
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
resizable,
wrap,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -27,56 +48,159 @@ export default function positionDropdown(element, opts) {
// Compute bounds // Compute bounds
const anchorBounds = anchor.getBoundingClientRect() const anchorBounds = anchor.getBoundingClientRect()
const elementBounds = element.getBoundingClientRect() const elementBounds = element.getBoundingClientRect()
const winWidth = window.innerWidth
const winHeight = window.innerHeight
const screenOffset = 8
let styles = { let styles = {
maxHeight: null, maxHeight,
minWidth, minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth, maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null, left: null,
top: null, top: null,
} }
// Ignore all our logic for custom logic
if (typeof customUpdate === "function") { if (typeof customUpdate === "function") {
styles = customUpdate(anchorBounds, elementBounds, { styles = customUpdate(anchorBounds, elementBounds, {
...styles, ...styles,
offset: opts.offset, offset: opts.offset,
}) })
} else { }
// Determine vertical styles
if (align === "right-outside" || align === "left-outside") { // Otherwise position ourselves as normal
styles.top = else {
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2 // Checks if we overflow off the screen. We only report that we overflow
styles.maxHeight = maxHeight // when the alternative dimension is larger than the one we are checking.
if (styles.top + elementBounds.height > window.innerHeight) { const doesXOverflow = () => {
styles.top = window.innerHeight - elementBounds.height const overflows = styles.left + elementBounds.width > winWidth
} return overflows && anchorBounds.left > winWidth - anchorBounds.right
} else if ( }
window.innerHeight - anchorBounds.bottom < const doesYOverflow = () => {
(maxHeight || 100) const overflows = styles.top + elementBounds.height > winHeight
) { return overflows && anchorBounds.top > winHeight - anchorBounds.bottom
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
} }
// Determine horizontal styles // Applies a dynamic max height constraint if appropriate
if (!maxWidth && useAnchorWidth) { const applyMaxHeight = height => {
styles.maxWidth = anchorBounds.width if (!styles.maxHeight && resizable) {
styles.maxHeight = height
}
} }
if (useAnchorWidth) {
styles.minWidth = anchorBounds.width // Applies the X strategy to our styles
const applyXStrategy = strategy => {
switch (strategy) {
case Strategies.StartToStart:
default:
styles.left = anchorBounds.left
break
case Strategies.EndToEnd:
styles.left = anchorBounds.right - elementBounds.width
break
case Strategies.StartToEnd:
styles.left = anchorBounds.right + offset
break
case Strategies.EndToStart:
styles.left = anchorBounds.left - elementBounds.width - offset
break
case Strategies.MidPoint:
styles.left =
anchorBounds.left +
anchorBounds.width / 2 -
elementBounds.width / 2
break
case Strategies.ScreenEdge:
styles.left = winWidth - elementBounds.width - screenOffset
break
}
} }
// Applies the Y strategy to our styles
const applyYStrategy = strategy => {
switch (strategy) {
case Strategies.StartToStart:
styles.top = anchorBounds.top
applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
break
case Strategies.EndToEnd:
styles.top = anchorBounds.bottom - elementBounds.height
applyMaxHeight(anchorBounds.bottom - screenOffset)
break
case Strategies.StartToEnd:
default:
styles.top = anchorBounds.bottom + offset
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
break
case Strategies.EndToStart:
styles.top = anchorBounds.top - elementBounds.height - offset
applyMaxHeight(anchorBounds.top - screenOffset)
break
case Strategies.MidPoint:
styles.top =
anchorBounds.top +
anchorBounds.height / 2 -
elementBounds.height / 2
break
case Strategies.ScreenEdge:
styles.top = winHeight - elementBounds.height - screenOffset
applyMaxHeight(winHeight - 2 * screenOffset)
break
}
}
// Determine X strategy
if (align === "right") { if (align === "right") {
styles.left = applyXStrategy(Strategies.EndToEnd)
anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") { } else if (align === "right-outside") {
styles.left = anchorBounds.right + offset applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") { } else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset applyXStrategy(Strategies.EndToStart)
} else { } else {
styles.left = anchorBounds.left applyXStrategy(Strategies.StartToStart)
}
// Determine Y strategy
if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint)
} else {
applyYStrategy(Strategies.StartToEnd)
}
// Handle screen overflow
if (doesXOverflow()) {
// Swap left to right
if (align === "left") {
applyXStrategy(Strategies.EndToEnd)
}
// Swap right-outside to left-outside
else if (align === "right-outside") {
applyXStrategy(Strategies.EndToStart)
}
}
if (doesYOverflow()) {
// If wrapping, lock to the bottom of the screen and also reposition to
// the side to not block the anchor
if (wrap) {
applyYStrategy(Strategies.MidPoint)
if (doesYOverflow()) {
applyYStrategy(Strategies.ScreenEdge)
}
applyXStrategy(Strategies.StartToEnd)
if (doesXOverflow()) {
applyXStrategy(Strategies.EndToStart)
}
}
// Othewise invert as normal
else {
// If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") {
applyYStrategy(Strategies.ScreenEdge)
}
// Otherwise flip above
else {
applyYStrategy(Strategies.EndToStart)
}
}
} }
} }

View file

@ -1,268 +0,0 @@
<script>
import Flatpickr from "svelte-flatpickr"
import "flatpickr/dist/flatpickr.css"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/textfield/dist/index-vars.css"
import "@spectrum-css/picker/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import { uuid } from "../../helpers"
export let id = null
export let disabled = false
export let readonly = false
export let enableTime = true
export let value = null
export let placeholder = null
export let appendTo = undefined
export let timeOnly = false
export let ignoreTimezones = false
export let time24hr = false
export let range = false
export let flatpickr
export let useKeyboardShortcuts = true
const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper`
let open = false
let flatpickrOptions
// Another classic flatpickr issue. Errors were randomly being thrown due to
// flatpickr internal code. Making sure that "destroy" is a valid function
// fixes it. The sooner we remove flatpickr the better.
$: {
if (flatpickr && !flatpickr.destroy) {
flatpickr.destroy = () => {}
}
}
const resolveTimeStamp = timestamp => {
let maskedDate = new Date(`0-${timestamp}`)
if (maskedDate instanceof Date && !isNaN(maskedDate.getTime())) {
return maskedDate
} else {
return null
}
}
$: flatpickrOptions = {
element: `#${flatpickrId}`,
enableTime: timeOnly || enableTime || false,
noCalendar: timeOnly || false,
altInput: true,
time_24hr: time24hr || false,
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
wrap: true,
mode: range ? "range" : "single",
appendTo,
disableMobile: "true",
onReady: () => {
let timestamp = resolveTimeStamp(value)
if (timeOnly && timestamp) {
dispatch("change", timestamp.toISOString())
}
},
onOpen: () => dispatch("open"),
onClose: () => dispatch("close"),
}
$: redrawOptions = {
timeOnly,
enableTime,
time24hr,
disabled,
}
const handleChange = event => {
const [dates] = event.detail
const noTimezone = enableTime && !timeOnly && ignoreTimezones
let newValue = dates[0]
if (newValue) {
newValue = newValue.toISOString()
}
// If time only set date component to 2000-01-01
if (timeOnly) {
newValue = `2000-01-01T${newValue.split("T")[1]}`
}
// For date-only fields, construct a manual timestamp string without a time
// or time zone
else if (!enableTime) {
const year = dates[0].getFullYear()
const month = `${dates[0].getMonth() + 1}`.padStart(2, "0")
const day = `${dates[0].getDate()}`.padStart(2, "0")
newValue = `${year}-${month}-${day}T00:00:00.000`
}
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
// time picked, without timezone
else if (noTimezone) {
const offset = dates[0].getTimezoneOffset() * 60000
newValue = new Date(dates[0].getTime() - offset)
.toISOString()
.slice(0, -1)
}
if (range) {
dispatch("change", event.detail)
} else {
dispatch("change", newValue)
}
}
const clearDateOnBackspace = event => {
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
dispatch("change", "")
flatpickr.close()
}
}
const onOpen = () => {
open = true
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
}
const onClose = () => {
open = false
if (useKeyboardShortcuts) {
document.removeEventListener("keyup", clearDateOnBackspace)
}
// Manually blur all input fields since flatpickr creates a second
// duplicate input field.
// We need to blur both because the focus styling does not get properly
// applied.
const els = document.querySelectorAll(`#${flatpickrId} input`)
els.forEach(el => el.blur())
}
const parseDate = val => {
if (!val) {
return null
}
let date
let time
// it is a string like 00:00:00, just time
let ts = resolveTimeStamp(val)
if (timeOnly && ts) {
date = ts
} else if (val instanceof Date) {
// Use real date obj if already parsed
date = val
} else if (isNaN(val)) {
// Treat as date string of some sort
date = new Date(val)
} else {
// Treat as numerical timestamp
date = new Date(parseInt(val))
}
time = date.getTime()
if (isNaN(time)) {
return null
}
// By rounding to the nearest second we avoid locking up in an endless
// loop in the builder, caused by potentially enriching {{ now }} to every
// millisecond.
return new Date(Math.floor(time / 1000) * 1000)
}
</script>
{#key redrawOptions}
<Flatpickr
bind:flatpickr
value={range ? value : parseDate(value)}
on:open={onOpen}
on:close={onClose}
options={flatpickrOptions}
on:change={handleChange}
element={`#${flatpickrId}`}
>
<div
id={flatpickrId}
class:is-disabled={disabled || readonly}
class="flatpickr spectrum-InputGroup spectrum-Datepicker"
class:is-focused={open}
aria-readonly="false"
aria-required="false"
aria-haspopup="true"
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:click={flatpickr?.open}
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={disabled}
>
<input
{disabled}
{readonly}
data-input
type="text"
class="spectrum-Textfield-input spectrum-InputGroup-input"
class:is-disabled={disabled}
{placeholder}
{id}
{value}
/>
</div>
<button
type="button"
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1"
class:is-disabled={disabled}
on:click={flatpickr?.open}
>
<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="true"
aria-label="Calendar"
>
<use xlink:href="#spectrum-icon-18-Calendar" />
</svg>
</button>
</div>
</Flatpickr>
{/key}
{#if open}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="overlay" on:mousedown|self={flatpickr?.close} />
{/if}
<style>
.spectrum-Textfield-input {
pointer-events: none;
}
.spectrum-Textfield:not(.is-disabled):hover {
cursor: pointer;
}
.flatpickr {
width: 100%;
overflow: hidden;
}
.flatpickr .spectrum-Textfield {
width: 100%;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
max-height: 100%;
}
:global(.flatpickr-calendar) {
font-family: var(--font-sans);
}
.is-disabled {
pointer-events: none !important;
}
</style>

View file

@ -0,0 +1,249 @@
<script>
import { cleanInput } from "./utils"
import Select from "../../Select.svelte"
import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
export let value
const dispatch = createEventDispatcher()
const DaysOfWeek = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
const MonthsOfYear = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
const now = dayjs()
let calendarDate
$: calendarDate = dayjs(value || dayjs()).startOf("month")
$: mondays = getMondays(calendarDate)
const getMondays = monthStart => {
if (!monthStart?.isValid()) {
return []
}
let monthEnd = monthStart.endOf("month")
let calendarStart = monthStart.startOf("week")
const numWeeks = Math.ceil((monthEnd.diff(calendarStart, "day") + 1) / 7)
let mondays = []
for (let i = 0; i < numWeeks; i++) {
mondays.push(calendarStart.add(i, "weeks"))
}
return mondays
}
const handleCalendarYearChange = e => {
calendarDate = calendarDate.year(parseInt(e.target.value))
}
const handleDateChange = date => {
const base = value || now
dispatch(
"change",
base.year(date.year()).month(date.month()).date(date.date())
)
}
export const setDate = date => {
calendarDate = date
}
const cleanYear = cleanInput({ max: 9999, pad: 0, fallback: now.year() })
</script>
<div class="spectrum-Calendar">
<div class="spectrum-Calendar-header">
<div
class="spectrum-Calendar-title"
aria-live="assertive"
aria-atomic="true"
>
<div class="month-selector">
<Select
autoWidth
placeholder={null}
options={MonthsOfYear.map((m, idx) => ({ label: m, value: idx }))}
value={calendarDate.month()}
on:change={e => (calendarDate = calendarDate.month(e.detail))}
/>
</div>
<NumberInput
value={calendarDate.year()}
min={0}
max={9999}
width={64}
on:change={handleCalendarYearChange}
on:input={cleanYear}
/>
</div>
<button
aria-label="Previous"
title="Previous"
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-prevMonth"
on:click={() => (calendarDate = calendarDate.subtract(1, "month"))}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronLeft100"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
<button
aria-label="Next"
title="Next"
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-nextMonth"
on:click={() => (calendarDate = calendarDate.add(1, "month"))}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronRight100"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
</div>
<div
class="spectrum-Calendar-body"
aria-readonly="true"
aria-disabled="false"
>
<table role="presentation" class="spectrum-Calendar-table">
<thead role="presentation">
<tr>
{#each DaysOfWeek as day}
<th scope="col" class="spectrum-Calendar-tableCell">
<abbr class="spectrum-Calendar-dayOfWeek" title={day}>
{day[0]}
</abbr>
</th>
{/each}
</tr>
</thead>
<tbody role="presentation">
{#each mondays as monday}
<tr>
{#each [0, 1, 2, 3, 4, 5, 6] as dayOffset}
{@const date = monday.add(dayOffset, "days")}
{@const outsideMonth = date.month() !== calendarDate.month()}
<td
class="spectrum-Calendar-tableCell"
aria-disabled="true"
aria-selected="false"
aria-invalid="false"
title={date.format("dddd, MMMM D, YYYY")}
on:click={() => handleDateChange(date)}
>
<span
role="presentation"
class="spectrum-Calendar-date"
class:is-outsideMonth={outsideMonth}
class:is-today={!outsideMonth && date.isSame(now, "day")}
class:is-selected={date.isSame(value, "day")}
>
{date.date()}
</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
/* Calendar overrides */
.spectrum-Calendar {
width: auto;
}
.spectrum-Calendar-header {
width: auto;
}
.spectrum-Calendar-title {
display: flex;
justify-content: flex-start;
align-items: stretch;
flex: 1 1 auto;
}
.spectrum-Calendar-header button {
border-radius: 4px;
}
.spectrum-Calendar-date.is-outsideMonth {
visibility: visible;
color: var(--spectrum-global-color-gray-400);
}
.spectrum-Calendar-date.is-today,
.spectrum-Calendar-date.is-today::before {
border-color: var(--spectrum-global-color-gray-400);
}
.spectrum-Calendar-date.is-today.is-selected,
.spectrum-Calendar-date.is-today.is-selected::before {
border-color: var(
--primaryColorHover,
var(--spectrum-global-color-blue-700)
);
}
.spectrum-Calendar-date.is-selected:not(.is-range-selection) {
background: var(--primaryColor, var(--spectrum-global-color-blue-400));
}
.spectrum-Calendar tr {
box-sizing: content-box;
height: 40px;
}
.spectrum-Calendar-tableCell {
box-sizing: content-box;
}
.spectrum-Calendar-nextMonth,
.spectrum-Calendar-prevMonth {
order: 1;
padding: 4px;
}
.spectrum-Calendar-date {
color: var(--spectrum-alias-text-color);
}
.spectrum-Calendar-date.is-selected {
color: white;
}
.spectrum-Calendar-dayOfWeek {
color: var(--spectrum-global-color-gray-600);
}
/* Style select */
.month-selector :global(.spectrum-Picker) {
background: none;
border: none;
padding: 4px 6px;
}
.month-selector :global(.spectrum-Picker:hover),
.month-selector :global(.spectrum-Picker.is-open) {
background: var(--spectrum-global-color-gray-200);
}
.month-selector :global(.spectrum-Picker-label) {
font-size: 18px;
font-weight: bold;
}
</style>

View file

@ -0,0 +1,94 @@
<script>
import Icon from "../../../Icon/Icon.svelte"
import { getDateDisplayValue } from "../../../helpers"
export let anchor
export let disabled
export let readonly
export let error
export let focused
export let placeholder
export let id
export let value
export let icon
export let enableTime
export let timeOnly
$: displayValue = getDateDisplayValue(value, { enableTime, timeOnly })
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
bind:this={anchor}
class:is-disabled={disabled || readonly}
class:is-invalid={!!error}
class:is-focused={focused}
class="spectrum-InputGroup spectrum-Datepicker"
aria-readonly={readonly}
aria-required="false"
aria-haspopup="true"
on:click
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={disabled}
class:is-invalid={!!error}
>
{#if !!error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input
{disabled}
{readonly}
data-input
type="text"
class="spectrum-Textfield-input spectrum-InputGroup-input"
class:is-disabled={disabled}
{placeholder}
{id}
value={displayValue}
/>
</div>
{#if !disabled && !readonly}
<button
type="button"
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1"
class:is-invalid={!!error}
>
<Icon name={icon} />
</button>
{/if}
</div>
<style>
/* Date label overrides */
.spectrum-Textfield-input {
pointer-events: none;
}
.spectrum-Textfield:not(.is-disabled):hover {
cursor: pointer;
}
.spectrum-Datepicker {
width: 100%;
overflow: hidden;
}
.spectrum-Datepicker .spectrum-Textfield {
width: 100%;
}
.is-disabled {
pointer-events: none !important;
}
input:read-only {
border-right-width: 1px;
border-top-right-radius: var(--spectrum-textfield-border-radius);
border-bottom-right-radius: var(--spectrum-textfield-border-radius);
}
</style>

View file

@ -0,0 +1,83 @@
<script>
import "@spectrum-css/calendar/dist/index-vars.css"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/textfield/dist/index-vars.css"
import Popover from "../../../Popover/Popover.svelte"
import { onMount } from "svelte"
import DateInput from "./DateInput.svelte"
import { parseDate } from "../../../helpers"
import DatePickerPopoverContents from "./DatePickerPopoverContents.svelte"
export let id = null
export let disabled = false
export let readonly = false
export let error = null
export let enableTime = true
export let value = null
export let placeholder = null
export let timeOnly = false
export let ignoreTimezones = false
export let useKeyboardShortcuts = true
export let appendTo = null
export let api = null
export let align = "left"
let isOpen = false
let anchor
let popover
$: parsedValue = parseDate(value, { timeOnly, enableTime })
const onOpen = () => {
isOpen = true
}
const onClose = () => {
isOpen = false
}
onMount(() => {
api = {
open: () => popover?.show(),
close: () => popover?.hide(),
}
})
</script>
<DateInput
bind:anchor
{disabled}
{readonly}
{error}
{placeholder}
{id}
{enableTime}
{timeOnly}
focused={isOpen}
value={parsedValue}
on:click={popover?.show}
icon={timeOnly ? "Clock" : "Calendar"}
/>
<Popover
bind:this={popover}
on:open
on:close
on:open={onOpen}
on:close={onClose}
portalTarget={appendTo}
{anchor}
{align}
resizable={false}
>
{#if isOpen}
<DatePickerPopoverContents
{useKeyboardShortcuts}
{ignoreTimezones}
{enableTime}
{timeOnly}
value={parsedValue}
on:change
/>
{/if}
</Popover>

View file

@ -0,0 +1,102 @@
<script>
import dayjs from "dayjs"
import TimePicker from "./TimePicker.svelte"
import Calendar from "./Calendar.svelte"
import ActionButton from "../../../ActionButton/ActionButton.svelte"
import { createEventDispatcher, onMount } from "svelte"
import { stringifyDate } from "../../../helpers"
export let useKeyboardShortcuts = true
export let ignoreTimezones
export let enableTime
export let timeOnly
export let value
const dispatch = createEventDispatcher()
let calendar
$: showCalendar = !timeOnly
$: showTime = enableTime || timeOnly
const setToNow = () => {
const now = dayjs()
calendar?.setDate(now)
handleChange(now)
}
const handleChange = date => {
dispatch(
"change",
stringifyDate(date, { enableTime, timeOnly, ignoreTimezones })
)
}
const clearDateOnBackspace = event => {
// Ignore if we're typing a value
if (document.activeElement?.tagName.toLowerCase() === "input") {
return
}
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
dispatch("change", null)
}
}
onMount(() => {
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
return () => {
document.removeEventListener("keyup", clearDateOnBackspace)
}
})
</script>
<div class="date-time-popover">
{#if showCalendar}
<Calendar
{value}
on:change={e => handleChange(e.detail)}
bind:this={calendar}
/>
{/if}
<div class="footer" class:spaced={showCalendar}>
{#if showTime}
<TimePicker {value} on:change={e => handleChange(e.detail)} />
{/if}
<div class="actions">
<ActionButton
disabled={!value}
size="S"
on:click={() => dispatch("change", null)}
>
Clear
</ActionButton>
<ActionButton size="S" on:click={setToNow}>
{showTime ? "Now" : "Today"}
</ActionButton>
</div>
</div>
</div>
<style>
.date-time-popover {
padding: 8px;
overflow: hidden;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 60px;
}
.footer.spaced {
padding-top: 14px;
}
.actions {
padding: 4px 0;
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
gap: 6px;
}
</style>

View file

@ -0,0 +1,54 @@
<script>
export let value
export let min
export let max
export let hideArrows = false
export let width
$: style = width ? `width:${width}px;` : ""
</script>
<input
class:hide-arrows={hideArrows}
type="number"
{style}
{value}
{min}
{max}
onclick="this.select()"
on:change
on:input
/>
<style>
input {
background: none;
border: none;
outline: none;
color: var(--spectrum-alias-text-color);
padding: 4px 6px 5px 6px;
border-radius: 4px;
transition: background 130ms ease-out;
font-size: 18px;
font-weight: bold;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
box-sizing: content-box !important;
}
input:focus,
input:hover {
--space: 30px;
background: var(--spectrum-global-color-gray-200);
z-index: 1;
}
/* Hide built-in arrows */
input.hide-arrows::-webkit-outer-spin-button,
input.hide-arrows::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.hide-arrows {
-moz-appearance: textfield;
}
</style>

View file

@ -0,0 +1,59 @@
<script>
import { cleanInput } from "./utils"
import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
export let value
const dispatch = createEventDispatcher()
$: displayValue = value || dayjs()
const handleHourChange = e => {
dispatch("change", displayValue.hour(parseInt(e.target.value)))
}
const handleMinuteChange = e => {
dispatch("change", displayValue.minute(parseInt(e.target.value)))
}
const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" })
const cleanMinute = cleanInput({ max: 59, pad: 2, fallback: "00" })
</script>
<div class="time-picker">
<NumberInput
hideArrows
value={displayValue.hour().toString().padStart(2, "0")}
min={0}
max={23}
width={20}
on:input={cleanHour}
on:change={handleHourChange}
/>
<span>:</span>
<NumberInput
hideArrows
value={displayValue.minute().toString().padStart(2, "0")}
min={0}
max={59}
width={20}
on:input={cleanMinute}
on:change={handleMinuteChange}
/>
</div>
<style>
.time-picker {
display: flex;
flex-direction: row;
align-items: center;
}
.time-picker span {
font-weight: bold;
font-size: 18px;
z-index: 0;
margin-bottom: 1px;
}
</style>

View file

@ -0,0 +1,14 @@
export const cleanInput = ({ max, pad, fallback }) => {
return e => {
if (e.target.value) {
const value = parseInt(e.target.value)
if (isNaN(value)) {
e.target.value = fallback
} else {
e.target.value = Math.min(max, value).toString().padStart(pad, "0")
}
} else {
e.target.value = fallback
}
}
}

View file

@ -0,0 +1,69 @@
<script>
import CoreDatePicker from "./DatePicker/DatePicker.svelte"
import Icon from "../../Icon/Icon.svelte"
export let value = null
export let disabled = false
export let readonly = false
export let error = null
export let appendTo = undefined
export let ignoreTimezones = false
let fromDate
let toDate
</script>
<div class="date-range">
<CoreDatePicker
value={fromDate}
on:change={e => (fromDate = e.detail)}
enableTime={false}
/>
<div class="arrow">
<Icon name="ChevronRight" />
</div>
<CoreDatePicker
value={toDate}
on:change={e => (toDate = e.detail)}
enableTime={false}
/>
</div>
<style>
.date-range {
display: flex;
flex-direction: row;
border: 1px solid var(--spectrum-alias-border-color);
border-radius: 4px;
}
.date-range :global(.spectrum-InputGroup),
.date-range :global(.spectrum-Textfield),
.date-range :global(input) {
min-width: 0 !important;
width: 150px !important;
}
.date-range :global(input) {
border: none;
text-align: center;
}
.date-range :global(button) {
display: none;
}
.date-range :global(> :first-child input),
.date-range :global(> :first-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-range :global(> :last-child input),
.date-range :global(> :last-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.arrow {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
z-index: 1;
}
</style>

View file

@ -155,6 +155,7 @@
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
maxHeight={240}
> >
<div <div
class="popover-content" class="popover-content"

View file

@ -8,7 +8,9 @@ export { default as CoreTextArea } from "./TextArea.svelte"
export { default as CoreCombobox } from "./Combobox.svelte" export { default as CoreCombobox } from "./Combobox.svelte"
export { default as CoreSwitch } from "./Switch.svelte" export { default as CoreSwitch } from "./Switch.svelte"
export { default as CoreSearch } from "./Search.svelte" export { default as CoreSearch } from "./Search.svelte"
export { default as CoreDatePicker } from "./DatePicker.svelte" export { default as CoreDatePicker } from "./DatePicker/DatePicker.svelte"
export { default as CoreDatePickerPopoverContents } from "./DatePicker/DatePickerPopoverContents.svelte"
export { default as CoreDateRangePicker } from "./DateRangePicker.svelte"
export { default as CoreDropzone } from "./Dropzone.svelte" export { default as CoreDropzone } from "./Dropzone.svelte"
export { default as CoreStepper } from "./Stepper.svelte" export { default as CoreStepper } from "./Stepper.svelte"
export { default as CoreRichTextField } from "./RichTextField.svelte" export { default as CoreRichTextField } from "./RichTextField.svelte"

View file

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

View file

@ -0,0 +1,34 @@
<script>
import Field from "./Field.svelte"
import DateRangePicker from "./Core/DateRangePicker.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let label = null
export let labelPosition = "above"
export let disabled = false
export let readonly = false
export let error = null
export let helpText = null
export let appendTo = undefined
export let ignoreTimezones = false
const dispatch = createEventDispatcher()
const onChange = e => {
value = e.detail
dispatch("change", e.detail)
}
</script>
<Field {helpText} {label} {labelPosition} {error}>
<DateRangePicker
{error}
{disabled}
{readonly}
{value}
{appendTo}
{ignoreTimezones}
on:change={onChange}
/>
</Field>

View file

@ -18,13 +18,15 @@
export let open = false export let open = false
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 5 export let offset = 4
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex export let customZindex
export let handlePostionUpdate export let handlePostionUpdate
export let showPopover = true export let showPopover = true
export let clickOutsideOverride = false export let clickOutsideOverride = false
export let resizable = true
export let wrap = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -91,6 +93,8 @@
useAnchorWidth, useAnchorWidth,
offset, offset,
customUpdate: handlePostionUpdate, customUpdate: handlePostionUpdate,
resizable,
wrap,
}} }}
use:clickOutside={{ use:clickOutside={{
callback: dismissible ? handleOutsideClick : () => {}, callback: dismissible ? handleOutsideClick : () => {},
@ -116,12 +120,11 @@
min-width: var(--spectrum-global-dimension-size-2000); min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
overflow: auto; overflow: auto;
transition: opacity 260ms ease-out, transform 260ms ease-out; transition: opacity 260ms ease-out;
} }
.hidden { .hidden {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transform: translateY(-20px);
} }
.customZindex { .customZindex {
z-index: var(--customZindex) !important; z-index: var(--customZindex) !important;

View file

@ -1,4 +1,5 @@
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import dayjs from "dayjs"
export const deepGet = helpers.deepGet export const deepGet = helpers.deepGet
@ -115,3 +116,110 @@ export const copyToClipboard = value => {
} }
}) })
} }
// Parsed a date value. This is usually an ISO string, but can be a
// bunch of different formats and shapes depending on schema flags.
export const parseDate = (value, { enableTime = true }) => {
// If empty then invalid
if (!value) {
return null
}
// Certain string values need transformed
if (typeof value === "string") {
// Check for time only values
if (!isNaN(new Date(`0-${value}`))) {
value = `0-${value}`
}
// If date only, check for cases where we received a UTC string
else if (!enableTime && value.endsWith("Z")) {
value = value.split("Z")[0]
}
}
// Parse value and check for validity
const parsedDate = dayjs(value)
if (!parsedDate.isValid()) {
return null
}
// By rounding to the nearest second we avoid locking up in an endless
// loop in the builder, caused by potentially enriching {{ now }} to every
// millisecond.
return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000)
}
// Stringifies a dayjs object to create an ISO string that respects the various
// schema flags
export const stringifyDate = (
value,
{ enableTime = true, timeOnly = false, ignoreTimezones = false }
) => {
if (!value) {
return null
}
// Time only fields always ignore timezones, otherwise they make no sense.
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
// time picked, without timezone
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
if (offsetForTimezone) {
// Ensure we use the correct offset for the date
const referenceDate = timeOnly ? new Date() : value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000
return new Date(value.valueOf() - offset).toISOString().slice(0, -1)
}
// For date-only fields, construct a manual timestamp string without a time
// or time zone
else if (!enableTime) {
const year = value.year()
const month = `${value.month() + 1}`.padStart(2, "0")
const day = `${value.date()}`.padStart(2, "0")
return `${year}-${month}-${day}T00:00:00.000`
}
// Otherwise use a normal ISO string with time and timezone
else {
return value.toISOString()
}
}
// Determine the dayjs-compatible format of the browser's default locale
const getPatternForPart = part => {
switch (part.type) {
case "day":
return "D".repeat(part.value.length)
case "month":
return "M".repeat(part.value.length)
case "year":
return "Y".repeat(part.value.length)
case "literal":
return part.value
default:
console.log("Unsupported date part", part)
return ""
}
}
const localeDateFormat = new Intl.DateTimeFormat()
.formatToParts(new Date("2021-01-01"))
.map(getPatternForPart)
.join("")
// Formats a dayjs date according to schema flags
export const getDateDisplayValue = (
value,
{ enableTime = true, timeOnly = false }
) => {
if (!value?.isValid()) {
return ""
}
if (timeOnly) {
return value.format("HH:mm")
} else if (!enableTime) {
return value.format(localeDateFormat)
} else {
return value.format(`${localeDateFormat} HH:mm`)
}
}

View file

@ -3,13 +3,34 @@ import "./bbui.css"
// Spectrum icons // Spectrum icons
import "@spectrum-css/icon/dist/index-vars.css" import "@spectrum-css/icon/dist/index-vars.css"
// Components // Form components
export { default as Input } from "./Form/Input.svelte" export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte" export { default as Stepper } from "./Form/Stepper.svelte"
export { default as TextArea } from "./Form/TextArea.svelte" export { default as TextArea } from "./Form/TextArea.svelte"
export { default as Select } from "./Form/Select.svelte" export { default as Select } from "./Form/Select.svelte"
export { default as Combobox } from "./Form/Combobox.svelte" export { default as Combobox } from "./Form/Combobox.svelte"
export { default as Dropzone } from "./Form/Dropzone.svelte" export { default as Dropzone } from "./Form/Dropzone.svelte"
export { default as DatePicker } from "./Form/DatePicker.svelte"
export { default as DateRangePicker } from "./Form/DateRangePicker.svelte"
export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
export { default as Multiselect } from "./Form/Multiselect.svelte"
export { default as Search } from "./Form/Search.svelte"
export { default as RichTextField } from "./Form/RichTextField.svelte"
export { default as Slider } from "./Form/Slider.svelte"
export { default as File } from "./Form/File.svelte"
// Core form components to be used elsewhere (standard components)
export * from "./Form/Core"
// Fancy form components
export * from "./FancyForm"
// Components
export { default as Drawer } from "./Drawer/Drawer.svelte" export { default as Drawer } from "./Drawer/Drawer.svelte"
export { default as DrawerContent } from "./Drawer/DrawerContent.svelte" export { default as DrawerContent } from "./Drawer/DrawerContent.svelte"
export { default as Avatar } from "./Avatar/Avatar.svelte" export { default as Avatar } from "./Avatar/Avatar.svelte"
@ -21,12 +42,6 @@ export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon } from "./Icon/Icon.svelte" export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
@ -37,11 +52,6 @@ export { default as Page } from "./Layout/Page.svelte"
export { default as Link } from "./Link/Link.svelte" 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 AbsTooltip,
TooltipPosition,
TooltipType,
} from "./Tooltip/AbsTooltip.svelte"
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte" export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.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"
@ -53,8 +63,6 @@ export { default as NotificationDisplay } from "./Notification/NotificationDispl
export { default as Notification } from "./Notification/Notification.svelte" export { default as Notification } from "./Notification/Notification.svelte"
export { default as SideNavigation } from "./SideNavigation/Navigation.svelte" export { default as SideNavigation } from "./SideNavigation/Navigation.svelte"
export { default as SideNavigationItem } from "./SideNavigation/Item.svelte" export { default as SideNavigationItem } from "./SideNavigation/Item.svelte"
export { default as DatePicker } from "./Form/DatePicker.svelte"
export { default as Multiselect } from "./Form/Multiselect.svelte"
export { default as Context } from "./context" export { default as Context } from "./context"
export { default as Table } from "./Table/Table.svelte" export { default as Table } from "./Table/Table.svelte"
export { default as Tabs } from "./Tabs/Tabs.svelte" export { default as Tabs } from "./Tabs/Tabs.svelte"
@ -64,7 +72,6 @@ export { default as Tag } from "./Tags/Tag.svelte"
export { default as TreeView } from "./TreeView/Tree.svelte" export { default as TreeView } from "./TreeView/Tree.svelte"
export { default as TreeItem } from "./TreeView/Item.svelte" export { default as TreeItem } from "./TreeView/Item.svelte"
export { default as Divider } from "./Divider/Divider.svelte" export { default as Divider } from "./Divider/Divider.svelte"
export { default as Search } from "./Form/Search.svelte"
export { default as Pagination } from "./Pagination/Pagination.svelte" export { default as Pagination } from "./Pagination/Pagination.svelte"
export { default as Badge } from "./Badge/Badge.svelte" export { default as Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte" export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
@ -76,15 +83,15 @@ export { default as CopyInput } from "./Input/CopyInput.svelte"
export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte" export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte"
export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte" export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte" export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
export { default as RichTextField } from "./Form/RichTextField.svelte"
export { default as List } from "./List/List.svelte" export { default as List } from "./List/List.svelte"
export { default as ListItem } from "./List/ListItem.svelte" 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 Slider } from "./Form/Slider.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte" export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as File } from "./Form/File.svelte"
export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte" export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte"
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
// Renderers // Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte" export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
@ -96,9 +103,6 @@ export { default as Heading } from "./Typography/Heading.svelte"
export { default as Detail } from "./Typography/Detail.svelte" export { default as Detail } from "./Typography/Detail.svelte"
export { default as Code } from "./Typography/Code.svelte" export { default as Code } from "./Typography/Code.svelte"
// Core form components to be used elsewhere (standard components)
export * from "./Form/Core"
// Actions // Actions
export { default as autoResizeTextArea } from "./Actions/autoresize_textarea" export { default as autoResizeTextArea } from "./Actions/autoresize_textarea"
export { default as positionDropdown } from "./Actions/position_dropdown" export { default as positionDropdown } from "./Actions/position_dropdown"
@ -110,6 +114,3 @@ export { banner, BANNER_TYPES } from "./Stores/banner"
// Helpers // Helpers
export * as Helpers from "./helpers" export * as Helpers from "./helpers"
// Fancy form components
export * from "./FancyForm"

View file

@ -32,6 +32,7 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte" import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { BindingHelpers, BindingType } from "components/common/bindings/utils" import { BindingHelpers, BindingType } from "components/common/bindings/utils"
import { import {
bindingsToCompletions, bindingsToCompletions,
@ -356,7 +357,8 @@
value.customType !== "queryParams" && value.customType !== "queryParams" &&
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" value.customType !== "automationFields" &&
value.type !== "attachment"
) )
} }
@ -372,6 +374,15 @@
console.error(error) console.error(error)
} }
}) })
const handleAttachmentParams = keyValuObj => {
let params = {}
if (keyValuObj?.length) {
for (let param of keyValuObj) {
params[param.url] = param.filename
}
}
return params
}
</script> </script>
<div class="fields"> <div class="fields">
@ -437,6 +448,33 @@
value={inputData[key]} value={inputData[key]}
options={Object.keys(table?.schema || {})} options={Object.keys(table?.schema || {})}
/> />
{:else if value.type === "attachment"}
<div class="attachment-field-wrapper">
<div class="label-wrapper">
<Label>{label}</Label>
</div>
<div class="attachment-field-width">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
key
)}
object={handleAttachmentParams(inputData[key])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
/>
</div>
</div>
{:else if value.customType === "filters"} {:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton> <ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} title="Filtering"> <Drawer bind:this={drawer} title="Filtering">
@ -651,14 +689,22 @@
} }
.block-field { .block-field {
display: flex; /* Use Flexbox */ display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: row; /* Arrange label and field side by side */ flex-direction: row;
align-items: center; /* Align vertically in the center */ align-items: center;
gap: 10px; /* Add some space between label and field */ gap: 10px;
flex: 1; flex: 1;
} }
.attachment-field-width {
margin-top: var(--spacing-xs);
}
.label-wrapper {
margin-top: var(--spacing-s);
}
.test :global(.drawer) { .test :global(.drawer) {
width: 10000px !important; width: 10000px !important;
} }

View file

@ -106,6 +106,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--background); background: var(--background);
overflow: hidden;
} }
</style> </style>

View file

@ -29,7 +29,11 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula" import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte" import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { FieldType, FieldSubtype, SourceName } from "@budibase/types" import {
FieldType,
BBReferenceFieldSubType,
SourceName,
} from "@budibase/types"
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"
@ -41,8 +45,6 @@
const NUMBER_TYPE = FieldType.NUMBER const NUMBER_TYPE = FieldType.NUMBER
const JSON_TYPE = FieldType.JSON const JSON_TYPE = FieldType.JSON
const DATE_TYPE = FieldType.DATETIME const DATE_TYPE = FieldType.DATETIME
const USER_TYPE = FieldSubtype.USER
const USERS_TYPE = FieldSubtype.USERS
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
@ -263,9 +265,9 @@
delete saveColumn.fieldName delete saveColumn.fieldName
} }
if (isUsersColumn(saveColumn)) { if (isUsersColumn(saveColumn)) {
if (saveColumn.subtype === USER_TYPE) { if (saveColumn.subtype === BBReferenceFieldSubType.USER) {
saveColumn.relationshipType = RelationshipType.ONE_TO_MANY saveColumn.relationshipType = RelationshipType.ONE_TO_MANY
} else if (saveColumn.subtype === USERS_TYPE) { } else if (saveColumn.subtype === BBReferenceFieldSubType.USERS) {
saveColumn.relationshipType = RelationshipType.MANY_TO_MANY saveColumn.relationshipType = RelationshipType.MANY_TO_MANY
} }
} }
@ -363,19 +365,17 @@
function getAllowedTypes() { function getAllowedTypes() {
if (originalName) { if (originalName) {
const possibleTypes = ( const possibleTypes = SWITCHABLE_TYPES[field.type] || [
SWITCHABLE_TYPES[field.type] || [editableColumn.type] editableColumn.type,
).map(t => t.toLowerCase()) ]
return Object.entries(FIELDS) return Object.entries(FIELDS)
.filter(([fieldType]) => .filter(([_, field]) => possibleTypes.includes(field.type))
possibleTypes.includes(fieldType.toLowerCase())
)
.map(([_, fieldDefinition]) => fieldDefinition) .map(([_, fieldDefinition]) => fieldDefinition)
} }
const isUsers = const isUsers =
editableColumn.type === FieldType.BB_REFERENCE && editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === FieldSubtype.USERS editableColumn.subtype === BBReferenceFieldSubType.USERS
if (!externalTable) { if (!externalTable) {
return [ return [
@ -485,7 +485,9 @@
function isUsersColumn(column) { function isUsersColumn(column) {
return ( return (
column.type === FieldType.BB_REFERENCE && column.type === FieldType.BB_REFERENCE &&
[FieldSubtype.USER, FieldSubtype.USERS].includes(column.subtype) [BBReferenceFieldSubType.USER, BBReferenceFieldSubType.USERS].includes(
column.subtype
)
) )
} }
@ -513,6 +515,7 @@
/> />
{/if} {/if}
<Select <Select
placeholder={null}
disabled={!typeEnabled} disabled={!typeEnabled}
bind:value={editableColumn.fieldId} bind:value={editableColumn.fieldId}
on:change={onHandleTypeChange} on:change={onHandleTypeChange}
@ -688,12 +691,14 @@
> >
{:else if isUsersColumn(editableColumn) && datasource?.source !== SourceName.GOOGLE_SHEETS} {:else if isUsersColumn(editableColumn) && datasource?.source !== SourceName.GOOGLE_SHEETS}
<Toggle <Toggle
value={editableColumn.subtype === FieldSubtype.USERS} value={editableColumn.subtype === BBReferenceFieldSubType.USERS}
on:change={e => on:change={e =>
handleTypeChange( handleTypeChange(
makeFieldId( makeFieldId(
FieldType.BB_REFERENCE, FieldType.BB_REFERENCE,
e.detail ? FieldSubtype.USERS : FieldSubtype.USER e.detail
? BBReferenceFieldSubType.USERS
: BBReferenceFieldSubType.USER
) )
)} )}
disabled={!isCreating} disabled={!isCreating}

View file

@ -1,5 +1,5 @@
<script> <script>
import { FieldType, FieldSubtype } from "@budibase/types" import { FieldType, BBReferenceFieldSubType } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui" import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend" import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api" import { API } from "api"
@ -60,11 +60,11 @@
}, },
{ {
label: "User", label: "User",
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USER}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USER}`,
}, },
{ {
label: "Users", label: "Users",
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USERS}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USERS}`,
}, },
] ]

View file

@ -0,0 +1,89 @@
<script>
import { DatePicker } from "@budibase/bbui"
import dayjs from "dayjs"
import { createEventDispatcher } from "svelte"
import { memo } from "@budibase/frontend-core"
export let value
const dispatch = createEventDispatcher()
const valueStore = memo(value)
let date1
let date2
$: valueStore.set(value)
$: parseValue($valueStore)
const parseValue = value => {
if (!Array.isArray(value) || !value[0] || !value[1]) {
date1 = null
date2 = null
} else {
date1 = value[0]
date2 = value[1]
}
}
const onChangeDate1 = e => {
date1 = e.detail ? dayjs(e.detail).startOf("day") : null
if (date1 && (!date2 || date1.isAfter(date2))) {
date2 = date1.endOf("day")
} else if (!date1) {
date2 = null
}
broadcastChange()
}
const onChangeDate2 = e => {
date2 = e.detail ? dayjs(e.detail).endOf("day") : null
if (date2 && (!date1 || date2.isBefore(date1))) {
date1 = date2.startOf("day")
} else if (!date2) {
date1 = null
}
broadcastChange()
}
const broadcastChange = () => {
dispatch("change", [date1, date2])
}
</script>
<div class="date-range-picker">
<DatePicker
value={date1}
label="Date range"
enableTime={false}
on:change={onChangeDate1}
/>
<DatePicker value={date2} enableTime={false} on:change={onChangeDate2} />
</div>
<style>
.date-range-picker {
display: flex;
flex-direction: row;
align-items: flex-end;
}
/* Overlap date pickers to remove double border, but put the focused one on top */
.date-range-picker :global(.spectrum-InputGroup.is-focused) {
z-index: 1;
}
.date-range-picker :global(> :last-child) {
margin-left: -1px;
}
/* Remove border radius at the join */
.date-range-picker :global(> :first-child .spectrum-InputGroup),
.date-range-picker :global(> :first-child .spectrum-Picker) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-range-picker :global(> :last-child .spectrum-InputGroup),
.date-range-picker :global(> :last-child .spectrum-Textfield-input) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View file

@ -75,14 +75,12 @@
.relationship-container { .relationship-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: var(--spacing-m);
} }
.relationship-part { .relationship-part {
flex-basis: 70%; flex: 1 1 auto;
} }
.relationship-type { .relationship-type {
flex-basis: 30%; flex: 0 0 128px;
} }
</style> </style>

View file

@ -35,6 +35,8 @@
export let bindingDrawerLeft export let bindingDrawerLeft
export let allowHelpers = true export let allowHelpers = true
export let customButtonText = null export let customButtonText = null
export let keyBindings = false
export let allowJS = false
export let compare = (option, value) => option === value export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
@ -116,12 +118,23 @@
class:readOnly-menu={readOnly && showMenu} class:readOnly-menu={readOnly && showMenu}
> >
{#each fields as field, idx} {#each fields as field, idx}
<Input {#if keyBindings}
placeholder={keyPlaceholder} <DrawerBindableInput
readonly={readOnly} {bindings}
bind:value={field.name} placeholder={keyPlaceholder}
on:blur={changed} on:blur={e => {
/> field.name = e.detail
changed()
}}
disabled={readOnly}
value={field.name}
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
/>
{:else}
<Input readonly={readOnly} bind:value={field.name} on:blur={changed} />
{/if}
{#if isJsonArray(field.value)} {#if isJsonArray(field.value)}
<Select readonly={true} value="Array" options={["Array"]} /> <Select readonly={true} value="Array" options={["Array"]} />
{:else if options} {:else if options}
@ -134,14 +147,14 @@
{:else if bindings && bindings.length} {:else if bindings && bindings.length}
<DrawerBindableInput <DrawerBindableInput
{bindings} {bindings}
placeholder="Value" placeholder={valuePlaceholder}
on:blur={e => { on:blur={e => {
field.value = e.detail field.value = e.detail
changed() changed()
}} }}
disabled={readOnly} disabled={readOnly}
value={field.value} value={field.value}
allowJS={false} {allowJS}
{allowHelpers} {allowHelpers}
drawerLeft={bindingDrawerLeft} drawerLeft={bindingDrawerLeft}
/> />

View file

@ -1,6 +1,6 @@
import { import {
FieldType, FieldType,
FieldSubtype, BBReferenceFieldSubType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
AutoFieldSubType, AutoFieldSubType,
Hosting, Hosting,
@ -160,13 +160,13 @@ export const FIELDS = {
USER: { USER: {
name: "User", name: "User",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USER, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USER], icon: TypeIconMap[FieldType.USER],
}, },
USERS: { USERS: {
name: "Users", name: "Users",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USERS, subtype: BBReferenceFieldSubType.USERS,
icon: TypeIconMap[FieldType.USERS], icon: TypeIconMap[FieldType.USERS],
constraints: { constraints: {
type: "array", type: "array",

View file

@ -22,6 +22,7 @@ import {
isJSBinding, isJSBinding,
decodeJSBinding, decodeJSBinding,
encodeJSBinding, encodeJSBinding,
getJsHelperList,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { TableNames } from "./constants" import { TableNames } from "./constants"
import { JSONUtils, Constants } from "@budibase/frontend-core" import { JSONUtils, Constants } from "@budibase/frontend-core"
@ -1210,9 +1211,32 @@ const shouldReplaceBinding = (currentValue, from, convertTo, binding) => {
if (!currentValue?.includes(from)) { if (!currentValue?.includes(from)) {
return false return false
} }
if (convertTo === "readableBinding") { // some cases we have the same binding for readable/runtime, specific logic for this
// Dont replace if the value already matches the readable binding const sameBindings = binding.runtimeBinding.includes(binding.readableBinding)
const convertingToReadable = convertTo === "readableBinding"
const helperNames = Object.keys(getJsHelperList())
const matchedHelperNames = helperNames.filter(
name => name.includes(from) && currentValue.includes(name)
)
// edge case - if the binding is part of a helper it may accidentally replace it
if (matchedHelperNames.length > 0) {
const indexStart = currentValue.indexOf(from),
indexEnd = indexStart + from.length
for (let helperName of matchedHelperNames) {
const helperIndexStart = currentValue.indexOf(helperName),
helperIndexEnd = helperIndexStart + helperName.length
if (indexStart >= helperIndexStart && indexEnd <= helperIndexEnd) {
return false
}
}
}
if (convertingToReadable && !sameBindings) {
// Don't replace if the value already matches the readable binding
return currentValue.indexOf(binding.readableBinding) === -1 return currentValue.indexOf(binding.readableBinding) === -1
} else if (convertingToReadable) {
// if the runtime and readable bindings are very similar we have to assume it should be replaced
return true
} }
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then // remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected // this makes sure it is detected

View file

@ -189,6 +189,7 @@
<Select <Select
options={settingOptions} options={settingOptions}
bind:value={condition.setting} bind:value={condition.setting}
on:change={() => delete condition.settingValue}
/> />
<div>TO</div> <div>TO</div>
{#if definition} {#if definition}

View file

@ -1,7 +1,6 @@
<script> <script>
import { import {
Button, Button,
DatePicker,
Divider, Divider,
Layout, Layout,
notifications, notifications,
@ -25,13 +24,13 @@
import BackupsDefault from "assets/backups-default.png" import BackupsDefault from "assets/backups-default.png"
import { BackupTrigger, BackupType } from "constants/backend/backups" import { BackupTrigger, BackupType } from "constants/backend/backups"
import { onMount } from "svelte" import { onMount } from "svelte"
import DateRangePicker from "components/common/DateRangePicker.svelte"
let loading = true let loading = true
let backupData = null let backupData = null
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
let filterOpt = null let filterOpt = null
let startDate = null let dateRange = []
let endDate = null
let filters = [ let filters = [
{ {
label: "Manual backup", label: "Manual backup",
@ -52,7 +51,7 @@
] ]
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchBackups(filterOpt, page, startDate, endDate) $: fetchBackups(filterOpt, page, dateRange)
let schema = { let schema = {
type: { type: {
@ -99,13 +98,13 @@
}) })
} }
async function fetchBackups(filters, page, startDate, endDate) { async function fetchBackups(filters, page, dateRange) {
const response = await backups.searchBackups({ const response = await backups.searchBackups({
appId: $appStore.appId, appId: $appStore.appId,
...filters, ...filters,
page, page,
startDate, startDate: dateRange[0],
endDate, endDate: dateRange[1],
}) })
pageInfo.fetched(response.hasNextPage, response.nextPage) pageInfo.fetched(response.hasNextPage, response.nextPage)
@ -165,7 +164,7 @@
} }
onMount(async () => { onMount(async () => {
await fetchBackups(filterOpt, page, startDate, endDate) await fetchBackups(filterOpt, page, dateRange)
loading = false loading = false
}) })
</script> </script>
@ -207,7 +206,7 @@
View plans View plans
</Button> </Button>
</div> </div>
{:else if !backupData?.length && !loading && !filterOpt && !startDate} {:else if !backupData?.length && !loading && !filterOpt && !dateRange?.length}
<div class="center"> <div class="center">
<Layout noPadding gap="S" justifyItems="center"> <Layout noPadding gap="S" justifyItems="center">
<img height="130px" src={BackupsDefault} alt="BackupsDefault" /> <img height="130px" src={BackupsDefault} alt="BackupsDefault" />
@ -236,21 +235,15 @@
bind:value={filterOpt} bind:value={filterOpt}
/> />
</div> </div>
<DatePicker <DateRangePicker
range={true} value={dateRange}
label="Date Range" on:change={e => (dateRange = e.detail)}
on:change={e => {
if (e.detail[0].length > 1) {
startDate = e.detail[0][0].toISOString()
endDate = e.detail[0][1].toISOString()
}
}}
/> />
</div> </div>
<div> <div>
<Button cta disabled={loading} on:click={createManualBackup} <Button cta disabled={loading} on:click={createManualBackup}>
>Create new backup</Button Create new backup
> </Button>
</div> </div>
</div> </div>
<div class="table"> <div class="table">

View file

@ -12,7 +12,6 @@
Icon, Icon,
clickOutside, clickOutside,
CoreTextArea, CoreTextArea,
DatePicker,
Pagination, Pagination,
Helpers, Helpers,
Divider, Divider,
@ -27,6 +26,8 @@
import TimeRenderer from "./_components/TimeRenderer.svelte" import TimeRenderer from "./_components/TimeRenderer.svelte"
import AppColumnRenderer from "./_components/AppColumnRenderer.svelte" import AppColumnRenderer from "./_components/AppColumnRenderer.svelte"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import DateRangePicker from "components/common/DateRangePicker.svelte"
import dayjs from "dayjs"
const schema = { const schema = {
date: { width: "0.8fr" }, date: { width: "0.8fr" },
@ -69,16 +70,13 @@
let sidePanelVisible = false let sidePanelVisible = false
let wideSidePanel = false let wideSidePanel = false
let timer let timer
let startDate = new Date() let dateRange = [dayjs().subtract(30, "days"), dayjs()]
startDate.setDate(startDate.getDate() - 30)
let endDate = new Date()
$: fetchUsers(userPage, userSearchTerm) $: fetchUsers(userPage, userSearchTerm)
$: fetchLogs({ $: fetchLogs({
logsPage, logsPage,
logSearchTerm, logSearchTerm,
startDate, dateRange,
endDate,
selectedUsers, selectedUsers,
selectedApps, selectedApps,
selectedEvents, selectedEvents,
@ -136,8 +134,7 @@
const fetchLogs = async ({ const fetchLogs = async ({
logsPage, logsPage,
logSearchTerm, logSearchTerm,
startDate, dateRange,
endDate,
selectedUsers, selectedUsers,
selectedApps, selectedApps,
selectedEvents, selectedEvents,
@ -155,8 +152,8 @@
logsPageInfo.loading() logsPageInfo.loading()
await auditLogs.search({ await auditLogs.search({
bookmark: logsPage, bookmark: logsPage,
startDate, startDate: dateRange[0],
endDate, endDate: dateRange[1],
fullSearch: logSearchTerm, fullSearch: logSearchTerm,
userIds: selectedUsers, userIds: selectedUsers,
appIds: selectedApps, appIds: selectedApps,
@ -214,8 +211,8 @@
const downloadLogs = async () => { const downloadLogs = async () => {
try { try {
window.location = auditLogs.getDownloadUrl({ window.location = auditLogs.getDownloadUrl({
startDate, startDate: dateRange[0],
endDate, endDate: dateRange[1],
fullSearch: logSearchTerm, fullSearch: logSearchTerm,
userIds: selectedUsers, userIds: selectedUsers,
appIds: selectedApps, appIds: selectedApps,
@ -302,22 +299,9 @@
</div> </div>
<div class="date-picker"> <div class="date-picker">
<DatePicker <DateRangePicker
value={[startDate, endDate]} value={dateRange}
placeholder="Choose date range" on:change={e => (dateRange = e.detail)}
range={true}
on:change={e => {
if (e.detail[0]?.length === 1) {
startDate = e.detail[0][0].toISOString()
endDate = ""
} else if (e.detail[0]?.length > 1) {
startDate = e.detail[0][0].toISOString()
endDate = e.detail[0][1].toISOString()
} else {
startDate = ""
endDate = ""
}
}}
/> />
</div> </div>
<div class="freeSearch"> <div class="freeSearch">
@ -488,7 +472,7 @@
flex-direction: row; flex-direction: row;
gap: var(--spacing-l); gap: var(--spacing-l);
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-end;
} }
.side-panel-icons { .side-panel-icons {
@ -505,6 +489,13 @@
.date-picker { .date-picker {
flex-basis: calc(70% - 32px); flex-basis: calc(70% - 32px);
min-width: 100px; min-width: 100px;
display: flex;
flex-direction: row;
}
.date-picker :global(.date-range-picker),
.date-picker :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
} }
.freeSearch { .freeSearch {

View file

@ -9,7 +9,7 @@ const {
ObjectStore, ObjectStore,
retrieve, retrieve,
uploadDirectory, uploadDirectory,
makeSureBucketExists, createBucketIfNotExists,
} = objectStore } = objectStore
const bucketList = Object.values(ObjectStoreBuckets) const bucketList = Object.values(ObjectStoreBuckets)
@ -61,7 +61,7 @@ export async function importObjects() {
let count = 0 let count = 0
for (let bucket of buckets) { for (let bucket of buckets) {
const client = ObjectStore(bucket) const client = ObjectStore(bucket)
await makeSureBucketExists(client, bucket) await createBucketIfNotExists(client, bucket)
const files = await uploadDirectory(bucket, join(path, bucket), "/") const files = await uploadDirectory(bucket, join(path, bucket), "/")
count += files.length count += files.length
bar.update(count) bar.update(count)

View file

@ -54,11 +54,9 @@ export async function downloadDockerCompose() {
export async function checkDockerConfigured() { export async function checkDockerConfigured() {
const error = const error =
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" "docker has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker") const docker = await lookpath("docker")
const compose = await lookpath("docker-compose") if (!docker) {
const composeV2 = await lookpath("docker compose")
if (!docker || (!compose && !composeV2)) {
throw error throw error
} }
} }

View file

@ -3869,12 +3869,6 @@
"key": "timeOnly", "key": "timeOnly",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "24-hour time",
"key": "time24hr",
"defaultValue": false
},
{ {
"type": "boolean", "type": "boolean",
"label": "Ignore time zones", "label": "Ignore time zones",
@ -6974,6 +6968,12 @@
"key": "stripeRows", "key": "stripeRows",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "Quiet",
"key": "quiet",
"defaultValue": false
},
{ {
"section": true, "section": true,
"name": "Columns", "name": "Columns",

View file

@ -24,14 +24,7 @@
"@budibase/shared-core": "0.0.0", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",
"@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/tag": "^3.1.4",
"@spectrum-css/typography": "^3.0.2",
"@spectrum-css/vars": "^3.0.1",
"apexcharts": "^3.22.1", "apexcharts": "^3.22.1",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",

View file

@ -38,10 +38,8 @@
if (!field || !value) { if (!field || !value) {
return null return null
} }
let low = dayjs.utc().subtract(1, "year") let low = dayjs.utc().subtract(1, "year")
let high = dayjs.utc().add(1, "day") let high = dayjs.utc().add(1, "day")
if (value === "Last 1 day") { if (value === "Last 1 day") {
low = dayjs.utc().subtract(1, "day") low = dayjs.utc().subtract(1, "day")
} else if (value === "Last 7 days") { } else if (value === "Last 7 days") {
@ -53,7 +51,6 @@
} else if (value === "Last 6 months") { } else if (value === "Last 6 months") {
low = dayjs.utc().subtract(6, "months") low = dayjs.utc().subtract(6, "months")
} }
return { return {
range: { range: {
[field]: { [field]: {

View file

@ -11,6 +11,7 @@
export let allowEditRows = true export let allowEditRows = true
export let allowDeleteRows = true export let allowDeleteRows = true
export let stripeRows = false export let stripeRows = false
export let quiet = false
export let initialFilter = null export let initialFilter = null
export let initialSortColumn = null export let initialSortColumn = null
export let initialSortOrder = null export let initialSortOrder = null
@ -49,6 +50,8 @@
metadata: { dataSource: table }, metadata: { dataSource: table },
}, },
] ]
$: height = $component.styles?.normal?.height || "408px"
$: styles = getSanitisedStyles($component.styles)
// Provide additional data context for live binding eval // Provide additional data context for live binding eval
export const getAdditionalDataContext = () => { export const getAdditionalDataContext = () => {
@ -105,38 +108,48 @@
}, },
})) }))
} }
const getSanitisedStyles = styles => {
return {
...styles,
normal: {
...styles?.normal,
height: undefined,
},
}
}
</script> </script>
<div <div use:styleable={styles} class:in-builder={$builderStore.inBuilder}>
use:styleable={$component.styles} <span style="--height:{height};">
class:in-builder={$builderStore.inBuilder} <Provider {actions}>
> <Grid
<Provider {actions}> bind:this={grid}
<Grid datasource={table}
bind:this={grid} {API}
datasource={table} {stripeRows}
{API} {quiet}
{stripeRows} {initialFilter}
{initialFilter} {initialSortColumn}
{initialSortColumn} {initialSortOrder}
{initialSortOrder} {fixedRowHeight}
{fixedRowHeight} {columnWhitelist}
{columnWhitelist} {schemaOverrides}
{schemaOverrides} {repeat}
{repeat} canAddRows={allowAddRows}
canAddRows={allowAddRows} canEditRows={allowEditRows}
canEditRows={allowEditRows} canDeleteRows={allowDeleteRows}
canDeleteRows={allowDeleteRows} canEditColumns={false}
canEditColumns={false} canExpandRows={false}
canExpandRows={false} canSaveSchema={false}
canSaveSchema={false} showControls={false}
showControls={false} notifySuccess={notificationStore.actions.success}
notifySuccess={notificationStore.actions.success} notifyError={notificationStore.actions.error}
notifyError={notificationStore.actions.error} buttons={enrichedButtons}
buttons={enrichedButtons} on:rowclick={e => onRowClick?.({ row: e.detail })}
on:rowclick={e => onRowClick?.({ row: e.detail })} />
/> </Provider>
</Provider> </span>
</div> </div>
<style> <style>
@ -147,10 +160,14 @@
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
min-height: 230px;
height: 410px;
} }
div.in-builder :global(*) { div.in-builder :global(*) {
pointer-events: none; pointer-events: none;
} }
span {
display: contents;
}
span :global(.grid) {
height: var(--height);
}
</style> </style>

View file

@ -1,7 +1,4 @@
export const buildBackupsEndpoints = API => ({ export const buildBackupsEndpoints = API => ({
/**
* Gets a list of users in the current tenant.
*/
searchBackups: async ({ appId, trigger, type, page, startDate, endDate }) => { searchBackups: async ({ appId, trigger, type, page, startDate, endDate }) => {
const opts = {} const opts = {}
if (page) { if (page) {

View file

@ -67,6 +67,11 @@
const removeFilter = id => { const removeFilter = id => {
filters = filters.filter(field => field.id !== id) filters = filters.filter(field => field.id !== id)
// Clear all filters when no fields are specified
if (filters.length === 1 && filters[0].onEmptyFilter) {
filters = []
}
} }
const duplicateFilter = id => { const duplicateFilter = id => {

View file

@ -1,6 +1,7 @@
<script> <script>
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
import { Dropzone } from "@budibase/bbui" import { Dropzone } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte"
export let value export let value
export let focused = false export let focused = false
@ -8,7 +9,6 @@
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false export let invertX = false
export let invertY = false
export let schema export let schema
export let maximum export let maximum
@ -16,6 +16,7 @@
const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"] const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"]
let isOpen = false let isOpen = false
let anchor
$: editable = focused && !readonly $: editable = focused && !readonly
$: { $: {
@ -73,7 +74,12 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="attachment-cell" class:editable on:click={editable ? open : null}> <div
class="attachment-cell"
class:editable
on:click={editable ? open : null}
bind:this={anchor}
>
{#each value || [] as attachment} {#each value || [] as attachment}
{#if isImage(attachment.extension)} {#if isImage(attachment.extension)}
<img src={attachment.url} alt={attachment.extension} /> <img src={attachment.url} alt={attachment.extension} />
@ -86,16 +92,24 @@
</div> </div>
{#if isOpen} {#if isOpen}
<div class="dropzone" class:invertX class:invertY> <GridPopover
<Dropzone open={isOpen}
{value} {anchor}
compact {invertX}
on:change={e => onChange(e.detail)} maxHeight={null}
maximum={maximum || schema.constraints?.length?.maximum} on:close={close}
{processFiles} >
{handleFileTooLarge} <div class="dropzone">
/> <Dropzone
</div> {value}
compact
on:change={e => onChange(e.detail)}
maximum={maximum || schema.constraints?.length?.maximum}
{processFiles}
{handleFileTooLarge}
/>
</div>
</GridPopover>
{/if} {/if}
<style> <style>
@ -129,23 +143,8 @@
user-select: none; user-select: none;
} }
.dropzone { .dropzone {
position: absolute;
top: 100%;
left: 0;
width: 320px;
background: var(--grid-background-alt); background: var(--grid-background-alt);
border: var(--cell-border); width: 320px;
padding: var(--cell-padding); padding: var(--cell-padding);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
.dropzone.invertX {
left: auto;
right: 0;
}
.dropzone.invertY {
transform: translateY(-100%);
top: 0;
} }
</style> </style>

View file

@ -1,7 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import RelationshipCell from "./RelationshipCell.svelte" import RelationshipCell from "./RelationshipCell.svelte"
import { FieldSubtype, RelationshipType } from "@budibase/types" import { BBReferenceFieldSubType, RelationshipType } from "@budibase/types"
export let api export let api
@ -13,13 +13,16 @@
// This is not really used, just adding some content to be able to render the relationship cell // This is not really used, just adding some content to be able to render the relationship cell
tableId: "external", tableId: "external",
relationshipType: relationshipType:
subtype === FieldSubtype.USER subtype === BBReferenceFieldSubType.USER
? RelationshipType.ONE_TO_MANY ? RelationshipType.ONE_TO_MANY
: RelationshipType.MANY_TO_MANY, : RelationshipType.MANY_TO_MANY,
} }
async function searchFunction(searchParams) { async function searchFunction(searchParams) {
if (subtype !== FieldSubtype.USER && subtype !== FieldSubtype.USERS) { if (
subtype !== BBReferenceFieldSubType.USER &&
subtype !== BBReferenceFieldSubType.USERS
) {
throw `Search for '${subtype}' not implemented` throw `Search for '${subtype}' not implemented`
} }

View file

@ -1,7 +1,8 @@
<script> <script>
import dayjs from "dayjs" import { CoreDatePickerPopoverContents, Icon, Helpers } from "@budibase/bbui"
import { CoreDatePicker, Icon } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import dayjs from "dayjs"
import GridPopover from "../overlays/GridPopover.svelte"
export let value export let value
export let schema export let schema
@ -9,83 +10,117 @@
export let focused = false export let focused = false
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false
let flatpickr
let isOpen let isOpen
let anchor
// Adding the 0- will turn a string like 00:00:00 into a valid ISO $: timeOnly = schema?.timeOnly
// date, but will make actual ISO dates invalid $: enableTime = !schema?.dateOnly
$: isTimeValue = !isNaN(new Date(`0-${value}`)) $: ignoreTimezones = schema?.ignoreTimezones
$: timeOnly = isTimeValue || schema?.timeOnly
$: dateOnly = schema?.dateOnly
$: format = timeOnly
? "HH:mm:ss"
: dateOnly
? "MMM D YYYY"
: "MMM D YYYY, HH:mm"
$: editable = focused && !readonly $: editable = focused && !readonly
$: displayValue = getDisplayValue(value, format, timeOnly, isTimeValue) $: parsedValue = Helpers.parseDate(value, {
timeOnly,
const getDisplayValue = (value, format, timeOnly, isTimeValue) => { enableTime,
if (!value) { ignoreTimezones,
return "" })
} $: displayValue = getDisplayValue(parsedValue, timeOnly, enableTime)
// Parse full date strings // Ensure open state matches desired state
if (!timeOnly || !isTimeValue) {
return dayjs(value).format(format)
}
// Otherwise must be a time string
return dayjs(`0-${value}`).format(format)
}
// Ensure we close flatpickr when unselected
$: { $: {
if (!focused) { if (!focused && isOpen) {
flatpickr?.close() close()
} }
} }
const onKeyDown = () => { const getDisplayValue = (value, timeOnly, enableTime) => {
return isOpen return Helpers.getDateDisplayValue(value, {
enableTime,
timeOnly,
})
}
const open = () => {
isOpen = true
}
const close = () => {
isOpen = false
// Only save the changed value when closing. If the value is unchanged then
// this is handled upstream and no action is taken.
onChange(value)
}
const onKeyDown = e => {
if (!isOpen) {
return false
}
e.preventDefault()
if (e.key === "ArrowUp") {
changeDate(-1, "week")
} else if (e.key === "ArrowDown") {
changeDate(1, "week")
} else if (e.key === "ArrowLeft") {
changeDate(-1, "day")
} else if (e.key === "ArrowRight") {
changeDate(1, "day")
} else if (e.key === "Enter") {
close()
}
return true
}
const changeDate = (quantity, unit) => {
let newValue
if (!value) {
newValue = dayjs()
} else {
newValue = dayjs(value).add(quantity, unit)
}
value = Helpers.stringifyDate(newValue, {
enableTime,
timeOnly,
ignoreTimezones,
})
} }
onMount(() => { onMount(() => {
api = { api = {
onKeyDown, onKeyDown,
focus: () => flatpickr?.open(), focus: open,
blur: () => flatpickr?.close(), blur: close,
isActive: () => isOpen, isActive: () => isOpen,
} }
}) })
</script> </script>
<div class="container"> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="container"
class:editable
on:click={editable ? open : null}
bind:this={anchor}
>
<div class="value"> <div class="value">
{#if value} {displayValue}
{displayValue}
{/if}
</div> </div>
{#if editable} {#if editable}
<Icon name="Calendar" /> <Icon name="Calendar" />
{/if} {/if}
</div> </div>
{#if editable} {#if isOpen}
<div class="picker"> <GridPopover {anchor} {invertX} maxHeight={null} on:close={close}>
<CoreDatePicker <CoreDatePickerPopoverContents
{value} value={parsedValue}
on:change={e => onChange(e.detail)}
appendTo={document.documentElement}
enableTime={!dateOnly}
{timeOnly}
time24hr
ignoreTimezones={schema.ignoreTimezones}
bind:flatpickr
on:open={() => (isOpen = true)}
on:close={() => (isOpen = false)}
useKeyboardShortcuts={false} useKeyboardShortcuts={false}
on:change={e => (value = e.detail)}
{enableTime}
{timeOnly}
{ignoreTimezones}
/> />
</div> </GridPopover>
{/if} {/if}
<style> <style>
@ -97,6 +132,10 @@
align-items: center; align-items: center;
flex: 1 1 auto; flex: 1 1 auto;
gap: var(--cell-spacing); gap: var(--cell-spacing);
user-select: none;
}
.container.editable:hover {
cursor: pointer;
} }
.value { .value {
flex: 1 1 auto; flex: 1 1 auto;
@ -105,15 +144,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
line-height: 20px; line-height: 20px;
} height: 20px;
.picker {
position: absolute;
opacity: 0;
}
.picker :global(.flatpickr) {
min-width: 0;
}
.picker :global(.spectrum-Textfield-input) {
width: 100%;
} }
</style> </style>

View file

@ -43,6 +43,9 @@
on:mouseup on:mouseup
on:click on:click
on:contextmenu on:contextmenu
on:touchstart
on:touchend
on:touchcancel
{style} {style}
> >
{#if error} {#if error}

View file

@ -1,30 +1,22 @@
<script> <script>
import { getContext, onMount, tick } from "svelte" import { getContext, onMount, tick } from "svelte"
import { canBeDisplayColumn, canBeSortColumn } from "@budibase/shared-core" import { canBeDisplayColumn, canBeSortColumn } from "@budibase/shared-core"
import { import { Icon, Menu, MenuItem, Modal } from "@budibase/bbui"
Icon,
Popover,
Menu,
MenuItem,
clickOutside,
Modal,
} from "@budibase/bbui"
import GridCell from "./GridCell.svelte" import GridCell from "./GridCell.svelte"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
import MigrationModal from "../controls/MigrationModal.svelte" import MigrationModal from "../controls/MigrationModal.svelte"
import { debounce } from "../../../utils/utils" import { debounce } from "../../../utils/utils"
import { FieldType, FormulaType } from "@budibase/types" import { FieldType, FormulaType } from "@budibase/types"
import { TableNames } from "../../../constants" import { TableNames } from "../../../constants"
import GridPopover from "../overlays/GridPopover.svelte"
export let column export let column
export let idx export let idx
export let orderable = true
const { const {
reorder, reorder,
isReordering, isReordering,
isResizing, isResizing,
rand,
sort, sort,
visibleColumns, visibleColumns,
dispatch, dispatch,
@ -53,7 +45,6 @@
let open = false let open = false
let editIsOpen = false let editIsOpen = false
let timeout let timeout
let popover
let migrationModal let migrationModal
let searchValue let searchValue
let input let input
@ -66,6 +57,12 @@
$: resetSearchValue(column.name) $: resetSearchValue(column.name)
$: searching = searchValue != null $: searching = searchValue != null
$: debouncedUpdateFilter(searchValue) $: debouncedUpdateFilter(searchValue)
$: orderable = !column.primaryDisplay
const close = () => {
open = false
editIsOpen = false
}
const getSortingLabels = type => { const getSortingLabels = type => {
switch (type) { switch (type) {
@ -106,22 +103,19 @@
dispatch("edit-column", column.schema) dispatch("edit-column", column.schema)
} }
const cancelEdit = () => {
popover.hide()
editIsOpen = false
}
const onMouseDown = e => { const onMouseDown = e => {
if (e.button === 0 && orderable) { ui.actions.blur()
if ((e.touches?.length || e.button === 0) && orderable) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
reorder.actions.startReordering(column.name, e) reorder.actions.startReordering(column.name, e)
}, 200) }, 200)
} }
} }
const onMouseUp = e => { const onMouseUp = () => {
if (e.button === 0 && orderable) { if (timeout) {
clearTimeout(timeout) clearTimeout(timeout)
timeout = null
} }
} }
@ -236,7 +230,7 @@
} }
const debouncedUpdateFilter = debounce(updateFilter, 250) const debouncedUpdateFilter = debounce(updateFilter, 250)
onMount(() => subscribe("close-edit-column", cancelEdit)) onMount(() => subscribe("close-edit-column", close))
</script> </script>
<Modal bind:this={migrationModal}> <Modal bind:this={migrationModal}>
@ -258,6 +252,9 @@
<GridCell <GridCell
on:mousedown={onMouseDown} on:mousedown={onMouseDown}
on:mouseup={onMouseUp} on:mouseup={onMouseUp}
on:touchstart={onMouseDown}
on:touchend={onMouseUp}
on:touchcancel={onMouseUp}
on:contextmenu={onContextMenu} on:contextmenu={onContextMenu}
width={column.width} width={column.width}
left={column.left} left={column.left}
@ -310,88 +307,88 @@
</GridCell> </GridCell>
</div> </div>
<Popover {#if open}
bind:open <GridPopover
bind:this={popover} {anchor}
{anchor} align="right"
align="right" on:close={close}
offset={0} maxHeight={null}
popoverTarget={document.getElementById(`grid-${rand}`)} resizable
customZindex={50} >
> {#if editIsOpen}
{#if editIsOpen} <div class="content">
<div <slot />
use:clickOutside={() => { </div>
editIsOpen = false {:else}
}} <Menu>
class="content" <MenuItem
> icon="Edit"
<slot /> on:click={editColumn}
</div> disabled={!$config.canEditColumns || column.schema.disabled}
{:else} >
<Menu> Edit column
<MenuItem
icon="Edit"
on:click={editColumn}
disabled={!$config.canEditColumns || column.schema.disabled}
>
Edit column
</MenuItem>
<MenuItem
icon="Duplicate"
on:click={duplicateColumn}
disabled={!$config.canEditColumns}
>
Duplicate column
</MenuItem>
<MenuItem
icon="Label"
on:click={makeDisplayColumn}
disabled={idx === "sticky" || !canBeDisplayColumn(column.schema.type)}
>
Use as display column
</MenuItem>
<MenuItem
icon="SortOrderUp"
on:click={sortAscending}
disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "ascending")}
>
Sort {sortingLabels.ascending}
</MenuItem>
<MenuItem
icon="SortOrderDown"
on:click={sortDescending}
disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "descending")}
>
Sort {sortingLabels.descending}
</MenuItem>
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
Move left
</MenuItem>
<MenuItem
disabled={!canMoveRight}
icon="ChevronRight"
on:click={moveRight}
>
Move right
</MenuItem>
<MenuItem
disabled={idx === "sticky" || !$config.showControls}
icon="VisibilityOff"
on:click={hideColumn}
>
Hide column
</MenuItem>
{#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS}
<MenuItem icon="User" on:click={openMigrationModal}>
Migrate to user column
</MenuItem> </MenuItem>
{/if} <MenuItem
</Menu> icon="Duplicate"
{/if} on:click={duplicateColumn}
</Popover> disabled={!$config.canEditColumns}
>
Duplicate column
</MenuItem>
<MenuItem
icon="Label"
on:click={makeDisplayColumn}
disabled={column.primaryDisplay ||
!canBeDisplayColumn(column.schema.type)}
>
Use as display column
</MenuItem>
<MenuItem
icon="SortOrderUp"
on:click={sortAscending}
disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "ascending")}
>
Sort {sortingLabels.ascending}
</MenuItem>
<MenuItem
icon="SortOrderDown"
on:click={sortDescending}
disabled={!canBeSortColumn(column.schema.type) ||
(column.name === $sort.column && $sort.order === "descending")}
>
Sort {sortingLabels.descending}
</MenuItem>
<MenuItem
disabled={!canMoveLeft}
icon="ChevronLeft"
on:click={moveLeft}
>
Move left
</MenuItem>
<MenuItem
disabled={!canMoveRight}
icon="ChevronRight"
on:click={moveRight}
>
Move right
</MenuItem>
<MenuItem
disabled={column.primaryDisplay || !$config.showControls}
icon="VisibilityOff"
on:click={hideColumn}
>
Hide column
</MenuItem>
{#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS}
<MenuItem icon="User" on:click={openMigrationModal}>
Migrate to user column
</MenuItem>
{/if}
</Menu>
{/if}
</GridPopover>
{/if}
<style> <style>
.header-cell { .header-cell {
@ -485,7 +482,7 @@
} }
.content { .content {
width: 300px; width: 360px;
padding: 20px; padding: 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -1,6 +1,7 @@
<script> <script>
import { onMount, tick } from "svelte" import { onMount, tick } from "svelte"
import { clickOutside } from "@budibase/bbui" import { clickOutside } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte"
export let value export let value
export let focused = false export let focused = false
@ -8,10 +9,10 @@
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false export let invertX = false
export let invertY = false
let textarea let textarea
let isOpen = false let isOpen = false
let anchor
$: editable = focused && !readonly $: editable = focused && !readonly
$: { $: {
@ -52,25 +53,30 @@
}) })
</script> </script>
{#if isOpen} <!-- svelte-ignore a11y-no-static-element-interactions -->
<textarea <!-- svelte-ignore a11y-click-events-have-key-events -->
class:invertX <div
class:invertY class="long-form-cell"
bind:this={textarea} on:click={editable ? open : null}
value={value || ""} class:editable
on:change={handleChange} bind:this={anchor}
on:wheel|stopPropagation >
spellcheck="false" <div class="value">
use:clickOutside={close} {value || ""}
/>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="long-form-cell" on:click={editable ? open : null} class:editable>
<div class="value">
{value || ""}
</div>
</div> </div>
</div>
{#if isOpen}
<GridPopover {anchor} {invertX} on:close={close}>
<textarea
bind:this={textarea}
value={value || ""}
on:change={handleChange}
on:wheel|stopPropagation
spellcheck="false"
use:clickOutside={close}
/>
</GridPopover>
{/if} {/if}
<style> <style>
@ -93,30 +99,20 @@
line-height: 20px; line-height: 20px;
} }
textarea { textarea {
border: none;
width: 320px;
flex: 1 1 auto;
height: var(--max-cell-render-overflow);
padding: var(--cell-padding); padding: var(--cell-padding);
margin: 0; margin: 0;
border: 2px solid var(--cell-color);
background: var(--cell-background); background: var(--cell-background);
font-size: var(--cell-font-size); font-size: var(--cell-font-size);
font-family: var(--font-sans); font-family: var(--font-sans);
color: inherit; color: inherit;
position: absolute;
top: 0;
left: 0;
width: calc(100% + var(--max-cell-render-width-overflow));
height: calc(var(--row-height) + var(--max-cell-render-height));
z-index: 1; z-index: 1;
border-radius: 2px;
resize: none; resize: none;
line-height: 20px; line-height: 20px;
} overflow: auto;
textarea.invertX {
left: auto;
right: 0;
}
textarea.invertY {
transform: translateY(-100%);
top: calc(100% + 1px);
} }
textarea:focus { textarea:focus {
outline: none; outline: none;

View file

@ -1,7 +1,8 @@
<script> <script>
import { Icon, clickOutside } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { getColor } from "../lib/utils" import { getColor } from "../lib/utils"
import { onMount } from "svelte" import { onMount } from "svelte"
import GridPopover from "../overlays/GridPopover.svelte"
export let value export let value
export let schema export let schema
@ -10,12 +11,12 @@
export let multi = false export let multi = false
export let readonly = false export let readonly = false
export let api export let api
export let invertX = false export let invertX
export let invertY = false
export let contentLines = 1 export let contentLines = 1
let isOpen = false let isOpen = false
let focusedOptionIdx = null let focusedOptionIdx = null
let anchor
$: options = schema?.constraints?.inclusion || [] $: options = schema?.constraints?.inclusion || []
$: optionColors = schema?.optionColors || {} $: optionColors = schema?.optionColors || {}
@ -23,7 +24,7 @@
$: values = Array.isArray(value) ? value : [value].filter(x => x != null) $: values = Array.isArray(value) ? value : [value].filter(x => x != null)
$: { $: {
// Close when deselected // Close when deselected
if (!focused) { if (!focused && isOpen) {
close() close()
} }
} }
@ -89,6 +90,7 @@
class:editable class:editable
class:open class:open
on:click|self={editable ? open : null} on:click|self={editable ? open : null}
bind:this={anchor}
> >
<div <div
class="values" class="values"
@ -115,16 +117,15 @@
<Icon name="ChevronDown" /> <Icon name="ChevronDown" />
</div> </div>
{/if} {/if}
{#if isOpen} </div>
<div
class="options" {#if isOpen}
class:invertX <GridPopover {anchor} {invertX} on:close={close}>
class:invertY <div class="options">
on:wheel={e => e.stopPropagation()}
use:clickOutside={close}
>
{#each options as option, idx} {#each options as option, idx}
{@const color = optionColors[option] || getOptionColor(option)} {@const color = optionColors[option] || getOptionColor(option)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="option" class="option"
on:click={() => toggleOption(option)} on:click={() => toggleOption(option)}
@ -132,7 +133,9 @@
on:mouseenter={() => (focusedOptionIdx = idx)} on:mouseenter={() => (focusedOptionIdx = idx)}
> >
<div class="badge text" style="--color: {color}"> <div class="badge text" style="--color: {color}">
{option} <span>
{option}
</span>
</div> </div>
{#if values.includes(option)} {#if values.includes(option)}
<Icon name="Checkmark" color="var(--accent-color)" /> <Icon name="Checkmark" color="var(--accent-color)" />
@ -140,8 +143,8 @@
</div> </div>
{/each} {/each}
</div> </div>
{/if} </GridPopover>
</div> {/if}
<style> <style>
.container { .container {
@ -211,28 +214,10 @@
); );
} }
.options { .options {
min-width: calc(100% + 2px);
position: absolute;
top: 100%;
left: -1px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
max-height: var(--max-cell-render-height);
overflow-y: auto;
border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
.options.invertX {
left: auto;
right: 0;
}
.options.invertY {
transform: translateY(-100%);
top: 0;
} }
.option { .option {
flex: 0 0 var(--default-row-height); flex: 0 0 var(--default-row-height);
@ -242,10 +227,10 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--cell-spacing); gap: var(--cell-spacing);
background-color: var(--grid-background-alt);
} }
.option:hover, .option:hover,
.option.focused { .option.focused {
background-color: var(--spectrum-global-color-gray-200); background-color: var(--grid-background-alt);
cursor: pointer;
} }
</style> </style>

View file

@ -1,10 +1,11 @@
<script> <script>
import { getColor } from "../lib/utils" import { getColor } from "../lib/utils"
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
import { Icon, Input, ProgressCircle, clickOutside } from "@budibase/bbui" import { Icon, Input, ProgressCircle } from "@budibase/bbui"
import { debounce } from "../../../utils/utils" import { debounce } from "../../../utils/utils"
import GridPopover from "../overlays/GridPopover.svelte"
const { API, dispatch, cache } = getContext("grid") const { API, cache } = getContext("grid")
export let value export let value
export let api export let api
@ -13,7 +14,6 @@
export let schema export let schema
export let onChange export let onChange
export let invertX = false export let invertX = false
export let invertY = false
export let contentLines = 1 export let contentLines = 1
export let searchFunction = API.searchTable export let searchFunction = API.searchTable
export let primaryDisplay export let primaryDisplay
@ -27,15 +27,15 @@
let candidateIndex let candidateIndex
let lastSearchId let lastSearchId
let searching = false let searching = false
let valuesHeight = 0
let container let container
let anchor
$: oneRowOnly = schema?.relationshipType === "one-to-many" $: oneRowOnly = schema?.relationshipType === "one-to-many"
$: editable = focused && !readonly $: editable = focused && !readonly
$: lookupMap = buildLookupMap(value, isOpen) $: lookupMap = buildLookupMap(value, isOpen)
$: debouncedSearch(searchString) $: debouncedSearch(searchString)
$: { $: {
if (!focused) { if (!focused && isOpen) {
close() close()
} }
} }
@ -125,7 +125,6 @@
const open = async () => { const open = async () => {
isOpen = true isOpen = true
valuesHeight = container.getBoundingClientRect().height
// Find the primary display for the related table // Find the primary display for the related table
if (!primaryDisplay) { if (!primaryDisplay) {
@ -204,14 +203,6 @@
close() close()
} }
const showRelationship = async id => {
const relatedRow = await API.fetchRow({
tableId: schema.tableId,
rowId: id,
})
dispatch("edit-row", relatedRow)
}
const readable = value => { const readable = value => {
if (value == null) { if (value == null) {
return "" return ""
@ -238,8 +229,8 @@
class="wrapper" class="wrapper"
class:editable class:editable
class:focused class:focused
class:invertY
style="--color:{color};" style="--color:{color};"
bind:this={anchor}
> >
<div class="container" bind:this={container}> <div class="container" bind:this={container}>
<div <div
@ -250,11 +241,7 @@
{#each value || [] as relationship} {#each value || [] as relationship}
{#if relationship[primaryDisplay] || relationship.primaryDisplay} {#if relationship[primaryDisplay] || relationship.primaryDisplay}
<div class="badge"> <div class="badge">
<span <span>
on:click={editable
? () => showRelationship(relationship._id)
: null}
>
{readable( {readable(
relationship[primaryDisplay] || relationship.primaryDisplay relationship[primaryDisplay] || relationship.primaryDisplay
)} )}
@ -282,16 +269,13 @@
</div> </div>
{/if} {/if}
</div> </div>
</div>
{#if isOpen} <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <!-- svelte-ignore a11y-click-events-have-key-events -->
class="dropdown" {#if isOpen}
class:invertX <GridPopover open={isOpen} {anchor} {invertX} on:close={close}>
class:invertY <div class="dropdown" on:wheel|stopPropagation>
on:wheel|stopPropagation
use:clickOutside={close}
style="--values-height:{valuesHeight}px;"
>
<div class="search"> <div class="search">
<Input <Input
autofocus autofocus
@ -327,8 +311,8 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if} </GridPopover>
</div> {/if}
<style> <style>
.wrapper { .wrapper {
@ -337,7 +321,6 @@
min-height: var(--row-height); min-height: var(--row-height);
max-height: var(--row-height); max-height: var(--row-height);
overflow: hidden; overflow: hidden;
--max-relationship-height: 96px;
} }
.wrapper.focused { .wrapper.focused {
position: absolute; position: absolute;
@ -349,10 +332,6 @@
max-height: none; max-height: none;
overflow: visible; overflow: visible;
} }
.wrapper.invertY {
top: auto;
bottom: 0;
}
.container { .container {
min-height: var(--row-height); min-height: var(--row-height);
@ -363,7 +342,6 @@
.focused .container { .focused .container {
overflow-y: auto; overflow-y: auto;
border-radius: 2px; border-radius: 2px;
max-height: var(--max-relationship-height);
} }
.focused .container:after { .focused .container:after {
content: " "; content: " ";
@ -426,10 +404,6 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.editable .values .badge span:hover {
cursor: pointer;
text-decoration: underline;
}
.add { .add {
background: var(--spectrum-global-color-gray-200); background: var(--spectrum-global-color-gray-200);
@ -446,30 +420,9 @@
} }
.dropdown { .dropdown {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: calc(
var(--max-cell-render-height) + var(--row-height) - var(--values-height)
);
background: var(--grid-background-alt);
border: var(--cell-border);
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
padding: 0 0 8px 0;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
.dropdown.invertY {
transform: translateY(-100%);
top: -1px;
}
.dropdown.invertX {
left: auto;
right: 0;
} }
.searching { .searching {
@ -497,7 +450,8 @@
cursor: pointer; cursor: pointer;
} }
.result .badge { .result .badge {
max-width: calc(100% - 30px); flex: 1 1 auto;
overflow: hidden;
} }
.search { .search {
@ -505,7 +459,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
margin: 4px var(--cell-padding); margin: 4px var(--cell-padding);
width: calc(100% - 2 * var(--cell-padding));
} }
.search :global(.spectrum-Textfield) { .search :global(.spectrum-Textfield) {
min-width: 0; min-width: 0;

View file

@ -81,6 +81,7 @@
size="S" size="S"
value={column.visible} value={column.visible}
on:change={e => toggleVisibility(column, e.detail)} on:change={e => toggleVisibility(column, e.detail)}
disabled={column.primaryDisplay}
/> />
{/each} {/each}
</div> </div>

View file

@ -7,7 +7,11 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core" import { ValidColumnNameRegex } from "@budibase/shared-core"
import { FieldSubtype, FieldType, RelationshipType } from "@budibase/types" import {
BBReferenceFieldSubType,
FieldType,
RelationshipType,
} from "@budibase/types"
const { API, definition, rows } = getContext("grid") const { API, definition, rows } = getContext("grid")
@ -29,9 +33,9 @@
} }
const migrateUserColumn = async () => { const migrateUserColumn = async () => {
let subtype = FieldSubtype.USERS let subtype = BBReferenceFieldSubType.USERS
if (column.schema.relationshipType === RelationshipType.ONE_TO_MANY) { if (column.schema.relationshipType === RelationshipType.ONE_TO_MANY) {
subtype = FieldSubtype.USER subtype = BBReferenceFieldSubType.USER
} }
try { try {

View file

@ -10,6 +10,7 @@
import GridBody from "./GridBody.svelte" import GridBody from "./GridBody.svelte"
import ResizeOverlay from "../overlays/ResizeOverlay.svelte" import ResizeOverlay from "../overlays/ResizeOverlay.svelte"
import ReorderOverlay from "../overlays/ReorderOverlay.svelte" import ReorderOverlay from "../overlays/ReorderOverlay.svelte"
import PopoverOverlay from "../overlays/PopoverOverlay.svelte"
import HeaderRow from "./HeaderRow.svelte" import HeaderRow from "./HeaderRow.svelte"
import ScrollOverlay from "../overlays/ScrollOverlay.svelte" import ScrollOverlay from "../overlays/ScrollOverlay.svelte"
import MenuOverlay from "../overlays/MenuOverlay.svelte" import MenuOverlay from "../overlays/MenuOverlay.svelte"
@ -22,10 +23,12 @@
import NewRow from "./NewRow.svelte" import NewRow from "./NewRow.svelte"
import { createGridWebsocket } from "../lib/websocket" import { createGridWebsocket } from "../lib/websocket"
import { import {
MaxCellRenderHeight, MaxCellRenderOverflow,
MaxCellRenderWidthOverflow,
GutterWidth, GutterWidth,
DefaultRowHeight, DefaultRowHeight,
Padding,
SmallRowHeight,
ControlsHeight,
} from "../lib/constants" } from "../lib/constants"
export let API = null export let API = null
@ -39,6 +42,7 @@
export let canEditColumns = true export let canEditColumns = true
export let canSaveSchema = true export let canSaveSchema = true
export let stripeRows = false export let stripeRows = false
export let quiet = false
export let collaboration = true export let collaboration = true
export let showAvatars = true export let showAvatars = true
export let showControls = true export let showControls = true
@ -51,7 +55,7 @@
export let buttons = null export let buttons = null
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
const rand = Math.random() const gridID = `grid-${Math.random().toString().slice(2)}`
// Store props in a store for reference in other stores // Store props in a store for reference in other stores
const props = writable($$props) const props = writable($$props)
@ -59,7 +63,7 @@
// Build up context // Build up context
let context = { let context = {
API: API || createAPIClient(), API: API || createAPIClient(),
rand, gridID,
props, props,
} }
context = { ...context, ...createEventManagers() } context = { ...context, ...createEventManagers() }
@ -91,6 +95,7 @@
canEditColumns, canEditColumns,
canSaveSchema, canSaveSchema,
stripeRows, stripeRows,
quiet,
collaboration, collaboration,
showAvatars, showAvatars,
showControls, showControls,
@ -102,6 +107,8 @@
notifyError, notifyError,
buttons, buttons,
}) })
$: minHeight =
Padding + SmallRowHeight + $rowHeight + (showControls ? ControlsHeight : 0)
// Set context for children to consume // Set context for children to consume
setContext("grid", context) setContext("grid", context)
@ -120,13 +127,14 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="grid" class="grid"
id="grid-{rand}" id={gridID}
class:is-resizing={$isResizing} class:is-resizing={$isResizing}
class:is-reordering={$isReordering} class:is-reordering={$isReordering}
class:stripe={stripeRows} class:stripe={stripeRows}
class:quiet
on:mouseenter={() => gridFocused.set(true)} on:mouseenter={() => gridFocused.set(true)}
on:mouseleave={() => gridFocused.set(false)} on:mouseleave={() => gridFocused.set(false)}
style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-height:{MaxCellRenderHeight}px; --max-cell-render-width-overflow:{MaxCellRenderWidthOverflow}px; --content-lines:{$contentLines};" style="--row-height:{$rowHeight}px; --default-row-height:{DefaultRowHeight}px; --gutter-width:{GutterWidth}px; --max-cell-render-overflow:{MaxCellRenderOverflow}px; --content-lines:{$contentLines}; --min-height:{minHeight}px; --controls-height:{ControlsHeight}px;"
> >
{#if showControls} {#if showControls}
<div class="controls"> <div class="controls">
@ -178,6 +186,7 @@
<ReorderOverlay /> <ReorderOverlay />
<ScrollOverlay /> <ScrollOverlay />
<MenuOverlay /> <MenuOverlay />
<PopoverOverlay />
</div> </div>
</div> </div>
</div> </div>
@ -207,7 +216,6 @@
--cell-spacing: 4px; --cell-spacing: 4px;
--cell-border: 1px solid var(--spectrum-global-color-gray-200); --cell-border: 1px solid var(--spectrum-global-color-gray-200);
--cell-font-size: 14px; --cell-font-size: 14px;
--controls-height: 50px;
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -216,6 +224,7 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: var(--grid-background); background: var(--grid-background);
min-height: var(--min-height);
} }
.grid, .grid,
.grid :global(*) { .grid :global(*) {
@ -331,4 +340,9 @@
.grid-data-outer :global(.spectrum-Checkbox-partialCheckmark) { .grid-data-outer :global(.spectrum-Checkbox-partialCheckmark) {
transition: none; transition: none;
} }
/* Overrides */
.grid.quiet :global(.grid-data-content .row > .cell:not(:last-child)) {
border-right: none;
}
</style> </style>

View file

@ -12,6 +12,7 @@
bounds, bounds,
hoveredRowId, hoveredRowId,
menu, menu,
focusedCellAPI,
} = getContext("grid") } = getContext("grid")
export let scrollVertically = false export let scrollVertically = false
@ -35,6 +36,9 @@
e.preventDefault() e.preventDefault()
updateScroll(e.deltaX, e.deltaY, e.clientY) updateScroll(e.deltaX, e.deltaY, e.clientY)
// Close any open popovers when scrolling
$focusedCellAPI?.blur()
// If a context menu was visible, hide it // If a context menu was visible, hide it
if ($menu.visible) { if ($menu.visible) {
menu.actions.close() menu.actions.close()

View file

@ -1,11 +1,12 @@
<script> <script>
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { Icon, Popover, clickOutside } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import GridPopover from "../overlays/GridPopover.svelte"
const { visibleColumns, scroll, width, subscribe } = getContext("grid") const { visibleColumns, scroll, width, subscribe, ui } = getContext("grid")
let anchor let anchor
let open = false let isOpen = false
$: columnsWidth = $visibleColumns.reduce( $: columnsWidth = $visibleColumns.reduce(
(total, col) => (total += col.width), (total, col) => (total += col.width),
@ -14,8 +15,13 @@
$: end = columnsWidth - 1 - $scroll.left $: end = columnsWidth - 1 - $scroll.left
$: left = Math.min($width - 40, end) $: left = Math.min($width - 40, end)
const open = () => {
ui.actions.blur()
isOpen = true
}
const close = () => { const close = () => {
open = false isOpen = false
} }
onMount(() => subscribe("close-edit-column", close)) onMount(() => subscribe("close-edit-column", close))
@ -28,27 +34,23 @@
bind:this={anchor} bind:this={anchor}
class="add" class="add"
style="left:{left}px" style="left:{left}px"
on:click={() => (open = true)} on:click={open}
> >
<Icon name="Add" /> <Icon name="Add" />
</div> </div>
<Popover {#if isOpen}
bind:open <GridPopover
{anchor} {anchor}
align={$visibleColumns.length ? "right" : "left"} align={$visibleColumns.length ? "right" : "left"}
offset={0} on:close={close}
popoverTarget={document.getElementById(`add-column-button`)} maxHeight={null}
customZindex={50} resizable
>
<div
use:clickOutside={() => {
open = false
}}
class="content"
> >
<slot /> <div class="content">
</div> <slot />
</Popover> </div>
</GridPopover>
{/if}
<style> <style>
.add { .add {

View file

@ -30,6 +30,7 @@
refreshing, refreshing,
config, config,
filter, filter,
inlineFilters,
columnRenderMap, columnRenderMap,
} = getContext("grid") } = getContext("grid")
@ -157,7 +158,11 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<TempTooltip <TempTooltip
text="Click here to create your first row" text="Click here to create your first row"
condition={hasNoRows && $loaded && !$filter?.length && !$refreshing} condition={hasNoRows &&
$loaded &&
!$filter?.length &&
!$inlineFilters?.length &&
!$refreshing}
type={TooltipType.Info} type={TooltipType.Info}
> >
{#if !visible && !selectedRowCount && $config.canAddRows} {#if !visible && !selectedRowCount && $config.canAddRows}

View file

@ -1,5 +1,4 @@
export const Padding = 246 export const Padding = 100
export const MaxCellRenderHeight = 222
export const ScrollBarSize = 8 export const ScrollBarSize = 8
export const GutterWidth = 72 export const GutterWidth = 72
export const DefaultColumnWidth = 200 export const DefaultColumnWidth = 200
@ -11,5 +10,11 @@ export const DefaultRowHeight = SmallRowHeight
export const NewRowID = "new" export const NewRowID = "new"
export const BlankRowID = "blank" export const BlankRowID = "blank"
export const RowPageSize = 100 export const RowPageSize = 100
export const FocusedCellMinOffset = 48 export const FocusedCellMinOffset = ScrollBarSize * 3
export const MaxCellRenderWidthOverflow = Padding - 3 * ScrollBarSize export const ControlsHeight = 50
// Popovers
export const PopoverMinWidth = 200
export const PopoverMaxWidth = 400
export const PopoverMaxHeight = 236
export const MaxCellRenderOverflow = 222

View file

@ -20,3 +20,10 @@ export const getColumnIcon = column => {
return result || "Text" return result || "Text"
} }
export const parseEventLocation = e => {
return {
x: e.clientX ?? e.touches?.[0]?.clientX,
y: e.clientY ?? e.touches?.[0]?.clientY,
}
}

View file

@ -0,0 +1,71 @@
<script>
import { Popover, clickOutside } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import {
PopoverMinWidth,
PopoverMaxWidth,
PopoverMaxHeight,
} from "../lib/constants"
export let anchor
export let minWidth = PopoverMinWidth
export let maxWidth = PopoverMaxWidth
export let maxHeight = PopoverMaxHeight
export let align = "left"
export let open = true
export let resizable = false
export let wrap = true
const { gridID } = getContext("grid")
const dispatch = createEventDispatcher()
$: style = buildStyles(minWidth, maxWidth, maxHeight)
const buildStyles = (minWidth, maxWidth, maxHeight) => {
let style = ""
if (minWidth != null) {
style += `min-width: ${minWidth}px;`
}
if (maxWidth != null) {
style += `max-width: ${maxWidth}px;`
}
if (maxHeight != null) {
style += `max-height: ${maxHeight}px;`
}
return style
}
</script>
<Popover
{open}
{anchor}
{align}
{resizable}
{wrap}
portalTarget="#{gridID} .grid-popover-container"
offset={0}
>
<div
class="grid-popover-contents"
{style}
use:clickOutside={() => dispatch("close")}
on:wheel={e => e.stopPropagation()}
>
<slot />
</div>
</Popover>
<style>
:global(.grid-popover-container .spectrum-Popover) {
background: var(--grid-background);
min-width: none;
max-width: none;
overflow: hidden;
}
.grid-popover-contents {
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
</style>

View file

@ -20,6 +20,7 @@
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
".spectrum-Modal", ".spectrum-Modal",
".date-time-popover",
"#builder-side-panel-container", "#builder-side-panel-container",
"[data-grid-ignore]", "[data-grid-ignore]",
] ]

View file

@ -1,7 +1,8 @@
<script> <script>
import { clickOutside, Menu, MenuItem, Helpers } from "@budibase/bbui" import { Menu, MenuItem, Helpers } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { NewRowID } from "../lib/constants" import { NewRowID } from "../lib/constants"
import GridPopover from "./GridPopover.svelte"
const { const {
focusedRow, focusedRow,
@ -20,6 +21,8 @@
isDatasourcePlus, isDatasourcePlus,
} = getContext("grid") } = getContext("grid")
let anchor
$: style = makeStyle($menu) $: style = makeStyle($menu)
$: isNewRow = $focusedRowId === NewRowID $: isNewRow = $focusedRowId === NewRowID
@ -48,75 +51,74 @@
} }
</script> </script>
<div bind:this={anchor} {style} class="menu-anchor" />
{#if $menu.visible} {#if $menu.visible}
<div class="menu" {style} use:clickOutside={() => menu.actions.close()}> {#key style}
<Menu> <GridPopover {anchor} on:close={menu.actions.close} maxHeight={null}>
<MenuItem <Menu>
icon="Copy" <MenuItem
on:click={clipboard.actions.copy} icon="Copy"
on:click={menu.actions.close} on:click={clipboard.actions.copy}
> on:click={menu.actions.close}
Copy >
</MenuItem> Copy
<MenuItem </MenuItem>
icon="Paste" <MenuItem
disabled={$copiedCell == null || $focusedCellAPI?.isReadonly()} icon="Paste"
on:click={clipboard.actions.paste} disabled={$copiedCell == null || $focusedCellAPI?.isReadonly()}
on:click={menu.actions.close} on:click={clipboard.actions.paste}
> on:click={menu.actions.close}
Paste >
</MenuItem> Paste
<MenuItem </MenuItem>
icon="Maximize" <MenuItem
disabled={isNewRow || !$config.canEditRows || !$config.canExpandRows} icon="Maximize"
on:click={() => dispatch("edit-row", $focusedRow)} disabled={isNewRow || !$config.canEditRows || !$config.canExpandRows}
on:click={menu.actions.close} on:click={() => dispatch("edit-row", $focusedRow)}
> on:click={menu.actions.close}
Edit row in modal >
</MenuItem> Edit row in modal
<MenuItem </MenuItem>
icon="Copy" <MenuItem
disabled={isNewRow || !$focusedRow?._id || !$isDatasourcePlus} icon="Copy"
on:click={() => copyToClipboard($focusedRow?._id)} disabled={isNewRow || !$focusedRow?._id || !$isDatasourcePlus}
on:click={menu.actions.close} on:click={() => copyToClipboard($focusedRow?._id)}
> on:click={menu.actions.close}
Copy row _id >
</MenuItem> Copy row _id
<MenuItem </MenuItem>
icon="Copy" <MenuItem
disabled={isNewRow || !$focusedRow?._rev} icon="Copy"
on:click={() => copyToClipboard($focusedRow?._rev)} disabled={isNewRow || !$focusedRow?._rev}
on:click={menu.actions.close} on:click={() => copyToClipboard($focusedRow?._rev)}
> on:click={menu.actions.close}
Copy row _rev >
</MenuItem> Copy row _rev
<MenuItem </MenuItem>
icon="Duplicate" <MenuItem
disabled={isNewRow || !$config.canAddRows} icon="Duplicate"
on:click={duplicate} disabled={isNewRow || !$config.canAddRows}
> on:click={duplicate}
Duplicate row >
</MenuItem> Duplicate row
<MenuItem </MenuItem>
icon="Delete" <MenuItem
disabled={isNewRow || !$config.canDeleteRows} icon="Delete"
on:click={deleteRow} disabled={isNewRow || !$config.canDeleteRows}
> on:click={deleteRow}
Delete row >
</MenuItem> Delete row
</Menu> </MenuItem>
</div> </Menu>
</GridPopover>
{/key}
{/if} {/if}
<style> <style>
.menu { .menu-anchor {
opacity: 0;
pointer-events: none;
position: absolute; position: absolute;
background: var(--cell-background);
border: 1px solid var(--spectrum-global-color-gray-300);
width: 180px;
border-radius: 4px;
display: flex;
flex-direction: column;
box-shadow: 0 0 20px -4px rgba(0, 0, 0, 0.15);
} }
</style> </style>

View file

@ -0,0 +1,9 @@
<div class="grid-popover-container" />
<style>
.grid-popover-container {
position: fixed;
top: 0;
left: 0;
}
</style>

View file

@ -21,6 +21,7 @@
class="resize-slider" class="resize-slider"
class:visible={activeColumn === $stickyColumn.name} class:visible={activeColumn === $stickyColumn.name}
on:mousedown={e => resize.actions.startResizing($stickyColumn, e)} on:mousedown={e => resize.actions.startResizing($stickyColumn, e)}
on:touchstart={e => resize.actions.startResizing($stickyColumn, e)}
on:dblclick={() => resize.actions.resetSize($stickyColumn)} on:dblclick={() => resize.actions.resetSize($stickyColumn)}
style="left:{GutterWidth + $stickyColumn.width}px;" style="left:{GutterWidth + $stickyColumn.width}px;"
> >
@ -32,6 +33,7 @@
class="resize-slider" class="resize-slider"
class:visible={activeColumn === column.name} class:visible={activeColumn === column.name}
on:mousedown={e => resize.actions.startResizing(column, e)} on:mousedown={e => resize.actions.startResizing(column, e)}
on:touchstart={e => resize.actions.startResizing(column, e)}
on:dblclick={() => resize.actions.resetSize(column)} on:dblclick={() => resize.actions.resetSize(column)}
style={getStyle(column, offset, $scrollLeft)} style={getStyle(column, offset, $scrollLeft)}
> >

View file

@ -2,6 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { domDebounce } from "../../../utils/utils" import { domDebounce } from "../../../utils/utils"
import { DefaultRowHeight, ScrollBarSize } from "../lib/constants" import { DefaultRowHeight, ScrollBarSize } from "../lib/constants"
import { parseEventLocation } from "../lib/utils"
const { const {
scroll, scroll,
@ -17,6 +18,7 @@
height, height,
isDragging, isDragging,
menu, menu,
focusedCellAPI,
} = getContext("grid") } = getContext("grid")
// State for dragging bars // State for dragging bars
@ -47,33 +49,27 @@
$: barLeft = ScrollBarSize + availWidth * ($scrollLeft / $maxScrollLeft) $: barLeft = ScrollBarSize + availWidth * ($scrollLeft / $maxScrollLeft)
// Helper to close the context menu if it's open // Helper to close the context menu if it's open
const closeMenu = () => { const closePopovers = () => {
if ($menu.visible) { if ($menu.visible) {
menu.actions.close() menu.actions.close()
} }
} $focusedCellAPI?.blur()
const getLocation = e => {
return {
y: e.touches?.[0]?.clientY ?? e.clientY,
x: e.touches?.[0]?.clientX ?? e.clientX,
}
} }
// V scrollbar drag handlers // V scrollbar drag handlers
const startVDragging = e => { const startVDragging = e => {
e.preventDefault() e.preventDefault()
initialMouse = getLocation(e).y initialMouse = parseEventLocation(e).y
initialScroll = $scrollTop initialScroll = $scrollTop
document.addEventListener("mousemove", moveVDragging) document.addEventListener("mousemove", moveVDragging)
document.addEventListener("touchmove", moveVDragging) document.addEventListener("touchmove", moveVDragging)
document.addEventListener("mouseup", stopVDragging) document.addEventListener("mouseup", stopVDragging)
document.addEventListener("touchend", stopVDragging) document.addEventListener("touchend", stopVDragging)
isDraggingV = true isDraggingV = true
closeMenu() closePopovers()
} }
const moveVDragging = domDebounce(e => { const moveVDragging = domDebounce(e => {
const delta = getLocation(e).y - initialMouse const delta = parseEventLocation(e).y - initialMouse
const weight = delta / availHeight const weight = delta / availHeight
const newScrollTop = initialScroll + weight * $maxScrollTop const newScrollTop = initialScroll + weight * $maxScrollTop
scroll.update(state => ({ scroll.update(state => ({
@ -92,17 +88,17 @@
// H scrollbar drag handlers // H scrollbar drag handlers
const startHDragging = e => { const startHDragging = e => {
e.preventDefault() e.preventDefault()
initialMouse = getLocation(e).x initialMouse = parseEventLocation(e).x
initialScroll = $scrollLeft initialScroll = $scrollLeft
document.addEventListener("mousemove", moveHDragging) document.addEventListener("mousemove", moveHDragging)
document.addEventListener("touchmove", moveHDragging) document.addEventListener("touchmove", moveHDragging)
document.addEventListener("mouseup", stopHDragging) document.addEventListener("mouseup", stopHDragging)
document.addEventListener("touchend", stopHDragging) document.addEventListener("touchend", stopHDragging)
isDraggingH = true isDraggingH = true
closeMenu() closePopovers()
} }
const moveHDragging = domDebounce(e => { const moveHDragging = domDebounce(e => {
const delta = getLocation(e).x - initialMouse const delta = parseEventLocation(e).x - initialMouse
const weight = delta / availWidth const weight = delta / availWidth
const newScrollLeft = initialScroll + weight * $maxScrollLeft const newScrollLeft = initialScroll + weight * $maxScrollLeft
scroll.update(state => ({ scroll.update(state => ({

View file

@ -48,22 +48,28 @@ export const createStores = () => {
export const deriveStores = context => { export const deriveStores = context => {
const { columns, stickyColumn } = context const { columns, stickyColumn } = context
// Derive if we have any normal columns // Quick access to all columns
const hasNonAutoColumn = derived( const allColumns = derived(
[columns, stickyColumn], [columns, stickyColumn],
([$columns, $stickyColumn]) => { ([$columns, $stickyColumn]) => {
let allCols = $columns || [] let allCols = $columns || []
if ($stickyColumn) { if ($stickyColumn) {
allCols = [...allCols, $stickyColumn] allCols = [...allCols, $stickyColumn]
} }
const normalCols = allCols.filter(column => { return allCols
return !column.schema?.autocolumn
})
return normalCols.length > 0
} }
) )
// Derive if we have any normal columns
const hasNonAutoColumn = derived(allColumns, $allColumns => {
const normalCols = $allColumns.filter(column => {
return !column.schema?.autocolumn
})
return normalCols.length > 0
})
return { return {
allColumns,
hasNonAutoColumn, hasNonAutoColumn,
} }
} }
@ -142,24 +148,26 @@ export const createActions = context => {
} }
export const initialise = context => { export const initialise = context => {
const { definition, columns, stickyColumn, enrichedSchema } = context const {
definition,
columns,
stickyColumn,
allColumns,
enrichedSchema,
compact,
} = context
// Merge new schema fields with existing schema in order to preserve widths // Merge new schema fields with existing schema in order to preserve widths
enrichedSchema.subscribe($enrichedSchema => { const processColumns = $enrichedSchema => {
if (!$enrichedSchema) { if (!$enrichedSchema) {
columns.set([]) columns.set([])
stickyColumn.set(null) stickyColumn.set(null)
return return
} }
const $definition = get(definition) const $definition = get(definition)
const $columns = get(columns) const $allColumns = get(allColumns)
const $stickyColumn = get(stickyColumn) const $stickyColumn = get(stickyColumn)
const $compact = get(compact)
// Generate array of all columns to easily find pre-existing columns
let allColumns = $columns || []
if ($stickyColumn) {
allColumns.push($stickyColumn)
}
// Find primary display // Find primary display
let primaryDisplay let primaryDisplay
@ -171,7 +179,7 @@ export const initialise = context => {
// Get field list // Get field list
let fields = [] let fields = []
Object.keys($enrichedSchema).forEach(field => { Object.keys($enrichedSchema).forEach(field => {
if (field !== primaryDisplay) { if ($compact || field !== primaryDisplay) {
fields.push(field) fields.push(field)
} }
}) })
@ -181,7 +189,7 @@ export const initialise = context => {
fields fields
.map(field => { .map(field => {
const fieldSchema = $enrichedSchema[field] const fieldSchema = $enrichedSchema[field]
const oldColumn = allColumns?.find(x => x.name === field) const oldColumn = $allColumns?.find(x => x.name === field)
return { return {
name: field, name: field,
label: fieldSchema.displayName || field, label: fieldSchema.displayName || field,
@ -189,9 +197,18 @@ export const initialise = context => {
width: fieldSchema.width || oldColumn?.width || DefaultColumnWidth, width: fieldSchema.width || oldColumn?.width || DefaultColumnWidth,
visible: fieldSchema.visible ?? true, visible: fieldSchema.visible ?? true,
order: fieldSchema.order ?? oldColumn?.order, order: fieldSchema.order ?? oldColumn?.order,
primaryDisplay: field === primaryDisplay,
} }
}) })
.sort((a, b) => { .sort((a, b) => {
// If we don't have a pinned column then primary display will be in
// the normal columns list, and should be first
if (a.name === primaryDisplay) {
return -1
} else if (b.name === primaryDisplay) {
return 1
}
// Sort by order first // Sort by order first
const orderA = a.order const orderA = a.order
const orderB = b.order const orderB = b.order
@ -214,12 +231,12 @@ export const initialise = context => {
) )
// Update sticky column // Update sticky column
if (!primaryDisplay) { if ($compact || !primaryDisplay) {
stickyColumn.set(null) stickyColumn.set(null)
return return
} }
const stickySchema = $enrichedSchema[primaryDisplay] const stickySchema = $enrichedSchema[primaryDisplay]
const oldStickyColumn = allColumns?.find(x => x.name === primaryDisplay) const oldStickyColumn = $allColumns?.find(x => x.name === primaryDisplay)
stickyColumn.set({ stickyColumn.set({
name: primaryDisplay, name: primaryDisplay,
label: stickySchema.displayName || primaryDisplay, label: stickySchema.displayName || primaryDisplay,
@ -228,6 +245,13 @@ export const initialise = context => {
visible: true, visible: true,
order: 0, order: 0,
left: GutterWidth, left: GutterWidth,
primaryDisplay: true,
}) })
}) }
// Process columns when schema changes
enrichedSchema.subscribe(processColumns)
// Process columns when compact flag changes
compact.subscribe(() => processColumns(get(enrichedSchema)))
} }

View file

@ -13,13 +13,13 @@ export const createStores = () => {
} }
export const createActions = context => { export const createActions = context => {
const { menu, focusedCellId, rand } = context const { menu, focusedCellId, gridID } = context
const open = (cellId, e) => { const open = (cellId, e) => {
e.preventDefault() e.preventDefault()
// Get DOM node for grid data wrapper to compute relative position to // Get DOM node for grid data wrapper to compute relative position to
const gridNode = document.getElementById(`grid-${rand}`) const gridNode = document.getElementById(gridID)
const dataNode = gridNode?.getElementsByClassName("grid-data-outer")?.[0] const dataNode = gridNode?.getElementsByClassName("grid-data-outer")?.[0]
if (!dataNode) { if (!dataNode) {
return return

View file

@ -1,4 +1,5 @@
import { get, writable, derived } from "svelte/store" import { get, writable, derived } from "svelte/store"
import { parseEventLocation } from "../lib/utils"
const reorderInitialState = { const reorderInitialState = {
sourceColumn: null, sourceColumn: null,
@ -31,8 +32,8 @@ export const createActions = context => {
scroll, scroll,
bounds, bounds,
stickyColumn, stickyColumn,
ui,
maxScrollLeft, maxScrollLeft,
width,
} = context } = context
let autoScrollInterval let autoScrollInterval
@ -43,7 +44,6 @@ export const createActions = context => {
const $visibleColumns = get(visibleColumns) const $visibleColumns = get(visibleColumns)
const $bounds = get(bounds) const $bounds = get(bounds)
const $stickyColumn = get(stickyColumn) const $stickyColumn = get(stickyColumn)
ui.actions.blur()
// Generate new breakpoints for the current columns // Generate new breakpoints for the current columns
let breakpoints = $visibleColumns.map(col => ({ let breakpoints = $visibleColumns.map(col => ({
@ -55,6 +55,11 @@ export const createActions = context => {
x: 0, x: 0,
column: $stickyColumn.name, column: $stickyColumn.name,
}) })
} else if (!$visibleColumns[0].primaryDisplay) {
breakpoints.unshift({
x: 0,
column: null,
})
} }
// Update state // Update state
@ -69,6 +74,9 @@ export const createActions = context => {
// Add listeners to handle mouse movement // Add listeners to handle mouse movement
document.addEventListener("mousemove", onReorderMouseMove) document.addEventListener("mousemove", onReorderMouseMove)
document.addEventListener("mouseup", stopReordering) document.addEventListener("mouseup", stopReordering)
document.addEventListener("touchmove", onReorderMouseMove)
document.addEventListener("touchend", stopReordering)
document.addEventListener("touchcancel", stopReordering)
// Trigger a move event immediately so ensure a candidate column is chosen // Trigger a move event immediately so ensure a candidate column is chosen
onReorderMouseMove(e) onReorderMouseMove(e)
@ -77,7 +85,7 @@ export const createActions = context => {
// Callback when moving the mouse when reordering columns // Callback when moving the mouse when reordering columns
const onReorderMouseMove = e => { const onReorderMouseMove = e => {
// Immediately handle the current position // Immediately handle the current position
const x = e.clientX const { x } = parseEventLocation(e)
reorder.update(state => ({ reorder.update(state => ({
...state, ...state,
latestX: x, latestX: x,
@ -86,8 +94,8 @@ export const createActions = context => {
// Check if we need to start auto-scrolling // Check if we need to start auto-scrolling
const $reorder = get(reorder) const $reorder = get(reorder)
const proximityCutoff = 140 const proximityCutoff = Math.min(140, get(width) / 6)
const speedFactor = 8 const speedFactor = 16
const rightProximity = Math.max(0, $reorder.gridLeft + $reorder.width - x) const rightProximity = Math.max(0, $reorder.gridLeft + $reorder.width - x)
const leftProximity = Math.max(0, x - $reorder.gridLeft) const leftProximity = Math.max(0, x - $reorder.gridLeft)
if (rightProximity < proximityCutoff) { if (rightProximity < proximityCutoff) {
@ -158,19 +166,22 @@ export const createActions = context => {
// Ensure auto-scrolling is stopped // Ensure auto-scrolling is stopped
stopAutoScroll() stopAutoScroll()
// Swap position of columns
let { sourceColumn, targetColumn } = get(reorder)
moveColumn(sourceColumn, targetColumn)
// Reset state
reorder.set(reorderInitialState)
// Remove event handlers // Remove event handlers
document.removeEventListener("mousemove", onReorderMouseMove) document.removeEventListener("mousemove", onReorderMouseMove)
document.removeEventListener("mouseup", stopReordering) document.removeEventListener("mouseup", stopReordering)
document.removeEventListener("touchmove", onReorderMouseMove)
document.removeEventListener("touchend", stopReordering)
document.removeEventListener("touchcancel", stopReordering)
// Save column changes // Ensure there's actually a change
await columns.actions.saveChanges() let { sourceColumn, targetColumn } = get(reorder)
if (sourceColumn !== targetColumn) {
moveColumn(sourceColumn, targetColumn)
await columns.actions.saveChanges()
}
// Reset state
reorder.set(reorderInitialState)
} }
// Moves a column after another columns. // Moves a column after another columns.
@ -185,8 +196,7 @@ export const createActions = context => {
if (--targetIdx < sourceIdx) { if (--targetIdx < sourceIdx) {
targetIdx++ targetIdx++
} }
state.splice(targetIdx, 0, removed[0]) return state.toSpliced(targetIdx, 0, removed[0])
return state.slice()
}) })
} }

View file

@ -1,5 +1,6 @@
import { writable, get, derived } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { MinColumnWidth, DefaultColumnWidth } from "../lib/constants" import { MinColumnWidth, DefaultColumnWidth } from "../lib/constants"
import { parseEventLocation } from "../lib/utils"
const initialState = { const initialState = {
initialMouseX: null, initialMouseX: null,
@ -24,8 +25,11 @@ export const createActions = context => {
// Starts resizing a certain column // Starts resizing a certain column
const startResizing = (column, e) => { const startResizing = (column, e) => {
const { x } = parseEventLocation(e)
// Prevent propagation to stop reordering triggering // Prevent propagation to stop reordering triggering
e.stopPropagation() e.stopPropagation()
e.preventDefault()
ui.actions.blur() ui.actions.blur()
// Find and cache index // Find and cache index
@ -39,7 +43,7 @@ export const createActions = context => {
width: column.width, width: column.width,
left: column.left, left: column.left,
initialWidth: column.width, initialWidth: column.width,
initialMouseX: e.clientX, initialMouseX: x,
column: column.name, column: column.name,
columnIdx, columnIdx,
}) })
@ -47,12 +51,16 @@ export const createActions = context => {
// Add mouse event listeners to handle resizing // Add mouse event listeners to handle resizing
document.addEventListener("mousemove", onResizeMouseMove) document.addEventListener("mousemove", onResizeMouseMove)
document.addEventListener("mouseup", stopResizing) document.addEventListener("mouseup", stopResizing)
document.addEventListener("touchmove", onResizeMouseMove)
document.addEventListener("touchend", stopResizing)
document.addEventListener("touchcancel", stopResizing)
} }
// Handler for moving the mouse to resize columns // Handler for moving the mouse to resize columns
const onResizeMouseMove = e => { const onResizeMouseMove = e => {
const { initialMouseX, initialWidth, width, columnIdx } = get(resize) const { initialMouseX, initialWidth, width, columnIdx } = get(resize)
const dx = e.clientX - initialMouseX const { x } = parseEventLocation(e)
const dx = x - initialMouseX
const newWidth = Math.round(Math.max(MinColumnWidth, initialWidth + dx)) const newWidth = Math.round(Math.max(MinColumnWidth, initialWidth + dx))
// Ignore small changes // Ignore small changes
@ -87,6 +95,9 @@ export const createActions = context => {
resize.set(initialState) resize.set(initialState)
document.removeEventListener("mousemove", onResizeMouseMove) document.removeEventListener("mousemove", onResizeMouseMove)
document.removeEventListener("mouseup", stopResizing) document.removeEventListener("mouseup", stopResizing)
document.removeEventListener("touchmove", onResizeMouseMove)
document.removeEventListener("touchend", stopResizing)
document.removeEventListener("touchcancel", stopResizing)
// Persist width if it changed // Persist width if it changed
if ($resize.width !== $resize.initialWidth) { if ($resize.width !== $resize.initialWidth) {

View file

@ -196,6 +196,20 @@ export const createActions = context => {
// Handles validation errors from the rows API and updates local validation // Handles validation errors from the rows API and updates local validation
// state, storing error messages against relevant cells // state, storing error messages against relevant cells
const handleValidationError = (rowId, error) => { const handleValidationError = (rowId, error) => {
// If the server doesn't reply with a valid error, assume that the source
// of the error is the focused cell's column
if (!error?.json?.validationErrors && error?.message) {
const focusedColumn = get(focusedCellId)?.split("-")[1]
if (focusedColumn) {
error = {
json: {
validationErrors: {
[focusedColumn]: error.message,
},
},
}
}
}
if (error?.json?.validationErrors) { if (error?.json?.validationErrors) {
// Normal validation errors // Normal validation errors
const keys = Object.keys(error.json.validationErrors) const keys = Object.keys(error.json.validationErrors)
@ -214,11 +228,19 @@ export const createActions = context => {
// Process errors for columns that we have // Process errors for columns that we have
for (let column of erroredColumns) { for (let column of erroredColumns) {
// Ensure we have a valid error to display
let err = error.json.validationErrors[column]
if (Array.isArray(err)) {
err = err[0]
}
if (typeof err !== "string" || !err.length) {
error = "Something went wrong"
}
// Set error against the cell
validation.actions.setError( validation.actions.setError(
`${rowId}-${column}`, `${rowId}-${column}`,
`${column} ${error.json.validationErrors[column]}` Helpers.capitalise(err)
) )
// Ensure the column is visible // Ensure the column is visible
const index = $columns.findIndex(x => x.name === column) const index = $columns.findIndex(x => x.name === column)
if (index !== -1 && !$columns[index].visible) { if (index !== -1 && !$columns[index].visible) {
@ -523,6 +545,7 @@ export const initialise = context => {
previousFocusedCellId, previousFocusedCellId,
rows, rows,
validation, validation,
focusedCellId,
} = context } = context
// Wipe the row change cache when changing row // Wipe the row change cache when changing row
@ -537,12 +560,22 @@ export const initialise = context => {
// Ensure any unsaved changes are saved when changing cell // Ensure any unsaved changes are saved when changing cell
previousFocusedCellId.subscribe(async id => { previousFocusedCellId.subscribe(async id => {
const rowId = id?.split("-")[0] if (!id) {
const hasErrors = validation.actions.rowHasErrors(rowId) return
const hasChanges = Object.keys(get(rowChangeCache)[rowId] || {}).length > 0 }
const isSavingChanges = get(inProgressChanges)[rowId] // Stop if we changed row
if (rowId && !hasErrors && hasChanges && !isSavingChanges) { const oldRowId = id.split("-")[0]
await rows.actions.applyRowChanges(rowId) const oldColumn = id.split("-")[1]
const newRowId = get(focusedCellId)?.split("-")[0]
if (oldRowId !== newRowId) {
return
}
// Otherwise we just changed cell in the same row
const hasChanges = oldColumn in (get(rowChangeCache)[oldRowId] || {})
const hasErrors = validation.actions.rowHasErrors(oldRowId)
const isSavingChanges = get(inProgressChanges)[oldRowId]
if (oldRowId && !hasErrors && hasChanges && !isSavingChanges) {
await rows.actions.applyRowChanges(oldRowId)
} }
}) })
} }

View file

@ -98,7 +98,7 @@ export const deriveStores = context => {
// Derive whether we should use the compact UI, depending on width // Derive whether we should use the compact UI, depending on width
const compact = derived([stickyColumn, width], ([$stickyColumn, $width]) => { const compact = derived([stickyColumn, width], ([$stickyColumn, $width]) => {
return ($stickyColumn?.width || 0) + $width + GutterWidth < 1100 return ($stickyColumn?.width || 0) + $width + GutterWidth < 800
}) })
return { return {

View file

@ -1,7 +1,6 @@
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { import {
MaxCellRenderHeight, MaxCellRenderOverflow,
MaxCellRenderWidthOverflow,
MinColumnWidth, MinColumnWidth,
ScrollBarSize, ScrollBarSize,
} from "../lib/constants" } from "../lib/constants"
@ -95,11 +94,11 @@ export const deriveStores = context => {
// Compute the last row index with space to render popovers below it // Compute the last row index with space to render popovers below it
const minBottom = const minBottom =
$height - ScrollBarSize * 3 - MaxCellRenderHeight + offset $height - ScrollBarSize * 3 - MaxCellRenderOverflow + offset
const lastIdx = Math.floor(minBottom / $rowHeight) const lastIdx = Math.floor(minBottom / $rowHeight)
// Compute the first row index with space to render popovers above it // Compute the first row index with space to render popovers above it
const minTop = MaxCellRenderHeight + offset const minTop = MaxCellRenderOverflow + offset
const firstIdx = Math.ceil(minTop / $rowHeight) const firstIdx = Math.ceil(minTop / $rowHeight)
// Use the greater of the two indices so that we prefer content below, // Use the greater of the two indices so that we prefer content below,
@ -117,7 +116,7 @@ export const deriveStores = context => {
let inversionIdx = $visibleColumns.length let inversionIdx = $visibleColumns.length
for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) { for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) { if (rightEdge + MaxCellRenderOverflow <= cutoff) {
break break
} }
} }

View file

@ -4,7 +4,7 @@
export { OperatorOptions, SqlNumberTypeRangeMap } from "@budibase/shared-core" export { OperatorOptions, SqlNumberTypeRangeMap } from "@budibase/shared-core"
export { Feature as Features } from "@budibase/types" export { Feature as Features } from "@budibase/types"
import { BpmCorrelationKey } from "@budibase/shared-core" import { BpmCorrelationKey } from "@budibase/shared-core"
import { FieldType, FieldTypeSubtypes } from "@budibase/types" import { FieldType, BBReferenceFieldSubType } from "@budibase/types"
// Cookie names // Cookie names
export const Cookies = { export const Cookies = {
@ -134,7 +134,7 @@ export const TypeIconMap = {
[FieldType.USER]: "User", [FieldType.USER]: "User",
[FieldType.USERS]: "UserGroup", [FieldType.USERS]: "UserGroup",
[FieldType.BB_REFERENCE]: { [FieldType.BB_REFERENCE]: {
[FieldTypeSubtypes.BB_REFERENCE.USER]: "User", [BBReferenceFieldSubType.USER]: "User",
[FieldTypeSubtypes.BB_REFERENCE.USERS]: "UserGroup", [BBReferenceFieldSubType.USERS]: "UserGroup",
}, },
} }

View file

@ -8,6 +8,10 @@ const isBetterSample = (newValue, oldValue) => {
return true return true
} }
if (oldValue != null && newValue == null) {
return false
}
// Don't change type // Don't change type
const oldType = typeof oldValue const oldType = typeof oldValue
const newType = typeof newValue const newType = typeof newValue

@ -1 +1 @@
Subproject commit 06b1064f7e2f7cac5d4bef2ee999796a2a1f0f2c Subproject commit 479879246aac5dd3073cc695945c62c41fae5b0e

View file

@ -61,14 +61,17 @@
"@google-cloud/firestore": "6.8.0", "@google-cloud/firestore": "6.8.0",
"@koa/router": "8.0.8", "@koa/router": "8.0.8",
"@socket.io/redis-adapter": "^8.2.1", "@socket.io/redis-adapter": "^8.2.1",
"@types/xml2js": "^0.4.14",
"airtable": "0.10.1", "airtable": "0.10.1",
"arangojs": "7.2.0", "arangojs": "7.2.0",
"archiver": "7.0.1", "archiver": "7.0.1",
"aws-sdk": "2.1030.0", "aws-sdk": "2.1030.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bl": "^6.0.12",
"bull": "4.10.1", "bull": "4.10.1",
"chokidar": "3.5.3", "chokidar": "3.5.3",
"content-disposition": "^0.5.4",
"cookies": "0.8.0", "cookies": "0.8.0",
"csvtojson": "2.0.10", "csvtojson": "2.0.10",
"curlconverter": "3.21.0", "curlconverter": "3.21.0",
@ -95,7 +98,7 @@
"memorystream": "0.3.1", "memorystream": "0.3.1",
"mongodb": "^6.3.0", "mongodb": "^6.3.0",
"mssql": "10.0.1", "mssql": "10.0.1",
"mysql2": "3.5.2", "mysql2": "3.9.7",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"object-sizeof": "2.6.1", "object-sizeof": "2.6.1",
"open": "8.4.0", "open": "8.4.0",

View file

@ -76,7 +76,7 @@ function writeFile(output: any, filename: string) {
console.log(`Wrote spec to ${path}`) console.log(`Wrote spec to ${path}`)
return path return path
} catch (err) { } catch (err) {
console.error(err) console.error("Error writing spec file", err)
} }
} }

View file

@ -6,7 +6,19 @@ import sdk from "../../../sdk"
import tk from "timekeeper" import tk from "timekeeper"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import { QueryPreview, SourceName } from "@budibase/types" import {
Datasource,
FieldSchema,
BBReferenceFieldSubType,
FieldType,
QueryPreview,
RelationshipType,
SourceName,
Table,
TableSchema,
} from "@budibase/types"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { tableForDatasource } from "../../../tests/utilities/structures"
tk.freeze(mocks.date.MOCK_DATE) tk.freeze(mocks.date.MOCK_DATE)
@ -223,4 +235,152 @@ describe("/datasources", () => {
}) })
}) })
}) })
describe.each([
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
[DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)],
[DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)],
[DatabaseName.MARIADB, getDatasource(DatabaseName.MARIADB)],
])("fetch schema (%s)", (_, dsProvider) => {
beforeAll(async () => {
datasource = await config.api.datasource.create(await dsProvider)
})
it("fetching schema will not drop tables or columns", async () => {
const datasourceId = datasource!._id!
const simpleTable = await config.api.table.save(
tableForDatasource(datasource, {
name: "simple",
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
},
})
)
type SupportedSqlTypes =
| FieldType.STRING
| FieldType.BARCODEQR
| FieldType.LONGFORM
| FieldType.OPTIONS
| FieldType.DATETIME
| FieldType.NUMBER
| FieldType.BOOLEAN
| FieldType.FORMULA
| FieldType.BIGINT
| FieldType.BB_REFERENCE
| FieldType.LINK
| FieldType.ARRAY
const fullSchema: {
[type in SupportedSqlTypes]: FieldSchema & { type: type }
} = {
[FieldType.STRING]: {
name: "string",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
[FieldType.LONGFORM]: {
name: "longform",
type: FieldType.LONGFORM,
},
[FieldType.OPTIONS]: {
name: "options",
type: FieldType.OPTIONS,
constraints: {
presence: { allowEmpty: false },
},
},
[FieldType.NUMBER]: {
name: "number",
type: FieldType.NUMBER,
},
[FieldType.BOOLEAN]: {
name: "boolean",
type: FieldType.BOOLEAN,
},
[FieldType.ARRAY]: {
name: "array",
type: FieldType.ARRAY,
},
[FieldType.DATETIME]: {
name: "datetime",
type: FieldType.DATETIME,
dateOnly: true,
timeOnly: false,
},
[FieldType.LINK]: {
name: "link",
type: FieldType.LINK,
tableId: simpleTable._id!,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "link",
},
[FieldType.FORMULA]: {
name: "formula",
type: FieldType.FORMULA,
formula: "any formula",
},
[FieldType.BARCODEQR]: {
name: "barcodeqr",
type: FieldType.BARCODEQR,
},
[FieldType.BIGINT]: {
name: "bigint",
type: FieldType.BIGINT,
},
[FieldType.BB_REFERENCE]: {
name: "bb_reference",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}
await config.api.table.save(
tableForDatasource(datasource, {
name: "full",
schema: fullSchema,
})
)
const persisted = await config.api.datasource.get(datasourceId)
await config.api.datasource.fetchSchema(datasourceId)
const updated = await config.api.datasource.get(datasourceId)
const expected: Datasource = {
...persisted,
entities:
persisted?.entities &&
Object.entries(persisted.entities).reduce<Record<string, Table>>(
(acc, [tableName, table]) => {
acc[tableName] = {
...table,
primaryDisplay: expect.not.stringMatching(
new RegExp(`^${table.primaryDisplay || ""}$`)
),
schema: Object.entries(table.schema).reduce<TableSchema>(
(acc, [fieldName, field]) => {
acc[fieldName] = expect.objectContaining({
...field,
})
return acc
},
{}
),
}
return acc
},
{}
),
_rev: expect.any(String),
}
expect(updated).toEqual(expected)
})
})
}) })

View file

@ -13,7 +13,7 @@ import {
DeleteRow, DeleteRow,
FieldSchema, FieldSchema,
FieldType, FieldType,
FieldTypeSubtypes, BBReferenceFieldSubType,
FormulaType, FormulaType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
NumberFieldMetadata, NumberFieldMetadata,
@ -1015,12 +1015,12 @@ describe.each([
user: { user: {
name: "user", name: "user",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldTypeSubtypes.BB_REFERENCE.USER, subtype: BBReferenceFieldSubType.USER,
}, },
users: { users: {
name: "users", name: "users",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldTypeSubtypes.BB_REFERENCE.USERS, subtype: BBReferenceFieldSubType.USERS,
}, },
}), }),
() => config.createUser(), () => config.createUser(),

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