Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow creation transformation functions over data lake variables #1706

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
365 changes: 365 additions & 0 deletions src/components/TransformingFunctionDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
<template>
<v-dialog :model-value="modelValue" max-width="600px" @update:model-value="emit('update:modelValue', $event)">
<v-card class="rounded-lg" :style="interfaceStore.globalGlassMenuStyles">
<v-card-title class="text-h6 font-weight-bold py-4 text-center">
{{ editingExistingFunction ? 'Edit Compound Variable' : 'New Compound Variable' }}
</v-card-title>
<v-card-text class="px-8">
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<v-text-field
v-model="newFunction.id"
label="Variable ID"
variant="outlined"
:disabled="editingExistingFunction || !isManualIdEnabled"
:rules="[(v) => !!v || 'ID is required']"
class="flex-1"
/>
<v-btn
class="self-start mt-1"
variant="text"
icon="mdi-pencil"
:color="isManualIdEnabled ? 'white' : 'grey'"
:disabled="editingExistingFunction"
:style="{ cursor: editingExistingFunction ? 'not-allowed' : 'pointer' }"
@click="toggleManualIdEditing"
/>
</div>
<v-text-field
v-model="newFunction.name"
label="Variable Name"
variant="outlined"
:rules="[(v) => !!v || 'Name is required']"
/>
<v-select
v-model="newFunction.type"
label="Variable Type"
variant="outlined"
:items="['string', 'number', 'boolean']"
:rules="[(v) => !!v || 'Type is required']"
/>
<div class="flex flex-col gap-2">
<label class="text-sm">Expression</label>
<div
ref="expressionEditorContainer"
class="h-[300px] border border-[#FFFFFF33] rounded-lg overflow-hidden"
/>
<div class="text-xs text-gray-400">
Create complex transformations by combining existing Data Lake variables using JavaScript expressions.
Type '\{\{' to access available variables. Return is optional, but should be included in complex
expressions. Remember to set the type accordingly.
</div>
</div>
<v-textarea
v-model="newFunction.description"
label="Description"
variant="outlined"
placeholder="Optional description of what this transformation does"
rows="2"
/>
</div>
</v-card-text>
<v-divider class="mt-2 mx-10" />
<v-card-actions>
<div class="flex justify-between items-center pa-2 w-full h-full">
<v-btn color="white" variant="text" @click="closeDialog">Cancel</v-btn>
<v-btn color="primary" @click="saveTransformingFunction">Save</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script setup lang="ts">
import * as monaco from 'monaco-editor'
// @ts-ignore: Worker imports
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
// @ts-ignore: Worker imports
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

import { DataLakeVariable, getAllDataLakeVariablesInfo } from '@/libs/actions/data-lake'
import {
createTransformingFunction,
TransformingFunction,
updateTransformingFunction,
} from '@/libs/actions/data-lake-transformations'
import { machinizeString } from '@/libs/utils'
import { useAppInterfaceStore } from '@/stores/appInterface'

/**
* Props for the TransformingFunctionDialog component
*/
const props = defineProps<{
/** Whether the dialog is visible */
modelValue: boolean
/** The function to edit, if in edit mode */
editFunction?: TransformingFunction
}>()

const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()

const interfaceStore = useAppInterfaceStore()
const editingExistingFunction = computed(() => !!props.editFunction)
const isManualIdEnabled = ref(false)

const defaultValues = {
id: '',
name: '',
type: 'number' as 'string' | 'number' | 'boolean',
expression: '',
description: '',
}

const newFunction = ref(defaultValues)
// Auto-update ID from name when name changes (if manual ID editing is not enabled)
watch(
() => newFunction.value.name,
(newName) => {
if (!isManualIdEnabled.value && !editingExistingFunction.value) {
newFunction.value.id = machinizeString(newName)
}
}
)

// Initialize newFunction when editing
watch(
() => props.editFunction,
(func) => {
if (func) {
newFunction.value = {
id: func.id,
name: func.name,
type: func.type,
expression: func.expression,
description: func.description || '',
}
} else {
newFunction.value = { ...defaultValues }
}
},
{ immediate: true }
)

const variablesMap = ref<Record<string, DataLakeVariable>>({})

const closeDialog = (): void => {
emit('update:modelValue', false)
newFunction.value = { ...defaultValues }
isManualIdEnabled.value = false
}

// Toggle manual ID editing mode
const toggleManualIdEditing = (): void => {
if (!editingExistingFunction.value) {
isManualIdEnabled.value = !isManualIdEnabled.value
}
}

