1
0
Fork 0
mirror of synced 2024-05-16 18:33:53 +12:00
budibase/packages/client/src/utils/buttonActions.js
2024-04-12 12:52:02 +02:00

583 lines
17 KiB
JavaScript

import { get } from "svelte/store"
import download from "downloadjs"
import { downloadStream } from "@budibase/frontend-core"
import {
routeStore,
builderStore,
confirmationStore,
authStore,
stateStore,
notificationStore,
dataSourceStore,
uploadStore,
rowSelectionStore,
sidePanelStore,
} from "stores"
import { API } from "api"
import { ActionTypes } from "constants"
import { enrichDataBindings } from "./enrichDataBinding"
import { Helpers } from "@budibase/bbui"
// Default action handler, which extracts an action from context that was
// provided by another component and executes it with all action parameters
const contextActionHandler = async (action, context) => {
const key = getActionContextKey(action)
const fn = context[key]
if (fn) {
return await fn(action.parameters)
}
}
// Generates the context key, which is the key that this action depends on in
// context to provide the function it will run. This is broken out as a util
// because we reuse this inside the core Component.svelte file to determine
// what the required action context keys are for all action settings.
export const getActionContextKey = action => {
const type = action?.["##eventHandlerType"]
const key = (componentId, type) => `${componentId}_${type}`
switch (type) {
case "Scroll To Field":
return key(action.parameters.componentId, ActionTypes.ScrollTo)
case "Update Field Value":
return key(action.parameters.componentId, ActionTypes.UpdateFieldValue)
case "Validate Form":
return key(action.parameters.componentId, ActionTypes.ValidateForm)
case "Refresh Data Provider":
return key(action.parameters.componentId, ActionTypes.RefreshDatasource)
case "Clear Form":
return key(action.parameters.componentId, ActionTypes.ClearForm)
case "Change Form Step":
return key(action.parameters.componentId, ActionTypes.ChangeFormStep)
case "Clear Row Selection":
return key(action.parameters.componentId, ActionTypes.ClearRowSelection)
default:
return null
}
}
// If button actions depend on context, they must declare which keys they need
export const getActionDependentContextKeys = action => {
const type = action?.["##eventHandlerType"]
switch (type) {
case "Save Row":
case "Duplicate Row":
if (action.parameters?.providerId) {
return [action.parameters.providerId]
}
}
return []
}
const saveRowHandler = async (action, context) => {
const { fields, providerId, tableId, notificationOverride } =
action.parameters
let payload
if (providerId) {
payload = { ...context[providerId] }
} else {
payload = {}
}
if (fields) {
for (let [field, value] of Object.entries(fields)) {
Helpers.deepSet(payload, field, value)
}
}
if (tableId) {
if (tableId.startsWith("view")) {
payload._viewId = tableId
} else {
payload.tableId = tableId
}
}
try {
const row = await API.saveRow(payload)
if (!notificationOverride) {
notificationStore.actions.success("Row saved")
}
// Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true,
})
return { row }
} catch (error) {
// Abort next actions
return false
}
}
const duplicateRowHandler = async (action, context) => {
const { fields, providerId, tableId, notificationOverride } =
action.parameters
if (providerId) {
let payload = { ...context[providerId] }
if (fields) {
for (let [field, value] of Object.entries(fields)) {
Helpers.deepSet(payload, field, value)
}
}
if (tableId) {
if (tableId.startsWith("view")) {
payload._viewId = tableId
} else {
payload.tableId = tableId
}
}
delete payload._id
delete payload._rev
try {
const row = await API.saveRow(payload)
if (!notificationOverride) {
notificationStore.actions.success("Row saved")
}
// Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true,
})
return { row }
} catch (error) {
// Abort next actions
return false
}
}
}
const fetchRowHandler = async action => {
const { tableId, rowId } = action.parameters
if (tableId && rowId) {
try {
const row = await API.fetchRow({ tableId, rowId })
return { row }
} catch (error) {
return false
}
}
}
const deleteRowHandler = async action => {
const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
if (tableId && rowConfig) {
try {
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
} else if (Number.isInteger(parsedRowConfig)) {
requestConfig = [String(parsedRowConfig)]
}
if (!requestConfig && !parsedRowConfig) {
notificationStore.actions.warning("No valid rows were supplied")
return false
}
const resp = await API.deleteRows({ tableId, rows: requestConfig })
if (!notificationOverride) {
notificationStore.actions.success(
resp?.length == 1 ? "Row deleted" : `${resp.length} Rows deleted`
)
}
// Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true,
})
} catch (error) {
console.error(error)
notificationStore.actions.error(
"An error occurred while executing the query"
)
}
}
}
const triggerAutomationHandler = async action => {
const { fields, notificationOverride, timeout } = action.parameters
try {
const result = await API.triggerAutomation({
automationId: action.parameters.automationId,
fields,
timeout,
})
// Value will exist if automation is synchronous, so return it.
if (result.value) {
if (!notificationOverride) {
notificationStore.actions.success("Automation ran successfully")
}
return { result }
}
if (!notificationOverride) {
notificationStore.actions.success("Automation triggered")
}
} catch (error) {
// Abort next actions
return false
}
}
const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters
routeStore.actions.navigate(url, peek, externalNewTab)
}
const queryExecutionHandler = async action => {
const { datasourceId, queryId, queryParams, notificationOverride } =
action.parameters
try {
const query = await API.fetchQueryDefinition(queryId)
if (query?.datasourceId == null) {
notificationStore.actions.error("That query couldn't be found")
return false
}
const result = await API.executeQuery({
datasourceId,
queryId,
parameters: queryParams,
})
// Trigger a notification and invalidate the datasource as long as this
// was not a readable query
if (!query.readable) {
if (!notificationOverride) {
notificationStore.actions.success("Query executed successfully")
}
await dataSourceStore.actions.invalidateDataSource(query.datasourceId)
}
return { result }
} catch (error) {
notificationStore.actions.error(
"An error occurred while executing the query"
)
// Abort next actions
return false
}
}
const logoutHandler = async action => {
await authStore.actions.logOut()
let redirectUrl = "/builder/auth/login"
let internal = false
if (action.parameters.redirectUrl) {
internal = action.parameters.redirectUrl?.startsWith("/")
redirectUrl = routeStore.actions.createFullURL(
action.parameters.redirectUrl
)
}
window.location.href = redirectUrl
if (internal) {
window.location.reload()
}
}
const closeScreenModalHandler = action => {
let url
if (action?.parameters) {
url = action.parameters.url
}
// Emit this as a window event, so parent screens which are iframing us in
// can close the modal
window.parent.postMessage({ type: "close-screen-modal", url })
}
const updateStateHandler = action => {
const { type, key, value, persist } = action.parameters
if (type === "set") {
stateStore.actions.setValue(key, value, persist)
} else if (type === "delete") {
stateStore.actions.deleteValue(key)
}
// Emit this as an event so that parent windows which are iframing us in
// can also update their state
if (get(routeStore).queryParams?.peek) {
window.parent.postMessage({
type: "update-state",
detail: { type, key, value, persist },
})
}
}
const s3UploadHandler = async action => {
const { componentId } = action.parameters
if (!componentId) {
return
}
const res = await uploadStore.actions.processFileUpload(componentId)
return {
publicUrl: res?.publicUrl,
}
}
const exportDataHandler = async action => {
let selection = rowSelectionStore.actions.getSelection(
action.parameters.tableComponentId
)
if (selection.selectedRows && selection.selectedRows.length > 0) {
try {
const data = await API.exportRows({
tableId: selection.tableId,
rows: selection.selectedRows,
format: action.parameters.type,
columns: action.parameters.columns?.map(
column => column.name || column
),
delimiter: action.parameters.delimiter,
customHeaders: action.parameters.customHeaders,
})
download(
new Blob([data], { type: "text/plain" }),
`${selection.tableId}.${action.parameters.type}`
)
} catch (error) {
notificationStore.actions.error("There was an error exporting the data")
}
} else {
notificationStore.actions.error("Please select at least one row")
}
}
const continueIfHandler = action => {
const { type, value, operator, referenceValue } = action.parameters
if (!type || !operator) {
return
}
let match = false
if (value == null && referenceValue == null) {
match = true
} else if (value === referenceValue) {
match = true
} else {
match = JSON.stringify(value) === JSON.stringify(referenceValue)
}
if (type === "continue") {
return operator === "equal" ? match : !match
} else {
return operator === "equal" ? !match : match
}
}
const showNotificationHandler = action => {
const { message, type, autoDismiss } = action.parameters
if (!message || !type) {
return
}
notificationStore.actions[type]?.(message, autoDismiss)
}
const promptUserHandler = () => {}
const openSidePanelHandler = action => {
const { id } = action.parameters
if (id) {
sidePanelStore.actions.open(id)
}
}
const closeSidePanelHandler = () => {
sidePanelStore.actions.close()
}
const downloadFileHandler = async action => {
const { url, fileName } = action.parameters
try {
const { type } = action.parameters
if (type === "attachment") {
const { tableId, rowId, attachmentColumn } = action.parameters
const res = await API.downloadAttachment(
tableId,
rowId,
attachmentColumn,
{ suppressErrors: true }
)
await downloadStream(res)
return
}
const response = await fetch(url)
if (!response.ok) {
notificationStore.actions.error(
`Failed to download from '${url}'. Server returned status code: ${response.status}`
)
return
}
const objectUrl = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = objectUrl
link.download = fileName
link.click()
URL.revokeObjectURL(objectUrl)
} catch (e) {
console.error(e)
if (e.status) {
notificationStore.actions.error(
`Failed to download from '${url}'. Server returned status code: ${e.status}`
)
} else {
notificationStore.actions.error(`Failed to download from '${url}'.`)
}
}
}
const handlerMap = {
["Fetch Row"]: fetchRowHandler,
["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
["Execute Query"]: queryExecutionHandler,
["Trigger Automation"]: triggerAutomationHandler,
["Log Out"]: logoutHandler,
["Close Screen Modal"]: closeScreenModalHandler,
["Update State"]: updateStateHandler,
["Upload File to S3"]: s3UploadHandler,
["Export Data"]: exportDataHandler,
["Continue if / Stop if"]: continueIfHandler,
["Show Notification"]: showNotificationHandler,
["Prompt User"]: promptUserHandler,
["Open Side Panel"]: openSidePanelHandler,
["Close Side Panel"]: closeSidePanelHandler,
["Download File"]: downloadFileHandler,
}
const confirmTextMap = {
["Delete Row"]: "Are you sure you want to delete this row?",
["Save Row"]: "Are you sure you want to save this row?",
["Execute Query"]: "Are you sure you want to execute this query?",
["Trigger Automation"]: "Are you sure you want to trigger this automation?",
["Prompt User"]: "Are you sure you want to continue?",
}
/**
* Parses an array of actions and returns a function which will execute the
* actions in the current context.
* A handler returning `false` is a flag to stop execution of handlers
*/
export const enrichButtonActions = (actions, context) => {
// Prevent button actions in the builder preview
if (!actions?.length || get(builderStore).inBuilder) {
return null
}
// If this is a function then it has already been enriched
if (typeof actions === "function") {
return actions
}
// Get handlers for each action. If no bespoke handler is configured, fall
// back to simply executing this action from context.
const handlers = actions.map(def => {
return handlerMap[def["##eventHandlerType"]] || contextActionHandler
})
return async eventContext => {
// Button context is built up as actions are executed.
// Inherit any previous button context which may have come from actions
// before a confirmable action since this breaks the chain.
let buttonContext = context.actions || []
for (let i = 0; i < handlers.length; i++) {
try {
// Skip any non-existent action definitions
if (!handlers[i]) {
continue
}
// Built total context for this action
const totalContext = {
...context,
state: get(stateStore),
actions: buttonContext,
eventContext,
}
// Get and enrich this button action with the total context
let action = actions[i]
action = enrichDataBindings(action, totalContext)
const callback = async () => handlers[i](action, totalContext)
// If this action is confirmable, show confirmation and await a
// callback to execute further actions
if (action.parameters?.confirm) {
return new Promise(resolve => {
const defaultText = confirmTextMap[action["##eventHandlerType"]]
const confirmText = action.parameters?.confirmText || defaultText
const defaultTitleText = action["##eventHandlerType"]
const customTitleText =
action.parameters?.customTitleText || defaultTitleText
confirmationStore.actions.showConfirmation(
customTitleText,
confirmText,
async () => {
// When confirmed, execute this action immediately,
// then execute the rest of the actions in the chain
const result = await callback()
if (result !== false) {
// Generate a new total context to pass into the next enrichment
buttonContext.push(result)
const newContext = { ...context, actions: buttonContext }
// Enrich and call the next button action if there is more than one action remaining
const next = enrichButtonActions(
actions.slice(i + 1),
newContext
)
resolve(typeof next === "function" ? await next() : true)
} else {
resolve(false)
}
},
() => {
resolve(false)
}
)
})
}
// For non-confirmable actions, execute the handler immediately
else {
const result = await callback()
if (result === false) {
return
} else {
buttonContext.push(result)
}
}
} catch (error) {
console.error("Error while executing button handler")
console.error(error)
// Stop executing further actions on error
return
}
}
}
}