Merge branch 'master' of github.com:Budibase/budibase into aggregate-all-sql-dbs

This commit is contained in:
mike12345567 2024-09-11 13:25:25 +01:00
commit 05de673781
60 changed files with 2688 additions and 496 deletions

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.31.7",
"version": "2.32.0",
"npmClient": "yarn",
"packages": [
"packages/*",

@ -1 +1 @@
Subproject commit c24374879d2b61516fabc24d7404e7da235be05e
Subproject commit 048c37ecd921340614bf61a76a774aaa46176569

View File

@ -171,6 +171,7 @@ const environment = {
// Couch/search
SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE,
SQL_MAX_ROWS: process.env.SQL_MAX_ROWS,
SQL_MAX_RELATED_ROWS: process.env.MAX_RELATED_ROWS,
// smtp
SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED,
SMTP_USER: process.env.SMTP_USER,

View File

@ -0,0 +1,21 @@
import { publishEvent } from "../events"
import {
Event,
AIConfigCreatedEvent,
AIConfigUpdatedEvent,
} from "@budibase/types"
async function AIConfigCreated(timestamp?: string | number) {
const properties: AIConfigCreatedEvent = {}
await publishEvent(Event.AI_CONFIG_CREATED, properties, timestamp)
}
async function AIConfigUpdated() {
const properties: AIConfigUpdatedEvent = {}
await publishEvent(Event.AI_CONFIG_UPDATED, properties)
}
export default {
AIConfigCreated,
AIConfigUpdated,
}

View File

@ -4,6 +4,7 @@ export { default as auth } from "./auth"
export { default as automation } from "./automation"
export { default as datasource } from "./datasource"
export { default as email } from "./email"
export { default as ai } from "./ai"
export { default as license } from "./license"
export { default as layout } from "./layout"
export { default as org } from "./org"

View File

@ -267,6 +267,8 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// default values set correctly and their types flow through the system.
export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(false),
})

View File

