Merge remote-tracking branch 'origin/v3-ui' into feature/automation-branching-ux

This commit is contained in:
Dean 2024-09-27 09:10:48 +01:00
commit 8de5a0d7b6
10 changed files with 545 additions and 53 deletions

View File

@ -4,6 +4,7 @@
import OpenAILogo from "./logos/OpenAI.svelte" import OpenAILogo from "./logos/OpenAI.svelte"
import AnthropicLogo from "./logos/Anthropic.svelte" import AnthropicLogo from "./logos/Anthropic.svelte"
import TogetherAILogo from "./logos/TogetherAI.svelte" import TogetherAILogo from "./logos/TogetherAI.svelte"
import AzureOpenAILogo from "./logos/AzureOpenAI.svelte"
import { Providers } from "./constants" import { Providers } from "./constants"
const logos = { const logos = {
@ -11,6 +12,7 @@
[Providers.OpenAI.name]: OpenAILogo, [Providers.OpenAI.name]: OpenAILogo,
[Providers.Anthropic.name]: AnthropicLogo, [Providers.Anthropic.name]: AnthropicLogo,
[Providers.TogetherAI.name]: TogetherAILogo, [Providers.TogetherAI.name]: TogetherAILogo,
[Providers.AzureOpenAI.name]: AzureOpenAILogo,
} }
export let config export let config
@ -26,8 +28,8 @@
<div class="icon"> <div class="icon">
<svelte:component <svelte:component
this={logos[config.name || config.provider]} this={logos[config.name || config.provider]}
height="30" height="18"
width="30" width="18"
/> />
</div> </div>
<div class="header"> <div class="header">
@ -110,7 +112,7 @@
.tag { .tag {
display: flex; display: flex;
color: var(--spectrum-body-m-text-color); color: #ffffff;
padding: 4px 8px; padding: 4px 8px;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@ -1,6 +1,6 @@
import { it, expect, describe, vi } from "vitest" import { it, expect, describe, vi } from "vitest"
import AISettings from "./index.svelte" import AISettings from "./index.svelte"
import { render } from "@testing-library/svelte" import { render, fireEvent } from "@testing-library/svelte"
import { admin, licensing } from "stores/portal" import { admin, licensing } from "stores/portal"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -55,39 +55,43 @@ describe("AISettings", () => {
expect(enterpriseTag).toBeInTheDocument() expect(enterpriseTag).toBeInTheDocument()
}) })
it("should show the premium label on cloud when Budibase AI isn't enabled", async () => { it("the add configuration button should not do anything the user doesn't have the correct license on cloud", 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 let addConfigurationButton
let configModal
setupEnv(Hosting.Cloud) setupEnv(Hosting.Cloud)
instance = render(AISettings) instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).not.toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).not.toBeInTheDocument()
})
it("the add configuration button should open the config modal if the user has the correct license on cloud", async () => {
let addConfigurationButton
let configModal
setupEnv(Hosting.Cloud, { customAIConfigsEnabled: true }) setupEnv(Hosting.Cloud, { customAIConfigsEnabled: true })
instance = render(AISettings) instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).toBeInTheDocument()
}) })
it("should not show the add configuration button if the user doesn't have the correct license on self host", async () => { it("the add configuration button should open the config modal if the user has the correct license on self host", async () => {
let addConfigurationButton let addConfigurationButton
let configModal
setupEnv(Hosting.Self)
instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).not.toBeInTheDocument()
setupEnv(Hosting.Self, { customAIConfigsEnabled: true }) setupEnv(Hosting.Self, { customAIConfigsEnabled: true })
instance = render(AISettings, {}) instance = render(AISettings)
addConfigurationButton = instance.queryByText("Add configuration") addConfigurationButton = instance.queryByText("Add configuration")
expect(addConfigurationButton).toBeInTheDocument() expect(addConfigurationButton).toBeInTheDocument()
await fireEvent.click(addConfigurationButton)
configModal = instance.queryByText("Custom AI Configuration")
expect(configModal).toBeInTheDocument()
}) })
}) })
}) })

View File

@ -84,8 +84,10 @@
<Label size="M">API Key</Label> <Label size="M">API Key</Label>
<Input type="password" bind:value={config.apiKey} /> <Input type="password" bind:value={config.apiKey} />
</div> </div>
<div class="form-row">
<Toggle text="Active" bind:value={config.active} /> <Toggle text="Active" bind:value={config.active} />
<Toggle text="Set as default" bind:value={config.isDefault} /> <Toggle text="Set as default" bind:value={config.isDefault} />
</div>
</ModalContent> </ModalContent>
<style> <style>

View File

