1
0
Fork 0
mirror of synced 2024-09-20 11:27:56 +12:00

Merge remote-tracking branch 'origin/master' into dean-fixes

This commit is contained in:
Dean 2024-07-18 10:11:02 +01:00
commit 94c7f50f6e
30 changed files with 1245 additions and 315 deletions

View file

@ -4,8 +4,9 @@ import { Ctx } from "@budibase/types"
function validate(
schema: Joi.ObjectSchema | Joi.ArraySchema,
property: string,
opts: { errorPrefix: string } = { errorPrefix: `Invalid ${property}` }
opts?: { errorPrefix?: string; allowUnknown?: boolean }
) {
const errorPrefix = opts?.errorPrefix ?? `Invalid ${property}`
// Return a Koa middleware function
return (ctx: Ctx, next: any) => {
if (!schema) {
@ -28,10 +29,12 @@ function validate(
})
}
const { error } = schema.validate(params)
const { error } = schema.validate(params, {
allowUnknown: opts?.allowUnknown,
})
if (error) {
let message = error.message
if (opts.errorPrefix) {
if (errorPrefix) {
message = `Invalid ${property} - ${message}`
}
ctx.throw(400, message)
@ -42,7 +45,7 @@ function validate(
export function body(
schema: Joi.ObjectSchema | Joi.ArraySchema,
opts?: { errorPrefix: string }
opts?: { errorPrefix?: string; allowUnknown?: boolean }
) {
return validate(schema, "body", opts)
}

View file

@ -1,5 +1,5 @@
<script>
import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { Select, Label, Checkbox, Body } from "@budibase/bbui"
import { tables, viewsV2 } from "stores/builder"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -46,19 +46,35 @@
{#if parameters.confirm}
<Label small>Title</Label>
<Input placeholder="Delete Row" bind:value={parameters.customTitleText} />
<DrawerBindableInput
placeholder="Prompt User"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<Label small>Text</Label>
<Input
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
<DrawerBindableInput
placeholder="Are you sure you want to continue?"
value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/>
<Label small>Confirm Text</Label>
<Input placeholder="Confirm" bind:value={parameters.confirmButtonText} />
<DrawerBindableInput
placeholder="Confirm"
value={parameters.confirmButtonText}
on:change={e => (parameters.confirmButtonText = e.detail)}
{bindings}
/>
<Label small>Cancel Text</Label>
<Input placeholder="Cancel" bind:value={parameters.cancelButtonText} />
<DrawerBindableInput
placeholder="Cancel"
value={parameters.cancelButtonText}
on:change={e => (parameters.cancelButtonText = e.detail)}
{bindings}
/>
{/if}
</div>
</div>

View file

@ -1,11 +1,12 @@
<script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { Select, Label, Body, Checkbox } from "@budibase/bbui"
import {
selectedScreen,
componentStore,
tables,
viewsV2,
} from "stores/builder"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { getSchemaForDatasourcePlus } from "dataBinding"
import SaveFields from "./SaveFields.svelte"
import { getDatasourceLikeProviders } from "components/design/settings/controls/ButtonActionEditor/actions/utils"
@ -73,22 +74,35 @@
{#if parameters.confirm}
<Label small>Title</Label>
<Input
placeholder="Duplicate Row"
bind:value={parameters.customTitleText}
<DrawerBindableInput
placeholder="Prompt User"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<Label small>Text</Label>
<Input
placeholder="Are you sure you want to duplicate this row?"
bind:value={parameters.confirmText}
<DrawerBindableInput
placeholder="Are you sure you want to continue?"
value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/>
<Label small>Confirm Text</Label>
<Input placeholder="Confirm" bind:value={parameters.confirmButtonText} />
<DrawerBindableInput
placeholder="Confirm"
value={parameters.confirmButtonText}
on:change={e => (parameters.confirmButtonText = e.detail)}
{bindings}
/>
<Label small>Cancel Text</Label>
<Input placeholder="Cancel" bind:value={parameters.cancelButtonText} />
<DrawerBindableInput
placeholder="Cancel"
value={parameters.cancelButtonText}
on:change={e => (parameters.cancelButtonText = e.detail)}
{bindings}
/>
{/if}
</div>

View file

@ -1,5 +1,6 @@
<script>
import { Select, Layout, Input, Checkbox } from "@budibase/bbui"
import { Select, Layout, Checkbox } from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { datasources, integrations, queries } from "stores/builder"
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
@ -58,37 +59,46 @@
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
{#if parameters.queryId}
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Input
label="Title"
placeholder="Execute Query"
bind:value={parameters.customTitleText}
/>
<div class="params">
<DrawerBindableInput
label="Title"
placeholder="Prompt User"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<DrawerBindableInput
label="Message"
placeholder="Are you sure you want to continue?"
value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/>
<Input
label="Text"
placeholder="Are you sure you want to execute this query?"
bind:value={parameters.confirmText}
/>
<DrawerBindableInput
label="Confirm Text"
placeholder="Confirm"
value={parameters.confirmButtonText}
on:change={e => (parameters.confirmButtonText = e.detail)}
{bindings}
/>
<Input
label="Confirm Text"
placeholder="Confirm"
bind:value={parameters.confirmButtonText}
/>
<Input
label="Cancel Text"
placeholder="Cancel"
bind:value={parameters.cancelButtonText}
/>
<DrawerBindableInput
label="Cancel Text"
placeholder="Cancel"
value={parameters.cancelButtonText}
on:change={e => (parameters.cancelButtonText = e.detail)}
{bindings}
/>
</div>
{/if}
{#if query?.parameters?.length > 0}
<br />
<div class="params">
<BindingBuilder
customParams={parameters.queryParams}

View file

@ -1,11 +1,12 @@
<script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { Select, Label, Body, Checkbox } from "@budibase/bbui"
import {
selectedScreen,
componentStore,
tables,
viewsV2,
} from "stores/builder"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { getSchemaForDatasourcePlus } from "dataBinding"
import SaveFields from "./SaveFields.svelte"
import { getDatasourceLikeProviders } from "components/design/settings/controls/ButtonActionEditor/actions/utils"
@ -73,19 +74,35 @@
{#if parameters.confirm}
<Label small>Title</Label>
<Input placeholder="Save Row" bind:value={parameters.customTitleText} />
<DrawerBindableInput
placeholder="Prompt User"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<Label small>Text</Label>
<Input
placeholder="Are you sure you want to save this row?"
bind:value={parameters.confirmText}
<DrawerBindableInput
placeholder="Are you sure you want to continue?"
value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/>
<Label small>Confirm Text</Label>
<Input placeholder="Confirm" bind:value={parameters.confirmButtonText} />
<DrawerBindableInput
placeholder="Confirm"
value={parameters.confirmButtonText}
on:change={e => (parameters.confirmButtonText = e.detail)}
{bindings}
/>
<Label small>Cancel Text</Label>
<Input placeholder="Cancel" bind:value={parameters.cancelButtonText} />
<DrawerBindableInput
placeholder="Cancel"
value={parameters.cancelButtonText}
on:change={e => (parameters.cancelButtonText = e.detail)}
{bindings}
/>
{/if}
</div>

View file

@ -124,7 +124,7 @@
<PropertyControl
label="Text align"
control={BarButtonList}
onChange={align => nav.syncAppNavigation({ textAlign: align })}
onChange={align => update("textAlign", align)}
value={$nav.textAlign}
props={{
options: alignmentOptions,

View file

@ -1,16 +1,6 @@
import * as triggers from "../../automations/triggers"
import {
getAutomationParams,
generateAutomationID,
DocumentType,
} from "../../db/utils"
import {
checkForWebhooks,
updateTestHistory,
removeDeprecated,
} from "../../automations/utils"
import { deleteEntityMetadata } from "../../utilities"
import { MetadataTypes } from "../../constants"
import { DocumentType } from "../../db/utils"
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
import { automations, features } from "@budibase/pro"
@ -41,42 +31,9 @@ function getTriggerDefinitions() {
* *
*************************/
async function cleanupAutomationMetadata(automationId: string) {
await deleteEntityMetadata(MetadataTypes.AUTOMATION_TEST_INPUT, automationId)
await deleteEntityMetadata(
MetadataTypes.AUTOMATION_TEST_HISTORY,
automationId
)
}
function cleanAutomationInputs(automation: Automation) {
if (automation == null) {
return automation
}
let steps = automation.definition.steps
let trigger = automation.definition.trigger
let allSteps = [...steps, trigger]
// live is not a property used anymore
if (automation.live != null) {
delete automation.live
}
for (let step of allSteps) {
if (step == null) {
continue
}
for (let inputName of Object.keys(step.inputs)) {
if (!step.inputs[inputName] || step.inputs[inputName] === "") {
delete step.inputs[inputName]
}
}
}
return automation
}
export async function create(
ctx: UserCtx<Automation, { message: string; automation: Automation }>
) {
const db = context.getAppDB()
let automation = ctx.request.body
automation.appId = ctx.appId
@ -86,66 +43,17 @@ export async function create(
return
}
// Respect existing IDs if recreating a deleted automation
if (!automation._id) {
automation._id = generateAutomationID()
}
automation.type = "automation"
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
newAuto: automation,
})
const response = await db.put(automation)
await events.automation.created(automation)
for (let step of automation.definition.steps) {
await events.automation.stepCreated(automation, step)
}
automation._rev = response.rev
const createdAutomation = await sdk.automations.create(automation)
ctx.status = 200
ctx.body = {
message: "Automation created successfully",
automation: {
...automation,
...response,
},
automation: createdAutomation,
}
builderSocket?.emitAutomationUpdate(ctx, automation)
}
export function getNewSteps(oldAutomation: Automation, automation: Automation) {
const oldStepIds = oldAutomation.definition.steps.map(s => s.id)
return automation.definition.steps.filter(s => !oldStepIds.includes(s.id))
}
export function getDeletedSteps(
oldAutomation: Automation,
automation: Automation
) {
const stepIds = automation.definition.steps.map(s => s.id)
return oldAutomation.definition.steps.filter(s => !stepIds.includes(s.id))
}
export async function handleStepEvents(
oldAutomation: Automation,
automation: Automation
) {
// new steps
const newSteps = getNewSteps(oldAutomation, automation)
for (let step of newSteps) {
await events.automation.stepCreated(automation, step)
}
// old steps
const deletedSteps = getDeletedSteps(oldAutomation, automation)
for (let step of deletedSteps) {
await events.automation.stepDeleted(automation, step)
}
}
export async function update(ctx: UserCtx) {
const db = context.getAppDB()
let automation = ctx.request.body
automation.appId = ctx.appId
@ -155,72 +63,28 @@ export async function update(ctx: UserCtx) {
return
}
const oldAutomation = await db.get<Automation>(automation._id)
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
oldAuto: oldAutomation,
newAuto: automation,
})
const response = await db.put(automation)
automation._rev = response.rev
const oldAutoTrigger =
oldAutomation && oldAutomation.definition.trigger
? oldAutomation.definition.trigger
: undefined
const newAutoTrigger =
automation && automation.definition.trigger
? automation.definition.trigger
: {}
// trigger has been updated, remove the test inputs
if (oldAutoTrigger && oldAutoTrigger.id !== newAutoTrigger.id) {
await events.automation.triggerUpdated(automation)
await deleteEntityMetadata(
MetadataTypes.AUTOMATION_TEST_INPUT,
automation._id!
)
}
await handleStepEvents(oldAutomation, automation)
const updatedAutomation = await sdk.automations.update(automation)
ctx.status = 200
ctx.body = {
message: `Automation ${automation._id} updated successfully.`,
automation: {
...automation,
_rev: response.rev,
_id: response.id,
},
automation: updatedAutomation,
}
builderSocket?.emitAutomationUpdate(ctx, automation)
}
export async function fetch(ctx: UserCtx) {
const db = context.getAppDB()
const response = await db.allDocs(
getAutomationParams(null, {
include_docs: true,
})
)
ctx.body = response.rows.map(row => row.doc)
ctx.body = await sdk.automations.fetch()
}
export async function find(ctx: UserCtx) {
const db = context.getAppDB()
ctx.body = await db.get(ctx.params.id)
ctx.body = await sdk.automations.get(ctx.params.id)
}
export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
const db = context.getAppDB()
const automationId = ctx.params.id
const oldAutomation = await db.get<Automation>(automationId)
await checkForWebhooks({
oldAuto: oldAutomation,
})
// delete metadata first
await cleanupAutomationMetadata(automationId)
ctx.body = await db.remove(automationId, ctx.params.rev)
await events.automation.deleted(oldAutomation)
ctx.body = await sdk.automations.remove(automationId, ctx.params.rev)
builderSocket?.emitAutomationDeletion(ctx, automationId)
}

View file

@ -0,0 +1,80 @@
import {
CreateRowActionRequest,
Ctx,
RowActionResponse,
RowActionsResponse,
UpdateRowActionRequest,
} from "@budibase/types"
import sdk from "../../../sdk"
async function getTable(ctx: Ctx) {
const { tableId } = ctx.params
const table = await sdk.tables.getTable(tableId)
if (!table) {
ctx.throw(404)
}
return table
}
export async function find(ctx: Ctx<void, RowActionsResponse>) {
const table = await getTable(ctx)
if (!(await sdk.rowActions.docExists(table._id!))) {
ctx.body = {
actions: {},
}
return
}
const { actions } = await sdk.rowActions.get(table._id!)
const result: RowActionsResponse = {
actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>(
(acc, [key, action]) => ({
...acc,
[key]: { id: key, tableId: table._id!, ...action },
}),
{}
),
}
ctx.body = result
}
export async function create(
ctx: Ctx<CreateRowActionRequest, RowActionResponse>
) {
const table = await getTable(ctx)
const createdAction = await sdk.rowActions.create(table._id!, {
name: ctx.request.body.name,
})
ctx.body = {
tableId: table._id!,
...createdAction,
}
ctx.status = 201
}
export async function update(
ctx: Ctx<UpdateRowActionRequest, RowActionResponse>
) {
const table = await getTable(ctx)
const { actionId } = ctx.params
const actions = await sdk.rowActions.update(table._id!, actionId, {
name: ctx.request.body.name,
})
ctx.body = {
tableId: table._id!,
...actions,
}
}
export async function remove(ctx: Ctx<void, void>) {
const table = await getTable(ctx)
const { actionId } = ctx.params
await sdk.rowActions.remove(table._id!, actionId)
ctx.status = 204
}

View file

@ -0,0 +1,2 @@
export * from "./crud"
export * from "./run"

View file

@ -0,0 +1,3 @@
export function run() {
throw new Error("Function not implemented.")
}

View file

@ -28,6 +28,7 @@ import opsRoutes from "./ops"
import debugRoutes from "./debug"
import Router from "@koa/router"
import { api as pro } from "@budibase/pro"
import rowActionRoutes from "./rowAction"
export { default as staticRoutes } from "./static"
export { default as publicRoutes } from "./public"
@ -65,6 +66,7 @@ export const mainRoutes: Router[] = [
opsRoutes,
debugRoutes,
environmentVariableRoutes,
rowActionRoutes,
// these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this
tableRoutes,

View file

@ -0,0 +1,53 @@
import Router from "@koa/router"
import * as rowActionController from "../controllers/rowAction"
import { authorizedResource } from "../../middleware/authorized"
import { middleware, permissions } from "@budibase/backend-core"
import Joi from "joi"
const { PermissionLevel, PermissionType } = permissions
export function rowActionValidator() {
return middleware.joiValidator.body(
Joi.object({
name: Joi.string().required(),
}),
{ allowUnknown: true }
)
}
const router: Router = new Router()
// CRUD endpoints
router
.get(
"/api/tables/:tableId/actions",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionController.find
)
.post(
"/api/tables/:tableId/actions",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionValidator(),
rowActionController.create
)
.put(
"/api/tables/:tableId/actions/:actionId",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionValidator(),
rowActionController.update
)
.delete(
"/api/tables/:tableId/actions/:actionId",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionController.remove
)
// Other endpoints
.post(
"/api/tables/:tableId/actions/:actionId/run",
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
rowActionController.run
)
export default router

View file

@ -154,7 +154,7 @@ describe("/automations", () => {
tableId: table._id,
},
}
automation.appId = config.appId
automation.appId = config.getAppId()
automation = await config.createAutomation(automation)
await setup.delay(500)
const res = await testAutomation(config, automation, {
@ -267,8 +267,7 @@ describe("/automations", () => {
}
it("updates a automations name", async () => {
let automation = newAutomation()
await config.createAutomation(automation)
const automation = await config.createAutomation(newAutomation())
automation.name = "Updated Name"
jest.clearAllMocks()
@ -294,8 +293,7 @@ describe("/automations", () => {
})
it("updates a automations name using POST request", async () => {
let automation = newAutomation()
await config.createAutomation(automation)
const automation = await config.createAutomation(newAutomation())
automation.name = "Updated Name"
jest.clearAllMocks()
@ -392,8 +390,7 @@ describe("/automations", () => {
describe("fetch", () => {
it("return all the automations for an instance", async () => {
await clearAllAutomations(config)
const autoConfig = basicAutomation()
await config.createAutomation(autoConfig)
const autoConfig = await config.createAutomation(basicAutomation())
const res = await request
.get(`/api/automations`)
.set(config.defaultHeaders())

View file

@ -0,0 +1,412 @@
import _ from "lodash"
import tk from "timekeeper"
import { CreateRowActionRequest, RowActionResponse } from "@budibase/types"
import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests"
describe("/rowsActions", () => {
const config = setup.getConfig()
let tableId: string
beforeAll(async () => {
tk.freeze(new Date())
await config.init()
})
beforeEach(async () => {
const table = await config.api.table.save(setup.structures.basicTable())
tableId = table._id!
})
afterAll(setup.afterAll)
const createRowAction = config.api.rowAction.save
function createRowActionRequest(): CreateRowActionRequest {
return {
name: generator.string(),
}
}
function createRowActionRequests(count: number): CreateRowActionRequest[] {
return generator
.unique(() => generator.string(), count)
.map(name => ({ name }))
}
function unauthorisedTests() {
it("returns unauthorised (401) for unauthenticated requests", async () => {
await createRowAction(
tableId,
createRowActionRequest(),
{
status: 401,
body: {
message: "Session not authenticated",
},
},
{ publicUser: true }
)
})
it("returns forbidden (403) for non-builder users", async () => {
const user = await config.createUser({
builder: {},
})
await config.withUser(user, async () => {
await createRowAction(generator.guid(), createRowActionRequest(), {
status: 403,
})
})
})
it("rejects (404) for a non-existing table", async () => {
await createRowAction(generator.guid(), createRowActionRequest(), {
status: 404,
})
})
}
describe("create", () => {
unauthorisedTests()
it("creates new row actions for tables without existing actions", async () => {
const rowAction = createRowActionRequest()
const res = await createRowAction(tableId, rowAction, {
status: 201,
})
expect(res).toEqual({
id: expect.stringMatching(/^row_action_\w+/),
tableId: tableId,
...rowAction,
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: {
...rowAction,
id: res.id,
tableId: tableId,
},
},
})
})
it("trims row action names", async () => {
const name = " action name "
const res = await createRowAction(
tableId,
{ name },
{
status: 201,
}
)
expect(res).toEqual({
id: expect.stringMatching(/^row_action_\w+/),
tableId: tableId,
name: "action name",
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: expect.objectContaining({
name: "action name",
}),
},
})
})
it("can create multiple row actions for the same table", async () => {
const rowActions = createRowActionRequests(3)
const responses: RowActionResponse[] = []
for (const action of rowActions) {
responses.push(await createRowAction(tableId, action))
}
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[responses[0].id]: { ...rowActions[0], id: responses[0].id, tableId },
[responses[1].id]: { ...rowActions[1], id: responses[1].id, tableId },
[responses[2].id]: { ...rowActions[2], id: responses[2].id, tableId },
},
})
})
it("rejects with bad request when creating with no name", async () => {
const rowAction: CreateRowActionRequest = {
name: "",
}
await createRowAction(tableId, rowAction, {
status: 400,
body: {
message: 'Invalid body - "name" is not allowed to be empty',
},
})
})
it("ignores not valid row action data", async () => {
const rowAction = createRowActionRequest()
const dirtyRowAction = {
...rowAction,
id: generator.guid(),
valueToIgnore: generator.string(),
}
const res = await createRowAction(tableId, dirtyRowAction, {
status: 201,
})
expect(res).toEqual({
id: expect.any(String),
tableId,
...rowAction,
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: {
id: res.id,
tableId: tableId,
...rowAction,
},
},
})
})
it("can not create multiple row actions with the same name (for the same table)", async () => {
const action = await createRowAction(tableId, {
name: "Row action name ",
})
await createRowAction(
tableId,
{ name: action.name },
{
status: 409,
body: {
message: "A row action with the same name already exists.",
},
}
)
await createRowAction(
tableId,
{ name: "row action name" },
{
status: 409,
body: {
message: "A row action with the same name already exists.",
},
}
)
})
it("can reuse row action names between different tables", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
const action = await createRowAction(tableId, createRowActionRequest())
await createRowAction(otherTable._id!, { name: action.name })
})
})
describe("find", () => {
unauthorisedTests()
it("returns only the actions for the requested table", async () => {
const rowActions: RowActionResponse[] = []
for (const action of createRowActionRequests(3)) {
rowActions.push(await createRowAction(tableId, action))
}
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const response = await config.api.rowAction.find(tableId)
expect(response).toEqual({
actions: {
[rowActions[0].id]: expect.any(Object),
[rowActions[1].id]: expect.any(Object),
[rowActions[2].id]: expect.any(Object),
},
})
})
it("returns empty for tables without row actions", async () => {
const response = await config.api.rowAction.find(tableId)
expect(response).toEqual({
actions: {},
})
})
})
describe("update", () => {
unauthorisedTests()
it("can update existing actions", async () => {
for (const rowAction of createRowActionRequests(3)) {
await createRowAction(tableId, rowAction)
}
const persisted = await config.api.rowAction.find(tableId)
const [actionId, actionData] = _.sample(
Object.entries(persisted.actions)
)!
const updatedName = generator.string()
const res = await config.api.rowAction.update(tableId, actionId, {
...actionData,
name: updatedName,
})
expect(res).toEqual({
id: actionId,
tableId,
name: updatedName,
})
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: expect.objectContaining({
[actionId]: {
...actionData,
name: updatedName,
},
}),
})
)
})
it("trims row action names", async () => {
const rowAction = await createRowAction(
tableId,
createRowActionRequest(),
{
status: 201,
}
)
const res = await config.api.rowAction.update(tableId, rowAction.id, {
...rowAction,
name: " action name ",
})
expect(res).toEqual(expect.objectContaining({ name: "action name" }))
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: expect.objectContaining({
[rowAction.id]: expect.objectContaining({
name: "action name",
}),
}),
})
)
})
it("throws Bad Request when trying to update by a non-existing id", async () => {
await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
tableId,
generator.guid(),
createRowActionRequest(),
{ status: 400 }
)
})
it("throws Bad Request when trying to update by a via another table id", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const action = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
otherTable._id!,
action.id,
createRowActionRequest(),
{ status: 400 }
)
})
it("can not use existing row action names (for the same table)", async () => {
const action1 = await createRowAction(tableId, createRowActionRequest())
const action2 = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
tableId,
action1.id,
{ name: action2.name },
{
status: 409,
body: {
message: "A row action with the same name already exists.",
},
}
)
})
it("does not throw with name conflicts for the same row action", async () => {
const action1 = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(tableId, action1.id, {
name: action1.name,
})
})
})
describe("delete", () => {
unauthorisedTests()
it("can delete existing actions", async () => {
const actions: RowActionResponse[] = []
for (const rowAction of createRowActionRequests(3)) {
actions.push(await createRowAction(tableId, rowAction))
}
const actionToDelete = _.sample(actions)!
await config.api.rowAction.delete(tableId, actionToDelete.id, {
status: 204,
})
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: actions
.filter(a => a.id !== actionToDelete.id)
.reduce((acc, c) => ({ ...acc, [c.id]: expect.any(Object) }), {}),
})
)
})
it("throws Bad Request when trying to delete by a non-existing id", async () => {
await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.delete(tableId, generator.guid(), {
status: 400,
})
})
it("throws Bad Request when trying to delete by a via another table id", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const action = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.delete(otherTable._id!, action.id, {
status: 400,
})
})
})
})

