mirror of
https://github.com/docmost/docmost.git
synced 2026-08-30 18:36:26 +08:00
feat(base): redesign formula editor popover with polished header, pills, and accordion
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user