@ -23,7 +23,7 @@ export const Providers = {
models: [{ label: "Llama 3 8B", value: "meta-llama/Meta-Llama-3-8B" }], models: [{ label: "Llama 3 8B", value: "meta-llama/Meta-Llama-3-8B" }],
}, },
AzureOpenAI: { AzureOpenAI: {
name: "Azure Open AI", name: "Azure OpenAI",
models: [ models: [
{ label: "GPT 4o Mini", value: "gpt-4o-mini" }, { label: "GPT 4o Mini", value: "gpt-4o-mini" },
{ label: "GPT 4o", value: "gpt-4o" }, { label: "GPT 4o", value: "gpt-4o" },

View File

@ -27,7 +27,6 @@
let editingUuid let editingUuid
$: isCloud = $admin.cloud $: isCloud = $admin.cloud
$: budibaseAIEnabled = $licensing.budibaseAIEnabled
$: customAIConfigsEnabled = $licensing.customAIConfigsEnabled $: customAIConfigsEnabled = $licensing.customAIConfigsEnabled
async function fetchAIConfig() { async function fetchAIConfig() {
@ -127,18 +126,8 @@
</Modal> </Modal>
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<div class="header">
<Heading size="M">AI</Heading> <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} {#if !isCloud && !customAIConfigsEnabled}
<Tags> <Tags>
<Tag icon="LockClosed">Premium</Tag> <Tag icon="LockClosed">Premium</Tag>
@ -147,24 +136,43 @@
<Tags> <Tags>
<Tag icon="LockClosed">Enterprise</Tag> <Tag icon="LockClosed">Enterprise</Tag>
</Tags> </Tags>
{:else}
<Button size="S" cta on:click={newConfig}>Add configuration</Button>
{/if} {/if}
</div> </div>
<Body>Configure your AI settings within this section:</Body>
</Layout>
<Divider />
<div style={`opacity: ${customAIConfigsEnabled ? 1 : 0.5}`}>
<Layout noPadding>
<div class="config-heading">
<Heading size="S">AI Configurations</Heading>
<Button
size="S"
cta={customAIConfigsEnabled}
secondary={!customAIConfigsEnabled}
on:click={customAIConfigsEnabled ? newConfig : null}
>
Add configuration
</Button>
</div>
<Body size="S" <Body size="S"
>Use the following interface to select your preferred AI configuration.</Body >Use the following interface to select your preferred AI configuration.</Body
> >
{#if customAIConfigsEnabled}
<Body size="S">Select your AI Model:</Body> <Body size="S">Select your AI Model:</Body>
{/if}
{#if fullAIConfig?.config} {#if fullAIConfig?.config}
{#each Object.keys(fullAIConfig.config) as key} {#each Object.keys(fullAIConfig.config) as key}
<AIConfigTile <AIConfigTile
config={fullAIConfig.config[key]} config={fullAIConfig.config[key]}
editHandler={() => editConfig(key)} editHandler={customAIConfigsEnabled ? () => editConfig(key) : null}
deleteHandler={() => deleteConfig(key)} deleteHandler={customAIConfigsEnabled
? () => deleteConfig(key)
: null}
/> />
{/each} {/each}
{/if} {/if}
</Layout> </Layout>
</div>
</Layout> </Layout>
<style> <style>
@ -172,5 +180,12 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: -18px;
}
.header {
display: flex;
align-items: center;
gap: 12px;
} }
</style> </style>

View File

@ -0,0 +1,64 @@
<script>
export let width
export let height
</script>
<svg xmlns="http://www.w3.org/2000/svg" {width} {height} viewBox="0 0 96 96">
<defs>
<linearGradient
id="e399c19f-b68f-429d-b176-18c2117ff73c"
x1="-1032.172"
x2="-1059.213"
y1="145.312"
y2="65.426"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#114a8b" />
<stop offset="1" stop-color="#0669bc" />
</linearGradient>
<linearGradient
id="ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15"
x1="-1023.725"
x2="-1029.98"
y1="108.083"
y2="105.968"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-opacity=".3" />
<stop offset=".071" stop-opacity=".2" />
<stop offset=".321" stop-opacity=".1" />
<stop offset=".623" stop-opacity=".05" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
<linearGradient
id="a7fee970-a784-4bb1-af8d-63d18e5f7db9"
x1="-1027.165"
x2="-997.482"
y1="147.642"
y2="68.561"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#3ccbf4" />
<stop offset="1" stop-color="#2892df" />
</linearGradient>
</defs>
<path
fill="url(#e399c19f-b68f-429d-b176-18c2117ff73c)"
d="M33.338 6.544h26.038l-27.03 80.087a4.152 4.152 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.152 4.152 0 0 1 3.934-2.825z"
/>
<path
fill="#0078d4"
d="M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.171 4.171 0 0 0 2.846 1.121h23.38z"
/>
<path
fill="url(#ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15)"
d="M33.338 6.544a4.118 4.118 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.443 4.443 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.237 4.237 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z"
/>
<path
fill="url(#a7fee970-a784-4bb1-af8d-63d18e5f7db9)"
d="M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.146 4.146 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z"
/>
</svg>

View File

@ -1,5 +1,10 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { licensing } from "stores/portal"
if ($licensing.customAIConfigsEnabled) {
$redirect("./ai")
} else {
$redirect("./auth") $redirect("./auth")
}
</script> </script>

View File

@ -5,6 +5,7 @@ import {
LoopStepType, LoopStepType,
CreateRowStepOutputs, CreateRowStepOutputs,
ServerLogStepOutputs, ServerLogStepOutputs,
FieldType,
} from "@budibase/types" } from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
@ -269,4 +270,145 @@ describe("Loop automations", () => {
expect(results.steps[1].outputs.message).toContain("- 3") expect(results.steps[1].outputs.message).toContain("- 3")
}) })
it("should run an automation with a loop and update row step", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const builder = createAutomationBuilder({
name: "Test Loop and Update Row",
})
const results = await builder
.appAction({ fields: {} })
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.updateRow({
rowId: "{{ loop.currentItem._id }}",
row: {
name: "Updated {{ loop.currentItem.name }}",
value: "{{ loop.currentItem.value }}",
tableId: table._id,
},
meta: {},
})
.queryRows({
tableId: table._id!,
})
.run()
const expectedRows = [
{ name: "Updated Row 1", value: 1 },
{ name: "Updated Row 2", value: 2 },
{ name: "Updated Row 3", value: 3 },
]
expect(results.steps[1].outputs.items).toEqual(
expect.arrayContaining(
expectedRows.map(row =>
expect.objectContaining({
success: true,
row: expect.objectContaining(row),
})
)
)
)
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining(
expectedRows.map(row => expect.objectContaining(row))
)
)
expect(results.steps[1].outputs.items).toHaveLength(expectedRows.length)
expect(results.steps[2].outputs.rows).toHaveLength(expectedRows.length)
})
it("should run an automation with a loop and delete row step", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const rows = [
{ name: "Row 1", value: 1, tableId: table._id },
{ name: "Row 2", value: 2, tableId: table._id },
{ name: "Row 3", value: 3, tableId: table._id },
]
await config.api.row.bulkImport(table._id!, { rows })
const builder = createAutomationBuilder({
name: "Test Loop and Delete Row",
})
const results = await builder
.appAction({ fields: {} })
.queryRows({
tableId: table._id!,
})
.loop({
option: LoopStepType.ARRAY,
binding: "{{ steps.1.rows }}",
})
.deleteRow({
tableId: table._id!,
id: "{{ loop.currentItem._id }}",
})
.queryRows({
tableId: table._id!,
})
.run()
expect(results.steps).toHaveLength(3)
expect(results.steps[2].outputs.rows).toHaveLength(0)
})
}) })