const saveTransformingFunction = (): void => {
if (!newFunction.value.name || !newFunction.value.expression || !newFunction.value.type) return

if (editingExistingFunction.value && props.editFunction) {
const { id: _, ...otherProps } = newFunction.value
updateTransformingFunction({
id: props.editFunction.id,
...otherProps,
})
} else {
createTransformingFunction(
newFunction.value.id,
newFunction.value.name,
newFunction.value.type,
newFunction.value.expression,
newFunction.value.description
)
}

emit('saved')
closeDialog()
}

const expressionEditorContainer = ref<HTMLElement | null>(null)
let expressionEditor: monaco.editor.IStandaloneCodeEditor | null = null
let completionProvider: monaco.IDisposable | null = null

// Configure Monaco environment
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'typescript' || label === 'javascript') {
return new tsWorker()
}
return new editorWorker()
},
}

// Configure custom language for data lake expressions
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true,
diagnosticCodesToIgnore: [1005, 1128, 7027],
})

// Add custom tokens to JavaScript language
monaco.languages.setMonarchTokensProvider('javascript', {
tokenizer: {
root: [
[/\{\{[^}]*\}\}/, { token: 'variable.name.data-lake' }],
[/[a-z_$][\w$]*/, 'identifier'],
[/[A-Z][\w$]*/, 'type.identifier'],
[/"([^"\\]|\\.)*$/, 'string.invalid'],
[/'([^'\\]|\\.)*$/, 'string.invalid'],
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
[/[0-9]+/, 'number'],
[/[,.]/, 'delimiter'],
[/[()]/, '@brackets'],
[/[{}]/, '@brackets'],
[/[[\]]/, '@brackets'],
[/[;]/, 'delimiter'],
[/[+\-*/=<>!&|^~?:]/, 'operator'],
[/@[a-zA-Z]+/, 'annotation'],
[/\s+/, 'white'],
],
string_double: [
[/[^"]+/, 'string'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^']+/, 'string'],
[/'/, 'string', '@pop'],
],
},
})

// Create custom theme to style our data lake variables
monaco.editor.defineTheme('data-lake-dark', {
base: 'vs-dark',
inherit: true,
rules: [{ token: 'variable.name.data-lake', foreground: '4EC9B0', fontStyle: 'italic' }],
colors: {},
})

// Create Monaco editor
const createEditor = (container: HTMLElement, value: string): monaco.editor.IStandaloneCodeEditor => {
return monaco.editor.create(container, {
value,
language: 'javascript',
theme: 'data-lake-dark',
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
padding: { top: 12, bottom: 12 },
autoClosingBrackets: 'never',
autoClosingQuotes: 'never',
})
}

// Initialize editor
const initEditor = async (): Promise<void> => {
if (!expressionEditorContainer.value || expressionEditor) return
expressionEditor = createEditor(expressionEditorContainer.value, newFunction.value.expression)

// Dispose of previous completion provider if it exists
if (completionProvider) {
completionProvider.dispose()
completionProvider = null
}

// Configure auto-completion for data lake variables
completionProvider = monaco.languages.registerCompletionItemProvider('javascript', {
triggerCharacters: ['{'],
provideCompletionItems: (model, position) => {
const textUntilPosition = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
})

if (!textUntilPosition.endsWith('{{')) {
return { suggestions: [] }
}

const suggestions = Object.entries(variablesMap.value).map(([id, variable]) => ({
label: variable.name,
kind: monaco.languages.CompletionItemKind.Variable,
insertText: `${id}}}`,
detail: `${variable.type} - ${variable.description || 'No description'}`,
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
}))

return { suggestions }
},
})

// Watch for changes
expressionEditor.onDidChangeModelContent(() => {
if (expressionEditor) {
newFunction.value.expression = expressionEditor.getValue()
}
})
}

// Clean up editor
const finishEditor = (): void => {
if (expressionEditor) {
expressionEditor.dispose()
expressionEditor = null
}
if (completionProvider) {
completionProvider.dispose()
completionProvider = null
}
}

// Update editor when dialog opens/closes
watch(
() => props.modelValue,
async (newValue) => {
if (newValue) {
await nextTick()
await initEditor()
} else {
finishEditor()
}
}
)

// Update editor when editing an existing function
watch(
() => newFunction.value.expression,
(newValue) => {
if (expressionEditor && expressionEditor.getValue() !== newValue) {
expressionEditor.setValue(newValue)
}
}
)

onBeforeUnmount(() => {
finishEditor()
})

// Load available variables when mounted
onMounted((): void => {
variablesMap.value = getAllDataLakeVariablesInfo()
})
</script>

<style scoped>
.v-data-table ::v-deep tbody tr:hover {
background-color: rgba(0, 0, 0, 0.1) !important;
}
</style>
Loading
Loading