Merge branch 'master' into fix-component-auto-expand

This commit is contained in:
Andrew Kingston 2024-05-13 13:24:07 +01:00 committed by GitHub
commit 68148a6d3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
220 changed files with 5636 additions and 2569 deletions

View File

@ -1,4 +1,5 @@
{ {
"root": true,
"env": { "env": {
"browser": true, "browser": true,
"es6": true, "es6": true,
@ -53,7 +54,8 @@
"ignoreRestSiblings": true "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: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
REUSE_CONTAINERS: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -150,8 +148,6 @@ jobs:
test-server: test-server:
runs-on: budi-tubby-tornado-quad-core-150gb runs-on: budi-tubby-tornado-quad-core-150gb
env:
REUSE_CONTAINERS: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -174,7 +170,8 @@ jobs:
docker pull mongo:7.0-jammy & docker pull mongo:7.0-jammy &
docker pull mariadb:lts & docker pull mariadb:lts &
docker pull testcontainers/ryuk:0.5.1 & 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 & docker pull redis &
wait $(jobs -p) wait $(jobs -p)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,52 @@
import { GenericContainer, Wait } from "testcontainers" import {
GenericContainer,
Wait,
getContainerRuntimeClient,
} from "testcontainers"
import { ContainerInfo } from "dockerode"
import path from "path" import path from "path"
import lockfile from "proper-lockfile" import lockfile from "proper-lockfile"
async function getBudibaseContainers() {
const client = await getContainerRuntimeClient()
const conatiners = await client.container.list()
return conatiners.filter(
container =>
container.Labels["com.budibase"] === "true" &&
container.Labels["org.testcontainers"] === "true"
)
}
async function killContainers(containers: ContainerInfo[]) {
const client = await getContainerRuntimeClient()
for (const container of containers) {
const c = client.container.getById(container.Id)
await c.kill()
await c.remove()
}
}
export default async function setup() { export default async function setup() {
const lockPath = path.resolve(__dirname, "globalSetup.ts") const lockPath = path.resolve(__dirname, "globalSetup.ts")
if (process.env.REUSE_CONTAINERS) { // If you run multiple tests at the same time, it's possible for the CouchDB
// If you run multiple tests at the same time, it's possible for the CouchDB // shared container to get started multiple times despite having an
// shared container to get started multiple times despite having an // identical reuse hash. To avoid that, we do a filesystem-based lock so
// identical reuse hash. To avoid that, we do a filesystem-based lock so // that only one globalSetup.ts is running at a time.
// that only one globalSetup.ts is running at a time. lockfile.lockSync(lockPath)
lockfile.lockSync(lockPath)
} // Remove any containers that are older than 24 hours. This is to prevent
// containers getting full volumes or accruing any other problems from being
// left up for very long periods of time.
const threshold = new Date(Date.now() - 1000 * 60 * 60 * 24)
const containers = (await getBudibaseContainers()).filter(container => {
const created = new Date(container.Created * 1000)
return created < threshold
})
await killContainers(containers)
try { try {
let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") const couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
.withExposedPorts(5984, 4984) .withExposedPorts(5984, 4984)
.withEnvironment({ .withEnvironment({
COUCHDB_PASSWORD: "budibase", COUCHDB_PASSWORD: "budibase",
@ -28,20 +61,29 @@ export default async function setup() {
target: "/opt/couchdb/etc/local.d/test-couchdb.ini", target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
}, },
]) ])
.withLabels({ "com.budibase": "true" })
.withReuse()
.withWaitStrategy( .withWaitStrategy(
Wait.forSuccessfulCommand( Wait.forSuccessfulCommand(
"curl http://budibase:budibase@localhost:5984/_up" "curl http://budibase:budibase@localhost:5984/_up"
).withStartupTimeout(20000) ).withStartupTimeout(20000)
) )
if (process.env.REUSE_CONTAINERS) { const minio = new GenericContainer("minio/minio")
couchdb = couchdb.withReuse() .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 { } 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 & /opt/clouseau/bin/clouseau > /dev/stdout 2>&1 &
# Start CouchDB. # Start CouchDB.
/docker-entrypoint.sh /opt/couchdb/bin/couchdb & /docker-entrypoint.sh /opt/couchdb/bin/couchdb > /dev/stdout 2>&1 &
# Start SQS. # Start SQS. Use 127.0.0.1 instead of localhost to avoid IPv6 issues.
/opt/sqs/sqs --server "http://localhost:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 & /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. # Wait for CouchDB to start up.
while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do 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/server/package.json
RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
# We will never want to sync pro, but the script is still required
RUN echo '' > scripts/syncProPackage.js
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile

View File

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

View File

@ -32,7 +32,6 @@
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js",
"get-past-client-version": "node scripts/getPastClientVersion.js", "get-past-client-version": "node scripts/getPastClientVersion.js",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream", "build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
@ -60,7 +59,8 @@
"dev:all": "yarn run kill-all && lerna run --stream dev", "dev:all": "yarn run kill-all && lerna run --stream dev",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
"dev:docker": "./scripts/devDocker.sh", "dev:docker": "./scripts/devDocker.sh",
"test": "REUSE_CONTAINERS=1 lerna run --concurrency 1 --stream test --stream", "test": "lerna run --concurrency 1 --stream test --stream",
"test:containers:kill": "./scripts/killTestcontainers.sh",
"lint:eslint": "eslint packages --max-warnings=0", "lint:eslint": "eslint packages --max-warnings=0",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
@ -107,6 +107,7 @@
"@budibase/shared-core": "0.0.0", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@budibase/pro": "npm:@budibase/pro@latest",
"tough-cookie": "4.1.3", "tough-cookie": "4.1.3",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"semver": "7.5.3", "semver": "7.5.3",

View File

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

View File

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

View File

@ -3,11 +3,11 @@ import {
AllDocsResponse, AllDocsResponse,
AnyDocument, AnyDocument,
Database, Database,
DatabaseOpts,
DatabaseQueryOpts,
DatabasePutOpts,
DatabaseCreateIndexOpts, DatabaseCreateIndexOpts,
DatabaseDeleteIndexOpts, DatabaseDeleteIndexOpts,
DatabaseOpts,
DatabasePutOpts,
DatabaseQueryOpts,
Document, Document,
isDocument, isDocument,
RowResponse, RowResponse,
@ -17,7 +17,7 @@ import {
import { getCouchInfo } from "./connections" import { getCouchInfo } from "./connections"
import { directCouchUrlCall } from "./utils" import { directCouchUrlCall } from "./utils"
import { getPouchDB } from "./pouchDB" import { getPouchDB } from "./pouchDB"
import { WriteStream, ReadStream } from "fs" import { ReadStream, WriteStream } from "fs"
import { newid } from "../../docIds/newid" import { newid } from "../../docIds/newid"
import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { SQLITE_DESIGN_DOC_ID } from "../../constants"
import { DDInstrumentedDatabase } from "../instrumentation" import { DDInstrumentedDatabase } from "../instrumentation"
@ -38,6 +38,39 @@ function buildNano(couchInfo: { url: string; cookie: string }) {
type DBCall<T> = () => Promise<T> 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( export function DatabaseWithConnection(
dbName: string, dbName: string,
connection: string, connection: string,
@ -119,7 +152,7 @@ export class DatabaseImpl implements Database {
} catch (err: any) { } catch (err: any) {
// Handling race conditions // Handling race conditions
if (err.statusCode !== 412) { 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) { if (err.statusCode === 404 && err.reason === DATABASE_NOT_FOUND) {
await this.checkAndCreateDb() await this.checkAndCreateDb()
return await this.performCall(call) 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) { if (err.statusCode === 404) {
return return
} else { } 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 export const removeKeyNumbering = dataFilters.removeKeyNumbering
function isEmpty(value: any) {
return value == null || value === ""
}
/** /**
* Class to build lucene query URLs. * Class to build lucene query URLs.
* Optionally takes a base lucene query object. * Optionally takes a base lucene query object.
@ -282,15 +286,14 @@ export class QueryBuilder<T> {
} }
const equal = (key: string, value: any) => { const equal = (key: string, value: any) => {
// 0 evaluates to false, which means we would return all rows if we don't check it if (isEmpty(value)) {
if (!value && value !== 0) {
return null return null
} }
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}` return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
} }
const contains = (key: string, value: any, mode = "AND") => { const contains = (key: string, value: any, mode = "AND") => {
if (!value || (Array.isArray(value) && value.length === 0)) { if (isEmpty(value)) {
return null return null
} }
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -306,7 +309,7 @@ export class QueryBuilder<T> {
} }
const fuzzy = (key: string, value: any) => { const fuzzy = (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
value = builder.preprocess(value, { value = builder.preprocess(value, {
@ -328,7 +331,7 @@ export class QueryBuilder<T> {
} }
const oneOf = (key: string, value: any) => { const oneOf = (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return `*:*` return `*:*`
} }
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -386,7 +389,7 @@ export class QueryBuilder<T> {
// Construct the actual lucene search query string from JSON structure // Construct the actual lucene search query string from JSON structure
if (this.#query.string) { if (this.#query.string) {
build(this.#query.string, (key: string, value: any) => { build(this.#query.string, (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
value = builder.preprocess(value, { value = builder.preprocess(value, {
@ -399,7 +402,7 @@ export class QueryBuilder<T> {
} }
if (this.#query.range) { if (this.#query.range) {
build(this.#query.range, (key: string, value: any) => { build(this.#query.range, (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
if (value.low == null || value.low === "") { if (value.low == null || value.low === "") {
@ -421,7 +424,7 @@ export class QueryBuilder<T> {
} }
if (this.#query.notEqual) { if (this.#query.notEqual) {
build(this.#query.notEqual, (key: string, value: any) => { build(this.#query.notEqual, (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
if (typeof value === "boolean") { if (typeof value === "boolean") {
@ -431,10 +434,28 @@ export class QueryBuilder<T> {
}) })
} }
if (this.#query.empty) { 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) { 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) { if (this.#query.oneOf) {
build(this.#query.oneOf, oneOf) build(this.#query.oneOf, oneOf)

View File

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

View File

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

View File

@ -1,9 +1,14 @@
import { join } from "path" import path, { join } from "path"
import { tmpdir } from "os" import { tmpdir } from "os"
import fs from "fs" import fs from "fs"
import env from "../environment" import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3" 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 * * NOTE: When adding a new bucket - name *
* sure that S3 usages (like budibase-infra) * * sure that S3 usages (like budibase-infra) *
@ -55,3 +60,50 @@ export const bucketTTLConfig = (
return params 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 testContainerUtils from "./testContainerUtils"
export * as utils from "./utils" export * as utils from "./utils"
export * from "./jestUtils" export * from "./jestUtils"
import * as minio from "./minio"
export const objectStoreTestProviders = { minio }

View File

@ -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") .split("\n")
.filter(x => x.length > 0) .filter(x => x.length > 0)
.map(x => JSON.parse(x) as ContainerInfo) .map(x => JSON.parse(x) as ContainerInfo)
.filter(x => x.Labels.includes("org.testcontainers=true")) .filter(
x =>
x.Labels.includes("org.testcontainers=true") &&
x.Labels.includes("com.budibase=true")
)
} }
export function getContainerByImage(image: string) { export function getContainerByImage(image: string) {
@ -82,10 +86,18 @@ export function setupEnv(...envs: any[]) {
throw new Error("CouchDB SQL port not found") 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 = [ const configs = [
{ key: "COUCH_DB_PORT", value: `${couchPort}` }, { key: "COUCH_DB_PORT", value: `${couchPort}` },
{ key: "COUCH_DB_URL", value: `http://127.0.0.1:${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: "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)) { for (const config of configs.filter(x => !!x.value)) {

View File

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

View File

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

View File

@ -1,13 +1,24 @@
// These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [ const ignoredClasses = [
".flatpickr-calendar",
".spectrum-Popover",
".download-js-link", ".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 clickHandlers = []
let candidateTarget
/** // Processes a "click outside" event and invokes callbacks if our source element
* Handle a body click event // is valid
*/
const handleClick = event => { const handleClick = event => {
// Ignore click if this is an ignored class // Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) { if (event.target.closest('[data-ignore-click-outside="true"]')) {
@ -21,41 +32,60 @@ const handleClick = event => {
// Process handlers // Process handlers
clickHandlers.forEach(handler => { clickHandlers.forEach(handler => {
// Check that the click isn't inside the target
if (handler.element.contains(event.target)) { if (handler.element.contains(event.target)) {
return return
} }
// Ignore clicks for modals, unless the handler is registered from a modal // Ignore clicks for certain classes unless we're nested inside them
const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null for (let className of conditionallyIgnoredClasses) {
const clickInModal = event.target.closest(".spectrum-Underlay") != null const sourceInside = handler.anchor.closest(className) != null
if (clickInModal && !sourceInModal) { const clickInside = event.target.closest(className) != null
return if (clickInside && !sourceInside) {
} return
}
// Ignore clicks for drawers, unless the handler is registered from a drawer
const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
const clickInDrawer = event.target.closest(".drawer-wrapper") != null
if (clickInDrawer && !sourceInDrawer) {
return
}
if (handler.allowedType && event.type !== handler.allowedType) {
return
} }
handler.callback?.(event) handler.callback?.(event)
}) })
} }
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 * 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) let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) { if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback, allowedType }) clickHandlers.push({ id, element, anchor, callback })
} else { } else {
existingHandler.callback = callback existingHandler.callback = callback
} }
@ -82,8 +112,7 @@ export default (element, opts) => {
const callback = const callback =
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null) newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
const anchor = newOpts?.anchor || element const anchor = newOpts?.anchor || element
const allowedType = newOpts?.allowedType || "click" updateHandler(id, element, anchor, callback)
updateHandler(id, element, anchor, callback, allowedType)
} }
update(opts) update(opts)
return { 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) { export default function positionDropdown(element, opts) {
let resizeObserver let resizeObserver
let latestOpts = opts let latestOpts = opts
@ -19,6 +38,8 @@ export default function positionDropdown(element, opts) {
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
resizable,
wrap,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -27,56 +48,159 @@ export default function positionDropdown(element, opts) {
// Compute bounds // Compute bounds
const anchorBounds = anchor.getBoundingClientRect() const anchorBounds = anchor.getBoundingClientRect()
const elementBounds = element.getBoundingClientRect() const elementBounds = element.getBoundingClientRect()
const winWidth = window.innerWidth
const winHeight = window.innerHeight
const screenOffset = 8
let styles = { let styles = {
maxHeight: null, maxHeight,
minWidth, minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth, maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null, left: null,
top: null, top: null,
} }
// Ignore all our logic for custom logic
if (typeof customUpdate === "function") { if (typeof customUpdate === "function") {
styles = customUpdate(anchorBounds, elementBounds, { styles = customUpdate(anchorBounds, elementBounds, {
...styles, ...styles,
offset: opts.offset, offset: opts.offset,
}) })
} else { }
// Determine vertical styles
if (align === "right-outside" || align === "left-outside") { // Otherwise position ourselves as normal
styles.top = else {
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2 // Checks if we overflow off the screen. We only report that we overflow
styles.maxHeight = maxHeight // when the alternative dimension is larger than the one we are checking.
if (styles.top + elementBounds.height > window.innerHeight) { const doesXOverflow = () => {
styles.top = window.innerHeight - elementBounds.height const overflows = styles.left + elementBounds.width > winWidth
} return overflows && anchorBounds.left > winWidth - anchorBounds.right
} else if ( }
window.innerHeight - anchorBounds.bottom < const doesYOverflow = () => {
(maxHeight || 100) const overflows = styles.top + elementBounds.height > winHeight
) { return overflows && anchorBounds.top > winHeight - anchorBounds.bottom
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
} }
// Determine horizontal styles // Applies a dynamic max height constraint if appropriate
if (!maxWidth && useAnchorWidth) { const applyMaxHeight = height => {
styles.maxWidth = anchorBounds.width if (!styles.maxHeight && resizable) {
styles.maxHeight = height
}
} }
if (useAnchorWidth) {
styles.minWidth = anchorBounds.width // Applies the X strategy to our styles
const applyXStrategy = strategy => {
switch (strategy) {
case Strategies.StartToStart:
default:
styles.left = anchorBounds.left
break
case Strategies.EndToEnd:
styles.left = anchorBounds.right - elementBounds.width
break
case Strategies.StartToEnd:
styles.left = anchorBounds.right + offset
break
case Strategies.EndToStart:
styles.left = anchorBounds.left - elementBounds.width - offset
break
case Strategies.MidPoint:
styles.left =
anchorBounds.left +
anchorBounds.width / 2 -
elementBounds.width / 2
break
case Strategies.ScreenEdge:
styles.left = winWidth - elementBounds.width - screenOffset
break
}
} }
// Applies the Y strategy to our styles
const applyYStrategy = strategy => {
switch (strategy) {
case Strategies.StartToStart:
styles.top = anchorBounds.top
applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
break
case Strategies.EndToEnd:
styles.top = anchorBounds.bottom - elementBounds.height
applyMaxHeight(anchorBounds.bottom - screenOffset)
break
case Strategies.StartToEnd:
default:
styles.top = anchorBounds.bottom + offset
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
break
case Strategies.EndToStart:
styles.top = anchorBounds.top - elementBounds.height - offset
applyMaxHeight(anchorBounds.top - screenOffset)
break
case Strategies.MidPoint:
styles.top =
anchorBounds.top +
anchorBounds.height / 2 -
elementBounds.height / 2
break
case Strategies.ScreenEdge:
styles.top = winHeight - elementBounds.height - screenOffset
applyMaxHeight(winHeight - 2 * screenOffset)
break
}
}
// Determine X strategy
if (align === "right") { if (align === "right") {
styles.left = applyXStrategy(Strategies.EndToEnd)
anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") { } else if (align === "right-outside") {
styles.left = anchorBounds.right + offset applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") { } else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset applyXStrategy(Strategies.EndToStart)
} else { } else {
styles.left = anchorBounds.left applyXStrategy(Strategies.StartToStart)
}
// Determine Y strategy
if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint)
} else {
applyYStrategy(Strategies.StartToEnd)
}
// Handle screen overflow
if (doesXOverflow()) {
// Swap left to right
if (align === "left") {
applyXStrategy(Strategies.EndToEnd)
}
// Swap right-outside to left-outside
else if (align === "right-outside") {
applyXStrategy(Strategies.EndToStart)
}
}
if (doesYOverflow()) {
// If wrapping, lock to the bottom of the screen and also reposition to
// the side to not block the anchor
if (wrap) {
applyYStrategy(Strategies.MidPoint)
if (doesYOverflow()) {
applyYStrategy(Strategies.ScreenEdge)
}
applyXStrategy(Strategies.StartToEnd)
if (doesXOverflow()) {
applyXStrategy(Strategies.EndToStart)
}
}
// Othewise invert as normal
else {
// If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") {
applyYStrategy(Strategies.ScreenEdge)
}
// Otherwise flip above
else {
applyYStrategy(Strategies.EndToStart)
}
}
} }
} }

View File

@ -8,6 +8,8 @@
export let size = "S" export let size = "S"
export let extraButtonText export let extraButtonText
export let extraButtonAction export let extraButtonAction
export let extraLinkText
export let extraLinkAction
export let showCloseButton = true export let showCloseButton = true
let show = true let show = true
@ -28,8 +30,13 @@
<use xlink:href="#spectrum-icon-18-{icon}" /> <use xlink:href="#spectrum-icon-18-{icon}" />
</svg> </svg>
<div class="spectrum-Toast-body"> <div class="spectrum-Toast-body">
<div class="spectrum-Toast-content"> <div class="spectrum-Toast-content row-content">
<slot /> <slot />
{#if extraLinkText}
<button class="link" on:click={extraLinkAction}>
<u>{extraLinkText}</u>
</button>
{/if}
</div> </div>
{#if extraButtonText && extraButtonAction} {#if extraButtonText && extraButtonAction}
<button <button
@ -73,4 +80,23 @@
.spectrum-Button { .spectrum-Button {
border: 1px solid rgba(255, 255, 255, 0.2); 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> </style>

View File

@ -11,6 +11,7 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let suffix = null export let suffix = null
export let validateOn = "change"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -24,7 +25,16 @@
const newValue = e.target.value const newValue = e.target.value
dispatch("change", newValue) dispatch("change", newValue)
value = 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) error = validate(newValue)
} }
} }
@ -61,7 +71,7 @@
type={type || "text"} type={type || "text"}
on:input={onChange} on:input={onChange}
on:focus={() => (focused = true)} on:focus={() => (focused = true)}
on:blur={() => (focused = false)} on:blur={onBlur}
class:placeholder class:placeholder
bind:this={ref} 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} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
maxHeight={240}
> >
<div <div
class="popover-content" class="popover-content"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte" import { onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { FIELDS } from "constants/backend"
export let block export let block
export let testData export let testData
@ -228,6 +229,10 @@
categoryName, categoryName,
bindingName bindingName
) => { ) => {
const field = Object.values(FIELDS).find(
field => field.type === value.type && field.subtype === value.subtype
)
return { return {
readableBinding: bindingName readableBinding: bindingName
? `${bindingName}.${name}` ? `${bindingName}.${name}`
@ -238,7 +243,7 @@
icon, icon,
category: categoryName, category: categoryName,
display: { display: {
type: value.type, type: field?.name || value.type,
name, name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount, rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
}, },
@ -282,6 +287,7 @@
for (const key in table?.schema) { for (const key in table?.schema) {
schema[key] = { schema[key] = {
type: table.schema[key].type, type: table.schema[key].type,
subtype: table.schema[key].subtype,
} }
} }
// remove the original binding // remove the original binding
@ -358,7 +364,8 @@
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" && 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 { tables } from "stores/builder"
import { Select, Checkbox, Label } from "@budibase/bbui" import { Select, Checkbox, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte" import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
@ -14,7 +16,6 @@
export let bindings export let bindings
export let isTestModal export let isTestModal
export let isUpdateRow export let isUpdateRow
$: parsedBindings = bindings.map(binding => { $: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding) let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid" clone.icon = "ShareAndroid"
@ -26,15 +27,19 @@
$: { $: {
table = $tables.list.find(table => table._id === value?.tableId) 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 // Just sorting attachment types to the bottom here for a cleaner UX
schemaFields.map(([, schema]) => { schemaFields = Object.entries(table?.schema ?? {}).sort(
([, schemaA], [, schemaB]) =>
(schemaA.type === "attachment") - (schemaB.type === "attachment")
)
schemaFields.forEach(([, schema]) => {
if (!schema.autocolumn && !value[schema.name]) { if (!schema.autocolumn && !value[schema.name]) {
value[schema.name] = "" value[schema.name] = ""
} }
}) })
} }
const onChangeTable = e => { const onChangeTable = e => {
value["tableId"] = e.detail value["tableId"] = e.detail
dispatch("change", value) dispatch("change", value)
@ -114,10 +119,16 @@
</div> </div>
{#if schemaFields.length} {#if schemaFields.length}
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"} {#if !schema.autocolumn}
<div class="schema-fields"> <div
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<Label>{field}</Label> <Label>{field}</Label>
<div class="field-width"> <div
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
{#if isTestModal} {#if isTestModal}
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}

View File

@ -1,10 +1,12 @@
<script> <script>
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui" import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte" import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let onChange export let onChange
export let field export let field
@ -22,6 +24,27 @@
function schemaHasOptions(schema) { function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length 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> </script>
{#if schemaHasOptions(schema) && schema.type !== "array"} {#if schemaHasOptions(schema) && schema.type !== "array"}
@ -77,6 +100,35 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}
/> />
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
<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)} {:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component <svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
@ -90,3 +142,10 @@
title={schema.name} title={schema.name}
/> />
{/if} {/if}
<style>
.attachment-field-spacinng {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-l);
}
</style>

View File

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

View File

@ -1,7 +1,9 @@
<script> <script>
import { createEventDispatcher } from "svelte" 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 FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
export let schema export let schema
export let filters export let filters
@ -10,7 +12,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let modal let drawer
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.entries(schema || {}).map( $: schemaFields = Object.entries(schema || {}).map(
@ -22,37 +24,53 @@
$: text = getText(filters) $: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0 $: 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 getText = filters => {
const count = filters?.filter(filter => filter.field)?.length const count = filters?.filter(filter => filter.field)?.length
return count ? `Filter (${count})` : "Filter" return count ? `Filter (${count})` : "Filter"
} }
</script> </script>
<ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}> <ActionButton icon="Filter" quiet {disabled} on:click={drawer.show} {selected}>
{text} {text}
</ActionButton> </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> <Drawer
.wrapper :global(.main) { bind:this={drawer}
padding: 0; title="Filtering"
} on:drawerHide
</style> 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( 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}` const label = path == null ? column : `${path}.0.${column}`

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<script> <script>
import { FieldType, FieldSubtype } from "@budibase/types" import { FieldType, BBReferenceFieldSubType } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui" import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend" import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api" import { API } from "api"
@ -59,12 +59,16 @@
value: FieldType.ATTACHMENTS, value: FieldType.ATTACHMENTS,
}, },
{ {
label: "User", label: "Users",
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USER}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USER}`,
}, },
{ {
label: "Users", label: "Users",
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USERS}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USERS}`,
},
{
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 { .relationship-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: var(--spacing-m);
} }
.relationship-part { .relationship-part {
flex-basis: 70%; flex: 1 1 auto;
} }
.relationship-type { .relationship-type {
flex-basis: 30%; flex: 0 0 128px;
} }
</style> </style>

View File

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

View File

@ -1,8 +1,10 @@
<script> <script>
import { Body, Label, Input } from "@budibase/bbui" import { Body, Label } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings
onMount(() => { onMount(() => {
if (!parameters.confirm) { if (!parameters.confirm) {
@ -15,11 +17,18 @@
<Body size="S">Enter the message you wish to display to the user.</Body> <Body size="S">Enter the message you wish to display to the user.</Body>
<div class="params"> <div class="params">
<Label small>Title</Label> <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> <Label small>Message</Label>
<Input <DrawerBindableInput
placeholder="Are you sure you want to continue?" 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>
</div> </div>

View File

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

View File

@ -21,26 +21,24 @@
const currentStep = derived(multiStepStore, state => state.currentStep) const currentStep = derived(multiStepStore, state => state.currentStep)
const componentType = "@budibase/standard-components/multistepformblockstep" const componentType = "@budibase/standard-components/multistepformblockstep"
setContext("multi-step-form-block", multiStepStore)
let cachedValue let cachedValue
let cachedInstance = {} let cachedInstance = {}
$: if (!isEqual(cachedValue, value)) { $: if (!isEqual(cachedValue, value)) {
cachedValue = value cachedValue = value
} }
$: if (!isEqual(componentInstance, cachedInstance)) { $: if (!isEqual(componentInstance, cachedInstance)) {
cachedInstance = componentInstance cachedInstance = componentInstance
} }
setContext("multi-step-form-block", multiStepStore)
$: stepCount = cachedValue?.length || 0 $: stepCount = cachedValue?.length || 0
$: updateStore(stepCount) $: updateStore(stepCount)
$: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance) $: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance)
$: emitCurrentStep($currentStep) $: emitCurrentStep($currentStep)
$: stepLabel = getStepLabel($multiStepStore) $: stepLabel = getStepLabel($multiStepStore)
$: stepDef = getDefinition(stepLabel) $: stepDef = getDefinition(stepLabel)
$: stepSettings = cachedValue?.[$currentStep] || {} $: savedInstance = cachedValue?.[$currentStep] || {}
$: defaults = Utils.buildMultiStepFormBlockDefaultProps({ $: defaults = Utils.buildMultiStepFormBlockDefaultProps({
_id: cachedInstance._id, _id: cachedInstance._id,
stepCount: $multiStepStore.stepCount, stepCount: $multiStepStore.stepCount,
@ -48,14 +46,16 @@
actionType: cachedInstance.actionType, actionType: cachedInstance.actionType,
dataSource: cachedInstance.dataSource, 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 = { $: stepInstance = {
_id: Helpers.uuid(), _id: savedInstance._id || Helpers.uuid(),
_component: componentType, _component: savedInstance._component || componentType,
_instanceName: `Step ${currentStep + 1}`, _instanceName: `Step ${currentStep + 1}`,
title: stepSettings.title ?? defaults?.title, title: savedInstance.title ?? defaults?.title,
buttons: stepSettings.buttons || defaults?.buttons, buttons: savedInstance.buttons || defaults?.buttons,
fields: stepSettings.fields, fields: savedInstance.fields,
desc: stepSettings.desc, desc: savedInstance.desc,
// Needed for field configuration // Needed for field configuration
dataSource, dataSource,
@ -92,7 +92,8 @@
} }
const addStep = () => { const addStep = () => {
value = value.toSpliced($currentStep + 1, 0, {}) const newInstance = componentStore.createInstance(componentType)
value = value.toSpliced($currentStep + 1, 0, newInstance)
dispatch("change", value) dispatch("change", value)
multiStepStore.update(state => ({ multiStepStore.update(state => ({
...state, ...state,

View File

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

View File

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

View File

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

View File

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

View File

@ -695,7 +695,7 @@
menuItems={schemaMenuItems} menuItems={schemaMenuItems}
showMenu={!schemaReadOnly} showMenu={!schemaReadOnly}
readOnly={schemaReadOnly} readOnly={schemaReadOnly}
compare={(option, value) => option.type === value.type} compare={(option, value) => option.type === value?.type}
/> />
</Tab> </Tab>
{/if} {/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 => { const upgradeAction = key => {
return defaultNavigateAction( return defaultNavigateAction(
key, key,
"Upgrade Plan", "Upgrade",
`${get(admin).accountPortalUrl}/portal/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 { import {
FieldType, FieldType,
FieldSubtype, BBReferenceFieldSubType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
AutoFieldSubType, AutoFieldSubType,
Hosting, Hosting,
@ -159,15 +159,17 @@ export const FIELDS = {
}, },
USER: { USER: {
name: "User", name: "User",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE_SINGLE,
subtype: FieldSubtype.USER, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USER], icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][
BBReferenceFieldSubType.USER
],
}, },
USERS: { USERS: {
name: "Users", name: "User List",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USERS, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USERS], icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER],
constraints: { constraints: {
type: "array", type: "array",
}, },
@ -253,6 +255,7 @@ export const SchemaTypeOptions = [
{ label: "Number", value: FieldType.NUMBER }, { label: "Number", value: FieldType.NUMBER },
{ label: "Boolean", value: FieldType.BOOLEAN }, { label: "Boolean", value: FieldType.BOOLEAN },
{ label: "Datetime", value: FieldType.DATETIME }, { label: "Datetime", value: FieldType.DATETIME },
{ label: "JSON", value: FieldType.JSON },
] ]
export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({ 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 ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal" import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils" import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
import { FIELDS } from "constants/backend"
const { ContextScopes } = Constants const { ContextScopes } = Constants
@ -491,7 +492,7 @@ const generateComponentContextBindings = (asset, componentContext) => {
icon: bindingCategory.icon, icon: bindingCategory.icon,
display: { display: {
name: `${fieldSchema.name || key}`, 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 // are objects
let fixedSchema = {} let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => { 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") { if (typeof fieldSchema === "string") {
fixedSchema[fieldName] = { fixedSchema[fieldName] = {
type: fieldSchema, type: fieldSchema,
name: fieldName, name: fieldName,
display: { type: fieldSchema },
} }
} else { } else {
fixedSchema[fieldName] = { fixedSchema[fieldName] = {
...fieldSchema, ...fieldSchema,
name: fieldName, name: fieldName,
display: { type: field?.name || fieldSchema.type },
} }
} }
}) })
@ -1106,50 +1115,51 @@ export const getAllStateVariables = () => {
getAllAssets().forEach(asset => { getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => { findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._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) => { const parseEventSettings = (settings, comp) => {
if (!settings?.length) {
return
}
// Extract top level event settings
settings settings
.filter(setting => setting.type === "event") .filter(setting => setting.type === "event")
.forEach(setting => { .forEach(setting => {
eventSettings.push(comp[setting.key]) eventSettings.push(comp[setting.key])
}) })
}
const parseComponentSettings = (settings, component) => { // Recurse into any nested instance types
// Parse the nested button configurations
settings settings
.filter(setting => setting.type === "buttonConfiguration") .filter(setting => nestedTypes.includes(setting.type))
.forEach(setting => { .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)) { // Backwards compatibility for multi-step from blocks which
buttonConfig.forEach(button => { // didn't set a proper component type previously.
const nestedSettings = componentStore.getComponentSettings( if (setting.type === "stepConfiguration" && !type) {
button._component type = "@budibase/standard-components/multistepformblockstep"
) }
parseEventSettings(nestedSettings, button)
// Parsed nested component instances inside this setting
const nestedSettings = componentStore.getComponentSettings(type)
parseEventSettings(nestedSettings, instance)
}) })
} }
}) })
parseEventSettings(settings, component)
} }
// Parse the base component settings parseEventSettings(settings, component)
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)
})
}) })
}) })

