Skip to content

Commit

Permalink
feat: add formula syntax & examples
Browse files Browse the repository at this point in the history
  • Loading branch information
nichenqin committed Oct 31, 2024
1 parent 209db31 commit 4b60be2
Show file tree
Hide file tree
Showing 8 changed files with 503 additions and 170 deletions.
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"@codemirror/language": "^6.10.3",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@floating-ui/dom": "^1.6.12",
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.6",
"@svelte-put/clickoutside": "^3.0.2",
Expand Down
136 changes: 114 additions & 22 deletions apps/frontend/src/lib/components/formula/formula-editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import { derived } from "svelte/store"
import FieldIcon from "../blocks/field-icon/field-icon.svelte"
import { type Field } from "@undb/table"
import { computePosition, flip, shift, offset } from "@floating-ui/dom"
import { globalFormulaRegistry } from "@undb/formula/src/formula/formula.registry"
const functions = FORMULA_FUNCTIONS
Expand All @@ -31,8 +33,15 @@
export let value: string = ""
let editor: EditorView
let suggestions: string[] = [...functions, ...$fields]
let formulaSuggestions: string[] = [...functions]
let fieldSuggestions: string[] = [...$fields]
$: suggestions = [...formulaSuggestions, ...fieldSuggestions]
let selectedSuggestion: string = ""
let hoverSuggestion: string = ""
$: hoverFormula = hoverSuggestion ? globalFormulaRegistry.get(hoverSuggestion as FormulaFunction) : undefined
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#5c6bc0" },
Expand Down Expand Up @@ -281,8 +290,8 @@
if (!isInsideParens) {
const functionNode = visitor.getNearestFunctionNode()
if (functionNode) {
const functionStart = functionNode.start.startIndex
const functionNameLength = functionNode.IDENTIFIER().text.length
const functionStart = functionNode.start.tokenIndex
const functionNameLength = functionNode.IDENTIFIER().getText().length
const transaction = editor.state.update({
changes: {
from: functionStart,
Expand Down Expand Up @@ -383,9 +392,30 @@
errorMessage = (error as Error).message
}
}
let hoverSuggestionContainer: HTMLElement
let editorContainerWrapper: HTMLElement
function update() {
if (hoverSuggestionContainer && editorContainerWrapper && hoverFormula) {
computePosition(editorContainerWrapper, hoverSuggestionContainer, {
placement: "left-start",
middleware: [flip(), shift({ padding: 5 }), offset(10)],
}).then(({ x, y }) => {
Object.assign(hoverSuggestionContainer.style, {
left: `${x}px`,
top: `${y}px`,
})
})
}
}
onMount(() => {
update()
})
</script>

<div>
<div bind:this={editorContainerWrapper} id="editor-container-wrapper" class="relative">
<div id="editor-container" class="mb-2 rounded-sm border"></div>
{#if errorMessage}
<p class="text-destructive flex items-center gap-1 text-xs">
Expand All @@ -395,32 +425,94 @@
{/if}

<ul class="mt-2 flex h-[250px] flex-col divide-y overflow-auto rounded-lg border border-gray-200">
{#each suggestions as suggestion}
<div class="sticky top-0 z-10 border-b bg-gray-100 px-2 py-1.5 text-xs font-semibold">Formula</div>
{#each formulaSuggestions as suggestion}
{@const isSelected = suggestion === selectedSuggestion}
{@const isFunction = functions.includes(suggestion)}
{@const isField = !isFunction}
<button type="button" on:click={() => insertSuggestion(suggestion)} class="w-full text-left text-xs font-medium">
{@const isHovered = suggestion === hoverSuggestion}
<button
type="button"
on:click={() => insertSuggestion(suggestion)}
on:mouseenter={() => {
hoverSuggestion = suggestion
update()
}}
class="group relative w-full text-left text-xs font-medium"
>
<li
class={cn("flex w-full items-center gap-1 p-2 hover:bg-gray-100", (isSelected || isHovered) && "bg-gray-100")}
>
<span class="font-normal">
<SquareFunctionIcon class="size-4" />
</span>
<span>
{suggestion}()
</span>
</li>

<div class="absolute left-0 top-0 z-50 -translate-x-[100%] group-hover:block">hello</div>
</button>
{/each}
<div class="sticky top-0 z-10 border-b bg-gray-100 px-2 py-1.5 text-xs font-semibold">Field</div>
{#each fieldSuggestions as suggestion}
{@const isSelected = suggestion === selectedSuggestion}
{@const field = $table.schema.getFieldByIdOrName(suggestion).into(null)}
<button
type="button"
on:mouseenter={() => {
hoverSuggestion = ""
}}
on:click={() => insertSuggestion(suggestion)}
class="w-full text-left text-xs font-medium"
>
<li class={cn("flex w-full items-center gap-1 p-2 hover:bg-gray-100", isSelected && "bg-gray-100")}>
{#if isFunction}
<span class="font-normal">
<SquareFunctionIcon class="size-4" />
{#if field}
<span class="flex items-center gap-1">
<FieldIcon class="size-4" type={field.type} {field} />
{field.name.value}
</span>
<span>
{suggestion}()
</span>
{:else}
{@const field = $table.schema.getFieldByIdOrName(suggestion).into(null)}
{#if field}
<span class="flex items-center gap-1">
<FieldIcon class="size-4" type={field.type} {field} />
{field.name.value}
</span>
{/if}
{/if}
</li>
</button>
{/each}
</ul>

<div bind:this={hoverSuggestionContainer} class="fixed left-0 top-0 w-80 rounded-md border bg-white shadow-md">
{#if hoverFormula}
<div>
<div class="flex items-center gap-2 border-b bg-gray-100 px-2 py-1 text-sm">
<SquareFunctionIcon class="size-4" />
{hoverSuggestion}()
</div>
</div>

<div class="space-y-2 p-2">
<p class="overflow-hidden whitespace-normal break-words text-xs text-gray-500">{hoverFormula.description}</p>
<div class="space-y-2">
<p class="text-xs font-semibold text-gray-500">Syntax</p>
{#each hoverFormula.syntax as syntax}
<div class="whitespace-normal break-words rounded-sm border px-2 py-1 text-xs leading-6 text-gray-800">
{syntax}
</div>
{/each}
</div>
{#if hoverFormula.examples && hoverFormula.examples.length > 0}
<p class="text-xs font-semibold text-gray-500">Examples</p>
<div class="space-y-2">
{#each hoverFormula.examples as example}
<div class="whitespace-normal break-words rounded-sm border px-2 py-1 text-xs leading-6 text-gray-800">
{example[0]}
{#if example[1]}
<span class="text-gray-500">
=> {example[1]}
</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>

<style lang="postcss">
Expand Down
Binary file modified bun.lockb
Binary file not shown.
8 changes: 4 additions & 4 deletions packages/formula/src/formula.visitor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { globalFormulaRegistry } from "./formula/formula.registry"
import { FormulaFunction } from "./formula/formula.type"
import { globalFunctionRegistry } from "./formula/registry"
import {
AddSubExprContext,
AndExprContext,
Expand Down Expand Up @@ -172,15 +172,15 @@ export class FormulaVisitor extends FormulaParserVisitor<ExpressionResult> {
const funcName = ctx.IDENTIFIER().getText() as FormulaFunction
const args = ctx.argumentList() ? (this.visit(ctx.argumentList()!) as FunctionExpressionResult) : undefined

if (!globalFunctionRegistry.isValid(funcName)) {
if (!globalFormulaRegistry.isValid(funcName)) {
throw new Error(`Unknown function: ${funcName}`)
}

if (args) {
globalFunctionRegistry.validateArgs(funcName, args.arguments)
globalFormulaRegistry.validateArgs(funcName, args.arguments)
}

const returnType = globalFunctionRegistry.get(funcName)!.returnType
const returnType = globalFormulaRegistry.get(funcName)!.returnType

return {
type: "functionCall",
Expand Down
25 changes: 25 additions & 0 deletions packages/formula/src/formula/formula.registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "bun:test"
import { FormulaRegistry } from "./formula.registry"

describe("FormulaRegistry", () => {
it("should register ADD functions", () => {
const registry = new FormulaRegistry()
registry.register("ADD", [["number", "number"]], "number")
expect(registry.isValid("ADD")).toBe(true)
expect(registry.get("ADD")?.syntax).toEqual(["ADD(number1, number2)"])
})

it("should register SUM functions", () => {
const registry = new FormulaRegistry()
registry.register("SUM", [["number", "variadic"]], "number")
expect(registry.isValid("SUM")).toBe(true)
expect(registry.get("SUM")?.syntax).toEqual(["SUM(number1, [number2, ...])"])
})

it("should register ABS functions", () => {
const registry = new FormulaRegistry()
registry.register("ABS", [["number"]], "number")
expect(registry.isValid("ABS")).toBe(true)
expect(registry.get("ABS")?.syntax).toEqual(["ABS(number)"])
})
})
Loading

0 comments on commit 4b60be2

Please sign in to comment.