View file

@ -4,7 +4,12 @@ import {
getDatasource,
knexClient,
} from "../../../integrations/tests/utils"
import { db as dbCore, utils } from "@budibase/backend-core"
import {
db as dbCore,
MAX_VALID_DATE,
MIN_VALID_DATE,
utils,
} from "@budibase/backend-core"
import * as setup from "./utilities"
import {
@ -1098,21 +1103,37 @@ describe.each([
}).toFindNothing()
})
// We never implemented half-open ranges in Lucene.
!isLucene &&
it("can search using just a low value", async () => {
await expectQuery({
range: { age: { low: 5 } },
}).toContainExactly([{ age: 10 }])
})
it("greater than equal to", async () => {
await expectQuery({
range: {
age: { low: 10, high: Number.MAX_SAFE_INTEGER },
},
}).toContainExactly([{ age: 10 }])
})
// We never implemented half-open ranges in Lucene.
!isLucene &&
it("can search using just a high value", async () => {
await expectQuery({
range: { age: { high: 5 } },
}).toContainExactly([{ age: 1 }])
})
it("greater than", async () => {
await expectQuery({
range: {
age: { low: 5, high: Number.MAX_SAFE_INTEGER },
},
}).toContainExactly([{ age: 10 }])
})
it("less than equal to", async () => {
await expectQuery({
range: {
age: { high: 1, low: Number.MIN_SAFE_INTEGER },
},
}).toContainExactly([{ age: 1 }])
})
it("less than", async () => {
await expectQuery({
range: {
age: { high: 5, low: Number.MIN_SAFE_INTEGER },
},
}).toContainExactly([{ age: 1 }])
})
})
describe("sort", () => {
@ -1232,21 +1253,29 @@ describe.each([
}).toFindNothing()
})
// We never implemented half-open ranges in Lucene.
!isLucene &&
it("can search using just a low value", async () => {
await expectQuery({
range: { dob: { low: JAN_5TH } },
}).toContainExactly([{ dob: JAN_10TH }])
})
it("greater than equal to", async () => {
await expectQuery({
range: { dob: { low: JAN_10TH, high: MAX_VALID_DATE.toISOString() } },
}).toContainExactly([{ dob: JAN_10TH }])
})
// We never implemented half-open ranges in Lucene.
!isLucene &&
it("can search using just a high value", async () => {
await expectQuery({
range: { dob: { high: JAN_5TH } },
}).toContainExactly([{ dob: JAN_1ST }])
})
it("greater than", async () => {
await expectQuery({
range: { dob: { low: JAN_5TH, high: MAX_VALID_DATE.toISOString() } },
}).toContainExactly([{ dob: JAN_10TH }])
})
it("less than equal to", async () => {
await expectQuery({
range: { dob: { high: JAN_1ST, low: MIN_VALID_DATE.toISOString() } },
}).toContainExactly([{ dob: JAN_1ST }])
})
it("less than", async () => {
await expectQuery({
range: { dob: { high: JAN_5TH, low: MIN_VALID_DATE.toISOString() } },
}).toContainExactly([{ dob: JAN_1ST }])
})
})
describe("sort", () => {

View file

@ -7,18 +7,11 @@ import { db as dbCore, context, utils } from "@budibase/backend-core"
import { getAutomationMetadataParams } from "../db/utils"
import { cloneDeep } from "lodash/fp"
import { quotas } from "@budibase/pro"
import {
Automation,
AutomationJob,
Webhook,
WebhookActionType,
} from "@budibase/types"
import sdk from "../sdk"
import { Automation, AutomationJob } from "@budibase/types"
import { automationsEnabled } from "../features"
import { helpers, REBOOT_CRON } from "@budibase/shared-core"
import tracer from "dd-trace"
const WH_STEP_ID = definitions.WEBHOOK.stepId
const CRON_STEP_ID = definitions.CRON.stepId
let Runner: Thread
if (automationsEnabled()) {
@ -229,76 +222,6 @@ export async function enableCronTrigger(appId: any, automation: Automation) {
return { enabled, automation }
}
/**
* This function handles checking if any webhooks need to be created or deleted for automations.
* @param appId The ID of the app in which we are checking for webhooks
* @param oldAuto The old automation object if updating/deleting
* @param newAuto The new automation object if creating/updating
* @returns After this is complete the new automation object may have been updated and should be
* written to DB (this does not write to DB as it would be wasteful to repeat).
*/
export async function checkForWebhooks({ oldAuto, newAuto }: any) {
const appId = context.getAppId()
if (!appId) {
throw new Error("Unable to check webhooks - no app ID in context.")
}
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
const newTrigger = newAuto ? newAuto.definition.trigger : null
const triggerChanged =
oldTrigger && newTrigger && oldTrigger.id !== newTrigger.id
function isWebhookTrigger(auto: any) {
return (
auto &&
auto.definition.trigger &&
auto.definition.trigger.stepId === WH_STEP_ID
)
}
// need to delete webhook
if (
isWebhookTrigger(oldAuto) &&
(!isWebhookTrigger(newAuto) || triggerChanged) &&
oldTrigger.webhookId
) {
try {
const db = context.getAppDB()
// need to get the webhook to get the rev
const webhook = await db.get<Webhook>(oldTrigger.webhookId)
// might be updating - reset the inputs to remove the URLs
if (newTrigger) {
delete newTrigger.webhookId
newTrigger.inputs = {}
}
await sdk.automations.webhook.destroy(webhook._id!, webhook._rev!)
} catch (err) {
// don't worry about not being able to delete, if it doesn't exist all good
}
}
// need to create webhook
if (
(!isWebhookTrigger(oldAuto) || triggerChanged) &&
isWebhookTrigger(newAuto)
) {
const webhook = await sdk.automations.webhook.save(
sdk.automations.webhook.newDoc(
"Automation webhook",
WebhookActionType.AUTOMATION,
newAuto._id
)
)
const id = webhook._id
newTrigger.webhookId = id
// the app ID has to be development for this endpoint
// it can only be used when building the app
// but the trigger endpoint will always be used in production
const prodAppId = dbCore.getProdAppID(appId)
newTrigger.inputs = {
schemaUrl: `api/webhooks/schema/${appId}/${id}`,
triggerUrl: `api/webhooks/trigger/${prodAppId}/${id}`,
}
}
return newAuto
}
/**
* When removing an app/unpublishing it need to make sure automations are cleaned up (cron).
* @param appId the app that is being removed.

View file

@ -349,3 +349,11 @@ export function isRelationshipColumn(
): column is RelationshipFieldMetadata {
return column.type === FieldType.LINK
}
/**
* Generates a new row actions ID.
* @returns The new row actions ID which the row actions doc can be stored under.
*/
export function generateRowActionsID(tableId: string) {
return `${DocumentType.ROW_ACTIONS}${SEPARATOR}${tableId}`
}

View file

@ -0,0 +1,248 @@
import { Automation, Webhook, WebhookActionType } from "@budibase/types"
import { generateAutomationID, getAutomationParams } from "../../../db/utils"
import { deleteEntityMetadata } from "../../../utilities"
import { MetadataTypes } from "../../../constants"
import {
context,
events,
HTTPError,
db as dbCore,
} from "@budibase/backend-core"
import { definitions } from "../../../automations/triggerInfo"
import automations from "."
function getDb() {
return context.getAppDB()
}
function cleanAutomationInputs(automation: Automation) {
if (automation == null) {
return automation
}
let steps = automation.definition.steps
let trigger = automation.definition.trigger
let allSteps = [...steps, trigger]
// live is not a property used anymore
if (automation.live != null) {
delete automation.live
}
for (let step of allSteps) {
if (step == null) {
continue
}
for (let inputName of Object.keys(step.inputs)) {
if (!step.inputs[inputName] || step.inputs[inputName] === "") {
delete step.inputs[inputName]
}
}
}
return automation
}
async function handleStepEvents(
oldAutomation: Automation,
automation: Automation
) {
const getNewSteps = (oldAutomation: Automation, automation: Automation) => {
const oldStepIds = oldAutomation.definition.steps.map(s => s.id)
return automation.definition.steps.filter(s => !oldStepIds.includes(s.id))
}
const getDeletedSteps = (
oldAutomation: Automation,
automation: Automation
) => {
const stepIds = automation.definition.steps.map(s => s.id)
return oldAutomation.definition.steps.filter(s => !stepIds.includes(s.id))
}
// new steps
const newSteps = getNewSteps(oldAutomation, automation)
for (let step of newSteps) {
await events.automation.stepCreated(automation, step)
}
// old steps
const deletedSteps = getDeletedSteps(oldAutomation, automation)
for (let step of deletedSteps) {
await events.automation.stepDeleted(automation, step)
}
}
export async function fetch() {
const db = getDb()
const response = await db.allDocs<Automation>(
getAutomationParams(null, {
include_docs: true,
})
)
return response.rows.map(row => row.doc)
}
export async function get(automationId: string) {
const db = getDb()
const result = await db.get<Automation>(automationId)
return result
}
export async function create(automation: Automation) {
automation = { ...automation }
const db = getDb()
// Respect existing IDs if recreating a deleted automation
if (!automation._id) {
automation._id = generateAutomationID()
}
automation.type = "automation"
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
newAuto: automation,
})
const response = await db.put(automation)
await events.automation.created(automation)
for (let step of automation.definition.steps) {
await events.automation.stepCreated(automation, step)
}
automation._rev = response.rev
automation._id = response.id
return automation
}
export async function update(automation: Automation) {
automation = { ...automation }
if (!automation._id || !automation._rev) {
throw new HTTPError("_id or _rev fields missing", 400)
}
const db = getDb()
const oldAutomation = await db.get<Automation>(automation._id)
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({
oldAuto: oldAutomation,
newAuto: automation,
})
const response = await db.put(automation)
automation._rev = response.rev
const oldAutoTrigger =
oldAutomation && oldAutomation.definition.trigger
? oldAutomation.definition.trigger
: undefined
const newAutoTrigger =
automation && automation.definition.trigger
? automation.definition.trigger
: undefined
// trigger has been updated, remove the test inputs
if (oldAutoTrigger && oldAutoTrigger.id !== newAutoTrigger?.id) {
await events.automation.triggerUpdated(automation)
await deleteEntityMetadata(
MetadataTypes.AUTOMATION_TEST_INPUT,
automation._id!
)
}
await handleStepEvents(oldAutomation, automation)
return {
...automation,
_rev: response.rev,
_id: response.id,
}
}
export async function remove(automationId: string, rev: string) {
const db = getDb()
const existing = await db.get<Automation>(automationId)
await checkForWebhooks({
oldAuto: existing,
})
// delete metadata first
await deleteEntityMetadata(MetadataTypes.AUTOMATION_TEST_INPUT, automationId)
await deleteEntityMetadata(
MetadataTypes.AUTOMATION_TEST_HISTORY,
automationId
)
const result = await db.remove(automationId, rev)
await events.automation.deleted(existing)
return result
}
/**
* This function handles checking if any webhooks need to be created or deleted for automations.
* @param appId The ID of the app in which we are checking for webhooks
* @param oldAuto The old automation object if updating/deleting
* @param newAuto The new automation object if creating/updating
* @returns After this is complete the new automation object may have been updated and should be
* written to DB (this does not write to DB as it would be wasteful to repeat).
*/
async function checkForWebhooks({ oldAuto, newAuto }: any) {
const WH_STEP_ID = definitions.WEBHOOK.stepId
const appId = context.getAppId()
if (!appId) {
throw new Error("Unable to check webhooks - no app ID in context.")
}
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
const newTrigger = newAuto ? newAuto.definition.trigger : null
const triggerChanged =
oldTrigger && newTrigger && oldTrigger.id !== newTrigger.id
function isWebhookTrigger(auto: any) {
return (
auto &&
auto.definition.trigger &&
auto.definition.trigger.stepId === WH_STEP_ID
)
}
// need to delete webhook
if (
isWebhookTrigger(oldAuto) &&
(!isWebhookTrigger(newAuto) || triggerChanged) &&
oldTrigger.webhookId
) {
try {
const db = getDb()
// need to get the webhook to get the rev
const webhook = await db.get<Webhook>(oldTrigger.webhookId)
// might be updating - reset the inputs to remove the URLs
if (newTrigger) {
delete newTrigger.webhookId
newTrigger.inputs = {}
}
await automations.webhook.destroy(webhook._id!, webhook._rev!)
} catch (err) {
// don't worry about not being able to delete, if it doesn't exist all good
}
}
// need to create webhook
if (
(!isWebhookTrigger(oldAuto) || triggerChanged) &&
isWebhookTrigger(newAuto)
) {
const webhook = await automations.webhook.save(
automations.webhook.newDoc(
"Automation webhook",
WebhookActionType.AUTOMATION,
newAuto._id
)
)
const id = webhook._id
newTrigger.webhookId = id
// the app ID has to be development for this endpoint
// it can only be used when building the app
// but the trigger endpoint will always be used in production
const prodAppId = dbCore.getProdAppID(appId)
newTrigger.inputs = {
schemaUrl: `api/webhooks/schema/${appId}/${id}`,
triggerUrl: `api/webhooks/trigger/${prodAppId}/${id}`,
}
}
return newAuto
}

View file

@ -1,7 +1,9 @@
import * as crud from "./crud"
import * as webhook from "./webhook"
import * as utils from "./utils"
export default {
...crud,
webhook,
utils,
}

View file

@ -0,0 +1,109 @@
import { context, HTTPError, utils } from "@budibase/backend-core"
import { generateRowActionsID } from "../../db/utils"
import {
SEPARATOR,
TableRowActions,
VirtualDocumentType,
} from "@budibase/types"
function ensureUniqueAndThrow(
doc: TableRowActions,
name: string,
existingRowActionId?: string
) {
if (
Object.entries(doc.actions).find(
([id, a]) =>
a.name.toLowerCase() === name.toLowerCase() &&
id !== existingRowActionId
)
) {
throw new HTTPError("A row action with the same name already exists.", 409)
}
}
export async function create(tableId: string, rowAction: { name: string }) {
const action = { name: rowAction.name.trim() }
const db = context.getAppDB()
const rowActionsId = generateRowActionsID(tableId)
let doc: TableRowActions
try {
doc = await db.get<TableRowActions>(rowActionsId)
} catch (e: any) {
if (e.status !== 404) {
throw e
}
doc = { _id: rowActionsId, actions: {} }
}
ensureUniqueAndThrow(doc, action.name)
const newId = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}`
doc.actions[newId] = action
await db.put(doc)
return {
id: newId,
...action,
}
}
export async function get(tableId: string) {
const db = context.getAppDB()
const rowActionsId = generateRowActionsID(tableId)
return await db.get<TableRowActions>(rowActionsId)
}
export async function docExists(tableId: string) {
const db = context.getAppDB()
const rowActionsId = generateRowActionsID(tableId)
const result = await db.exists(rowActionsId)
return result
}
export async function update(
tableId: string,
rowActionId: string,
rowAction: { name: string }
) {
const action = { name: rowAction.name.trim() }
const actionsDoc = await get(tableId)
if (!actionsDoc.actions[rowActionId]) {
throw new HTTPError(
`Row action '${rowActionId}' not found in '${tableId}'`,
400
)
}
ensureUniqueAndThrow(actionsDoc, action.name, rowActionId)
actionsDoc.actions[rowActionId] = action
const db = context.getAppDB()
await db.put(actionsDoc)
return {
id: rowActionId,
...action,
}
}
export async function remove(tableId: string, rowActionId: string) {
const actionsDoc = await get(tableId)
if (!actionsDoc.actions[rowActionId]) {
throw new HTTPError(
`Row action '${rowActionId}' not found in '${tableId}'`,
400
)
}
delete actionsDoc.actions[rowActionId]
const db = context.getAppDB()
await db.put(actionsDoc)
}

View file

@ -10,6 +10,7 @@ import { default as users } from "./users"
import { default as plugins } from "./plugins"
import * as views from "./app/views"
import * as permissions from "./app/permissions"
import * as rowActions from "./app/rowActions"
const sdk = {
backups,
@ -24,6 +25,7 @@ const sdk = {
views,
permissions,
links,
rowActions,
}
// default export for TS

View file

@ -13,6 +13,7 @@ import { UserAPI } from "./user"
import { QueryAPI } from "./query"
import { RoleAPI } from "./role"
import { TemplateAPI } from "./template"
import { RowActionAPI } from "./rowAction"
export default class API {
table: TableAPI
@ -29,6 +30,7 @@ export default class API {
query: QueryAPI
roles: RoleAPI
templates: TemplateAPI
rowAction: RowActionAPI
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
@ -45,5 +47,6 @@ export default class API {
this.query = new QueryAPI(config)
this.roles = new RoleAPI(config)
this.templates = new TemplateAPI(config)
this.rowAction = new RowActionAPI(config)
}
}

View file

@ -0,0 +1,73 @@
import {
CreateRowActionRequest,
RowActionResponse,
RowActionsResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class RowActionAPI extends TestAPI {
save = async (
tableId: string,
rowAction: CreateRowActionRequest,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._post<RowActionResponse>(
`/api/tables/${tableId}/actions`,
{
body: rowAction,
expectations: {
...expectations,
status: expectations?.status || 201,
},
...config,
}
)
}
find = async (
tableId: string,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._get<RowActionsResponse>(
`/api/tables/${tableId}/actions`,
{
expectations,
...config,
}
)
}
update = async (
tableId: string,
rowActionId: string,
rowAction: CreateRowActionRequest,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._put<RowActionResponse>(
`/api/tables/${tableId}/actions/${rowActionId}`,
{
body: rowAction,
expectations,
...config,
}
)
}
delete = async (
tableId: string,
rowActionId: string,
expectations?: Expectations,
config?: { publicUser?: boolean }
) => {
return await this._delete<RowActionResponse>(
`/api/tables/${tableId}/actions/${rowActionId}`,
{
expectations,
...config,
}
)
}
}

View file

@ -159,7 +159,7 @@ export function automationTrigger(
}
export function newAutomation({ steps, trigger }: any = {}) {
const automation: any = basicAutomation()
const automation = basicAutomation()
if (trigger) {
automation.definition.trigger = trigger

View file

@ -7,3 +7,4 @@ export * from "./table"
export * from "./permission"
export * from "./attachment"
export * from "./user"
export * from "./rowAction"

View file

@ -0,0 +1,14 @@
interface RowActionData {
name: string
}
export interface CreateRowActionRequest extends RowActionData {}
export interface UpdateRowActionRequest extends RowActionData {}
export interface RowActionResponse extends RowActionData {
id: string
tableId: string
}
export interface RowActionsResponse {
actions: Record<string, RowActionResponse>
}

View file

@ -16,3 +16,4 @@ export * from "./links"
export * from "./component"
export * from "./sqlite"
export * from "./snippet"
export * from "./rowAction"

View file

@ -0,0 +1,11 @@
import { Document } from "../document"
export interface TableRowActions extends Document {
_id: string
actions: Record<
string,
{
name: string
}
>
}

View file

@ -39,6 +39,7 @@ export enum DocumentType {
AUDIT_LOG = "al",
APP_MIGRATION_METADATA = "_design/migrations",
SCIM_LOG = "scimlog",
ROW_ACTIONS = "ra",
}
// these are the core documents that make up the data, design
@ -68,6 +69,7 @@ export enum InternalTable {
// documents or enriched into existence as part of get requests
export enum VirtualDocumentType {
VIEW = "view",
ROW_ACTION = "row_action",
}
export interface Document {

View file

@ -0,0 +1,31 @@
const nodemailer = require("nodemailer")
const options = {
port: 587,
host: "smtp.ethereal.email",
secure: false,
auth: {
user: "seamus99@ethereal.email",
pass: "5ghVteZAqj6jkKJF9R",
},
}
const transporter = nodemailer.createTransport(options)
transporter.verify(function (error) {
if (error) {
console.log(error)
} else {
console.log("Ethereal server is ready to take our messages")
}
})
const message = {
from: "from@example.com",
to: "to@example.com",
subject: "Did this email arrive?",
html: "Hello World!",
}
transporter.sendMail(message).then(response => {
console.log("Test email URL: " + nodemailer.getTestMessageUrl(response))
})