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>
);
}
@@ -48,6 +48,7 @@ export const GridHeader = memo(function GridHeader({
{baseId && (
<CreatePropertyPopover
baseId={baseId}
properties={properties}
onPropertyCreated={onPropertyCreated}
/>
)}
@@ -21,10 +21,12 @@ import {
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
import { PropertyOptions } from "./property-options";
import { FormulaEditor } from "../formula/formula-editor";
import classes from "@/features/base/styles/grid.module.css";
type CreatePropertyPopoverProps = {
baseId: string;
properties?: IBaseProperty[];
onPropertyCreated?: () => void;
};
@@ -42,7 +44,7 @@ const typesWithOptions = new Set<BasePropertyType>([
"person",
]);
export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePropertyPopoverProps) {
export function CreatePropertyPopover({ baseId, properties, onPropertyCreated }: CreatePropertyPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [panel, setPanel] = useState<Panel>("typePicker");
@@ -231,7 +233,34 @@ export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePrope
/>
</Stack>
)}
{(panel === "configure" || panel === "confirmDiscard") && (
{panel === "configure" && selectedType === "formula" && (
<Stack gap={0} p="sm">
<FormulaEditor
properties={properties ?? []}
editingPropertyId={null}
onCancel={handleBackToTypePicker}
onSave={(source, ast, resultType, dependencies) => {
createPropertyMutation.mutate(
{
baseId,
name: name.trim() || t("Formula"),
type: "formula",
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
},
{ onSuccess: () => onPropertyCreated?.() },
);
handleClose();
}}
/>
</Stack>
)}
{(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && (
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
<TextInput
ref={nameInputRef}
@@ -0,0 +1,101 @@
import { useEffect, useMemo, useState } from "react";
import {
parseRaw,
resolve,
typecheck,
BaseFormulaGraph,
type FormulaResultType,
type FormulaFn,
} from "@docmost/base-formula/client";
import type { IBaseProperty } from "@/features/base/types/base.types";
type ParseState =
| { state: "idle" }
| {
state: "ok";
resultType: FormulaResultType;
ast: unknown;
dependencies: string[];
}
| {
state: "error";
code: string;
message: string;
span?: { start: number; end: number };
};
export function useFormulaParser(
source: string,
properties: IBaseProperty[],
editingPropertyId: string | null,
registryForTypecheck: ReadonlyMap<string, FormulaFn>,
): ParseState {
const [state, setState] = useState<ParseState>({ state: "idle" });
const deps = useMemo(
() => ({ source, properties, editingPropertyId, registryForTypecheck }),
[source, properties, editingPropertyId, registryForTypecheck],
);
useEffect(() => {
const handle = setTimeout(() => {
if (!source.trim()) {
setState({ state: "idle" });
return;
}
try {
const nameToId = new Map(properties.map((p) => [p.name, p.id]));
const raw = parseRaw(source);
const resolved = resolve(raw, nameToId);
const typeMap = new Map<string, FormulaResultType>(
properties.map((p) => [p.id, clientResultTypeOf(p.type)]),
);
const tc = typecheck(resolved.ast, typeMap, registryForTypecheck);
const candidate = {
id: editingPropertyId ?? "pending",
type: "formula" as const,
typeOptions: { dependencies: resolved.dependencies },
};
const others = editingPropertyId
? properties.filter((p) => p.id !== editingPropertyId)
: properties;
const graph = new BaseFormulaGraph([...others, candidate as any]);
const cycle = graph.detectCycle(candidate as any);
if (cycle) {
setState({
state: "error",
code: "CYCLE",
message: `Cycle: ${cycle.join(" \u2192 ")}`,
});
return;
}
setState({
state: "ok",
resultType: tc.resultType,
ast: resolved.ast,
dependencies: resolved.dependencies,
});
} catch (e: any) {
const first = e?.errors?.[0];
setState({
state: "error",
code: first?.code ?? "PARSE_ERROR",
message: first?.message ?? e?.message ?? String(e),
span: first?.span,
});
}
}, 150);
return () => clearTimeout(handle);
}, [deps]);
return state;
}
function clientResultTypeOf(type: string): FormulaResultType {
if (type === "number") return "number";
if (type === "text" || type === "url" || type === "email") return "string";
if (type === "checkbox") return "boolean";
if (type === "date" || type === "createdAt" || type === "lastEditedAt")
return "date";
return "null";
}
+4 -1
View File
@@ -1,5 +1,7 @@
// Client-side public surface: parse, typecheck, cycle-detect, pretty-print.
// Does NOT export eval or the function registry.
// Does NOT export eval. Registry is exposed (metadata + function shape) so the
// client can drive typechecking and the function palette in the formula editor.
import "./functions/index";
export * from "./ast";
export * from "./types";
export * from "./error";
@@ -8,5 +10,6 @@ export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
export { registry, register } from "./functions/registry";
export type { FormulaFn } from "./functions/registry";
export * from "./graph";