feat(ee): bases

Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
Philipinho
2026-06-14 01:29:06 +01:00
parent d86d51c27e
commit 4e5bff6d55
233 changed files with 22278 additions and 141 deletions
@@ -0,0 +1,189 @@
import { useEffect, useRef, useState } from "react";
import {
Button,
Divider,
Group,
Paper,
Stack,
Text,
} from "@mantine/core";
import {
IconAlertTriangle,
IconMathFunction,
IconPointFilled,
} from "@tabler/icons-react";
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 "@/ee/base/hooks/use-formula-parser";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
type Props = {
properties: IBaseProperty[];
editingPropertyId: string | null;
initialSource?: string;
name?: string;
disabled?: boolean;
onSave: (
source: string,
ast: unknown,
resultType: string,
dependencies: string[],
) => void;
onCancel: () => void;
};
export function FormulaEditor({
properties,
editingPropertyId,
initialSource = "",
name,
disabled = false,
onSave,
onCancel,
}: Props) {
const [source, setSource] = useState(initialSource);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pendingCursorRef = useRef<number | null>(null);
const parseState = useFormulaParser(
source,
properties,
editingPropertyId,
registry,
);
const canSave = parseState.state === "ok" && !disabled;
// useEffect (not RAF) ensures the DOM update ran before restoring cursor.
useEffect(() => {
if (pendingCursorRef.current === null) return;
const pos = pendingCursorRef.current;
pendingCursorRef.current = null;
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(pos, pos);
}, [source]);
const insertAtCursor = (snippet: string, cursorOffsetFromEnd = 0) => {
const ta = textareaRef.current;
const start = ta?.selectionStart ?? source.length;
const end = ta?.selectionEnd ?? source.length;
const before = source.slice(0, start);
const after = source.slice(end);
const prev = before.slice(-1);
const needsSpace = prev !== "" && !/[\s(,]/.test(prev);
const prefix = needsSpace ? " " : "";
const next = before + prefix + snippet + after;
pendingCursorRef.current =
before.length + prefix.length + snippet.length - cursorOffsetFromEnd;
setSource(next);
};
return (
<Paper
withBorder
radius="md"
shadow="sm"
p={0}
style={{ overflow: "hidden" }}
>
<Stack gap={0}>
<Group
justify="space-between"
wrap="nowrap"
px="md"
py={12}
className={classes.formulaHeaderRow}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<div className={classes.formulaIconBadge}>
<IconMathFunction size={14} />
</div>
<Text size="sm" fw={600}>
Formula
</Text>
{name && (
<Text size="sm" c="dimmed" truncate>
· {name}
</Text>
)}
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<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>
<Stack gap={6} px={14} pt={10} pb={8}>
<FormulaInput
ref={textareaRef}
value={source}
onChange={setSource}
hasError={parseState.state === "error"}
/>
<Group justify="space-between" gap={8} mih={16}>
{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={8} px={14} pt={10} pb={10}>
<PropertyChipRow
properties={properties.filter((p) => p.id !== editingPropertyId)}
onInsert={(name) => insertAtCursor(`prop("${name}")`)}
/>
</Stack>
<Divider />
<Stack gap={6} px={14} pt={10} pb={10}>
<Text size="xs" fw={600} c="gray.7">
Functions
</Text>
<FunctionPalette
registry={registry}
onInsert={(name) => insertAtCursor(`${name}()`, 1)}
/>
</Stack>
</Stack>
</Paper>
);
}
@@ -0,0 +1,40 @@
import { forwardRef } from "react";
import { Textarea } from "@mantine/core";
type Props = {
value: string;
onChange: (v: string) => void;
hasError?: boolean;
};
export const FormulaInput = forwardRef<HTMLTextAreaElement, Props>(
function FormulaInput({ value, onChange, hasError }, ref) {
return (
<Textarea
ref={ref}
autosize
minRows={3}
maxRows={8}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder='prop("Price") * prop("Qty")'
styles={{
input: {
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, 'JetBrains Mono', monospace",
fontSize: 13,
lineHeight: 1.65,
backgroundColor: "var(--mantine-color-gray-0)",
borderColor: hasError
? "var(--mantine-color-red-6)"
: "var(--mantine-color-blue-6)",
borderWidth: 1.5,
boxShadow: hasError
? "0 0 0 3px var(--mantine-color-red-1)"
: "0 0 0 3px var(--mantine-color-blue-1)",
},
}}
/>
);
},
);
@@ -0,0 +1,48 @@
import { FormulaEditor } from "./formula-editor";
import { useBaseQuery } from "@/ee/base/queries/base-query";
import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query";
import {
IBaseProperty,
FormulaTypeOptions,
TypeOptions,
} from "@/ee/base/types/base.types";
type Props = {
property: IBaseProperty;
pageId: string;
onClose: () => void;
};
export function FormulaPropertyEditor({ property, pageId, onClose }: Props) {
const { data: base } = useBaseQuery(pageId);
const updatePropertyMutation = useUpdatePropertyMutation();
const opts = property.typeOptions as FormulaTypeOptions | undefined;
return (
<FormulaEditor
properties={base?.properties ?? []}
editingPropertyId={property.id}
initialSource={opts?.source ?? ""}
name={property.name}
onCancel={onClose}
onSave={(source, ast, resultType, dependencies) => {
if (source === (opts?.source ?? "")) {
onClose();
return;
}
updatePropertyMutation.mutate({
propertyId: property.id,
pageId,
typeOptions: {
source,
ast,
resultType,
dependencies,
astVersion: 1,
} as TypeOptions,
});
onClose();
}}
/>
);
}
@@ -0,0 +1,83 @@
import { useState } from "react";
import {
Accordion,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import type { FormulaFn } from "@docmost/base-formula/client";
import classes from "@/ee/base/styles/formula.module.css";
const CATEGORIES = ["logic", "math", "string", "date", "coercion"] as const;
export function FunctionPalette({
registry,
onInsert,
}: {
registry: ReadonlyMap<string, FormulaFn>;
onInsert: (name: string) => void;
}) {
const [open, setOpen] = useState<string | null>("logic");
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
value={open}
onChange={setOpen}
variant="contained"
radius="md"
chevronSize={14}
styles={{
item: { borderColor: "var(--mantine-color-gray-2)" },
control: { padding: "7px 12px", minHeight: 0 },
label: {
padding: 0,
fontSize: 13,
fontWeight: 600,
textTransform: "capitalize",
},
content: { padding: "6px 10px 10px" },
panel: { background: "var(--mantine-color-gray-0)" },
}}
>
{CATEGORIES.map((cat) => {
const fns = byCat.get(cat) ?? [];
return (
<Accordion.Item key={cat} value={cat}>
<Accordion.Control>
<Group gap={8}>
<span>{cat}</span>
<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)}
className={classes.fnChip}
>
{fn.name}
<span className={classes.fnChipParens}>
()
</span>
</UnstyledButton>
</Tooltip>
))}
</Group>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
);
}
@@ -0,0 +1,56 @@
import { useState, useMemo } from "react";
import { Group, TextInput, UnstyledButton, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import type { IBaseProperty } from "@/ee/base/types/base.types";
import classes from "@/ee/base/styles/formula.module.css";
export function PropertyChipRow({
properties,
onInsert,
}: {
properties: IBaseProperty[];
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 (
<div>
<Group justify="space-between" mb={8}>
<Text size="xs" fw={600} c="gray.7">
Properties
</Text>
<TextInput
size="xs"
placeholder="Search"
leftSection={<IconSearch size={12} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
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)}
className={classes.propChip}
>
{p.name}
</UnstyledButton>
))}
</Group>
)}
</div>
);
}