mirror of https://github.com/nocodb/nocodb
62 changed files with 2079 additions and 647 deletions
@ -0,0 +1,123 @@
|
||||
<script setup lang="ts"> |
||||
import { Modal as AModal } from 'ant-design-vue' |
||||
import Editor from '~/components/monaco/Editor.vue' |
||||
import FullScreenIcon from '~icons/cil/fullscreen' |
||||
import FullScreenExitIcon from '~icons/cil/fullscreen-exit' |
||||
import { inject } from '#imports' |
||||
import { EditModeInj } from '~/context' |
||||
|
||||
interface Props { |
||||
modelValue: string | Record<string, any> | undefined |
||||
} |
||||
|
||||
interface Emits { |
||||
(event: 'update:modelValue', model: string): void |
||||
} |
||||
|
||||
const props = defineProps<Props>() |
||||
|
||||
const emits = defineEmits<Emits>() |
||||
|
||||
const editEnabled = inject(EditModeInj, ref(false)) |
||||
|
||||
let vModel = $(useVModel(props, 'modelValue', emits)) |
||||
|
||||
let localValueState = $ref<string | undefined>(undefined) |
||||
let localValue = $( |
||||
computed<string | undefined>({ |
||||
get: () => localValueState, |
||||
set: (val: undefined | string | Record<string, any>) => { |
||||
localValueState = typeof val === 'object' ? JSON.stringify(val, null, 2) : val |
||||
}, |
||||
}), |
||||
) |
||||
|
||||
let error = $ref<string | undefined>(undefined) |
||||
let isExpanded = $ref(false) |
||||
|
||||
const clear = () => { |
||||
error = undefined |
||||
isExpanded = false |
||||
editEnabled.value = false |
||||
|
||||
localValue = vModel |
||||
} |
||||
|
||||
const formatJson = (json: string) => { |
||||
try { |
||||
return JSON.stringify(JSON.parse(json), null, 2) |
||||
} catch (e) { |
||||
return json |
||||
} |
||||
} |
||||
|
||||
const onSave = () => { |
||||
isExpanded = false |
||||
editEnabled.value = false |
||||
localValue = localValue ? formatJson(localValue) : localValue |
||||
vModel = localValue |
||||
} |
||||
|
||||
watch( |
||||
$$(vModel), |
||||
(val) => { |
||||
localValue = val |
||||
}, |
||||
{ immediate: true }, |
||||
) |
||||
|
||||
watch($$(localValue), (val) => { |
||||
try { |
||||
JSON.parse(val) |
||||
error = undefined |
||||
} catch (e: any) { |
||||
error = e |
||||
} |
||||
}) |
||||
|
||||
watch(editEnabled, () => { |
||||
isExpanded = false |
||||
localValue = vModel |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<component :is="isExpanded ? AModal : 'div'" v-model:visible="isExpanded" :closable="false" centered :footer="null"> |
||||
<div v-if="editEnabled" class="flex flex-col w-full"> |
||||
<div class="flex flex-row justify-between pt-1 pb-2"> |
||||
<a-button type="text" size="small" @click="isExpanded = !isExpanded"> |
||||
<FullScreenExitIcon v-if="isExpanded" class="h-2.5" /> |
||||
<FullScreenIcon v-else class="h-2.5" /> |
||||
</a-button> |
||||
<div class="flex flex-row"> |
||||
<a-button type="text" size="small" :onclick="clear"><div class="text-xs">Cancel</div></a-button> |
||||
<a-button type="primary" size="small" :disabled="!!error || localValue === vModel"> |
||||
<div class="text-xs" :onclick="onSave">Save</div> |
||||
</a-button> |
||||
</div> |
||||
</div> |
||||
<Editor |
||||
:model-value="localValue" |
||||
class="min-w-full w-80" |
||||
:class="{ 'expanded-editor': isExpanded, 'editor': !isExpanded }" |
||||
:hide-minimap="true" |
||||
:disable-deep-compare="true" |
||||
@update:model-value="localValue = $event" |
||||
/> |
||||
<span v-if="error" class="text-xs w-full py-1 text-red-500"> |
||||
{{ error.toString() }} |
||||
</span> |
||||
</div> |
||||
<span v-else>{{ vModel }}</span> |
||||
</component> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"> |
||||
.expanded-editor { |
||||
min-height: min(600px, 80vh); |
||||
} |
||||
|
||||
.editor { |
||||
min-height: min(200px, 10vh); |
||||
} |
||||
</style> |
@ -1,119 +0,0 @@
|
||||
<script lang="ts" setup> |
||||
import MonacoJsonObjectEditor from '@/components/monaco/Editor.vue' |
||||
import { computed, inject } from '#imports' |
||||
import { EditModeInj } from '~/context' |
||||
|
||||
interface Props { |
||||
modelValue: string | Record<string, any> |
||||
isForm: boolean |
||||
} |
||||
|
||||
const props = defineProps<Props>() |
||||
|
||||
const emits = defineEmits(['update:modelValue', 'cancel']) |
||||
|
||||
const editEnabled = inject(EditModeInj) |
||||
|
||||
let expand = $ref(false) |
||||
|
||||
let isValid = $ref(true) |
||||
|
||||
let error = $ref() |
||||
|
||||
const vModel = computed({ |
||||
get: () => (typeof props.modelValue === 'string' ? JSON.parse(props.modelValue) : props.modelValue), |
||||
set: (val) => { |
||||
if (props.isForm) { |
||||
emits('update:modelValue', JSON.stringify(val)) |
||||
} |
||||
}, |
||||
}) |
||||
|
||||
function save() { |
||||
expand = false |
||||
emits('update:modelValue', JSON.stringify(vModel.value)) |
||||
} |
||||
|
||||
function validate(n: boolean, e: any) { |
||||
isValid = n |
||||
error = e |
||||
} |
||||
</script> |
||||
|
||||
<script lang="ts"> |
||||
export default { |
||||
name: 'JsonEditableCell', |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<v-dialog :is="expand ? 'v-dialog' : 'div'" v-model="expand" max-width="800px" class="cell-container" @keydown.stop.enter> |
||||
<div class="d-flex pa-1" :class="{ backgroundColor: expand }"> |
||||
<v-spacer /> |
||||
<v-icon small class="mr-2" @click="expand = !expand"> |
||||
{{ expand ? 'mdi-arrow-collapse' : 'mdi-arrow-expand' }} |
||||
</v-icon> |
||||
<template v-if="!isForm"> |
||||
<v-btn outlined x-small class="mr-1" @click="$emit('cancel')"> |
||||
<!-- Cancel --> |
||||
{{ $t('general.cancel') }} |
||||
</v-btn> |
||||
<v-btn x-small color="primary" :disabled="!isValid" @click="save"> |
||||
<!-- Save --> |
||||
{{ $t('general.save') }} |
||||
</v-btn> |
||||
</template> |
||||
<v-btn v-else-if="expand" x-small @click="expand = false"> |
||||
<!-- Close --> |
||||
{{ $t('general.close') }} |
||||
</v-btn> |
||||
</div> |
||||
<MonacoJsonObjectEditor |
||||
v-if="expand" |
||||
v-model="vModel" |
||||
class="text-left caption" |
||||
style="width: 300px; min-height: min(600px, 80vh); min-width: 100%" |
||||
@validate="validate" |
||||
/> |
||||
<MonacoJsonObjectEditor |
||||
v-else |
||||
v-model="vModel" |
||||
class="text-left caption" |
||||
style="width: 300px; min-height: 200px; min-width: 100%" |
||||
@validate="validate" |
||||
/> |
||||
<div v-show="error" class="px-2 py-1 text-left caption error--text"> |
||||
{{ error }} |
||||
</div> |
||||
</v-dialog> |
||||
</template> |
||||
|
||||
<style scoped> |
||||
.cell-container { |
||||
width: 100%; |
||||
} |
||||
</style> |
||||
<!-- |
||||
/** |
||||
* @copyright Copyright (c) 2021, Xgene Cloud Ltd |
||||
* |
||||
* @author Naveen MR <oof1lab@gmail.com> |
||||
* @author Pranav C Balan <pranavxc@gmail.com> |
||||
* |
||||
* @license GNU AGPL version 3 or any later version |
||||
* |
||||
* This program is free software: you can redistribute it and/or modify |
||||
* it under the terms of the GNU Affero General Public License as |
||||
* published by the Free Software Foundation, either version 3 of the |
||||
* License, or (at your option) any later version. |
||||
* |
||||
* This program is distributed in the hope that it will be useful, |
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
* GNU Affero General Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU Affero General Public License |
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
* |
||||
*/ |
||||
--> |
@ -0,0 +1,683 @@
|
||||
<script setup lang="ts"> |
||||
import type { Ref } from 'vue' |
||||
import type { ListItem as AntListItem } from 'ant-design-vue' |
||||
import jsep from 'jsep' |
||||
import type { ColumnType } from 'nocodb-sdk' |
||||
import { UITypes, jsepCurlyHook } from 'nocodb-sdk' |
||||
import { useColumnCreateStoreOrThrow, useDebounceFn } from '#imports' |
||||
import { MetaInj } from '~/context' |
||||
import { |
||||
NcAutocompleteTree, |
||||
formulaList, |
||||
formulaTypes, |
||||
formulas, |
||||
getUIDTIcon, |
||||
getWordUntilCaret, |
||||
insertAtCursor, |
||||
validateDateWithUnknownFormat, |
||||
} from '@/utils' |
||||
import MdiFunctionIcon from '~icons/mdi/function' |
||||
import MdiOperatorIcon from '~icons/mdi/calculator' |
||||
|
||||
enum JSEPNode { |
||||
COMPOUND = 'Compound', |
||||
IDENTIFIER = 'Identifier', |
||||
MEMBER_EXP = 'MemberExpression', |
||||
LITERAL = 'Literal', |
||||
THIS_EXP = 'ThisExpression', |
||||
CALL_EXP = 'CallExpression', |
||||
UNARY_EXP = 'UnaryExpression', |
||||
BINARY_EXP = 'BinaryExpression', |
||||
ARRAY_EXP = 'ArrayExpression', |
||||
} |
||||
|
||||
const { formState, validateInfos, setAdditionalValidations, sqlUi, onDataTypeChange, onAlter, column } = |
||||
useColumnCreateStoreOrThrow() |
||||
|
||||
const meta = inject(MetaInj) |
||||
|
||||
const columns = computed(() => meta?.value?.columns || []) |
||||
|
||||
const validators = { |
||||
formula_raw: [ |
||||
{ |
||||
validator: (_: any, formula: any) => { |
||||
return new Promise<void>((resolve, reject) => { |
||||
const res = parseAndValidateFormula(formula) |
||||
if (res !== true) { |
||||
return reject(new Error(res)) |
||||
} |
||||
resolve() |
||||
}) |
||||
}, |
||||
}, |
||||
], |
||||
} |
||||
|
||||
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: string) => !unsupportedFnList.includes(fn)) |
||||
.map((fn: string) => ({ |
||||
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), |
||||
})), |
||||
...availableBinOps.map((op: string) => ({ |
||||
text: op, |
||||
type: 'op', |
||||
})), |
||||
] |
||||
}) |
||||
|
||||
// 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 |
||||
}) |
||||
|
||||
function parseAndValidateFormula(formula: string) { |
||||
try { |
||||
const parsedTree = jsep(formula) |
||||
const metaErrors = validateAgainstMeta(parsedTree) |
||||
if (metaErrors.size) { |
||||
return [...metaErrors].join(', ') |
||||
} |
||||
return true |
||||
} catch (e: any) { |
||||
return e.message |
||||
} |
||||
} |
||||
|
||||
function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) { |
||||
if (parsedTree.type === JSEPNode.CALL_EXP) { |
||||
// validate function name |
||||
if (!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: Record<string, any>) => validateAgainstMeta(arg, errors)) |
||||
|
||||
// validate data type |
||||
if (parsedTree.callee.type === JSEPNode.IDENTIFIER) { |
||||
const expectedType = formulas[parsedTree.callee.name].type |
||||
if (expectedType === formulaTypes.NUMERIC) { |
||||
if (parsedTree.callee.name === 'WEEKDAY') { |
||||
// parsedTree.arguments[0] = date |
||||
validateAgainstType( |
||||
parsedTree.arguments[0], |
||||
formulaTypes.DATE, |
||||
(v: any) => { |
||||
if (!validateDateWithUnknownFormat(v)) { |
||||
typeErrors.add('The first parameter of WEEKDAY() should have date value') |
||||
} |
||||
}, |
||||
typeErrors, |
||||
) |
||||
// parsedTree.arguments[1] = startDayOfWeek (optional) |
||||
validateAgainstType( |
||||
parsedTree.arguments[1], |
||||
formulaTypes.STRING, |
||||
(v: any) => { |
||||
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: Record<string, any>) => validateAgainstType(arg, expectedType, null, typeErrors)) |
||||
} |
||||
} else if (expectedType === formulaTypes.DATE) { |
||||
if (parsedTree.callee.name === 'DATEADD') { |
||||
// parsedTree.arguments[0] = date |
||||
validateAgainstType( |
||||
parsedTree.arguments[0], |
||||
formulaTypes.DATE, |
||||
(v: any) => { |
||||
if (!validateDateWithUnknownFormat(v)) { |
||||
typeErrors.add('The first parameter of DATEADD() should have date value') |
||||
} |
||||
}, |
||||
typeErrors, |
||||
) |
||||
// parsedTree.arguments[1] = numeric |
||||
validateAgainstType( |
||||
parsedTree.arguments[1], |
||||
formulaTypes.NUMERIC, |
||||
(v: any) => { |
||||
if (typeof v !== 'number') { |
||||
typeErrors.add('The second parameter of DATEADD() should have numeric value') |
||||
} |
||||
}, |
||||
typeErrors, |
||||
) |
||||
// parsedTree.arguments[2] = ["day" | "week" | "month" | "year"] |
||||
validateAgainstType( |
||||
parsedTree.arguments[2], |
||||
formulaTypes.STRING, |
||||
(v: any) => { |
||||
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 === JSEPNode.IDENTIFIER) { |
||||
if ( |
||||
columns.value |
||||
.filter((c: Record<string, any>) => !column || column.id !== c.id) |
||||
.every((c: Record<string, any>) => 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 = columns.value |
||||
.filter((c: Record<string, any>) => c.id !== column?.id && c.uidt === UITypes.Formula) |
||||
.reduce((res: Record<string, any>[], c: Record<string, any>) => { |
||||
// 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: string) => columns.value.filter((col: ColumnType) => 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 = columns.value.find((c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula) |
||||
if (targetFormulaCol) { |
||||
formulaPaths.push({ |
||||
[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: string[] = [] |
||||
// 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: string) => { |
||||
// 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 === JSEPNode.BINARY_EXP) { |
||||
if (!availableBinOps.includes(parsedTree.operator)) { |
||||
errors.add(`'${parsedTree.operator}' operation is not available`) |
||||
} |
||||
validateAgainstMeta(parsedTree.left, errors) |
||||
validateAgainstMeta(parsedTree.right, errors) |
||||
} else if (parsedTree.type === JSEPNode.LITERAL || parsedTree.type === JSEPNode.UNARY_EXP) { |
||||
// do nothing |
||||
} else if (parsedTree.type === JSEPNode.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 validateAgainstType(parsedTree: any, expectedType: string, func: any, typeErrors = new Set()) { |
||||
if (parsedTree === false || typeof parsedTree === 'undefined') { |
||||
return typeErrors |
||||
} |
||||
if (parsedTree.type === JSEPNode.LITERAL) { |
||||
if (typeof func === 'function') { |
||||
func(parsedTree.value) |
||||
} else if (expectedType === formulaTypes.NUMERIC) { |
||||
if (typeof parsedTree.value !== 'number') { |
||||
typeErrors.add('Numeric type is expected') |
||||
} |
||||
} else if (expectedType === formulaTypes.STRING) { |
||||
if (typeof parsedTree.value !== 'string') { |
||||
typeErrors.add('string type is expected') |
||||
} |
||||
} |
||||
} else if (parsedTree.type === JSEPNode.IDENTIFIER) { |
||||
const col = columns.value.find((c) => c.title === parsedTree.name) as Record<string, any> |
||||
if (col === undefined) { |
||||
return |
||||
} |
||||
if (col.uidt === UITypes.Formula) { |
||||
const foundType = getRootDataType(jsep(col?.formula_raw)) |
||||
if (foundType === 'N/A') { |
||||
typeErrors.add(`Not supported to reference column ${col.title}`) |
||||
} else if (expectedType !== foundType) { |
||||
typeErrors.add(`Type ${expectedType} is expected but found Type ${foundType}`) |
||||
} |
||||
} else { |
||||
switch (col.uidt) { |
||||
// string |
||||
case UITypes.SingleLineText: |
||||
case UITypes.LongText: |
||||
case UITypes.MultiSelect: |
||||
case UITypes.SingleSelect: |
||||
case UITypes.PhoneNumber: |
||||
case UITypes.Email: |
||||
case UITypes.URL: |
||||
if (expectedType !== formulaTypes.STRING) { |
||||
typeErrors.add( |
||||
`Column '${parsedTree.name}' with ${formulaTypes.STRING} type is found but ${expectedType} type is expected`, |
||||
) |
||||
} |
||||
break |
||||
|
||||
// numeric |
||||
case UITypes.Year: |
||||
case UITypes.Number: |
||||
case UITypes.Decimal: |
||||
case UITypes.Rating: |
||||
case UITypes.Count: |
||||
case UITypes.AutoNumber: |
||||
case UITypes.Currency: |
||||
if (expectedType !== formulaTypes.NUMERIC) { |
||||
typeErrors.add( |
||||
`Column '${parsedTree.name}' with ${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`, |
||||
) |
||||
} |
||||
break |
||||
|
||||
// date |
||||
case UITypes.Date: |
||||
case UITypes.DateTime: |
||||
case UITypes.CreateTime: |
||||
case UITypes.LastModifiedTime: |
||||
if (expectedType !== formulaTypes.DATE) { |
||||
typeErrors.add( |
||||
`Column '${parsedTree.name}' with ${formulaTypes.DATE} type is found but ${expectedType} type is expected`, |
||||
) |
||||
} |
||||
break |
||||
|
||||
// not supported |
||||
case UITypes.ForeignKey: |
||||
case UITypes.Attachment: |
||||
case UITypes.ID: |
||||
case UITypes.Time: |
||||
case UITypes.Percent: |
||||
case UITypes.Duration: |
||||
case UITypes.Rollup: |
||||
case UITypes.Lookup: |
||||
case UITypes.Barcode: |
||||
case UITypes.Button: |
||||
case UITypes.Checkbox: |
||||
case UITypes.Collaborator: |
||||
default: |
||||
typeErrors.add(`Not supported to reference column '${parsedTree.name}'`) |
||||
break |
||||
} |
||||
} |
||||
} else if (parsedTree.type === JSEPNode.UNARY_EXP || parsedTree.type === JSEPNode.BINARY_EXP) { |
||||
if (expectedType !== formulaTypes.NUMERIC) { |
||||
// parsedTree.name won't be available here |
||||
typeErrors.add(`${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`) |
||||
} |
||||
} else if (parsedTree.type === JSEPNode.CALL_EXP) { |
||||
if (formulas[parsedTree.callee.name]?.type && expectedType !== formulas[parsedTree.callee.name].type) { |
||||
typeErrors.add(`${expectedType} not matched with ${formulas[parsedTree.callee.name].type}`) |
||||
} |
||||
} |
||||
return typeErrors |
||||
} |
||||
|
||||
function getRootDataType(parsedTree: any): any { |
||||
// given a parse tree, return the data type of it |
||||
if (parsedTree.type === JSEPNode.CALL_EXP) { |
||||
return formulas[parsedTree.callee.name].type |
||||
} else if (parsedTree.type === JSEPNode.IDENTIFIER) { |
||||
const col = columns.value.find((c) => c.title === parsedTree.name) as Record<string, any> |
||||
if (col?.uidt === UITypes.Formula) { |
||||
return getRootDataType(jsep(col?.formula_raw)) |
||||
} else { |
||||
switch (col?.uidt) { |
||||
// string |
||||
case UITypes.SingleLineText: |
||||
case UITypes.LongText: |
||||
case UITypes.MultiSelect: |
||||
case UITypes.SingleSelect: |
||||
case UITypes.PhoneNumber: |
||||
case UITypes.Email: |
||||
case UITypes.URL: |
||||
return formulaTypes.STRING |
||||
|
||||
// numeric |
||||
case UITypes.Year: |
||||
case UITypes.Number: |
||||
case UITypes.Decimal: |
||||
case UITypes.Rating: |
||||
case UITypes.Count: |
||||
case UITypes.AutoNumber: |
||||
return formulaTypes.NUMERIC |
||||
|
||||
// date |
||||
case UITypes.Date: |
||||
case UITypes.DateTime: |
||||
case UITypes.CreateTime: |
||||
case UITypes.LastModifiedTime: |
||||
return formulaTypes.DATE |
||||
|
||||
// not supported |
||||
case UITypes.ForeignKey: |
||||
case UITypes.Attachment: |
||||
case UITypes.ID: |
||||
case UITypes.Time: |
||||
case UITypes.Currency: |
||||
case UITypes.Percent: |
||||
case UITypes.Duration: |
||||
case UITypes.Rollup: |
||||
case UITypes.Lookup: |
||||
case UITypes.Barcode: |
||||
case UITypes.Button: |
||||
case UITypes.Checkbox: |
||||
case UITypes.Collaborator: |
||||
default: |
||||
return 'N/A' |
||||
} |
||||
} |
||||
} else if (parsedTree.type === JSEPNode.BINARY_EXP || parsedTree.type === JSEPNode.UNARY_EXP) { |
||||
return formulaTypes.NUMERIC |
||||
} else if (parsedTree.type === JSEPNode.LITERAL) { |
||||
return typeof parsedTree.value |
||||
} else { |
||||
return 'N/A' |
||||
} |
||||
} |
||||
|
||||
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') { |
||||
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len, 1) |
||||
} else if (item.type === 'column') { |
||||
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, `{${text}}`, len + +!isCurlyBracketBalanced()) |
||||
} else { |
||||
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len) |
||||
} |
||||
autocomplete.value = false |
||||
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 (!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.formula_raw = (column?.colOptions as Record<string, any>)?.formula_raw || '' |
||||
|
||||
// set additional validations |
||||
setAdditionalValidations({ |
||||
...validators, |
||||
}) |
||||
|
||||
defineExpose({ |
||||
formulaSuggestionDrawer, |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
jsep.plugins.register(jsepCurlyHook) |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="formula-wrapper"> |
||||
<a-form-item v-bind="validateInfos.formula_raw" label="Formula"> |
||||
<a-input |
||||
ref="formulaRef" |
||||
v-model:value="formState.formula_raw" |
||||
class="mb-2" |
||||
@keydown.down.prevent="suggestionListDown" |
||||
@keydown.up.prevent="suggestionListUp" |
||||
@keydown.enter.prevent="selectText" |
||||
@change="handleInputDeb" |
||||
/> |
||||
</a-form-item> |
||||
<div class="text-gray-600 mt-2 prose-sm"> |
||||
Hint: Use {} to reference columns, e.g: {column_name}. For more, please check out |
||||
<a class="prose-sm" href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" target="_blank" |
||||
>Formulas</a |
||||
>. |
||||
</div> |
||||
<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" |
||||
@click.prevent.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" /> |
||||
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" /> |
||||
</template> |
||||
</a-list-item-meta> |
||||
</a-list-item> |
||||
</template> |
||||
</a-list> |
||||
</a-drawer> |
||||
</div> |
||||
</template> |
@ -0,0 +1,87 @@
|
||||
// ref : https://medium.com/weekly-webtips/js-implementing-auto-complete-f4c5a5d5c009
|
||||
|
||||
interface Node { |
||||
value: [] |
||||
isLeaf: boolean |
||||
children: Record<string, Node> |
||||
} |
||||
|
||||
export class NcAutocompleteTree { |
||||
trie: Record<string, any> |
||||
suggestions: Record<string, any>[] |
||||
|
||||
constructor() { |
||||
this.trie = {} |
||||
this.suggestions = [] |
||||
} |
||||
|
||||
newNode(): Node { |
||||
return { |
||||
value: [], |
||||
isLeaf: false, |
||||
children: {}, |
||||
} |
||||
} |
||||
|
||||
add(word: Record<string, any>) { |
||||
if (Object.keys(this.trie).length === 0) { |
||||
this.trie = this.newNode() |
||||
} |
||||
|
||||
let root = this.trie |
||||
for (const letter of word.text.toLowerCase()) { |
||||
if (!(letter in root.children)) { |
||||
root.children[letter] = this.newNode() |
||||
} |
||||
root = root.children[letter] |
||||
} |
||||
root.value = root.value || [] |
||||
root.value.push(word) |
||||
} |
||||
|
||||
find(word: string) { |
||||
let root = this.trie |
||||
for (const letter of word) { |
||||
if (letter in root.children) { |
||||
root = root.children[letter] |
||||
} else { |
||||
return null // if not found return null
|
||||
} |
||||
} |
||||
|
||||
return root // return the root where it ends search
|
||||
} |
||||
|
||||
traverse(root: Node) { |
||||
if (root.value && root.value.length) { |
||||
this.suggestions.push(...root.value) |
||||
} |
||||
|
||||
for (const letter in root.children) { |
||||
this.traverse(root.children[letter]) |
||||
} |
||||
} |
||||
|
||||
complete(word: string, CHILDREN = null) { |
||||
this.suggestions = [] |
||||
const root = this.find(word.toLowerCase()) |
||||
|
||||
if (!root) { |
||||
return this.suggestions |
||||
} // cannot suggest anything
|
||||
|
||||
const children = root.children |
||||
|
||||
let spread = 0 |
||||
for (const letter in children) { |
||||
this.traverse(children[letter]) |
||||
spread++ |
||||
|
||||
if (CHILDREN && spread === CHILDREN) { |
||||
break |
||||
} |
||||
} |
||||
|
||||
return this.suggestions |
||||
} |
||||
} |
@ -0,0 +1,448 @@
|
||||
import type { Input as AntInput } from 'ant-design-vue' |
||||
|
||||
const formulaTypes = { |
||||
NUMERIC: 'numeric', |
||||
STRING: 'string', |
||||
DATE: 'date', |
||||
LOGICAL: 'logical', |
||||
COND_EXP: 'conditional_expression', |
||||
} |
||||
|
||||
const formulas: Record<string, any> = { |
||||
AVG: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'Average of input parameters', |
||||
syntax: 'AVG(value1, [value2, ...])', |
||||
examples: ['AVG(10, 5) => 7.5', 'AVG({column1}, {column2})', 'AVG({column1}, {column2}, {column3})'], |
||||
}, |
||||
ADD: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'Sum of input parameters', |
||||
syntax: 'ADD(value1, [value2, ...])', |
||||
examples: ['ADD(5, 5) => 10', 'ADD({column1}, {column2})', 'ADD({column1}, {column2}, {column3})'], |
||||
}, |
||||
DATEADD: { |
||||
type: formulaTypes.DATE, |
||||
validation: { |
||||
args: { |
||||
rqd: 3, |
||||
}, |
||||
}, |
||||
description: 'Adds a "count" units to Datetime.', |
||||
syntax: 'DATEADD(date | datetime, value, ["day" | "week" | "month" | "year"])', |
||||
examples: [ |
||||
'DATEADD({column1}, 2, "day")', |
||||
'DATEADD({column1}, -2, "day")', |
||||
'DATEADD({column1}, 2, "week")', |
||||
'DATEADD({column1}, -2, "week")', |
||||
'DATEADD({column1}, 2, "month")', |
||||
'DATEADD({column1}, -2, "month")', |
||||
'DATEADD({column1}, 2, "year")', |
||||
'DATEADD({column1}, -2, "year")', |
||||
], |
||||
}, |
||||
AND: { |
||||
type: formulaTypes.COND_EXP, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'TRUE if all expr evaluate to TRUE', |
||||
syntax: 'AND(expr1, [expr2, ...])', |
||||
examples: ['AND(5 > 2, 5 < 10) => 1', 'AND({column1} > 2, {column2} < 10)'], |
||||
}, |
||||
OR: { |
||||
type: formulaTypes.COND_EXP, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'TRUE if at least one expr evaluates to TRUE', |
||||
syntax: 'OR(expr1, [expr2, ...])', |
||||
examples: ['OR(5 > 2, 5 < 10) => 1', 'OR({column1} > 2, {column2} < 10)'], |
||||
}, |
||||
CONCAT: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'Concatenated string of input parameters', |
||||
syntax: 'CONCAT(str1, [str2, ...])', |
||||
examples: ['CONCAT("AA", "BB", "CC") => "AABBCC"', 'CONCAT({column1}, {column2}, {column3})'], |
||||
}, |
||||
TRIM: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Remove trailing and leading whitespaces from input parameter', |
||||
syntax: 'TRIM(str)', |
||||
examples: ['TRIM(" HELLO WORLD ") => "HELLO WORLD"', 'TRIM({column1})'], |
||||
}, |
||||
UPPER: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Upper case converted string of input parameter', |
||||
syntax: 'UPPER(str)', |
||||
examples: ['UPPER("nocodb") => "NOCODB"', 'UPPER({column1})'], |
||||
}, |
||||
LOWER: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Lower case converted string of input parameter', |
||||
syntax: 'LOWER(str)', |
||||
examples: ['LOWER("NOCODB") => "nocodb"', 'LOWER({column1})'], |
||||
}, |
||||
LEN: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Input parameter character length', |
||||
syntax: 'LEN(value)', |
||||
examples: ['LEN("NocoDB") => 6', 'LEN({column1})'], |
||||
}, |
||||
MIN: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'Minimum value amongst input parameters', |
||||
syntax: 'MIN(value1, [value2, ...])', |
||||
examples: ['MIN(1000, 2000) => 1000', 'MIN({column1}, {column2})'], |
||||
}, |
||||
MAX: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
}, |
||||
}, |
||||
description: 'Maximum value amongst input parameters', |
||||
syntax: 'MAX(value1, [value2, ...])', |
||||
examples: ['MAX(1000, 2000) => 2000', 'MAX({column1}, {column2})'], |
||||
}, |
||||
CEILING: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Rounded next largest integer value of input parameter', |
||||
syntax: 'CEILING(value)', |
||||
examples: ['CEILING(1.01) => 2', 'CEILING({column1})'], |
||||
}, |
||||
FLOOR: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Rounded largest integer less than or equal to input parameter', |
||||
syntax: 'FLOOR(value)', |
||||
examples: ['FLOOR(3.1415) => 3', 'FLOOR({column1})'], |
||||
}, |
||||
ROUND: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Nearest integer to the input parameter', |
||||
syntax: 'ROUND(value)', |
||||
examples: ['ROUND(3.1415) => 3', 'ROUND({column1})'], |
||||
}, |
||||
MOD: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 2, |
||||
}, |
||||
}, |
||||
description: 'Remainder after integer division of input parameters', |
||||
syntax: 'MOD(value1, value2)', |
||||
examples: ['MOD(1024, 1000) => 24', 'MOD({column}, 2)'], |
||||
}, |
||||
REPEAT: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 2, |
||||
}, |
||||
}, |
||||
description: 'Specified copies of the input parameter string concatenated together', |
||||
syntax: 'REPEAT(str, count)', |
||||
examples: ['REPEAT("A", 5) => "AAAAA"', 'REPEAT({column}, 5)'], |
||||
}, |
||||
LOG: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: {}, |
||||
description: 'Logarithm of input parameter to the base (default = e) specified', |
||||
syntax: 'LOG([base], value)', |
||||
examples: ['LOG(2, 1024) => 10', 'LOG(2, {column1})'], |
||||
}, |
||||
EXP: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: {}, |
||||
description: 'Exponential value of input parameter (e ^ power)', |
||||
syntax: 'EXP(power)', |
||||
examples: ['EXP(1) => 2.718281828459045', 'EXP({column1})'], |
||||
}, |
||||
POWER: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 2, |
||||
}, |
||||
}, |
||||
description: 'base to the exponent power, as in base ^ exponent', |
||||
syntax: 'POWER(base, exponent)', |
||||
examples: ['POWER(2, 10) => 1024', 'POWER({column1}, 10)'], |
||||
}, |
||||
SQRT: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Square root of the input parameter', |
||||
syntax: 'SQRT(value)', |
||||
examples: ['SQRT(100) => 10', 'SQRT({column1})'], |
||||
}, |
||||
ABS: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Absolute value of the input parameter', |
||||
syntax: 'ABS(value)', |
||||
examples: ['ABS({column1})'], |
||||
}, |
||||
NOW: { |
||||
type: formulaTypes.DATE, |
||||
validation: { |
||||
args: { |
||||
rqd: 0, |
||||
}, |
||||
}, |
||||
description: 'Returns the current time and day', |
||||
syntax: 'NOW()', |
||||
examples: ['NOW() => 2022-05-19 17:20:43'], |
||||
}, |
||||
REPLACE: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 3, |
||||
}, |
||||
}, |
||||
description: 'String, after replacing all occurrences of srchStr with rplcStr', |
||||
syntax: 'REPLACE(str, srchStr, rplcStr)', |
||||
examples: ['REPLACE("AABBCC", "AA", "BB") => "BBBBCC"', 'REPLACE({column1}, {column2}, {column3})'], |
||||
}, |
||||
SEARCH: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 2, |
||||
}, |
||||
}, |
||||
description: 'Index of srchStr specified if found, 0 otherwise', |
||||
syntax: 'SEARCH(str, srchStr)', |
||||
examples: ['SEARCH("HELLO WORLD", "WORLD") => 7', 'SEARCH({column1}, "abc")'], |
||||
}, |
||||
INT: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Integer value of input parameter', |
||||
syntax: 'INT(value)', |
||||
examples: ['INT(3.1415) => 3', 'INT({column1})'], |
||||
}, |
||||
RIGHT: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 2, |
||||
}, |
||||
}, |
||||
description: 'n characters from the end of input parameter', |
||||
syntax: 'RIGHT(str, n)', |
||||
examples: ['RIGHT("HELLO WORLD", 5) => WORLD', 'RIGHT({column1}, 3)'], |
||||
}, |
||||
LEFT: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 2, |
||||
}, |
||||
}, |
||||
description: 'n characters from the beginning of input parameter', |
||||
syntax: 'LEFT(str, n)', |
||||
examples: ['LEFT({column1}, 2)', 'LEFT("ABCD", 2) => "AB"'], |
||||
}, |
||||
SUBSTR: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
min: 2, |
||||
max: 3, |
||||
}, |
||||
}, |
||||
description: 'Substring of length n of input string from the postition specified', |
||||
syntax: ' SUBTR(str, position, [n])', |
||||
examples: ['SUBSTR("HELLO WORLD", 7) => WORLD', 'SUBSTR("HELLO WORLD", 7, 3) => WOR', 'SUBSTR({column1}, 7, 5)'], |
||||
}, |
||||
MID: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 3, |
||||
}, |
||||
}, |
||||
description: 'Alias for SUBSTR', |
||||
syntax: 'MID(str, position, [count])', |
||||
examples: ['MID("NocoDB", 3, 2) => "co"', 'MID({column1}, 3, 2)'], |
||||
}, |
||||
IF: { |
||||
type: formulaTypes.COND_EXP, |
||||
validation: { |
||||
args: { |
||||
min: 2, |
||||
max: 3, |
||||
}, |
||||
}, |
||||
description: 'SuccessCase if expr evaluates to TRUE, elseCase otherwise', |
||||
syntax: 'IF(expr, successCase, elseCase)', |
||||
examples: ['IF(5 > 1, "YES", "NO") => "YES"', 'IF({column} > 1, "YES", "NO")'], |
||||
}, |
||||
SWITCH: { |
||||
type: formulaTypes.COND_EXP, |
||||
validation: { |
||||
args: { |
||||
min: 3, |
||||
}, |
||||
}, |
||||
description: 'Switch case value based on expr output', |
||||
syntax: 'SWITCH(expr, [pattern, value, ..., default])', |
||||
examples: [ |
||||
'SWITCH(1, 1, "One", 2, "Two", "N/A") => "One""', |
||||
'SWITCH(2, 1, "One", 2, "Two", "N/A") => "Two"', |
||||
'SWITCH(3, 1, "One", 2, "Two", "N/A") => "N/A"', |
||||
'SWITCH({column1}, 1, "One", 2, "Two", "N/A")', |
||||
], |
||||
}, |
||||
URL: { |
||||
type: formulaTypes.STRING, |
||||
validation: { |
||||
args: { |
||||
rqd: 1, |
||||
}, |
||||
}, |
||||
description: 'Convert to a hyperlink if it is a valid URL', |
||||
syntax: 'URL(str)', |
||||
examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'], |
||||
}, |
||||
WEEKDAY: { |
||||
type: formulaTypes.NUMERIC, |
||||
validation: { |
||||
args: { |
||||
min: 1, |
||||
max: 2, |
||||
}, |
||||
}, |
||||
description: 'Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default', |
||||
syntax: 'WEEKDAY(date, [startDayOfWeek])', |
||||
examples: ['WEEKDAY("2021-06-09")', 'WEEKDAY(NOW(), "sunday")'], |
||||
}, |
||||
} |
||||
|
||||
const formulaList = Object.keys(formulas) |
||||
|
||||
// ref : https://stackoverflow.com/a/11077016
|
||||
function insertAtCursor(myField: typeof AntInput, myValue: string, len = 0, b = 0) { |
||||
// MOZILLA and others
|
||||
if (myField.selectionStart || myField.selectionStart === 0) { |
||||
const startPos = myField.selectionStart |
||||
const endPos = myField.selectionEnd |
||||
myField.value = myField.value.substring(0, startPos - len) + myValue + myField.value.substring(endPos, myField.value.length) |
||||
const pos = +startPos - len + myValue.length - b |
||||
// https://stackoverflow.com/a/4302688
|
||||
if (myField.setSelectionRange) { |
||||
myField.focus() |
||||
myField.setSelectionRange(pos, pos) |
||||
} else if (myField.createTextRange) { |
||||
const range = myField.createTextRange() |
||||
range.collapse(true) |
||||
range.moveEnd('character', pos) |
||||
range.moveStart('character', pos) |
||||
range.select() |
||||
} |
||||
} else { |
||||
myField.value += myValue |
||||
} |
||||
return myField.value |
||||
} |
||||
|
||||
function ReturnWord(text: string, caretPos: number) { |
||||
const preText = text.substring(0, caretPos) |
||||
if (preText.indexOf(' ') > 0) { |
||||
const words = preText.split(' ') |
||||
return words[words.length - 1] // return last word
|
||||
} else { |
||||
return preText |
||||
} |
||||
} |
||||
|
||||
function getWordUntilCaret(ctrl: typeof AntInput) { |
||||
const caretPos = GetCaretPosition(ctrl) |
||||
const word = ReturnWord(ctrl.value, caretPos) |
||||
return word || '' |
||||
} |
||||
|
||||
function GetCaretPosition(ctrl: typeof AntInput) { |
||||
let CaretPos = 0 |
||||
if (ctrl.selectionStart || ctrl.selectionStart === 0) { |
||||
CaretPos = ctrl.selectionStart |
||||
} |
||||
return CaretPos |
||||
} |
||||
|
||||
export { formulaList, formulas, formulaTypes, getWordUntilCaret, insertAtCursor } |
Loading…
Reference in new issue