@ -49,6 +49,13 @@ function getBaseLimit() {
return envLimit || 5000
}
function getRelationshipLimit() {
const envLimit = environment.SQL_MAX_RELATED_ROWS
? parseInt(environment.SQL_MAX_RELATED_ROWS)
: null
return envLimit || 500
}
function getTableName(table?: Table): string | undefined {
// SQS uses the table ID rather than the table name
if (
@ -915,7 +922,7 @@ class InternalBuilder {
const primaryKey = `${toAlias}.${toPrimary || toKey}`
let subQuery: Knex.QueryBuilder = knex
.from(toTableWithSchema)
.limit(getBaseLimit())
.limit(getRelationshipLimit())
// add sorting to get consistent order
.orderBy(primaryKey)

View File

@ -55,6 +55,11 @@ export function quotas(): Quotas {
value: 1,
triggers: [],
},
budibaseAICredits: {
name: "Budibase AI Credits",
value: 1,
triggers: [],
},
},
static: {
rows: {
@ -87,6 +92,11 @@ export function quotas(): Quotas {
value: 1,
triggers: [],
},
aiCustomConfigs: {
name: "Plugins",
value: 1,
triggers: [],
},
},
},
constant: {

View File

@ -17,6 +17,7 @@ export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
automations: 0,
dayPasses: 0,
queries: 0,
budibaseAICredits: 0,
triggers: {},
breakdown: {
rowQueries: {
@ -46,12 +47,14 @@ export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
automations: 0,
dayPasses: 0,
queries: 0,
budibaseAICredits: 0,
triggers: {},
},
current: {
automations: 0,
dayPasses: 0,
queries: 0,
budibaseAICredits: 0,
triggers: {},
},
},
@ -62,6 +65,7 @@ export const usage = (users: number = 0, creators: number = 0): QuotaUsage => {
creators,
userGroups: 0,
rows: 0,
aiCustomConfigs: 0,
triggers: {},
},
}

View File

@ -93,6 +93,7 @@
"identity-obj-proxy": "^3.0.0",
"jest": "29.7.0",
"jsdom": "^21.1.1",
"resize-observer-polyfill": "^1.5.1",
"svelte-jester": "^1.3.2",
"vite": "^4.5.0",
"vite-plugin-static-copy": "^0.17.0",

View File

@ -0,0 +1,133 @@
<script>
import { Body, Label, Icon } from "@budibase/bbui"
import BudibaseLogo from "./logos/Budibase.svelte"
import OpenAILogo from "./logos/OpenAI.svelte"
import AnthropicLogo from "./logos/Anthropic.svelte"
import TogetherAILogo from "./logos/TogetherAI.svelte"
import { Providers } from "./constants"
const logos = {
["Budibase AI"]: BudibaseLogo,
[Providers.OpenAI.name]: OpenAILogo,
[Providers.Anthropic.name]: AnthropicLogo,
[Providers.TogetherAI.name]: TogetherAILogo,
}
export let config
export let disabled
export let editHandler
export let deleteHandler
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div on:click class:disabled class="option">
<div class="icon">
<svelte:component
this={logos[config.name || config.provider]}
height="30"
width="30"
/>
</div>
<div class="header">
<Body>{config.provider}</Body>
<Label>{config.name}</Label>
</div>
<div class="controls">
{#if config.name !== "Budibase AI"}
<Icon
on:click={editHandler}
color="var(--grey-6)"
size="S"
hoverable
name="Edit"
/>
<Icon
on:click={deleteHandler}
color="var(--grey-6)"
size="S"
hoverable
name="Delete"
/>
{/if}
{#if config.active}
<div class="tag active">Activated</div>
{:else if !config.active}
<div class="tag disabled">Disabled</div>
{/if}
{#if config.isDefault}
<div class="tag default">Default</div>
{/if}
</div>
</div>
<style>
.option {
background-color: var(--background);
border: 1px solid var(--grey-4);
padding: 16px;
border-radius: 4px;
cursor: pointer;
display: grid;
grid-template-columns: 6% 1fr auto;
grid-gap: 20px;
align-items: center;
}
.option :global(label) {
cursor: pointer;
}
.option:hover {
background-color: var(--background-alt);
}
.header {
align-items: center;
}
.icon {
background-color: white;
height: 38px;
width: 38px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
}
.disabled {
pointer-events: none;
}
.controls {
display: grid;
grid-auto-flow: column;
grid-gap: 10px;
align-items: center;
}
.tag {
display: flex;
color: var(--spectrum-body-m-text-color);
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
font-size: 12px;
border-radius: 5px;
}
.default {
background: var(--grey-6);
}
.active {
background: var(--spectrum-global-color-green-600);
}
.disabled {
background: var(--spectrum-global-color-red-600);
}
</style>

View File

@ -0,0 +1,92 @@
import { it, expect, describe, vi } from "vitest"
import AISettings from "./index.svelte"
import { render } from "@testing-library/svelte"
import { admin, licensing } from "stores/portal"
vi.spyOn(notifications, "error").mockImplementation(vi.fn)
vi.spyOn(notifications, "success").mockImplementation(vi.fn)
const Hosting = {
Cloud: "cloud",
Self: "self",
}
function setupEnv(hosting, features = {}) {
const defaultFeatures = {
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
...features,
}
admin.subscribe = vi.fn().mockImplementation(callback => {
callback({ cloud: hosting === Hosting.Cloud })
return () => {}
})
licensing.subscribe = vi.fn().mockImplementation(callback => {
callback(defaultFeatures)
return () => {}
})
}
describe("AISettings", () => {
let instance = null
afterEach(() => {
vi.restoreAllMocks()
})
it("that the AISettings is rendered", () => {
instance = render(AISettings, {})
expect(instance).toBeDefined()
})
describe("Licensing", () => {
it("should show the premium label on self host for custom configs", async () => {
setupEnv(Hosting.Self)
instance = render(AISettings, {})
const premiumTag = instance.queryByText("Premium")
expect(premiumTag).toBeInTheDocument()
})
it("should show the enterprise label on cloud for custom configs", async () => {
setupEnv(Hosting.Cloud)
instance = render(AISettings, {})
const enterpriseTag = instance.queryByText("Enterprise")
expect(enterpriseTag).toBeInTheDocument()
})
it("should show the premium label on cloud when Budibase AI isn't enabled", async () => {
setupEnv(Hosting.Cloud)
instance = render(AISettings, {})
const premiumTag = instance.queryByText("Premium")
expect(premiumTag).toBeInTheDocument()
})
it("should not show the add configuration button if the user doesn't have the correct license on cloud", async () => {
let addConfigurationButton
setupEnv(Hosting.Cloud)
instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).not.toBeInTheDocument()
setupEnv(Hosting.Cloud, { customAIConfigsEnabled: true })
instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
})
it("should not show the add configuration button if the user doesn't have the correct license on self host", async () => {
let addConfigurationButton
setupEnv(Hosting.Self)
instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).not.toBeInTheDocument()
setupEnv(Hosting.Self, { customAIConfigsEnabled: true })
instance = render(AISettings, {})
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,97 @@
<script>
import { ModalContent, Label, Input, Select, Toggle } from "@budibase/bbui"
import { ConfigMap, Providers } from "./constants"
export let config = {
active: false,
isDefault: false,
}
export let saveHandler
export let deleteHandler
let validation
$: {
const { provider, defaultModel, name, apiKey } = config
validation = provider && defaultModel && name && apiKey
}
$: canEditBaseUrl =
config.provider && ConfigMap[config.provider].baseUrl === ""
function prefillConfig(evt) {
const provider = evt.detail
// grab the preset config from the constants for that provider and fill it in
if (ConfigMap[provider]) {
config = {
...config,
...ConfigMap[provider],
provider,
}
} else {
config.provider = provider
}
}
</script>
<ModalContent
confirmText={"Save"}
cancelText={"Delete"}
onConfirm={saveHandler}
onCancel={deleteHandler}
disabled={!validation}
size="M"
title="Custom AI Configuration"
>
<div class="form-row">
<Label size="M">Provider</Label>
<Select
placeholder={null}
bind:value={config.provider}
options={Object.keys(Providers)}
on:change={prefillConfig}
/>
</div>
<div class="form-row">
<Label size="M">Name</Label>
<Input
error={config.name === "Budibase AI" ? "Cannot use this name" : null}
placeholder={"Enter a name"}
bind:value={config.name}
/>
</div>
<div class="form-row">
<Label size="M">Default Model</Label>
{#if config.provider !== Providers.Custom.name}
<Select
placeholder={config.provider ? "Choose an option" : "Select a provider"}
bind:value={config.defaultModel}
options={config.provider ? Providers[config.provider].models : []}
/>
{:else}
<Input bind:value={config.defaultModel} />
{/if}
</div>
<div class="form-row">
<Label size="M">Base URL</Label>
<Input
disabled={!canEditBaseUrl}
placeholder={"https://budibase.ai"}
bind:value={config.baseUrl}
/>
</div>
<div class="form-row">
<Label size="M">API Key</Label>
<Input type="password" bind:value={config.apiKey} />
</div>
<Toggle text="Active" bind:value={config.active} />
<Toggle text="Set as default" bind:value={config.isDefault} />
</ModalContent>
<style>
.form-row {
display: grid;
grid-gap: var(--spacing-s);
align-items: center;
}
</style>

View File

@ -0,0 +1,60 @@
export const Providers = {
OpenAI: {
name: "OpenAI",
models: [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" },
{ label: "GPT 4 Turbo", value: "gpt-4-turbo" },
{ label: "GPT 4", value: "gpt-4" },
{ label: "GPT 3.5 Turbo", value: "gpt-3.5-turbo" },
],
},
Anthropic: {
name: "Anthropic",
models: [
{ label: "Claude 3.5 Sonnet", value: "claude-3-5-sonnet-20240620" },
{ label: "Claude 3 Sonnet", value: "claude-3-sonnet-20240229" },
{ label: "Claude 3 Opus", value: "claude-3-opus-20240229" },
{ label: "Claude 3 Haiku", value: "claude-3-haiku-20240307" },
],
},
TogetherAI: {
name: "Together AI",
models: [{ label: "Llama 3 8B", value: "meta-llama/Meta-Llama-3-8B" }],
},
AzureOpenAI: {
name: "Azure Open AI",
models: [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" },
{ label: "GPT 4 Turbo", value: "gpt-4-turbo" },
{ label: "GPT 4", value: "gpt-4" },
{ label: "GPT 3.5 Turbo", value: "gpt-3.5-turbo" },
],
},
Custom: {
name: "Custom",
},
}
export const ConfigMap = {
OpenAI: {
name: "OpenAI",
baseUrl: "https://api.openai.com",
},
Anthropic: {
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
},
TogetherAI: {
name: "TogetherAI",
baseUrl: "https://api.together.xyz/v1",
},
AzureOpenAI: {
name: "Azure OpenAI",
baseUrl: "",
},
Custom: {
baseUrl: "",
},
}

View File

@ -0,0 +1,173 @@
<script>
import { onMount } from "svelte"
import {
Button,
Layout,
Heading,
Body,
Helpers,
Divider,
notifications,
Modal,
Tags,
Tag,
} from "@budibase/bbui"
import { admin, licensing } from "stores/portal"
import { API } from "api"
import AIConfigModal from "./ConfigModal.svelte"
import AIConfigTile from "./AIConfigTile.svelte"
const ConfigTypes = {
AI: "ai",
}
let modal
let fullAIConfig
let editingAIConfig = {}
let editingUuid
$: isCloud = $admin.cloud
$: budibaseAIEnabled = $licensing.budibaseAIEnabled
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
async function fetchAIConfig() {
try {
const aiDoc = await API.getConfig(ConfigTypes.AI)
if (aiDoc._id) {
fullAIConfig = aiDoc
}
} catch (error) {
notifications.error("Error fetching AI config")
}
}
async function saveConfig() {
// Use existing key or generate new one
const id = editingUuid || Helpers.uuid()
// Creating first custom AI Config
if (!fullAIConfig) {
fullAIConfig = {
type: ConfigTypes.AI,
config: {
[id]: editingAIConfig,
},
}
} else {
// We don't store the default BB AI config in the DB
delete fullAIConfig.config.budibase_ai
// unset the default value from other configs if default is set
if (editingAIConfig.isDefault) {
for (let key in fullAIConfig.config) {
fullAIConfig.config[key].isDefault = false
}
}
// Add new or update existing custom AI Config
fullAIConfig.config[id] = editingAIConfig
}
try {
await API.saveConfig(fullAIConfig)
notifications.success(`Successfully saved and activated AI Configuration`)
} catch (error) {
notifications.error(
`Failed to save AI Configuration, reason: ${
error?.message || "Unknown"
}`
)
} finally {
await fetchAIConfig()
}
}
async function deleteConfig(key) {
// We don't store the default BB AI config in the DB
delete fullAIConfig.config.budibase_ai
// Delete the configuration
delete fullAIConfig.config[key]
try {
await API.saveConfig(fullAIConfig)
notifications.success(`Deleted config`)
} catch (error) {
notifications.error(
`Failed to delete config, reason: ${error?.message || "Unknown"}`
)
} finally {
await fetchAIConfig()
}
}
function editConfig(uuid) {
editingUuid = uuid
editingAIConfig = fullAIConfig?.config[editingUuid]
modal.show()
}
function newConfig() {
editingUuid = undefined
editingAIConfig = undefined
modal.show()
}
onMount(() => {
fetchAIConfig()
})
</script>
<Modal bind:this={modal}>
<AIConfigModal
saveHandler={saveConfig}
deleteHandler={deleteConfig}
bind:config={editingAIConfig}
/>
</Modal>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading size="M">AI</Heading>
{#if isCloud && !budibaseAIEnabled}
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
{/if}
<Body>Configure your AI settings within this section:</Body>
</Layout>
<Divider />
<Layout noPadding>
<div class="config-heading">
<Heading size="S">AI Configurations</Heading>
{#if !isCloud && !customAIConfigsEnabled}
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
{:else if isCloud && !customAIConfigsEnabled}
<Tags>
<Tag icon="LockClosed">Enterprise</Tag>
</Tags>
{:else}
<Button size="S" cta on:click={newConfig}>Add configuration</Button>
{/if}
</div>
<Body size="S"
>Use the following interface to select your preferred AI configuration.</Body
>
<Body size="S">Select your AI Model:</Body>
{#if fullAIConfig?.config}
{#each Object.keys(fullAIConfig.config) as key}
<AIConfigTile
config={fullAIConfig.config[key]}
editHandler={() => editConfig(key)}
deleteHandler={() => deleteConfig(key)}
/>
{/each}
{/if}
</Layout>
</Layout>
<style>
.config-heading {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -0,0 +1,27 @@
<script>
export let height
export let width
</script>
<svg
id="katman_1"
{height}
{width}
data-name="katman 1"
xmlns="http://www.w3.org/2000/svg"
version="1.1"
viewBox="0 0 841.89 595.28"
>
<defs>
<style>
.cls-1 {
fill: #000;
stroke-width: 0px;
}
</style>
</defs>
<path
class="cls-1"
d="M552.13,91.34h-90.99l162.93,412.61h88.87l-160.81-412.61ZM289.76,91.34l-160.81,412.61h90.99l35.97-86.75h169.28l33.86,84.64h90.99L384.97,91.34h-95.22ZM281.29,341.02l55.01-146,57.13,146h-112.14Z"
/>
</svg>

View File

@ -0,0 +1,36 @@
<script>
export let width
export let height
</script>
<svg
{width}
{height}
viewBox="0 0 265 265"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_1799)">
<path
d="M158.2 8.6V116.6C158.2 121.3 162 125.2 166.8 125.2H213.8C218 125.2 222 123.2 224.6 119.8L262.9 68.9C265.7 65.2 265.7 60.1 262.9 56.4L224.6 5.4C222 2 218 0 213.8 0H166.8C162 0 158.2 3.8 158.2 8.6Z"
fill="#FF4E4E"
/>
<path
d="M158.2 148.4V256.4C158.2 261.1 162 265 166.8 265H213.8C218 265 222 263 224.6 259.6L262.9 208.7C265.7 205 265.7 199.9 262.9 196.2L224.6 145.3C222.1 141.9 218.1 139.9 213.8 139.9H166.8C162 139.8 158.2 143.7 158.2 148.4Z"
fill="#6E56FF"
/>
<path
d="M0 8.6V116.6C0 121.3 3.8 125.2 8.6 125.2H109.6C113.8 125.2 117.8 123.2 120.4 119.8L155.9 72.5C160.3 66.6 160.3 58.5 155.9 52.6L120.3 5.4C117.8 2 113.8 0 109.5 0H8.6C3.8 0 0 3.8 0 8.6Z"
fill="#F97777"
/>
<path
d="M0 148.4V256.4C0 261.1 3.8 265 8.6 265H109.6C113.8 265 117.8 263 120.4 259.6L155.9 212.3C160.3 206.4 160.3 198.3 155.9 192.4L120.4 145.1C117.9 141.7 113.9 139.7 109.6 139.7H8.6C3.8 139.8 0 143.7 0 148.4Z"
fill="#9F8FFF"
/>
</g>
<defs>
<clipPath id="clip0_1_1799">
<rect width="265" height="265" fill="white" />
</clipPath>
</defs>
</svg>

View File

@ -0,0 +1,18 @@
<script>
export let height
export let width
</script>
<svg
fill="#000000"
{width}
{height}
viewBox="0 0 24 24"
role="img"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI icon</title>
<path
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
/>
</svg>

View File

@ -0,0 +1,57 @@
<script>
export let height
export let width
</script>
<svg
{width}
{height}
viewBox="0 0 107 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.0727 6.13665H0.99707V4.44456H3.0727V1.2183H4.92272V4.44456H7.83312V6.13665H4.92272V12.9276C4.92272 13.4089 5.01297 13.7548 5.19346 13.9654C5.38899 14.1609 5.71988 14.2587 6.18615 14.2587H8.1941V15.9508H6.07334C4.9904 15.9508 4.2158 15.7101 3.74954 15.2288C3.29831 14.7475 3.0727 13.9879 3.0727 12.9501V6.13665Z"
fill="currentColor"
/>
<path
d="M14.1547 16.0861C13.0417 16.0861 12.049 15.838 11.1766 15.3416C10.3193 14.8453 9.64995 14.1534 9.16864 13.266C8.68734 12.3786 8.44669 11.3558 8.44669 10.1977C8.44669 9.03952 8.68734 8.01675 9.16864 7.12934C9.64995 6.24193 10.3193 5.55006 11.1766 5.05371C12.049 4.55736 13.0417 4.30919 14.1547 4.30919C15.2677 4.30919 16.2529 4.55736 17.1102 5.05371C17.9826 5.55006 18.6594 6.24193 19.1407 7.12934C19.622 8.01675 19.8627 9.03952 19.8627 10.1977C19.8627 11.3558 19.622 12.3786 19.1407 13.266C18.6594 14.1534 17.9826 14.8453 17.1102 15.3416C16.2529 15.838 15.2677 16.0861 14.1547 16.0861ZM14.1547 14.4392C14.9067 14.4392 15.5685 14.2587 16.1401 13.8977C16.7266 13.5367 17.1779 13.0329 17.4937 12.3861C17.8096 11.7393 17.9675 11.0099 17.9675 10.1977C17.9675 9.38546 17.8096 8.65598 17.4937 8.00923C17.1779 7.36247 16.7266 6.85861 16.1401 6.49763C15.5685 6.13665 14.9067 5.95616 14.1547 5.95616C13.4026 5.95616 12.7333 6.13665 12.1467 6.49763C11.5752 6.85861 11.1315 7.36247 10.8156 8.00923C10.4998 8.65598 10.3418 9.38546 10.3418 10.1977C10.3418 11.0099 10.4998 11.7393 10.8156 12.3861C11.1315 13.0329 11.5752 13.5367 12.1467 13.8977C12.7333 14.2587 13.4026 14.4392 14.1547 14.4392Z"
fill="currentColor"
/>
<path
d="M32.5327 4.44456V15.3867C32.5327 18.8612 30.6301 20.5984 26.8247 20.5984C25.3357 20.5984 24.1174 20.26 23.1698 19.5831C22.2373 18.9063 21.6958 17.9437 21.5454 16.6953H23.4406C23.591 17.4172 23.9745 17.9738 24.5912 18.3648C25.2079 18.7559 25.9975 18.9514 26.9601 18.9514C29.4418 18.9514 30.6827 17.7406 30.6827 15.3191V13.9654C29.8555 15.3792 28.5394 16.0861 26.7345 16.0861C25.6666 16.0861 24.7115 15.853 23.8692 15.3867C23.042 14.9205 22.3877 14.2436 21.9064 13.3562C21.4401 12.4688 21.207 11.416 21.207 10.1977C21.207 9.03952 21.4401 8.01675 21.9064 7.12934C22.3877 6.24193 23.0495 5.55006 23.8918 5.05371C24.7341 4.55736 25.6816 4.30919 26.7345 4.30919C27.667 4.30919 28.4642 4.4972 29.126 4.87322C29.7878 5.2342 30.3067 5.75311 30.6827 6.42994L30.9309 4.44456H32.5327ZM26.915 14.4392C27.652 14.4392 28.3063 14.2662 28.8778 13.9203C29.4644 13.5593 29.9156 13.0629 30.2315 12.4312C30.5473 11.7845 30.7053 11.055 30.7053 10.2428C30.7053 9.41554 30.5473 8.67854 30.2315 8.03179C29.9156 7.36999 29.4644 6.85861 28.8778 6.49763C28.3063 6.13665 27.652 5.95616 26.915 5.95616C25.7719 5.95616 24.8469 6.35474 24.14 7.1519C23.4481 7.93402 23.1021 8.94928 23.1021 10.1977C23.1021 11.4461 23.4481 12.4688 24.14 13.266C24.8469 14.0481 25.7719 14.4392 26.915 14.4392Z"
fill="currentColor"
/>
<path
d="M34.2758 10.1977C34.2758 9.02448 34.5014 7.99419 34.9526 7.10678C35.4189 6.21937 36.0656 5.53502 36.8929 5.05371C37.7201 4.55736 38.6677 4.30919 39.7356 4.30919C40.7884 4.30919 41.721 4.52728 42.5332 4.96346C43.3454 5.39965 43.9846 6.03136 44.4509 6.85861C44.9171 7.68585 45.1653 8.65598 45.1954 9.769C45.1954 9.93445 45.1803 10.1977 45.1503 10.5586H36.216V10.7166C36.2461 11.8446 36.592 12.7471 37.2538 13.4239C37.9156 14.1008 38.7805 14.4392 39.8484 14.4392C40.6756 14.4392 41.375 14.2361 41.9466 13.83C42.5332 13.4089 42.9242 12.8298 43.1198 12.0928H44.9923C44.7667 13.266 44.2027 14.2286 43.3002 14.9806C42.3978 15.7176 41.2923 16.0861 39.9837 16.0861C38.8406 16.0861 37.8404 15.8455 36.9831 15.3642C36.1258 14.8678 35.4565 14.176 34.9752 13.2885C34.5089 12.3861 34.2758 11.3558 34.2758 10.1977ZM43.2777 9.02448C43.1874 8.04683 42.8189 7.28727 42.1722 6.7458C41.5405 6.20433 40.7358 5.9336 39.7581 5.9336C38.8858 5.9336 38.1187 6.21937 37.4569 6.79092C36.7951 7.36247 36.4191 8.10699 36.3288 9.02448H43.2777Z"
fill="currentColor"
/>
<path
d="M47.5703 6.13665H45.4947V4.44456H47.5703V1.2183H49.4203V4.44456H52.3307V6.13665H49.4203V12.9276C49.4203 13.4089 49.5106 13.7548 49.6911 13.9654C49.8866 14.1609 50.2175 14.2587 50.6838 14.2587H52.6917V15.9508H50.571C49.488 15.9508 48.7134 15.7101 48.2472 15.2288C47.7959 14.7475 47.5703 13.9879 47.5703 12.9501V6.13665Z"
fill="currentColor"
/>
<path
d="M59.2771 4.30919C60.6759 4.30919 61.804 4.73033 62.6613 5.57262C63.5186 6.4149 63.9473 7.73849 63.9473 9.54339V15.9508H62.0973V9.6562C62.0973 8.46797 61.8265 7.56552 61.2851 6.94885C60.7436 6.31714 59.969 6.00128 58.9613 6.00128C57.8934 6.00128 57.0436 6.3773 56.4119 7.12934C55.7952 7.88138 55.4868 8.90415 55.4868 10.1977V15.9508H53.6368V0.157928H55.4868V6.3397C55.8629 5.73807 56.3592 5.24924 56.9759 4.87322C57.6076 4.4972 58.3747 4.30919 59.2771 4.30919Z"
fill="currentColor"
/>
<path
d="M65.5284 10.1977C65.5284 9.02448 65.754 7.99419 66.2052 7.10678C66.6715 6.21937 67.3182 5.53502 68.1455 5.05371C68.9727 4.55736 69.9203 4.30919 70.9882 4.30919C72.041 4.30919 72.9736 4.52728 73.7858 4.96346C74.598 5.39965 75.2372 6.03136 75.7035 6.85861C76.1697 7.68585 76.4179 8.65598 76.448 9.769C76.448 9.93445 76.4329 10.1977 76.4029 10.5586H67.4686V10.7166C67.4987 11.8446 67.8446 12.7471 68.5064 13.4239C69.1682 14.1008 70.0331 14.4392 71.101 14.4392C71.9282 14.4392 72.6276 14.2361 73.1992 13.83C73.7858 13.4089 74.1768 12.8298 74.3723 12.0928H76.2449C76.0193 13.266 75.4553 14.2286 74.5528 14.9806C73.6504 15.7176 72.5449 16.0861 71.2363 16.0861C70.0932 16.0861 69.093 15.8455 68.2357 15.3642C67.3784 14.8678 66.7091 14.176 66.2278 13.2885C65.7615 12.3861 65.5284 11.3558 65.5284 10.1977ZM74.5303 9.02448C74.44 8.04683 74.0715 7.28727 73.4248 6.7458C72.7931 6.20433 71.9884 5.9336 71.0107 5.9336C70.1384 5.9336 69.3713 6.21937 68.7095 6.79092C68.0477 7.36247 67.6717 8.10699 67.5814 9.02448H74.5303Z"
fill="currentColor"
/>
<path
d="M84.0314 4.44456V6.24945H83.1064C82.0084 6.24945 81.2188 6.61795 80.7374 7.35495C80.2712 8.09195 80.038 9.00192 80.038 10.0849V15.9508H78.188V4.44456H79.7899L80.038 6.18177C80.3689 5.65534 80.7976 5.2342 81.324 4.91834C81.8505 4.60249 82.5799 4.44456 83.5125 4.44456H84.0314Z"
fill="currentColor"
/>
<path
d="M102.875 14.2587V15.9508H101.859C101.122 15.9508 100.596 15.8004 100.28 15.4995C99.9643 15.1987 99.7988 14.755 99.7838 14.1684C98.8964 15.4469 97.6104 16.0861 95.9258 16.0861C94.6473 16.0861 93.6171 15.7853 92.8349 15.1837C92.0678 14.5821 91.6843 13.7623 91.6843 12.7245C91.6843 11.5664 92.0754 10.679 92.8575 10.0623C93.6546 9.44562 94.8053 9.13729 96.3093 9.13729H99.6935V8.34764C99.6935 7.5956 99.4378 7.00901 98.9265 6.58787C98.4301 6.16673 97.7307 5.95616 96.8283 5.95616C96.0311 5.95616 95.3693 6.13665 94.8429 6.49763C94.3315 6.84356 94.0156 7.30983 93.8953 7.89642H92.0453C92.1806 6.76836 92.677 5.88847 93.5343 5.25676C94.4067 4.62505 95.5348 4.30919 96.9185 4.30919C98.3925 4.30919 99.5281 4.67017 100.325 5.39213C101.137 6.09905 101.544 7.12182 101.544 8.46045V13.4239C101.544 13.9804 101.799 14.2587 102.311 14.2587H102.875ZM99.6935 10.694H96.1289C94.4142 10.694 93.5569 11.3332 93.5569 12.6117C93.5569 13.1833 93.7825 13.642 94.2337 13.9879C94.685 14.3339 95.2941 14.5069 96.0612 14.5069C97.1892 14.5069 98.0766 14.2136 98.7234 13.627C99.3702 13.0253 99.6935 12.2357 99.6935 11.258V10.694Z"
fill="currentColor"
/>
<path
d="M104.851 0C105.197 0 105.483 0.112806 105.708 0.338418C105.934 0.56403 106.047 0.849805 106.047 1.19574C106.047 1.54168 105.934 1.82746 105.708 2.05307C105.483 2.27868 105.197 2.39149 104.851 2.39149C104.505 2.39149 104.219 2.27868 103.994 2.05307C103.768 1.82746 103.655 1.54168 103.655 1.19574C103.655 0.849805 103.768 0.56403 103.994 0.338418C104.219 0.112806 104.505 0 104.851 0ZM103.926 4.44456H105.776V15.9508H103.926V4.44456Z"
fill="currentColor"
/>
<path
d="M88.067 14.1988C88.067 15.1664 87.2731 15.9508 86.2937 15.9508C85.3144 15.9508 84.5205 15.1664 84.5205 14.1988C84.5205 13.2312 85.3144 12.4468 86.2937 12.4468C87.2731 12.4468 88.067 13.2312 88.067 14.1988Z"
fill="#0F6FFF"
/>
</svg>

View File

@ -22,6 +22,8 @@ export const createLicensingStore = () => {
backupsEnabled: false,
brandingEnabled: false,
scimEnabled: false,
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
// the currently used quotas from the db
quotaUsage: undefined,
// derived quota metrics for percentages used
@ -142,6 +144,14 @@ export const createLicensingStore = () => {
Constants.Features.VIEW_READONLY_COLUMNS
)
const budibaseAIEnabled = license.features.includes(
Constants.Features.BUDIBASE_AI
)
const customAIConfigsEnabled = license.features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
store.update(state => {
return {
...state,
@ -153,6 +163,8 @@ export const createLicensingStore = () => {
groupsEnabled,
backupsEnabled,
brandingEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,
environmentVariablesEnabled,
auditLogsEnabled,

View File

@ -1,7 +1,9 @@
import { derived } from "svelte/store"
import { admin } from "./admin"
import { auth } from "./auth"
import { isEnabled } from "helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
import { FeatureFlag } from "@budibase/types"
export const menu = derived([admin, auth], ([$admin, $auth]) => {
const user = $auth?.user
@ -62,6 +64,13 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/settings/environment",
},
]
if (isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) {
settingsSubPages.push({
title: "AI",
href: "/builder/portal/settings/ai",
})
}
if (!cloud) {
settingsSubPages.push({
title: "Version",
@ -75,7 +84,9 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
menu.push({
title: "Settings",
href: "/builder/portal/settings",
subPages: settingsSubPages,
subPages: [...settingsSubPages].sort((a, b) =>
a.title.localeCompare(b.title)
),
})
}

View File

@ -35,7 +35,7 @@ export default defineConfig(({ mode }) => {
// Copy fonts to an additional path so that svelte's automatic
// prefixing of the base URL path can still resolve assets
copyFonts("builder/fonts"),
]
]
return {
test: {

View File

@ -1,4 +1,7 @@
import { expect } from "vitest"
import "@testing-library/jest-dom/vitest"
global.ResizeObserver = require("resize-observer-polyfill")
expect.extend({
toBeFunc: received => {

View File

@ -12,7 +12,7 @@
export let columns
export let fromRelationshipField
const { datasource, dispatch, cache, config } = getContext("grid")
const { datasource, dispatch, config } = getContext("grid")
$: canSetRelationshipSchemas = $config.canSetRelationshipSchemas
@ -114,29 +114,19 @@
return { ...c, options }
})
let relationshipPanelColumns = []
async function fetchRelationshipPanelColumns(relationshipField) {
relationshipPanelColumns = []
if (!relationshipField) {
return
$: relationshipPanelColumns = Object.entries(
relationshipField?.columns || {}
).map(([name, column]) => {
return {
name: name,
label: name,
schema: {
type: column.type,
visible: column.visible,
readonly: column.readonly,
},
}
const table = await cache.actions.getTable(relationshipField.tableId)
relationshipPanelColumns = Object.entries(
relationshipField?.columns || {}
).map(([name, column]) => {
return {
name: name,
label: name,
schema: {
type: table.schema[name].type,
visible: column.visible,
readonly: column.readonly,
},
}
})
}
$: fetchRelationshipPanelColumns(relationshipField)
})
async function toggleColumn(column, permission) {
const visible = permission !== FieldPermissions.HIDDEN
@ -219,7 +209,7 @@
on:close={() => (relationshipFieldName = null)}
open={relationshipFieldName}
anchor={relationshipPanelAnchor}
align="right-outside"
align="left"
>
{#if relationshipPanelColumns.length}
<div class="relationship-header">

View File

@ -44,6 +44,7 @@
{wrap}
portalTarget="#{gridID} .grid-popover-container"
offset={0}
clickOutsideOverride
>
<div
class="grid-popover-contents"

View File

@ -76,7 +76,9 @@ export const ExtendedBudibaseRoleOptions = [
value: BudibaseRoles.Owner,
sortOrder: 0,
},
].concat(BudibaseRoleOptions)
]
.concat(BudibaseRoleOptions)
.concat(BudibaseRoleOptionsOld)
export const PlanType = {
FREE: "free",

@ -1 +1 @@
Subproject commit a4d1d15d9ce6ac3deedb2e42625c90ba32756758
Subproject commit ec1d2bda756f02c6b4efdee086e4c59b0c2a1b0c

View File

@ -1,5 +1,5 @@
// need to handle table name + field or just field, depending on if relationships used
import { FieldSchema, FieldType, Row, Table } from "@budibase/types"
import { FieldSchema, FieldType, Row, Table, JsonTypes } from "@budibase/types"
import {
helpers,
PROTECTED_EXTERNAL_COLUMNS,
@ -62,6 +62,22 @@ export function generateIdForRow(
return generateRowIdField(idParts)
}
function fixJsonTypes(row: Row, table: Table) {
for (let [fieldName, schema] of Object.entries(table.schema)) {
if (JsonTypes.includes(schema.type) && typeof row[fieldName] === "string") {
try {
row[fieldName] = JSON.parse(row[fieldName])
} catch (err) {
if (!helpers.schema.isDeprecatedSingleUserColumn(schema)) {
// couldn't convert back to array, ignore
delete row[fieldName]
}
}
}
}
return row
}
export function basicProcessing({
row,
table,
@ -134,12 +150,15 @@ export function basicProcessing({
// make sure all of them have an _id
const sortField = relatedTable.primaryDisplay || relatedTable.primary![0]!
thisRow[col] = (thisRow[col] as Row[])
.map(relatedRow => {
relatedRow._id = relatedRow._id
? relatedRow._id
: generateIdForRow(relatedRow, relatedTable)
return relatedRow
})
.map(relatedRow =>
basicProcessing({
row: relatedRow,
table: relatedTable,
tables,
isLinked: false,
sqs,
})
)
.sort((a, b) => {
const aField = a?.[sortField],
bField = b?.[sortField]
@ -154,24 +173,5 @@ export function basicProcessing({
})
}
}
return thisRow
}
export function fixArrayTypes(row: Row, table: Table) {
for (let [fieldName, schema] of Object.entries(table.schema)) {
if (
[FieldType.ARRAY, FieldType.BB_REFERENCE].includes(schema.type) &&
typeof row[fieldName] === "string"
) {
try {
row[fieldName] = JSON.parse(row[fieldName])
} catch (err) {
if (!helpers.schema.isDeprecatedSingleUserColumn(schema)) {
// couldn't convert back to array, ignore
delete row[fieldName]
}
}
}
}
return row
return fixJsonTypes(thisRow, table)
}

View File

@ -14,12 +14,7 @@ import {
processFormulas,
} from "../../../../utilities/rowProcessor"
import { isKnexEmptyReadResponse } from "./sqlUtils"
import {
basicProcessing,
generateIdForRow,
fixArrayTypes,
getInternalRowId,
} from "./basic"
import { basicProcessing, generateIdForRow, getInternalRowId } from "./basic"
import sdk from "../../../../sdk"
import { processStringSync } from "@budibase/string-templates"
import validateJs from "validate.js"
@ -149,16 +144,13 @@ export async function sqlOutputProcessing(
rowId = generateIdForRow(row, table)
row._id = rowId
}
const thisRow = fixArrayTypes(
basicProcessing({
row,
table,
tables: Object.values(tables),
isLinked: false,
sqs: opts?.sqs,
}),
table
)
const thisRow = basicProcessing({
row,
table,
tables: Object.values(tables),
isLinked: false,
sqs: opts?.sqs,
})
if (thisRow._id == null) {
throw new Error("Unable to generate row ID for SQL rows")
}

View File

@ -29,6 +29,9 @@ async function parseSchema(view: CreateViewRequest) {
acc[key] = {
visible: fieldSchema.visible,
readonly: fieldSchema.readonly,
order: fieldSchema.order,
width: fieldSchema.width,
icon: fieldSchema.icon,
}
return acc
}, {})

View File

@ -9,10 +9,10 @@ import {
db as dbCore,
MAX_VALID_DATE,
MIN_VALID_DATE,
setEnv as setCoreEnv,
SQLITE_DESIGN_DOC_ID,
utils,
withEnv as withCoreEnv,
setEnv as setCoreEnv,
} from "@budibase/backend-core"
import * as setup from "./utilities"
@ -1937,6 +1937,67 @@ describe.each([
})
})
isSql &&
describe("related formulas", () => {
beforeAll(async () => {
const arrayTable = await createTable(
{
name: { name: "name", type: FieldType.STRING },
array: {
name: "array",
type: FieldType.ARRAY,
constraints: {
type: JsonFieldSubType.ARRAY,
inclusion: ["option 1", "option 2"],
},
},
},
"array"
)
table = await createTable(
{
relationship: {
type: FieldType.LINK,
relationshipType: RelationshipType.MANY_TO_ONE,
name: "relationship",
fieldName: "relate",
tableId: arrayTable._id!,
constraints: {
type: "array",
},
},
formula: {
type: FieldType.FORMULA,
name: "formula",
formula: encodeJSBinding(
`let array = [];$("relationship").forEach(rel => array = array.concat(rel.array));return array.sort().join(",")`
),
},
},
"main"
)
const arrayRows = await Promise.all([
config.api.row.save(arrayTable._id!, {
name: "foo",
array: ["option 1"],
}),
config.api.row.save(arrayTable._id!, {
name: "bar",
array: ["option 2"],
}),
])
await Promise.all([
config.api.row.save(table._id!, {
relationship: [arrayRows[0]._id, arrayRows[1]._id],
}),
])
})
it("formula is correct with relationship arrays", async () => {
await expectQuery({}).toContain([{ formula: "option 1,option 2" }])
})
})
describe("user", () => {
let user1: User
let user2: User
@ -2846,6 +2907,28 @@ describe.each([
'Invalid body - "query.$and.conditions[1].$and.conditions" is required'
)
})
it("returns no rows when onEmptyFilter set to none", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
$and: {
conditions: [{ equal: { name: "" } }],
},
},
}).toFindNothing()
})
it("returns all rows when onEmptyFilter set to all", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
$and: {
conditions: [{ equal: { name: "" } }],
},
},
}).toHaveLength(4)
})
})
!isLucene &&
@ -2974,5 +3057,27 @@ describe.each([
},
}).toContainExactly([{ age: 1, name: "Jane" }])
})
it("returns no rows when onEmptyFilter set to none", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
$or: {
conditions: [{ equal: { name: "" } }],
},
},
}).toFindNothing()
})
it("returns all rows when onEmptyFilter set to all", async () => {
await expectSearch({
query: {
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
$or: {
conditions: [{ equal: { name: "" } }],
},
},
}).toHaveLength(4)
})
})
})

View File

@ -1278,9 +1278,18 @@ describe.each([
schema: expect.objectContaining({
aux: expect.objectContaining({
columns: {
id: { visible: false, readonly: false },
name: { visible: true, readonly: true },
dob: { visible: true, readonly: true },
id: expect.objectContaining({
visible: false,
readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
},
}),
}),
@ -1323,16 +1332,34 @@ describe.each([
schema: expect.objectContaining({
aux: expect.objectContaining({
columns: {
id: { visible: false, readonly: false },
name: { visible: true, readonly: true },
dob: { visible: true, readonly: true },
id: expect.objectContaining({
visible: false,
readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
},
}),
aux2: expect.objectContaining({
columns: {
id: { visible: false, readonly: false },
name: { visible: true, readonly: true },
dob: { visible: true, readonly: true },
id: expect.objectContaining({
visible: false,
readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
},
}),
}),
@ -1375,16 +1402,34 @@ describe.each([
schema: expect.objectContaining({
aux: expect.objectContaining({
columns: {
id: { visible: false, readonly: false },
fullName: { visible: true, readonly: true },
age: { visible: false, readonly: false },
id: expect.objectContaining({
visible: false,
readonly: false,
}),
fullName: expect.objectContaining({
visible: true,
readonly: true,
}),
age: expect.objectContaining({
visible: false,
readonly: false,
}),
},
}),
aux2: expect.objectContaining({
columns: {
id: { visible: false, readonly: false },
name: { visible: true, readonly: true },
age: { visible: false, readonly: false },
id: expect.objectContaining({
visible: false,
readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
age: expect.objectContaining({
visible: false,
readonly: false,
}),
},
}),
}),
@ -1427,9 +1472,18 @@ describe.each([
schema: expect.objectContaining({
aux: expect.objectContaining({
columns: {
id: { visible: false, readonly: false },
name: { visible: true, readonly: true },
dob: { visible: true, readonly: true },
id: expect.objectContaining({
visible: false,
readonly: false,
}),
name: expect.objectContaining({
visible: true,
readonly: true,
}),
dob: expect.objectContaining({
visible: true,
readonly: true,
}),
},
}),
}),

View File

@ -16,6 +16,7 @@ import * as delay from "./steps/delay"
import * as queryRow from "./steps/queryRows"
import * as loop from "./steps/loop"
import * as collect from "./steps/collect"
import * as branch from "./steps/branch"
import * as triggerAutomationRun from "./steps/triggerAutomationRun"
import env from "../environment"
import {
@ -28,6 +29,7 @@ import {
} from "@budibase/types"
import sdk from "../sdk"
import { getAutomationPlugin } from "../utilities/fileSystem"
import { features } from "@budibase/backend-core"
type ActionImplType = ActionImplementations<
typeof env.SELF_HOSTED extends "true" ? Hosting.SELF : Hosting.CLOUD
@ -98,6 +100,9 @@ if (env.SELF_HOSTED) {
}
export async function getActionDefinitions() {
if (await features.flags.isEnabled("AUTOMATION_BRANCHING")) {
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
}
const actionDefinitions = BUILTIN_ACTION_DEFINITIONS
if (env.SELF_HOSTED) {
const plugins = await sdk.plugins.fetch(PluginType.AUTOMATION)

View File

@ -31,7 +31,7 @@ import { cache, configs, context, HTTPError } from "@budibase/backend-core"
import { dataFilters, utils } from "@budibase/shared-core"
import { GOOGLE_SHEETS_PRIMARY_KEY } from "../constants"
interface GoogleSheetsConfig {
export interface GoogleSheetsConfig {
spreadsheetId: string
auth: OAuthClientConfig
continueSetupId?: string
@ -157,7 +157,7 @@ const SCHEMA: Integration = {
},
}
class GoogleSheetsIntegration implements DatasourcePlus {
export class GoogleSheetsIntegration implements DatasourcePlus {
private readonly config: GoogleSheetsConfig
private readonly spreadsheetId: string
private client: GoogleSpreadsheet = undefined!
@ -378,6 +378,10 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return this.create({ sheet, row: json.body as Row })
case Operation.BULK_CREATE:
return this.createBulk({ sheet, rows: json.body as Row[] })
case Operation.BULK_UPSERT:
// This is technically not correct because it won't update existing
// rows, but it's better than not having this functionality at all.
return this.createBulk({ sheet, rows: json.body as Row[] })
case Operation.READ:
return this.read({ ...json, sheet })
case Operation.UPDATE:
@ -395,9 +399,19 @@ class GoogleSheetsIntegration implements DatasourcePlus {
sheet,
})
case Operation.CREATE_TABLE:
return this.createTable(json?.table?.name)
if (!json.table) {
throw new Error(
"attempted to create a table without specifying the table to create"
)
}
return this.createTable(json.table)
case Operation.UPDATE_TABLE:
return this.updateTable(json.table!)
if (!json.table) {
throw new Error(
"attempted to create a table without specifying the table to create"
)
}
return this.updateTable(json.table)
case Operation.DELETE_TABLE:
return this.deleteTable(json?.table?.name)
default:
@ -422,13 +436,13 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return rowObject
}
private async createTable(name?: string) {
if (!name) {
throw new Error("Must provide name for new sheet.")
}
private async createTable(table: Table) {
try {
await this.connect()
await this.client.addSheet({ title: name, headerValues: [name] })
await this.client.addSheet({
title: table.name,
headerValues: Object.keys(table.schema),
})
} catch (err) {
console.error("Error creating new table in google sheets", err)
throw err
@ -552,37 +566,16 @@ class GoogleSheetsIntegration implements DatasourcePlus {
} else {
rows = await sheet.getRows()
}
// this is a special case - need to handle the _id, it doesn't exist
// we cannot edit the returned structure from google, it does not have
// setter functions and is immutable, easier to update the filters
// to look for the _rowNumber property rather than rowNumber
if (query.filters?.equal) {
const idFilterKeys = Object.keys(query.filters.equal).filter(filter =>
filter.includes(GOOGLE_SHEETS_PRIMARY_KEY)
)
for (let idFilterKey of idFilterKeys) {
const id = query.filters.equal[idFilterKey]
delete query.filters.equal[idFilterKey]
query.filters.equal[`_${GOOGLE_SHEETS_PRIMARY_KEY}`] = id
}
}
let filtered = dataFilters.runQuery(
rows,
query.filters || {},
(row: GoogleSpreadsheetRow, headerKey: string) => {
return row.get(headerKey)
}
)
if (hasFilters && query.paginate) {
filtered = filtered.slice(offset, offset + limit)
rows = rows.slice(offset, offset + limit)
}
const headerValues = sheet.headerValues
let response = []
for (let row of filtered) {
response.push(
this.buildRowObject(headerValues, row.toObject(), row._rowNumber)
)
}
let response = rows.map(row =>
this.buildRowObject(headerValues, row.toObject(), row.rowNumber)
)
response = dataFilters.runQuery(response, query.filters || {})
if (query.sort) {
if (Object.keys(query.sort).length !== 1) {

View File

@ -1,50 +1,44 @@
import { setEnv as setCoreEnv } from "@budibase/backend-core"
import type { GoogleSpreadsheetWorksheet } from "google-spreadsheet"
import nock from "nock"
jest.mock("google-auth-library")
const { OAuth2Client } = require("google-auth-library")
const setCredentialsMock = jest.fn()
const getAccessTokenMock = jest.fn()
OAuth2Client.mockImplementation(() => {
return {
setCredentials: setCredentialsMock,
getAccessToken: getAccessTokenMock,
}
})
jest.mock("google-spreadsheet")
const { GoogleSpreadsheet } = require("google-spreadsheet")
const sheetsByTitle: { [title: string]: GoogleSpreadsheetWorksheet } = {}
const sheetsByIndex: GoogleSpreadsheetWorksheet[] = []
const mockGoogleIntegration = {
useOAuth2Client: jest.fn(),
loadInfo: jest.fn(),
sheetsByTitle,
sheetsByIndex,
}
GoogleSpreadsheet.mockImplementation(() => mockGoogleIntegration)
import { structures } from "@budibase/backend-core/tests"
import TestConfiguration from "../../tests/utilities/TestConfiguration"
import GoogleSheetsIntegration from "../googlesheets"
import { FieldType, Table, TableSchema, TableSourceType } from "@budibase/types"
import { generateDatasourceID } from "../../db/utils"
import {
Datasource,
FieldType,
SourceName,
Table,
TableSourceType,
} from "@budibase/types"
import { GoogleSheetsMock } from "./utils/googlesheets"
describe("Google Sheets Integration", () => {
let integration: any,
config = new TestConfiguration()
let cleanupEnv: () => void
const config = new TestConfiguration()
beforeAll(() => {
let cleanupEnv: () => void
let datasource: Datasource
let mock: GoogleSheetsMock
beforeAll(async () => {
cleanupEnv = setCoreEnv({
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
})
await config.init()
datasource = await config.api.datasource.create({
name: "Test Datasource",
type: "datasource",
source: SourceName.GOOGLE_SHEETS,
config: {
spreadsheetId: "randomId",
auth: {
appId: "appId",
accessToken: "accessToken",
refreshToken: "refreshToken",
},
},
})
})
afterAll(async () => {
@ -53,125 +47,257 @@ describe("Google Sheets Integration", () => {
})
beforeEach(async () => {
integration = new GoogleSheetsIntegration.integration({
spreadsheetId: "randomId",
auth: {
appId: "appId",
accessToken: "accessToken",
refreshToken: "refreshToken",
},
})
await config.init()
jest.clearAllMocks()
nock.cleanAll()
nock("https://www.googleapis.com/").post("/oauth2/v4/token").reply(200, {
grant_type: "client_credentials",
client_id: "your-client-id",
client_secret: "your-client-secret",
})
mock = GoogleSheetsMock.forDatasource(datasource)
})
function createBasicTable(name: string, columns: string[]): Table {
return {
type: "table",
name,
sourceId: generateDatasourceID(),
sourceType: TableSourceType.EXTERNAL,
schema: {
...columns.reduce((p, c) => {
p[c] = {
name: c,
describe("create", () => {
it("creates a new table", async () => {
const table = await config.api.table.save({
name: "Test Table",
type: "table",
sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: "string",
},
}
return p
}, {} as TableSchema),
},
}
}
function createSheet({
headerValues,
}: {
headerValues: string[]
}): GoogleSpreadsheetWorksheet {
return {
// to ignore the unmapped fields
...({} as any),
loadHeaderRow: jest.fn(),
headerValues,
setHeaderRow: jest.fn(),
}
}
describe("update table", () => {
it("adding a new field will be adding a new header row", async () => {
await config.doInContext(structures.uuid(), async () => {
const tableColumns = ["name", "description", "new field"]
const table = createBasicTable(structures.uuid(), tableColumns)
const sheet = createSheet({ headerValues: ["name", "description"] })
sheetsByTitle[table.name] = sheet
await integration.updateTable(table)
expect(sheet.loadHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledWith(tableColumns)
},
description: {
name: "description",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
})
expect(table.name).toEqual("Test Table")
expect(mock.cell("A1")).toEqual("name")
expect(mock.cell("B1")).toEqual("description")
expect(mock.cell("A2")).toEqual(null)
expect(mock.cell("B2")).toEqual(null)
})
it("removing an existing field will remove the header from the google sheet", async () => {
const sheet = await config.doInContext(structures.uuid(), async () => {
const tableColumns = ["name"]
const table = createBasicTable(structures.uuid(), tableColumns)
const sheet = createSheet({
headerValues: ["name", "description", "location"],
})
sheetsByTitle[table.name] = sheet
await integration.updateTable(table)
return sheet
it("can handle multiple tables", async () => {
const table1 = await config.api.table.save({
name: "Test Table 1",
type: "table",
sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
one: {
name: "one",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
})
expect(sheet.loadHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledWith([
"name",
"description",
"location",
])
const table2 = await config.api.table.save({
name: "Test Table 2",
type: "table",
sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
two: {
name: "two",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
})
expect(table1.name).toEqual("Test Table 1")
expect(table2.name).toEqual("Test Table 2")
expect(mock.cell("Test Table 1!A1")).toEqual("one")
expect(mock.cell("Test Table 1!A2")).toEqual(null)
expect(mock.cell("Test Table 2!A1")).toEqual("two")
expect(mock.cell("Test Table 2!A2")).toEqual(null)
})
})
describe("getTableNames", () => {
it("can fetch table names", async () => {
await config.doInContext(structures.uuid(), async () => {
const sheetNames: string[] = []
for (let i = 0; i < 5; i++) {
const sheet = createSheet({ headerValues: [] })
sheetsByIndex.push(sheet)
sheetNames.push(sheet.title)
}
const res = await integration.getTableNames()
expect(mockGoogleIntegration.loadInfo).toHaveBeenCalledTimes(1)
expect(res).toEqual(sheetNames)
describe("read", () => {
let table: Table
beforeEach(async () => {
table = await config.api.table.save({
name: "Test Table",
type: "table",
sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
description: {
name: "description",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
})
await config.api.row.bulkImport(table._id!, {
rows: [
{
name: "Test Contact 1",
description: "original description 1",
},
{
name: "Test Contact 2",
description: "original description 2",
},
],
})
})
it("can read table details", async () => {
const response = await config.api.table.get(table._id!)
expect(response.name).toEqual("Test Table")
expect(response.schema).toEqual({
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
description: {
name: "description",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
})
})
it("can read table rows", async () => {
const rows = await config.api.row.fetch(table._id!)
expect(rows.length).toEqual(2)
expect(rows[0].name).toEqual("Test Contact 1")
expect(rows[0].description).toEqual("original description 1")
expect(rows[0]._id).toEqual("%5B2%5D")
expect(rows[1].name).toEqual("Test Contact 2")
expect(rows[1].description).toEqual("original description 2")
expect(rows[1]._id).toEqual("%5B3%5D")
})
it("can get a specific row", async () => {
const row1 = await config.api.row.get(table._id!, "2")
expect(row1.name).toEqual("Test Contact 1")
expect(row1.description).toEqual("original description 1")
const row2 = await config.api.row.get(table._id!, "3")
expect(row2.name).toEqual("Test Contact 2")
expect(row2.description).toEqual("original description 2")
})
})
describe("testConnection", () => {
it("can test successful connections", async () => {
await config.doInContext(structures.uuid(), async () => {
const res = await integration.testConnection()
expect(mockGoogleIntegration.loadInfo).toHaveBeenCalledTimes(1)
expect(res).toEqual({ connected: true })
describe("update", () => {
let table: Table
beforeEach(async () => {
table = await config.api.table.save({
name: "Test Table",
type: "table",
sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
description: {
name: "description",
type: FieldType.STRING,
},
},
})
})
it("should be able to add a new row", async () => {
const row = await config.api.row.save(table._id!, {
name: "Test Contact",
description: "original description",
})
expect(row.name).toEqual("Test Contact")
expect(row.description).toEqual("original description")
expect(mock.cell("A2")).toEqual("Test Contact")
expect(mock.cell("B2")).toEqual("original description")
const row2 = await config.api.row.save(table._id!, {
name: "Test Contact 2",
description: "original description 2",
})
expect(row2.name).toEqual("Test Contact 2")
expect(row2.description).toEqual("original description 2")
// Notable that adding a new row adds it at the top, not the bottom. Not
// entirely sure if this is the intended behaviour or an incorrect
// implementation of the GoogleSheetsMock.
expect(mock.cell("A2")).toEqual("Test Contact 2")
expect(mock.cell("B2")).toEqual("original description 2")
expect(mock.cell("A3")).toEqual("Test Contact")
expect(mock.cell("B3")).toEqual("original description")
})
it("should be able to add multiple rows", async () => {
await config.api.row.bulkImport(table._id!, {
rows: [
{
name: "Test Contact 1",
description: "original description 1",
},
{
name: "Test Contact 2",
description: "original description 2",
},
],
})
expect(mock.cell("A2")).toEqual("Test Contact 1")
expect(mock.cell("B2")).toEqual("original description 1")
expect(mock.cell("A3")).toEqual("Test Contact 2")
expect(mock.cell("B3")).toEqual("original description 2")
})
it("should be able to update a row", async () => {
const row = await config.api.row.save(table._id!, {
name: "Test Contact",
description: "original description",
})
expect(mock.cell("A2")).toEqual("Test Contact")
expect(mock.cell("B2")).toEqual("original description")
await config.api.row.save(table._id!, {
...row,
name: "Test Contact Updated",
description: "original description updated",
})
expect(mock.cell("A2")).toEqual("Test Contact Updated")
expect(mock.cell("B2")).toEqual("original description updated")
})
})
})

View File

@ -149,6 +149,7 @@ function generateManyRelationshipJson(config: { schema?: string } = {}) {
}
describe("SQL query builder", () => {
const relationshipLimit = 500
const limit = 500
const client = SqlClient.POSTGRES
let sql: any
@ -160,7 +161,7 @@ describe("SQL query builder", () => {
it("should add the schema to the LEFT JOIN", () => {
const query = sql._query(generateRelationshipJson({ schema: "production" }))
expect(query).toEqual({
bindings: [5000, limit],
bindings: [relationshipLimit, limit],
sql: `select "brands".*, (select json_agg(json_build_object('product_id',"products"."product_id",'product_name',"products"."product_name",'brand_id',"products"."brand_id")) from (select "products".* from "production"."products" as "products" where "products"."brand_id" = "brands"."brand_id" order by "products"."brand_id" asc limit $1) as "products") as "products" from "production"."brands" order by "test"."id" asc limit $2`,
})
})
@ -168,7 +169,7 @@ describe("SQL query builder", () => {
it("should handle if the schema is not present when doing a LEFT JOIN", () => {
const query = sql._query(generateRelationshipJson())
expect(query).toEqual({
bindings: [5000, limit],
bindings: [relationshipLimit, limit],
sql: `select "brands".*, (select json_agg(json_build_object('product_id',"products"."product_id",'product_name',"products"."product_name",'brand_id',"products"."brand_id")) from (select "products".* from "products" as "products" where "products"."brand_id" = "brands"."brand_id" order by "products"."brand_id" asc limit $1) as "products") as "products" from "brands" order by "test"."id" asc limit $2`,
})
})
@ -178,7 +179,7 @@ describe("SQL query builder", () => {
generateManyRelationshipJson({ schema: "production" })
)
expect(query).toEqual({
bindings: [5000, limit],
bindings: [relationshipLimit, limit],
sql: `select "stores".*, (select json_agg(json_build_object('product_id',"products"."product_id",'product_name',"products"."product_name")) from (select "products".* from "production"."products" as "products" inner join "production"."stocks" as "stocks" on "products"."product_id" = "stocks"."product_id" where "stocks"."store_id" = "stores"."store_id" order by "products"."product_id" asc limit $1) as "products") as "products" from "production"."stores" order by "test"."id" asc limit $2`,
})
})

View File

@ -32,7 +32,7 @@ function multiline(sql: string) {
}
describe("Captures of real examples", () => {
const baseLimit = 5000
const relationshipLimit = 500
const primaryLimit = 100
function getJson(name: string): QueryJson {
@ -42,7 +42,9 @@ describe("Captures of real examples", () => {
describe("create", () => {
it("should create a row with relationships", () => {
const queryJson = getJson("createWithRelationships.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: ["A Street", 34, "London", "A", "B", "designer", 1990],
sql: multiline(`insert into "persons" ("address", "age", "city", "firstname", "lastname", "type", "year")
@ -54,9 +56,11 @@ describe("Captures of real examples", () => {
describe("read", () => {
it("should handle basic retrieval with relationships", () => {
const queryJson = getJson("basicFetchWithRelationships.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: [baseLimit, baseLimit, primaryLimit],
bindings: [relationshipLimit, relationshipLimit, primaryLimit],
sql: expect.stringContaining(
multiline(
`select json_agg(json_build_object('executorid',"b"."executorid",'taskname',"b"."taskname",'taskid',"b"."taskid",'completed',"b"."completed",'qaid',"b"."qaid",'executorid',"b"."executorid",'taskname',"b"."taskname",'taskid',"b"."taskid",'completed',"b"."completed",'qaid',"b"."qaid")`
@ -67,9 +71,11 @@ describe("Captures of real examples", () => {
it("should handle filtering by relationship", () => {
const queryJson = getJson("filterByRelationship.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: [baseLimit, "assembling", primaryLimit],
bindings: [relationshipLimit, "assembling", primaryLimit],
sql: expect.stringContaining(
multiline(
`where exists (select 1 from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid"
@ -81,9 +87,11 @@ describe("Captures of real examples", () => {
it("should handle fetching many to many relationships", () => {
const queryJson = getJson("fetchManyToMany.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: [baseLimit, primaryLimit],
bindings: [relationshipLimit, primaryLimit],
sql: expect.stringContaining(
multiline(
`select json_agg(json_build_object('executorid',"b"."executorid",'taskname',"b"."taskname",'taskid',"b"."taskid",'completed',"b"."completed",'qaid',"b"."qaid"))
@ -97,9 +105,11 @@ describe("Captures of real examples", () => {
it("should handle enrichment of rows", () => {
const queryJson = getJson("enrichRelationship.json")
const filters = queryJson.filters?.oneOf?.taskid as number[]
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: [baseLimit, ...filters, baseLimit],
bindings: [relationshipLimit, ...filters, relationshipLimit],
sql: multiline(
`select "a".*, (select json_agg(json_build_object('productname',"b"."productname",'productid',"b"."productid"))
from (select "b".* from "products" as "b" inner join "products_tasks" as "c" on "b"."productid" = "c"."productid"
@ -111,7 +121,9 @@ describe("Captures of real examples", () => {
it("should manage query with many relationship filters", () => {
const queryJson = getJson("manyRelationshipFilters.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
const filters = queryJson.filters
const notEqualsValue = Object.values(filters?.notEqual!)[0]
const rangeValue: { high?: string | number; low?: string | number } =
@ -120,9 +132,9 @@ describe("Captures of real examples", () => {
expect(query).toEqual({
bindings: [
baseLimit,
baseLimit,
baseLimit,
relationshipLimit,
relationshipLimit,
relationshipLimit,
rangeValue.low,
rangeValue.high,
equalValue,
@ -141,7 +153,9 @@ describe("Captures of real examples", () => {
describe("update", () => {
it("should handle performing a simple update", () => {
const queryJson = getJson("updateSimple.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5],
sql: multiline(
@ -153,7 +167,9 @@ describe("Captures of real examples", () => {
it("should handle performing an update of relationships", () => {
const queryJson = getJson("updateRelationship.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5],
sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4,
@ -165,7 +181,9 @@ describe("Captures of real examples", () => {
describe("delete", () => {
it("should handle deleting with relationships", () => {
const queryJson = getJson("deleteSimple.json")
let query = new Sql(SqlClient.POSTGRES, baseLimit)._query(queryJson)
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
queryJson
)
expect(query).toEqual({
bindings: ["ddd", ""],
sql: multiline(`delete from "compositetable" as "a"
@ -178,7 +196,7 @@ describe("Captures of real examples", () => {
describe("returning (everything bar Postgres)", () => {
it("should be able to handle row returning", () => {
const queryJson = getJson("createSimple.json")
const SQL = new Sql(SqlClient.MS_SQL, baseLimit)
const SQL = new Sql(SqlClient.MS_SQL, relationshipLimit)
let query = SQL._query(queryJson, { disableReturning: true })
expect(query).toEqual({
sql: "insert into [people] ([age], [name]) values (@p0, @p1)",

View File

@ -0,0 +1,863 @@
// In this file is a mock implementation of the Google Sheets API. It is used
// to test the Google Sheets integration, and it keeps track of a single
// spreadsheet with many sheets. It aims to be a faithful recreation of the
// Google Sheets API, but it is not a perfect recreation. Some fields are
// missing if they aren't relevant to our use of the API. It's possible that
// this will cause problems for future feature development, but the original
// development of these tests involved hitting Google's APIs directly and
// examining the responses. If we couldn't find a good example of something in
// use, it wasn't included.
import { Datasource } from "@budibase/types"
import nock from "nock"
import { GoogleSheetsConfig } from "../../googlesheets"
import type {
SpreadsheetProperties,
ExtendedValue,
WorksheetDimension,
WorksheetDimensionProperties,
WorksheetProperties,
CellData,
CellBorder,
CellFormat,
CellPadding,
Color,
} from "google-spreadsheet/src/lib/types/sheets-types"
const BLACK: Color = { red: 0, green: 0, blue: 0 }
const WHITE: Color = { red: 1, green: 1, blue: 1 }
const NO_PADDING: CellPadding = { top: 0, right: 0, bottom: 0, left: 0 }
const DEFAULT_BORDER: CellBorder = {
style: "SOLID",
width: 1,
color: BLACK,
colorStyle: { rgbColor: BLACK },
}
const DEFAULT_CELL_FORMAT: CellFormat = {
hyperlinkDisplayType: "PLAIN_TEXT",
horizontalAlignment: "LEFT",
verticalAlignment: "BOTTOM",
wrapStrategy: "OVERFLOW_CELL",
textDirection: "LEFT_TO_RIGHT",
textRotation: { angle: 0, vertical: false },
padding: NO_PADDING,
backgroundColorStyle: { rgbColor: BLACK },
borders: {
top: DEFAULT_BORDER,
bottom: DEFAULT_BORDER,
left: DEFAULT_BORDER,
right: DEFAULT_BORDER,
},
numberFormat: {
type: "NUMBER",
pattern: "General",
},
backgroundColor: WHITE,
textFormat: {
foregroundColor: BLACK,
fontFamily: "Arial",
fontSize: 10,
bold: false,
italic: false,
strikethrough: false,
underline: false,
},
}
// https://protobuf.dev/reference/protobuf/google.protobuf/#value
type Value = string | number | boolean | null
interface Range {
row: number
column: number
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values#ValueRange
interface ValueRange {
range: string
majorDimension: WorksheetDimension
values: Value[][]
}
// https://developers.google.com/sheets/api/reference/rest/v4/UpdateValuesResponse
interface UpdateValuesResponse {
spreadsheetId: string
updatedRange: string
updatedRows: number
updatedColumns: number
updatedCells: number
updatedData: ValueRange
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/response#AddSheetResponse
interface AddSheetResponse {
properties: WorksheetProperties
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/response
interface BatchUpdateResponse {
spreadsheetId: string
replies: {
addSheet?: AddSheetResponse
}[]
updatedSpreadsheet: Spreadsheet
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest
interface AddSheetRequest {
properties: WorksheetProperties
}
interface Request {
addSheet?: AddSheetRequest
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request
interface BatchUpdateRequest {
requests: Request[]
includeSpreadsheetInResponse: boolean
responseRanges: string[]
responseIncludeGridData: boolean
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#RowData
interface RowData {
values: CellData[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridData
interface GridData {
startRow: number
startColumn: number
rowData: RowData[]
rowMetadata: WorksheetDimensionProperties[]
columnMetadata: WorksheetDimensionProperties[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#Sheet
interface Sheet {
properties: WorksheetProperties
data: GridData[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#Spreadsheet
interface Spreadsheet {
properties: SpreadsheetProperties
spreadsheetId: string
sheets: Sheet[]
}
// https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption
type ValueInputOption =
| "USER_ENTERED"
| "RAW"
| "INPUT_VALUE_OPTION_UNSPECIFIED"
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption
type InsertDataOption = "OVERWRITE" | "INSERT_ROWS"
// https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption
type ValueRenderOption = "FORMATTED_VALUE" | "UNFORMATTED_VALUE" | "FORMULA"
// https://developers.google.com/sheets/api/reference/rest/v4/DateTimeRenderOption
type DateTimeRenderOption = "SERIAL_NUMBER" | "FORMATTED_STRING"
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#query-parameters
interface AppendParams {
valueInputOption?: ValueInputOption
insertDataOption?: InsertDataOption
includeValuesInResponse?: boolean
responseValueRenderOption?: ValueRenderOption
responseDateTimeRenderOption?: DateTimeRenderOption
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#query-parameters
interface BatchGetParams {
ranges: string[]
majorDimension?: WorksheetDimension
valueRenderOption?: ValueRenderOption
dateTimeRenderOption?: DateTimeRenderOption
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet#response-body
interface BatchGetResponse {
spreadsheetId: string
valueRanges: ValueRange[]
}
interface AppendRequest {
range: string
params: AppendParams
body: ValueRange
}
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#response-body
interface AppendResponse {
spreadsheetId: string
tableRange: string
updates: UpdateValuesResponse
}
export class GoogleSheetsMock {
private config: GoogleSheetsConfig
private spreadsheet: Spreadsheet
static forDatasource(datasource: Datasource): GoogleSheetsMock {
return new GoogleSheetsMock(datasource.config as GoogleSheetsConfig)
}
private constructor(config: GoogleSheetsConfig) {
this.config = config
this.spreadsheet = {
properties: {
title: "Test Spreadsheet",
locale: "en_US",
autoRecalc: "ON_CHANGE",
timeZone: "America/New_York",
defaultFormat: {},
iterativeCalculationSettings: {},
spreadsheetTheme: {},
},
spreadsheetId: config.spreadsheetId,
sheets: [],
}
this.mockAuth()
this.mockAPI()
}
private route(
method: "get" | "put" | "post",
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
const headers = { reqheaders: { authorization: "Bearer test" } }
const scope = nock("https://sheets.googleapis.com/", headers)
return scope[method](path).reply(200, handler).persist()
}
private get(
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
return this.route("get", path, handler)
}
private put(
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
return this.route("put", path, handler)
}
private post(
path: string | RegExp,
handler: (uri: string, request: nock.Body) => nock.Body
): nock.Scope {
return this.route("post", path, handler)
}
private mockAuth() {
nock("https://www.googleapis.com/")
.post("/oauth2/v4/token")
.reply(200, {
grant_type: "client_credentials",
client_id: "your-client-id",
client_secret: "your-client-secret",
})
.persist()
nock("https://oauth2.googleapis.com/")
.post("/token", {
client_id: "test",
client_secret: "test",
grant_type: "refresh_token",
refresh_token: "refreshToken",
})
.reply(200, {
access_token: "test",
expires_in: 3600,
token_type: "Bearer",
scopes: "https://www.googleapis.com/auth/spreadsheets",
})
.persist()
}
private mockAPI() {
const spreadsheetId = this.config.spreadsheetId
this.get(`/v4/spreadsheets/${spreadsheetId}/`, () =>
this.handleGetSpreadsheet()
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchUpdate
this.post(
`/v4/spreadsheets/${spreadsheetId}/:batchUpdate`,
(_uri, request) => this.handleBatchUpdate(request as BatchUpdateRequest)
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/update
this.put(
new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`),
(_uri, request) => this.handleValueUpdate(request as ValueRange)
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/batchGet
this.get(
new RegExp(`/v4/spreadsheets/${spreadsheetId}/values:batchGet.*`),
uri => {
const url = new URL(uri, "https://sheets.googleapis.com/")
const params: BatchGetParams = {
ranges: url.searchParams.getAll("ranges"),
majorDimension:
(url.searchParams.get("majorDimension") as WorksheetDimension) ||
"ROWS",
valueRenderOption:
(url.searchParams.get("valueRenderOption") as ValueRenderOption) ||
undefined,
dateTimeRenderOption:
(url.searchParams.get(
"dateTimeRenderOption"
) as DateTimeRenderOption) || undefined,
}
return this.handleBatchGet(params as unknown as BatchGetParams)
}
)
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get
this.get(new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*`), uri => {
const range = uri.split("/").pop()
if (!range) {
throw new Error("No range provided")
}
return this.getValueRange(decodeURIComponent(range))
})
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append
this.post(
new RegExp(`/v4/spreadsheets/${spreadsheetId}/values/.*:append`),
(_uri, request) => {
const url = new URL(_uri, "https://sheets.googleapis.com/")
const params: Record<string, any> = Object.fromEntries(
url.searchParams.entries()
)
if (params.includeValuesInResponse === "true") {
params.includeValuesInResponse = true
} else {
params.includeValuesInResponse = false
}
let range = url.pathname.split("/").pop()
if (!range) {
throw new Error("No range provided")
}
if (range.endsWith(":append")) {
range = range.slice(0, -7)
}
range = decodeURIComponent(range)
return this.handleValueAppend({
range,
params,
body: request as ValueRange,
})
}
)
}
private handleValueAppend(request: AppendRequest): AppendResponse {
const { range, params, body } = request
const { sheet, bottomRight } = this.parseA1Notation(range)
const newRows = body.values.map(v => this.valuesToRowData(v))
const toDelete =
params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0
sheet.data[0].rowData.splice(bottomRight.row + 1, toDelete, ...newRows)
sheet.data[0].rowMetadata.splice(bottomRight.row + 1, toDelete, {
hiddenByUser: false,
hiddenByFilter: false,
pixelSize: 100,
developerMetadata: [],
})
// It's important to give back a correct updated range because the API
// library we use makes use of it to assign the correct row IDs to rows.
const updatedRange = this.createA1FromRanges(
sheet,
{
row: bottomRight.row + 1,
column: 0,
},
{
row: bottomRight.row + newRows.length,
column: 0,
}
)
return {
spreadsheetId: this.spreadsheet.spreadsheetId,
tableRange: range,
updates: {
spreadsheetId: this.spreadsheet.spreadsheetId,
updatedRange,
updatedRows: body.values.length,
updatedColumns: body.values[0].length,
updatedCells: body.values.length * body.values[0].length,
updatedData: body,
},
}
}
private handleBatchGet(params: BatchGetParams): BatchGetResponse {
const { ranges, majorDimension } = params
if (majorDimension && majorDimension !== "ROWS") {
throw new Error("Only row-major updates are supported")
}
return {
spreadsheetId: this.spreadsheet.spreadsheetId,
valueRanges: ranges.map(range => this.getValueRange(range)),
}
}
private handleBatchUpdate(
batchUpdateRequest: BatchUpdateRequest
): BatchUpdateResponse {
const response: BatchUpdateResponse = {
spreadsheetId: this.spreadsheet.spreadsheetId,
replies: [],
updatedSpreadsheet: this.spreadsheet,
}
for (const request of batchUpdateRequest.requests) {
if (request.addSheet) {
response.replies.push({
addSheet: this.handleAddSheet(request.addSheet),
})
}
}
return response
}
private handleAddSheet(request: AddSheetRequest): AddSheetResponse {
const properties: Omit<WorksheetProperties, "dataSourceSheetProperties"> = {
index: this.spreadsheet.sheets.length,
hidden: false,
rightToLeft: false,
tabColor: BLACK,
tabColorStyle: { rgbColor: BLACK },
sheetType: "GRID",
title: request.properties.title,
sheetId: this.spreadsheet.sheets.length,
gridProperties: {
rowCount: 100,
columnCount: 26,
frozenRowCount: 0,
frozenColumnCount: 0,
hideGridlines: false,
rowGroupControlAfter: false,
columnGroupControlAfter: false,
},
}
this.spreadsheet.sheets.push({
properties: properties as WorksheetProperties,
data: [this.createEmptyGrid(100, 26)],
})
// dataSourceSheetProperties is only returned by the API if the sheet type is
// DATA_SOURCE, which we aren't using, so sadly we need to cast here.
return { properties: properties as WorksheetProperties }
}
private handleGetSpreadsheet(): Spreadsheet {
return this.spreadsheet
}
private handleValueUpdate(valueRange: ValueRange): UpdateValuesResponse {
this.iterateCells(valueRange, (cell, value) => {
cell.userEnteredValue = this.createValue(value)
})
const response: UpdateValuesResponse = {
spreadsheetId: this.spreadsheet.spreadsheetId,
updatedRange: valueRange.range,
updatedRows: valueRange.values.length,
updatedColumns: valueRange.values[0].length,
updatedCells: valueRange.values.length * valueRange.values[0].length,
updatedData: valueRange,
}
return response
}
private iterateCells(
valueRange: ValueRange,
cb: (cell: CellData, value: Value) => void
) {
if (valueRange.majorDimension !== "ROWS") {
throw new Error("Only row-major updates are supported")
}
const { sheet, topLeft, bottomRight } = this.parseA1Notation(
valueRange.range
)
for (let row = topLeft.row; row <= bottomRight.row; row++) {
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
const value = valueRange.values[row - topLeft.row][col - topLeft.column]
cb(cell, value)
}
}
}
private getValueRange(range: string): ValueRange {
const { sheet, topLeft, bottomRight } = this.parseA1Notation(range)
const valueRange: ValueRange = {
range,
majorDimension: "ROWS",
values: [],
}
for (let row = topLeft.row; row <= bottomRight.row; row++) {
const values: Value[] = []
for (let col = topLeft.column; col <= bottomRight.column; col++) {
const cell = this.getCellNumericIndexes(sheet, row, col)
if (!cell) {
throw new Error("Cell not found")
}
values.push(this.cellValue(cell))
}
valueRange.values.push(values)
}
return this.trimValueRange(valueRange)
}
// When Google Sheets returns a value range, it will trim the data down to the
// smallest possible size. It does all of the following:
//
// 1. Converts cells in non-empty rows up to the first value to empty strings.
// 2. Removes all cells after the last non-empty cell in a row.
// 3. Removes all rows after the last non-empty row.
// 4. Rows that are before the first non-empty row that are empty are replaced with [].
//
// We replicate this behaviour here.
private trimValueRange(valueRange: ValueRange): ValueRange {
for (const row of valueRange.values) {
if (row.every(v => v == null)) {
row.splice(0, row.length)
continue
}
for (let i = row.length - 1; i >= 0; i--) {
const cell = row[i]
if (cell == null) {
row.pop()
} else {
break
}
}
for (let i = 0; i < row.length; i++) {
const cell = row[i]
if (cell == null) {
row[i] = ""
} else {
break
}
}
}
for (let i = valueRange.values.length - 1; i >= 0; i--) {
const row = valueRange.values[i]
if (row.length === 0) {
valueRange.values.pop()
} else {
break
}
}
return valueRange
}
private valuesToRowData(values: Value[]): RowData {
return {
values: values.map(v => {
return this.createCellData(v)
}),
}
}
private unwrapValue(from: ExtendedValue): Value {
if ("stringValue" in from) {
return from.stringValue
} else if ("numberValue" in from) {
return from.numberValue
} else if ("boolValue" in from) {
return from.boolValue
} else if ("formulaValue" in from) {
return from.formulaValue
} else {
return null
}
}
private cellValue(from: CellData): Value {
return this.unwrapValue(from.userEnteredValue)
}
private createValue(from: Value): ExtendedValue {
if (from == null) {
return {} as ExtendedValue
} else if (typeof from === "string") {
return {
stringValue: from,
}
} else if (typeof from === "number") {
return {
numberValue: from,
}
} else if (typeof from === "boolean") {
return {
boolValue: from,
}
} else {
throw new Error("Unsupported value type")
}
}
/**
* Because the structure of a CellData is very nested and contains a lot of
* extraneous formatting information, this function abstracts it away and just
* lets you create a cell containing a given value.
*
* When you want to read the value back out, use {@link cellValue}.
*
* @param value value to store in the returned cell
* @returns a CellData containing the given value. Read it back out with
* {@link cellValue}
*/
private createCellData(value: Value): CellData {
return {
userEnteredValue: this.createValue(value),
effectiveValue: this.createValue(value),
formattedValue: value?.toString() || "",
userEnteredFormat: DEFAULT_CELL_FORMAT,
effectiveFormat: DEFAULT_CELL_FORMAT,
}
}
private createEmptyGrid(numRows: number, numCols: number): GridData {
const rowData: RowData[] = []
for (let row = 0; row < numRows; row++) {
const cells: CellData[] = []
for (let col = 0; col < numCols; col++) {
cells.push(this.createCellData(null))
}
rowData.push({ values: cells })
}
const rowMetadata: WorksheetDimensionProperties[] = []
for (let row = 0; row < numRows; row++) {
rowMetadata.push({
hiddenByFilter: false,
hiddenByUser: false,
pixelSize: 100,
developerMetadata: [],
})
}
const columnMetadata: WorksheetDimensionProperties[] = []
for (let col = 0; col < numCols; col++) {
columnMetadata.push({
hiddenByFilter: false,
hiddenByUser: false,
pixelSize: 100,
developerMetadata: [],
})
}
return {
startRow: 0,
startColumn: 0,
rowData,
rowMetadata,
columnMetadata,
}
}
private cellData(cell: string): CellData | undefined {
const {
sheet,
topLeft: { row, column },
} = this.parseA1Notation(cell)
return this.getCellNumericIndexes(sheet, row, column)
}
cell(cell: string): Value | undefined {
const cellData = this.cellData(cell)
if (!cellData) {
return undefined
}
return this.cellValue(cellData)
}
private getCellNumericIndexes(
sheet: Sheet,
row: number,
column: number
): CellData | undefined {
const data = sheet.data[0]
const rowData = data.rowData[row]
if (!rowData) {
return undefined
}
const cell = rowData.values[column]
if (!cell) {
return undefined
}
return cell
}
// https://developers.google.com/sheets/api/guides/concepts#cell
//
// Examples from
// https://code.luasoftware.com/tutorials/google-sheets-api/google-sheets-api-range-parameter-a1-notation
//
// "Sheet1!A1" -> First cell on Row 1 Col 1
// "Sheet1!A1:C1" -> Col 1-3 (A, B, C) on Row 1 = A1, B1, C1
// "A1" -> First visible sheet (if sheet name is ommitted)
// "'My Sheet'!A1" -> If sheet name which contain space or start with a bracket.
// "Sheet1" -> All cells in Sheet1.
// "Sheet1!A:A" -> All cells on Col 1.
// "Sheet1!A:B" -> All cells on Col 1 and 2.
// "Sheet1!1:1" -> All cells on Row 1.
// "Sheet1!1:2" -> All cells on Row 1 and 2.
//
// How that translates to our code below, omitting the `sheet` property:
//
// "Sheet1!A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } }
// "Sheet1!A1:C1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 2 } }
// "A1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 0 } }
// "Sheet1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 100, column: 25 } }
// -> This is because we default to having a 100x26 grid.
// "Sheet1!A:A" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 0 } }
// "Sheet1!A:B" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 99, column: 1 } }
// "Sheet1!1:1" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 0, column: 25 } }
// "Sheet1!1:2" -> { topLeft: { row: 0, column: 0 }, bottomRight: { row: 1, column: 25 } }
private parseA1Notation(range: string): {
sheet: Sheet
topLeft: Range
bottomRight: Range
} {
let sheet: Sheet
let rest: string
if (!range.includes("!")) {
sheet = this.spreadsheet.sheets[0]
rest = range
} else {
let sheetName = range.split("!")[0]
if (sheetName.startsWith("'") && sheetName.endsWith("'")) {
sheetName = sheetName.slice(1, -1)
}
const foundSheet = this.getSheetByName(sheetName)
if (!foundSheet) {
throw new Error(`Sheet ${sheetName} not found`)
}
sheet = foundSheet
rest = range.split("!")[1]
}
const [topLeft, bottomRight] = rest.split(":")
const parsedTopLeft = topLeft ? this.parseCell(topLeft) : undefined
let parsedBottomRight = bottomRight
? this.parseCell(bottomRight)
: undefined
if (!parsedTopLeft && !parsedBottomRight) {
throw new Error("No range provided")
}
if (!parsedTopLeft) {
throw new Error("No top left cell provided")
}
if (!parsedBottomRight) {
parsedBottomRight = parsedTopLeft
}
if (parsedTopLeft && parsedTopLeft.row === undefined) {
parsedTopLeft.row = 0
}
if (parsedTopLeft && parsedTopLeft.column === undefined) {
parsedTopLeft.column = 0
}
if (parsedBottomRight && parsedBottomRight.row === undefined) {
parsedBottomRight.row = sheet.properties.gridProperties.rowCount - 1
}
if (parsedBottomRight && parsedBottomRight.column === undefined) {
parsedBottomRight.column = sheet.properties.gridProperties.columnCount - 1
}
return {
sheet,
topLeft: parsedTopLeft as Range,
bottomRight: parsedBottomRight as Range,
}
}
private createA1FromRanges(sheet: Sheet, topLeft: Range, bottomRight: Range) {
let title = sheet.properties.title
if (title.includes(" ")) {
title = `'${title}'`
}
const topLeftLetter = this.numberToLetter(topLeft.column)
const bottomRightLetter = this.numberToLetter(bottomRight.column)
const topLeftRow = topLeft.row + 1
const bottomRightRow = bottomRight.row + 1
return `${title}!${topLeftLetter}${topLeftRow}:${bottomRightLetter}${bottomRightRow}`
}
/**
* Parses a cell reference into a row and column.
* @param cell a string of the form A1, B2, etc.
* @returns
*/
private parseCell(cell: string): Partial<Range> {
const firstChar = cell.slice(0, 1)
if (this.isInteger(firstChar)) {
return { row: parseInt(cell) - 1 }
}
const column = this.letterToNumber(firstChar)
if (cell.length === 1) {
return { column }
}
const number = cell.slice(1)
return { row: parseInt(number) - 1, column }
}
private isInteger(value: string): boolean {
return !isNaN(parseInt(value))
}
private letterToNumber(letter: string): number {
return letter.charCodeAt(0) - 65
}
private numberToLetter(number: number): string {
return String.fromCharCode(number + 65)
}
private getSheetByName(name: string): Sheet | undefined {
return this.spreadsheet.sheets.find(
sheet => sheet.properties.title === name
)
}
}

View File

@ -182,11 +182,20 @@ function buildTableMap(tables: Table[]) {
return tableMap
}
function reverseUserColumnMapping(rows: Row[]) {
// table is only needed to handle relationships
function reverseUserColumnMapping(rows: Row[], table?: Table) {
const prefixLength = USER_COLUMN_PREFIX.length
return rows.map(row => {
const finalRow: Row = {}
for (let key of Object.keys(row)) {
// handle relationships
if (
table?.schema[key]?.type === FieldType.LINK &&
typeof row[key] === "string"
) {
// no table required, relationship rows don't contain relationships
row[key] = reverseUserColumnMapping(JSON.parse(row[key]))
}
// it should be the first prefix
const index = key.indexOf(USER_COLUMN_PREFIX)
if (index !== -1) {
@ -261,7 +270,7 @@ async function runSqlQuery(
if (opts?.countTotalRows) {
return processRowCountResponse(response)
} else if (Array.isArray(response)) {
return reverseUserColumnMapping(response)
return reverseUserColumnMapping(response, json.meta.table)
}
return response
}
@ -368,17 +377,6 @@ export async function search(
})
)
// make sure relationships have columns reversed correctly
for (let columnName of Object.keys(table.schema)) {
if (table.schema[columnName].type !== FieldType.LINK) {
continue
}
// process the relationships (JSON generated by SQS)
for (let row of processed) {
row[columnName] = reverseUserColumnMapping(row[columnName])
}
}
// check for pagination final row
let nextRow: boolean = false
if (paginate && params.limit && rows.length > params.limit) {

View File

@ -7,6 +7,7 @@ import {
View,
ViewFieldMetadata,
ViewV2,
ViewV2ColumnEnriched,
ViewV2Enriched,
} from "@budibase/types"
import { HTTPError } from "@budibase/backend-core"
@ -176,7 +177,7 @@ export async function enrichSchema(
}
const relTable = tableCache[tableId]
const result: Record<string, RelationSchemaField> = {}
const result: Record<string, ViewV2ColumnEnriched> = {}
for (const relTableFieldName of Object.keys(relTable.schema)) {
const relTableField = relTable.schema[relTableFieldName]
@ -188,9 +189,13 @@ export async function enrichSchema(
continue
}
const isVisible = !!viewFields[relTableFieldName]?.visible
const isReadonly = !!viewFields[relTableFieldName]?.readonly
const viewFieldSchema = viewFields[relTableFieldName]
const isVisible = !!viewFieldSchema?.visible
const isReadonly = !!viewFieldSchema?.readonly
result[relTableFieldName] = {
...relTableField,
...viewFieldSchema,
name: relTableField.name,
visible: isVisible,
readonly: isReadonly,
}
@ -211,6 +216,7 @@ export async function enrichSchema(
...tableSchema[key],
...ui,
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order,
columns: undefined,
}
if (schema[key].type === FieldType.LINK) {

View File

@ -355,10 +355,14 @@ describe("table sdk", () => {
visible: true,
columns: {
title: {
name: "title",
type: "string",
visible: true,
readonly: true,
},
age: {
name: "age",
type: "number",
visible: false,
readonly: false,
},

View File

@ -27,6 +27,12 @@ import { isPlainObject, isEmpty } from "lodash"
import { decodeNonAscii } from "./helpers/schema"
const HBS_REGEX = /{{([^{].*?)}}/g
const LOGICAL_OPERATORS = Object.values(LogicalOperator)
const SEARCH_OPERATORS = [
...Object.values(BasicOperator),
...Object.values(ArrayOperator),
...Object.values(RangeOperator),
]
/**
* Returns the valid operator options for a certain data type
@ -117,7 +123,7 @@ export function recurseLogicalOperators(
filters: SearchFilters,
fn: (f: SearchFilters) => SearchFilters
) {
for (const logical of Object.values(LogicalOperator)) {
for (const logical of LOGICAL_OPERATORS) {
if (filters[logical]) {
filters[logical]!.conditions = filters[logical]!.conditions.map(
condition => fn(condition)
@ -135,7 +141,7 @@ export function recurseSearchFilters(
filters = processFn(filters)
// Recurse through logical operators
for (const logical of Object.values(LogicalOperator)) {
for (const logical of LOGICAL_OPERATORS) {
if (filters[logical]) {
filters[logical]!.conditions = filters[logical]!.conditions.map(
condition => recurseSearchFilters(condition, processFn)
@ -448,10 +454,10 @@ export function fixupFilterArrays(filters: SearchFilters) {
return filters
}
export const search = (
docs: Record<string, any>[],
export function search<T>(
docs: Record<string, T>[],
query: RowSearchParams
): SearchResponse<Record<string, any>> => {
): SearchResponse<Record<string, T>> {
let result = runQuery(docs, query.query)
if (query.sort) {
result = sort(result, query.sort, query.sortOrder || SortOrder.ASCENDING)
@ -471,15 +477,11 @@ export const search = (
* Performs a client-side search on an array of data
* @param docs the data
* @param query the JSON query
* @param findInDoc optional fn when trying to extract a value
* from custom doc type e.g. Google Sheets
*
*/
export const runQuery = (
docs: Record<string, any>[],
query: SearchFilters,
findInDoc: Function = deepGet
) => {
export function runQuery<T extends Record<string, any>>(
docs: T[],
query: SearchFilters
): T[] {
if (!docs || !Array.isArray(docs)) {
return []
}
@ -502,11 +504,11 @@ export const runQuery = (
type: SearchFilterOperator,
test: (docValue: any, testValue: any) => boolean
) =>
(doc: Record<string, any>) => {
(doc: T) => {
for (const [key, testValue] of Object.entries(query[type] || {})) {
const valueToCheck = isLogicalSearchOperator(type)
? doc
: findInDoc(doc, removeKeyNumbering(key))
: deepGet(doc, removeKeyNumbering(key))
const result = test(valueToCheck, testValue)
if (query.allOr && result) {
return true
@ -749,11 +751,8 @@ export const runQuery = (
}
)
const docMatch = (doc: Record<string, any>) => {
const filterFunctions: Record<
SearchFilterOperator,
(doc: Record<string, any>) => boolean
> = {
const docMatch = (doc: T) => {
const filterFunctions: Record<SearchFilterOperator, (doc: T) => boolean> = {
string: stringMatch,
fuzzy: fuzzyMatch,
range: rangeMatch,
@ -780,12 +779,16 @@ export const runQuery = (
return filterFunctions[key as SearchFilterOperator]?.(doc) ?? false
})
if (query.allOr) {
// there are no filters - logical operators can cover this up
if (!hasFilters(query)) {
return true
} else if (query.allOr) {
return results.some(result => result === true)
} else {
return results.every(result => result === true)
}
}
return docs.filter(docMatch)
}
@ -797,12 +800,12 @@ export const runQuery = (
* @param sortOrder the sort order ("ascending" or "descending")
* @param sortType the type of sort ("string" or "number")
*/
export const sort = (
docs: any[],
sort: string,
export function sort<T extends Record<string, any>>(
docs: T[],
sort: keyof T,
sortOrder: SortOrder,
sortType = SortType.STRING
) => {
): T[] {
if (!sort || !sortOrder || !sortType) {
return docs
}
@ -817,19 +820,17 @@ export const sort = (
return parseFloat(x)
}
return docs
.slice()
.sort((a: { [x: string]: any }, b: { [x: string]: any }) => {
const colA = parse(a[sort])
const colB = parse(b[sort])
return docs.slice().sort((a, b) => {
const colA = parse(a[sort])
const colB = parse(b[sort])
const result = colB == null || colA > colB ? 1 : -1
if (sortOrder.toLowerCase() === "descending") {
return result * -1
}
const result = colB == null || colA > colB ? 1 : -1
if (sortOrder.toLowerCase() === "descending") {
return result * -1
}
return result
})
return result
})
}
/**
@ -838,7 +839,7 @@ export const sort = (
* @param docs the data
* @param limit the number of docs to limit to
*/
export const limit = (docs: any[], limit: string) => {
export function limit<T>(docs: T[], limit: string): T[] {
const numLimit = parseFloat(limit)
if (isNaN(numLimit)) {
return docs
@ -850,14 +851,33 @@ export const hasFilters = (query?: SearchFilters) => {
if (!query) {
return false
}
const skipped = ["allOr", "onEmptyFilter"]
for (let [key, value] of Object.entries(query)) {
if (skipped.includes(key) || typeof value !== "object") {
continue
const check = (filters: SearchFilters): boolean => {
for (const logical of LOGICAL_OPERATORS) {
if (filters[logical]) {
for (const condition of filters[logical]?.conditions || []) {
const result = check(condition)
if (result) {
return result
}
}
}
}
if (Object.keys(value || {}).length !== 0) {
return true
for (const search of SEARCH_OPERATORS) {
const searchValue = filters[search]
if (!searchValue || typeof searchValue !== "object") {
continue
}
const filtered = Object.entries(searchValue).filter(entry => {
const valueDefined =
entry[1] !== undefined || entry[1] !== null || entry[1] !== ""
// not empty is an edge case, null is allowed for it - this is covered by test cases
return search === BasicOperator.NOT_EMPTY || valueDefined
})
if (filtered.length !== 0) {
return true
}
}
return false
}
return false
return check(query)
}

View File

@ -10,6 +10,11 @@ export enum AutoReason {
FOREIGN_KEY = "foreign_key",
}
export type FieldSubType =
| AutoFieldSubType
| JsonFieldSubType
| BBReferenceFieldSubType
export enum AutoFieldSubType {
CREATED_BY = "createdBy",
CREATED_AT = "createdAt",

View File

@ -38,8 +38,7 @@ export type ViewFieldMetadata = UIFieldMetadata & {
columns?: Record<string, RelationSchemaField>
}
export type RelationSchemaField = {
visible?: boolean
export type RelationSchemaField = UIFieldMetadata & {
readonly?: boolean
}

View File

@ -111,6 +111,22 @@ export interface SCIMInnerConfig {
export interface SCIMConfig extends Config<SCIMInnerConfig> {}
type AIProvider = "OpenAI" | "Anthropic" | "AzureOpenAI" | "Custom"
export interface AIInnerConfig {
[key: string]: {
provider: AIProvider
isDefault: boolean
name: string
active: boolean
baseUrl?: string
apiKey?: string
defaultModel?: string
}
}
export interface AIConfig extends Config<AIInnerConfig> {}
export const isSettingsConfig = (config: Config): config is SettingsConfig =>
config.type === ConfigType.SETTINGS
@ -126,6 +142,9 @@ export const isOIDCConfig = (config: Config): config is OIDCConfig =>
export const isSCIMConfig = (config: Config): config is SCIMConfig =>
config.type === ConfigType.SCIM
export const isAIConfig = (config: Config): config is AIConfig =>
config.type === ConfigType.AI
export enum ConfigType {
SETTINGS = "settings",
ACCOUNT = "account",
@ -134,4 +153,5 @@ export enum ConfigType {
OIDC = "oidc",
OIDC_LOGOS = "logos_oidc",
SCIM = "scim",
AI = "ai",
}

View File

@ -35,6 +35,7 @@ export interface StaticUsage {
[StaticQuotaName.CREATORS]: number
[StaticQuotaName.USER_GROUPS]: number
[StaticQuotaName.ROWS]: number
[StaticQuotaName.AI_CUSTOM_CONFIGS]: number
triggers: {
[key in StaticQuotaName]?: QuotaTriggers
}
@ -44,6 +45,7 @@ export interface MonthlyUsage {
[MonthlyQuotaName.QUERIES]: number
[MonthlyQuotaName.AUTOMATIONS]: number
[MonthlyQuotaName.DAY_PASSES]: number
[MonthlyQuotaName.BUDIBASE_AI_CREDITS]: number
triggers: {
[key in MonthlyQuotaName]?: QuotaTriggers
}

View File

@ -0,0 +1,5 @@
import { BaseEvent } from "./event"
export interface AIConfigCreatedEvent extends BaseEvent {}
export interface AIConfigUpdatedEvent extends BaseEvent {}

View File

@ -33,6 +33,10 @@ export enum Event {
EMAIL_SMTP_CREATED = "email:smtp:created",
EMAIL_SMTP_UPDATED = "email:smtp:updated",
// AI
AI_CONFIG_CREATED = "ai:config:created",
AI_CONFIG_UPDATED = "ai:config:updated",
// AUTH
AUTH_SSO_CREATED = "auth:sso:created",
AUTH_SSO_UPDATED = "auth:sso:updated",
@ -243,6 +247,10 @@ export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
[Event.EMAIL_SMTP_CREATED]: `Email configuration created`,
[Event.EMAIL_SMTP_UPDATED]: `Email configuration updated`,
// AI
[Event.AI_CONFIG_CREATED]: `AI configuration created`,
[Event.AI_CONFIG_UPDATED]: `AI configuration updated`,
// AUTH
[Event.AUTH_SSO_CREATED]: `SSO configuration created`,
[Event.AUTH_SSO_UPDATED]: `SSO configuration updated`,

View File

@ -2,6 +2,7 @@ export * from "./app"
export * from "./auth"
export * from "./automation"
export * from "./email"
export * from "./ai"
export * from "./datasource"
export * from "./event"
export * from "./layout"

View File

@ -1,6 +1,7 @@
export enum FeatureFlag {
PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE",
PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT",
AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS",
ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS",
}

View File

@ -15,6 +15,8 @@ export enum Feature {
EXPANDED_PUBLIC_API = "expandedPublicApi",
VIEW_PERMISSIONS = "viewPermissions",
VIEW_READONLY_COLUMNS = "viewReadonlyColumns",
BUDIBASE_AI = "budibaseAI",
AI_CUSTOM_CONFIGS = "aiCustomConfigs",
}
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }

View File

@ -17,12 +17,14 @@ export enum StaticQuotaName {
CREATORS = "creators",
USER_GROUPS = "userGroups",
PLUGINS = "plugins",
AI_CUSTOM_CONFIGS = "aiCustomConfigs",
}
export enum MonthlyQuotaName {
QUERIES = "queries",
AUTOMATIONS = "automations",
DAY_PASSES = "dayPasses",
BUDIBASE_AI_CREDITS = "budibaseAICredits",
}
export enum ConstantQuotaName {
@ -62,6 +64,7 @@ export type MonthlyQuotas = {
[MonthlyQuotaName.QUERIES]: Quota
[MonthlyQuotaName.AUTOMATIONS]: Quota
[MonthlyQuotaName.DAY_PASSES]: Quota
[MonthlyQuotaName.BUDIBASE_AI_CREDITS]: Quota
}
export type StaticQuotas = {
@ -71,6 +74,7 @@ export type StaticQuotas = {
[StaticQuotaName.CREATORS]: Quota
[StaticQuotaName.USER_GROUPS]: Quota
[StaticQuotaName.PLUGINS]: Quota
[StaticQuotaName.AI_CUSTOM_CONFIGS]: Quota
}
export type ConstantQuotas = {

View File

@ -3,7 +3,9 @@ import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents"
export interface ViewV2Enriched extends ViewV2 {
schema?: {
[key: string]: FieldSchema & {
columns?: Record<string, RelationSchemaField>
columns?: Record<string, ViewV2ColumnEnriched>
}
}
}
export type ViewV2ColumnEnriched = RelationSchemaField & FieldSchema

View File

@ -29,6 +29,10 @@ import {
SSOConfigType,
UserCtx,
OIDCLogosConfig,
AIConfig,
PASSWORD_REPLACEMENT,
isAIConfig,
AIInnerConfig,
} from "@budibase/types"
import * as pro from "@budibase/pro"
@ -38,6 +42,11 @@ const getEventFns = async (config: Config, existing?: Config) => {
if (!existing) {
if (isSMTPConfig(config)) {
fns.push(events.email.SMTPCreated)
} else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigCreated)
fns.push(() =>
pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length)
)
} else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE))
if (config.config.activated) {
@ -74,6 +83,11 @@ const getEventFns = async (config: Config, existing?: Config) => {
} else {
if (isSMTPConfig(config)) {
fns.push(events.email.SMTPUpdated)
} else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigUpdated)
fns.push(() =>
pro.quotas.updateCustomAIConfigCount(Object.keys(config.config).length)
)
} else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE))
if (!existing.config.activated && config.config.activated) {
@ -122,7 +136,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
}
}
}
return fns
}
@ -197,6 +210,18 @@ async function verifyOIDCConfig(config: OIDCConfigs) {
await verifySSOConfig(ConfigType.OIDC, config.configs[0])
}
export async function verifyAIConfig(
configToSave: AIInnerConfig,
existingConfig: AIConfig
) {
// ensure that the redacted API keys are not overwritten in the DB
for (const uuid in existingConfig.config) {
if (configToSave[uuid]?.apiKey === PASSWORD_REPLACEMENT) {
configToSave[uuid].apiKey = existingConfig.config[uuid].apiKey
}
}
}
export async function save(ctx: UserCtx<Config>) {
const body = ctx.request.body
const type = body.type
@ -224,6 +249,11 @@ export async function save(ctx: UserCtx<Config>) {
case ConfigType.OIDC:
await verifyOIDCConfig(config)
break
case ConfigType.AI:
if (existingConfig) {
await verifyAIConfig(config, existingConfig)
}
break
}
} catch (err: any) {
ctx.throw(400, err)
@ -304,6 +334,32 @@ function enrichOIDCLogos(oidcLogos: OIDCLogosConfig) {
)
}
async function enrichAIConfig(aiConfig: AIConfig) {
// Strip out the API Keys from the response so they don't show in the UI
for (const key in aiConfig.config) {
if (aiConfig.config[key].apiKey) {
aiConfig.config[key].apiKey = PASSWORD_REPLACEMENT
}
}
// Return the Budibase AI data source as part of the response if licensing allows
const budibaseAIEnabled = await pro.features.isBudibaseAIEnabled()
const defaultConfigExists = Object.keys(aiConfig.config).some(
key => aiConfig.config[key].isDefault
)
if (budibaseAIEnabled) {
aiConfig.config["budibase_ai"] = {
provider: "OpenAI",
active: true,
isDefault: !defaultConfigExists,
defaultModel: env.BUDIBASE_AI_DEFAULT_MODEL || "",
name: "Budibase AI",
}
}
return aiConfig
}
export async function find(ctx: UserCtx) {
try {
// Find the config with the most granular scope based on context
@ -314,6 +370,10 @@ export async function find(ctx: UserCtx) {
if (type === ConfigType.OIDC_LOGOS) {
enrichOIDCLogos(scopedConfig)
}
if (type === ConfigType.AI) {
await enrichAIConfig(scopedConfig)
}
ctx.body = scopedConfig
} else {
// don't throw an error, there simply is nothing to return

View File

@ -0,0 +1,106 @@
import * as pro from "@budibase/pro"
import { verifyAIConfig } from "../configs"
import { TestConfiguration, structures } from "../../../../tests"
import { AIInnerConfig } from "@budibase/types"
describe("Global configs controller", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
afterEach(() => {
jest.resetAllMocks()
})
it("Should strip secrets when pulling AI config", async () => {
const data = structures.configs.ai()
await config.api.configs.saveConfig(data)
const response = await config.api.configs.getAIConfig()
expect(response.body.config).toEqual({
ai: {
active: true,
apiKey: "--secret-value--",
baseUrl: "https://api.example.com",
defaultModel: "gpt4",
isDefault: false,
name: "Test",
provider: "OpenAI",
},
})
})
it("Should return the default BB AI config when the feature is turned on", async () => {
jest
.spyOn(pro.features, "isBudibaseAIEnabled")
.mockImplementation(() => Promise.resolve(true))
const data = structures.configs.ai()
await config.api.configs.saveConfig(data)
const response = await config.api.configs.getAIConfig()
expect(response.body.config).toEqual({
budibase_ai: {
provider: "OpenAI",
active: true,
isDefault: true,
name: "Budibase AI",
defaultModel: "",
},
ai: {
active: true,
apiKey: "--secret-value--",
baseUrl: "https://api.example.com",
defaultModel: "gpt4",
isDefault: false,
name: "Test",
provider: "OpenAI",
},
})
})
it("Should not not return the default Budibase AI config when on self host", async () => {
jest
.spyOn(pro.features, "isBudibaseAIEnabled")
.mockImplementation(() => Promise.resolve(false))
const data = structures.configs.ai()
await config.api.configs.saveConfig(data)
const response = await config.api.configs.getAIConfig()
expect(response.body.config).toEqual({
ai: {
active: true,
apiKey: "--secret-value--",
baseUrl: "https://api.example.com",
defaultModel: "gpt4",
isDefault: false,
name: "Test",
provider: "OpenAI",
},
})
})
it("Should not update existing secrets when updating an existing AI Config", async () => {
const data = structures.configs.ai()
await config.api.configs.saveConfig(data)
const newConfig: AIInnerConfig = {
ai: {
provider: "OpenAI",
isDefault: true,
apiKey: "--secret-value--",
name: "MyConfig",
active: true,
defaultModel: "gpt4",
},
}
await verifyAIConfig(newConfig, data)
// should be unchanged
expect(newConfig.ai.apiKey).toEqual("myapikey")
})
})

View File

@ -65,6 +65,22 @@ function scimValidation() {
}).unknown(true)
}
function aiValidation() {
// prettier-ignore
return Joi.object().pattern(
Joi.string(),
Joi.object({
provider: Joi.string().required(),
isDefault: Joi.boolean().required(),
name: Joi.string().required(),
active: Joi.boolean().required(),
baseUrl: Joi.string().optional().allow("", null),
apiKey: Joi.string().required(),
defaultModel: Joi.string().optional(),
}).required()
)
}
function buildConfigSaveValidation() {
// prettier-ignore
return auth.joiValidator.body(Joi.object({
@ -82,7 +98,8 @@ function buildConfigSaveValidation() {
{ is: ConfigType.ACCOUNT, then: Joi.object().unknown(true) },
{ is: ConfigType.GOOGLE, then: googleValidation() },
{ is: ConfigType.OIDC, then: oidcValidation() },
{ is: ConfigType.SCIM, then: scimValidation() }
{ is: ConfigType.SCIM, then: scimValidation() },
{ is: ConfigType.AI, then: aiValidation() }
],
}),
}).required().unknown(true),

View File

@ -70,6 +70,9 @@ const environment = {
PASSPORT_OIDCAUTH_FAILURE_REDIRECT:
process.env.PASSPORT_OIDCAUTH_FAILURE_REDIRECT || "/error",
// Budibase AI
BUDIBASE_AI_API_KEY: process.env.BUDIBASE_AI_API_KEY,
BUDIBASE_AI_DEFAULT_MODEL: process.env.BUDIBASE_AI_DEFAULT_MODEL,
_set(key: any, value: any) {
process.env[key] = value
// @ts-ignore

View File

@ -22,6 +22,14 @@ export class ConfigAPI extends TestAPI {
.expect("Content-Type", /json/)
}
getAIConfig = () => {
return this.request
.get(`/api/global/configs/ai`)
.set(this.config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
}
saveConfig = (data: any) => {
return this.request
.post(`/api/global/configs`)

View File

@ -5,6 +5,7 @@ import {
SMTPConfig,
GoogleConfig,
OIDCConfig,
AIConfig,
} from "@budibase/types"
export function oidc(conf?: any): OIDCConfig {
@ -81,3 +82,20 @@ export function settings(conf?: any): SettingsConfig {
},
}
}
export function ai(): AIConfig {
return {
type: ConfigType.AI,
config: {
ai: {
provider: "OpenAI",
isDefault: false,
name: "Test",
active: true,
defaultModel: "gpt4",
apiKey: "myapikey",
baseUrl: "https://api.example.com",
},
},
}
}

272
yarn.lock
View File

@ -8,9 +8,9 @@
integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
"@adobe/css-tools@^4.3.2":
version "4.3.3"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff"
integrity sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==
version "4.4.0"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.0.tgz#728c484f4e10df03d5a3acd0d8adcbbebff8ad63"
integrity sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==
"@adobe/spectrum-css-workflow-icons@1.2.1":
version "1.2.1"
@ -1995,14 +1995,14 @@
resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
"@babel/runtime@^7.10.5", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.10.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.8.4":
version "7.24.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12"
integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.13.10":
"@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.9.2":
version "7.25.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.6.tgz#9afc3289f7184d8d7f98b099884c26317b9264d2"
integrity sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==
@ -2053,7 +2053,7 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.29.24":
"@budibase/backend-core@2.31.8":
version "0.0.0"
dependencies:
"@budibase/nano" "10.1.5"
@ -2134,16 +2134,17 @@
through2 "^2.0.0"
"@budibase/pro@npm:@budibase/pro@latest":
version "2.29.24"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.29.24.tgz#2dbd4c6c0f757aab7e17c413c6d6e4520086f9ac"
integrity sha512-m1v24UD6O21Vbrfsuo5kC5oeg7FzjWO2w8TQMw1VvPKmdIqqclaKDPTPytxwllTMkapMDRNzM5cQzqnQ3yHf6A==
version "2.31.8"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.31.8.tgz#92b27f99f815f5d20bf58bfae916760b14a036da"
integrity sha512-nmNKVoMdUVqEIq6xqoBq0gVBCLkoPMszmn0Zu0SJ/Dc2SpsXhPz9S3n9xXfAA+FHUg9LgUAS+eKPCKPWZXtDHQ==
dependencies:
"@budibase/backend-core" "2.29.24"
"@budibase/shared-core" "2.29.24"
"@budibase/string-templates" "2.29.24"
"@budibase/types" "2.29.24"
"@budibase/backend-core" "2.31.8"
"@budibase/shared-core" "2.31.8"
"@budibase/string-templates" "2.31.8"
"@budibase/types" "2.31.8"
"@koa/router" "8.0.8"
bull "4.10.1"
dd-trace "5.2.0"
joi "17.6.0"
jsonwebtoken "9.0.2"
lru-cache "^7.14.1"
@ -2152,13 +2153,13 @@
scim-patch "^0.8.1"
scim2-parse-filter "^0.2.8"
"@budibase/shared-core@2.29.24":
"@budibase/shared-core@2.31.8":
version "0.0.0"
dependencies:
"@budibase/types" "0.0.0"
cron-validate "1.4.5"
"@budibase/string-templates@2.29.24":
"@budibase/string-templates@2.31.8":
version "0.0.0"
dependencies:
"@budibase/handlebars-helpers" "^0.13.2"
@ -2166,7 +2167,7 @@
handlebars "^4.7.8"
lodash.clonedeep "^4.5.0"
"@budibase/types@2.29.24":
"@budibase/types@2.31.8":
version "0.0.0"
dependencies:
scim-patch "^0.8.1"
@ -5338,9 +5339,9 @@
redent "^3.0.0"
"@testing-library/svelte@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@testing-library/svelte/-/svelte-4.1.0.tgz#de6fa34d13d99505e68134ef2acfbafdc03ed39a"
integrity sha512-MJqe7x9WowkiAVdk9mvazEC2ktFZdmK2OqFVoO557PC37aBemQ4ozqdK3yrG34Zg9kuln3qgTVeLSh08e69AMw==
version "4.2.3"
resolved "https://registry.yarnpkg.com/@testing-library/svelte/-/svelte-4.2.3.tgz#bd0fd3dde5c63012da95d58ee640c0b44fd27a04"
integrity sha512-8vM2+JSPc6wZWkO9ICPmHvzacjy8jBw+iVjmNs+0VsPV3AO3v4P8qCLWTaQ9nYW/e+IR1BCy3MM3Uqg21dlBkw==
dependencies:
"@testing-library/dom" "^9.3.1"
@ -5412,9 +5413,9 @@
"@types/readdir-glob" "*"
"@types/aria-query@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc"
integrity sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==
version "5.0.4"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708"
integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==
"@types/babel__core@^7.1.14":
version "7.20.0"
@ -5485,9 +5486,9 @@
"@types/chai" "*"
"@types/chai@*", "@types/chai@^4.3.4":
version "4.3.16"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.16.tgz#b1572967f0b8b60bf3f87fe1d854a5604ea70c82"
integrity sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==
version "4.3.19"
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.19.tgz#14519f437361d41e84102ed3fbc922ddace3e228"
integrity sha512-2hHHvQBVE2FiSK4eN0Br6snX9MtolHaTo/batnLjlGRhoQzlCL61iVpxoqO7SfFyOw+P/pwv+0zNHzKoGWz9Cw==
"@types/chance@1.1.3":
version "1.1.3"
@ -6922,16 +6923,16 @@ acorn@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.11.3, acorn@^8.2.4, acorn@^8.4.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0:
version "8.12.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
acorn@^8.12.0, acorn@^8.12.1:
acorn@^8.1.0, acorn@^8.11.3, acorn@^8.12.0, acorn@^8.12.1, acorn@^8.8.1:
version "8.12.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248"
integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==
acorn@^8.10.0, acorn@^8.11.0, acorn@^8.2.4, acorn@^8.4.1, acorn@^8.8.2, acorn@^8.9.0:
version "8.12.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c"
integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==
add-stream@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa"
@ -7259,7 +7260,7 @@ aria-query@^5.0.0, aria-query@^5.3.0:
dependencies:
dequal "^2.0.3"
array-buffer-byte-length@^1.0.1:
array-buffer-byte-length@^1.0.0, array-buffer-byte-length@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f"
integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==
@ -8306,9 +8307,9 @@ caseless@~0.12.0:
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
chai@^4.3.7:
version "4.4.1"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1"
integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==
version "4.5.0"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8"
integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==
dependencies:
assertion-error "^1.1.0"
check-error "^1.0.3"
@ -8316,7 +8317,7 @@ chai@^4.3.7:
get-func-name "^2.0.2"
loupe "^2.3.6"
pathval "^1.1.1"
type-detect "^4.0.8"
type-detect "^4.1.0"
chai@^5.1.1:
version "5.1.1"
@ -9625,9 +9626,9 @@ dedent@^1.0.0:
integrity sha512-7glNLfvdsMzZm3FpRY1CHuI2lbYDR+71YmrhmTZjYFD5pfT0ACgnGRdrrC9Mk2uICnzkcdelCx5at787UDGOvg==
deep-eql@^4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d"
integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==
version "4.1.4"
resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7"
integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==
dependencies:
type-detect "^4.0.0"
@ -9637,15 +9638,16 @@ deep-eql@^5.0.1:
integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==
deep-equal@^2.0.5:
version "2.2.0"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6"
integrity sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==
version "2.2.3"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1"
integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==
dependencies:
call-bind "^1.0.2"
es-get-iterator "^1.1.2"
get-intrinsic "^1.1.3"
array-buffer-byte-length "^1.0.0"
call-bind "^1.0.5"
es-get-iterator "^1.1.3"
get-intrinsic "^1.2.2"
is-arguments "^1.1.1"
is-array-buffer "^3.0.1"
is-array-buffer "^3.0.2"
is-date-object "^1.0.5"
is-regex "^1.1.4"
is-shared-array-buffer "^1.0.2"
@ -9653,11 +9655,11 @@ deep-equal@^2.0.5:
object-is "^1.1.5"
object-keys "^1.1.1"
object.assign "^4.1.4"
regexp.prototype.flags "^1.4.3"
regexp.prototype.flags "^1.5.1"
side-channel "^1.0.4"
which-boxed-primitive "^1.0.2"
which-collection "^1.0.1"
which-typed-array "^1.1.9"
which-typed-array "^1.1.13"
deep-equal@~1.0.1:
version "1.0.1"
@ -10619,7 +10621,7 @@ es-errors@^1.2.1, es-errors@^1.3.0:
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-get-iterator@^1.1.2:
es-get-iterator@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==
@ -11936,7 +11938,7 @@ get-func-name@^2.0.1, get-func-name@^2.0.2:
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41"
integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==
get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4:
get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
@ -13211,7 +13213,7 @@ is-arguments@^1.1.1:
call-bind "^1.0.2"
has-tostringtag "^1.0.0"
is-array-buffer@^3.0.1, is-array-buffer@^3.0.4:
is-array-buffer@^3.0.2, is-array-buffer@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.4.tgz#7a1f92b3d61edd2bc65d24f130530ea93d7fae98"
integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==
@ -13402,10 +13404,10 @@ is-lambda@^1.0.1:
resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5"
integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==
is-map@^2.0.1, is-map@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127"
integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==
is-map@^2.0.2, is-map@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e"
integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==
is-module@^1.0.0:
version "1.0.0"
@ -13552,10 +13554,10 @@ is-self-closing@^1.0.1:
dependencies:
self-closing-tags "^1.0.1"
is-set@^2.0.1, is-set@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec"
integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==
is-set@^2.0.2, is-set@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d"
integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==
is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3:
version "1.0.3"
@ -13653,10 +13655,10 @@ is-utf8@^0.2.0:
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
integrity sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==
is-weakmap@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.1.tgz#5008b59bdc43b698201d18f62b37b2ca243e8cf2"
integrity sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==
is-weakmap@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd"
integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==
is-weakref@^1.0.2:
version "1.0.2"
@ -13665,13 +13667,13 @@ is-weakref@^1.0.2:
dependencies:
call-bind "^1.0.2"
is-weakset@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.2.tgz#4569d67a747a1ce5a994dfd4ef6dcea76e7c0a1d"
integrity sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==
is-weakset@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.3.tgz#e801519df8c0c43e12ff2834eead84ec9e624007"
integrity sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==
dependencies:
call-bind "^1.0.2"
get-intrinsic "^1.1.1"
call-bind "^1.0.7"
get-intrinsic "^1.2.4"
is-whitespace@^0.3.0:
version "0.3.0"
@ -14395,9 +14397,9 @@ jsdom@^16.0.1:
xml-name-validator "^3.0.0"
jsdom@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.1.tgz#ab796361e3f6c01bcfaeda1fea3c06197ac9d8ae"
integrity sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w==
version "21.1.2"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.2.tgz#6433f751b8718248d646af1cdf6662dc8a1ca7f9"
integrity sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==
dependencies:
abab "^2.0.6"
acorn "^8.8.2"
@ -14412,7 +14414,7 @@ jsdom@^21.1.1:
http-proxy-agent "^5.0.0"
https-proxy-agent "^5.0.1"
is-potential-custom-element-name "^1.0.1"
nwsapi "^2.2.2"
nwsapi "^2.2.4"
parse5 "^7.1.2"
rrweb-cssom "^0.6.0"
saxes "^6.0.0"
@ -16191,7 +16193,7 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mlly@^1.1.0, mlly@^1.7.0:
mlly@^1.1.0, mlly@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.1.tgz#e0336429bb0731b6a8e887b438cbdae522c8f32f"
integrity sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==
@ -16849,11 +16851,16 @@ nunjucks@^3.2.3:
asap "^2.0.3"
commander "^5.1.0"
nwsapi@^2.2.0, nwsapi@^2.2.2:
nwsapi@^2.2.0:
version "2.2.4"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.4.tgz#fd59d5e904e8e1f03c25a7d5a15cfa16c714a1e5"
integrity sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==
nwsapi@^2.2.4:
version "2.2.12"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.12.tgz#fb6af5c0ec35b27b4581eb3bbad34ec9e5c696f8"
integrity sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==
nx-cloud@16.0.5:
version "16.0.5"
resolved "https://registry.yarnpkg.com/nx-cloud/-/nx-cloud-16.0.5.tgz#fa0b0185d254405ec47fcbcdbbd8b12ff1add096"
@ -16954,12 +16961,12 @@ object-inspect@^1.13.1:
integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
object-is@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac"
integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==
version "1.1.6"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
call-bind "^1.0.7"
define-properties "^1.2.1"
object-keys@^1.1.1:
version "1.1.1"
@ -18024,12 +18031,12 @@ pkg-dir@^4.2.0:
find-up "^4.0.0"
pkg-types@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.1.tgz#07b626880749beb607b0c817af63aac1845a73f2"
integrity sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ==
version "1.2.0"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.2.0.tgz#d0268e894e93acff11a6279de147e83354ebd42d"
integrity sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==
dependencies:
confbox "^0.1.7"
mlly "^1.7.0"
mlly "^1.7.1"
pathe "^1.1.2"
pkginfo@0.4.x:
@ -19396,7 +19403,7 @@ regexp-tree@~0.1.1:
resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.27.tgz#2198f0ef54518ffa743fe74d983b56ffd631b6cd"
integrity sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==
regexp.prototype.flags@^1.4.3, regexp.prototype.flags@^1.5.2:
regexp.prototype.flags@^1.5.1, regexp.prototype.flags@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334"
integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==
@ -19520,6 +19527,11 @@ requires-port@^1.0.0:
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-alpn@^1.0.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"
@ -20743,16 +20755,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==
@ -20843,7 +20846,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==
@ -20857,13 +20860,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"
@ -21523,12 +21519,7 @@ tiny-queue@^0.2.0:
resolved "https://registry.yarnpkg.com/tiny-queue/-/tiny-queue-0.2.1.tgz#25a67f2c6e253b2ca941977b5ef7442ef97a6046"
integrity sha512-EijGsv7kzd9I9g0ByCl6h42BWNGUZrlCSejfrb3AKeHC33SGbASu1VDf5O3rRiiUOhAC9CHdZxFPbZu0HmR70A==
tinybench@^2.3.1:
version "2.8.0"
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.8.0.tgz#30e19ae3a27508ee18273ffed9ac7018949acd7b"
integrity sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==
tinybench@^2.8.0:
tinybench@^2.3.1, tinybench@^2.8.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
@ -21873,11 +21864,16 @@ type-check@~0.3.2:
dependencies:
prelude-ls "~1.1.2"
type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.8:
type-detect@4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
type-detect@^4.0.0, type-detect@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c"
integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==
type-fest@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
@ -22042,9 +22038,9 @@ typo-js@*:
integrity sha512-C7pYBQK17EjSg8tVNY91KHdUt5Nf6FMJ+c3js076quPmBML57PmNMzAcIq/2kf/hSYtFABNDIYNYlJRl5BJhGw==
ufo@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.3.tgz#3325bd3c977b6c6cd3160bf4ff52989adc9d3344"
integrity sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==
version "1.5.4"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754"
integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==
uglify-js@^3.1.4:
version "3.17.4"
@ -22703,21 +22699,21 @@ which-boxed-primitive@^1.0.2:
is-symbol "^1.0.3"
which-collection@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.1.tgz#70eab71ebbbd2aefaf32f917082fc62cdcb70906"
integrity sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==
version "1.0.2"
resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0"
integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==
dependencies:
is-map "^2.0.1"
is-set "^2.0.1"
is-weakmap "^2.0.1"
is-weakset "^2.0.1"
is-map "^2.0.3"
is-set "^2.0.3"
is-weakmap "^2.0.2"
is-weakset "^2.0.3"
which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
which-typed-array@^1.1.14, which-typed-array@^1.1.15, which-typed-array@^1.1.9:
which-typed-array@^1.1.13, which-typed-array@^1.1.14, which-typed-array@^1.1.15:
version "1.1.15"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d"
integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==
@ -22749,15 +22745,7 @@ which@^3.0.0:
dependencies:
isexe "^2.0.0"
why-is-node-running@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e"
integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==
dependencies:
siginfo "^2.0.0"
stackback "0.0.2"
why-is-node-running@^2.3.0:
why-is-node-running@^2.2.2, why-is-node-running@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04"
integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==
@ -22832,7 +22820,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==
@ -22850,15 +22838,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"
@ -22941,7 +22920,12 @@ ws@^7.4.6:
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
ws@^8.13.0, ws@~8.17.1:
ws@^8.13.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc"
integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==
ws@~8.17.1:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
@ -23172,9 +23156,9 @@ yocto-queue@^0.1.0:
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
yocto-queue@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
version "1.1.1"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110"
integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==
yup@0.32.9:
version "0.32.9"