mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
481 lines
17 KiB
481 lines
17 KiB
<script setup lang="ts"> |
|
import type { Ref } from 'vue' |
|
import type { ListItem as AntListItem } from 'ant-design-vue' |
|
import jsep from 'jsep' |
|
import { UITypes, jsepCurlyHook } from 'nocodb-sdk' |
|
import { useColumnCreateStoreOrThrow, useDebounceFn } from '#imports' |
|
import { MetaInj } from '~/context' |
|
import { NcAutocompleteTree, formulaList, formulaTypes, formulas, getUIDTIcon, getWordUntilCaret, insertAtCursor } from '@/utils' |
|
import MdiFunctionIcon from '~icons/mdi/function' |
|
import MdiColumnIcon from '~icons/mdi/view-column-outline' |
|
import MdiOperatorIcon from '~icons/mdi/calculator' |
|
|
|
const { formState, validateInfos, setAdditionalValidations, sqlUi, onDataTypeChange, onAlter, column } = |
|
useColumnCreateStoreOrThrow() |
|
|
|
const meta = inject(MetaInj) |
|
|
|
const columns = computed(() => meta?.value?.columns || []) |
|
|
|
const validators = { |
|
'colOptions.formula_raw': [ |
|
{ |
|
validator: (_: any, formula: any) => { |
|
return new Promise<void>((resolve, reject) => { |
|
if (!parseAndValidateFormula(formula)) { |
|
return reject(new Error('Invalid formula')) |
|
} |
|
resolve() |
|
}) |
|
}, |
|
}, |
|
], |
|
} |
|
|
|
const formula = ref() |
|
|
|
const formulaSuggestionDrawer = ref(true) |
|
|
|
const availableFunctions = formulaList |
|
|
|
const availableBinOps = ['+', '-', '*', '/', '>', '<', '==', '<=', '>=', '!='] |
|
|
|
const autocomplete = ref(false) |
|
|
|
const formulaRef = ref() |
|
|
|
const sugListRef = ref() |
|
|
|
const sugOptionsRef = ref<typeof AntListItem[]>([]) |
|
|
|
const wordToComplete = ref<string | undefined>('') |
|
|
|
const selected = ref(0) |
|
|
|
const tooltip = ref(true) |
|
|
|
const sortOrder: Record<string, number> = { |
|
column: 0, |
|
function: 1, |
|
op: 2, |
|
} |
|
|
|
const suggestionsList = computed(() => { |
|
const unsupportedFnList = sqlUi.value.getUnsupportedFnList() |
|
return [ |
|
...availableFunctions |
|
.filter((fn) => !unsupportedFnList.includes(fn)) |
|
.map((fn) => ({ |
|
text: `${fn}()`, |
|
type: 'function', |
|
description: formulas[fn].description, |
|
syntax: formulas[fn].syntax, |
|
examples: formulas[fn].examples, |
|
})), |
|
...columns.value |
|
.filter( |
|
(c: Record<string, any>) => |
|
!column || (column.id !== c.id && !(c.uidt === UITypes.LinkToAnotherRecord && c.system === 1)), |
|
) |
|
.map((c: any) => ({ |
|
text: c.title, |
|
type: 'column', |
|
icon: getUIDTIcon(c.uidt), |
|
c, |
|
})), |
|
...availableBinOps.map((op: string) => ({ |
|
text: op, |
|
type: 'op', |
|
})), |
|
] |
|
}) |
|
|
|
// set default suggestion list |
|
const suggestion: Ref<Record<string, any>[] | null> = ref(suggestionsList.value) |
|
|
|
const acTree = computed(() => { |
|
const ref = new NcAutocompleteTree() |
|
for (const sug of suggestionsList.value) { |
|
ref.add(sug) |
|
} |
|
return ref |
|
}) |
|
|
|
function parseAndValidateFormula(formula: string) { |
|
try { |
|
const parsedTree = jsep(formula) |
|
const metaErrors = validateAgainstMeta(parsedTree) |
|
if (metaErrors.length) { |
|
return [...metaErrors].join(', ') |
|
} |
|
return true |
|
} catch (e: any) { |
|
return e.message |
|
} |
|
} |
|
|
|
function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) { |
|
return [] |
|
// if (parsedTree.type === jsep.CALL_EXP) { |
|
// // validate function name |
|
// if (!this.availableFunctions.includes(parsedTree.callee.name)) { |
|
// errors.add(`'${parsedTree.callee.name}' function is not available`); |
|
// } |
|
// // validate arguments |
|
// const validation = formulas[parsedTree.callee.name] && formulas[parsedTree.callee.name].validation; |
|
// if (validation && validation.args) { |
|
// if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) { |
|
// errors.add(`'${parsedTree.callee.name}' required ${validation.args.rqd} arguments`); |
|
// } else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) { |
|
// errors.add(`'${parsedTree.callee.name}' required minimum ${validation.args.min} arguments`); |
|
// } else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) { |
|
// errors.add(`'${parsedTree.callee.name}' required maximum ${validation.args.max} arguments`); |
|
// } |
|
// } |
|
// parsedTree.arguments.map(arg => this.validateAgainstMeta(arg, errors)); |
|
// |
|
// // validate data type |
|
// if (parsedTree.callee.type === jsep.IDENTIFIER) { |
|
// const expectedType = formulas[parsedTree.callee.name].type; |
|
// if (expectedType === formulaTypes.NUMERIC) { |
|
// if (parsedTree.callee.name === 'WEEKDAY') { |
|
// // parsedTree.arguments[0] = date |
|
// this.validateAgainstType( |
|
// parsedTree.arguments[0], |
|
// formulaTypes.DATE, |
|
// v => { |
|
// if (!validateDateWithUnknownFormat(v)) { |
|
// typeErrors.add('The first parameter of WEEKDAY() should have date value'); |
|
// } |
|
// }, |
|
// typeErrors |
|
// ); |
|
// // parsedTree.arguments[1] = startDayOfWeek (optional) |
|
// this.validateAgainstType( |
|
// parsedTree.arguments[1], |
|
// formulaTypes.STRING, |
|
// v => { |
|
// if ( |
|
// typeof v !== 'string' || |
|
// !['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes( |
|
// v.toLowerCase() |
|
// ) |
|
// ) { |
|
// typeErrors.add( |
|
// 'The second parameter of WEEKDAY() should have the value either "sunday", "monday", "tuesday", "wednesday", "thursday", "friday" or "saturday"' |
|
// ); |
|
// } |
|
// }, |
|
// typeErrors |
|
// ); |
|
// } else { |
|
// parsedTree.arguments.map(arg => this.validateAgainstType(arg, expectedType, null, typeErrors)); |
|
// } |
|
// } else if (expectedType === formulaTypes.DATE) { |
|
// if (parsedTree.callee.name === 'DATEADD') { |
|
// // parsedTree.arguments[0] = date |
|
// this.validateAgainstType( |
|
// parsedTree.arguments[0], |
|
// formulaTypes.DATE, |
|
// v => { |
|
// if (!validateDateWithUnknownFormat(v)) { |
|
// typeErrors.add('The first parameter of DATEADD() should have date value'); |
|
// } |
|
// }, |
|
// typeErrors |
|
// ); |
|
// // parsedTree.arguments[1] = numeric |
|
// this.validateAgainstType( |
|
// parsedTree.arguments[1], |
|
// formulaTypes.NUMERIC, |
|
// v => { |
|
// if (typeof v !== 'number') { |
|
// typeErrors.add('The second parameter of DATEADD() should have numeric value'); |
|
// } |
|
// }, |
|
// typeErrors |
|
// ); |
|
// // parsedTree.arguments[2] = ["day" | "week" | "month" | "year"] |
|
// this.validateAgainstType( |
|
// parsedTree.arguments[2], |
|
// formulaTypes.STRING, |
|
// v => { |
|
// if (!['day', 'week', 'month', 'year'].includes(v)) { |
|
// typeErrors.add( |
|
// 'The third parameter of DATEADD() should have the value either "day", "week", "month" or "year"' |
|
// ); |
|
// } |
|
// }, |
|
// typeErrors |
|
// ); |
|
// } |
|
// } |
|
// } |
|
// |
|
// errors = new Set([...errors, ...typeErrors]); |
|
// } else if (parsedTree.type === jsep.IDENTIFIER) { |
|
// if ( |
|
// this.meta.columns.filter(c => !this.column || this.column.id !== c.id).every(c => c.title !== parsedTree.name) |
|
// ) { |
|
// errors.add(`Column '${parsedTree.name}' is not available`); |
|
// } |
|
// |
|
// // check circular reference |
|
// // e.g. formula1 -> formula2 -> formula1 should return circular reference error |
|
// |
|
// // get all formula columns excluding itself |
|
// const formulaPaths = this.meta.columns |
|
// .filter(c => c.id !== this.column.id && c.uidt === UITypes.Formula) |
|
// .reduce((res, c) => { |
|
// // in `formula`, get all the target neighbours |
|
// // i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type |
|
// const neighbours = (c.colOptions.formula.match(/cl_\w{14}/g) || []).filter( |
|
// colId => this.meta.columns.filter(col => col.id === colId && col.uidt === UITypes.Formula).length |
|
// ); |
|
// if (neighbours.length > 0) { |
|
// // e.g. formula column 1 -> [formula column 2, formula column3] |
|
// res.push({ [c.id]: neighbours }); |
|
// } |
|
// return res; |
|
// }, []); |
|
// // include target formula column (i.e. the one to be saved if applicable) |
|
// const targetFormulaCol = this.meta.columns.find(c => c.title === parsedTree.name && c.uidt === UITypes.Formula); |
|
// if (targetFormulaCol) { |
|
// formulaPaths.push({ |
|
// [this.column.id]: [targetFormulaCol.id], |
|
// }); |
|
// } |
|
// const vertices = formulaPaths.length; |
|
// if (vertices > 0) { |
|
// // perform kahn's algo for cycle detection |
|
// const adj = new Map(); |
|
// const inDegrees = new Map(); |
|
// // init adjacency list & indegree |
|
// for (const [_, v] of Object.entries(formulaPaths)) { |
|
// const src = Object.keys(v)[0]; |
|
// const neighbours = v[src]; |
|
// inDegrees.set(src, inDegrees.get(src) || 0); |
|
// for (const neighbour of neighbours) { |
|
// adj.set(src, (adj.get(src) || new Set()).add(neighbour)); |
|
// inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1); |
|
// } |
|
// } |
|
// const queue = []; |
|
// // put all vertices with in-degree = 0 (i.e. no incoming edges) to queue |
|
// inDegrees.forEach((inDegree, col) => { |
|
// if (inDegree === 0) { |
|
// // in-degree = 0 means we start traversing from this node |
|
// queue.push(col); |
|
// } |
|
// }); |
|
// // init count of visited vertices |
|
// let visited = 0; |
|
// // BFS |
|
// while (queue.length !== 0) { |
|
// // remove a vertex from the queue |
|
// const src = queue.shift(); |
|
// // if this node has neighbours, increase visited by 1 |
|
// const neighbours = adj.get(src) || new Set(); |
|
// if (neighbours.size > 0) { |
|
// visited += 1; |
|
// } |
|
// // iterate each neighbouring nodes |
|
// neighbours.forEach(neighbour => { |
|
// // decrease in-degree of its neighbours by 1 |
|
// inDegrees.set(neighbour, inDegrees.get(neighbour) - 1); |
|
// // if in-degree becomes 0 |
|
// if (inDegrees.get(neighbour) === 0) { |
|
// // then put the neighboring node to the queue |
|
// queue.push(neighbour); |
|
// } |
|
// }); |
|
// } |
|
// // vertices not same as visited = cycle found |
|
// if (vertices !== visited) { |
|
// errors.add('Can’t save field because it causes a circular reference'); |
|
// } |
|
// } |
|
// } else if (parsedTree.type === jsep.BINARY_EXP) { |
|
// if (!this.availableBinOps.includes(parsedTree.operator)) { |
|
// errors.add(`'${parsedTree.operator}' operation is not available`); |
|
// } |
|
// this.validateAgainstMeta(parsedTree.left, errors); |
|
// this.validateAgainstMeta(parsedTree.right, errors); |
|
// } else if (parsedTree.type === jsep.LITERAL || parsedTree.type === jsep.UNARY_EXP) { |
|
// // do nothing |
|
// } else if (parsedTree.type === jsep.COMPOUND) { |
|
// if (parsedTree.body.length) { |
|
// errors.add('Can’t save field because the formula is invalid'); |
|
// } |
|
// } else { |
|
// errors.add('Can’t save field because the formula is invalid'); |
|
// } |
|
// return errors; |
|
} |
|
|
|
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(it: Record<string, any>) { |
|
const text = it.text |
|
const len = wordToComplete.value?.length || 0 |
|
if (it.type === 'function') { |
|
formState.value.colOptions.formula_raw = insertAtCursor(formulaRef.value.$el, text, len, 1) |
|
} else if (it.type === 'column') { |
|
formState.value.colOptions.formula_raw = insertAtCursor(formulaRef.value.$el, `{${text}}`, len + +!isCurlyBracketBalanced()) |
|
} else { |
|
formState.value.colOptions.formula_raw = insertAtCursor(formulaRef.value.$el, text, len) |
|
} |
|
autocomplete.value = false |
|
suggestion.value = null |
|
} |
|
|
|
const handleInputDeb = useDebounceFn(function () { |
|
handleInput() |
|
}, 250) |
|
|
|
function handleInput() { |
|
selected.value = 0 |
|
suggestion.value = null |
|
const query = getWordUntilCaret(formulaRef.value.$el) |
|
const parts = query.split(/\W+/) |
|
wordToComplete.value = parts.pop() || '' |
|
suggestion.value = acTree.value.complete(wordToComplete.value)?.sort((x, y) => sortOrder[x.type] - sortOrder[y.type]) // TODO: check .type |
|
if (!isCurlyBracketBalanced()) { |
|
suggestion.value = suggestion.value.filter((v: Record<string, any>) => v.type === 'column') |
|
} |
|
autocomplete.value = !!suggestion.value.length |
|
} |
|
|
|
function selectText() { |
|
if (suggestion.value && selected.value > -1 && selected.value < suggestion.value.length) { |
|
appendText(suggestion.value[selected.value]) |
|
} |
|
} |
|
|
|
function suggestionListUp() { |
|
if (suggestion.value) { |
|
selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1 |
|
scrollToSelectedOption() |
|
} |
|
} |
|
|
|
function suggestionListDown() { |
|
if (suggestion.value) { |
|
selected.value = ++selected.value % suggestion.value.length |
|
scrollToSelectedOption() |
|
} |
|
} |
|
|
|
function scrollToSelectedOption() { |
|
nextTick(() => { |
|
if (sugOptionsRef.value[selected.value]) { |
|
try { |
|
sugListRef.value.$el.scrollTo({ |
|
top: sugOptionsRef.value[selected.value].$el.offsetTop, |
|
behavior: 'smooth', |
|
}) |
|
} catch (e) {} |
|
} |
|
}) |
|
} |
|
|
|
function getFormulaTypeName(type: string) { |
|
switch (type) { |
|
case 'function': |
|
return 'Function' |
|
case 'op': |
|
return 'Operator' |
|
case 'column': |
|
return 'Column' |
|
default: |
|
return '' |
|
} |
|
} |
|
|
|
// set default value |
|
formState.value.colOptions = { |
|
formula: '', |
|
formula_raw: '', |
|
...column?.colOptions, |
|
} |
|
</script> |
|
|
|
<template> |
|
<div class="formula-wrapper"> |
|
<a-form-item v-bind="validateInfos['colOptions.formula_raw']" label="Formula"> |
|
<a-input |
|
ref="formulaRef" |
|
v-model:value="formState.colOptions.formula_raw" |
|
@keydown.down.prevent="suggestionListDown" |
|
@keydown.up.prevent="suggestionListUp" |
|
@keydown.enter.prevent="selectText" |
|
@change="handleInputDeb" |
|
/> |
|
</a-form-item> |
|
<div class="hint"> |
|
Hint: Use {} to reference columns, e.g: {column_name}. For more, please check out |
|
<a href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" target="_blank">Formulas</a>. |
|
</div> |
|
<!-- TODO: close drawer when EditOrAdd is closed --> |
|
<a-drawer |
|
v-model:visible="formulaSuggestionDrawer" |
|
:closable="false" |
|
:mask="false" |
|
:mask-closable="false" |
|
placement="right" |
|
width="500px" |
|
class="h-full overflow-auto" |
|
> |
|
<a-list ref="sugListRef" :data-source="suggestion" :locale="{ emptyText: 'No suggested formula was found' }"> |
|
<template #renderItem="{ item, index }"> |
|
<a-list-item |
|
:ref=" |
|
(el) => { |
|
sugOptionsRef[index] = el |
|
} |
|
" |
|
class="cursor-pointer" |
|
@mousedown.stop="appendText(item)" |
|
> |
|
<a-list-item-meta> |
|
<template v-if="item.type === 'function'" #description> |
|
{{ item.description }} <br /><br /> |
|
Syntax: <br /> |
|
{{ item.syntax }} <br /><br /> |
|
Examples: <br /> |
|
<div v-for="(example, idx) of item.examples" :key="idx"> |
|
<div>({{ idx + 1 }}): {{ example }}</div> |
|
</div> |
|
</template> |
|
<template #title> |
|
<div class="flex"> |
|
<div class="flex-1"> |
|
{{ item.text }} |
|
</div> |
|
<div class=""> |
|
{{ getFormulaTypeName(item.type) }} |
|
</div> |
|
</div> |
|
</template> |
|
<template #avatar> |
|
<MdiFunctionIcon v-if="item.type === 'function'" class="text-lg" /> |
|
<MdiOperatorIcon v-if="item.type === 'op'" class="text-lg" /> |
|
<MdiColumnIcon v-if="item.type === 'column'" class="text-lg" /> |
|
</template> |
|
</a-list-item-meta> |
|
</a-list-item> |
|
</template> |
|
</a-list> |
|
</a-drawer> |
|
</div> |
|
</template>
|
|
|