Skip to content

Commit

Permalink
Merge pull request frappe#199 from frappe/expression-builder
Browse files Browse the repository at this point in the history
feat: expression builder
  • Loading branch information
nextchamp-saqib authored Jan 13, 2024
2 parents ab0a2be + 6121c7c commit c3fc0b2
Show file tree
Hide file tree
Showing 18 changed files with 642 additions and 476 deletions.
17 changes: 15 additions & 2 deletions frontend/src/components/Controls/Code.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,19 @@
@update="onUpdate"
@focus="emit('focus')"
@blur="emit('blur')"
@ready="codeMirror = $event"
/>
</template>

<script setup>
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
import { javascript } from '@codemirror/lang-javascript'
import { MySQL, sql } from '@codemirror/lang-sql'
import { python } from '@codemirror/lang-python'
import { MySQL, sql } from '@codemirror/lang-sql'
import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { Codemirror } from 'vue-codemirror'
const props = defineProps({
Expand Down Expand Up @@ -65,6 +66,7 @@ const onUpdate = (viewUpdate) => {
})
}
const codeMirror = ref(null)
const code = computed({
get: () => (props.modelValue ? props.modelValue || '' : props.value || ''),
set: (value) => emit('update:modelValue', value),
Expand Down Expand Up @@ -174,4 +176,15 @@ const getHighlighterStyle = () =>
])
extensions.push(syntaxHighlighting(getHighlighterStyle()))
defineExpose({
get cursorPos() {
return codeMirror.value.view.state.selection.ranges[0].to
},
focus: () => codeMirror.value.view.focus(),
setCursorPos: (pos) => {
const _pos = Math.min(pos, code.value.length)
codeMirror.value.view.dispatch({ selection: { anchor: _pos, head: _pos } })
},
})
</script>
2 changes: 1 addition & 1 deletion frontend/src/components/DraggableList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function onChange(e) {
<div class="mb-2 flex items-center gap-1">
<GripVertical class="handle h-4 w-4 flex-shrink-0 cursor-grab text-gray-500" />
<div class="flex-1">
<slot :item="item" :index="idx">
<slot name="item" :item="item" :index="idx">
<div
class="group form-input flex h-7 flex-1 cursor-pointer items-center justify-between px-2"
>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/dashboard/DashboardItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ function openQueryInNewTab() {
:options="item.options"
/>
<div class="absolute right-3 top-3 z-10 flex items-center">
<div class="absolute right-3 top-3 z-[10001] flex items-center">
<div v-if="chartFilters?.length">
<Tooltip :text="chartFilters.map((c) => c.label || c.column?.label).join(', ')">
<div
Expand Down
11 changes: 5 additions & 6 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,20 @@
padding: 0px !important;
position: relative !important;
}
.cm-gutters {
/* .cm-gutters {
display: none !important;
}
} */
.cm-gutters {
@apply !border-none !bg-transparent pl-1 !text-sm !leading-6;
@apply !border-r !bg-transparent !text-sm !leading-6 !text-gray-600;
}
/* hide fold bar */
.cm-foldGutter span {
@apply !opacity-0;
}
.cm-activeLine {
@apply !bg-transparent;
}
.cm-activeLineGutter {
@apply !bg-transparent !font-medium text-gray-900;
@apply !bg-transparent text-gray-600;
}
.cm-editor {
height: 100%;
Expand All @@ -52,7 +51,7 @@
}

.cm-placeholder {
@apply !leading-6 !text-gray-600;
@apply !leading-6 !text-gray-500;
}
.cm-content {
padding: 6px 0px !important;
Expand Down
201 changes: 72 additions & 129 deletions frontend/src/query/visual/ColumnExpressionEditor.vue
Original file line number Diff line number Diff line change
@@ -1,151 +1,94 @@
<script setup>
import Code from '@/components/Controls/Code.vue'
import ExpressionHelp from '@/components/ExpressionHelp.vue'
import UsePopover from '@/components/UsePopover.vue'
import { COLUMN_TYPES, FIELDTYPES, GRANULARITIES } from '@/utils'
import { parse } from '@/utils/expressions'
import { FUNCTIONS } from '@/utils/query'
import { debounce } from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { computed, defineProps, inject, reactive } from 'vue'
import ExpressionBuilder from './ExpressionBuilder.vue'
import { NEW_COLUMN } from './constants'
import { getSelectedTables } from './useAssistedQuery'
const query = inject('query')
const emptyExpressionColumn = {
...NEW_COLUMN,
expression: {
raw: '',
ast: {},
},
}
const assistedQuery = inject('assistedQuery')
const emit = defineEmits(['update:column'])
const emit = defineEmits(['save', 'discard', 'remove'])
const props = defineProps({ column: Object })
const column = computed({
get: () => props.column,
set: (val) => emit('update:column', val),
})
const focused = ref(false)
const columnCompletions = computed(() => {
// a list of options for code autocompletion
const selectedTables = getSelectedTables(assistedQuery)
return assistedQuery.columnOptions
.filter((c) => selectedTables.includes(c.table))
.map((c) => ({ label: `${c.table}.${c.column}` }))
const propsColumn = props.column || emptyExpressionColumn
const column = reactive({
...NEW_COLUMN,
...propsColumn,
})
const getCompletions = (context, syntaxTree) => {
let word = context.matchBefore(/\w*/)
let nodeBefore = syntaxTree.resolveInner(context.pos, -1)
if (nodeBefore.name === 'TemplateString') {
return {
from: word.from,
options: columnCompletions.value,
}
}
if (nodeBefore.name === 'VariableName') {
return {
from: word.from,
options: Object.keys(FUNCTIONS).map((label) => ({ label })),
}
}
if (!column.expression) {
column.expression = { ...emptyExpressionColumn.expression }
}
const codeEditor = ref(null)
const helpInfo = ref(null)
const codeViewUpdate = debounce(function ({ cursorPos }) {
if (!column.value.expression?.raw) return
setCompletionPosition()
helpInfo.value = null
const tokens = parse(column.value.expression.raw).tokens
const token = tokens
.filter((t) => t.start <= cursorPos - 1 && t.end >= cursorPos && t.type == 'FUNCTION')
.at(-1)
if (token) {
const { value } = token
if (FUNCTIONS[value]) {
helpInfo.value = FUNCTIONS[value]
}
}
}, 300)
const helpInfoRefreshKey = ref(0)
const observer = new ResizeObserver(() => {
helpInfoRefreshKey.value += 1
})
onMounted(() => {
codeEditor.value && observer.observe(codeEditor.value)
const isValid = computed(() => {
if (!column.label || !column.type) return false
if (!column.expression.raw) return false
return true
})
function setCompletionPosition() {
const completion = document.querySelector('.cm-tooltip-autocomplete')
if (!completion) return
const cursor = document.querySelector('.cm-cursor.cm-cursor-primary')
const left = Number(cursor.style.left.replace('px', ''))
const top = Number(cursor.style.top.replace('px', ''))
const columnOptions = computed(() => {
const selectedTables = getSelectedTables(assistedQuery)
return assistedQuery.columnOptions.filter((c) => selectedTables.includes(c.table)) || []
})
completion.setAttribute('style', `left: ${left}px !important; top: ${top + 20}px !important;`)
function onSave() {
if (!isValid.value) return
emit('save', {
...column,
label: column.label.trim(),
type: column.type.trim(),
expression: {
raw: column.expression.raw,
ast: parse(column.expression.raw).ast,
},
})
}
const COLUMN_TYPES = [
{ label: 'String', value: 'String' },
{ label: 'Integer', value: 'Integer' },
{ label: 'Decimal', value: 'Decimal' },
{ label: 'Text', value: 'Text' },
{ label: 'Datetime', value: 'Datetime' },
{ label: 'Date', value: 'Date' },
{ label: 'Time', value: 'Time' },
]
</script>

<template>
<div class="exp-editor space-y-3 text-base">
<div>
<span class="mb-2 block text-sm leading-4 text-gray-700">Expression</span>
<div ref="codeEditor" class="form-input min-h-[7rem] border border-gray-400 p-0">
<Code
:modelValue="column.expression.raw"
:completions="getCompletions"
:autofocus="false"
placeholder="Write an expression"
@focus="focused = true"
@blur="focused = false"
@viewUpdate="codeViewUpdate"
@update:modelValue="
column.expression = {
raw: $event,
ast: parse($event).ast,
}
"
></Code>
<div class="space-y-3 text-base">
<ExpressionBuilder v-model="column.expression" :columnOptions="columnOptions" />

<div class="grid grid-cols-2 gap-4">
<FormControl
type="text"
label="Label"
class="col-span-1"
v-model="column.label"
placeholder="Label"
autocomplete="off"
/>
<FormControl
label="Type"
type="select"
class="col-span-1"
v-model="column.type"
:options="COLUMN_TYPES"
/>
<div v-if="FIELDTYPES.DATE.includes(column.type)" class="col-span-1 space-y-1">
<span class="mb-2 block text-sm leading-4 text-gray-700">Date Format</span>
<Autocomplete
:modelValue="column.granularity"
placeholder="Date Format"
:options="GRANULARITIES"
@update:modelValue="(op) => (column.granularity = op.value)"
/>
</div>
</div>

<FormControl
type="text"
label="Label"
class="w-full"
v-model="column.label"
placeholder="Label"
/>
<FormControl
label="Type"
type="select"
class="w-full"
v-model="column.type"
:options="COLUMN_TYPES"
/>
</div>
<UsePopover
v-if="codeEditor"
:show="focused && Boolean(helpInfo)"
:targetElement="codeEditor"
placement="right-start"
:key="helpInfoRefreshKey"
>
<div class="w-[10rem] text-sm transition-all">
<ExpressionHelp v-show="helpInfo?.syntax" :info="helpInfo" />
<div class="flex flex-col justify-between gap-2 lg:flex-row">
<Button variant="outline" @click="emit('discard')"> Discard </Button>
<div class="flex flex-col gap-2 lg:flex-row">
<Button variant="outline" theme="red" @click="emit('remove')">Remove</Button>
<Button variant="solid" :disabled="!isValid" @click="onSave"> Save </Button>
</div>
</div>
</UsePopover>
</div>
</template>
<style>
.exp-editor .cm-tooltip-autocomplete {
position: absolute !important;
}
</style>
Loading

0 comments on commit c3fc0b2

Please sign in to comment.