1
0
Fork 0
mirror of synced 2024-08-23 05:51:29 +12:00

Merge branch 'master' into feature/changelog-in-builder

This commit is contained in:
Andrew Kingston 2024-05-13 13:37:23 +01:00 committed by GitHub
commit 824893cbcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
217 changed files with 5629 additions and 2569 deletions

View file

@ -1,4 +1,5 @@
{
"root": true,
"env": {
"browser": true,
"es6": true,
@ -53,7 +54,8 @@
"ignoreRestSiblings": true
}
],
"local-rules/no-budibase-imports": "error"
"no-redeclare": "off",
"@typescript-eslint/no-redeclare": "error"
}
},
{

View file

@ -92,8 +92,6 @@ jobs:
test-libraries:
runs-on: ubuntu-latest
env:
REUSE_CONTAINERS: true
steps:
- name: Checkout repo
uses: actions/checkout@v4
@ -150,8 +148,6 @@ jobs:
test-server:
runs-on: budi-tubby-tornado-quad-core-150gb
env:
REUSE_CONTAINERS: true
steps:
- name: Checkout repo
uses: actions/checkout@v4
@ -174,7 +170,8 @@ jobs:
docker pull mongo:7.0-jammy &
docker pull mariadb:lts &
docker pull testcontainers/ryuk:0.5.1 &
docker pull budibase/couchdb:v3.2.1-sql &
docker pull budibase/couchdb:v3.2.1-sqs &
docker pull minio/minio &
docker pull redis &
wait $(jobs -p)

View file

@ -12,4 +12,5 @@ packages/pro/coverage
packages/account-portal/packages/ui/build
packages/account-portal/packages/ui/.routify
packages/account-portal/packages/server/build
packages/account-portal/packages/server/coverage
**/*.ivm.bundle.js

View file

@ -2,7 +2,7 @@
apiVersion: {{ ternary "autoscaling/v2" "autoscaling/v2beta2" (.Capabilities.APIVersions.Has "autoscaling/v2") }}
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "budibase.fullname" . }}-apps
name: {{ include "budibase.fullname" . }}-automation-worker
labels:
{{- include "budibase.labels" . | nindent 4 }}
spec:

View file

@ -1,19 +1,52 @@
import { GenericContainer, Wait } from "testcontainers"
import {
GenericContainer,
Wait,
getContainerRuntimeClient,
} from "testcontainers"
import { ContainerInfo } from "dockerode"
import path from "path"
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() {
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
// shared container to get started multiple times despite having an
// identical reuse hash. To avoid that, we do a filesystem-based lock so
// that only one globalSetup.ts is running at a time.
lockfile.lockSync(lockPath)
}
// 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
// identical reuse hash. To avoid that, we do a filesystem-based lock so
// that only one globalSetup.ts is running at a time.
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 {
let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
const couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
.withExposedPorts(5984, 4984)
.withEnvironment({
COUCHDB_PASSWORD: "budibase",
@ -28,20 +61,29 @@ export default async function setup() {
target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
},
])
.withLabels({ "com.budibase": "true" })
.withReuse()
.withWaitStrategy(
Wait.forSuccessfulCommand(
"curl http://budibase:budibase@localhost:5984/_up"
).withStartupTimeout(20000)
)
if (process.env.REUSE_CONTAINERS) {
couchdb = couchdb.withReuse()
}
const minio = new GenericContainer("minio/minio")
.withExposedPorts(9000)
.withCommand(["server", "/data"])
.withEnvironment({
MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase",
})
.withLabels({ "com.budibase": "true" })
.withReuse()
.withWaitStrategy(
Wait.forHttp("/minio/health/ready", 9000).withStartupTimeout(10000)
)
await couchdb.start()
await Promise.all([couchdb.start(), minio.start()])
} finally {
if (process.env.REUSE_CONTAINERS) {
lockfile.unlockSync(lockPath)
}
lockfile.unlockSync(lockPath)
}
}

View file

@ -70,10 +70,10 @@ sed -i "s#COUCHDB_ERLANG_COOKIE#${COUCHDB_ERLANG_COOKIE}#g" /opt/clouseau/clouse
/opt/clouseau/bin/clouseau > /dev/stdout 2>&1 &
# Start CouchDB.
/docker-entrypoint.sh /opt/couchdb/bin/couchdb &
/docker-entrypoint.sh /opt/couchdb/bin/couchdb > /dev/stdout 2>&1 &
# Start SQS.
/opt/sqs/sqs --server "http://localhost:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 &
# Start SQS. Use 127.0.0.1 instead of localhost to avoid IPv6 issues.
/opt/sqs/sqs --server "http://127.0.0.1:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 > /dev/stdout 2>&1 &
# Wait for CouchDB to start up.
while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do

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/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 ./scripts/removeWorkspaceDependencies.sh package.json
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.11",
"version": "2.25.0",
"npmClient": "yarn",
"packages": [
"packages/*",

View file

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

View file

@ -69,7 +69,7 @@ async function populateUsersFromDB(
export async function getUser(
userId: string,
tenantId?: string,
populateUser?: any
populateUser?: (userId: string, tenantId: string) => Promise<User>
) {
if (!populateUser) {
populateUser = populateFromDB
@ -83,7 +83,7 @@ export async function getUser(
}
const client = await redis.getUserClient()
// try cache
let user = await client.get(userId)
let user: User = await client.get(userId)
if (!user) {
user = await populateUser(userId, tenantId)
await client.store(userId, user, EXPIRY_SECONDS)

View file

@ -281,7 +281,7 @@ export function doInScimContext(task: any) {
return newContext(updates, task)
}
export async function ensureSnippetContext() {
export async function ensureSnippetContext(enabled = !env.isTest()) {
const ctx = getCurrentContext()
// If we've already added snippets to context, continue
@ -292,7 +292,7 @@ export async function ensureSnippetContext() {
// Otherwise get snippets for this app and update context
let snippets: Snippet[] | undefined
const db = getAppDB()
if (db && !env.isTest()) {
if (db && enabled) {
const app = await db.get<App>(DocumentType.APP_METADATA)
snippets = app.snippets
}

View file

@ -3,11 +3,11 @@ import {
AllDocsResponse,
AnyDocument,
Database,
DatabaseOpts,
DatabaseQueryOpts,
DatabasePutOpts,
DatabaseCreateIndexOpts,
DatabaseDeleteIndexOpts,
DatabaseOpts,
DatabasePutOpts,
DatabaseQueryOpts,
Document,
isDocument,
RowResponse,
@ -17,7 +17,7 @@ import {
import { getCouchInfo } from "./connections"
import { directCouchUrlCall } from "./utils"
import { getPouchDB } from "./pouchDB"
import { WriteStream, ReadStream } from "fs"
import { ReadStream, WriteStream } from "fs"
import { newid } from "../../docIds/newid"
import { SQLITE_DESIGN_DOC_ID } from "../../constants"
import { DDInstrumentedDatabase } from "../instrumentation"
@ -38,6 +38,39 @@ function buildNano(couchInfo: { url: string; cookie: string }) {
type DBCall<T> = () => Promise<T>
class CouchDBError extends Error {
status: number
statusCode: number
reason: string
name: string
errid: string
error: string
description: string
constructor(
message: string,
info: {
status: number | undefined
statusCode: number | undefined
name: string
errid: string
description: string
reason: string
error: string
}
) {
super(message)
const statusCode = info.status || info.statusCode || 500
this.status = statusCode
this.statusCode = statusCode
this.reason = info.reason
this.name = info.name
this.errid = info.errid
this.description = info.description
this.error = info.error
}
}
export function DatabaseWithConnection(
dbName: string,
connection: string,
@ -119,7 +152,7 @@ export class DatabaseImpl implements Database {
} catch (err: any) {
// Handling race conditions
if (err.statusCode !== 412) {
throw err
throw new CouchDBError(err.message, err)
}
}
}
@ -138,10 +171,9 @@ export class DatabaseImpl implements Database {
if (err.statusCode === 404 && err.reason === DATABASE_NOT_FOUND) {
await this.checkAndCreateDb()
return await this.performCall(call)
} else if (err.statusCode) {
err.status = err.statusCode
}
throw err
// stripping the error down the props which are safe/useful, drop everything else
throw new CouchDBError(`CouchDB error: ${err.message}`, err)
}
}
@ -288,7 +320,7 @@ export class DatabaseImpl implements Database {
if (err.statusCode === 404) {
return
} else {
throw { ...err, status: err.statusCode }
throw new CouchDBError(err.message, err)
}
}
}

View file

@ -12,6 +12,10 @@ import { dataFilters } from "@budibase/shared-core"
export const removeKeyNumbering = dataFilters.removeKeyNumbering
function isEmpty(value: any) {
return value == null || value === ""
}
/**
* Class to build lucene query URLs.
* Optionally takes a base lucene query object.
@ -282,15 +286,14 @@ export class QueryBuilder<T> {
}
const equal = (key: string, value: any) => {
// 0 evaluates to false, which means we would return all rows if we don't check it
if (!value && value !== 0) {
if (isEmpty(value)) {
return null
}
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
}
const contains = (key: string, value: any, mode = "AND") => {
if (!value || (Array.isArray(value) && value.length === 0)) {
if (isEmpty(value)) {
return null
}
if (!Array.isArray(value)) {
@ -306,7 +309,7 @@ export class QueryBuilder<T> {
}
const fuzzy = (key: string, value: any) => {
if (!value) {
if (isEmpty(value)) {
return null
}
value = builder.preprocess(value, {
@ -328,7 +331,7 @@ export class QueryBuilder<T> {
}
const oneOf = (key: string, value: any) => {
if (!value) {
if (isEmpty(value)) {
return `*:*`
}
if (!Array.isArray(value)) {
@ -386,7 +389,7 @@ export class QueryBuilder<T> {
// Construct the actual lucene search query string from JSON structure
if (this.#query.string) {
build(this.#query.string, (key: string, value: any) => {
if (!value) {
if (isEmpty(value)) {
return null
}
value = builder.preprocess(value, {
@ -399,7 +402,7 @@ export class QueryBuilder<T> {
}
if (this.#query.range) {
build(this.#query.range, (key: string, value: any) => {
if (!value) {
if (isEmpty(value)) {
return null
}
if (value.low == null || value.low === "") {
@ -421,7 +424,7 @@ export class QueryBuilder<T> {
}
if (this.#query.notEqual) {
build(this.#query.notEqual, (key: string, value: any) => {
if (!value) {
if (isEmpty(value)) {
return null
}
if (typeof value === "boolean") {
@ -431,10 +434,28 @@ export class QueryBuilder<T> {
})
}
if (this.#query.empty) {
build(this.#query.empty, (key: string) => `(*:* -${key}:["" TO *])`)
build(this.#query.empty, (key: string) => {
// Because the structure of an empty filter looks like this:
// { empty: { someKey: null } }
//
// The check inside of `build` does not set `allFiltersEmpty`, which results
// in weird behaviour when the empty filter is the only filter. We get around
// this by setting `allFiltersEmpty` to false here.
allFiltersEmpty = false
return `(*:* -${key}:["" TO *])`
})
}
if (this.#query.notEmpty) {
build(this.#query.notEmpty, (key: string) => `${key}:["" TO *]`)
build(this.#query.notEmpty, (key: string) => {
// Because the structure of a notEmpty filter looks like this:
// { notEmpty: { someKey: null } }
//
// The check inside of `build` does not set `allFiltersEmpty`, which results
// in weird behaviour when the empty filter is the only filter. We get around
// this by setting `allFiltersEmpty` to false here.
allFiltersEmpty = false
return `${key}:["" TO *]`
})
}
if (this.#query.oneOf) {
build(this.#query.oneOf, oneOf)

View file

@ -13,7 +13,7 @@ import { getGlobalDB, doInTenant } from "../context"
import { decrypt } from "../security/encryption"
import * as identity from "../context/identity"
import env from "../environment"
import { Ctx, EndpointMatcher, SessionCookie } from "@budibase/types"
import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
import { InvalidAPIKeyError, ErrorCode } from "../errors"
import tracer from "dd-trace"
@ -41,7 +41,10 @@ function finalise(ctx: any, opts: FinaliseOpts = {}) {
ctx.version = opts.version
}
async function checkApiKey(apiKey: string, populateUser?: Function) {
async function checkApiKey(
apiKey: string,
populateUser?: (userId: string, tenantId: string) => Promise<User>
) {
// check both the primary and the fallback internal api keys
// this allows for rotation
if (isValidInternalAPIKey(apiKey)) {
@ -128,6 +131,7 @@ export default function (
} else {
user = await getUser(userId, session.tenantId)
}
// @ts-ignore
user.csrfToken = session.csrfToken
if (session?.lastAccessedAt < timeMinusOneMinute()) {
@ -167,19 +171,25 @@ export default function (
authenticated = false
}
if (user) {
const isUser = (
user: any
): user is User & { budibaseAccess?: string } => {
return user && user.email
}
if (isUser(user)) {
tracer.setUser({
id: user?._id,
tenantId: user?.tenantId,
budibaseAccess: user?.budibaseAccess,
status: user?.status,
id: user._id!,
tenantId: user.tenantId,
budibaseAccess: user.budibaseAccess,
status: user.status,
})
}
// isAuthenticated is a function, so use a variable to be able to check authed state
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
if (user && user.email) {
if (isUser(user)) {
return identity.doInUserContext(user, ctx, next)
} else {
return next()

View file

@ -13,13 +13,14 @@ import { bucketTTLConfig, budibaseTempDir } from "./utils"
import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3"
const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created
const STATE = {
bucketCreationPromises: {},
}
const signedFilePrefix = "/files/signed"
export const SIGNED_FILE_PREFIX = "/files/signed"
type ListParams = {
ContinuationToken?: string
@ -40,8 +41,13 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike
}
type StreamUploadParams = BaseUploadParams & {
stream: ReadStream
export type StreamTypes =
| ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
}
const CONTENT_TYPE_MAP: any = {
@ -83,7 +89,7 @@ export function ObjectStore(
bucket: string,
opts: { presigning: boolean } = { presigning: false }
) {
const config: any = {
const config: AWS.S3.ClientConfiguration = {
s3ForcePathStyle: true,
signatureVersion: "v4",
apiVersion: "2006-03-01",
@ -174,11 +180,9 @@ export async function upload({
const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) {
if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) {
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
let contentType = type
@ -222,11 +226,9 @@ export async function streamUpload({
const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) {
if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) {
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
// Set content type for certain known extensions
@ -333,7 +335,7 @@ export function getPresignedUrl(
const signedUrl = new URL(url)
const path = signedUrl.pathname
const query = signedUrl.search
return `${signedFilePrefix}${path}${query}`
return `${SIGNED_FILE_PREFIX}${path}${query}`
}
}
@ -521,6 +523,26 @@ export async function getReadStream(
return client.getObject(params).createReadStream()
}
export async function getObjectMetadata(
bucket: string,
path: string
): Promise<HeadObjectOutput> {
bucket = sanitizeBucket(bucket)
path = sanitizeKey(path)
const client = ObjectStore(bucket)
const params = {
Bucket: bucket,
Key: path,
}
try {
return await client.headObject(params).promise()
} catch (err: any) {
throw new Error("Unable to retrieve metadata from object")
}
}
/*
Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract
the bucket and the path from it
@ -530,7 +552,9 @@ export function extractBucketAndPath(
): { bucket: string; path: string } | null {
const baseUrl = url.split("?")[0]
const regex = new RegExp(`^${signedFilePrefix}/(?<bucket>[^/]+)/(?<path>.+)$`)
const regex = new RegExp(
`^${SIGNED_FILE_PREFIX}/(?<bucket>[^/]+)/(?<path>.+)$`
)
const match = baseUrl.match(regex)
if (match && match.groups) {

View file

@ -1,9 +1,14 @@
import { join } from "path"
import path, { join } from "path"
import { tmpdir } from "os"
import fs from "fs"
import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"
import * as objectStore from "./objectStore"
import {
AutomationAttachment,
AutomationAttachmentContent,
BucketedContent,
} from "@budibase/types"
/****************************************************
* NOTE: When adding a new bucket - name *
* sure that S3 usages (like budibase-infra) *
@ -55,3 +60,50 @@ export const bucketTTLConfig = (
return params
}
async function processUrlAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent> {
const response = await fetch(attachment.url)
if (!response.ok || !response.body) {
throw new Error(`Unexpected response ${response.statusText}`)
}
const fallbackFilename = path.basename(new URL(attachment.url).pathname)
return {
filename: attachment.filename || fallbackFilename,
content: response.body,
}
}
export async function processObjectStoreAttachment(
attachment: AutomationAttachment
): Promise<BucketedContent> {
const result = objectStore.extractBucketAndPath(attachment.url)
if (result === null) {
throw new Error("Invalid signed URL")
}
const { bucket, path: objectPath } = result
const readStream = await objectStore.getReadStream(bucket, objectPath)
const fallbackFilename = path.basename(objectPath)
return {
bucket,
path: objectPath,
filename: attachment.filename || fallbackFilename,
content: readStream,
}
}
export async function processAutomationAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent | BucketedContent> {
const isFullyFormedUrl =
attachment.url?.startsWith("http://") ||
attachment.url?.startsWith("https://")
if (isFullyFormedUrl) {
return await processUrlAttachment(attachment)
} else {
return await processObjectStoreAttachment(attachment)
}
}

View file

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

View file

@ -1,34 +0,0 @@
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")
.filter(x => x.length > 0)
.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) {
@ -82,10 +86,18 @@ export function setupEnv(...envs: any[]) {
throw new Error("CouchDB SQL port not found")
}
const minio = getContainerByImage("minio/minio")
const minioPort = getExposedV4Port(minio, 9000)
if (!minioPort) {
throw new Error("Minio port not found")
}
const configs = [
{ key: "COUCH_DB_PORT", value: `${couchPort}` },
{ key: "COUCH_DB_URL", value: `http://127.0.0.1:${couchPort}` },
{ key: "COUCH_DB_SQL_URL", value: `http://127.0.0.1:${couchSqlPort}` },
{ key: "MINIO_URL", value: `http://127.0.0.1:${minioPort}` },
]
for (const config of configs.filter(x => !!x.value)) {

View file

@ -43,6 +43,7 @@
"@spectrum-css/avatar": "3.0.2",
"@spectrum-css/button": "3.0.1",
"@spectrum-css/buttongroup": "3.0.2",
"@spectrum-css/calendar": "3.2.7",
"@spectrum-css/checkbox": "3.0.2",
"@spectrum-css/dialog": "3.0.1",
"@spectrum-css/divider": "1.0.3",
@ -82,7 +83,6 @@
"dayjs": "^1.10.8",
"easymde": "^2.16.1",
"svelte-dnd-action": "^0.9.8",
"svelte-flatpickr": "3.2.3",
"svelte-portal": "^1.0.0"
},
"resolutions": {

View file

@ -38,7 +38,15 @@
<div use:getAnchor on:click={openMenu}>
<slot name="control" />
</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>
<slot />
</Menu>

View file

@ -1,13 +1,24 @@
// These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [
".flatpickr-calendar",
".spectrum-Popover",
".download-js-link",
".spectrum-Menu",
".date-time-popover",
]
// These class names will only trigger a callback when clicked if the registered
// component is not nested inside them. For example, clicking inside a modal
// will not close the modal, or clicking inside a popover will not close the
// popover.
const conditionallyIgnoredClasses = [
".spectrum-Underlay",
".drawer-wrapper",
".spectrum-Popover",
]
let clickHandlers = []
let candidateTarget
/**
* Handle a body click event
*/
// Processes a "click outside" event and invokes callbacks if our source element
// is valid
const handleClick = event => {
// Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) {
@ -21,41 +32,60 @@ const handleClick = event => {
// Process handlers
clickHandlers.forEach(handler => {
// Check that the click isn't inside the target
if (handler.element.contains(event.target)) {
return
}
// Ignore clicks for modals, unless the handler is registered from a modal
const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null
const clickInModal = event.target.closest(".spectrum-Underlay") != null
if (clickInModal && !sourceInModal) {
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
// Ignore clicks for certain classes unless we're nested inside them
for (let className of conditionallyIgnoredClasses) {
const sourceInside = handler.anchor.closest(className) != null
const clickInside = event.target.closest(className) != null
if (clickInside && !sourceInside) {
return
}
}
handler.callback?.(event)
})
}
document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("mousedown", handleClick, true)
// On mouse up we only trigger a "click outside" callback if we targetted the
// same element that we did on mouse down. This fixes all sorts of issues where
// we get annoying callbacks firing when we drag to select text.
const handleMouseUp = e => {
if (candidateTarget === e.target) {
handleClick(e)
}
candidateTarget = null
}
// On mouse down we store which element was targetted for comparison later
const handleMouseDown = e => {
// Only handle the primary mouse button here.
// We handle context menu (right click) events in another handler.
if (e.button !== 0) {
return
}
candidateTarget = e.target
// Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener
document.removeEventListener("mouseup", handleMouseUp)
document.addEventListener("mouseup", handleMouseUp, true)
}
// Global singleton listeners for our events
document.addEventListener("mousedown", handleMouseDown)
document.addEventListener("contextmenu", handleClick)
/**
* Adds or updates a click handler
*/
const updateHandler = (id, element, anchor, callback, allowedType) => {
const updateHandler = (id, element, anchor, callback) => {
let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback, allowedType })
clickHandlers.push({ id, element, anchor, callback })
} else {
existingHandler.callback = callback
}
@ -82,8 +112,7 @@ export default (element, opts) => {
const callback =
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
const anchor = newOpts?.anchor || element
const allowedType = newOpts?.allowedType || "click"
updateHandler(id, element, anchor, callback, allowedType)
updateHandler(id, element, anchor, callback)
}
update(opts)
return {

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) {
let resizeObserver
let latestOpts = opts
@ -19,6 +38,8 @@ export default function positionDropdown(element, opts) {
useAnchorWidth,
offset = 5,
customUpdate,
resizable,
wrap,
} = opts
if (!anchor) {
return
@ -27,56 +48,159 @@ export default function positionDropdown(element, opts) {
// Compute bounds
const anchorBounds = anchor.getBoundingClientRect()
const elementBounds = element.getBoundingClientRect()
const winWidth = window.innerWidth
const winHeight = window.innerHeight
const screenOffset = 8
let styles = {
maxHeight: null,
minWidth,
maxWidth,
maxHeight,
minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null,
top: null,
}
// Ignore all our logic for custom logic
if (typeof customUpdate === "function") {
styles = customUpdate(anchorBounds, elementBounds, {
...styles,
offset: opts.offset,
})
} else {
// Determine vertical styles
if (align === "right-outside" || align === "left-outside") {
styles.top =
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2
styles.maxHeight = maxHeight
if (styles.top + elementBounds.height > window.innerHeight) {
styles.top = window.innerHeight - elementBounds.height
}
} else if (
window.innerHeight - anchorBounds.bottom <
(maxHeight || 100)
) {
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
}
// Otherwise position ourselves as normal
else {
// Checks if we overflow off the screen. We only report that we overflow
// when the alternative dimension is larger than the one we are checking.
const doesXOverflow = () => {
const overflows = styles.left + elementBounds.width > winWidth
return overflows && anchorBounds.left > winWidth - anchorBounds.right
}
const doesYOverflow = () => {
const overflows = styles.top + elementBounds.height > winHeight
return overflows && anchorBounds.top > winHeight - anchorBounds.bottom
}
// Determine horizontal styles
if (!maxWidth && useAnchorWidth) {
styles.maxWidth = anchorBounds.width
// Applies a dynamic max height constraint if appropriate
const applyMaxHeight = height => {
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") {
styles.left =
anchorBounds.left + anchorBounds.width - elementBounds.width
applyXStrategy(Strategies.EndToEnd)
} else if (align === "right-outside") {
styles.left = anchorBounds.right + offset
applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset
applyXStrategy(Strategies.EndToStart)
} 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

@ -8,6 +8,8 @@
export let size = "S"
export let extraButtonText
export let extraButtonAction
export let extraLinkText
export let extraLinkAction
export let showCloseButton = true
let show = true
@ -28,8 +30,13 @@
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
<div class="spectrum-Toast-body">
<div class="spectrum-Toast-content">
<div class="spectrum-Toast-content row-content">
<slot />
{#if extraLinkText}
<button class="link" on:click={extraLinkAction}>
<u>{extraLinkText}</u>
</button>
{/if}
</div>
{#if extraButtonText && extraButtonAction}
<button
@ -73,4 +80,23 @@
.spectrum-Button {
border: 1px solid rgba(255, 255, 255, 0.2);
}
.row-content {
display: flex;
}
.link {
background: none;
border: none;
margin: 0;
margin-left: 0.5em;
padding: 0;
cursor: pointer;
color: white;
font-weight: 600;
}
u {
font-weight: 600;
}
</style>

View file

@ -11,6 +11,7 @@
export let error = null
export let validate = null
export let suffix = null
export let validateOn = "change"
const dispatch = createEventDispatcher()
@ -24,7 +25,16 @@
const newValue = e.target.value
dispatch("change", newValue)
value = newValue
if (validate) {
if (validate && (error || validateOn === "change")) {
error = validate(newValue)
}
}
const onBlur = e => {
focused = false
const newValue = e.target.value
dispatch("blur", newValue)
if (validate && validateOn === "blur") {
error = validate(newValue)
}
}
@ -61,7 +71,7 @@
type={type || "text"}
on:input={onChange}
on:focus={() => (focused = true)}
on:blur={() => (focused = false)}
on:blur={onBlur}
class:placeholder
bind:this={ref}
/>

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,252 @@
<script>
import { cleanInput } from "./utils"
import Select from "../../Select.svelte"
import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
import isoWeek from "dayjs/plugin/isoWeek"
dayjs.extend(isoWeek)
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("isoWeek")
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}
maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight}
maxHeight={240}
>
<div
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 CoreSwitch } from "./Switch.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 CoreStepper } from "./Stepper.svelte"
export { default as CoreRichTextField } from "./RichTextField.svelte"

View file

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

@ -7,11 +7,11 @@
export let narrower = false
export let noPadding = false
let sidePanelVisble = false
let sidePanelVisible = false
setContext("side-panel", {
open: () => (sidePanelVisble = true),
close: () => (sidePanelVisble = false),
open: () => (sidePanelVisible = true),
close: () => (sidePanelVisible = false),
})
</script>
@ -24,9 +24,9 @@
</div>
<div
id="side-panel"
class:visible={sidePanelVisble}
class:visible={sidePanelVisible}
use:clickOutside={() => {
sidePanelVisble = false
sidePanelVisible = false
}}
>
<slot name="side-panel" />

View file

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

View file

@ -1,4 +1,5 @@
import { helpers } from "@budibase/shared-core"
import dayjs from "dayjs"
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
import "@spectrum-css/icon/dist/index-vars.css"
// Components
// Form components
export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte"
export { default as TextArea } from "./Form/TextArea.svelte"
export { default as Select } from "./Form/Select.svelte"
export { default as Combobox } from "./Form/Combobox.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 DrawerContent } from "./Drawer/DrawerContent.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 Icon } from "./Icon/Icon.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 Popover } from "./Popover/Popover.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 Tooltip } from "./Tooltip/Tooltip.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 Menu } from "./Menu/Menu.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 SideNavigation } from "./SideNavigation/Navigation.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 Table } from "./Table/Table.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 TreeItem } from "./TreeView/Item.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 Badge } from "./Badge/Badge.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 MarkdownEditor } from "./Markdown/MarkdownEditor.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 ListItem } from "./List/ListItem.svelte"
export { default as IconSideNav } from "./IconSideNav/IconSideNav.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 File } from "./Form/File.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
export { default as BoldRenderer } from "./Table/BoldRenderer.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 Code } from "./Typography/Code.svelte"
// Core form components to be used elsewhere (standard components)
export * from "./Form/Core"
// Actions
export { default as autoResizeTextArea } from "./Actions/autoresize_textarea"
export { default as positionDropdown } from "./Actions/position_dropdown"
@ -110,6 +114,3 @@ export { banner, BANNER_TYPES } from "./Stores/banner"
// Helpers
export * as Helpers from "./helpers"
// Fancy form components
export * from "./FancyForm"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 804 KiB

View file

@ -48,6 +48,7 @@
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { FIELDS } from "constants/backend"
export let block
export let testData
@ -228,6 +229,10 @@
categoryName,
bindingName
) => {
const field = Object.values(FIELDS).find(
field => field.type === value.type && field.subtype === value.subtype
)
return {
readableBinding: bindingName
? `${bindingName}.${name}`
@ -238,7 +243,7 @@
icon,
category: categoryName,
display: {
type: value.type,
type: field?.name || value.type,
name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
},
@ -282,6 +287,7 @@
for (const key in table?.schema) {
schema[key] = {
type: table.schema[key].type,
subtype: table.schema[key].subtype,
}
}
// remove the original binding
@ -358,7 +364,8 @@
value.customType !== "cron" &&
value.customType !== "triggerSchema" &&
value.customType !== "automationFields" &&
value.type !== "attachment"
value.type !== "attachment" &&
value.type !== "attachment_single"
)
}

View file

@ -2,6 +2,8 @@
import { tables } from "stores/builder"
import { Select, Checkbox, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
@ -14,7 +16,6 @@
export let bindings
export let isTestModal
export let isUpdateRow
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
@ -26,15 +27,19 @@
$: {
table = $tables.list.find(table => table._id === value?.tableId)
schemaFields = Object.entries(table?.schema ?? {})
// surface the schema so the user can see it in the json
schemaFields.map(([, schema]) => {
// Just sorting attachment types to the bottom here for a cleaner UX
schemaFields = Object.entries(table?.schema ?? {}).sort(
([, schemaA], [, schemaB]) =>
(schemaA.type === "attachment") - (schemaB.type === "attachment")
)
schemaFields.forEach(([, schema]) => {
if (!schema.autocolumn && !value[schema.name]) {
value[schema.name] = ""
}
})
}
const onChangeTable = e => {
value["tableId"] = e.detail
dispatch("change", value)
@ -114,10 +119,16 @@
</div>
{#if schemaFields.length}
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"}
<div class="schema-fields">
{#if !schema.autocolumn}
<div
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<Label>{field}</Label>
<div class="field-width">
<div
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
{#if isTestModal}
<RowSelectorTypes
{isTestModal}

View file

@ -1,10 +1,12 @@
<script>
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import Editor from "components/integration/QueryEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let onChange
export let field
@ -22,6 +24,27 @@
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
const handleAttachmentParams = keyValuObj => {
let params = {}
if (
schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(keyValuObj).length === 0
) {
return []
}
if (!Array.isArray(keyValuObj)) {
keyValuObj = [keyValuObj]
}
if (keyValuObj.length) {
for (let param of keyValuObj) {
params[param.url] = param.filename
}
}
return params
}
</script>
{#if schemaHasOptions(schema) && schema.type !== "array"}
@ -77,6 +100,35 @@
on:change={e => onChange(e, field)}
useLabel={false}
/>
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
<div class="attachment-field-spacinng">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE
? e.detail.length > 0
? { url: e.detail[0].name, filename: e.detail[0].value }
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
field
)}
object={handleAttachmentParams(value[field])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(value[field]).length >= 1}
/>
</div>
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}
@ -90,3 +142,10 @@
title={schema.name}
/>
{/if}
<style>
.attachment-field-spacinng {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-l);
}
</style>

View file

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

View file

@ -1,7 +1,9 @@
<script>
import { createEventDispatcher } from "svelte"
import { ActionButton, Modal, ModalContent } from "@budibase/bbui"
import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
export let schema
export let filters
@ -10,7 +12,7 @@
const dispatch = createEventDispatcher()
let modal
let drawer
$: tempValue = filters || []
$: schemaFields = Object.entries(schema || {}).map(
@ -22,37 +24,53 @@
$: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
$: bindings = [
{
type: "context",
runtimeBinding: `${makePropSafe("now")}`,
readableBinding: `Date`,
category: "Date",
icon: "Date",
display: {
name: "Server date",
},
},
...getUserBindings(),
]
const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length
return count ? `Filter (${count})` : "Filter"
}
</script>
<ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}>
<ActionButton icon="Filter" quiet {disabled} on:click={drawer.show} {selected}>
{text}
</ActionButton>
<Modal bind:this={modal}>
<ModalContent
title="Filter"
confirmText="Save"
size="XL"
onConfirm={() => dispatch("change", tempValue)}
>
<div class="wrapper">
<FilterBuilder
allowBindings={false}
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
/>
</div>
</ModalContent>
</Modal>
<style>
.wrapper :global(.main) {
padding: 0;
}
</style>
<Drawer
bind:this={drawer}
title="Filtering"
on:drawerHide
on:drawerShow
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
dispatch("change", tempValue)
drawer.hide()
}}
>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
{bindings}
/>
</DrawerContent>
</Drawer>

View file

@ -55,7 +55,7 @@ export function getBindings({
)
}
const field = Object.values(FIELDS).find(
field => field.type === schema.type
field => field.type === schema.type && field.subtype === schema.subtype
)
const label = path == null ? column : `${path}.0.${column}`

View file

@ -12,8 +12,13 @@
OptionSelectDnD,
Layout,
AbsTooltip,
ProgressCircle,
} from "@budibase/bbui"
import { SWITCHABLE_TYPES, ValidColumnNameRegex } from "@budibase/shared-core"
import {
SWITCHABLE_TYPES,
ValidColumnNameRegex,
helpers,
} from "@budibase/shared-core"
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder"
@ -29,7 +34,11 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { FieldType, FieldSubtype, SourceName } from "@budibase/types"
import {
BBReferenceFieldSubType,
FieldType,
SourceName,
} from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
@ -41,8 +50,6 @@
const NUMBER_TYPE = FieldType.NUMBER
const JSON_TYPE = FieldType.JSON
const DATE_TYPE = FieldType.DATETIME
const USER_TYPE = FieldSubtype.USER
const USERS_TYPE = FieldSubtype.USERS
const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
@ -65,7 +72,6 @@
let savingColumn
let deleteColName
let jsonSchemaModal
let allowedTypes = []
let editableColumn = {
type: FIELDS.STRING.type,
constraints: FIELDS.STRING.constraints,
@ -173,6 +179,11 @@
SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id
(acc, field) => ({
@ -186,7 +197,10 @@
// don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase()
} else if (type === FieldType.BB_REFERENCE) {
} else if (
type === FieldType.BB_REFERENCE ||
type === FieldType.BB_REFERENCE_SINGLE
) {
return `${type}${subtype || ""}`.toUpperCase()
} else {
return type.toUpperCase()
@ -224,11 +238,6 @@
editableColumn.subtype,
editableColumn.autocolumn
)
allowedTypes = getAllowedTypes().map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
}
}
@ -243,11 +252,11 @@
}
async function saveColumn() {
savingColumn = true
if (errors?.length) {
return
}
savingColumn = true
let saveColumn = cloneDeep(editableColumn)
delete saveColumn.fieldId
@ -262,13 +271,6 @@
if (saveColumn.type !== LINK_TYPE) {
delete saveColumn.fieldName
}
if (isUsersColumn(saveColumn)) {
if (saveColumn.subtype === USER_TYPE) {
saveColumn.relationshipType = RelationshipType.ONE_TO_MANY
} else if (saveColumn.subtype === USERS_TYPE) {
saveColumn.relationshipType = RelationshipType.MANY_TO_MANY
}
}
try {
await tables.saveField({
@ -287,6 +289,8 @@
}
} catch (err) {
notifications.error(`Error saving column: ${err.message}`)
} finally {
savingColumn = false
}
}
@ -361,22 +365,36 @@
deleteColName = ""
}
function getAllowedTypes() {
function getAllowedTypes(datasource) {
if (originalName) {
const possibleTypes = (
SWITCHABLE_TYPES[field.type] || [editableColumn.type]
).map(t => t.toLowerCase())
let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) {
// This will handle old single users columns
return [
{
...FIELDS.USER,
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
]
} else if (
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === BBReferenceFieldSubType.USERS
) {
// This will handle old multi users columns
return [
{
...FIELDS.USERS,
subtype: BBReferenceFieldSubType.USERS,
},
]
}
return Object.entries(FIELDS)
.filter(([fieldType]) =>
possibleTypes.includes(fieldType.toLowerCase())
)
.filter(([_, field]) => possibleTypes.includes(field.type))
.map(([_, fieldDefinition]) => fieldDefinition)
}
const isUsers =
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === FieldSubtype.USERS
if (!externalTable) {
return [
FIELDS.STRING,
@ -393,7 +411,8 @@
FIELDS.LINK,
FIELDS.FORMULA,
FIELDS.JSON,
isUsers ? FIELDS.USERS : FIELDS.USER,
FIELDS.USER,
FIELDS.USERS,
FIELDS.AUTO,
]
} else {
@ -407,8 +426,12 @@
FIELDS.BOOLEAN,
FIELDS.FORMULA,
FIELDS.BIGINT,
isUsers ? FIELDS.USERS : FIELDS.USER,
FIELDS.USER,
]
if (datasource && datasource.source !== SourceName.GOOGLE_SHEETS) {
fields.push(FIELDS.USERS)
}
// no-sql or a spreadsheet
if (!externalTable || table.sql) {
fields = [...fields, FIELDS.LINK, FIELDS.ARRAY]
@ -482,13 +505,6 @@
return newError
}
function isUsersColumn(column) {
return (
column.type === FieldType.BB_REFERENCE &&
[FieldSubtype.USER, FieldSubtype.USERS].includes(column.subtype)
)
}
onMount(() => {
mounted = true
})
@ -513,6 +529,7 @@
/>
{/if}
<Select
placeholder={null}
disabled={!typeEnabled}
bind:value={editableColumn.fieldId}
on:change={onHandleTypeChange}
@ -686,20 +703,6 @@
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
>
{:else if isUsersColumn(editableColumn) && datasource?.source !== SourceName.GOOGLE_SHEETS}
<Toggle
value={editableColumn.subtype === FieldSubtype.USERS}
on:change={e =>
handleTypeChange(
makeFieldId(
FieldType.BB_REFERENCE,
e.detail ? FieldSubtype.USERS : FieldSubtype.USER
)
)}
disabled={!isCreating}
thin
text="Allow multiple users"
/>
{/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select
@ -734,7 +737,20 @@
<Button quiet warning text on:click={confirmDelete}>Delete</Button>
{/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button disabled={invalid} newStyles cta on:click={saveColumn}>Save</Button>
<Button
disabled={invalid || savingColumn}
newStyles
cta
on:click={saveColumn}
>
{#if savingColumn}
<div class="save-loading">
<ProgressCircle overBackground={true} size="S" />
</div>
{:else}
Save
{/if}
</Button>
</div>
<Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal
@ -799,4 +815,9 @@
cursor: pointer;
color: var(--spectrum-global-color-gray-900);
}
.save-loading {
display: flex;
justify-content: center;
}
</style>

View file

@ -13,7 +13,9 @@
onMount(() => subscribe("edit-column", editColumn))
</script>
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>
{#if editableColumn}
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>
{/if}

View file

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

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 {
display: flex;
align-items: center;
gap: 20px;
gap: var(--spacing-m);
}
.relationship-part {
flex-basis: 70%;
flex: 1 1 auto;
}
.relationship-type {
flex-basis: 30%;
flex: 0 0 128px;
}
</style>

View file

@ -4,6 +4,7 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import { FieldType } from "@budibase/types"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte"
@ -102,6 +103,8 @@
longform: value => !isJSBinding(value),
json: value => !isJSBinding(value),
boolean: isValidBoolean,
attachment: false,
attachment_single: false,
}
const isValid = value => {
@ -116,7 +119,16 @@
if (type === "json" && !isJSBinding(value)) {
return "json-slot-icon"
}
if (!["string", "number", "bigint", "barcodeqr"].includes(type)) {
if (
![
"string",
"number",
"bigint",
"barcodeqr",
"attachment",
"attachment_single",
].includes(type)
) {
return "slot-icon"
}
return ""
@ -157,7 +169,7 @@
{updateOnChange}
/>
{/if}
{#if !disabled && type !== "formula"}
{#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
<div
class={`icon ${getIconClass(value, type)}`}
on:click={() => {

View file

@ -1,8 +1,10 @@
<script>
import { Body, Label, Input } from "@budibase/bbui"
import { Body, Label } from "@budibase/bbui"
import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings
onMount(() => {
if (!parameters.confirm) {
@ -15,11 +17,18 @@
<Body size="S">Enter the message you wish to display to the user.</Body>
<div class="params">
<Label small>Title</Label>
<Input placeholder="Prompt User" bind:value={parameters.customTitleText} />
<DrawerBindableInput
placeholder="Title"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<Label small>Message</Label>
<Input
<DrawerBindableInput
placeholder="Are you sure you want to continue?"
bind:value={parameters.confirmText}
value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/>
</div>
</div>

View file

@ -47,4 +47,5 @@ export const FieldTypeToComponentMap = {
[FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner",
[FieldType.BB_REFERENCE]: "bbreferencefield",
[FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield",
}

View file

@ -21,26 +21,24 @@
const currentStep = derived(multiStepStore, state => state.currentStep)
const componentType = "@budibase/standard-components/multistepformblockstep"
setContext("multi-step-form-block", multiStepStore)
let cachedValue
let cachedInstance = {}
$: if (!isEqual(cachedValue, value)) {
cachedValue = value
}
$: if (!isEqual(componentInstance, cachedInstance)) {
cachedInstance = componentInstance
}
setContext("multi-step-form-block", multiStepStore)
$: stepCount = cachedValue?.length || 0
$: updateStore(stepCount)
$: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance)
$: emitCurrentStep($currentStep)
$: stepLabel = getStepLabel($multiStepStore)
$: stepDef = getDefinition(stepLabel)
$: stepSettings = cachedValue?.[$currentStep] || {}
$: savedInstance = cachedValue?.[$currentStep] || {}
$: defaults = Utils.buildMultiStepFormBlockDefaultProps({
_id: cachedInstance._id,
stepCount: $multiStepStore.stepCount,
@ -48,14 +46,16 @@
actionType: cachedInstance.actionType,
dataSource: cachedInstance.dataSource,
})
// For backwards compatibility we need to sometimes manually set base
// properties like _id and _component as we didn't used to save these
$: stepInstance = {
_id: Helpers.uuid(),
_component: componentType,
_id: savedInstance._id || Helpers.uuid(),
_component: savedInstance._component || componentType,
_instanceName: `Step ${currentStep + 1}`,
title: stepSettings.title ?? defaults?.title,
buttons: stepSettings.buttons || defaults?.buttons,
fields: stepSettings.fields,
desc: stepSettings.desc,
title: savedInstance.title ?? defaults?.title,
buttons: savedInstance.buttons || defaults?.buttons,
fields: savedInstance.fields,
desc: savedInstance.desc,
// Needed for field configuration
dataSource,
@ -92,7 +92,8 @@
}
const addStep = () => {
value = value.toSpliced($currentStep + 1, 0, {})
const newInstance = componentStore.createInstance(componentType)
value = value.toSpliced($currentStep + 1, 0, newInstance)
dispatch("change", value)
multiStepStore.update(state => ({
...state,

View file

@ -1,6 +1,6 @@
<script>
import { createEventDispatcher, getContext } from "svelte"
import { ActionButton } from "@budibase/bbui"
import { ActionButton, AbsTooltip } from "@budibase/bbui"
const multiStepStore = getContext("multi-step-form-block")
const dispatch = createEventDispatcher()
@ -28,45 +28,49 @@
</div>
{:else}
<div class="step-actions">
<ActionButton
size="S"
secondary
icon="ChevronLeft"
disabled={currentStep === 0}
on:click={() => {
stepAction("previousStep")
}}
tooltip={"Previous step"}
/>
<ActionButton
size="S"
secondary
disabled={currentStep === stepCount - 1}
icon="ChevronRight"
on:click={() => {
stepAction("nextStep")
}}
tooltip={"Next step"}
/>
<ActionButton
size="S"
secondary
icon="Close"
disabled={stepCount === 1}
on:click={() => {
stepAction("removeStep")
}}
tooltip={"Remove step"}
/>
<ActionButton
size="S"
secondary
icon="MultipleAdd"
on:click={() => {
stepAction("addStep")
}}
tooltip={"Add step"}
/>
<AbsTooltip text="Previous step" noWrap>
<ActionButton
size="S"
secondary
icon="ChevronLeft"
disabled={currentStep === 0}
on:click={() => {
stepAction("previousStep")
}}
/>
</AbsTooltip>
<AbsTooltip text="Next step" noWrap>
<ActionButton
size="S"
secondary
disabled={currentStep === stepCount - 1}
icon="ChevronRight"
on:click={() => {
stepAction("nextStep")
}}
/>
</AbsTooltip>
<AbsTooltip text="Remove step" noWrap>
<ActionButton
size="S"
secondary
icon="Close"
disabled={stepCount === 1}
on:click={() => {
stepAction("removeStep")
}}
/>
</AbsTooltip>
<AbsTooltip text="Add step" noWrap>
<ActionButton
size="S"
secondary
icon="MultipleAdd"
on:click={() => {
stepAction("addStep")
}}
/>
</AbsTooltip>
</div>
{/if}

View file

@ -75,6 +75,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
return createComponent(
"@budibase/standard-components/labelfield",
{
_id: column.field,
_instanceName: column.field,
active: column.active,
field: column.field,

View file

@ -65,6 +65,7 @@ describe("getColumns", () => {
it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_id: "three",
_instanceName: "three",
active: true,
columnType: "foo",
@ -73,6 +74,7 @@ describe("getColumns", () => {
label: "three label",
},
{
_id: "two",
_instanceName: "two",
active: true,
columnType: "foo",
@ -81,6 +83,7 @@ describe("getColumns", () => {
label: "two label",
},
{
_id: "one",
_instanceName: "one",
active: false,
columnType: "foo",
@ -91,6 +94,7 @@ describe("getColumns", () => {
])
expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four",
active: true,
columnType: "foo",
@ -115,6 +119,7 @@ describe("getColumns", () => {
it("returns all columns, with non-hidden columns automatically selected", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_id: "two",
_instanceName: "two",
active: true,
columnType: "foo",
@ -123,6 +128,7 @@ describe("getColumns", () => {
label: "two",
},
{
_id: "three",
_instanceName: "three",
active: true,
columnType: "foo",
@ -131,6 +137,7 @@ describe("getColumns", () => {
label: "three",
},
{
_id: "one",
_instanceName: "one",
active: false,
columnType: "foo",
@ -141,6 +148,7 @@ describe("getColumns", () => {
])
expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four",
active: true,
columnType: "foo",
@ -173,6 +181,7 @@ describe("getColumns", () => {
it("returns all columns, including those missing from the initial data", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_id: "three",
_instanceName: "three",
active: true,
columnType: "foo",
@ -181,6 +190,7 @@ describe("getColumns", () => {
label: "three label",
},
{
_id: "two",
_instanceName: "two",
active: false,
columnType: "foo",
@ -189,6 +199,7 @@ describe("getColumns", () => {
label: "two",
},
{
_id: "one",
_instanceName: "one",
active: false,
columnType: "foo",
@ -199,6 +210,7 @@ describe("getColumns", () => {
])
expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four",
active: true,
columnType: "foo",
@ -228,6 +240,7 @@ describe("getColumns", () => {
it("returns all valid columns, excluding those that aren't valid for the schema", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_id: "three",
_instanceName: "three",
active: true,
columnType: "foo",
@ -236,6 +249,7 @@ describe("getColumns", () => {
label: "three label",
},
{
_id: "two",
_instanceName: "two",
active: false,
columnType: "foo",
@ -244,6 +258,7 @@ describe("getColumns", () => {
label: "two",
},
{
_id: "one",
_instanceName: "one",
active: false,
columnType: "foo",
@ -254,6 +269,7 @@ describe("getColumns", () => {
])
expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four",
active: true,
columnType: "foo",
@ -318,6 +334,7 @@ describe("getColumns", () => {
beforeEach(ctx => {
ctx.updateSortable([
{
_id: "three",
_instanceName: "three",
active: true,
columnType: "foo",
@ -326,6 +343,7 @@ describe("getColumns", () => {
label: "three",
},
{
_id: "one",
_instanceName: "one",
active: true,
columnType: "foo",
@ -334,6 +352,7 @@ describe("getColumns", () => {
label: "one",
},
{
_id: "two",
_instanceName: "two",
active: false,
columnType: "foo",

View file

@ -37,6 +37,7 @@
export let customButtonText = null
export let keyBindings = false
export let allowJS = false
export let actionButtonDisabled = false
export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({
@ -189,7 +190,14 @@
{/if}
{#if !readOnly && !noAddButton}
<div>
<ActionButton icon="Add" secondary thin outline on:click={addEntry}>
<ActionButton
disabled={actionButtonDisabled}
icon="Add"
secondary
thin
outline
on:click={addEntry}
>
{#if customButtonText}
{customButtonText}
{:else}

View file

@ -25,6 +25,6 @@
name="field"
headings
options={SchemaTypeOptionsExpanded}
compare={(option, value) => option.type === value.type}
compare={(option, value) => option.type === value?.type}
/>
{/key}

View file

@ -695,7 +695,7 @@
menuItems={schemaMenuItems}
showMenu={!schemaReadOnly}
readOnly={schemaReadOnly}
compare={(option, value) => option.type === value.type}
compare={(option, value) => option.type === value?.type}
/>
</Tab>
{/if}

View file

@ -0,0 +1,43 @@
<script>
import "@spectrum-css/toast/dist/index-vars.css"
import Portal from "svelte-portal"
import { fly } from "svelte/transition"
import { Banner, BANNER_TYPES } from "@budibase/bbui"
import { licensing } from "stores/portal"
export let show = true
const oneDayInSeconds = 86400
$: license = $licensing.license
function daysUntilCancel() {
const cancelAt = license?.billing?.subscription?.cancelAt
const diffTime = Math.abs(cancelAt - new Date().getTime()) / 1000
return Math.floor(diffTime / oneDayInSeconds)
}
</script>
<Portal target=".banner-container">
<div class="banner">
{#if show}
<div transition:fly={{ y: -30 }}>
<Banner
type={BANNER_TYPES.INFO}
extraLinkText={"Please select a plan."}
extraLinkAction={$licensing.goToUpgradePage}
showCloseButton={false}
>
Your free trial will end in {daysUntilCancel()} days.
</Banner>
</div>
{/if}
</div>
</Portal>
<style>
.banner {
pointer-events: none;
width: 100%;
}
</style>

View file

@ -12,7 +12,7 @@ const defaultCacheFn = key => {
const upgradeAction = key => {
return defaultNavigateAction(
key,
"Upgrade Plan",
"Upgrade",
`${get(admin).accountPortalUrl}/portal/upgrade`
)
}

View file

@ -0,0 +1,66 @@
<script>
import { Modal, ModalContent } from "@budibase/bbui"
import FreeTrial from "../../../../assets/FreeTrial.svelte"
import { get } from "svelte/store"
import { auth, licensing } from "stores/portal"
import { API } from "api"
import { PlanType } from "@budibase/types"
import { sdk } from "@budibase/shared-core"
let freeTrialModal
$: planType = $licensing?.license?.plan?.type
$: showFreeTrialModal(planType, freeTrialModal)
const showFreeTrialModal = (planType, freeTrialModal) => {
if (
planType === PlanType.ENTERPRISE_BASIC_TRIAL &&
!$auth.user?.freeTrialConfirmedAt &&
sdk.users.isAdmin($auth.user)
) {
freeTrialModal?.show()
}
}
</script>
<Modal bind:this={freeTrialModal} disableCancel={true}>
<ModalContent
confirmText="Get started"
size="M"
showCancelButton={false}
showCloseIcon={false}
onConfirm={async () => {
if (get(auth).user) {
try {
await API.updateSelf({
freeTrialConfirmedAt: new Date().toISOString(),
})
// Update the cached user
await auth.getSelf()
} finally {
freeTrialModal.hide()
}
}
}}
>
<h1>Experience all of Budibase with a free 14-day trial</h1>
<div class="free-trial-text">
We've upgraded you to a free 14-day trial that allows you to try all our
features before deciding which plan is right for you.
<p>
At the end of your trial, we'll automatically downgrade you to the Free
plan unless you choose to upgrade.
</p>
</div>
<FreeTrial />
</ModalContent>
</Modal>
<style>
h1 {
font-size: 26px;
}
.free-trial-text {
font-size: 16px;
}
</style>

View file

@ -1,6 +1,6 @@
import {
FieldType,
FieldSubtype,
BBReferenceFieldSubType,
INTERNAL_TABLE_SOURCE_ID,
AutoFieldSubType,
Hosting,
@ -159,15 +159,17 @@ export const FIELDS = {
},
USER: {
name: "User",
type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USER,
icon: TypeIconMap[FieldType.USER],
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][
BBReferenceFieldSubType.USER
],
},
USERS: {
name: "Users",
name: "User List",
type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USERS,
icon: TypeIconMap[FieldType.USERS],
subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER],
constraints: {
type: "array",
},
@ -253,6 +255,7 @@ export const SchemaTypeOptions = [
{ label: "Number", value: FieldType.NUMBER },
{ label: "Boolean", value: FieldType.BOOLEAN },
{ label: "Datetime", value: FieldType.DATETIME },
{ label: "JSON", value: FieldType.JSON },
]
export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({

View file

@ -29,6 +29,7 @@ import { JSONUtils, Constants } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
import { FIELDS } from "constants/backend"
const { ContextScopes } = Constants
@ -491,7 +492,7 @@ const generateComponentContextBindings = (asset, componentContext) => {
icon: bindingCategory.icon,
display: {
name: `${fieldSchema.name || key}`,
type: fieldSchema.type,
type: fieldSchema.display?.type || fieldSchema.type,
},
})
})
@ -1019,15 +1020,23 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// are objects
let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
const field = Object.values(FIELDS).find(
field =>
field.type === fieldSchema.type &&
field.subtype === fieldSchema.subtype
)
if (typeof fieldSchema === "string") {
fixedSchema[fieldName] = {
type: fieldSchema,
name: fieldName,
display: { type: fieldSchema },
}
} else {
fixedSchema[fieldName] = {
...fieldSchema,
name: fieldName,
display: { type: field?.name || fieldSchema.type },
}
}
})
@ -1106,50 +1115,51 @@ export const getAllStateVariables = () => {
getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._component)
const nestedTypes = [
"buttonConfiguration",
"fieldConfiguration",
"stepConfiguration",
]
// Extracts all event settings from a component instance.
// Recurses into nested types to find all event-like settings at any
// depth.
const parseEventSettings = (settings, comp) => {
if (!settings?.length) {
return
}
// Extract top level event settings
settings
.filter(setting => setting.type === "event")
.forEach(setting => {
eventSettings.push(comp[setting.key])
})
}
const parseComponentSettings = (settings, component) => {
// Parse the nested button configurations
// Recurse into any nested instance types
settings
.filter(setting => setting.type === "buttonConfiguration")
.filter(setting => nestedTypes.includes(setting.type))
.forEach(setting => {
const buttonConfig = component[setting.key]
const instances = comp[setting.key]
if (Array.isArray(instances) && instances.length) {
instances.forEach(instance => {
let type = instance?._component
if (Array.isArray(buttonConfig)) {
buttonConfig.forEach(button => {
const nestedSettings = componentStore.getComponentSettings(
button._component
)
parseEventSettings(nestedSettings, button)
// Backwards compatibility for multi-step from blocks which
// didn't set a proper component type previously.
if (setting.type === "stepConfiguration" && !type) {
type = "@budibase/standard-components/multistepformblockstep"
}
// Parsed nested component instances inside this setting
const nestedSettings = componentStore.getComponentSettings(type)
parseEventSettings(nestedSettings, instance)
})
}
})
parseEventSettings(settings, component)
}
// Parse the base component settings
parseComponentSettings(settings, component)
// Parse step configuration
const stepSetting = settings.find(
setting => setting.type === "stepConfiguration"
)
const steps = stepSetting ? component[stepSetting.key] : []
const stepDefinition = componentStore.getComponentSettings(
"@budibase/standard-components/multistepformblockstep"
)
steps?.forEach(step => {
parseComponentSettings(stepDefinition, step)
})
parseEventSettings(settings, component)
})
})

View file

@ -20,6 +20,9 @@ export function getFormattedPlanName(userPlanType) {
case PlanType.ENTERPRISE:
planName = "Enterprise"
break
case PlanType.ENTERPRISE_BASIC_TRIAL:
planName = "Trial"
break
default:
planName = "Free" // Default to "Free" if the type is not explicitly handled
}

View file

@ -32,6 +32,7 @@
import { UserAvatars } from "@budibase/frontend-core"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import PreviewOverlay from "./_components/PreviewOverlay.svelte"
import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte"
export let application
@ -192,6 +193,8 @@
<CommandPalette />
</Modal>
<EnterpriseBasicTrialModal />
<style>
.back-to-apps {
display: contents;

View file

@ -71,6 +71,7 @@
"multifieldselect",
"s3upload",
"codescanner",
"bbreferencesinglefield",
"bbreferencefield"
]
},

View file

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

View file

@ -6,7 +6,7 @@
import { sdk } from "@budibase/shared-core"
</script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan && !$licensing.isEnterpriseTrial}
{#if $admin.cloud && $auth?.user?.accountPortalAccess}
<Button
cta

View file

@ -1,7 +1,7 @@
<script>
import { isActive, redirect, goto, url } from "@roxi/routify"
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
import { organisation, auth, menu, appsStore } from "stores/portal"
import { organisation, auth, menu, appsStore, licensing } from "stores/portal"
import { onMount } from "svelte"
import UpgradeButton from "./_components/UpgradeButton.svelte"
import MobileMenu from "./_components/MobileMenu.svelte"
@ -10,6 +10,8 @@
import HelpMenu from "components/common/HelpMenu.svelte"
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
import { sdk } from "@budibase/shared-core"
import EnterpriseBasicTrialBanner from "components/portal/licensing/EnterpriseBasicTrialBanner.svelte"
import { Constants } from "@budibase/frontend-core"
let loaded = false
let mobileMenuVisible = false
@ -33,6 +35,14 @@
const showMobileMenu = () => (mobileMenuVisible = true)
const hideMobileMenu = () => (mobileMenuVisible = false)
const showFreeTrialBanner = () => {
return (
$licensing.license?.plan?.type ===
Constants.PlanType.ENTERPRISE_BASIC_TRIAL &&
sdk.users.isAdmin($auth.user)
)
}
onMount(async () => {
// Prevent non-builders from accessing the portal
if ($auth.user) {
@ -58,6 +68,7 @@
<HelpMenu />
<div class="container">
<VerificationPromptBanner />
<EnterpriseBasicTrialBanner show={showFreeTrialBanner()} />
<div class="nav">
<div class="branding">
<Logo />

View file

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

View file

@ -29,6 +29,7 @@
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"]
const oneDayInSeconds = 86400
const EXCLUDE_QUOTAS = {
Queries: () => true,
@ -104,24 +105,17 @@
if (!timestamp) {
return
}
const now = new Date()
now.setHours(0)
now.setMinutes(0)
const thenDate = new Date(timestamp)
thenDate.setHours(0)
thenDate.setMinutes(0)
const difference = thenDate.getTime() - now
// return the difference in days
return (difference / (1000 * 3600 * 24)).toFixed(0)
const diffTime = Math.abs(timestamp - new Date().getTime()) / 1000
return Math.floor(diffTime / oneDayInSeconds)
}
const setTextRows = () => {
textRows = []
if (cancelAt && !usesInvoicing) {
textRows.push({ message: "Subscription has been cancelled" })
if (plan?.type !== Constants.PlanType.ENTERPRISE_BASIC_TRIAL) {
textRows.push({ message: "Subscription has been cancelled" })
}
textRows.push({
message: `${getDaysRemaining(cancelAt)} days remaining`,
tooltip: new Date(cancelAt),

View file

@ -440,6 +440,8 @@ export class ComponentStore extends BudiStore {
return state
})
componentTreeNodesStore.makeNodeVisible(componentInstance._id)
// Log event
analytics.captureEvent(Events.COMPONENT_CREATED, {
name: componentInstance._component,

View file

@ -103,6 +103,8 @@ export const createLicensingStore = () => {
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
const isFreePlan = planType === Constants.PlanType.FREE
const isBusinessPlan = planType === Constants.PlanType.BUSINESS
const isEnterpriseTrial =
planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL
const groupsEnabled = license.features.includes(
Constants.Features.USER_GROUPS
)
@ -143,6 +145,7 @@ export const createLicensingStore = () => {
isEnterprisePlan,
isFreePlan,
isBusinessPlan,
isEnterpriseTrial,
groupsEnabled,
backupsEnabled,
brandingEnabled,

View file

@ -3869,12 +3869,6 @@
"key": "timeOnly",
"defaultValue": false
},
{
"type": "boolean",
"label": "24-hour time",
"key": "time24hr",
"defaultValue": false
},
{
"type": "boolean",
"label": "Ignore time zones",
@ -4226,8 +4220,8 @@
]
},
"attachmentfield": {
"name": "Attachment list",
"icon": "Attach",
"name": "Attachment List",
"icon": "DocumentFragmentGroup",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
@ -4324,7 +4318,7 @@
},
"attachmentsinglefield": {
"name": "Single Attachment",
"icon": "Attach",
"icon": "DocumentFragment",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
@ -6723,7 +6717,20 @@
"illegalChildren": ["section", "sidepanel"],
"showEmptyState": false,
"draggable": false,
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action."
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.",
"settings": [
{
"type": "boolean",
"key": "ignoreClicksOutside",
"label": "Ignore clicks outside",
"defaultValue": false
},
{
"type": "event",
"key": "onClose",
"label": "On close"
}
]
},
"rowexplorer": {
"block": true,
@ -7018,8 +7025,8 @@
},
"bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field",
"icon": "User",
"name": "User List Field",
"icon": "UserGroup",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
@ -7123,5 +7130,113 @@
]
}
]
},
"bbreferencesinglefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field",
"icon": "User",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
"size": {
"width": 400,
"height": 50
},
"settings": [
{
"type": "field/bb_reference_single",
"label": "Field",
"key": "field",
"required": true
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "text",
"label": "Help text",
"key": "helpText"
},
{
"type": "event",
"label": "On change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{
"type": "validation/link",
"label": "Validation",
"key": "validation"
},
{
"type": "boolean",
"label": "Search",
"key": "autocomplete",
"defaultValue": true
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "boolean",
"label": "Read only",
"key": "readonly",
"defaultValue": false,
"dependsOn": {
"setting": "disabled",
"value": true,
"invert": true
}
},
{
"type": "select",
"label": "Layout",
"key": "span",
"defaultValue": 6,
"hidden": true,
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "1 column",
"value": 6,
"barIcon": "Stop",
"barTitle": "1 column"
},
{
"label": "2 columns",
"value": 3,
"barIcon": "ColumnTwoA",
"barTitle": "2 columns"
},
{
"label": "3 columns",
"value": 2,
"barIcon": "ViewColumn",
"barTitle": "3 columns"
}
]
}
]
}
}

View file

@ -24,14 +24,7 @@
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0",
"@spectrum-css/button": "^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",
"@spectrum-css/card": "3.0.3",
"apexcharts": "^3.22.1",
"dayjs": "^1.10.8",
"downloadjs": "1.4.7",
@ -41,7 +34,6 @@
"screenfull": "^6.0.1",
"shortid": "^2.2.15",
"svelte-apexcharts": "^1.0.2",
"svelte-flatpickr": "^3.3.4",
"svelte-spa-router": "^4.0.1"
},
"devDependencies": {

View file

@ -206,13 +206,6 @@
/>
{/key}
<!--
Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with.
-->
<div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />

View file

@ -60,16 +60,6 @@
--spectrum-link-primary-m-text-color-hover: var(--primaryColorHover);
}
/* Theme flatpickr */
:global(.flatpickr-day.selected) {
background: var(--primaryColor);
border-color: var(--primaryColor);
}
:global(.flatpickr-day.selected:hover) {
background: var(--primaryColorHover);
border-color: var(--primaryColorHover);
}
/* Custom scrollbars */
:global(::-webkit-scrollbar) {
width: 8px;

View file

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

View file

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

View file

@ -73,7 +73,10 @@
$context.device.width,
$context.device.height
)
$: autoCloseSidePanel = !$builderStore.inBuilder && $sidePanelStore.open
$: autoCloseSidePanel =
!$builderStore.inBuilder &&
$sidePanelStore.open &&
!$sidePanelStore.ignoreClicksOutside
$: screenId = $builderStore.inBuilder
? `${$builderStore.screen?._id}-screen`
: "screen"
@ -191,6 +194,11 @@
}
return url
}
const handleClickLink = () => {
mobileOpen = false
sidePanelStore.actions.close()
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
@ -281,7 +289,7 @@
url={navItem.url}
subLinks={navItem.subLinks}
internalLink={navItem.internalLink}
on:clickLink={() => (mobileOpen = false)}
on:clickLink={handleClickLink}
leftNav={navigation === "Left"}
{mobile}
{navStateStore}
@ -316,10 +324,7 @@
<div
id="side-panel-container"
class:open={$sidePanelStore.open}
use:clickOutside={{
callback: autoCloseSidePanel ? sidePanelStore.actions.close : null,
allowedType: "mousedown",
}}
use:clickOutside={autoCloseSidePanel ? sidePanelStore.actions.close : null}
class:builder={$builderStore.inBuilder}
>
<div class="side-panel-header">

View file

@ -5,6 +5,9 @@
const { styleable, sidePanelStore, builderStore, dndIsDragging } =
getContext("sdk")
export let onClose
export let ignoreClicksOutside
// Automatically show and hide the side panel when inside the builder.
// For some unknown reason, svelte reactivity breaks if we reference the
// reactive variable "open" inside the following expression, or if we define
@ -26,6 +29,10 @@
}
}
// $: {
// }
// Derive visibility
$: open = $sidePanelStore.contentId === $component.id
@ -36,10 +43,17 @@
let renderKey = null
$: {
if (open) {
sidePanelStore.actions.setIgnoreClicksOutside(ignoreClicksOutside)
renderKey = Math.random()
}
}
const handleSidePanelClose = async () => {
if (onClose) {
await onClose()
}
}
const showInSidePanel = (el, visible) => {
const update = visible => {
const target = document.getElementById("side-panel-container")
@ -51,6 +65,7 @@
} else {
if (target.contains(node)) {
target.removeChild(node)
handleSidePanelClose()
}
}
}

View file

@ -21,6 +21,7 @@
[FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner",
[FieldType.BB_REFERENCE]: "bbreferencefield",
[FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield",
}
const getFieldSchema = field => {

View file

@ -1,8 +1,10 @@
<script>
import RelationshipField from "./RelationshipField.svelte"
import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte"
export let defaultValue
export let type = FieldType.BB_REFERENCE
function updateUserIDs(value) {
if (Array.isArray(value)) {
@ -22,6 +24,7 @@
<RelationshipField
{...$$props}
{type}
datasourceType={"user"}
primaryDisplay={"email"}
defaultValue={updateReferences(defaultValue)}

View file

@ -0,0 +1,6 @@
<script>
import { FieldType } from "@budibase/types"
import BBReferenceField from "./BBReferenceField.svelte"
</script>
<BBReferenceField {...$$restProps} type={FieldType.BB_REFERENCE_SINGLE} />

View file

@ -49,7 +49,6 @@
readonly={fieldState.readonly}
error={fieldState.error}
id={fieldState.fieldId}
appendTo={document.getElementById("flatpickr-root")}
{enableTime}
{timeOnly}
{time24hr}

View file

@ -1,9 +1,9 @@
<script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext, onMount } from "svelte"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants"
const { API } = getContext("sdk")
@ -21,6 +21,7 @@
export let primaryDisplay
export let span
export let helpText = null
export let type = FieldType.LINK
let fieldState
let fieldApi
@ -28,12 +29,10 @@
let tableDefinition
let searchTerm
let open
let initialValue
$: type =
datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE
$: multiselect = fieldSchema?.relationshipType !== "one-to-many"
$: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId
$: fetch = fetchData({
API,
@ -52,18 +51,19 @@
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
$: component = multiselect ? CoreMultiselect : CoreSelect
$: expandedDefaultValue = expand(defaultValue)
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
let optionsObj = {}
let initialValuesProcessed
let optionsObj
$: {
if (!initialValuesProcessed && primaryDisplay) {
if (primaryDisplay && fieldState && !optionsObj) {
// Persist the initial values as options, allowing them to be present in the dropdown,
// even if they are not in the inital fetch results
initialValuesProcessed = true
optionsObj = (fieldState?.value || []).reduce((accumulator, value) => {
let valueAsSafeArray = fieldState.value || []
if (!Array.isArray(valueAsSafeArray)) {
valueAsSafeArray = [fieldState.value]
}
optionsObj = valueAsSafeArray.reduce((accumulator, value) => {
// fieldState has to be an array of strings to be valid for an update
// therefore we cannot guarantee value will be an object
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
@ -75,7 +75,7 @@
[primaryDisplay]: value.primaryDisplay,
}
return accumulator
}, optionsObj)
}, {})
}
}
@ -86,7 +86,7 @@
accumulator[row._id] = row
}
return accumulator
}, optionsObj)
}, optionsObj || {})
return Object.values(result)
}
@ -110,17 +110,10 @@
}
$: forceFetchRows(filter)
$: debouncedFetchRows(
searchTerm,
primaryDisplay,
initialValue || defaultValue
)
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const forceFetchRows = async () => {
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
optionsObj = {}
fieldApi?.setValue([])
selectedValue = []
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
}
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
@ -136,7 +129,7 @@
if (defaultVal && !Array.isArray(defaultVal)) {
defaultVal = defaultVal.split(",")
}
if (defaultVal && defaultVal.some(val => !optionsObj[val])) {
if (defaultVal && optionsObj && defaultVal.some(val => !optionsObj[val])) {
await fetch.update({
query: { oneOf: { _id: defaultVal } },
})
@ -162,16 +155,13 @@
if (!values) {
return []
}
if (!Array.isArray(values)) {
values = [values]
}
values = values.map(value =>
typeof value === "object" ? value._id : value
)
// Make sure field state is valid
if (values?.length > 0) {
fieldApi.setValue(values)
}
return values
}
@ -179,25 +169,20 @@
return row?.[primaryDisplay] || "-"
}
const singleHandler = e => {
handleChange(e.detail == null ? [] : [e.detail])
}
const multiHandler = e => {
handleChange(e.detail)
}
const expand = values => {
if (!values) {
return []
const handleChange = e => {
let value = e.detail
if (!multiselect) {
value = value == null ? [] : [value]
}
if (
type === FieldType.BB_REFERENCE_SINGLE &&
value &&
Array.isArray(value)
) {
value = value[0] || null
}
if (Array.isArray(values)) {
return values
}
return values.split(",").map(value => value.trim())
}
const handleChange = value => {
const changed = fieldApi.setValue(value)
if (onChange && changed) {
onChange({
@ -211,16 +196,6 @@
fetch.nextPage()
}
}
onMount(() => {
// if the form is in 'Update' mode, then we need to fetch the matching row so that the value is correctly set
if (fieldState?.value) {
initialValue =
fieldSchema?.relationshipType !== "one-to-many"
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
}
})
</script>
<Field
@ -229,7 +204,7 @@
{disabled}
{readonly}
{validation}
defaultValue={expandedDefaultValue}
{defaultValue}
{type}
{span}
{helpText}
@ -243,7 +218,7 @@
options={enrichedOptions}
{autocomplete}
value={selectedValue}
on:change={multiselect ? multiHandler : singleHandler}
on:change={handleChange}
on:loadMore={loadMore}
id={fieldState.fieldId}
disabled={fieldState.disabled}

View file

@ -17,3 +17,4 @@ export { default as jsonfield } from "./JSONField.svelte"
export { default as s3upload } from "./S3Upload.svelte"
export { default as codescanner } from "./CodeScannerField.svelte"
export { default as bbreferencefield } from "./BBReferenceField.svelte"
export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte"

View file

@ -1,5 +1,6 @@
import flatpickr from "flatpickr"
import dayjs from "dayjs"
import { FieldTypes } from "../../../constants"
import { Helpers } from "@budibase/bbui"
/**
* Creates a validation function from a combination of schema-level constraints
@ -81,7 +82,7 @@ export const createValidatorFromConstraints = (
// Date constraint
if (exists(schemaConstraints.datetime?.earliest)) {
const limit = schemaConstraints.datetime.earliest
const limitString = flatpickr.formatDate(new Date(limit), "F j Y, H:i")
const limitString = Helpers.getDateDisplayValue(dayjs(limit))
rules.push({
type: "datetime",
constraint: "minValue",
@ -91,7 +92,7 @@ export const createValidatorFromConstraints = (
}
if (exists(schemaConstraints.datetime?.latest)) {
const limit = schemaConstraints.datetime.latest
const limitString = flatpickr.formatDate(new Date(limit), "F j Y, H:i")
const limitString = Helpers.getDateDisplayValue(dayjs(limit))
rules.push({
type: "datetime",
constraint: "maxValue",

View file

@ -3,6 +3,7 @@ import { writable, derived } from "svelte/store"
export const createSidePanelStore = () => {
const initialState = {
contentId: null,
ignoreClicksOutside: true,
}
const store = writable(initialState)
const derivedStore = derived(store, $store => {
@ -32,11 +33,18 @@ export const createSidePanelStore = () => {
}, 50)
}
const setIgnoreClicksOutside = bool => {
store.update(state => {
state.ignoreClicksOutside = bool
return state
})
}
return {
subscribe: derivedStore.subscribe,
actions: {
open,
close,
setIgnoreClicksOutside,
},
}
}

View file

@ -240,6 +240,7 @@ const triggerAutomationHandler = async action => {
const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters
routeStore.actions.navigate(url, peek, externalNewTab)
closeSidePanelHandler()
}
const queryExecutionHandler = async action => {
@ -541,16 +542,22 @@ export const enrichButtonActions = (actions, context) => {
// then execute the rest of the actions in the chain
const result = await callback()
if (result !== false) {
// Generate a new total context to pass into the next enrichment
// Generate a new total context for the next enrichment
buttonContext.push(result)
const newContext = { ...context, actions: buttonContext }
// Enrich and call the next button action if there is more than one action remaining
// Enrich and call the next button action if there is more
// than one action remaining
const next = enrichButtonActions(
actions.slice(i + 1),
newContext
)
resolve(typeof next === "function" ? await next() : true)
if (typeof next === "function") {
// Pass the event context back into the new action chain
resolve(await next(eventContext))
} else {
resolve(true)
}
} else {
resolve(false)
}

View file

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

View file

@ -124,6 +124,8 @@
const fieldSchema = schemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType
filter.constraints = fieldSchema?.constraints
// Update external type based on field
filter.externalType = getSchema(filter)?.externalType
@ -280,7 +282,7 @@
timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.BB_REFERENCE}
{:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)}
<FilterUsers
bind:value={filter.value}
multiselect={[
@ -288,6 +290,7 @@
OperatorOptions.ContainsAny.value,
].includes(filter.operator)}
disabled={filter.noValue}
type={filter.valueType}
/>
{:else}
<Input disabled />
@ -324,8 +327,6 @@
<style>
.container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.fields {
display: grid;

View file

@ -4,6 +4,7 @@
import { createAPIClient } from "../api"
export let API = createAPIClient()
export let value = null
export let disabled
export let multiselect = false
@ -23,12 +24,14 @@
$: component = multiselect ? Multiselect : Select
</script>
<svelte:component
this={component}
bind:value
autocomplete
{options}
getOptionLabel={option => option.email}
getOptionValue={option => option._id}
{disabled}
/>
<div class="user-control">
<svelte:component
this={component}
bind:value
autocomplete
{options}
getOptionLabel={option => option.email}
getOptionValue={option => option._id}
{disabled}
/>
</div>

View file

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

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