mirror of
https://github.com/docmost/docmost.git
synced 2026-07-26 14:04:42 +10:00
feat(ee): bases
Table and kanban UI, formula engine package, and the base-embed editor extension
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user