feat(base): add formula editor popover with live parse and palette

This commit is contained in:
Philipinho
2026-04-24 00:35:26 +01:00
parent 48d77a2b53
commit 28fed815ba
8 changed files with 323 additions and 3 deletions
@@ -0,0 +1,84 @@
import { useState } from "react";
import { Button, Divider, Group, Paper, Stack, Text } from "@mantine/core";
import { registry } from "@docmost/base-formula/client";
import { FormulaInput } from "./formula-input";
import { PropertyChipRow } from "./property-chip-row";
import { FunctionPalette } from "./function-palette";
import { useFormulaParser } from "@/features/base/hooks/use-formula-parser";
import type { IBaseProperty } from "@/features/base/types/base.types";
type Props = {
properties: IBaseProperty[];
editingPropertyId: string | null;
initialSource?: string;
onSave: (
source: string,
ast: unknown,
resultType: string,
dependencies: string[],
) => void;
onCancel: () => void;
};
export function FormulaEditor({
properties,
editingPropertyId,
initialSource = "",
onSave,
onCancel,
}: Props) {
const [source, setSource] = useState(initialSource);
const parseState = useFormulaParser(
source,
properties,
editingPropertyId,
registry,
);
const canSave = parseState.state === "ok";
const insertAtEnd = (snippet: string) =>
setSource((s) => `${s}${s ? " " : ""}${snippet}`);
return (
<Paper p="md" withBorder>
<Stack gap="sm">
<Text fw={500}>Formula</Text>
<FormulaInput
value={source}
onChange={setSource}
error={parseState.state === "error" ? parseState : undefined}
resultType={parseState.state === "ok" ? parseState.resultType : undefined}
/>
<Divider />
<Text size="sm" c="dimmed">Properties</Text>
<PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtEnd(`prop("${name}")`)}
/>
<Divider />
<Text size="sm" c="dimmed">Functions</Text>
<FunctionPalette
registry={registry}
onInsert={(name) => insertAtEnd(`${name}()`)}
/>
<Group justify="flex-end">
<Button variant="subtle" onClick={onCancel}>Cancel</Button>
<Button
disabled={!canSave}
onClick={() => {
if (parseState.state !== "ok") return;
onSave(
source,
parseState.ast,
parseState.resultType,
parseState.dependencies,
);
}}
>
Save
</Button>
</Group>
</Stack>
</Paper>
);
}
@@ -0,0 +1,35 @@
import { Textarea, Text } from "@mantine/core";
type Props = {
value: string;
onChange: (v: string) => void;
error?: { message: string; span?: { start: number; end: number } };
resultType?: string;
};
export function FormulaInput({ value, onChange, error, resultType }: Props) {
return (
<div>
<Textarea
autosize
minRows={3}
maxRows={8}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
placeholder='prop("Price") * prop("Qty")'
/>
{error && (
<Text size="xs" c="red.7" mt="xs">
{error.message}
{error.span ? ` (col ${error.span.start + 1})` : null}
</Text>
)}
{!error && resultType && (
<Text size="xs" c="dimmed" mt="xs">
Returns: {resultType}
</Text>
)}
</div>
);
}
@@ -0,0 +1,42 @@
import { Accordion, Badge, Group, Tooltip } from "@mantine/core";
import type { FormulaFn } from "@docmost/base-formula/client";
const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const;
export function FunctionPalette({
registry,
onInsert,
}: {
registry: ReadonlyMap<string, FormulaFn>;
onInsert: (name: string) => void;
}) {
const byCat = new Map<string, FormulaFn[]>();
for (const fn of registry.values()) {
if (!byCat.has(fn.category)) byCat.set(fn.category, []);
byCat.get(fn.category)!.push(fn);
}
return (
<Accordion multiple>
{CATEGORIES.map((cat) => (
<Accordion.Item key={cat} value={cat}>
<Accordion.Control>{cat}</Accordion.Control>
<Accordion.Panel>
<Group gap={4}>
{(byCat.get(cat) ?? []).map((fn) => (
<Tooltip key={fn.name} label={fn.doc}>
<Badge
variant="outline"
style={{ cursor: "pointer" }}
onClick={() => onInsert(fn.name)}
>
{fn.name}
</Badge>
</Tooltip>
))}
</Group>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
);
}
@@ -0,0 +1,25 @@
import { Badge, Group } from "@mantine/core";
import type { IBaseProperty } from "@/features/base/types/base.types";
export function PropertyChipRow({
properties,
onInsert,
}: {
properties: IBaseProperty[];
onInsert: (name: string) => void;
}) {
return (
<Group gap={4}>
{properties.map((p) => (
<Badge
key={p.id}
variant="light"
style={{ cursor: "pointer" }}
onClick={() => onInsert(p.name)}
>
{p.name}
</Badge>
))}
</Group>
);
}