View File

@ -1,8 +1,9 @@
import * as automation from "../../index" import * as automation from "../../index"
import * as setup from "../utilities" import * as setup from "../utilities"
import { LoopStepType, FieldType } from "@budibase/types" import { LoopStepType, FieldType, Table } from "@budibase/types"
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
import { DatabaseName } from "../../../integrations/tests/utils" import { DatabaseName } from "../../../integrations/tests/utils"
import { FilterConditions } from "../../../automations/steps/filter"
describe("Automation Scenarios", () => { describe("Automation Scenarios", () => {
let config = setup.getConfig() let config = setup.getConfig()
@ -195,6 +196,91 @@ describe("Automation Scenarios", () => {
) )
}) })
}) })
it("should trigger an automation which creates and then updates a row", async () => {
const table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
const builder = createAutomationBuilder({
name: "Test Create and Update Row",
})
const results = await builder
.appAction({ fields: {} })
.createRow(
{
row: {
name: "Initial Row",
value: 1,
tableId: table._id,
},
},
{ stepName: "CreateRowStep" }
)
.updateRow(
{
rowId: "{{ steps.CreateRowStep.row._id }}",
row: {
name: "Updated Row",
value: 2,
tableId: table._id,
},
meta: {},
},
{ stepName: "UpdateRowStep" }
)
.queryRows(
{
tableId: table._id!,
},
{ stepName: "QueryRowsStep" }
)
.run()
expect(results.steps).toHaveLength(3)
expect(results.steps[0].outputs).toMatchObject({
success: true,
row: {
name: "Initial Row",
value: 1,
},
})
expect(results.steps[1].outputs).toMatchObject({
success: true,
row: {
name: "Updated Row",
value: 2,
},
})
const expectedRows = [{ name: "Updated Row", value: 2 }]
expect(results.steps[2].outputs.rows).toEqual(
expect.arrayContaining(
expectedRows.map(row => expect.objectContaining(row))
)
)
})
}) })
describe("Name Based Automations", () => { describe("Name Based Automations", () => {
@ -233,4 +319,167 @@ describe("Automation Scenarios", () => {
expect(results.steps[2].outputs.rows).toHaveLength(1) expect(results.steps[2].outputs.rows).toHaveLength(1)
}) })
}) })
describe("Automations with filter", () => {
let table: Table
beforeEach(async () => {
table = await config.createTable({
name: "TestTable",
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
presence: true,
},
},
value: {
name: "value",
type: FieldType.NUMBER,
constraints: {
presence: true,
},
},
},
})
})
it("should stop an automation if the condition is not met", async () => {
const builder = createAutomationBuilder({
name: "Test Equal",
})
const results = await builder
.appAction({ fields: {} })
.createRow({
row: {
name: "Equal Test",
value: 10,
tableId: table._id,
},
})
.queryRows({
tableId: table._id!,
})
.filter({
field: "{{ steps.2.rows.0.value }}",
condition: FilterConditions.EQUAL,
value: 20,
})
.serverLog({ text: "Equal condition met" })
.run()
expect(results.steps[2].outputs.success).toBeTrue()
expect(results.steps[2].outputs.result).toBeFalse()
expect(results.steps[3]).toBeUndefined()
})
it("should continue the automation if the condition is met", async () => {
const builder = createAutomationBuilder({
name: "Test Not Equal",
})
const results = await builder
.appAction({ fields: {} })
.createRow({
row: {
name: "Not Equal Test",
value: 10,
tableId: table._id,
},
})
.queryRows({
tableId: table._id!,
})
.filter({
field: "{{ steps.2.rows.0.value }}",
condition: FilterConditions.NOT_EQUAL,
value: 20,
})
.serverLog({ text: "Not Equal condition met" })
.run()
expect(results.steps[2].outputs.success).toBeTrue()
expect(results.steps[2].outputs.result).toBeTrue()
expect(results.steps[3].outputs.success).toBeTrue()
})
const testCases = [
{
condition: FilterConditions.EQUAL,
value: 10,
rowValue: 10,
expectPass: true,
},
{
condition: FilterConditions.NOT_EQUAL,
value: 10,
rowValue: 20,
expectPass: true,
},
{
condition: FilterConditions.GREATER_THAN,
value: 10,
rowValue: 15,
expectPass: true,
},
{
condition: FilterConditions.LESS_THAN,
value: 10,
rowValue: 5,
expectPass: true,
},
{
condition: FilterConditions.GREATER_THAN,
value: 10,
rowValue: 5,
expectPass: false,
},
{
condition: FilterConditions.LESS_THAN,
value: 10,
rowValue: 15,
expectPass: false,
},
]
it.each(testCases)(
"should pass the filter when condition is $condition",
async ({ condition, value, rowValue, expectPass }) => {
const builder = createAutomationBuilder({
name: `Test ${condition}`,
})
const results = await builder
.appAction({ fields: {} })
.createRow({
row: {
name: `${condition} Test`,
value: rowValue,
tableId: table._id,
},
})
.queryRows({
tableId: table._id!,
})
.filter({
field: "{{ steps.2.rows.0.value }}",
condition,
value,
})
.serverLog({
text: `${condition} condition ${expectPass ? "passed" : "failed"}`,
})
.run()
expect(results.steps[2].outputs.result).toBe(expectPass)
if (expectPass) {
expect(results.steps[3].outputs.success).toBeTrue()
} else {
expect(results.steps[3]).toBeUndefined()
}
}
)
})
}) })

View File

@ -33,6 +33,7 @@ import {
BranchStepInputs, BranchStepInputs,
SearchFilters, SearchFilters,
Branch, Branch,
FilterStepInputs,
} from "@budibase/types" } from "@budibase/types"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import * as setup from "../utilities" import * as setup from "../utilities"
@ -181,6 +182,14 @@ class BaseStepBuilder {
opts?.stepName opts?.stepName
) )
} }
filter(input: FilterStepInputs): this {
return this.step(
AutomationActionStepId.FILTER,
BUILTIN_ACTION_DEFINITIONS.FILTER,
input
)
}
} }
class StepBuilder extends BaseStepBuilder { class StepBuilder extends BaseStepBuilder {
build(): AutomationStep[] { build(): AutomationStep[] {