diff --git a/apps/client/src/features/base/components/formula/formula-editor.tsx b/apps/client/src/features/base/components/formula/formula-editor.tsx
new file mode 100644
index 000000000..b786e35b6
--- /dev/null
+++ b/apps/client/src/features/base/components/formula/formula-editor.tsx
@@ -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 (
+
+
+ Formula
+
+
+ Properties
+ p.id !== editingPropertyId)}
+ onInsert={(name) => insertAtEnd(`prop("${name}")`)}
+ />
+
+ Functions
+ insertAtEnd(`${name}()`)}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/formula/formula-input.tsx b/apps/client/src/features/base/components/formula/formula-input.tsx
new file mode 100644
index 000000000..e165c8f17
--- /dev/null
+++ b/apps/client/src/features/base/components/formula/formula-input.tsx
@@ -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 (
+
+
+ );
+}
diff --git a/apps/client/src/features/base/components/formula/function-palette.tsx b/apps/client/src/features/base/components/formula/function-palette.tsx
new file mode 100644
index 000000000..690456add
--- /dev/null
+++ b/apps/client/src/features/base/components/formula/function-palette.tsx
@@ -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;
+ onInsert: (name: string) => void;
+}) {
+ const byCat = new Map();
+ for (const fn of registry.values()) {
+ if (!byCat.has(fn.category)) byCat.set(fn.category, []);
+ byCat.get(fn.category)!.push(fn);
+ }
+ return (
+
+ {CATEGORIES.map((cat) => (
+
+ {cat}
+
+
+ {(byCat.get(cat) ?? []).map((fn) => (
+
+ onInsert(fn.name)}
+ >
+ {fn.name}
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/features/base/components/formula/property-chip-row.tsx b/apps/client/src/features/base/components/formula/property-chip-row.tsx
new file mode 100644
index 000000000..ab90e7bc0
--- /dev/null
+++ b/apps/client/src/features/base/components/formula/property-chip-row.tsx
@@ -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 (
+
+ {properties.map((p) => (
+ onInsert(p.name)}
+ >
+ {p.name}
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/features/base/components/grid/grid-header.tsx b/apps/client/src/features/base/components/grid/grid-header.tsx
index bed424cf8..c934419db 100644
--- a/apps/client/src/features/base/components/grid/grid-header.tsx
+++ b/apps/client/src/features/base/components/grid/grid-header.tsx
@@ -48,6 +48,7 @@ export const GridHeader = memo(function GridHeader({
{baseId && (
)}
diff --git a/apps/client/src/features/base/components/property/create-property-popover.tsx b/apps/client/src/features/base/components/property/create-property-popover.tsx
index 4b6cd2727..dea9d3882 100644
--- a/apps/client/src/features/base/components/property/create-property-popover.tsx
+++ b/apps/client/src/features/base/components/property/create-property-popover.tsx
@@ -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([
"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("typePicker");
@@ -231,7 +233,34 @@ export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePrope
/>
)}
- {(panel === "configure" || panel === "confirmDiscard") && (
+ {panel === "configure" && selectedType === "formula" && (
+
+ {
+ createPropertyMutation.mutate(
+ {
+ baseId,
+ name: name.trim() || t("Formula"),
+ type: "formula",
+ typeOptions: {
+ source,
+ ast,
+ resultType,
+ dependencies,
+ astVersion: 1,
+ } as TypeOptions,
+ },
+ { onSuccess: () => onPropertyCreated?.() },
+ );
+ handleClose();
+ }}
+ />
+
+ )}
+ {(panel === "configure" || panel === "confirmDiscard") && selectedType !== "formula" && (
,
+): ParseState {
+ const [state, setState] = useState({ 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(
+ 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";
+}
diff --git a/packages/base-formula/src/index.client.ts b/packages/base-formula/src/index.client.ts
index e9fe47ada..e9e0ac2b1 100644
--- a/packages/base-formula/src/index.client.ts
+++ b/packages/base-formula/src/index.client.ts
@@ -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";