|
|
|
<script setup lang="ts">
|
|
|
|
import type { Ref } from 'vue'
|
|
|
|
import type { ListItem as AntListItem } from 'ant-design-vue'
|
|
|
|
import jsep from 'jsep'
|
|
|
|
import {
|
|
|
|
FormulaError,
|
|
|
|
UITypes,
|
|
|
|
isHiddenCol,
|
|
|
|
jsepCurlyHook,
|
|
|
|
substituteColumnIdWithAliasInFormula,
|
|
|
|
validateFormulaAndExtractTreeWithType,
|
|
|
|
} from 'nocodb-sdk'
|
|
|
|
import type { ColumnType, FormulaType } from 'nocodb-sdk'
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
value: any
|
|
|
|
}>()
|
|
|
|
|
|
|
|
const emit = defineEmits(['update:value'])
|
|
|
|
|
|
|
|
const uiTypesNotSupportedInFormulas = [UITypes.QrCode, UITypes.Barcode]
|
|
|
|
|
|
|
|
const vModel = useVModel(props, 'value', emit)
|
|
|
|
|
|
|
|
const { setAdditionalValidations, validateInfos, sqlUi, column } = useColumnCreateStoreOrThrow()
|
|
|
|
|
|
|
|
const { t } = useI18n()
|
|
|
|
|
|
|
|
const { predictFunction: _predictFunction } = useNocoEe()
|
|
|
|
|
|
|
|
const meta = inject(MetaInj, ref())
|
|
|
|
|
|
|
|
const supportedColumns = computed(
|
|
|
|
() =>
|
|
|
|
meta?.value?.columns?.filter((col) => {
|
|
|
|
if (uiTypesNotSupportedInFormulas.includes(col.uidt as UITypes)) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isHiddenCol(col)) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
return true
|
|
|
|
}) || [],
|
|
|
|
)
|
|
|
|
const { getMeta } = useMetas()
|
|
|
|
|
|
|
|
const suggestionPreviewed = ref<Record<any, string> | undefined>()
|
|
|
|
|
|
|
|
const validators = {
|
|
|
|
formula_raw: [
|
|
|
|
{
|
|
|
|
validator: (_: any, formula: any) => {
|
|
|
|
return (async () => {
|
|
|
|
if (!formula?.trim()) throw new Error('Required')
|
|
|
|
|
|
|
|
try {
|
|
|
|
await validateFormulaAndExtractTreeWithType({
|
|
|
|
column: column.value,
|
|
|
|
formula,
|
|
|
|
columns: supportedColumns.value,
|
|
|
|
clientOrSqlUi: sqlUi.value,
|
|
|
|
getMeta,
|
|
|
|
})
|
|
|
|
} catch (e: any) {
|
|
|
|
if (e instanceof FormulaError && e.extra?.key) {
|
|
|
|
throw new Error(t(e.extra.key, e.extra))
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new Error(e.message)
|
|
|
|
}
|
|
|
|
})()
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
|
|
|
}
|
|
|
|
|
|
|
|
const availableFunctions = formulaList
|
|
|
|
|
|
|
|
const availableBinOps = ['+', '-', '*', '/', '>', '<', '==', '<=', '>=', '!=', '&']
|
|
|
|
|
|
|
|
const autocomplete = ref(false)
|
|
|
|
|
|
|
|
const formulaRef = ref()
|
|
|
|
|
|
|
|
const sugListRef = ref()
|
|
|
|
|
|
|
|
const variableListRef = ref<(typeof AntListItem)[]>([])
|
|
|
|
|
|
|
|
const sugOptionsRef = ref<(typeof AntListItem)[]>([])
|
|
|
|
|
|
|
|
const wordToComplete = ref<string | undefined>('')
|
|
|
|
|
|
|
|
const selected = ref(0)
|
|
|
|
|
|
|
|
const sortOrder: Record<string, number> = {
|
|
|
|
column: 0,
|
|
|
|
function: 1,
|
|
|
|
op: 2,
|
|
|
|
}
|
|
|
|
|
|
|
|
const suggestionsList = computed(() => {
|
|
|
|
const unsupportedFnList = sqlUi.value.getUnsupportedFnList()
|
|
|
|
return (
|
|
|
|
[
|
|
|
|
...availableFunctions.map((fn: string) => ({
|
|
|
|
text: `${fn}()`,
|
|
|
|
type: 'function',
|
|
|
|
description: formulas[fn].description,
|
|
|
|
syntax: formulas[fn].syntax,
|
|
|
|
examples: formulas[fn].examples,
|
|
|
|
docsUrl: formulas[fn].docsUrl,
|
|
|
|
unsupported: unsupportedFnList.includes(fn),
|
|
|
|
})),
|
|
|
|
...supportedColumns.value
|
|
|
|
.filter((c) => {
|
|
|
|
// skip system LTAR columns
|
|
|
|
if (c.uidt === UITypes.LinkToAnotherRecord && c.system) return false
|
|
|
|
// v1 logic? skip the current column
|
|
|
|
if (!column) return true
|
|
|
|
return column.value?.id !== c.id
|
|
|
|
})
|
|
|
|
.map((c: any) => ({
|
|
|
|
text: c.title,
|
|
|
|
type: 'column',
|
|
|
|
icon: getUIDTIcon(c.uidt),
|
|
|
|
uidt: c.uidt,
|
|
|
|
})),
|
|
|
|
...availableBinOps.map((op: string) => ({
|
|
|
|
text: op,
|
|
|
|
type: 'op',
|
|
|
|
})),
|
|
|
|
]
|
|
|
|
// move unsupported functions to the end
|
|
|
|
.sort((a: Record<string, any>, b: Record<string, any>) => {
|
|
|
|
if (a.unsupported && !b.unsupported) {
|
|
|
|
return 1
|
|
|
|
}
|
|
|
|
if (!a.unsupported && b.unsupported) {
|
|
|
|
return -1
|
|
|
|
}
|
|
|
|
return 0
|
|
|
|
})
|
|
|
|
)
|
|
|
|
})
|
|
|
|
|
|
|
|
// set default suggestion list
|
|
|
|
const suggestion: Ref<Record<string, any>[]> = ref(suggestionsList.value)
|
|
|
|
|
|
|
|
const acTree = computed(() => {
|
|
|
|
const ref = new NcAutocompleteTree()
|
|
|
|
for (const sug of suggestionsList.value) {
|
|
|
|
ref.add(sug)
|
|
|
|
}
|
|
|
|
return ref
|
|
|
|
})
|
|
|
|
|
|
|
|
const suggestedFormulas = computed(() => {
|
|
|
|
return suggestion.value.filter((s) => s && s.type !== 'column')
|
|
|
|
})
|
|
|
|
|
|
|
|
const variableList = computed(() => {
|
|
|
|
return suggestion.value.filter((s) => s && s.type === 'column')
|
|
|
|
})
|
|
|
|
|
|
|
|
function isCurlyBracketBalanced() {
|
|
|
|
// count number of opening curly brackets and closing curly brackets
|
|
|
|
const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce(
|
|
|
|
(acc: Record<number, number>, cur: number) => {
|
|
|
|
acc[cur] = (acc[cur] || 0) + 1
|
|
|
|
return acc
|
|
|
|
},
|
|
|
|
{},
|
|
|
|
)
|
|
|
|
return (cntCurlyBrackets['{'] || 0) === (cntCurlyBrackets['}'] || 0)
|
|
|
|
}
|
|
|
|
|
|
|
|
function appendText(item: Record<string, any>) {
|
|
|
|
const text = item.text
|
|
|
|
const len = wordToComplete.value?.length || 0
|
|
|
|
|
|
|
|
if (item.type === 'function') {
|
|
|
|
vModel.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len, 1)
|
|
|
|
} else if (item.type === 'column') {
|
|
|
|
vModel.value.formula_raw = insertAtCursor(formulaRef.value.$el, `{${text}}`, len + +!isCurlyBracketBalanced())
|
|
|
|
} else {
|
|
|
|
vModel.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len)
|
|
|
|
}
|
|
|
|
autocomplete.value = false
|
|
|
|
wordToComplete.value = ''
|
|
|
|
if (item.type === 'function' || item.type === 'op') {
|
|
|
|
// if function / operator is chosen, display columns only
|
|
|
|
suggestion.value = suggestionsList.value.filter((f) => f.type === 'column')
|
|
|
|
} else {
|
|
|
|
// show all options if column is chosen
|
|
|
|
suggestion.value = suggestionsList.value
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const handleInputDeb = useDebounceFn(function () {
|
|
|
|
handleInput()
|
|
|
|
}, 250)
|
|
|
|
|
|
|
|
function handleInput() {
|
|
|
|
selected.value = 0
|
|
|
|
suggestion.value = []
|
|
|
|
const query = getWordUntilCaret(formulaRef.value.$el)
|
|
|
|
const parts = query.split(/\W+/)
|
|
|
|
wordToComplete.value = parts.pop() || ''
|
|
|
|
suggestion.value = acTree.value
|
|
|
|
.complete(wordToComplete.value)
|
|
|
|
?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type])
|
|
|
|
|
|
|
|
if (suggestion.value.length > 0 && suggestion.value[0].type !== 'column') {
|
|
|
|
suggestionPreviewed.value = suggestion.value[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isCurlyBracketBalanced()) {
|
|
|
|
suggestion.value = suggestion.value.filter((v) => v.type === 'column')
|
|
|
|
}
|
|
|
|
autocomplete.value = !!suggestion.value.length
|
|
|
|
}
|
|
|
|
|
|
|
|
function selectText() {
|
|
|
|
if (suggestion.value && selected.value > -1 && selected.value < suggestionsList.value.length) {
|
|
|
|
if (selected.value < suggestedFormulas.value.length) {
|
|
|
|
if (suggestedFormulas.value[selected.value].unsupported) return
|
|
|
|
appendText(suggestedFormulas.value[selected.value])
|
|
|
|
} else {
|
|
|
|
appendText(variableList.value[selected.value + suggestedFormulas.value.length])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
selected.value = 0
|
|
|
|
}
|
|
|
|
|
|
|
|
function suggestionListUp() {
|
|
|
|
if (suggestion.value) {
|
|
|
|
selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1
|
|
|
|
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
|
|
|
|
scrollToSelectedOption()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function suggestionListDown() {
|
|
|
|
if (suggestion.value) {
|
|
|
|
selected.value = ++selected.value % suggestion.value.length
|
|
|
|
suggestionPreviewed.value = suggestedFormulas.value[selected.value]
|
|
|
|
|
|
|
|
scrollToSelectedOption()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function scrollToSelectedOption() {
|
|
|
|
nextTick(() => {
|
|
|
|
if (sugOptionsRef.value[selected.value]) {
|
|
|
|
try {
|
|
|
|
sugOptionsRef.value[selected.value].$el.scrollIntoView({
|
|
|
|
block: 'nearest',
|
|
|
|
inline: 'start',
|
|
|
|
})
|
|
|
|
} catch (e) {}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// set default value
|
|
|
|
if ((column.value?.colOptions as any)?.formula_raw) {
|
|
|
|
vModel.value.formula_raw =
|
|
|
|
substituteColumnIdWithAliasInFormula(
|
|
|
|
(column.value?.colOptions as FormulaType)?.formula,
|
|
|
|
meta?.value?.columns as ColumnType[],
|
|
|
|
(column.value?.colOptions as any)?.formula_raw,
|
|
|
|
) || ''
|
|
|
|
}
|
|
|
|
|
|
|
|
// set additional validations
|
|
|
|
setAdditionalValidations({
|
|
|
|
...validators,
|
|
|
|
})
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
jsep.plugins.register(jsepCurlyHook)
|
|
|
|
})
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<template>
|
|
|
|
<div class="formula-wrapper relative">
|
|
|
|
<div
|
|
|
|
v-if="suggestionPreviewed && !suggestionPreviewed.unsupported && suggestionPreviewed.type === 'function'"
|
|
|
|
class="absolute -left-91 w-84 top-0 bg-white z-10 pl-3 pt-3 border-1 shadow-md rounded-xl"
|
|
|
|
>
|
|
|
|
<div class="pr-3">
|
|
|
|
<div class="flex flex-row w-full justify-between pb-1 border-b-1">
|
|
|
|
<div class="flex items-center gap-x-1 font-semibold text-base">
|
|
|
|
<component :is="iconMap.function" class="text-lg" />
|
|
|
|
{{ suggestionPreviewed.text }}
|
|
|
|
</div>
|
|
|
|
<NcButton type="text" size="small" @click="suggestionPreviewed = undefined">
|
|
|
|
<GeneralIcon icon="close" />
|
|
|
|
</NcButton>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="flex flex-col max-h-120 nc-scrollbar-md pr-2">
|
|
|
|
<div class="flex mt-3">{{ suggestionPreviewed.description }}</div>
|
|
|
|
|
|
|
|
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Syntax</div>
|
|
|
|
<div class="bg-white rounded-md py-1 px-2 border-1">{{ suggestionPreviewed.syntax }}</div>
|
|
|
|
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Examples</div>
|
|
|
|
<div
|
|
|
|
v-for="(example, index) of suggestionPreviewed.examples"
|
|
|
|
:key="example"
|
|
|
|
class="bg-gray-100 py-1 px-2"
|
|
|
|
:class="{
|
|
|
|
'border-t-1 border-gray-200': index !== 0,
|
|
|
|
'rounded-b-md': index === suggestionPreviewed.examples.length - 1 && suggestionPreviewed.examples.length !== 1,
|
|
|
|
'rounded-t-md': index === 0 && suggestionPreviewed.examples.length !== 1,
|
|
|
|
'rounded-md': suggestionPreviewed.examples.length === 1,
|
|
|
|
}"
|
|
|
|
>
|
|
|
|
{{ example }}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="flex flex-row mt-1 mb-3 justify-end pr-3">
|
|
|
|
<a v-if="suggestionPreviewed.docsUrl" target="_blank" rel="noopener noreferrer" :href="suggestionPreviewed.docsUrl">
|
|
|
|
<NcButton type="text" class="!text-gray-400 !hover:text-gray-800 !text-xs"
|
|
|
|
>View in Docs
|
|
|
|
<GeneralIcon icon="openInNew" class="ml-1" />
|
|
|
|
</NcButton>
|
|
|
|
</a>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<a-form-item v-bind="validateInfos.formula_raw" class="!pb-1" :label="$t('datatype.Formula')">
|
|
|
|
<!-- <GeneralIcon
|
|
|
|
v-if="isEeUI"
|
|
|
|
icon="magic"
|
|
|
|
:class="{ 'nc-animation-pulse': loadMagic }"
|
|
|
|
class="text-orange-400 cursor-pointer absolute right-1 top-1 z-10"
|
|
|
|
@click="predictFunction()"
|
|
|
|
/> -->
|
|
|
|
<a-textarea
|
|
|
|
ref="formulaRef"
|
|
|
|
v-model:value="vModel.formula_raw"
|
|
|
|
class="nc-formula-input !rounded-md !my-1"
|
|
|
|
@keydown.down.prevent="suggestionListDown"
|
|
|
|
@keydown.up.prevent="suggestionListUp"
|
|
|
|
@keydown.enter.prevent="selectText"
|
|
|
|
@change="handleInputDeb"
|
|
|
|
/>
|
|
|
|
</a-form-item>
|
|
|
|
|
|
|
|
<div ref="sugListRef" class="h-[250px] overflow-auto nc-scrollbar-md">
|
|
|
|
<template v-if="suggestedFormulas.length > 0">
|
|
|
|
<div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Formulas</div>
|
|
|
|
|
|
|
|
<a-list
|
|
|
|
:data-source="suggestedFormulas"
|
|
|
|
:locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
|
|
|
|
class="border-1 border-t-0 rounded-b-lg !mb-4"
|
|
|
|
>
|
|
|
|
<template #renderItem="{ item, index }">
|
|
|
|
<a-list-item
|
|
|
|
:ref="
|
|
|
|
(el) => {
|
|
|
|
sugOptionsRef[index] = el
|
|
|
|
}
|
|
|
|
"
|
|
|
|
class="cursor-pointer !overflow-hidden hover:bg-gray-50"
|
|
|
|
:class="{
|
|
|
|
'!bg-gray-100': selected === index,
|
|
|
|
'cursor-not-allowed': item.unsupported,
|
|
|
|
}"
|
|
|
|
@click.prevent.stop="!item.unsupported && appendText(item)"
|
|
|
|
@mouseenter="suggestionPreviewed = item"
|
|
|
|
>
|
|
|
|
<a-list-item-meta>
|
|
|
|
<template #title>
|
|
|
|
<div class="flex items-center gap-x-1" :class="{ 'text-gray-400': item.unsupported }">
|
|
|
|
<component :is="iconMap.function" v-if="item.type === 'function'" class="text-lg" />
|
|
|
|
|
|
|
|
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="text-lg" />
|
|
|
|
|
|
|
|
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
|
|
|
|
<span class="prose-sm" :class="{ 'text-gray-600': !item.unsupported }">{{ item.text }}</span>
|
|
|
|
</div>
|
|
|
|
<div v-if="item.unsupported" class="ml-5 text-gray-400 text-xs">{{ $t('msg.formulaNotSupported') }}</div>
|
|
|
|
</template>
|
|
|
|
</a-list-item-meta>
|
|
|
|
</a-list-item>
|
|
|
|
</template>
|
|
|
|
</a-list>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<template v-if="variableList.length > 0">
|
|
|
|
<div class="rounded-t-lg border-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs">Fields</div>
|
|
|
|
|
|
|
|
<a-list
|
|
|
|
ref="variableListRef"
|
|
|
|
:data-source="variableList"
|
|
|
|
:locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }"
|
|
|
|
class="border-1 border-t-0 rounded-b-lg !overflow-hidden"
|
|
|
|
>
|
|
|
|
<template #renderItem="{ item, index }">
|
|
|
|
<a-list-item
|
|
|
|
:ref="
|
|
|
|
(el) => {
|
|
|
|
sugOptionsRef[index + suggestedFormulas.length] = el
|
|
|
|
}
|
|
|
|
"
|
|
|
|
:class="{
|
|
|
|
'!bg-gray-100': selected === index + suggestedFormulas.length,
|
|
|
|
}"
|
|
|
|
class="cursor-pointer hover:bg-gray-50"
|
|
|
|
@click.prevent.stop="appendText(item)"
|
|
|
|
>
|
|
|
|
<a-list-item-meta>
|
|
|
|
<template #title>
|
|
|
|
<div class="flex items-center gap-x-1">
|
|
|
|
<component :is="item.icon" class="text-lg" />
|
|
|
|
|
|
|
|
<span class="prose-sm text-gray-600">{{ item.text }}</span>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
</a-list-item-meta>
|
|
|
|
</a-list-item>
|
|
|
|
</template>
|
|
|
|
</a-list>
|
|
|
|
</template>
|
|
|
|
<div v-if="suggestion.length === 0">
|
|
|
|
<span class="text-gray-500">Empty</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
:deep(.ant-list-item) {
|
|
|
|
@apply !pt-1.75 pb-0.75 !px-2;
|
|
|
|
}
|
|
|
|
</style>
|