Files
nocodb/packages/nc-gui/components/virtual-cell/Button.vue
2026-03-18 06:00:22 +00:00

434 lines
12 KiB
Vue

<script setup lang="ts">
import { ButtonActionsType, type ButtonType, type ColumnType, type FilterType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { validateRowFilters } from '~/utils/dataUtils'
const column = inject(ColumnInj) as Ref<
ColumnType & {
colOptions: ButtonType
}
>
const cellValue = inject(CellValueInj, ref())
const { currentRow, displayValue, changedColumns } = useSmartsheetRowStoreOrThrow()
const { generateRows, generatingRows, generatingColumnRows, generatingColumns, aiIntegrations } = useNocoAi()
const { appInfo } = useGlobal()
const { getColor } = useTheme()
const meta = inject(MetaInj, ref())
const isGrid = inject(IsGridInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const { isUIAllowed } = useRoles()
const isPublic = inject(IsPublicInj, ref(false))
const { $api } = useNuxtApp()
const { t } = useI18n()
const rowId = computed(() => {
return extractPkFromRow(currentRow.value?.row, meta.value!.columns!)
})
const { runScript, activeExecutions, fieldIDRowMapping } = useScriptExecutor()
const { addScriptExecution } = useActionPane()
const scriptStore = useScriptStore()
const { loadScript } = scriptStore
const isLoading = ref(false)
const pk = computed(() => {
if (!meta.value?.columns) return
return extractPkFromRow(currentRow.value?.row, meta.value.columns)
})
const isAiButtonType = computed(() => column.value?.colOptions?.type === ButtonActionsType.Ai)
const isFieldAiIntegrationAvailable = computed(() => {
if (!isAiButtonType.value) return true
const fkIntegrationId = column.value?.colOptions?.fk_integration_id
return !!fkIntegrationId
})
const generate = async () => {
if (!meta?.value?.id || !meta.value.columns || !column?.value?.id) return
if (!pk.value) return
const outputColumnIds =
ncIsString(column.value.colOptions?.output_column_ids) && column.value.colOptions.output_column_ids.split(',').length > 0
? column.value.colOptions.output_column_ids.split(',')
: []
const outputColumns = outputColumnIds.map((id) => meta.value?.columnsById[id])
generatingRows.value.push(pk.value)
generatingColumnRows.value.push(column.value.id)
generatingColumns.value.push(...(outputColumnIds ?? []))
// In expanded form get preview data and update local state so that expanded form save btn works properly
const res = await generateRows(meta.value.id, column.value.id, [pk.value], false, isExpandedForm.value)
if (res?.length) {
const resRow = res[0]
if (outputColumnIds) {
for (const col of outputColumns) {
if (col && currentRow.value.row) {
changedColumns.value.add(col.title!)
currentRow.value.row[col.title!] = resRow[col.title!]
}
}
}
}
generatingRows.value = generatingRows.value.filter((v) => v !== pk.value)
generatingColumnRows.value = generatingColumnRows.value.filter((v) => v !== column.value.id)
generatingColumns.value = generatingColumns.value.filter((v) => !outputColumnIds?.includes(v))
}
const isExecutingId = ref('')
const isExecuting = computed(
() =>
activeExecutions.value.get(isExecutingId.value)?.status === 'running' ||
fieldIDRowMapping.value.get(`${pk.value}:${column.value.id}`) === 'running',
)
const invalidUrlTooltip = ref('')
const baseStore = useBase()
const { getBaseType } = baseStore
const { metas } = useMetas()
const { user } = useGlobal()
const isFilterConditionMet = computed(() => {
const filters = column.value.colOptions?.filters as FilterType[] | undefined
if (!filters || !filters.length) return true
const rowData = currentRow.value?.row
if (!rowData) return true
const columns = meta.value?.columns as ColumnType[]
if (!columns) return true
return validateRowFilters(filters, rowData, columns, getBaseType(meta.value?.source_id), metas.value, meta.value?.base_id, {
currentUser: user.value?.id ? { id: user.value.id, email: user.value.email } : undefined,
})
})
const filterDisabledTooltip = computed(() => {
if (isFilterConditionMet.value) return ''
return t('msg.buttonConditionNotMet')
})
const componentProps = computed(() => {
const filterDisabled = !isFilterConditionMet.value
if (column.value.colOptions.type === ButtonActionsType.Url) {
let url = addMissingUrlSchma(cellValue.value?.url)
// if url params not encoded, encode them using encodeURI
try {
url = decodeURI(url) === url ? encodeURI(url) : url
} catch {
url = encodeURI(url)
}
const isValidUrl = isValidURL(url, { require_tld: !appInfo.value?.allowLocalUrl })
invalidUrlTooltip.value = !isValidUrl ? t('msg.error.invalidURL') : ''
return {
href: url,
target: '_blank',
...(column.value?.colOptions.error || !isValidUrl || filterDisabled ? { disabled: true } : {}),
}
} else if (column.value.colOptions.type === ButtonActionsType.Webhook) {
return {
disabled:
filterDisabled ||
isPublic.value ||
!isUIAllowed('hookTrigger') ||
isLoading.value ||
!column.value.colOptions.fk_webhook_id ||
!cellValue.value?.fk_webhook_id,
}
} else if (column.value.colOptions.type === ButtonActionsType.Script) {
return {
disabled: filterDisabled || isPublic.value || isExecuting.value || !isUIAllowed('dataEdit') || isLoading.value,
}
} else if (column.value.colOptions.type === ButtonActionsType.Ai) {
return {
disabled:
filterDisabled ||
isPublic.value ||
!isUIAllowed('dataEdit') ||
!isFieldAiIntegrationAvailable.value ||
isLoading.value ||
(pk.value &&
generatingRows.value.includes(pk.value) &&
column.value?.id &&
generatingColumnRows.value.includes(column.value.id)),
}
}
// If button type is missing then keep it as disabled
if (!column.value.colOptions.type) {
return {
disabled: true,
}
}
return filterDisabled ? { disabled: true } : {}
})
const buttonColors = computed(() => {
return getButtonColorsCssVariables(column.value.colOptions.theme ?? 'solid', column.value.colOptions.color ?? 'brand', getColor)
})
const afterActionStatus = ref<{
status: 'success' | 'error'
tooltip?: string
} | null>(null)
const triggerAction = async () => {
const colOptions = column.value.colOptions
afterActionStatus.value = null
if (!colOptions.type) return
if (colOptions.type === ButtonActionsType.Url) {
confirmPageLeavingRedirect(componentProps.value?.href, componentProps.value?.target, appInfo.value?.allowLocalUrl)
} else if (colOptions.type === ButtonActionsType.Webhook) {
try {
isLoading.value = true
await $api.internal.postOperation(
meta.value!.fk_workspace_id!,
meta.value!.base_id!,
{
operation: 'hookTrigger',
hookId: cellValue.value?.fk_webhook_id,
rowId: rowId!.value,
},
{},
)
afterActionStatus.value = { status: 'success' }
ncDelay(2000).then(() => {
afterActionStatus.value = null
})
} catch (e: any) {
console.log(e)
const errorMsg = await extractSdkResponseErrorMsg(e)
message.error(errorMsg)
afterActionStatus.value = { status: 'error', tooltip: errorMsg }
ncDelay(3000).then(() => {
afterActionStatus.value = null
})
} finally {
isLoading.value = false
}
} else if (colOptions.type === ButtonActionsType.Ai) {
await generate()
} else if (colOptions.type === ButtonActionsType.Script) {
try {
isLoading.value = true
const script = await loadScript(colOptions.fk_script_id)
if (!script) {
throw new Error('Script not found')
}
const rowId = pk.value
const scriptExecutionId = await runScript(script, currentRow.value.row, {
pk: rowId!,
fieldId: column.value.id!,
executionId: `${rowId}-${column.value.id!}-${Date.now()}`,
})
addScriptExecution(scriptExecutionId, {
recordId: rowId as string,
displayValue: displayValue.value,
scriptId: script?.id as string,
scriptName: script?.title || 'Untitled Script',
buttonFieldName: column.value.title || 'Button',
})
isExecutingId.value = scriptExecutionId
afterActionStatus.value = { status: 'success' }
ncDelay(2000).then(() => {
afterActionStatus.value = null
})
} catch (e: any) {
console.log(e)
const errorMsg = await extractSdkResponseErrorMsg(e)
message.error(errorMsg)
afterActionStatus.value = { status: 'error', tooltip: errorMsg }
ncDelay(3000).then(() => {
afterActionStatus.value = null
})
} finally {
isLoading.value = false
}
}
}
</script>
<template>
<div
:class="{
'justify-center': isGrid && !isExpandedForm,
}"
class="w-full flex items-center"
>
<NcTooltip
:disabled="
isAiButtonType
? (isFieldAiIntegrationAvailable || isPublic || !isUIAllowed('dataEdit')) && !filterDisabledTooltip
: !invalidUrlTooltip && !afterActionStatus?.tooltip && !filterDisabledTooltip
"
class="flex"
>
<template #title>
{{
filterDisabledTooltip
? filterDisabledTooltip
: isAiButtonType
? aiIntegrations.length
? $t('tooltip.aiIntegrationReConfigure')
: $t('tooltip.aiIntegrationAddAndReConfigure')
: afterActionStatus?.tooltip || invalidUrlTooltip
}}
</template>
<component
:is="column.colOptions.type === ButtonActionsType.Url ? 'a' : 'button'"
v-bind="componentProps"
data-testid="nc-button-cell"
:class="[
`${column.colOptions.color ?? 'brand'} ${column.colOptions.theme ?? 'solid'}`,
{ '!w-6': !column.colOptions.label, 'disabled': componentProps.disabled, 'is-expanded-form': isExpandedForm },
]"
class="nc-cell-button nc-button-cell-link btn-cell-colors truncate flex items-center"
:style="buttonColors"
@click.prevent="triggerAction"
>
<GeneralIcon
v-if="afterActionStatus"
:icon="afterActionStatus.status === 'success' ? 'ncCheck' : 'ncInfo'"
class="w-4 h-4 flex-none text-current"
/>
<GeneralLoader
v-else-if="
isLoading ||
isExecuting ||
(pk && generatingRows.includes(pk) && column?.id && generatingColumnRows.includes(column.id))
"
class="flex w-4 h-4 !text-current"
size="medium"
/>
<GeneralIcon v-else-if="column.colOptions.icon" :icon="column.colOptions.icon" class="!w-4 min-w-4 min-h-4 !h-4" />
<NcTooltip v-if="column.colOptions.label" class="!truncate" show-on-truncate-only>
<span class="truncate font-medium" :class="{ 'text-sm': isExpandedForm, 'text-[13px]': !isExpandedForm }">
{{ column.colOptions.label }}
</span>
<template #title>
{{ column.colOptions.label }}
</template>
</NcTooltip>
</component>
</NcTooltip>
</div>
</template>
<style lang="scss">
.nc-data-cell {
&:has(.nc-virtual-cell-button) {
@apply !border-none;
box-shadow: none !important;
&:focus-within:not(.nc-readonly-div-data-cell):not(.nc-system-field) {
box-shadow: none !important;
}
}
&:has(.nc-cell-button.is-expanded-form) {
@apply -mt-1 -ml-1;
}
.nc-cell-attachment {
@apply !border-none;
}
}
.nc-button-cell-link {
@apply !no-underline;
}
</style>
<style scoped lang="scss">
.nc-cell-button {
@apply px-2 flex items-center gap-2 transition-all justify-center;
&:not([class*='text']) {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
&:focus-within {
@apply outline-none ring-0 shadow-focus;
}
&[disabled] {
@apply opacity-50 cursor-not-allowed;
}
&.is-expanded-form {
@apply h-8 rounded-lg my-1;
}
&:not(.is-expanded-form) {
@apply h-6 rounded-md;
}
}
.btn-cell-colors {
color: var(--btn-cell-text);
background: var(--btn-cell-bg);
&:hover {
background: var(--btn-cell-bg-hover);
color: var(--btn-cell-text-hover);
}
&.disabled,
&[disabled] {
@apply cursor-not-allowed opacity-60;
background: var(--btn-cell-disabled-bg);
color: var(--btn-cell-disabled-text);
}
&.light {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
}
</style>