mirror of
https://github.com/docmost/docmost.git
synced 2026-07-13 06:15:04 +10:00
feat(base): add formula editor popover with live parse and palette
This commit is contained in:
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user