forked from frappe/insights
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request frappe#199 from frappe/expression-builder
feat: expression builder
- Loading branch information
Showing
18 changed files
with
642 additions
and
476 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
Oops, something went wrong.