Browse Source

feat: Formula colouring and improved suggestions (#9072)

* feat: formula language

* feat: formula coloring and ux improvements

* fix: suggestions generation

* fix: handle undefined editor

* fix: handle formula errors

* fix: update imports

* fix: minor corrections

* fix: test corrections

* fix: increase timeout

* fix: clear existing formulas before pasting

* fix: ux improve

* fix: ux improve

* fix: coloring issue

* fix: remove styles

* fix: handle wrapping

* fix: bug fixes

* fix: strict suggestion handling

* fix: update indent strategy

* fix: handle formula in nested state and unbalanced parens

* fix: formula fix

* chore: sync dependencies
pull/9123/head
Anbarasu 4 months ago committed by GitHub
parent
commit
a5fc9be175
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/nc-gui/components/cell/ClampedText.vue
  2. 28
      packages/nc-gui/components/monaco/Editor.vue
  3. 106
      packages/nc-gui/components/monaco/formula.ts
  4. 3
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  5. 454
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  6. 4
      packages/nc-gui/components/virtual-cell/Formula.vue
  7. 3
      packages/nc-gui/package.json
  8. 23
      packages/nc-gui/plugins/monaco.ts
  9. 17
      packages/nc-gui/utils/urlUtils.ts
  10. 27
      pnpm-lock.yaml
  11. 16
      tests/playwright/pages/Dashboard/Grid/Column/index.ts
  12. 8
      tests/playwright/tests/db/columns/columnFormula.spec.ts

4
packages/nc-gui/components/cell/ClampedText.vue

@ -7,7 +7,7 @@ const props = defineProps<{
<template>
<div v-if="!props.lines || props.lines === 1" class="text-ellipsis overflow-hidden">
<span :style="{ 'word-break': 'keep-all', 'white-space': 'nowrap' }">{{ props.value || '' }}</span>
<span :style="{ 'word-break': 'keep-all', 'white-space': 'nowrap' }">{{ props.value ?? '' }}</span>
</div>
<div
@ -21,6 +21,6 @@ const props = defineProps<{
'word-break': 'break-all',
}"
>
{{ props.value || '' }}
{{ props.value ?? '' }}
</div>
</template>

28
packages/nc-gui/components/monaco/Editor.vue

@ -51,25 +51,6 @@ const vModel = computed<string>({
const isValid = ref(true)
/**
* Adding monaco editor to Vite
*
* @ts-expect-error */
self.MonacoEnvironment = window.MonacoEnvironment = {
async getWorker(_: any, label: string) {
switch (label) {
case 'json': {
const workerBlob = new Blob([JsonWorker], { type: 'text/javascript' })
return await initWorker(URL.createObjectURL(workerBlob))
}
default: {
const workerBlob = new Blob([EditorWorker], { type: 'text/javascript' })
return await initWorker(URL.createObjectURL(workerBlob))
}
}
},
}
const root = ref<HTMLDivElement>()
let editor: MonacoEditor.IStandaloneCodeEditor
@ -98,16 +79,23 @@ onMounted(async () => {
editor = monacoEditor.create(root.value, {
model,
contextmenu: false,
theme: 'vs',
foldingStrategy: 'indentation',
selectOnLineNumbers: true,
language: props.lang,
scrollbar: {
verticalScrollbarSize: 1,
horizontalScrollbarSize: 1,
},
lineNumbers: 'off',
tabSize: 2,
automaticLayout: true,
readOnly,
bracketPairColorization: {
enabled: true,
independentColorPoolPerBracketType: true,
},
minimap: {
enabled: !hideMinimap,
},
@ -163,3 +151,5 @@ watch(
<template>
<div ref="root"></div>
</template>
<style scoped lang="scss"></style>

106
packages/nc-gui/components/monaco/formula.ts

@ -0,0 +1,106 @@
import type { Thenable, editor, languages } from 'monaco-editor'
import { formulas } from 'nocodb-sdk'
const formulaKeyWords = Object.keys(formulas)
const theme: editor.IStandaloneThemeData = {
base: 'vs',
inherit: true,
rules: [
{ token: 'string', foreground: '#007b77', fontStyle: 'bold' },
{ token: 'keyword', foreground: '#00921d', fontStyle: 'bold' },
{ token: 'number', foreground: '#9c6200', fontStyle: 'bold' },
{ token: 'operator', foreground: '#000000' },
{ token: 'identifier', foreground: '#8541f9', fontStyle: 'bold' },
{ token: 'delimiter.parenthesis', foreground: '#333333', fontStyle: 'bold' },
{ token: 'delimiter.brace', foreground: '#8541f9', fontStyle: 'bold' },
{ token: 'invalid', foreground: '#000000' },
],
colors: {
'editor.foreground': '#000000',
'editor.background': '#FFFFFF',
'editorCursor.foreground': '#3366FF',
'editor.selectionBackground': '#3366FF50',
'focusBorder': '#ffffff',
},
}
const generateLanguageDefinition = (identifiers: string[]) => {
identifiers = identifiers.map((identifier) => `{${identifier}}`)
const languageDefinition: languages.IMonarchLanguage | Thenable<languages.IMonarchLanguage> = {
defaultToken: 'invalid',
keywords: formulaKeyWords,
identifiers,
brackets: [
{ open: '(', close: ')', token: 'delimiter.parenthesis' },
{ open: '{', close: '}', token: 'delimiter.brace' },
],
tokenizer: {
root: [
[/"/, { token: 'string.quote', bracket: '@open', next: '@dblString' }],
[/'/, { token: 'string.quote', bracket: '@open', next: '@sglString' }],
[
new RegExp(`\\{(${identifiers.join('|').replace(/[{}]/g, '')})\\}`),
{
cases: {
'@identifiers': 'identifier',
'@default': 'invalid',
},
},
],
[
/[a-zA-Z_]\w*/,
{
cases: {
'@keywords': 'keyword',
'@default': 'invalid',
},
},
],
[/\d+/, 'number'],
[/[-+/*=<>!]+/, 'operator'],
[/[{}()\[\]]/, '@brackets'],
[/[ \t\r\n]+/, 'white'],
],
dblString: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
sglString: [
[/[^\\']+/, 'string'],
[/\\./, 'string.escape'],
[/'/, { token: 'string.quote', bracket: '@close', next: '@pop' }],
],
},
}
return languageDefinition
}
const languageConfiguration: languages.LanguageConfiguration = {
brackets: [
['{', '}'],
['[', ']'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '[', close: ']' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
}
export default {
name: 'formula',
theme,
generateLanguageDefinition,
languageConfiguration,
}

3
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -356,12 +356,11 @@ const isFullUpdateAllowed = computed(() => {
<template>
<div
v-if="!warningVisible"
class="overflow-auto max-h-[max(80vh,500px)]"
class="overflow-auto nc-scrollbar-md max-h-[max(80vh,500px)]"
:class="{
'bg-white': !props.fromTableExplorer,
'w-[384px]': !props.embedMode,
'min-w-[500px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'overflow-visible': formState.uidt === UITypes.Formula,
'!w-[600px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'min-w-[422px] !w-full': isLinksOrLTAR(formState.uidt),
'shadow-lg shadow-gray-300 border-1 border-gray-200 rounded-xl p-5': !embedMode,

454
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -2,6 +2,9 @@
import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep'
import type { editor as MonacoEditor } from 'monaco-editor'
import { KeyCode, Position, Range, languages, editor as monacoEditor } from 'monaco-editor'
import {
FormulaDataTypes,
FormulaError,
@ -14,18 +17,18 @@ import {
validateFormulaAndExtractTreeWithType,
} from 'nocodb-sdk'
import type { ColumnType, FormulaType } from 'nocodb-sdk'
import formulaLanguage from '../../monaco/formula'
const props = defineProps<{
value: any
}>()
const emit = defineEmits(['update:value'])
const uiTypesNotSupportedInFormulas = [UITypes.QrCode, UITypes.Barcode]
const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, sqlUi, column, fromTableExplorer } = useColumnCreateStoreOrThrow()
const { setAdditionalValidations, sqlUi, column, validateInfos, fromTableExplorer } = useColumnCreateStoreOrThrow()
const { t } = useI18n()
@ -53,6 +56,9 @@ const { getMeta } = useMetas()
const suggestionPreviewed = ref<Record<any, string> | undefined>()
// If -1 Show Fields first, if 1 show Formulas first
const priority = ref(0)
const showFunctionList = ref<boolean>(true)
const validators = {
@ -89,8 +95,6 @@ const availableBinOps = ['+', '-', '*', '/', '>', '<', '==', '<=', '>=', '!=', '
const autocomplete = ref(false)
const formulaRef = ref()
const variableListRef = ref<(typeof AntListItem)[]>([])
const sugOptionsRef = ref<(typeof AntListItem)[]>([])
@ -169,31 +173,276 @@ const variableList = computed(() => {
return suggestion.value.filter((s) => s && s.type === 'column')
})
function isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets
const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce(
(acc: Record<number, number>, cur: number) => {
acc[cur] = (acc[cur] || 0) + 1
return acc
const monacoRoot = ref<HTMLDivElement>()
let editor: MonacoEditor.IStandaloneCodeEditor
function getCurrentKeyword() {
const model = editor.getModel()
const position = editor.getPosition()
if (!model || !position) {
return null
}
const word = model.getWordAtPosition(position)
return word?.word
}
const handleInputDeb = useDebounceFn(function () {
handleInput()
}, 250)
onMounted(async () => {
if (monacoRoot.value) {
const model = monacoEditor.createModel(vModel.value.formula_raw, 'formula')
languages.register({
id: formulaLanguage.name,
})
monacoEditor.defineTheme(formulaLanguage.name, formulaLanguage.theme)
languages.setMonarchTokensProvider(
formulaLanguage.name,
formulaLanguage.generateLanguageDefinition(supportedColumns.value.map((c) => c.title!)),
)
languages.setLanguageConfiguration(formulaLanguage.name, formulaLanguage.languageConfiguration)
monacoEditor.addKeybindingRules([
{
keybinding: KeyCode.DownArrow,
when: 'editorTextFocus',
},
{
keybinding: KeyCode.UpArrow,
when: 'editorTextFocus',
},
])
editor = monacoEditor.create(monacoRoot.value, {
model,
'contextmenu': false,
'theme': 'formula',
'selectOnLineNumbers': false,
'language': 'formula',
'roundedSelection': false,
'scrollBeyondLastLine': false,
'lineNumbers': 'off',
'glyphMargin': false,
'folding': false,
'wordWrap': 'on',
'wrappingStrategy': 'advanced',
// This seems to be a bug in the monoco.
// https://github.com/microsoft/monaco-editor/issues/4535#issuecomment-2234042290
'bracketPairColorization.enabled': false,
'padding': {
top: 8,
bottom: 8,
},
'lineDecorationsWidth': 8,
'lineNumbersMinChars': 0,
'renderLineHighlight': 'none',
'renderIndentGuides': false,
'scrollbar': {
horizontal: 'hidden',
},
'tabSize': 2,
'automaticLayout': false,
'overviewRulerLanes': 0,
'hideCursorInOverviewRuler': true,
'overviewRulerBorder': false,
'matchBrackets': 'never',
'minimap': {
enabled: false,
},
})
editor.layout({
width: 339,
height: 120,
})
editor.onDidChangeModelContent(async () => {
vModel.value.formula_raw = editor.getValue()
await handleInputDeb()
})
editor.onDidChangeCursorPosition(() => {
const position = editor.getPosition()
const model = editor.getModel()
if (!position || !model) return
const text = model.getValue()
const offset = model.getOffsetAt(position)
// IF cursor is inside string, don't show any suggestions
if (isCursorInsideString(text, offset)) {
autocomplete.value = false
suggestion.value = []
} else {
handleInput()
}
const findEnclosingFunction = (text: string, offset: number) => {
const formulaRegex = /\b(?<!['"])(\w+)\s*\(/g // Regular expression to match function names
const quoteRegex = /"/g // Regular expression to match quotes
const functionStack = [] // Stack to keep track of functions
let inQuote = false
let match
while ((match = formulaRegex.exec(text)) !== null) {
if (match.index > offset) break
if (!inQuote) {
const functionData = {
name: match[1],
start: match.index,
end: formulaRegex.lastIndex,
}
let parenBalance = 1
let childValueStart = -1
let childValueEnd = -1
for (let i = formulaRegex.lastIndex; i < text.length; i++) {
if (text[i] === '(') {
parenBalance++
} else if (text[i] === ')') {
parenBalance--
if (parenBalance === 0) {
functionData.end = i + 1
break
}
}
// Child value handling
if (childValueStart === -1 && ['(', ',', '{'].includes(text[i])) {
childValueStart = i
} else if (childValueStart !== -1 && ['(', ',', '{'].includes(text[i])) {
childValueStart = i
} else if (childValueStart !== -1 && ['}', ',', ')'].includes(text[i])) {
childValueEnd = i
childValueStart = -1
}
if (i >= offset) {
// If we've reached the offset and parentheses are still open, consider the current position as the end of the function
if (parenBalance > 0) {
functionData.end = i + 1
break
}
// Check for nested functions
const nestedFunction = findEnclosingFunction(
text.substring(functionData.start + match[1].length + 1, i),
offset - functionData.start - match[1].length - 1,
)
if (nestedFunction) {
return nestedFunction
} else {
functionStack.push(functionData)
break
}
}
}
// If child value ended before offset, use child value end as function end
if (childValueEnd !== -1 && childValueEnd < offset) {
functionData.end = childValueEnd + 1
}
functionStack.push(functionData)
}
// Check for quotes
let quoteMatch
while ((quoteMatch = quoteRegex.exec(text)) !== null && quoteMatch.index < match.index) {
inQuote = !inQuote
}
}
const enclosingFunctions = functionStack.filter((func) => func.start <= offset && func.end >= offset)
return enclosingFunctions.length > 0 ? enclosingFunctions[enclosingFunctions.length - 1].name : null
}
const lastFunction = findEnclosingFunction(text, offset)
suggestionPreviewed.value =
(suggestionsList.value.find((s) => s.text === `${lastFunction}()`) as Record<any, string>) || undefined
})
editor.focus()
}
})
useResizeObserver(monacoRoot, (entries) => {
const entry = entries[0]
const { height } = entry.contentRect
editor.layout({
width: 339,
height,
})
})
function insertStringAtPosition(editor: MonacoEditor.IStandaloneCodeEditor, text: string, skipCursorMove = false) {
const position = editor.getPosition()
if (!position) {
return
}
const range = new Range(position.lineNumber, position.column, position.lineNumber, position.column)
editor.executeEdits('', [
{
range,
text,
forceMoveMarkers: true,
},
{},
)
return (cntCurlyBrackets['{'] || 0) === (cntCurlyBrackets['}'] || 0)
])
// Move the cursor to the end of the inserted text
if (!skipCursorMove) {
const newPosition = new Position(position.lineNumber, position.column + text.length - 1)
editor.setPosition(newPosition)
}
editor.focus()
}
function appendText(item: Record<string, any>) {
const text = item.text
const len = wordToComplete.value?.length || 0
if (!item?.text) return
const text = item.text
const position = editor.getPosition()
if (!position) {
return // No cursor position available
}
const newColumn = Math.max(1, position.column - len)
const range = new Range(position.lineNumber, newColumn, position.lineNumber, position.column)
editor.executeEdits('', [
{
range,
text: '',
forceMoveMarkers: true,
},
])
if (item.type === 'function') {
vModel.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len, 1)
insertStringAtPosition(editor, text)
} else if (item.type === 'column') {
vModel.value.formula_raw = insertAtCursor(formulaRef.value.$el, `{${text}}`, len + +!isCurlyBracketBalanced())
insertStringAtPosition(editor, `{${text}}`, true)
} else {
vModel.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len)
insertStringAtPosition(editor, text, true)
}
autocomplete.value = false
wordToComplete.value = ''
if (item.type === 'function' || item.type === 'op') {
// if function / operator is chosen, display columns only
suggestion.value = suggestionsList.value.filter((f) => f.type === 'column')
@ -203,27 +452,93 @@ function appendText(item: Record<string, any>) {
}
}
const handleInputDeb = useDebounceFn(function () {
handleInput()
}, 250)
function isCursorBetweenParenthesis() {
const cursorPosition = editor?.getPosition()
if (!cursorPosition || !editor) return false
const line = editor.getModel()?.getLineContent(cursorPosition.lineNumber)
if (!line) {
return false
}
const cursorLine = line.substring(0, cursorPosition.column - 1)
const openParenthesis = (cursorLine.match(/\(/g) || []).length
const closeParenthesis = (cursorLine.match(/\)/g) || []).length
return openParenthesis > closeParenthesis
}
// Function to check if cursor is inside Strings
function isCursorInsideString(text: string, offset: number) {
let inSingleQuoteString = false
let inDoubleQuoteString = false
let escapeNextChar = false
for (let i = 0; i < offset; i++) {
const char = text[i]
if (escapeNextChar) {
escapeNextChar = false
continue
}
if (char === '\\') {
escapeNextChar = true
continue
}
if (char === "'" && !inDoubleQuoteString) {
inSingleQuoteString = !inSingleQuoteString
} else if (char === '"' && !inSingleQuoteString) {
inDoubleQuoteString = !inDoubleQuoteString
}
}
return inDoubleQuoteString || inSingleQuoteString
}
function handleInput() {
if (!editor) return
const model = editor.getModel()
const position = editor.getPosition()
if (!model || !position) return
const text = model.getValue()
const offset = model.getOffsetAt(position)
// IF cursor is inside string, don't show any suggestions
if (isCursorInsideString(text, offset)) {
autocomplete.value = false
suggestion.value = []
return
}
if (!isCursorBetweenParenthesis()) priority.value = 1
selected.value = 0
suggestion.value = []
const query = getWordUntilCaret(formulaRef.value.$el)
const query = getCurrentKeyword() ?? ''
const parts = query.split(/\W+/)
wordToComplete.value = parts.pop() || ''
suggestion.value = acTree.value
.complete(wordToComplete.value)
?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type])
if (suggestion.value.length > 0 && suggestion.value[0].type !== 'column') {
suggestionPreviewed.value = suggestion.value[0]
}
if (!isCurlyBracketBalanced()) {
suggestion.value = suggestion.value.filter((v) => v.type === 'column')
showFunctionList.value = false
if (isCursorBetweenParenthesis()) {
// Show columns at top
suggestion.value = suggestion.value.sort((a, b) => {
if (a.type === 'column') return -1
if (b.type === 'column') return 1
return 0
})
selected.value = 0
priority.value = -1
} else if (!showFunctionList.value) {
showFunctionList.value = true
}
@ -318,19 +633,19 @@ const suggestionPreviewPostion = ref({
})
onMounted(() => {
until(() => formulaRef.value?.$el as Ref<HTMLTextAreaElement>)
until(() => monacoRoot.value as HTMLDivElement)
.toBeTruthy()
.then(() => {
setTimeout(() => {
const textAreaPosition = formulaRef.value?.$el?.getBoundingClientRect()
if (!textAreaPosition) return
const monacoDivPosition = monacoRoot.value?.getBoundingClientRect()
if (!monacoDivPosition) return
suggestionPreviewPostion.value.top = `${textAreaPosition.top}px`
suggestionPreviewPostion.value.top = `${monacoDivPosition.top}px`
if (fromTableExplorer?.value || textAreaPosition.left > 352) {
suggestionPreviewPostion.value.left = `${textAreaPosition.left - 344}px`
if (fromTableExplorer?.value || monacoDivPosition.left > 352) {
suggestionPreviewPostion.value.left = `${monacoDivPosition.left - 344}px`
} else {
suggestionPreviewPostion.value.left = `${textAreaPosition.right + 8}px`
suggestionPreviewPostion.value.left = `${monacoDivPosition.right + 8}px`
}
}, 250)
})
@ -423,7 +738,7 @@ watch(parsedTree, (value, oldValue) => {
>
<div class="pr-3">
<div class="flex flex-row w-full justify-between pb-2 border-b-1">
<div class="flex items-center gap-x-1 font-semibold text-base text-gray-600">
<div class="flex items-center gap-x-1 font-semibold text-lg text-gray-600">
<component :is="iconMap.function" class="text-lg" />
{{ suggestionPreviewed.text }}
</div>
@ -433,17 +748,17 @@ watch(parsedTree, (value, oldValue) => {
</div>
</div>
<div class="flex flex-col max-h-120 nc-scrollbar-thin pr-2">
<div class="flex mt-3 text-sm">{{ suggestionPreviewed.description }}</div>
<div class="flex mt-3 text-[13px] leading-6">{{ suggestionPreviewed.description }}</div>
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Syntax</div>
<div class="bg-white rounded-md py-1 px-2 border-1">{{ suggestionPreviewed.syntax }}</div>
<div class="text-gray-500 uppercase text-xs mt-3 mb-2">Examples</div>
<div class="text-gray-500 uppercase text-[11px] mt-3 mb-2">Syntax</div>
<div class="bg-white rounded-md py-1 text-[13px] mono-font leading-6 px-2 border-1">{{ suggestionPreviewed.syntax }}</div>
<div class="text-gray-500 uppercase text-[11px] mt-3 mb-2">Examples</div>
<div
v-for="(example, index) of suggestionPreviewed.examples"
:key="example"
class="bg-gray-100 py-1 px-2"
class="bg-gray-100 mono-font text-[13px] leading-6 py-1 px-2"
:class="{
'border-t-1 border-gray-200': index !== 0,
'border-t-1 border-gray-200': index !== 0,
'rounded-b-md': index === suggestionPreviewed.examples.length - 1 && suggestionPreviewed.examples.length !== 1,
'rounded-t-md': index === 0 && suggestionPreviewed.examples.length !== 1,
'rounded-md': suggestionPreviewed.examples.length === 1,
@ -471,17 +786,18 @@ watch(parsedTree, (value, oldValue) => {
</template>
<div class="px-0.5">
<a-form-item class="mt-4" v-bind="validateInfos.formula_raw">
<a-textarea
ref="formulaRef"
v-model:value="vModel.formula_raw"
class="nc-formula-input !rounded-md"
@keydown="handleKeydown"
@change="handleInputDeb"
/>
<div
ref="monacoRoot"
:class="{
'!border-red-500 formula-error': validateInfos.formula_raw?.validateStatus === 'error',
'!focus-within:border-brand-500 formula-success': validateInfos.formula_raw?.validateStatus !== 'error',
}"
class="formula-monaco"
@keydown.stop="handleKeydown"
></div>
</a-form-item>
<div class="h-[250px] overflow-auto nc-scrollbar-thin border-1 border-gray-200 rounded-lg mt-4">
<template v-if="suggestedFormulas && showFunctionList">
<div class="h-[250px] overflow-auto flex flex-col nc-scrollbar-thin border-1 border-gray-200 rounded-lg mt-4">
<div v-if="suggestedFormulas && showFunctionList" :style="{ order: priority === -1 ? 2 : 1 }">
<div class="border-b-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs font-semibold sticky top-0 z-10">
Formulas
</div>
@ -520,9 +836,9 @@ watch(parsedTree, (value, oldValue) => {
</a-list-item>
</template>
</a-list>
</template>
</div>
<template v-if="variableList">
<div v-if="variableList" :style="{ order: priority === 1 ? 2 : 1 }">
<div class="border-b-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs font-semibold sticky top-0 z-10">
Fields
</div>
@ -549,7 +865,7 @@ watch(parsedTree, (value, oldValue) => {
<a-list-item-meta class="nc-variable-list-item">
<template #title>
<div class="flex items-center gap-x-1 justify-between">
<div class="flex items-center gap-x-1 rounded-md bg-gray-200 px-1 h-5">
<div class="flex items-center gap-x-1 rounded-md px-1 h-5">
<component :is="item.icon" class="w-4 h-4 !text-gray-600" />
<span class="text-small leading-[18px] text-gray-800 font-weight-500">{{ item.text }}</span>
@ -568,7 +884,7 @@ watch(parsedTree, (value, oldValue) => {
</a-list-item>
</template>
</a-list>
</template>
</div>
</div>
</div>
</a-tab-pane>
@ -675,6 +991,10 @@ watch(parsedTree, (value, oldValue) => {
@apply !pl-0;
}
:deep(.ant-form-item-control-input) {
@apply h-full;
}
:deep(.ant-tabs-content-holder) {
@apply mt-4;
}
@ -690,4 +1010,26 @@ watch(parsedTree, (value, oldValue) => {
:deep(.ant-tabs-tab-btn) {
@apply !mb-1;
}
.formula-monaco {
@apply rounded-md nc-scrollbar-md border-gray-200 border-1 overflow-y-auto overflow-x-hidden resize-y;
min-height: 100px;
height: 120px;
max-height: 250px;
&:focus-within:not(.formula-error) {
box-shadow: 0 0 0 2px var(--ant-primary-color-outline);
}
&:focus-within:not(.formula-success) {
box-shadow: 0 0 0 2px var(--ant-error-color-outline);
}
.view-line {
width: auto !important;
}
}
.mono-font {
font-family: 'JetBrainsMono', monospace;
}
</style>

4
packages/nc-gui/components/virtual-cell/Formula.vue

@ -21,6 +21,8 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activ
const isNumber = computed(() => (column.value.colOptions as any)?.parsed_tree?.dataType === FormulaDataTypes.NUMERIC)
const rowHeight = inject(RowHeightInj, ref(undefined))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
@ -47,7 +49,7 @@ const isGrid = inject(IsGridInj, ref(false))
<div v-else class="nc-cell-field py-1" @dblclick="activateShowEditNonEditableFieldWarning">
<div v-if="urls" v-html="urls" />
<div v-else>{{ result }}</div>
<LazyCellClampedText v-else :value="result" :lines="rowHeight" />
<div v-if="showEditNonEditableFieldWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
{{ $t('msg.info.computedFieldEditWarning') }}

3
packages/nc-gui/package.json

@ -66,6 +66,7 @@
"emoji-mart-vue-fast": "^15.0.2",
"file-saver": "^2.0.5",
"fuse.js": "^6.6.2",
"html-entities": "^2.5.2",
"httpsnippet": "^2.0.0",
"inflection": "^1.13.4",
"jsbarcode": "^3.11.6",
@ -75,7 +76,7 @@
"leaflet.markercluster": "^1.5.3",
"locale-codes": "^1.3.1",
"marked": "^4.3.0",
"monaco-editor": "^0.45.0",
"monaco-editor": "^0.50.0",
"monaco-sql-languages": "^0.11.0",
"nocodb-sdk": "workspace:^",
"papaparse": "^5.4.1",

23
packages/nc-gui/plugins/monaco.ts

@ -0,0 +1,23 @@
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline'
export default defineNuxtPlugin(() => {
/**
* Adding monaco editor to Vite
*
* @ts-expect-error */
self.MonacoEnvironment = window.MonacoEnvironment = {
async getWorker(_: any, label: string) {
switch (label) {
case 'json': {
const workerBlob = new Blob([JsonWorker], { type: 'text/javascript' })
return await initWorker(URL.createObjectURL(workerBlob))
}
default: {
const workerBlob = new Blob([EditorWorker], { type: 'text/javascript' })
return await initWorker(URL.createObjectURL(workerBlob))
}
}
},
}
})

17
packages/nc-gui/utils/urlUtils.ts

@ -1,35 +1,36 @@
import isURL from 'validator/lib/isURL'
import { decode } from 'html-entities'
export const replaceUrlsWithLink = (text: string): boolean | string => {
if (!text) {
return false
}
const rawText = text.toString()
// Create a temporary element to sanitize the string by encoding any HTML code
const tempEl = document.createElement('div')
tempEl.textContent = rawText
const sanitisedText = tempEl.innerHTML
const protocolRegex = /^(https?|ftp|mailto|file):\/\//
let isUrl
const out = sanitisedText.replace(/URI::\(([^)]*)\)(?: LABEL::\(([^)]*)\))?/g, (_, url, label) => {
const out = rawText.replace(/URI::\(([^)]*)\)(?: LABEL::\(([^)]*)\))?/g, (_, url, label) => {
if (!url.trim() && !label) {
return ' '
}
const fullUrl = protocolRegex.test(url) ? url : url.trim() ? `http://${url}` : ''
isUrl = isURL(fullUrl)
const anchorLabel = label || url || ''
const a = document.createElement('a')
a.textContent = anchorLabel
a.setAttribute('href', fullUrl)
a.setAttribute('href', decode(fullUrl))
a.setAttribute('class', ' nc-cell-field-link')
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener,noreferrer')
return a.outerHTML
})
return out
return isUrl ? out : false
}
export const isValidURL = (str: string, extraProps?) => {

27
pnpm-lock.yaml

@ -127,6 +127,9 @@ importers:
fuse.js:
specifier: ^6.6.2
version: 6.6.2
html-entities:
specifier: ^2.5.2
version: 2.5.2
httpsnippet:
specifier: ^2.0.0
version: 2.0.0(mkdirp@2.1.6)
@ -155,8 +158,8 @@ importers:
specifier: ^4.3.0
version: 4.3.0
monaco-editor:
specifier: ^0.45.0
version: 0.45.0
specifier: ^0.50.0
version: 0.50.0
monaco-sql-languages:
specifier: ^0.11.0
version: 0.11.0
@ -430,7 +433,7 @@ importers:
version: 0.26.0(vue@3.4.34)
vite-plugin-monaco-editor:
specifier: ^1.1.0
version: 1.1.0(monaco-editor@0.45.0)
version: 1.1.0(monaco-editor@0.50.0)
vite-plugin-purge-icons:
specifier: ^0.10.0
version: 0.10.0(vite@4.5.3)
@ -18136,6 +18139,10 @@ packages:
resolution: {integrity: sha512-Cc/RSOGlojr7NDw1oXamUQenYBB0f/SISO8QWtRdZkDOmlO/hvbGZMjgyl+6+mh2PKPRrGXUKH4JhCU18LNS2g==}
dev: false
/html-entities@2.5.2:
resolution: {integrity: sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==}
dev: false
/html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
dev: true
@ -21362,8 +21369,8 @@ packages:
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
dev: false
/monaco-editor@0.45.0:
resolution: {integrity: sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==}
/monaco-editor@0.50.0:
resolution: {integrity: sha512-8CclLCmrRRh+sul7C08BmPBP3P8wVWfBHomsTcndxg5NRCEPfu/mc2AGU8k37ajjDVXcXFc12ORAMUkmk+lkFA==}
/monaco-sql-languages@0.11.0:
resolution: {integrity: sha512-OoTQVLT6ldBXPEbQPw43hOy+u16dO+HkY/rCADXPM5NfRBQ1m9+04NdaQ94WcF2R6MPeansxW8AveIdjEhxqfw==}
@ -25925,9 +25932,6 @@ packages:
/sqlite3@5.1.6:
resolution: {integrity: sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==}
requiresBuild: true
peerDependenciesMeta:
node-gyp:
optional: true
dependencies:
'@mapbox/node-pre-gyp': 1.0.11
node-addon-api: 4.3.0
@ -25942,9 +25946,6 @@ packages:
/sqlite3@5.1.7:
resolution: {integrity: sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==}
requiresBuild: true
peerDependenciesMeta:
node-gyp:
optional: true
dependencies:
bindings: 1.5.0
node-addon-api: 7.0.0
@ -28205,12 +28206,12 @@ packages:
- supports-color
dev: true
/vite-plugin-monaco-editor@1.1.0(monaco-editor@0.45.0):
/vite-plugin-monaco-editor@1.1.0(monaco-editor@0.50.0):
resolution: {integrity: sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==}
peerDependencies:
monaco-editor: '>=0.33.0'
dependencies:
monaco-editor: 0.45.0
monaco-editor: 0.50.0
dev: true
/vite-plugin-purge-icons@0.10.0(vite@4.5.3):

16
tests/playwright/pages/Dashboard/Grid/Column/index.ts

@ -143,7 +143,7 @@ export class ColumnPageObject extends BasePage {
await this.rootPage.locator('.ant-select-item').locator(`text="${timeFormat}"`).click();
break;
case 'Formula':
await this.get().locator('.nc-formula-input').fill(formula);
await this.get().locator('.inputarea').fill(formula);
break;
case 'QrCode':
await this.get().locator('.ant-select-single').nth(1).click();
@ -222,7 +222,6 @@ export class ColumnPageObject extends BasePage {
await this.ltarOption.addFilters(ltarFilters);
}
if (custom) {
// enable advance options
await this.get().locator('.nc-ltar-relation-type >> .ant-radio').nth(1).dblclick();
@ -387,9 +386,18 @@ export class ColumnPageObject extends BasePage {
await this.defaultValueBtn().click();
switch (type) {
case 'Formula':
await this.get().locator('.nc-formula-input').fill(formula);
case 'Formula': {
const element = this.get().locator('.inputarea');
await element.focus();
await this.rootPage.keyboard.press('Control+A');
await this.rootPage.waitForTimeout(200);
await this.rootPage.keyboard.press('Backspace');
await this.rootPage.waitForTimeout(200);
await element.fill(formula);
break;
}
case 'Duration':
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.ant-select-item`).getByTestId(format).click();

8
tests/playwright/tests/db/columns/columnFormula.spec.ts

@ -145,13 +145,7 @@ const formulaDataByDbType = (context: NcContext, index: number) => {
},
{
formula: 'URLENCODE({City})',
result: [
'A%20Corua%20(La%20Corua)',
'Abha',
'Abu%20Dhabi',
'Acua',
'Adana',
],
result: ['A%20Corua%20(La%20Corua)', 'Abha', 'Abu%20Dhabi', 'Acua', 'Adana'],
},
];
else

Loading…
Cancel
Save