Merge branch 'master' into fix/builder-tabs-underline
This commit is contained in:
commit
a3ec428c5e
|
@ -170,7 +170,8 @@ jobs:
|
|||
docker pull mongo:7.0-jammy &
|
||||
docker pull mariadb:lts &
|
||||
docker pull testcontainers/ryuk:0.5.1 &
|
||||
docker pull budibase/couchdb:v3.2.1-sql &
|
||||
docker pull budibase/couchdb:v3.2.1-sqs &
|
||||
docker pull minio/minio &
|
||||
docker pull redis &
|
||||
|
||||
wait $(jobs -p)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
apiVersion: {{ ternary "autoscaling/v2" "autoscaling/v2beta2" (.Capabilities.APIVersions.Has "autoscaling/v2") }}
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "budibase.fullname" . }}-apps
|
||||
name: {{ include "budibase.fullname" . }}-automation-worker
|
||||
labels:
|
||||
{{- include "budibase.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
|
|
@ -46,7 +46,7 @@ export default async function setup() {
|
|||
await killContainers(containers)
|
||||
|
||||
try {
|
||||
let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
|
||||
const couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
|
||||
.withExposedPorts(5984, 4984)
|
||||
.withEnvironment({
|
||||
COUCHDB_PASSWORD: "budibase",
|
||||
|
@ -69,7 +69,20 @@ export default async function setup() {
|
|||
).withStartupTimeout(20000)
|
||||
)
|
||||
|
||||
await couchdb.start()
|
||||
const minio = new GenericContainer("minio/minio")
|
||||
.withExposedPorts(9000)
|
||||
.withCommand(["server", "/data"])
|
||||
.withEnvironment({
|
||||
MINIO_ACCESS_KEY: "budibase",
|
||||
MINIO_SECRET_KEY: "budibase",
|
||||
})
|
||||
.withLabels({ "com.budibase": "true" })
|
||||
.withReuse()
|
||||
.withWaitStrategy(
|
||||
Wait.forHttp("/minio/health/ready", 9000).withStartupTimeout(10000)
|
||||
)
|
||||
|
||||
await Promise.all([couchdb.start(), minio.start()])
|
||||
} finally {
|
||||
lockfile.unlockSync(lockPath)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.24.1",
|
||||
"version": "2.24.2",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -281,7 +281,7 @@ export function doInScimContext(task: any) {
|
|||
return newContext(updates, task)
|
||||
}
|
||||
|
||||
export async function ensureSnippetContext() {
|
||||
export async function ensureSnippetContext(enabled = !env.isTest()) {
|
||||
const ctx = getCurrentContext()
|
||||
|
||||
// If we've already added snippets to context, continue
|
||||
|
@ -292,7 +292,7 @@ export async function ensureSnippetContext() {
|
|||
// Otherwise get snippets for this app and update context
|
||||
let snippets: Snippet[] | undefined
|
||||
const db = getAppDB()
|
||||
if (db && !env.isTest()) {
|
||||
if (db && enabled) {
|
||||
const app = await db.get<App>(DocumentType.APP_METADATA)
|
||||
snippets = app.snippets
|
||||
}
|
||||
|
|
|
@ -12,6 +12,10 @@ import { dataFilters } from "@budibase/shared-core"
|
|||
|
||||
export const removeKeyNumbering = dataFilters.removeKeyNumbering
|
||||
|
||||
function isEmpty(value: any) {
|
||||
return value == null || value === ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to build lucene query URLs.
|
||||
* Optionally takes a base lucene query object.
|
||||
|
@ -282,15 +286,14 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
|
||||
const equal = (key: string, value: any) => {
|
||||
// 0 evaluates to false, which means we would return all rows if we don't check it
|
||||
if (!value && value !== 0) {
|
||||
if (isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
|
||||
}
|
||||
|
||||
const contains = (key: string, value: any, mode = "AND") => {
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
if (isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
|
@ -306,7 +309,7 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
|
||||
const fuzzy = (key: string, value: any) => {
|
||||
if (!value) {
|
||||
if (isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
value = builder.preprocess(value, {
|
||||
|
@ -328,7 +331,7 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
|
||||
const oneOf = (key: string, value: any) => {
|
||||
if (!value) {
|
||||
if (isEmpty(value)) {
|
||||
return `*:*`
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
|
@ -386,7 +389,7 @@ export class QueryBuilder<T> {
|
|||
// Construct the actual lucene search query string from JSON structure
|
||||
if (this.#query.string) {
|
||||
build(this.#query.string, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
if (isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
value = builder.preprocess(value, {
|
||||
|
@ -399,7 +402,7 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
if (this.#query.range) {
|
||||
build(this.#query.range, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
if (isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
if (value.low == null || value.low === "") {
|
||||
|
@ -421,7 +424,7 @@ export class QueryBuilder<T> {
|
|||
}
|
||||
if (this.#query.notEqual) {
|
||||
build(this.#query.notEqual, (key: string, value: any) => {
|
||||
if (!value) {
|
||||
if (isEmpty(value)) {
|
||||
return null
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
|
@ -431,10 +434,28 @@ export class QueryBuilder<T> {
|
|||
})
|
||||
}
|
||||
if (this.#query.empty) {
|
||||
build(this.#query.empty, (key: string) => `(*:* -${key}:["" TO *])`)
|
||||
build(this.#query.empty, (key: string) => {
|
||||
// Because the structure of an empty filter looks like this:
|
||||
// { empty: { someKey: null } }
|
||||
//
|
||||
// The check inside of `build` does not set `allFiltersEmpty`, which results
|
||||
// in weird behaviour when the empty filter is the only filter. We get around
|
||||
// this by setting `allFiltersEmpty` to false here.
|
||||
allFiltersEmpty = false
|
||||
return `(*:* -${key}:["" TO *])`
|
||||
})
|
||||
}
|
||||
if (this.#query.notEmpty) {
|
||||
build(this.#query.notEmpty, (key: string) => `${key}:["" TO *]`)
|
||||
build(this.#query.notEmpty, (key: string) => {
|
||||
// Because the structure of a notEmpty filter looks like this:
|
||||
// { notEmpty: { someKey: null } }
|
||||
//
|
||||
// The check inside of `build` does not set `allFiltersEmpty`, which results
|
||||
// in weird behaviour when the empty filter is the only filter. We get around
|
||||
// this by setting `allFiltersEmpty` to false here.
|
||||
allFiltersEmpty = false
|
||||
return `${key}:["" TO *]`
|
||||
})
|
||||
}
|
||||
if (this.#query.oneOf) {
|
||||
build(this.#query.oneOf, oneOf)
|
||||
|
|
|
@ -13,13 +13,14 @@ import { bucketTTLConfig, budibaseTempDir } from "./utils"
|
|||
import { v4 } from "uuid"
|
||||
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
|
||||
import fsp from "fs/promises"
|
||||
import { HeadObjectOutput } from "aws-sdk/clients/s3"
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline)
|
||||
// use this as a temporary store of buckets that are being created
|
||||
const STATE = {
|
||||
bucketCreationPromises: {},
|
||||
}
|
||||
const signedFilePrefix = "/files/signed"
|
||||
export const SIGNED_FILE_PREFIX = "/files/signed"
|
||||
|
||||
type ListParams = {
|
||||
ContinuationToken?: string
|
||||
|
@ -40,8 +41,13 @@ type UploadParams = BaseUploadParams & {
|
|||
path?: string | PathLike
|
||||
}
|
||||
|
||||
type StreamUploadParams = BaseUploadParams & {
|
||||
stream: ReadStream
|
||||
export type StreamTypes =
|
||||
| ReadStream
|
||||
| NodeJS.ReadableStream
|
||||
| ReadableStream<Uint8Array>
|
||||
|
||||
export type StreamUploadParams = BaseUploadParams & {
|
||||
stream?: StreamTypes
|
||||
}
|
||||
|
||||
const CONTENT_TYPE_MAP: any = {
|
||||
|
@ -83,7 +89,7 @@ export function ObjectStore(
|
|||
bucket: string,
|
||||
opts: { presigning: boolean } = { presigning: false }
|
||||
) {
|
||||
const config: any = {
|
||||
const config: AWS.S3.ClientConfiguration = {
|
||||
s3ForcePathStyle: true,
|
||||
signatureVersion: "v4",
|
||||
apiVersion: "2006-03-01",
|
||||
|
@ -174,12 +180,10 @@ export async function upload({
|
|||
const objectStore = ObjectStore(bucketName)
|
||||
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
|
||||
|
||||
if (ttl && (bucketCreated.created || bucketCreated.exists)) {
|
||||
if (ttl && bucketCreated.created) {
|
||||
let ttlConfig = bucketTTLConfig(bucketName, ttl)
|
||||
if (objectStore.putBucketLifecycleConfiguration) {
|
||||
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
|
||||
}
|
||||
}
|
||||
|
||||
let contentType = type
|
||||
if (!contentType) {
|
||||
|
@ -222,12 +226,10 @@ export async function streamUpload({
|
|||
const objectStore = ObjectStore(bucketName)
|
||||
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
|
||||
|
||||
if (ttl && (bucketCreated.created || bucketCreated.exists)) {
|
||||
if (ttl && bucketCreated.created) {
|
||||
let ttlConfig = bucketTTLConfig(bucketName, ttl)
|
||||
if (objectStore.putBucketLifecycleConfiguration) {
|
||||
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
|
||||
}
|
||||
}
|
||||
|
||||
// Set content type for certain known extensions
|
||||
if (filename?.endsWith(".js")) {
|
||||
|
@ -333,7 +335,7 @@ export function getPresignedUrl(
|
|||
const signedUrl = new URL(url)
|
||||
const path = signedUrl.pathname
|
||||
const query = signedUrl.search
|
||||
return `${signedFilePrefix}${path}${query}`
|
||||
return `${SIGNED_FILE_PREFIX}${path}${query}`
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -521,6 +523,26 @@ export async function getReadStream(
|
|||
return client.getObject(params).createReadStream()
|
||||
}
|
||||
|
||||
export async function getObjectMetadata(
|
||||
bucket: string,
|
||||
path: string
|
||||
): Promise<HeadObjectOutput> {
|
||||
bucket = sanitizeBucket(bucket)
|
||||
path = sanitizeKey(path)
|
||||
|
||||
const client = ObjectStore(bucket)
|
||||
const params = {
|
||||
Bucket: bucket,
|
||||
Key: path,
|
||||
}
|
||||
|
||||
try {
|
||||
return await client.headObject(params).promise()
|
||||
} catch (err: any) {
|
||||
throw new Error("Unable to retrieve metadata from object")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract
|
||||
the bucket and the path from it
|
||||
|
@ -530,7 +552,9 @@ export function extractBucketAndPath(
|
|||
): { bucket: string; path: string } | null {
|
||||
const baseUrl = url.split("?")[0]
|
||||
|
||||
const regex = new RegExp(`^${signedFilePrefix}/(?<bucket>[^/]+)/(?<path>.+)$`)
|
||||
const regex = new RegExp(
|
||||
`^${SIGNED_FILE_PREFIX}/(?<bucket>[^/]+)/(?<path>.+)$`
|
||||
)
|
||||
const match = baseUrl.match(regex)
|
||||
|
||||
if (match && match.groups) {
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import { join } from "path"
|
||||
import path, { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import fs from "fs"
|
||||
import env from "../environment"
|
||||
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"
|
||||
|
||||
import * as objectStore from "./objectStore"
|
||||
import {
|
||||
AutomationAttachment,
|
||||
AutomationAttachmentContent,
|
||||
BucketedContent,
|
||||
} from "@budibase/types"
|
||||
/****************************************************
|
||||
* NOTE: When adding a new bucket - name *
|
||||
* sure that S3 usages (like budibase-infra) *
|
||||
|
@ -55,3 +60,50 @@ export const bucketTTLConfig = (
|
|||
|
||||
return params
|
||||
}
|
||||
|
||||
async function processUrlAttachment(
|
||||
attachment: AutomationAttachment
|
||||
): Promise<AutomationAttachmentContent> {
|
||||
const response = await fetch(attachment.url)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Unexpected response ${response.statusText}`)
|
||||
}
|
||||
const fallbackFilename = path.basename(new URL(attachment.url).pathname)
|
||||
return {
|
||||
filename: attachment.filename || fallbackFilename,
|
||||
content: response.body,
|
||||
}
|
||||
}
|
||||
|
||||
export async function processObjectStoreAttachment(
|
||||
attachment: AutomationAttachment
|
||||
): Promise<BucketedContent> {
|
||||
const result = objectStore.extractBucketAndPath(attachment.url)
|
||||
|
||||
if (result === null) {
|
||||
throw new Error("Invalid signed URL")
|
||||
}
|
||||
|
||||
const { bucket, path: objectPath } = result
|
||||
const readStream = await objectStore.getReadStream(bucket, objectPath)
|
||||
const fallbackFilename = path.basename(objectPath)
|
||||
return {
|
||||
bucket,
|
||||
path: objectPath,
|
||||
filename: attachment.filename || fallbackFilename,
|
||||
content: readStream,
|
||||
}
|
||||
}
|
||||
|
||||
export async function processAutomationAttachment(
|
||||
attachment: AutomationAttachment
|
||||
): Promise<AutomationAttachmentContent | BucketedContent> {
|
||||
const isFullyFormedUrl =
|
||||
attachment.url?.startsWith("http://") ||
|
||||
attachment.url?.startsWith("https://")
|
||||
if (isFullyFormedUrl) {
|
||||
return await processUrlAttachment(attachment)
|
||||
} else {
|
||||
return await processObjectStoreAttachment(attachment)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,3 @@ export { generator } from "./structures"
|
|||
export * as testContainerUtils from "./testContainerUtils"
|
||||
export * as utils from "./utils"
|
||||
export * from "./jestUtils"
|
||||
import * as minio from "./minio"
|
||||
|
||||
export const objectStoreTestProviders = { minio }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -86,10 +86,18 @@ export function setupEnv(...envs: any[]) {
|
|||
throw new Error("CouchDB SQL port not found")
|
||||
}
|
||||
|
||||
const minio = getContainerByImage("minio/minio")
|
||||
|
||||
const minioPort = getExposedV4Port(minio, 9000)
|
||||
if (!minioPort) {
|
||||
throw new Error("Minio port not found")
|
||||
}
|
||||
|
||||
const configs = [
|
||||
{ key: "COUCH_DB_PORT", value: `${couchPort}` },
|
||||
{ key: "COUCH_DB_URL", value: `http://127.0.0.1:${couchPort}` },
|
||||
{ key: "COUCH_DB_SQL_URL", value: `http://127.0.0.1:${couchSqlPort}` },
|
||||
{ key: "MINIO_URL", value: `http://127.0.0.1:${minioPort}` },
|
||||
]
|
||||
|
||||
for (const config of configs.filter(x => !!x.value)) {
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
export let size = "S"
|
||||
export let extraButtonText
|
||||
export let extraButtonAction
|
||||
export let extraLinkText
|
||||
export let extraLinkAction
|
||||
export let showCloseButton = true
|
||||
|
||||
let show = true
|
||||
|
@ -28,8 +30,13 @@
|
|||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||
</svg>
|
||||
<div class="spectrum-Toast-body">
|
||||
<div class="spectrum-Toast-content">
|
||||
<div class="spectrum-Toast-content row-content">
|
||||
<slot />
|
||||
{#if extraLinkText}
|
||||
<button class="link" on:click={extraLinkAction}>
|
||||
<u>{extraLinkText}</u>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extraButtonText && extraButtonAction}
|
||||
<button
|
||||
|
@ -73,4 +80,23 @@
|
|||
.spectrum-Button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.row-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
margin: 0;
|
||||
margin-left: 0.5em;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
u {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -358,7 +358,8 @@
|
|||
value.customType !== "cron" &&
|
||||
value.customType !== "triggerSchema" &&
|
||||
value.customType !== "automationFields" &&
|
||||
value.type !== "attachment"
|
||||
value.type !== "attachment" &&
|
||||
value.type !== "attachment_single"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
import { tables } from "stores/builder"
|
||||
import { Select, Checkbox, Label } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { FieldType } from "@budibase/types"
|
||||
|
||||
import RowSelectorTypes from "./RowSelectorTypes.svelte"
|
||||
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
|
||||
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
|
||||
|
@ -14,7 +16,6 @@
|
|||
export let bindings
|
||||
export let isTestModal
|
||||
export let isUpdateRow
|
||||
|
||||
$: parsedBindings = bindings.map(binding => {
|
||||
let clone = Object.assign({}, binding)
|
||||
clone.icon = "ShareAndroid"
|
||||
|
@ -26,15 +27,19 @@
|
|||
|
||||
$: {
|
||||
table = $tables.list.find(table => table._id === value?.tableId)
|
||||
schemaFields = Object.entries(table?.schema ?? {})
|
||||
// surface the schema so the user can see it in the json
|
||||
schemaFields.map(([, schema]) => {
|
||||
|
||||
// Just sorting attachment types to the bottom here for a cleaner UX
|
||||
schemaFields = Object.entries(table?.schema ?? {}).sort(
|
||||
([, schemaA], [, schemaB]) =>
|
||||
(schemaA.type === "attachment") - (schemaB.type === "attachment")
|
||||
)
|
||||
|
||||
schemaFields.forEach(([, schema]) => {
|
||||
if (!schema.autocolumn && !value[schema.name]) {
|
||||
value[schema.name] = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onChangeTable = e => {
|
||||
value["tableId"] = e.detail
|
||||
dispatch("change", value)
|
||||
|
@ -114,10 +119,16 @@
|
|||
</div>
|
||||
{#if schemaFields.length}
|
||||
{#each schemaFields as [field, schema]}
|
||||
{#if !schema.autocolumn && schema.type !== "attachment"}
|
||||
<div class="schema-fields">
|
||||
{#if !schema.autocolumn}
|
||||
<div
|
||||
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
|
||||
schema.type !== FieldType.ATTACHMENT_SINGLE}
|
||||
>
|
||||
<Label>{field}</Label>
|
||||
<div class="field-width">
|
||||
<div
|
||||
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
|
||||
schema.type !== FieldType.ATTACHMENT_SINGLE}
|
||||
>
|
||||
{#if isTestModal}
|
||||
<RowSelectorTypes
|
||||
{isTestModal}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script>
|
||||
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
|
||||
import { FieldType } from "@budibase/types"
|
||||
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
|
||||
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
|
||||
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
|
||||
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
|
||||
import Editor from "components/integration/QueryEditor.svelte"
|
||||
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
|
||||
|
||||
export let onChange
|
||||
export let field
|
||||
|
@ -22,6 +24,27 @@
|
|||
function schemaHasOptions(schema) {
|
||||
return !!schema.constraints?.inclusion?.length
|
||||
}
|
||||
|
||||
const handleAttachmentParams = keyValuObj => {
|
||||
let params = {}
|
||||
|
||||
if (
|
||||
schema.type === FieldType.ATTACHMENT_SINGLE &&
|
||||
Object.keys(keyValuObj).length === 0
|
||||
) {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(keyValuObj)) {
|
||||
keyValuObj = [keyValuObj]
|
||||
}
|
||||
|
||||
if (keyValuObj.length) {
|
||||
for (let param of keyValuObj) {
|
||||
params[param.url] = param.filename
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if schemaHasOptions(schema) && schema.type !== "array"}
|
||||
|
@ -77,6 +100,35 @@
|
|||
on:change={e => onChange(e, field)}
|
||||
useLabel={false}
|
||||
/>
|
||||
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
|
||||
<div class="attachment-field-spacinng">
|
||||
<KeyValueBuilder
|
||||
on:change={e =>
|
||||
onChange(
|
||||
{
|
||||
detail:
|
||||
schema.type === FieldType.ATTACHMENT_SINGLE
|
||||
? e.detail.length > 0
|
||||
? { url: e.detail[0].name, filename: e.detail[0].value }
|
||||
: {}
|
||||
: e.detail.map(({ name, value }) => ({
|
||||
url: name,
|
||||
filename: value,
|
||||
})),
|
||||
},
|
||||
field
|
||||
)}
|
||||
object={handleAttachmentParams(value[field])}
|
||||
allowJS
|
||||
{bindings}
|
||||
keyBindings
|
||||
customButtonText={"Add attachment"}
|
||||
keyPlaceholder={"URL"}
|
||||
valuePlaceholder={"Filename"}
|
||||
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
|
||||
Object.keys(value[field]).length >= 1}
|
||||
/>
|
||||
</div>
|
||||
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
|
||||
<svelte:component
|
||||
this={isTestModal ? ModalBindableInput : DrawerBindableInput}
|
||||
|
@ -90,3 +142,10 @@
|
|||
title={schema.name}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.attachment-field-spacinng {
|
||||
margin-top: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { ActionButton, Modal, ModalContent } from "@budibase/bbui"
|
||||
import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui"
|
||||
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
|
||||
import { getUserBindings } from "dataBinding"
|
||||
import { makePropSafe } from "@budibase/string-templates"
|
||||
|
||||
export let schema
|
||||
export let filters
|
||||
|
@ -10,7 +12,7 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let modal
|
||||
let drawer
|
||||
|
||||
$: tempValue = filters || []
|
||||
$: schemaFields = Object.entries(schema || {}).map(
|
||||
|
@ -22,37 +24,53 @@
|
|||
|
||||
$: text = getText(filters)
|
||||
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
|
||||
|
||||
$: bindings = [
|
||||
{
|
||||
type: "context",
|
||||
runtimeBinding: `${makePropSafe("now")}`,
|
||||
readableBinding: `Date`,
|
||||
category: "Date",
|
||||
icon: "Date",
|
||||
display: {
|
||||
name: "Server date",
|
||||
},
|
||||
},
|
||||
...getUserBindings(),
|
||||
]
|
||||
const getText = filters => {
|
||||
const count = filters?.filter(filter => filter.field)?.length
|
||||
return count ? `Filter (${count})` : "Filter"
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}>
|
||||
<ActionButton icon="Filter" quiet {disabled} on:click={drawer.show} {selected}>
|
||||
{text}
|
||||
</ActionButton>
|
||||
<Modal bind:this={modal}>
|
||||
<ModalContent
|
||||
title="Filter"
|
||||
confirmText="Save"
|
||||
size="XL"
|
||||
onConfirm={() => dispatch("change", tempValue)}
|
||||
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
title="Filtering"
|
||||
on:drawerHide
|
||||
on:drawerShow
|
||||
forceModal
|
||||
>
|
||||
<div class="wrapper">
|
||||
<Button
|
||||
cta
|
||||
slot="buttons"
|
||||
on:click={() => {
|
||||
dispatch("change", tempValue)
|
||||
drawer.hide()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DrawerContent slot="body">
|
||||
<FilterBuilder
|
||||
allowBindings={false}
|
||||
{filters}
|
||||
{schemaFields}
|
||||
datasource={{ type: "table", tableId }}
|
||||
on:change={e => (tempValue = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.wrapper :global(.main) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
readableToRuntimeBinding,
|
||||
runtimeToReadableBinding,
|
||||
} from "dataBinding"
|
||||
import { FieldType } from "@budibase/types"
|
||||
|
||||
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
|
||||
import { createEventDispatcher, setContext } from "svelte"
|
||||
|
@ -102,6 +103,8 @@
|
|||
longform: value => !isJSBinding(value),
|
||||
json: value => !isJSBinding(value),
|
||||
boolean: isValidBoolean,
|
||||
attachment: false,
|
||||
attachment_single: false,
|
||||
}
|
||||
|
||||
const isValid = value => {
|
||||
|
@ -116,7 +119,16 @@
|
|||
if (type === "json" && !isJSBinding(value)) {
|
||||
return "json-slot-icon"
|
||||
}
|
||||
if (!["string", "number", "bigint", "barcodeqr"].includes(type)) {
|
||||
if (
|
||||
![
|
||||
"string",
|
||||
"number",
|
||||
"bigint",
|
||||
"barcodeqr",
|
||||
"attachment",
|
||||
"attachment_single",
|
||||
].includes(type)
|
||||
) {
|
||||
return "slot-icon"
|
||||
}
|
||||
return ""
|
||||
|
@ -157,7 +169,7 @@
|
|||
{updateOnChange}
|
||||
/>
|
||||
{/if}
|
||||
{#if !disabled && type !== "formula"}
|
||||
{#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
|
||||
<div
|
||||
class={`icon ${getIconClass(value, type)}`}
|
||||
on:click={() => {
|
||||
|
|
|
@ -37,6 +37,7 @@
|
|||
export let customButtonText = null
|
||||
export let keyBindings = false
|
||||
export let allowJS = false
|
||||
export let actionButtonDisabled = false
|
||||
export let compare = (option, value) => option === value
|
||||
|
||||
let fields = Object.entries(object || {}).map(([name, value]) => ({
|
||||
|
@ -189,7 +190,14 @@
|
|||
{/if}
|
||||
{#if !readOnly && !noAddButton}
|
||||
<div>
|
||||
<ActionButton icon="Add" secondary thin outline on:click={addEntry}>
|
||||
<ActionButton
|
||||
disabled={actionButtonDisabled}
|
||||
icon="Add"
|
||||
secondary
|
||||
thin
|
||||
outline
|
||||
on:click={addEntry}
|
||||
>
|
||||
{#if customButtonText}
|
||||
{customButtonText}
|
||||
{:else}
|
||||
|
|
|
@ -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>
|
|
@ -12,7 +12,7 @@ const defaultCacheFn = key => {
|
|||
const upgradeAction = key => {
|
||||
return defaultNavigateAction(
|
||||
key,
|
||||
"Upgrade Plan",
|
||||
"Upgrade",
|
||||
`${get(admin).accountPortalUrl}/portal/upgrade`
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import { auth, licensing } from "stores/portal"
|
||||
import { API } from "api"
|
||||
import { PlanType } from "@budibase/types"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
let freeTrialModal
|
||||
|
||||
|
@ -14,7 +15,8 @@
|
|||
const showFreeTrialModal = (planType, freeTrialModal) => {
|
||||
if (
|
||||
planType === PlanType.ENTERPRISE_BASIC_TRIAL &&
|
||||
!$auth.user?.freeTrialConfirmedAt
|
||||
!$auth.user?.freeTrialConfirmedAt &&
|
||||
sdk.users.isAdmin($auth.user)
|
||||
) {
|
||||
freeTrialModal?.show()
|
||||
}
|
||||
|
|
|
@ -98,14 +98,22 @@
|
|||
})
|
||||
}
|
||||
|
||||
async function fetchBackups(filters, page, dateRange) {
|
||||
const response = await backups.searchBackups({
|
||||
async function fetchBackups(filters, page, dateRange = []) {
|
||||
const body = {
|
||||
appId: $appStore.appId,
|
||||
...filters,
|
||||
page,
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
})
|
||||
}
|
||||
|
||||
const [startDate, endDate] = dateRange
|
||||
if (startDate) {
|
||||
body.startDate = startDate
|
||||
}
|
||||
if (endDate) {
|
||||
body.endDate = endDate
|
||||
}
|
||||
|
||||
const response = await backups.searchBackups(body)
|
||||
pageInfo.fetched(response.hasNextPage, response.nextPage)
|
||||
|
||||
// flatten so we have an easier structure to use for the table schema
|
||||
|
@ -120,7 +128,7 @@
|
|||
})
|
||||
await fetchBackups(filterOpt, page)
|
||||
notifications.success(response.message)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
notifications.error("Unable to create backup")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { sdk } from "@budibase/shared-core"
|
||||
</script>
|
||||
|
||||
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
|
||||
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan && !$licensing.isEnterpriseTrial}
|
||||
{#if $admin.cloud && $auth?.user?.accountPortalAccess}
|
||||
<Button
|
||||
cta
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { isActive, redirect, goto, url } from "@roxi/routify"
|
||||
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
|
||||
import { organisation, auth, menu, appsStore } from "stores/portal"
|
||||
import { organisation, auth, menu, appsStore, licensing } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import UpgradeButton from "./_components/UpgradeButton.svelte"
|
||||
import MobileMenu from "./_components/MobileMenu.svelte"
|
||||
|
@ -10,6 +10,8 @@
|
|||
import HelpMenu from "components/common/HelpMenu.svelte"
|
||||
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import EnterpriseBasicTrialBanner from "components/portal/licensing/EnterpriseBasicTrialBanner.svelte"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
|
||||
let loaded = false
|
||||
let mobileMenuVisible = false
|
||||
|
@ -33,6 +35,14 @@
|
|||
const showMobileMenu = () => (mobileMenuVisible = true)
|
||||
const hideMobileMenu = () => (mobileMenuVisible = false)
|
||||
|
||||
const showFreeTrialBanner = () => {
|
||||
return (
|
||||
$licensing.license?.plan?.type ===
|
||||
Constants.PlanType.ENTERPRISE_BASIC_TRIAL &&
|
||||
sdk.users.isAdmin($auth.user)
|
||||
)
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Prevent non-builders from accessing the portal
|
||||
if ($auth.user) {
|
||||
|
@ -58,6 +68,7 @@
|
|||
<HelpMenu />
|
||||
<div class="container">
|
||||
<VerificationPromptBanner />
|
||||
<EnterpriseBasicTrialBanner show={showFreeTrialBanner()} />
|
||||
<div class="nav">
|
||||
<div class="branding">
|
||||
<Logo />
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
|
||||
|
||||
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"]
|
||||
const oneDayInSeconds = 86400
|
||||
|
||||
const EXCLUDE_QUOTAS = {
|
||||
Queries: () => true,
|
||||
|
@ -104,24 +105,17 @@
|
|||
if (!timestamp) {
|
||||
return
|
||||
}
|
||||
const now = new Date()
|
||||
now.setHours(0)
|
||||
now.setMinutes(0)
|
||||
|
||||
const thenDate = new Date(timestamp)
|
||||
thenDate.setHours(0)
|
||||
thenDate.setMinutes(0)
|
||||
|
||||
const difference = thenDate.getTime() - now
|
||||
// return the difference in days
|
||||
return (difference / (1000 * 3600 * 24)).toFixed(0)
|
||||
const diffTime = Math.abs(timestamp - new Date().getTime()) / 1000
|
||||
return Math.floor(diffTime / oneDayInSeconds)
|
||||
}
|
||||
|
||||
const setTextRows = () => {
|
||||
textRows = []
|
||||
|
||||
if (cancelAt && !usesInvoicing) {
|
||||
if (plan?.type !== Constants.PlanType.ENTERPRISE_BASIC_TRIAL) {
|
||||
textRows.push({ message: "Subscription has been cancelled" })
|
||||
}
|
||||
textRows.push({
|
||||
message: `${getDaysRemaining(cancelAt)} days remaining`,
|
||||
tooltip: new Date(cancelAt),
|
||||
|
|
|
@ -289,6 +289,7 @@
|
|||
OperatorOptions.ContainsAny.value,
|
||||
].includes(filter.operator)}
|
||||
disabled={filter.noValue}
|
||||
type={filter.valueType}
|
||||
/>
|
||||
{:else}
|
||||
<Input disabled />
|
||||
|
@ -325,8 +326,6 @@
|
|||
<style>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { createAPIClient } from "../api"
|
||||
|
||||
export let API = createAPIClient()
|
||||
|
||||
export let value = null
|
||||
export let disabled
|
||||
export let multiselect = false
|
||||
|
@ -23,6 +24,7 @@
|
|||
$: component = multiselect ? Multiselect : Select
|
||||
</script>
|
||||
|
||||
<div class="user-control">
|
||||
<svelte:component
|
||||
this={component}
|
||||
bind:value
|
||||
|
@ -32,3 +34,4 @@
|
|||
getOptionValue={option => option._id}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import stream from "stream"
|
|||
import archiver from "archiver"
|
||||
|
||||
import { quotas } from "@budibase/pro"
|
||||
import { objectStore } from "@budibase/backend-core"
|
||||
import { objectStore, context } from "@budibase/backend-core"
|
||||
import * as internal from "./internal"
|
||||
import * as external from "./external"
|
||||
import { isExternalTableID } from "../../../integrations/utils"
|
||||
|
@ -198,8 +198,18 @@ export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
|
|||
export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
|
||||
const tableId = utils.getTableId(ctx)
|
||||
|
||||
await context.ensureSnippetContext(true)
|
||||
|
||||
const enrichedQuery = await utils.enrichSearchContext(
|
||||
{ ...ctx.request.body.query },
|
||||
{
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
}
|
||||
)
|
||||
|
||||
const searchParams: RowSearchParams = {
|
||||
...ctx.request.body,
|
||||
query: enrichedQuery,
|
||||
tableId,
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import {
|
|||
getInternalRowId,
|
||||
} from "./basic"
|
||||
import sdk from "../../../../sdk"
|
||||
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
import validateJs from "validate.js"
|
||||
|
||||
validateJs.extend(validateJs.validators.datetime, {
|
||||
|
@ -117,6 +117,19 @@ export async function validate(
|
|||
})
|
||||
}
|
||||
|
||||
function fixBooleanFields({ row, table }: { row: Row; table: Table }) {
|
||||
for (let col of Object.values(table.schema)) {
|
||||
if (col.type === FieldType.BOOLEAN) {
|
||||
if (row[col.name] === 1) {
|
||||
row[col.name] = true
|
||||
} else if (row[col.name] === 0) {
|
||||
row[col.name] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
export async function sqlOutputProcessing(
|
||||
rows: DatasourcePlusQueryResponse,
|
||||
table: Table,
|
||||
|
@ -161,7 +174,9 @@ export async function sqlOutputProcessing(
|
|||
if (thisRow._id == null) {
|
||||
throw new Error("Unable to generate row ID for SQL rows")
|
||||
}
|
||||
finalRows[thisRow._id] = thisRow
|
||||
|
||||
finalRows[thisRow._id] = fixBooleanFields({ row: thisRow, table })
|
||||
|
||||
// do this at end once its been added to the final rows
|
||||
finalRows = await updateRelationshipColumns(
|
||||
table,
|
||||
|
@ -189,3 +204,63 @@ export async function sqlOutputProcessing(
|
|||
export function isUserMetadataTable(tableId: string) {
|
||||
return tableId === InternalTables.USER_METADATA
|
||||
}
|
||||
|
||||
export async function enrichArrayContext(
|
||||
fields: any[],
|
||||
inputs = {},
|
||||
helpers = true
|
||||
): Promise<any[]> {
|
||||
const map: Record<string, any> = {}
|
||||
for (let index in fields) {
|
||||
map[index] = fields[index]
|
||||
}
|
||||
const output = await enrichSearchContext(map, inputs, helpers)
|
||||
const outputArray: any[] = []
|
||||
for (let [key, value] of Object.entries(output)) {
|
||||
outputArray[parseInt(key)] = value
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
export async function enrichSearchContext(
|
||||
fields: Record<string, any>,
|
||||
inputs = {},
|
||||
helpers = true
|
||||
): Promise<Record<string, any>> {
|
||||
const enrichedQuery: Record<string, any> = {}
|
||||
if (!fields || !inputs) {
|
||||
return enrichedQuery
|
||||
}
|
||||
const parameters = { ...inputs }
|
||||
|
||||
if (Array.isArray(fields)) {
|
||||
return enrichArrayContext(fields, inputs, helpers)
|
||||
}
|
||||
|
||||
// enrich the fields with dynamic parameters
|
||||
for (let key of Object.keys(fields)) {
|
||||
if (fields[key] == null) {
|
||||
enrichedQuery[key] = null
|
||||
continue
|
||||
}
|
||||
if (typeof fields[key] === "object") {
|
||||
// enrich nested fields object
|
||||
enrichedQuery[key] = await enrichSearchContext(
|
||||
fields[key],
|
||||
parameters,
|
||||
helpers
|
||||
)
|
||||
} else if (typeof fields[key] === "string") {
|
||||
// enrich string value as normal
|
||||
enrichedQuery[key] = processStringSync(fields[key], parameters, {
|
||||
noEscaping: true,
|
||||
noHelpers: !helpers,
|
||||
escapeNewlines: true,
|
||||
})
|
||||
} else {
|
||||
enrichedQuery[key] = fields[key]
|
||||
}
|
||||
}
|
||||
|
||||
return enrichedQuery
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ import {
|
|||
} from "@budibase/types"
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
import sdk from "../../../sdk"
|
||||
import { db } from "@budibase/backend-core"
|
||||
import { db, context } from "@budibase/backend-core"
|
||||
import { enrichSearchContext } from "./utils"
|
||||
|
||||
export async function searchView(
|
||||
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
|
||||
|
@ -56,10 +57,16 @@ export async function searchView(
|
|||
})
|
||||
}
|
||||
|
||||
await context.ensureSnippetContext(true)
|
||||
|
||||
const enrichedQuery = await enrichSearchContext(query, {
|
||||
user: sdk.users.getUserContextBindings(ctx.user),
|
||||
})
|
||||
|
||||
const searchOptions: RequiredKeys<SearchViewRowRequest> &
|
||||
RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = {
|
||||
tableId: view.tableId,
|
||||
query,
|
||||
query: enrichedQuery,
|
||||
fields: viewFields,
|
||||
...getSortOptions(body, view),
|
||||
limit: body.limit,
|
||||
|
|
|
@ -4,10 +4,12 @@ import { APIError } from "@budibase/types"
|
|||
describe("/api/applications/:appId/sync", () => {
|
||||
let config = setup.getConfig()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
afterAll(async () => {
|
||||
setup.afterAll()
|
||||
})
|
||||
|
||||
describe("/api/attachments/process", () => {
|
||||
it("should accept an image file upload", async () => {
|
||||
|
@ -18,7 +20,8 @@ describe("/api/applications/:appId/sync", () => {
|
|||
expect(resp.length).toBe(1)
|
||||
|
||||
let upload = resp[0]
|
||||
expect(upload.url.endsWith(".jpg")).toBe(true)
|
||||
|
||||
expect(upload.url.split("?")[0].endsWith(".jpg")).toBe(true)
|
||||
expect(upload.extension).toBe("jpg")
|
||||
expect(upload.size).toBe(1)
|
||||
expect(upload.name).toBe("1px.jpg")
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
import { mocks } from "@budibase/backend-core/tests"
|
||||
import tk from "timekeeper"
|
||||
import * as setup from "./utilities"
|
||||
import { events } from "@budibase/backend-core"
|
||||
import sdk from "../../../sdk"
|
||||
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
|
||||
mocks.licenses.useBackups()
|
||||
|
||||
describe("/backups", () => {
|
||||
let config = setup.getConfig()
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(async () => {
|
||||
setup.afterAll()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
tk.reset()
|
||||
|
|
|
@ -32,8 +32,6 @@ import * as uuid from "uuid"
|
|||
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
|
||||
tk.freeze(timestamp)
|
||||
|
||||
jest.unmock("mssql")
|
||||
|
||||
describe.each([
|
||||
["internal", undefined],
|
||||
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
|
||||
|
@ -131,7 +129,13 @@ describe.each([
|
|||
|
||||
const assertRowUsage = async (expected: number) => {
|
||||
const usage = await getRowUsage()
|
||||
expect(usage).toBe(expected)
|
||||
|
||||
// Because our quota tracking is not perfect, we allow a 10% margin of
|
||||
// error. This is to account for the fact that parallel writes can result
|
||||
// in some quota updates getting lost. We don't have any need to solve this
|
||||
// right now, so we just allow for some error.
|
||||
expect(usage).toBeGreaterThan(expected * 0.9)
|
||||
expect(usage).toBeLessThan(expected * 1.1)
|
||||
}
|
||||
|
||||
const defaultRowFields = isInternal
|
||||
|
@ -194,6 +198,7 @@ describe.each([
|
|||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
it("increment row autoId per create row request", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
|
@ -228,6 +233,65 @@ describe.each([
|
|||
await assertRowUsage(rowUsage + 10)
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
it("should increment auto ID correctly when creating rows in parallel", async () => {
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
"Row ID": {
|
||||
name: "Row ID",
|
||||
type: FieldType.NUMBER,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
icon: "ri-magic-line",
|
||||
autocolumn: true,
|
||||
constraints: {
|
||||
type: "number",
|
||||
presence: true,
|
||||
numericality: {
|
||||
greaterThanOrEqualTo: "",
|
||||
lessThanOrEqualTo: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const sequence = Array(50)
|
||||
.fill(0)
|
||||
.map((_, i) => i + 1)
|
||||
|
||||
// This block of code is simulating users creating auto ID rows at the
|
||||
// same time. It's expected that this operation will sometimes return
|
||||
// a document conflict error (409), but the idea is to retry in those
|
||||
// situations. The code below does this a large number of times with
|
||||
// small, random delays between them to try and get through the list
|
||||
// as quickly as possible.
|
||||
await Promise.all(
|
||||
sequence.map(async () => {
|
||||
const attempts = 20
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
try {
|
||||
await config.api.row.save(table._id!, {})
|
||||
return
|
||||
} catch (e) {
|
||||
await new Promise(r => setTimeout(r, Math.random() * 15))
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to create row after ${attempts} attempts`)
|
||||
})
|
||||
)
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows).toHaveLength(50)
|
||||
|
||||
// The main purpose of this test is to ensure that even under pressure,
|
||||
// we maintain data integrity. An auto ID column should hand out
|
||||
// monotonically increasing unique integers no matter what.
|
||||
const ids = rows.map(r => r["Row ID"])
|
||||
expect(ids).toEqual(expect.arrayContaining(sequence))
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
it("row values are coerced", async () => {
|
||||
const str: FieldSchema = {
|
||||
|
@ -856,7 +920,7 @@ describe.each([
|
|||
await config.withEnv({ SELF_HOSTED: "true" }, async () => {
|
||||
return context.doInAppContext(config.getAppId(), async () => {
|
||||
const enriched = await outputProcessing(table, [row])
|
||||
expect((enriched as Row[])[0].attachment.url).toBe(
|
||||
expect((enriched as Row[])[0].attachment.url.split("?")[0]).toBe(
|
||||
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
|
||||
)
|
||||
})
|
||||
|
@ -889,7 +953,7 @@ describe.each([
|
|||
await config.withEnv({ SELF_HOSTED: "true" }, async () => {
|
||||
return context.doInAppContext(config.getAppId(), async () => {
|
||||
const enriched = await outputProcessing(table, [row])
|
||||
expect((enriched as Row[])[0].attachment[0].url).toBe(
|
||||
expect((enriched as Row[])[0].attachment[0].url.split("?")[0]).toBe(
|
||||
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { tableForDatasource } from "../../../tests/utilities/structures"
|
||||
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
|
||||
import { db as dbCore } from "@budibase/backend-core"
|
||||
|
||||
import * as setup from "./utilities"
|
||||
import {
|
||||
AutoFieldSubType,
|
||||
Datasource,
|
||||
EmptyFilterOption,
|
||||
BBReferenceFieldSubType,
|
||||
FieldType,
|
||||
RowSearchParams,
|
||||
SearchFilters,
|
||||
|
@ -13,10 +15,14 @@ import {
|
|||
SortType,
|
||||
Table,
|
||||
TableSchema,
|
||||
User,
|
||||
} from "@budibase/types"
|
||||
import _ from "lodash"
|
||||
import tk from "timekeeper"
|
||||
import { encodeJSBinding } from "@budibase/string-templates"
|
||||
|
||||
jest.unmock("mssql")
|
||||
const serverTime = new Date("2024-05-06T00:00:00.000Z")
|
||||
tk.freeze(serverTime)
|
||||
|
||||
describe.each([
|
||||
["lucene", undefined],
|
||||
|
@ -35,11 +41,25 @@ describe.each([
|
|||
let datasource: Datasource | undefined
|
||||
let table: Table
|
||||
|
||||
const snippets = [
|
||||
{
|
||||
name: "WeeksAgo",
|
||||
code: "return function (weeks) {\n const currentTime = new Date();\n currentTime.setDate(currentTime.getDate()-(7 * (weeks || 1)));\n return currentTime.toISOString();\n}",
|
||||
},
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
if (isSqs) {
|
||||
envCleanup = config.setEnv({ SQS_SEARCH_ENABLE: "true" })
|
||||
}
|
||||
await config.init()
|
||||
|
||||
if (config.app?.appId) {
|
||||
config.app = await config.api.application.update(config.app?.appId, {
|
||||
snippets,
|
||||
})
|
||||
}
|
||||
|
||||
if (dsProvider) {
|
||||
datasource = await config.createDatasource({
|
||||
datasource: await dsProvider,
|
||||
|
@ -67,6 +87,22 @@ describe.each([
|
|||
class SearchAssertion {
|
||||
constructor(private readonly query: RowSearchParams) {}
|
||||
|
||||
private findRow(expectedRow: any, foundRows: any[]) {
|
||||
const row = foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
|
||||
if (!row) {
|
||||
const fields = Object.keys(expectedRow)
|
||||
// To make the error message more readable, we only include the fields
|
||||
// that are present in the expected row.
|
||||
const searchedObjects = foundRows.map(row => _.pick(row, fields))
|
||||
throw new Error(
|
||||
`Failed to find row: ${JSON.stringify(
|
||||
expectedRow
|
||||
)} in ${JSON.stringify(searchedObjects)}`
|
||||
)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// Asserts that the query returns rows matching exactly the set of rows
|
||||
// passed in. The order of the rows matters. Rows returned in an order
|
||||
// different to the one passed in will cause the assertion to fail. Extra
|
||||
|
@ -82,9 +118,7 @@ describe.each([
|
|||
// eslint-disable-next-line jest/no-standalone-expect
|
||||
expect(foundRows).toEqual(
|
||||
expectedRows.map((expectedRow: any) =>
|
||||
expect.objectContaining(
|
||||
foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
|
||||
)
|
||||
expect.objectContaining(this.findRow(expectedRow, foundRows))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -104,9 +138,7 @@ describe.each([
|
|||
expect(foundRows).toEqual(
|
||||
expect.arrayContaining(
|
||||
expectedRows.map((expectedRow: any) =>
|
||||
expect.objectContaining(
|
||||
foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
|
||||
)
|
||||
expect.objectContaining(this.findRow(expectedRow, foundRows))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -125,9 +157,7 @@ describe.each([
|
|||
expect(foundRows).toEqual(
|
||||
expect.arrayContaining(
|
||||
expectedRows.map((expectedRow: any) =>
|
||||
expect.objectContaining(
|
||||
foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
|
||||
)
|
||||
expect.objectContaining(this.findRow(expectedRow, foundRows))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -156,6 +186,291 @@ describe.each([
|
|||
return expectSearch({ query })
|
||||
}
|
||||
|
||||
describe("boolean", () => {
|
||||
beforeAll(async () => {
|
||||
await createTable({
|
||||
isTrue: { name: "isTrue", type: FieldType.BOOLEAN },
|
||||
})
|
||||
await createRows([{ isTrue: true }, { isTrue: false }])
|
||||
})
|
||||
|
||||
describe("equal", () => {
|
||||
it("successfully finds true row", () =>
|
||||
expectQuery({ equal: { isTrue: true } }).toMatchExactly([
|
||||
{ isTrue: true },
|
||||
]))
|
||||
|
||||
it("successfully finds false row", () =>
|
||||
expectQuery({ equal: { isTrue: false } }).toMatchExactly([
|
||||
{ isTrue: false },
|
||||
]))
|
||||
})
|
||||
|
||||
describe("notEqual", () => {
|
||||
it("successfully finds false row", () =>
|
||||
expectQuery({ notEqual: { isTrue: true } }).toContainExactly([
|
||||
{ isTrue: false },
|
||||
]))
|
||||
|
||||
it("successfully finds true row", () =>
|
||||
expectQuery({ notEqual: { isTrue: false } }).toContainExactly([
|
||||
{ isTrue: true },
|
||||
]))
|
||||
})
|
||||
|
||||
describe("oneOf", () => {
|
||||
it("successfully finds true row", () =>
|
||||
expectQuery({ oneOf: { isTrue: [true] } }).toContainExactly([
|
||||
{ isTrue: true },
|
||||
]))
|
||||
|
||||
it("successfully finds false row", () =>
|
||||
expectQuery({ oneOf: { isTrue: [false] } }).toContainExactly([
|
||||
{ isTrue: false },
|
||||
]))
|
||||
})
|
||||
|
||||
describe("sort", () => {
|
||||
it("sorts ascending", () =>
|
||||
expectSearch({
|
||||
query: {},
|
||||
sort: "isTrue",
|
||||
sortOrder: SortOrder.ASCENDING,
|
||||
}).toMatchExactly([{ isTrue: false }, { isTrue: true }]))
|
||||
|
||||
it("sorts descending", () =>
|
||||
expectSearch({
|
||||
query: {},
|
||||
sort: "isTrue",
|
||||
sortOrder: SortOrder.DESCENDING,
|
||||
}).toMatchExactly([{ isTrue: true }, { isTrue: false }]))
|
||||
})
|
||||
})
|
||||
|
||||
// Ensure all bindings resolve and perform as expected
|
||||
describe("bindings", () => {
|
||||
let globalUsers: any = []
|
||||
|
||||
const future = new Date(serverTime.getTime())
|
||||
future.setDate(future.getDate() + 30)
|
||||
|
||||
const rows = (currentUser: User) => {
|
||||
return [
|
||||
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
|
||||
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
|
||||
{ name: currentUser.firstName, appointment: future.toISOString() },
|
||||
{ name: "serverDate", appointment: serverTime.toISOString() },
|
||||
{
|
||||
name: "single user, session user",
|
||||
single_user: JSON.stringify([currentUser]),
|
||||
},
|
||||
{
|
||||
name: "single user",
|
||||
single_user: JSON.stringify([globalUsers[0]]),
|
||||
},
|
||||
{
|
||||
name: "multi user",
|
||||
multi_user: JSON.stringify(globalUsers),
|
||||
},
|
||||
{
|
||||
name: "multi user with session user",
|
||||
multi_user: JSON.stringify([...globalUsers, currentUser]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up some global users
|
||||
globalUsers = await Promise.all(
|
||||
Array(2)
|
||||
.fill(0)
|
||||
.map(async () => {
|
||||
const globalUser = await config.globalUser()
|
||||
const userMedataId = globalUser._id
|
||||
? dbCore.generateUserMetadataID(globalUser._id)
|
||||
: null
|
||||
return {
|
||||
_id: globalUser._id,
|
||||
_meta: userMedataId,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await createTable({
|
||||
name: { name: "name", type: FieldType.STRING },
|
||||
appointment: { name: "appointment", type: FieldType.DATETIME },
|
||||
single_user: {
|
||||
name: "single_user",
|
||||
type: FieldType.BB_REFERENCE,
|
||||
subtype: BBReferenceFieldSubType.USER,
|
||||
},
|
||||
multi_user: {
|
||||
name: "multi_user",
|
||||
type: FieldType.BB_REFERENCE,
|
||||
subtype: BBReferenceFieldSubType.USERS,
|
||||
},
|
||||
})
|
||||
await createRows(rows(config.getUser()))
|
||||
})
|
||||
|
||||
// !! Current User is auto generated per run
|
||||
it("should return all rows matching the session user firstname", async () => {
|
||||
await expectQuery({
|
||||
equal: { name: "{{ [user].firstName }}" },
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: config.getUser().firstName,
|
||||
appointment: future.toISOString(),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should parse the date binding and return all rows after the resolved value", async () => {
|
||||
await expectQuery({
|
||||
range: {
|
||||
appointment: {
|
||||
low: "{{ [now] }}",
|
||||
high: "9999-00-00T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: config.getUser().firstName,
|
||||
appointment: future.toISOString(),
|
||||
},
|
||||
{ name: "serverDate", appointment: serverTime.toISOString() },
|
||||
])
|
||||
})
|
||||
|
||||
it("should parse the date binding and return all rows before the resolved value", async () => {
|
||||
await expectQuery({
|
||||
range: {
|
||||
appointment: {
|
||||
low: "0000-00-00T00:00:00.000Z",
|
||||
high: "{{ [now] }}",
|
||||
},
|
||||
},
|
||||
}).toContainExactly([
|
||||
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
|
||||
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
|
||||
{ name: "serverDate", appointment: serverTime.toISOString() },
|
||||
])
|
||||
})
|
||||
|
||||
it("should parse the encoded js snippet. Return rows with appointments up to 1 week in the past", async () => {
|
||||
const jsBinding = "return snippets.WeeksAgo();"
|
||||
const encodedBinding = encodeJSBinding(jsBinding)
|
||||
|
||||
await expectQuery({
|
||||
range: {
|
||||
appointment: {
|
||||
low: "0000-00-00T00:00:00.000Z",
|
||||
high: encodedBinding,
|
||||
},
|
||||
},
|
||||
}).toContainExactly([
|
||||
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
|
||||
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
|
||||
])
|
||||
})
|
||||
|
||||
it("should parse the encoded js binding. Return rows with appointments 2 weeks in the past", async () => {
|
||||
const jsBinding =
|
||||
"const currentTime = new Date()\ncurrentTime.setDate(currentTime.getDate()-14);\nreturn currentTime.toISOString();"
|
||||
const encodedBinding = encodeJSBinding(jsBinding)
|
||||
|
||||
await expectQuery({
|
||||
range: {
|
||||
appointment: {
|
||||
low: "0000-00-00T00:00:00.000Z",
|
||||
high: encodedBinding,
|
||||
},
|
||||
},
|
||||
}).toContainExactly([
|
||||
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
|
||||
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
|
||||
])
|
||||
})
|
||||
|
||||
it("should match a single user row by the session user id", async () => {
|
||||
await expectQuery({
|
||||
equal: { single_user: "{{ [user]._id }}" },
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "single user, session user",
|
||||
single_user: [{ _id: config.getUser()._id }],
|
||||
},
|
||||
])
|
||||
})
|
||||
// TODO(samwho): fix for SQS
|
||||
!isSqs &&
|
||||
it("should match the session user id in a multi user field", async () => {
|
||||
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
|
||||
return { _id: user._id }
|
||||
})
|
||||
|
||||
await expectQuery({
|
||||
contains: { multi_user: ["{{ [user]._id }}"] },
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "multi user with session user",
|
||||
multi_user: allUsers,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
!isSqs &&
|
||||
it("should not match the session user id in a multi user field", async () => {
|
||||
await expectQuery({
|
||||
notContains: { multi_user: ["{{ [user]._id }}"] },
|
||||
notEmpty: { multi_user: true },
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "multi user",
|
||||
multi_user: globalUsers.map((user: any) => {
|
||||
return { _id: user._id }
|
||||
}),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should match the session user id and a user table row id using helpers, user binding and a static user id.", async () => {
|
||||
await expectQuery({
|
||||
oneOf: {
|
||||
single_user: [
|
||||
"{{ default [user]._id '_empty_' }}",
|
||||
globalUsers[0]._id,
|
||||
],
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "single user, session user",
|
||||
single_user: [{ _id: config.getUser()._id }],
|
||||
},
|
||||
{
|
||||
name: "single user",
|
||||
single_user: [{ _id: globalUsers[0]._id }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should resolve 'default' helper to '_empty_' when binding resolves to nothing", async () => {
|
||||
await expectQuery({
|
||||
oneOf: {
|
||||
single_user: [
|
||||
"{{ default [user]._idx '_empty_' }}",
|
||||
globalUsers[0]._id,
|
||||
],
|
||||
},
|
||||
}).toContainExactly([
|
||||
{
|
||||
name: "single user",
|
||||
single_user: [{ _id: globalUsers[0]._id }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
|
||||
beforeAll(async () => {
|
||||
await createTable({
|
||||
|
@ -252,6 +567,31 @@ describe.each([
|
|||
}).toFindNothing())
|
||||
})
|
||||
|
||||
describe("empty", () => {
|
||||
it("finds no empty rows", () =>
|
||||
expectQuery({ empty: { name: null } }).toFindNothing())
|
||||
|
||||
it("should not be affected by when filter empty behaviour", () =>
|
||||
expectQuery({
|
||||
empty: { name: null },
|
||||
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
|
||||
}).toFindNothing())
|
||||
})
|
||||
|
||||
describe("notEmpty", () => {
|
||||
it("finds all non-empty rows", () =>
|
||||
expectQuery({ notEmpty: { name: null } }).toContainExactly([
|
||||
{ name: "foo" },
|
||||
{ name: "bar" },
|
||||
]))
|
||||
|
||||
it("should not be affected by when filter empty behaviour", () =>
|
||||
expectQuery({
|
||||
notEmpty: { name: null },
|
||||
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
|
||||
}).toContainExactly([{ name: "foo" }, { name: "bar" }]))
|
||||
})
|
||||
|
||||
describe("sort", () => {
|
||||
it("sorts ascending", () =>
|
||||
expectSearch({
|
||||
|
|
|
@ -1,3 +1,13 @@
|
|||
// Directly mock the AWS SDK
|
||||
jest.mock("aws-sdk", () => ({
|
||||
S3: jest.fn(() => ({
|
||||
getSignedUrl: jest.fn(
|
||||
(operation, params) => `http://example.com/${params.Bucket}/${params.Key}`
|
||||
),
|
||||
upload: jest.fn(() => ({ Contents: {} })),
|
||||
})),
|
||||
}))
|
||||
|
||||
const setup = require("./utilities")
|
||||
const { constants } = require("@budibase/backend-core")
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { context, events } from "@budibase/backend-core"
|
||||
import {
|
||||
AutoFieldSubType,
|
||||
Datasource,
|
||||
BBReferenceFieldSubType,
|
||||
Datasource,
|
||||
FieldType,
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
InternalTable,
|
||||
|
@ -149,6 +149,7 @@ describe.each([
|
|||
expect(res.name).toBeUndefined()
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
it("updates only the passed fields", async () => {
|
||||
await timekeeper.withFreeze(new Date(2021, 1, 1), async () => {
|
||||
const table = await config.api.table.save(
|
||||
|
@ -214,6 +215,57 @@ describe.each([
|
|||
})
|
||||
})
|
||||
|
||||
describe("external table validation", () => {
|
||||
!isInternal &&
|
||||
it("should error if column is of type auto", async () => {
|
||||
const table = basicTable(datasource)
|
||||
await config.api.table.save(
|
||||
{
|
||||
...table,
|
||||
schema: {
|
||||
...table.schema,
|
||||
auto: {
|
||||
name: "auto",
|
||||
autocolumn: true,
|
||||
type: FieldType.AUTO,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
body: {
|
||||
message: `Column "auto" has type "${FieldType.AUTO}" - this is not supported.`,
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
!isInternal &&
|
||||
it("should error if column has auto subtype", async () => {
|
||||
const table = basicTable(datasource)
|
||||
await config.api.table.save(
|
||||
{
|
||||
...table,
|
||||
schema: {
|
||||
...table.schema,
|
||||
auto: {
|
||||
name: "auto",
|
||||
autocolumn: true,
|
||||
type: FieldType.NUMBER,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
body: {
|
||||
message: `Column "auto" has subtype "${AutoFieldSubType.AUTO_ID}" - this is not supported.`,
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("should add a new column for an internal DB table", async () => {
|
||||
const saveTableRequest: SaveTableRequest = {
|
||||
...basicTable(),
|
||||
|
|
|
@ -24,8 +24,6 @@ import merge from "lodash/merge"
|
|||
import { quotas } from "@budibase/pro"
|
||||
import { roles } from "@budibase/backend-core"
|
||||
|
||||
jest.unmock("mssql")
|
||||
|
||||
describe.each([
|
||||
["internal", undefined],
|
||||
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
|
||||
|
|
|
@ -4,8 +4,11 @@ import {
|
|||
encodeJSBinding,
|
||||
} from "@budibase/string-templates"
|
||||
import sdk from "../sdk"
|
||||
import { Row } from "@budibase/types"
|
||||
import { AutomationAttachment, FieldType, Row } from "@budibase/types"
|
||||
import { LoopInput, LoopStepType } from "../definitions/automations"
|
||||
import { objectStore, context } from "@budibase/backend-core"
|
||||
import * as uuid from "uuid"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* When values are input to the system generally they will be of type string as this is required for template strings.
|
||||
|
@ -96,6 +99,98 @@ export function getError(err: any) {
|
|||
return typeof err !== "string" ? err.toString() : err
|
||||
}
|
||||
|
||||
export async function sendAutomationAttachmentsToStorage(
|
||||
tableId: string,
|
||||
row: Row
|
||||
): Promise<Row> {
|
||||
const table = await sdk.tables.getTable(tableId)
|
||||
const attachmentRows: Record<
|
||||
string,
|
||||
AutomationAttachment[] | AutomationAttachment
|
||||
> = {}
|
||||
|
||||
for (const [prop, value] of Object.entries(row)) {
|
||||
const schema = table.schema[prop]
|
||||
if (
|
||||
schema?.type === FieldType.ATTACHMENTS ||
|
||||
schema?.type === FieldType.ATTACHMENT_SINGLE
|
||||
) {
|
||||
attachmentRows[prop] = value
|
||||
}
|
||||
}
|
||||
for (const [prop, attachments] of Object.entries(attachmentRows)) {
|
||||
if (Array.isArray(attachments)) {
|
||||
if (attachments.length) {
|
||||
row[prop] = await Promise.all(
|
||||
attachments.map(attachment => generateAttachmentRow(attachment))
|
||||
)
|
||||
}
|
||||
} else if (Object.keys(row[prop]).length > 0) {
|
||||
row[prop] = await generateAttachmentRow(attachments)
|
||||
}
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
async function generateAttachmentRow(attachment: AutomationAttachment) {
|
||||
const prodAppId = context.getProdAppId()
|
||||
|
||||
async function uploadToS3(
|
||||
extension: string,
|
||||
content: objectStore.StreamTypes
|
||||
) {
|
||||
const fileName = `${uuid.v4()}${extension}`
|
||||
const s3Key = `${prodAppId}/attachments/${fileName}`
|
||||
|
||||
await objectStore.streamUpload({
|
||||
bucket: objectStore.ObjectStoreBuckets.APPS,
|
||||
stream: content,
|
||||
filename: s3Key,
|
||||
})
|
||||
|
||||
return s3Key
|
||||
}
|
||||
|
||||
async function getSize(s3Key: string) {
|
||||
return (
|
||||
await objectStore.getObjectMetadata(
|
||||
objectStore.ObjectStoreBuckets.APPS,
|
||||
s3Key
|
||||
)
|
||||
).ContentLength
|
||||
}
|
||||
|
||||
try {
|
||||
const { filename } = attachment
|
||||
const extension = path.extname(filename)
|
||||
const attachmentResult = await objectStore.processAutomationAttachment(
|
||||
attachment
|
||||
)
|
||||
|
||||
let s3Key = ""
|
||||
if (
|
||||
"path" in attachmentResult &&
|
||||
attachmentResult.path.startsWith(`${prodAppId}/attachments/`)
|
||||
) {
|
||||
s3Key = attachmentResult.path
|
||||
} else {
|
||||
s3Key = await uploadToS3(extension, attachmentResult.content)
|
||||
}
|
||||
|
||||
const size = await getSize(s3Key)
|
||||
|
||||
return {
|
||||
size,
|
||||
name: filename,
|
||||
extension,
|
||||
key: s3Key,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process attachment:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export function substituteLoopStep(hbsString: string, substitute: string) {
|
||||
let checkForJS = isJSBinding(hbsString)
|
||||
let substitutedHbsString = ""
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import { save } from "../../api/controllers/row"
|
||||
import { cleanUpRow, getError } from "../automationUtils"
|
||||
import {
|
||||
cleanUpRow,
|
||||
getError,
|
||||
sendAutomationAttachmentsToStorage,
|
||||
} from "../automationUtils"
|
||||
import { buildCtx } from "./utils"
|
||||
import {
|
||||
AutomationActionStepId,
|
||||
|
@ -89,6 +93,10 @@ export async function run({ inputs, appId, emitter }: AutomationStepInput) {
|
|||
|
||||
try {
|
||||
inputs.row = await cleanUpRow(inputs.row.tableId, inputs.row)
|
||||
inputs.row = await sendAutomationAttachmentsToStorage(
|
||||
inputs.row.tableId,
|
||||
inputs.row
|
||||
)
|
||||
await save(ctx)
|
||||
return {
|
||||
row: inputs.row,
|
||||
|
|
|
@ -108,7 +108,15 @@ export async function run({ inputs, appId, emitter }: AutomationStepInput) {
|
|||
|
||||
try {
|
||||
if (tableId) {
|
||||
inputs.row = await automationUtils.cleanUpRow(tableId, inputs.row)
|
||||
inputs.row = await automationUtils.cleanUpRow(
|
||||
inputs.row.tableId,
|
||||
inputs.row
|
||||
)
|
||||
|
||||
inputs.row = await automationUtils.sendAutomationAttachmentsToStorage(
|
||||
inputs.row.tableId,
|
||||
inputs.row
|
||||
)
|
||||
}
|
||||
await rowController.patch(ctx)
|
||||
return {
|
||||
|
|
|
@ -1,5 +1,18 @@
|
|||
import * as setup from "./utilities"
|
||||
import { basicTableWithAttachmentField } from "../../tests/utilities/structures"
|
||||
import { objectStore } from "@budibase/backend-core"
|
||||
|
||||
async function uploadTestFile(filename: string) {
|
||||
let bucket = "testbucket"
|
||||
await objectStore.upload({
|
||||
bucket,
|
||||
filename,
|
||||
body: Buffer.from("test data"),
|
||||
})
|
||||
let presignedUrl = await objectStore.getPresignedUrl(bucket, filename, 60000)
|
||||
|
||||
return presignedUrl
|
||||
}
|
||||
describe("test the create row action", () => {
|
||||
let table: any
|
||||
let row: any
|
||||
|
@ -43,4 +56,76 @@ describe("test the create row action", () => {
|
|||
const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {})
|
||||
expect(res.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("should check that an attachment field is sent to storage and parsed", async () => {
|
||||
let attachmentTable = await config.createTable(
|
||||
basicTableWithAttachmentField()
|
||||
)
|
||||
|
||||
let attachmentRow: any = {
|
||||
tableId: attachmentTable._id,
|
||||
}
|
||||
|
||||
let filename = "test1.txt"
|
||||
let presignedUrl = await uploadTestFile(filename)
|
||||
let attachmentObject = [
|
||||
{
|
||||
url: presignedUrl,
|
||||
filename,
|
||||
},
|
||||
]
|
||||
|
||||
attachmentRow.file_attachment = attachmentObject
|
||||
const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {
|
||||
row: attachmentRow,
|
||||
})
|
||||
|
||||
expect(res.success).toEqual(true)
|
||||
expect(res.row.file_attachment[0]).toHaveProperty("key")
|
||||
let s3Key = res.row.file_attachment[0].key
|
||||
|
||||
const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS)
|
||||
|
||||
const objectData = await client
|
||||
.headObject({ Bucket: objectStore.ObjectStoreBuckets.APPS, Key: s3Key })
|
||||
.promise()
|
||||
|
||||
expect(objectData).toBeDefined()
|
||||
expect(objectData.ContentLength).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should check that an single attachment field is sent to storage and parsed", async () => {
|
||||
let attachmentTable = await config.createTable(
|
||||
basicTableWithAttachmentField()
|
||||
)
|
||||
|
||||
let attachmentRow: any = {
|
||||
tableId: attachmentTable._id,
|
||||
}
|
||||
|
||||
let filename = "test2.txt"
|
||||
let presignedUrl = await uploadTestFile(filename)
|
||||
let attachmentObject = {
|
||||
url: presignedUrl,
|
||||
filename,
|
||||
}
|
||||
|
||||
attachmentRow.single_file_attachment = attachmentObject
|
||||
const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {
|
||||
row: attachmentRow,
|
||||
})
|
||||
|
||||
expect(res.success).toEqual(true)
|
||||
expect(res.row.single_file_attachment).toHaveProperty("key")
|
||||
let s3Key = res.row.single_file_attachment.key
|
||||
|
||||
const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS)
|
||||
|
||||
const objectData = await client
|
||||
.headObject({ Bucket: objectStore.ObjectStoreBuckets.APPS, Key: s3Key })
|
||||
.promise()
|
||||
|
||||
expect(objectData).toBeDefined()
|
||||
expect(objectData.ContentLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,14 +1,4 @@
|
|||
// lucene searching not supported in test due to use of PouchDB
|
||||
let rows: Row[] = []
|
||||
jest.mock("../../sdk/app/rows/search/internalSearch", () => ({
|
||||
fullSearch: jest.fn(() => {
|
||||
return {
|
||||
rows,
|
||||
}
|
||||
}),
|
||||
paginatedSearch: jest.fn(),
|
||||
}))
|
||||
import { Row, Table } from "@budibase/types"
|
||||
import { Table } from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
|
||||
const NAME = "Test"
|
||||
|
@ -25,8 +15,8 @@ describe("Test a query step automation", () => {
|
|||
description: "original description",
|
||||
tableId: table._id,
|
||||
}
|
||||
rows.push(await config.createRow(row))
|
||||
rows.push(await config.createRow(row))
|
||||
await config.createRow(row)
|
||||
await config.createRow(row)
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
|
|
@ -150,6 +150,22 @@ function getTableName(table?: Table): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
|
||||
if (Array.isArray(query)) {
|
||||
return query.map((q: SqlQuery) => convertBooleans(q) as SqlQuery)
|
||||
} else {
|
||||
if (query.bindings) {
|
||||
query.bindings = query.bindings.map(binding => {
|
||||
if (typeof binding === "boolean") {
|
||||
return binding ? 1 : 0
|
||||
}
|
||||
return binding
|
||||
})
|
||||
}
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
class InternalBuilder {
|
||||
private readonly client: string
|
||||
|
||||
|
@ -654,7 +670,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
|||
if (opts?.disableBindings) {
|
||||
return { sql: query.toString() }
|
||||
} else {
|
||||
return getNativeSql(query)
|
||||
let native = getNativeSql(query)
|
||||
if (sqlClient === SqlClient.SQL_LITE) {
|
||||
native = convertBooleans(native)
|
||||
}
|
||||
return native
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,13 +26,13 @@ import { parse } from "content-disposition"
|
|||
import path from "path"
|
||||
import { Builder as XmlBuilder } from "xml2js"
|
||||
|
||||
const BodyTypes = {
|
||||
NONE: "none",
|
||||
FORM_DATA: "form",
|
||||
XML: "xml",
|
||||
ENCODED: "encoded",
|
||||
JSON: "json",
|
||||
TEXT: "text",
|
||||
enum BodyType {
|
||||
NONE = "none",
|
||||
FORM_DATA = "form",
|
||||
XML = "xml",
|
||||
ENCODED = "encoded",
|
||||
JSON = "json",
|
||||
TEXT = "text",
|
||||
}
|
||||
|
||||
const coreFields = {
|
||||
|
@ -54,7 +54,7 @@ const coreFields = {
|
|||
},
|
||||
bodyType: {
|
||||
type: DatasourceFieldType.STRING,
|
||||
enum: Object.values(BodyTypes),
|
||||
enum: Object.values(BodyType),
|
||||
},
|
||||
pagination: {
|
||||
type: DatasourceFieldType.OBJECT,
|
||||
|
@ -131,25 +131,28 @@ class RestIntegration implements IntegrationBase {
|
|||
}
|
||||
|
||||
async parseResponse(response: any, pagination: PaginationConfig | null) {
|
||||
let data, raw, headers, filename
|
||||
let data: any[] | string | undefined,
|
||||
raw: string | undefined,
|
||||
headers: Record<string, string> = {},
|
||||
filename: string | undefined
|
||||
|
||||
const contentType = response.headers.get("content-type") || ""
|
||||
const contentDisposition = response.headers.get("content-disposition") || ""
|
||||
if (
|
||||
contentDisposition.includes("filename") ||
|
||||
contentDisposition.includes("attachment") ||
|
||||
contentDisposition.includes("form-data")
|
||||
) {
|
||||
filename =
|
||||
path.basename(parse(contentDisposition).parameters?.filename) || ""
|
||||
}
|
||||
|
||||
try {
|
||||
if (filename) {
|
||||
return handleFileResponse(response, filename, this.startTimeMs)
|
||||
} else {
|
||||
if (response.status === 204) {
|
||||
data = []
|
||||
raw = []
|
||||
raw = ""
|
||||
} else if (contentType.includes("application/json")) {
|
||||
data = await response.json()
|
||||
raw = JSON.stringify(data)
|
||||
|
@ -162,16 +165,18 @@ class RestIntegration implements IntegrationBase {
|
|||
raw = xmlResponse.rawXml
|
||||
} else {
|
||||
data = await response.text()
|
||||
raw = data
|
||||
raw = data as string
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw `Failed to parse response body: ${err}`
|
||||
}
|
||||
|
||||
const size = formatBytes(
|
||||
response.headers.get("content-length") || Buffer.byteLength(raw, "utf8")
|
||||
)
|
||||
let contentLength: string = response.headers.get("content-length")
|
||||
if (!contentLength && raw) {
|
||||
contentLength = Buffer.byteLength(raw, "utf8").toString()
|
||||
}
|
||||
const size = formatBytes(contentLength || "0")
|
||||
const time = `${Math.round(performance.now() - this.startTimeMs)}ms`
|
||||
headers = response.headers.raw()
|
||||
for (let [key, value] of Object.entries(headers)) {
|
||||
|
@ -255,7 +260,7 @@ class RestIntegration implements IntegrationBase {
|
|||
if (!input.headers) {
|
||||
input.headers = {}
|
||||
}
|
||||
if (bodyType === BodyTypes.NONE) {
|
||||
if (bodyType === BodyType.NONE) {
|
||||
return input
|
||||
}
|
||||
let error,
|
||||
|
@ -283,11 +288,11 @@ class RestIntegration implements IntegrationBase {
|
|||
}
|
||||
|
||||
switch (bodyType) {
|
||||
case BodyTypes.TEXT:
|
||||
case BodyType.TEXT:
|
||||
// content type defaults to plaintext
|
||||
input.body = string
|
||||
break
|
||||
case BodyTypes.ENCODED: {
|
||||
case BodyType.ENCODED: {
|
||||
const params = new URLSearchParams()
|
||||
for (let [key, value] of Object.entries(object)) {
|
||||
params.append(key, value as string)
|
||||
|
@ -298,7 +303,7 @@ class RestIntegration implements IntegrationBase {
|
|||
input.body = params
|
||||
break
|
||||
}
|
||||
case BodyTypes.FORM_DATA: {
|
||||
case BodyType.FORM_DATA: {
|
||||
const form = new FormData()
|
||||
for (let [key, value] of Object.entries(object)) {
|
||||
form.append(key, value)
|
||||
|
@ -309,14 +314,14 @@ class RestIntegration implements IntegrationBase {
|
|||
input.body = form
|
||||
break
|
||||
}
|
||||
case BodyTypes.XML:
|
||||
case BodyType.XML:
|
||||
if (object != null && Object.keys(object).length) {
|
||||
string = new XmlBuilder().buildObject(object)
|
||||
}
|
||||
input.body = string
|
||||
input.headers["Content-Type"] = "application/xml"
|
||||
break
|
||||
case BodyTypes.JSON:
|
||||
case BodyType.JSON:
|
||||
// if JSON error, throw it
|
||||
if (error) {
|
||||
throw "Invalid JSON for request body"
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
import fs from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
const response = (body: any, extra?: any) => () => ({
|
||||
promise: () => body,
|
||||
...extra,
|
||||
|
@ -62,9 +59,7 @@ class S3 {
|
|||
Body: "",
|
||||
},
|
||||
{
|
||||
createReadStream: jest
|
||||
.fn()
|
||||
.mockReturnValue(fs.createReadStream(join(__dirname, "aws-sdk.ts"))),
|
||||
createReadStream: jest.fn().mockReturnValue("stream"),
|
||||
}
|
||||
)
|
||||
)
|
|
@ -1,7 +1,6 @@
|
|||
jest.mock("aws-sdk", () => require("./aws-sdk.mock"))
|
||||
import { default as DynamoDBIntegration } from "../dynamodb"
|
||||
|
||||
jest.mock("aws-sdk")
|
||||
|
||||
class TestConfiguration {
|
||||
integration: any
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@ jest.mock("uuid", () => ({ v4: () => "00000000-0000-0000-0000-000000000000" }))
|
|||
import { default as RestIntegration } from "../rest"
|
||||
import { RestAuthType } from "@budibase/types"
|
||||
import fetch from "node-fetch"
|
||||
import { objectStoreTestProviders } from "@budibase/backend-core/tests"
|
||||
import { Readable } from "stream"
|
||||
|
||||
const FormData = require("form-data")
|
||||
|
@ -246,13 +245,13 @@ describe("REST Integration", () => {
|
|||
expect(output.extra.headers["content-type"]).toEqual("application/xml")
|
||||
})
|
||||
|
||||
test.each(contentTypes)(
|
||||
test.each([...contentTypes, undefined])(
|
||||
"should not throw an error on 204 no content",
|
||||
async contentType => {
|
||||
const input = buildInput(undefined, null, contentType, 204)
|
||||
const output = await config.integration.parseResponse(input)
|
||||
expect(output.data).toEqual([])
|
||||
expect(output.extra.raw).toEqual([])
|
||||
expect(output.extra.raw).toEqual("")
|
||||
expect(output.info.code).toEqual(204)
|
||||
expect(output.extra.headers["content-type"]).toEqual(contentType)
|
||||
}
|
||||
|
@ -627,15 +626,6 @@ describe("REST Integration", () => {
|
|||
})
|
||||
|
||||
describe("File Handling", () => {
|
||||
beforeAll(async () => {
|
||||
jest.unmock("aws-sdk")
|
||||
await objectStoreTestProviders.minio.start()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await objectStoreTestProviders.minio.stop()
|
||||
})
|
||||
|
||||
it("uploads file to object store and returns signed URL", async () => {
|
||||
const responseData = Buffer.from("teest file contnt")
|
||||
const filename = "test.tar.gz"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
jest.mock("aws-sdk", () => require("./aws-sdk.mock"))
|
||||
import { default as S3Integration } from "../s3"
|
||||
|
||||
jest.mock("aws-sdk")
|
||||
|
||||
class TestConfiguration {
|
||||
integration: any
|
||||
|
||||
|
|
|
@ -164,8 +164,8 @@ export async function search(
|
|||
throw new Error("SQS cannot currently handle multiple queries")
|
||||
}
|
||||
|
||||
let sql = query.sql,
|
||||
bindings = query.bindings
|
||||
let sql = query.sql
|
||||
let bindings = query.bindings
|
||||
|
||||
// quick hack for docIds
|
||||
sql = sql.replace(/`doc1`.`rowId`/g, "`doc1.rowId`")
|
||||
|
|
|
@ -1,230 +0,0 @@
|
|||
import tk from "timekeeper"
|
||||
import * as internalSdk from "../internal"
|
||||
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import {
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
TableSourceType,
|
||||
FieldType,
|
||||
Table,
|
||||
AutoFieldSubType,
|
||||
AutoColumnFieldMetadata,
|
||||
} from "@budibase/types"
|
||||
|
||||
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||
|
||||
tk.freeze(Date.now())
|
||||
|
||||
describe("sdk >> rows >> internal", () => {
|
||||
const config = new TestConfiguration()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
|
||||
function makeRow() {
|
||||
return {
|
||||
name: generator.first(),
|
||||
surname: generator.last(),
|
||||
age: generator.age(),
|
||||
address: generator.address(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("save", () => {
|
||||
const tableData: Table = {
|
||||
name: generator.word(),
|
||||
type: "table",
|
||||
sourceId: INTERNAL_TABLE_SOURCE_ID,
|
||||
sourceType: TableSourceType.INTERNAL,
|
||||
schema: {
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
surname: {
|
||||
name: "surname",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
age: {
|
||||
name: "age",
|
||||
type: FieldType.NUMBER,
|
||||
constraints: {
|
||||
type: FieldType.NUMBER,
|
||||
},
|
||||
},
|
||||
address: {
|
||||
name: "address",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: FieldType.STRING,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("save will persist the row properly", async () => {
|
||||
const table = await config.createTable(tableData)
|
||||
const row = makeRow()
|
||||
|
||||
await config.doInContext(config.appId, async () => {
|
||||
const response = await internalSdk.save(
|
||||
table._id!,
|
||||
row,
|
||||
config.getUser()._id
|
||||
)
|
||||
|
||||
expect(response).toEqual({
|
||||
table,
|
||||
row: {
|
||||
...row,
|
||||
type: "row",
|
||||
_rev: expect.stringMatching("1-.*"),
|
||||
},
|
||||
squashed: {
|
||||
...row,
|
||||
type: "row",
|
||||
_rev: expect.stringMatching("1-.*"),
|
||||
},
|
||||
})
|
||||
|
||||
const persistedRow = await config.api.row.get(
|
||||
table._id!,
|
||||
response.row._id!
|
||||
)
|
||||
expect(persistedRow).toEqual({
|
||||
...row,
|
||||
type: "row",
|
||||
_rev: expect.stringMatching("1-.*"),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("auto ids will update when creating new rows", async () => {
|
||||
const table = await config.createTable({
|
||||
...tableData,
|
||||
schema: {
|
||||
...tableData.schema,
|
||||
id: {
|
||||
name: "id",
|
||||
type: FieldType.AUTO,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
autocolumn: true,
|
||||
lastID: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
const row = makeRow()
|
||||
|
||||
await config.doInContext(config.appId, async () => {
|
||||
const response = await internalSdk.save(
|
||||
table._id!,
|
||||
row,
|
||||
config.getUser()._id
|
||||
)
|
||||
|
||||
expect(response).toEqual({
|
||||
table: {
|
||||
...table,
|
||||
schema: {
|
||||
...table.schema,
|
||||
id: {
|
||||
...table.schema.id,
|
||||
lastID: 1,
|
||||
},
|
||||
},
|
||||
_rev: expect.stringMatching("2-.*"),
|
||||
},
|
||||
row: {
|
||||
...row,
|
||||
id: 1,
|
||||
type: "row",
|
||||
_rev: expect.stringMatching("1-.*"),
|
||||
},
|
||||
squashed: {
|
||||
...row,
|
||||
id: 1,
|
||||
type: "row",
|
||||
_rev: expect.stringMatching("1-.*"),
|
||||
},
|
||||
})
|
||||
|
||||
const persistedRow = await config.api.row.get(
|
||||
table._id!,
|
||||
response.row._id!
|
||||
)
|
||||
expect(persistedRow).toEqual({
|
||||
...row,
|
||||
type: "row",
|
||||
id: 1,
|
||||
_rev: expect.stringMatching("1-.*"),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("auto ids will update when creating new rows in parallel", async () => {
|
||||
function makeRows(count: number) {
|
||||
return Array.from({ length: count }, () => makeRow())
|
||||
}
|
||||
|
||||
const table = await config.createTable({
|
||||
...tableData,
|
||||
schema: {
|
||||
...tableData.schema,
|
||||
id: {
|
||||
name: "id",
|
||||
type: FieldType.AUTO,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
autocolumn: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await config.doInContext(config.appId, async () => {
|
||||
for (const row of makeRows(5)) {
|
||||
await internalSdk.save(table._id!, row, config.getUser()._id)
|
||||
}
|
||||
await Promise.all(
|
||||
makeRows(20).map(row =>
|
||||
internalSdk.save(table._id!, row, config.getUser()._id)
|
||||
)
|
||||
)
|
||||
for (const row of makeRows(5)) {
|
||||
await internalSdk.save(table._id!, row, config.getUser()._id)
|
||||
}
|
||||
})
|
||||
|
||||
const persistedRows = await config.getRows(table._id!)
|
||||
expect(persistedRows).toHaveLength(30)
|
||||
expect(persistedRows).toEqual(
|
||||
expect.arrayContaining(
|
||||
Array.from({ length: 30 }).map((_, i) =>
|
||||
expect.objectContaining({ id: i + 1 })
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const persistedTable = await config.getTable(table._id)
|
||||
expect(
|
||||
(table.schema.id as AutoColumnFieldMetadata).lastID
|
||||
).toBeUndefined()
|
||||
expect((persistedTable.schema.id as AutoColumnFieldMetadata).lastID).toBe(
|
||||
30
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -6,6 +6,7 @@ import {
|
|||
Table,
|
||||
TableRequest,
|
||||
ViewV2,
|
||||
AutoFieldSubType,
|
||||
} from "@budibase/types"
|
||||
import { context } from "@budibase/backend-core"
|
||||
import { buildExternalTableId } from "../../../../integrations/utils"
|
||||
|
@ -29,6 +30,52 @@ import { populateExternalTableSchemas } from "../validation"
|
|||
import datasourceSdk from "../../datasources"
|
||||
import * as viewSdk from "../../views"
|
||||
|
||||
const DEFAULT_PRIMARY_COLUMN = "id"
|
||||
|
||||
function noPrimaryKey(table: Table) {
|
||||
return table.primary == null || table.primary.length === 0
|
||||
}
|
||||
|
||||
function validate(table: Table, oldTable?: Table) {
|
||||
if (
|
||||
!oldTable &&
|
||||
table.schema[DEFAULT_PRIMARY_COLUMN] &&
|
||||
noPrimaryKey(table)
|
||||
) {
|
||||
throw new Error(
|
||||
"External tables with no `primary` column set will define an `id` column, but we found an `id` column in the supplied schema. Either set a `primary` column or remove the `id` column."
|
||||
)
|
||||
}
|
||||
|
||||
if (hasTypeChanged(table, oldTable)) {
|
||||
throw new Error("A column type has changed.")
|
||||
}
|
||||
|
||||
const autoSubTypes = Object.values(AutoFieldSubType)
|
||||
// check for auto columns, they are not allowed
|
||||
for (let [key, column] of Object.entries(table.schema)) {
|
||||
// this column is a special case, do not validate it
|
||||
if (key === DEFAULT_PRIMARY_COLUMN) {
|
||||
continue
|
||||
}
|
||||
// the auto-column type should never be used
|
||||
if (column.type === FieldType.AUTO) {
|
||||
throw new Error(
|
||||
`Column "${key}" has type "${FieldType.AUTO}" - this is not supported.`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
column.subtype &&
|
||||
autoSubTypes.includes(column.subtype as AutoFieldSubType)
|
||||
) {
|
||||
throw new Error(
|
||||
`Column "${key}" has subtype "${column.subtype}" - this is not supported.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function save(
|
||||
datasourceId: string,
|
||||
update: Table,
|
||||
|
@ -47,28 +94,18 @@ export async function save(
|
|||
oldTable = await getTable(tableId)
|
||||
}
|
||||
|
||||
if (
|
||||
!oldTable &&
|
||||
(tableToSave.primary == null || tableToSave.primary.length === 0)
|
||||
) {
|
||||
if (tableToSave.schema.id) {
|
||||
throw new Error(
|
||||
"External tables with no `primary` column set will define an `id` column, but we found an `id` column in the supplied schema. Either set a `primary` column or remove the `id` column."
|
||||
)
|
||||
}
|
||||
// this will throw an error if something is wrong
|
||||
validate(tableToSave, oldTable)
|
||||
|
||||
tableToSave.primary = ["id"]
|
||||
tableToSave.schema.id = {
|
||||
if (!oldTable && noPrimaryKey(tableToSave)) {
|
||||
tableToSave.primary = [DEFAULT_PRIMARY_COLUMN]
|
||||
tableToSave.schema[DEFAULT_PRIMARY_COLUMN] = {
|
||||
type: FieldType.NUMBER,
|
||||
autocolumn: true,
|
||||
name: "id",
|
||||
name: DEFAULT_PRIMARY_COLUMN,
|
||||
}
|
||||
}
|
||||
|
||||
if (hasTypeChanged(tableToSave, oldTable)) {
|
||||
throw new Error("A column type has changed.")
|
||||
}
|
||||
|
||||
for (let view in tableToSave.views) {
|
||||
const tableView = tableToSave.views[view]
|
||||
if (!tableView || !viewSdk.isV2(tableView)) continue
|
||||
|
|
|
@ -124,3 +124,12 @@ export async function syncGlobalUsers() {
|
|||
await db.bulkDocs(toWrite)
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserContextBindings(user: ContextUser) {
|
||||
if (!user) {
|
||||
return {}
|
||||
}
|
||||
// Current user context for bindable search
|
||||
const { _id, _rev, firstName, lastName, email, status, roleId } = user
|
||||
return { _id, _rev, firstName, lastName, email, status, roleId }
|
||||
}
|
||||
|
|
|
@ -14,3 +14,6 @@ process.env.WORKER_URL = "http://localhost:10000"
|
|||
process.env.COUCH_DB_PASSWORD = "budibase"
|
||||
process.env.COUCH_DB_USER = "budibase"
|
||||
process.env.JWT_SECRET = "jwtsecret"
|
||||
process.env.MINIO_URL = "http://localhost"
|
||||
process.env.MINIO_ACCESS_KEY = "budibase"
|
||||
process.env.MINIO_SECRET_KEY = "budibase"
|
||||
|
|
|
@ -81,6 +81,12 @@ mocks.licenses.useUnlimited()
|
|||
|
||||
dbInit()
|
||||
|
||||
export interface CreateAppRequest {
|
||||
appName: string
|
||||
url?: string
|
||||
snippets?: any[]
|
||||
}
|
||||
|
||||
export interface TableToBuild extends Omit<Table, "sourceId" | "sourceType"> {
|
||||
sourceId?: string
|
||||
sourceType?: TableSourceType
|
||||
|
@ -580,8 +586,6 @@ export default class TestConfiguration {
|
|||
|
||||
// APP
|
||||
async createApp(appName: string, url?: string): Promise<App> {
|
||||
// create dev app
|
||||
// clear any old app
|
||||
this.appId = undefined
|
||||
this.app = await context.doInTenant(
|
||||
this.tenantId!,
|
||||
|
@ -592,6 +596,7 @@ export default class TestConfiguration {
|
|||
})) as App
|
||||
)
|
||||
this.appId = this.app.appId
|
||||
|
||||
return await context.doInAppContext(this.app.appId!, async () => {
|
||||
// create production app
|
||||
this.prodApp = await this.publish()
|
||||
|
|
|
@ -78,6 +78,32 @@ export function basicTable(
|
|||
)
|
||||
}
|
||||
|
||||
export function basicTableWithAttachmentField(
|
||||
datasource?: Datasource,
|
||||
...extra: Partial<Table>[]
|
||||
): Table {
|
||||
return tableForDatasource(
|
||||
datasource,
|
||||
{
|
||||
name: "TestTable",
|
||||
schema: {
|
||||
file_attachment: {
|
||||
type: FieldType.ATTACHMENTS,
|
||||
name: "description",
|
||||
constraints: {
|
||||
type: "array",
|
||||
},
|
||||
},
|
||||
single_file_attachment: {
|
||||
type: FieldType.ATTACHMENT_SINGLE,
|
||||
name: "description",
|
||||
},
|
||||
},
|
||||
},
|
||||
...extra
|
||||
)
|
||||
}
|
||||
|
||||
export function basicView(tableId: string) {
|
||||
return {
|
||||
tableId,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as linkRows from "../../db/linkedRows"
|
||||
import { processFormulas, fixAutoColumnSubType } from "./utils"
|
||||
import { context, objectStore, utils } from "@budibase/backend-core"
|
||||
import { objectStore, utils } from "@budibase/backend-core"
|
||||
import { InternalTables } from "../../db/utils"
|
||||
import { TYPE_TRANSFORM_MAP } from "./map"
|
||||
import {
|
||||
|
@ -25,45 +25,6 @@ type AutoColumnProcessingOpts = {
|
|||
noAutoRelationships?: boolean
|
||||
}
|
||||
|
||||
// Returns the next auto ID for a column in a table. On success, the table will
|
||||
// be updated which is why it gets returned. The nextID returned is guaranteed
|
||||
// to be given only to you, and if you don't use it it's gone forever (a gap
|
||||
// will be left in the auto ID sequence).
|
||||
//
|
||||
// This function can throw if it fails to generate an auto ID after so many
|
||||
// attempts.
|
||||
async function getNextAutoId(
|
||||
table: Table,
|
||||
column: string
|
||||
): Promise<{ table: Table; nextID: number }> {
|
||||
const db = context.getAppDB()
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const schema = table.schema[column]
|
||||
if (schema.type !== FieldType.NUMBER && schema.type !== FieldType.AUTO) {
|
||||
throw new Error(`Column ${column} is not an auto column`)
|
||||
}
|
||||
schema.lastID = (schema.lastID || 0) + 1
|
||||
try {
|
||||
const resp = await db.put(table)
|
||||
table._rev = resp.rev
|
||||
return { table, nextID: schema.lastID }
|
||||
} catch (e: any) {
|
||||
if (e.status !== 409) {
|
||||
throw e
|
||||
}
|
||||
// We wait for a random amount of time before retrying. The randomness
|
||||
// makes it less likely for multiple requests modifying this table to
|
||||
// collide.
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, Math.random() * 1.2 ** attempt * 1000)
|
||||
)
|
||||
table = await db.get(table._id)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to generate an auto ID")
|
||||
}
|
||||
|
||||
/**
|
||||
* This will update any auto columns that are found on the row/table with the correct information based on
|
||||
* time now and the current logged in user making the request.
|
||||
|
@ -116,9 +77,10 @@ export async function processAutoColumn(
|
|||
break
|
||||
case AutoFieldSubType.AUTO_ID:
|
||||
if (creating) {
|
||||
const { table: newTable, nextID } = await getNextAutoId(table, key)
|
||||
table = newTable
|
||||
row[key] = nextID
|
||||
schema.lastID = schema.lastID || 0
|
||||
row[key] = schema.lastID + 1
|
||||
schema.lastID++
|
||||
table.schema[key] = schema
|
||||
}
|
||||
break
|
||||
}
|
||||
|
@ -272,7 +234,7 @@ export async function outputProcessing<T extends Row[] | Row>(
|
|||
}
|
||||
} else if (column.type === FieldType.ATTACHMENT_SINGLE) {
|
||||
for (let row of enriched) {
|
||||
if (!row[property]) {
|
||||
if (!row[property] || Object.keys(row[property]).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
@ -100,13 +100,13 @@ describe("rowProcessor - outputProcessing", () => {
|
|||
}
|
||||
|
||||
const output = await outputProcessing(table, row, { squash: false })
|
||||
expect(output.attach[0].url).toBe(
|
||||
expect(output.attach[0].url?.split("?")[0]).toBe(
|
||||
"/files/signed/prod-budi-app-assets/test.jpg"
|
||||
)
|
||||
|
||||
row.attach[0].url = ""
|
||||
const output2 = await outputProcessing(table, row, { squash: false })
|
||||
expect(output2.attach[0].url).toBe(
|
||||
expect(output2.attach[0].url?.split("?")[0]).toBe(
|
||||
"/files/signed/prod-budi-app-assets/test.jpg"
|
||||
)
|
||||
|
||||
|
@ -141,13 +141,13 @@ describe("rowProcessor - outputProcessing", () => {
|
|||
}
|
||||
|
||||
const output = await outputProcessing(table, row, { squash: false })
|
||||
expect(output.attach.url).toBe(
|
||||
expect(output.attach.url?.split("?")[0]).toBe(
|
||||
"/files/signed/prod-budi-app-assets/test.jpg"
|
||||
)
|
||||
|
||||
row.attach.url = ""
|
||||
const output2 = await outputProcessing(table, row, { squash: false })
|
||||
expect(output2.attach.url).toBe(
|
||||
expect(output2.attach?.url?.split("?")[0]).toBe(
|
||||
"/files/signed/prod-budi-app-assets/test.jpg"
|
||||
)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Document } from "../document"
|
||||
import { EventEmitter } from "events"
|
||||
import { User } from "../global"
|
||||
import { ReadStream } from "fs"
|
||||
|
||||
export enum AutomationIOType {
|
||||
OBJECT = "object",
|
||||
|
@ -235,3 +236,18 @@ export interface AutomationMetadata extends Document {
|
|||
errorCount?: number
|
||||
automationChainCount?: number
|
||||
}
|
||||
|
||||
export type AutomationAttachment = {
|
||||
url: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type AutomationAttachmentContent = {
|
||||
filename: string
|
||||
content: ReadStream | NodeJS.ReadableStream | ReadableStream<Uint8Array>
|
||||
}
|
||||
|
||||
export type BucketedContent = AutomationAttachmentContent & {
|
||||
bucket: string
|
||||
path: string
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
jest.unmock("node-fetch")
|
||||
jest.unmock("aws-sdk")
|
||||
import { TestConfiguration } from "../../../../tests"
|
||||
import { EmailTemplatePurpose } from "../../../../constants"
|
||||
import { objectStoreTestProviders } from "@budibase/backend-core/tests"
|
||||
import { objectStore } from "@budibase/backend-core"
|
||||
import tk from "timekeeper"
|
||||
import { EmailAttachment } from "@budibase/types"
|
||||
|
@ -19,12 +17,10 @@ describe("/api/global/email", () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
tk.reset()
|
||||
await objectStoreTestProviders.minio.start()
|
||||
await config.beforeAll()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await objectStoreTestProviders.minio.stop()
|
||||
await config.afterAll()
|
||||
})
|
||||
|
||||
|
|
|
@ -6,8 +6,7 @@ import { processString } from "@budibase/string-templates"
|
|||
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
|
||||
import { configs, cache, objectStore } from "@budibase/backend-core"
|
||||
import ical from "ical-generator"
|
||||
import fetch from "node-fetch"
|
||||
import path from "path"
|
||||
import _ from "lodash"
|
||||
|
||||
const nodemailer = require("nodemailer")
|
||||
|
||||
|
@ -165,39 +164,12 @@ export async function sendEmail(
|
|||
}),
|
||||
}
|
||||
if (opts?.attachments) {
|
||||
const attachments = await Promise.all(
|
||||
opts.attachments?.map(async attachment => {
|
||||
const isFullyFormedUrl =
|
||||
attachment.url.startsWith("http://") ||
|
||||
attachment.url.startsWith("https://")
|
||||
if (isFullyFormedUrl) {
|
||||
const response = await fetch(attachment.url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`unexpected response ${response.statusText}`)
|
||||
}
|
||||
const fallbackFilename = path.basename(
|
||||
new URL(attachment.url).pathname
|
||||
let attachments = await Promise.all(
|
||||
opts.attachments?.map(objectStore.processAutomationAttachment)
|
||||
)
|
||||
return {
|
||||
filename: attachment.filename || fallbackFilename,
|
||||
content: response?.body,
|
||||
}
|
||||
} else {
|
||||
const url = attachment.url
|
||||
const result = objectStore.extractBucketAndPath(url)
|
||||
if (result === null) {
|
||||
throw new Error("Invalid signed URL")
|
||||
}
|
||||
const { bucket, path } = result
|
||||
const readStream = await objectStore.getReadStream(bucket, path)
|
||||
const fallbackFilename = path.split("/").pop() || ""
|
||||
return {
|
||||
filename: attachment.filename || fallbackFilename,
|
||||
content: readStream,
|
||||
}
|
||||
}
|
||||
attachments = attachments.map(attachment => {
|
||||
return _.omit(attachment, "path")
|
||||
})
|
||||
)
|
||||
message = { ...message, attachments }
|
||||
}
|
||||
|
||||
|
|
88
yarn.lock
88
yarn.lock
|
@ -2003,9 +2003,9 @@
|
|||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.13.10":
|
||||
version "7.24.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd"
|
||||
integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==
|
||||
version "7.24.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c"
|
||||
integrity sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
|
@ -2769,13 +2769,13 @@
|
|||
yargs "^17.7.2"
|
||||
|
||||
"@grpc/proto-loader@^0.7.8":
|
||||
version "0.7.12"
|
||||
resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.12.tgz#787b58e3e3771df30b1567c057b6ab89e3a42911"
|
||||
integrity sha512-DCVwMxqYzpUCiDMl7hQ384FqP4T3DbNpXU8pt681l3UWCip1WUiD5JrkImUwCB9a7f2cq4CUTmi5r/xIMRPY1Q==
|
||||
version "0.7.13"
|
||||
resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.13.tgz#f6a44b2b7c9f7b609f5748c6eac2d420e37670cf"
|
||||
integrity sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==
|
||||
dependencies:
|
||||
lodash.camelcase "^4.3.0"
|
||||
long "^5.0.0"
|
||||
protobufjs "^7.2.4"
|
||||
protobufjs "^7.2.5"
|
||||
yargs "^17.7.2"
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
|
@ -5746,9 +5746,9 @@
|
|||
integrity sha512-7GgtHCs/QZrBrDzgIJnQtuSvhFSwhyYSI2uafSwZoNt1iOGhEN5fwNrQMjtONyHm9+/LoA4453jH0CMYcr06Pg==
|
||||
|
||||
"@types/node@>=8.1.0":
|
||||
version "20.12.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.4.tgz#af5921bd75ccdf3a3d8b3fa75bf3d3359268cd11"
|
||||
integrity sha512-E+Fa9z3wSQpzgYQdYmme5X3OTuejnnTx88A6p6vkkJosR3KBz+HpE3kqNm98VE6cfLFcISx7zW7MsJkH6KwbTw==
|
||||
version "20.12.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.10.tgz#8f0c3f12b0f075eee1fe20c1afb417e9765bef76"
|
||||
integrity sha512-Eem5pH9pmWBHoGAT8Dr5fdc5rYA+4NAovdM4EktRPVAAiJhmWWfQrA0cFhAbOsQdSfIHjAud6YdkbL69+zSKjw==
|
||||
dependencies:
|
||||
undici-types "~5.26.4"
|
||||
|
||||
|
@ -5767,9 +5767,9 @@
|
|||
undici-types "~5.26.4"
|
||||
|
||||
"@types/nodemailer@^6.4.4":
|
||||
version "6.4.14"
|
||||
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.14.tgz#5c81a5e856db7f8ede80013e6dbad7c5fb2283e2"
|
||||
integrity sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==
|
||||
version "6.4.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.15.tgz#494be695e11c438f7f5df738fb4ab740312a6ed2"
|
||||
integrity sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
|
@ -5809,9 +5809,9 @@
|
|||
"@types/passport-oauth2" "*"
|
||||
|
||||
"@types/passport-oauth2@*":
|
||||
version "1.4.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.15.tgz#34f2684f53aad36e664cd01ca9879224229f47e7"
|
||||
integrity sha512-9cUTP/HStNSZmhxXGuRrBJfEWzIEJRub2eyJu3CvkA+8HAMc9W3aKdFhVq+Qz1hi42qn+GvSAnz3zwacDSYWpw==
|
||||
version "1.4.16"
|
||||
resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.16.tgz#59189a9d69783a63d7fb92d19cd28f96c95740af"
|
||||
integrity sha512-Sdr0rpAdkiidUOtyaapGgvXyMjqYlMTFHRy7gtJtzr0/ysEIa72N3j2FSHIRc14h29g1+dzDl8IW2WT2Mu29vQ==
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
"@types/oauth" "*"
|
||||
|
@ -9404,11 +9404,16 @@ dateformat@^4.6.3:
|
|||
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5"
|
||||
integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==
|
||||
|
||||
dayjs@^1.10.8, dayjs@^1.8.15:
|
||||
dayjs@^1.10.8:
|
||||
version "1.11.10"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
|
||||
integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==
|
||||
|
||||
dayjs@^1.8.15:
|
||||
version "1.11.11"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.11.tgz#dfe0e9d54c5f8b68ccf8ca5f72ac603e7e5ed59e"
|
||||
integrity sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==
|
||||
|
||||
dc-polyfill@^0.1.2:
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/dc-polyfill/-/dc-polyfill-0.1.3.tgz#fe9eefc86813439dd46d6f9ad9582ec079c39720"
|
||||
|
@ -10269,9 +10274,9 @@ ee-first@1.1.1:
|
|||
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
|
||||
|
||||
ejs@^3.1.7:
|
||||
version "3.1.9"
|
||||
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361"
|
||||
integrity sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==
|
||||
version "3.1.10"
|
||||
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b"
|
||||
integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==
|
||||
dependencies:
|
||||
jake "^10.8.5"
|
||||
|
||||
|
@ -18310,9 +18315,9 @@ posthog-js@^1.118.0:
|
|||
preact "^10.19.3"
|
||||
|
||||
posthog-js@^1.13.4:
|
||||
version "1.103.1"
|
||||
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.103.1.tgz#f846c413c28aca204dc1527f49d39f651348f3c4"
|
||||
integrity sha512-cFXFU4Z4kl/+RUUV4ju1DlfM7dwCGi6H9xWsfhljIhGcBbT8UfS4JGgZGXl9ABQDdgDPb9xciqnysFSsUQshTA==
|
||||
version "1.131.3"
|
||||
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.131.3.tgz#bd3e6123dc715f089825a92d3ec62480b7ec0a76"
|
||||
integrity sha512-ds/TADDS+rT/WgUyeW4cJ+X+fX+O1KdkOyssNI/tP90PrFf0IJsck5B42YOLhfz87U2vgTyBaKHkdlMgWuOFog==
|
||||
dependencies:
|
||||
fflate "^0.4.8"
|
||||
preact "^10.19.3"
|
||||
|
@ -18954,9 +18959,9 @@ q@^1.1.2:
|
|||
integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==
|
||||
|
||||
qs@^6.10.3:
|
||||
version "6.12.0"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.0.tgz#edd40c3b823995946a8a0b1f208669c7a200db77"
|
||||
integrity sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==
|
||||
version "6.12.1"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.1.tgz#39422111ca7cbdb70425541cba20c7d7b216599a"
|
||||
integrity sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==
|
||||
dependencies:
|
||||
side-channel "^1.0.6"
|
||||
|
||||
|
@ -20735,16 +20740,7 @@ string-similarity@^4.0.4:
|
|||
resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
|
||||
integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
|
@ -20834,7 +20830,7 @@ stringify-object@^3.2.1:
|
|||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
@ -20848,13 +20844,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
|
|||
dependencies:
|
||||
ansi-regex "^4.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
|
||||
|
@ -22777,7 +22766,7 @@ worker-farm@1.7.0:
|
|||
dependencies:
|
||||
errno "~0.1.7"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
|
@ -22795,15 +22784,6 @@ wrap-ansi@^5.1.0:
|
|||
string-width "^3.0.0"
|
||||
strip-ansi "^5.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
|
Loading…
Reference in New Issue