mirror of https://github.com/nocodb/nocodb
Wing-Kam Wong
2 years ago
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