mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08: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 && (
|
{baseId && (
|
||||||
<CreatePropertyPopover
|
<CreatePropertyPopover
|
||||||
baseId={baseId}
|
baseId={baseId}
|
||||||
|
properties={properties}
|
||||||
onPropertyCreated={onPropertyCreated}
|
onPropertyCreated={onPropertyCreated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
import { useCreatePropertyMutation } from "@/features/base/queries/base-property-query";
|
||||||
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
import { PropertyTypePicker, propertyTypes } from "./property-type-picker";
|
||||||
import { PropertyOptions } from "./property-options";
|
import { PropertyOptions } from "./property-options";
|
||||||
|
import { FormulaEditor } from "../formula/formula-editor";
|
||||||
import classes from "@/features/base/styles/grid.module.css";
|
import classes from "@/features/base/styles/grid.module.css";
|
||||||
|
|
||||||
type CreatePropertyPopoverProps = {
|
type CreatePropertyPopoverProps = {
|
||||||
baseId: string;
|
baseId: string;
|
||||||
|
properties?: IBaseProperty[];
|
||||||
onPropertyCreated?: () => void;
|
onPropertyCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ const typesWithOptions = new Set<BasePropertyType>([
|
|||||||
"person",
|
"person",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePropertyPopoverProps) {
|
export function CreatePropertyPopover({ baseId, properties, onPropertyCreated }: CreatePropertyPopoverProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [panel, setPanel] = useState<Panel>("typePicker");
|
const [panel, setPanel] = useState<Panel>("typePicker");
|
||||||
@@ -231,7 +233,34 @@ export function CreatePropertyPopover({ baseId, onPropertyCreated }: CreatePrope
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</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}>
|
<Stack gap={0} p="sm" style={panel === "confirmDiscard" ? { display: "none" } : undefined}>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={nameInputRef}
|
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.
|
// 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 "./ast";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./error";
|
export * from "./error";
|
||||||
@@ -8,5 +10,6 @@ export * from "./parser";
|
|||||||
export * from "./resolver";
|
export * from "./resolver";
|
||||||
export * from "./typecheck";
|
export * from "./typecheck";
|
||||||
export * from "./format";
|
export * from "./format";
|
||||||
|
export { registry, register } from "./functions/registry";
|
||||||
export type { FormulaFn } from "./functions/registry";
|
export type { FormulaFn } from "./functions/registry";
|
||||||
export * from "./graph";
|
export * from "./graph";
|
||||||
|
|||||||
Reference in New Issue
Block a user