View File

@ -20,6 +20,9 @@ export function getFormattedPlanName(userPlanType) {
case PlanType.ENTERPRISE: case PlanType.ENTERPRISE:
planName = "Enterprise" planName = "Enterprise"
break break
case PlanType.ENTERPRISE_BASIC_TRIAL:
planName = "Trial"
break
default: default:
planName = "Free" // Default to "Free" if the type is not explicitly handled 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 { UserAvatars } from "@budibase/frontend-core"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import PreviewOverlay from "./_components/PreviewOverlay.svelte" import PreviewOverlay from "./_components/PreviewOverlay.svelte"
import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte"
export let application export let application
@ -192,6 +193,8 @@
<CommandPalette /> <CommandPalette />
</Modal> </Modal>
<EnterpriseBasicTrialModal />
<style> <style>
.back-to-apps { .back-to-apps {
display: contents; display: contents;

View File

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

View File

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

View File

@ -6,7 +6,7 @@
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
</script> </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} {#if $admin.cloud && $auth?.user?.accountPortalAccess}
<Button <Button
cta cta

View File

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

View File

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

View File

@ -29,6 +29,7 @@
const manageUrl = `${$admin.accountPortalUrl}/portal/billing` const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"] const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"]
const oneDayInSeconds = 86400
const EXCLUDE_QUOTAS = { const EXCLUDE_QUOTAS = {
Queries: () => true, Queries: () => true,
@ -104,24 +105,17 @@
if (!timestamp) { if (!timestamp) {
return return
} }
const now = new Date() const diffTime = Math.abs(timestamp - new Date().getTime()) / 1000
now.setHours(0) return Math.floor(diffTime / oneDayInSeconds)
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 setTextRows = () => { const setTextRows = () => {
textRows = [] textRows = []
if (cancelAt && !usesInvoicing) { 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({ textRows.push({
message: `${getDaysRemaining(cancelAt)} days remaining`, message: `${getDaysRemaining(cancelAt)} days remaining`,
tooltip: new Date(cancelAt), tooltip: new Date(cancelAt),

View File

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

View File

@ -3869,12 +3869,6 @@
"key": "timeOnly", "key": "timeOnly",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "24-hour time",
"key": "time24hr",
"defaultValue": false
},
{ {
"type": "boolean", "type": "boolean",
"label": "Ignore time zones", "label": "Ignore time zones",
@ -4226,8 +4220,8 @@
] ]
}, },
"attachmentfield": { "attachmentfield": {
"name": "Attachment list", "name": "Attachment List",
"icon": "Attach", "icon": "DocumentFragmentGroup",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "editable": true,
@ -4324,7 +4318,7 @@
}, },
"attachmentsinglefield": { "attachmentsinglefield": {
"name": "Single Attachment", "name": "Single Attachment",
"icon": "Attach", "icon": "DocumentFragment",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "editable": true,
@ -6723,7 +6717,20 @@
"illegalChildren": ["section", "sidepanel"], "illegalChildren": ["section", "sidepanel"],
"showEmptyState": false, "showEmptyState": false,
"draggable": 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": { "rowexplorer": {
"block": true, "block": true,
@ -7018,8 +7025,8 @@
}, },
"bbreferencefield": { "bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels", "devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field", "name": "User List Field",
"icon": "User", "icon": "UserGroup",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "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/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",
"@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/tag": "^3.1.4",
"@spectrum-css/typography": "^3.0.2",
"@spectrum-css/vars": "^3.0.1",
"apexcharts": "^3.22.1", "apexcharts": "^3.22.1",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
@ -41,7 +34,6 @@
"screenfull": "^6.0.1", "screenfull": "^6.0.1",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-apexcharts": "^1.0.2", "svelte-apexcharts": "^1.0.2",
"svelte-flatpickr": "^3.3.4",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -206,13 +206,6 @@
/> />
{/key} {/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 --> <!-- Modal container to ensure they sit on top -->
<div class="modal-container" /> <div class="modal-container" />

View File

@ -60,16 +60,6 @@
--spectrum-link-primary-m-text-color-hover: var(--primaryColorHover); --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 */ /* Custom scrollbars */
:global(::-webkit-scrollbar) { :global(::-webkit-scrollbar) {
width: 8px; width: 8px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,10 @@
<script> <script>
import RelationshipField from "./RelationshipField.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte"
export let defaultValue export let defaultValue
export let type = FieldType.BB_REFERENCE
function updateUserIDs(value) { function updateUserIDs(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -22,6 +24,7 @@
<RelationshipField <RelationshipField
{...$$props} {...$$props}
{type}
datasourceType={"user"} datasourceType={"user"}
primaryDisplay={"email"} primaryDisplay={"email"}
defaultValue={updateReferences(defaultValue)} 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} readonly={fieldState.readonly}
error={fieldState.error} error={fieldState.error}
id={fieldState.fieldId} id={fieldState.fieldId}
appendTo={document.getElementById("flatpickr-root")}
{enableTime} {enableTime}
{timeOnly} {timeOnly}
{time24hr} {time24hr}

View File

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

View File

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

View File

@ -240,6 +240,7 @@ const triggerAutomationHandler = async action => {
const navigationHandler = action => { const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters const { url, peek, externalNewTab } = action.parameters
routeStore.actions.navigate(url, peek, externalNewTab) routeStore.actions.navigate(url, peek, externalNewTab)
closeSidePanelHandler()
} }
const queryExecutionHandler = async action => { const queryExecutionHandler = async action => {
@ -541,16 +542,22 @@ export const enrichButtonActions = (actions, context) => {
// then execute the rest of the actions in the chain // then execute the rest of the actions in the chain
const result = await callback() const result = await callback()
if (result !== false) { 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) buttonContext.push(result)
const newContext = { ...context, actions: buttonContext } 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( const next = enrichButtonActions(
actions.slice(i + 1), actions.slice(i + 1),
newContext 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 { } else {
resolve(false) resolve(false)
} }

View File

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

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