Merge pull request #11236 from Budibase/feature/delete-multiple-button-action

Delete multiple rows with Delete Row button action
This commit is contained in:
deanhannigan 2023-07-25 17:55:25 +01:00 committed by GitHub
commit f1d7789651
11 changed files with 273 additions and 65 deletions

View File

@ -491,6 +491,7 @@ const getSelectedRowsBindings = asset => {
readableBinding: `${table._instanceName}.Selected rows`, readableBinding: `${table._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
icon: "ViewRow", icon: "ViewRow",
display: { name: table._instanceName },
})) }))
) )
@ -506,6 +507,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`, )}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`, readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
display: { name: block._instanceName },
})) }))
) )
} }

View File

@ -206,6 +206,11 @@
return allBindings return allBindings
} }
const toDisplay = eventKey => {
const type = actionTypes.find(action => action.name == eventKey)
return type?.displayName || type?.name
}
</script> </script>
<DrawerContent> <DrawerContent>
@ -231,7 +236,9 @@
<ul> <ul>
{#each category as actionType} {#each category as actionType}
<li on:click={onAddAction(actionType)}> <li on:click={onAddAction(actionType)}>
<span class="action-name">{actionType.name}</span> <span class="action-name">
{actionType.displayName || actionType.name}
</span>
</li> </li>
{/each} {/each}
</ul> </ul>
@ -262,7 +269,7 @@
> >
<Icon name="DragHandle" size="XL" /> <Icon name="DragHandle" size="XL" />
<div class="action-header"> <div class="action-header">
{index + 1}.&nbsp;{action[EVENT_TYPE_KEY]} {index + 1}.&nbsp;{toDisplay(action[EVENT_TYPE_KEY])}
</div> </div>
<Icon <Icon
name="Close" name="Close"

View File

@ -1,5 +1,5 @@
<script> <script>
import { Select, Label, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -10,47 +10,59 @@
</script> </script>
<div class="root"> <div class="root">
<Label>Table</Label> <Body size="small">Please specify one or more rows to delete.</Body>
<Select <div class="params">
bind:value={parameters.tableId} <Label>Table</Label>
options={tableOptions} <Select
getOptionLabel={table => table.name} bind:value={parameters.tableId}
getOptionValue={table => table._id} options={tableOptions}
/> getOptionLabel={table => table.name}
getOptionValue={table => table._id}
<Label small>Row ID</Label>
<DrawerBindableInput
{bindings}
title="Row ID to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete this row?"
bind:value={parameters.confirmText}
/> />
{/if}
<Label small>Row IDs</Label>
<DrawerBindableInput
{bindings}
title="Rows to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
/>
{/if}
</div>
</div> </div>
<style> <style>
.root { .root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.params {
display: grid; display: grid;
column-gap: var(--spacing-l); column-gap: var(--spacing-l);
row-gap: var(--spacing-s); row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr; grid-template-columns: 60px 1fr;
align-items: center; align-items: center;
max-width: 800px;
margin: 0 auto;
} }
</style> </style>

View File

@ -24,6 +24,7 @@
}, },
{ {
"name": "Delete Row", "name": "Delete Row",
"displayName": "Delete Rows",
"type": "data", "type": "data",
"component": "DeleteRow" "component": "DeleteRow"
}, },

View File

@ -47,6 +47,14 @@
) )
} }
// If the data changes, double check that the selected elements are still present.
$: if (data) {
let rowIds = data.map(row => row._id)
if (rowIds.length) {
selectedRows = selectedRows.filter(row => rowIds.includes(row._id))
}
}
const getFields = (schema, customColumns, showAutoColumns) => { const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection // Check for an invalid column selection
let invalid = false let invalid = false

View File

@ -102,12 +102,46 @@ const fetchRowHandler = async action => {
} }
const deleteRowHandler = async action => { const deleteRowHandler = async action => {
const { tableId, revId, rowId, notificationOverride } = action.parameters const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
if (tableId && rowId) {
if (tableId && rowConfig) {
try { try {
await API.deleteRow({ tableId, rowId, revId }) let requestConfig
let parsedRowConfig = []
if (typeof rowConfig === "string") {
try {
parsedRowConfig = JSON.parse(rowConfig)
} catch (e) {
parsedRowConfig = rowConfig
.split(",")
.map(id => id.trim())
.filter(id => id)
}
} else {
parsedRowConfig = rowConfig
}
if (
typeof parsedRowConfig === "object" &&
parsedRowConfig.constructor === Object
) {
requestConfig = [parsedRowConfig]
} else if (Array.isArray(parsedRowConfig)) {
requestConfig = parsedRowConfig
}
if (!requestConfig.length) {
notificationStore.actions.warning("No valid rows were supplied")
return false
}
const resp = await API.deleteRows({ tableId, rows: requestConfig })
if (!notificationOverride) { if (!notificationOverride) {
notificationStore.actions.success("Row deleted") notificationStore.actions.success(
resp?.length == 1 ? "Row deleted" : `${resp.length} Rows deleted`
)
} }
// Refresh related datasources // Refresh related datasources
@ -115,8 +149,10 @@ const deleteRowHandler = async action => {
invalidateRelationships: true, invalidateRelationships: true,
}) })
} catch (error) { } catch (error) {
// Abort next actions console.error(error)
return false notificationStore.actions.error(
"An error occurred while executing the query"
)
} }
} }
} }

View File

@ -5,7 +5,7 @@ import { convertBookmark } from "../../../utilities"
// makes sure that the user doesn't need to pass in the type, tableId or _id params for // makes sure that the user doesn't need to pass in the type, tableId or _id params for
// the call to be correct // the call to be correct
function fixRow(row: Row, params: any) { export function fixRow(row: Row, params: any) {
if (!params || !row) { if (!params || !row) {
return row return row
} }

View File

@ -4,6 +4,11 @@ import * as external from "./external"
import { isExternalTable } from "../../../integrations/utils" import { isExternalTable } from "../../../integrations/utils"
import { import {
Ctx, Ctx,
UserCtx,
DeleteRowRequest,
DeleteRow,
DeleteRows,
Row,
SearchResponse, SearchResponse,
SortOrder, SortOrder,
SortType, SortType,
@ -11,6 +16,8 @@ import {
} from "@budibase/types" } from "@budibase/types"
import * as utils from "./utils" import * as utils from "./utils"
import { gridSocket } from "../../../websockets" import { gridSocket } from "../../../websockets"
import { addRev } from "../public/utils"
import { fixRow } from "../public/rows"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as exporters from "../view/exporters" import * as exporters from "../view/exporters"
import { apiFileReturn } from "../../../utilities/fileSystem" import { apiFileReturn } from "../../../utilities/fileSystem"
@ -104,35 +111,83 @@ export async function find(ctx: any) {
}) })
} }
export async function destroy(ctx: any) { function isDeleteRows(input: any): input is DeleteRows {
const appId = ctx.appId return input.rows !== undefined && Array.isArray(input.rows)
const inputs = ctx.request.body }
function isDeleteRow(input: any): input is DeleteRow {
return input._id !== undefined
}
async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
let request = ctx.request.body as DeleteRows
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
let response, row
if (inputs.rows) { const processedRows = request.rows.map(row => {
let { rows } = await quotas.addQuery<any>( let processedRow: Row = typeof row == "string" ? { _id: row } : row
() => pickApi(tableId).bulkDestroy(ctx), return !processedRow._rev
{ ? addRev(fixRow(processedRow, ctx.params), tableId)
datasourceId: tableId, : fixRow(processedRow, ctx.params)
} })
)
await quotas.removeRows(rows.length) return await Promise.all(processedRows)
response = rows }
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
gridSocket?.emitRowDeletion(ctx, row._id!) const tableId = utils.getTableId(ctx)
} const appId = ctx.appId
} else {
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), { let deleteRequest = ctx.request.body as DeleteRows
const rowDeletes: Row[] = await processDeleteRowsRequest(ctx)
deleteRequest.rows = rowDeletes
let { rows } = await quotas.addQuery<any>(
() => pickApi(tableId).bulkDestroy(ctx),
{
datasourceId: tableId, datasourceId: tableId,
}) }
await quotas.removeRow() )
response = resp.response await quotas.removeRows(rows.length)
row = resp.row
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
gridSocket?.emitRowDeletion(ctx, row._id!) gridSocket?.emitRowDeletion(ctx, row._id!)
} }
return rows
}
async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
const appId = ctx.appId
const tableId = utils.getTableId(ctx)
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), {
datasourceId: tableId,
})
await quotas.removeRow()
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
gridSocket?.emitRowDeletion(ctx, resp.row._id)
return resp
}
export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
let response, row
ctx.status = 200 ctx.status = 200
if (isDeleteRows(ctx.request.body)) {
response = await deleteRows(ctx)
} else if (isDeleteRow(ctx.request.body)) {
const deleteResp = await deleteRow(ctx)
response = deleteResp.response
row = deleteResp.row
} else {
ctx.status = 400
response = { message: "Invalid delete rows request" }
}
// for automations include the row that was deleted // for automations include the row that was deleted
ctx.row = row || {} ctx.row = row || {}
ctx.body = response ctx.body = response

View File

@ -519,6 +519,81 @@ describe("/rows", () => {
await assertRowUsage(rowUsage - 2) await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1) await assertQueryUsage(queryUsage + 1)
}) })
it("should be able to delete a variety of row set types", async () => {
const row1 = await config.createRow()
const row2 = await config.createRow()
const row3 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({
rows: [row1, row2._id, { _id: row3._id }],
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(3)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 3)
await assertQueryUsage(queryUsage + 1)
})
it("should accept a valid row object and delete the row", async () => {
const row1 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send(row1)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(row1._id)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
})
it("Should ignore malformed/invalid delete requests", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({ not: "valid" })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res.body.message).toEqual("Invalid delete rows request")
const res2 = await request
.delete(`/api/${table._id}/rows`)
.send({ rows: 123 })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res2.body.message).toEqual("Invalid delete rows request")
const res3 = await request
.delete(`/api/${table._id}/rows`)
.send("invalid")
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res3.body.message).toEqual("Invalid delete rows request")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
}) })
describe("fetchView", () => { describe("fetchView", () => {

View File

@ -1,5 +1,6 @@
export * from "./backup" export * from "./backup"
export * from "./datasource" export * from "./datasource"
export * from "./row"
export * from "./view" export * from "./view"
export * from "./rows" export * from "./rows"
export * from "./table" export * from "./table"

View File

@ -0,0 +1,11 @@
import { Row } from "../../../documents/app/row"
export interface DeleteRows {
rows: (Row | string)[]
}
export interface DeleteRow {
_id: string
}
export type DeleteRowRequest = DeleteRows | DeleteRow