feat(base): redesign formula editor popover with polished header, pills, and accordion

This commit is contained in:
Philipinho
2026-04-24 02:15:08 +01:00
parent 9808791db4
commit 7a254a9412
5 changed files with 292 additions and 99 deletions
@@ -1,5 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Divider, Group, Paper, Stack, Text } from "@mantine/core"; import {
Button,
Divider,
Group,
Kbd,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
IconAlertTriangle,
IconMathFunction,
IconPointFilled,
} from "@tabler/icons-react";
import { registry } from "@docmost/base-formula/client"; import { registry } from "@docmost/base-formula/client";
import { FormulaInput } from "./formula-input"; import { FormulaInput } from "./formula-input";
import { PropertyChipRow } from "./property-chip-row"; import { PropertyChipRow } from "./property-chip-row";
@@ -11,6 +24,7 @@ type Props = {
properties: IBaseProperty[]; properties: IBaseProperty[];
editingPropertyId: string | null; editingPropertyId: string | null;
initialSource?: string; initialSource?: string;
name?: string;
onSave: ( onSave: (
source: string, source: string,
ast: unknown, ast: unknown,
@@ -24,6 +38,7 @@ export function FormulaEditor({
properties, properties,
editingPropertyId, editingPropertyId,
initialSource = "", initialSource = "",
name,
onSave, onSave,
onCancel, onCancel,
}: Props) { }: Props) {
@@ -35,48 +50,136 @@ export function FormulaEditor({
registry, registry,
); );
const canSave = parseState.state === "ok"; const canSave = parseState.state === "ok";
const insertAtEnd = (snippet: string) => const insertAtEnd = (snippet: string) =>
setSource((s) => `${s}${s ? " " : ""}${snippet}`); setSource((s) => `${s}${s ? " " : ""}${snippet}`);
return ( return (
<Paper p="md" withBorder> <Paper
<Stack gap="sm"> withBorder
<Text fw={500}>Formula</Text> radius="md"
<FormulaInput shadow="sm"
value={source} p={0}
onChange={setSource} style={{ overflow: "hidden" }}
error={parseState.state === "error" ? parseState : undefined} >
resultType={parseState.state === "ok" ? parseState.resultType : undefined} <Stack gap={0}>
/> <Group
<Divider /> gap={10}
<Text size="sm" c="dimmed">Properties</Text> px="md"
<PropertyChipRow py={12}
properties={properties.filter((p) => p.id !== editingPropertyId)} style={{
onInsert={(name) => insertAtEnd(`prop("${name}")`)} borderBottom: "1px solid var(--mantine-color-gray-2)",
/> }}
<Divider /> >
<Text size="sm" c="dimmed">Functions</Text> <div
<FunctionPalette style={{
registry={registry} width: 22,
onInsert={(name) => insertAtEnd(`${name}()`)} height: 22,
/> borderRadius: 5,
<Group justify="flex-end"> display: "grid",
<Button variant="subtle" onClick={onCancel}>Cancel</Button> placeItems: "center",
<Button background: "var(--mantine-color-blue-0)",
disabled={!canSave} color: "var(--mantine-color-blue-6)",
onClick={() => {
if (parseState.state !== "ok") return;
onSave(
source,
parseState.ast,
parseState.resultType,
parseState.dependencies,
);
}} }}
> >
Save <IconMathFunction size={14} />
</Button> </div>
<Text size="sm" fw={600}>
Formula
</Text>
{name && (
<Text size="sm" c="dimmed">
· {name}
</Text>
)}
</Group>
<Stack gap={8} px="md" py="sm">
<FormulaInput
value={source}
onChange={setSource}
hasError={parseState.state === "error"}
/>
<Group justify="space-between" gap={8} mih={18}>
{parseState.state === "error" ? (
<Group gap={6} c="red.7">
<IconAlertTriangle size={12} />
<Text size="xs">{parseState.message}</Text>
</Group>
) : parseState.state === "ok" ? (
<Group gap={6} c="dimmed">
<IconPointFilled size={10} color="var(--mantine-color-teal-6)" />
<Text size="xs">
Returns{" "}
<Text span fw={600} c="gray.8">
{parseState.resultType}
</Text>
</Text>
</Group>
) : (
<Text size="xs" c="dimmed">
Click a property or function below to insert.
</Text>
)}
</Group>
</Stack>
<Divider />
<Stack gap={10} px="md" py="sm">
<PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtEnd(`prop("${name}")`)}
/>
</Stack>
<Divider />
<Stack gap={8} px="md" py="sm">
<Text size="xs" fw={600} c="gray.7">
Functions
</Text>
<FunctionPalette
registry={registry}
onInsert={(name) => insertAtEnd(`${name}()`)}
/>
</Stack>
<Group
justify="space-between"
px="md"
py={10}
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Group gap={6}>
<Kbd></Kbd>
<Kbd></Kbd>
<Text size="xs" c="dimmed">
to save
</Text>
</Group>
<Group gap={8}>
<Button variant="subtle" size="xs" onClick={onCancel}>
Cancel
</Button>
<Button
size="xs"
disabled={!canSave}
onClick={() => {
if (parseState.state !== "ok") return;
onSave(
source,
parseState.ast,
parseState.resultType,
parseState.dependencies,
);
}}
>
Save
</Button>
</Group>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
@@ -1,35 +1,36 @@
import { Textarea, Text } from "@mantine/core"; import { Textarea } from "@mantine/core";
type Props = { type Props = {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
error?: { message: string; span?: { start: number; end: number } }; hasError?: boolean;
resultType?: string;
}; };
export function FormulaInput({ value, onChange, error, resultType }: Props) { export function FormulaInput({ value, onChange, hasError }: Props) {
return ( return (
<div> <Textarea
<Textarea autosize
autosize minRows={3}
minRows={3} maxRows={8}
maxRows={8} value={value}
value={value} onChange={(e) => onChange(e.currentTarget.value)}
onChange={(e) => onChange(e.currentTarget.value)} placeholder='prop("Price") * prop("Qty")'
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }} styles={{
placeholder='prop("Price") * prop("Qty")' input: {
/> fontFamily:
{error && ( "ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
<Text size="xs" c="red.7" mt="xs"> fontSize: 13,
{error.message} lineHeight: 1.65,
{error.span ? ` (col ${error.span.start + 1})` : null} backgroundColor: "var(--mantine-color-gray-0)",
</Text> borderColor: hasError
)} ? "var(--mantine-color-red-6)"
{!error && resultType && ( : "var(--mantine-color-blue-6)",
<Text size="xs" c="dimmed" mt="xs"> borderWidth: 1.5,
Returns: {resultType} boxShadow: hasError
</Text> ? "0 0 0 3px var(--mantine-color-red-1)"
)} : "0 0 0 3px var(--mantine-color-blue-1)",
</div> },
}}
/>
); );
} }
@@ -1,4 +1,11 @@
import { Accordion, Badge, Group, Tooltip } from "@mantine/core"; import { useState } from "react";
import {
Accordion,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import type { FormulaFn } from "@docmost/base-formula/client"; import type { FormulaFn } from "@docmost/base-formula/client";
const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const; const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const;
@@ -10,33 +17,72 @@ export function FunctionPalette({
registry: ReadonlyMap<string, FormulaFn>; registry: ReadonlyMap<string, FormulaFn>;
onInsert: (name: string) => void; onInsert: (name: string) => void;
}) { }) {
const [open, setOpen] = useState<string | null>("logic");
const byCat = new Map<string, FormulaFn[]>(); const byCat = new Map<string, FormulaFn[]>();
for (const fn of registry.values()) { for (const fn of registry.values()) {
if (!byCat.has(fn.category)) byCat.set(fn.category, []); if (!byCat.has(fn.category)) byCat.set(fn.category, []);
byCat.get(fn.category)!.push(fn); byCat.get(fn.category)!.push(fn);
} }
return ( return (
<Accordion multiple> <Accordion
{CATEGORIES.map((cat) => ( value={open}
<Accordion.Item key={cat} value={cat}> onChange={setOpen}
<Accordion.Control>{cat}</Accordion.Control> variant="contained"
<Accordion.Panel> radius="md"
<Group gap={4}> styles={{
{(byCat.get(cat) ?? []).map((fn) => ( item: { borderColor: "var(--mantine-color-gray-3)" },
<Tooltip key={fn.name} label={fn.doc}> control: { padding: "9px 12px" },
<Badge label: { fontSize: 13, fontWeight: 500, textTransform: "capitalize" },
variant="outline" content: { padding: "8px 10px 12px" },
style={{ cursor: "pointer" }} }}
onClick={() => onInsert(fn.name)} >
> {CATEGORIES.map((cat) => {
{fn.name} const fns = byCat.get(cat) ?? [];
</Badge> return (
</Tooltip> <Accordion.Item key={cat} value={cat}>
))} <Accordion.Control>
</Group> <Group gap={8}>
</Accordion.Panel> <span>{cat}</span>
</Accordion.Item> <Text size="xs" c="dimmed" ff="monospace">
))} {fns.length}
</Text>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Group gap={6}>
{fns.map((fn) => (
<Tooltip key={fn.name} label={fn.doc} withArrow>
<UnstyledButton
onClick={() => onInsert(fn.name)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 3,
padding: "3px 9px",
borderRadius: 5,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
fontSize: 12.5,
color: "var(--mantine-color-blue-7)",
background: "var(--mantine-color-white)",
border: "1px solid var(--mantine-color-gray-3)",
cursor: "pointer",
}}
>
{fn.name}
<span style={{ color: "var(--mantine-color-gray-5)" }}>
()
</span>
</UnstyledButton>
</Tooltip>
))}
</Group>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion> </Accordion>
); );
} }
@@ -1,4 +1,6 @@
import { Badge, Group } from "@mantine/core"; import { useState, useMemo } from "react";
import { Group, TextInput, UnstyledButton, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { IBaseProperty } from "@/features/base/types/base.types"; import type { IBaseProperty } from "@/features/base/types/base.types";
export function PropertyChipRow({ export function PropertyChipRow({
@@ -8,18 +10,58 @@ export function PropertyChipRow({
properties: IBaseProperty[]; properties: IBaseProperty[];
onInsert: (name: string) => void; onInsert: (name: string) => void;
}) { }) {
const [query, setQuery] = useState("");
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return properties;
return properties.filter((p) => p.name.toLowerCase().includes(q));
}, [properties, query]);
return ( return (
<Group gap={4}> <div>
{properties.map((p) => ( <Group justify="space-between" mb={8}>
<Badge <Text size="xs" fw={600} c="gray.7">
key={p.id} Properties
variant="light" </Text>
style={{ cursor: "pointer" }} <TextInput
onClick={() => onInsert(p.name)} size="xs"
> placeholder="Search"
{p.name} leftSection={<IconSearch size={12} />}
</Badge> value={query}
))} onChange={(e) => setQuery(e.currentTarget.value)}
</Group> w={140}
/>
</Group>
{visible.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
No matches.
</Text>
) : (
<Group gap={6}>
{visible.map((p) => (
<UnstyledButton
key={p.id}
onClick={() => onInsert(p.name)}
style={{
display: "inline-flex",
alignItems: "center",
padding: "3px 9px",
borderRadius: 6,
fontSize: 12.5,
fontWeight: 500,
lineHeight: 1.4,
background: "var(--mantine-color-blue-0)",
border: "1px solid var(--mantine-color-blue-2)",
color: "var(--mantine-color-blue-7)",
cursor: "pointer",
}}
>
{p.name}
</UnstyledButton>
))}
</Group>
)}
</div>
); );
} }
@@ -206,7 +206,7 @@ export function CreatePropertyPopover({ baseId, properties, onPropertyCreated }:
onClose={noop} onClose={noop}
position="bottom-start" position="bottom-start"
shadow="md" shadow="md"
width={320} width={selectedType === "formula" ? 460 : 320}
withinPortal withinPortal
> >
<Popover.Target> <Popover.Target>
@@ -246,6 +246,7 @@ export function CreatePropertyPopover({ baseId, properties, onPropertyCreated }:
<FormulaEditor <FormulaEditor
properties={properties ?? []} properties={properties ?? []}
editingPropertyId={null} editingPropertyId={null}
name={name.trim() || undefined}
onCancel={handleBackToTypePicker} onCancel={handleBackToTypePicker}
onSave={(source, ast, resultType, dependencies) => { onSave={(source, ast, resultType, dependencies) => {
createPropertyMutation.mutate( createPropertyMutation.mutate(